Characters: build the page around Darragh Oakwanderer's sheet
Replaces the Characters placeholder with a real 5e character sheet feature, following the same read-only-detail/edit-split/game-notes pattern as NPCs and Enemies: - Character entity: identity, combat vitals, six ability scores, saving throw and skill proficiencies (computed modifiers, not stored), a small attacks table, and the sheet's freeform boxes (features, equipment, personality, appearance, backstory, allies, treasure). - Detail page shows the portrait uncropped, then the sheet - genuine <table>s for ability scores, skills and attacks, since that's the one part of a character sheet that actually is a table; everything else reuses the prose helpers from the enemy stat block. Game notes are the only editable field here, same as Enemy/NPC. - Edit page covers every field via CharacterType; the attack rows are six plain inputs handled by hand (not a Symfony collection field), since it's a short fixed-shape table, not a dynamic list. - Portraits follow the NPC convention: assets/image/characters/<NameNo Spaces>.png, computed path, not a DB column. - Imported the example sheet from assets/characters/ (Darragh Oakwanderer, halfling ranger) directly into the database. Tests: entity math (ability/save/skill modifiers, passive perception, portrait path), template rendering for show/edit/index (catches broken Twig/macro/path()/asset() calls), and the login-gate redirect. 21/21 passing; schema validate clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 513 KiB |
@@ -154,6 +154,54 @@ table.dataTable {
|
|||||||
.art-reorder-status.is-visible { opacity: 0.92; }
|
.art-reorder-status.is-visible { opacity: 0.92; }
|
||||||
.art-reorder-status.is-error { background: var(--bs-danger); color: #fff; }
|
.art-reorder-status.is-error { background: var(--bs-danger); color: #fff; }
|
||||||
|
|
||||||
|
/* --- Character sheets -------------------------------------------------- */
|
||||||
|
.min-w-0 { min-width: 0; }
|
||||||
|
|
||||||
|
.char-portrait {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.char-portrait img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.char-portrait--card { height: 150px; border-radius: 0.375rem 0.375rem 0 0; border-width: 0 0 1px; }
|
||||||
|
.char-portrait--hero { height: 220px; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.char-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.char-meta > div { display: flex; flex-direction: column; }
|
||||||
|
.char-meta dt {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
.char-meta dd { margin: 0.05rem 0 0; font-weight: 500; }
|
||||||
|
|
||||||
|
.char-ability-table td:first-child,
|
||||||
|
.char-ability-table th:first-child,
|
||||||
|
.char-skill-table td:first-child,
|
||||||
|
.char-skill-table th:first-child { white-space: nowrap; }
|
||||||
|
.char-ability-table tr.is-proficient,
|
||||||
|
.char-skill-table tr.is-proficient { font-weight: 600; }
|
||||||
|
.char-ability-table tr.is-proficient td:first-child,
|
||||||
|
.char-skill-table tr.is-proficient td:first-child {
|
||||||
|
box-shadow: inset 2px 0 0 var(--bs-primary);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.char-skill-table { font-size: 0.875rem; }
|
||||||
|
|
||||||
/* --- Tracker: unfoldable stat blocks --------------------------------- */
|
/* --- Tracker: unfoldable stat blocks --------------------------------- */
|
||||||
[data-bs-toggle="collapse"] .sb-caret {
|
[data-bs-toggle="collapse"] .sb-caret {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260922064937 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add character_sheet table for player character sheets';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE character_sheet (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(160) NOT NULL, player_name VARCHAR(120) DEFAULT NULL, race VARCHAR(120) DEFAULT NULL, character_class VARCHAR(80) DEFAULT NULL, level SMALLINT NOT NULL, background VARCHAR(120) DEFAULT NULL, alignment VARCHAR(40) DEFAULT NULL, experience_points INT DEFAULT NULL, armor_class SMALLINT DEFAULT NULL, initiative SMALLINT DEFAULT NULL, speed VARCHAR(60) DEFAULT NULL, hit_point_maximum SMALLINT DEFAULT NULL, current_hit_points SMALLINT DEFAULT NULL, temporary_hit_points SMALLINT DEFAULT NULL, hit_dice VARCHAR(40) DEFAULT NULL, proficiency_bonus SMALLINT NOT NULL, inspiration TINYINT NOT NULL, strength SMALLINT NOT NULL, dexterity SMALLINT NOT NULL, constitution SMALLINT NOT NULL, intelligence SMALLINT NOT NULL, wisdom SMALLINT NOT NULL, charisma SMALLINT NOT NULL, saving_throw_proficiencies JSON NOT NULL, skill_proficiencies JSON NOT NULL, attacks JSON NOT NULL, other_proficiencies_languages LONGTEXT DEFAULT NULL, equipment LONGTEXT DEFAULT NULL, features_traits LONGTEXT DEFAULT NULL, spellcasting LONGTEXT DEFAULT NULL, personality_traits LONGTEXT DEFAULT NULL, ideals LONGTEXT DEFAULT NULL, bonds LONGTEXT DEFAULT NULL, flaws LONGTEXT DEFAULT NULL, appearance LONGTEXT DEFAULT NULL, backstory LONGTEXT DEFAULT NULL, allies_organizations LONGTEXT DEFAULT NULL, treasure LONGTEXT DEFAULT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_character_name (name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE character_sheet');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use App\Form\CharacterGameNotesType;
|
||||||
|
use App\Form\CharacterType;
|
||||||
|
use App\Repository\CharacterRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -10,9 +16,111 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
class CharacterController extends AbstractController
|
class CharacterController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/characters', name: 'app_characters')]
|
#[Route('/characters', name: 'app_characters', methods: ['GET'])]
|
||||||
public function index(): Response
|
public function index(CharacterRepository $repo): Response
|
||||||
{
|
{
|
||||||
return $this->render('characters/index.html.twig');
|
return $this->render('characters/index.html.twig', [
|
||||||
|
'characters' => $repo->findAllOrdered(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-play detail page: the sheet read-only, plus the game-notes field.
|
||||||
|
*/
|
||||||
|
#[Route('/characters/{id}', name: 'app_characters_show', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||||
|
public function show(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(CharacterGameNotesType::class, $character);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Game notes saved.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters_show', ['id' => $character->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('characters/show.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/new', name: 'app_characters_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
return $this->handleForm($request, $em, new Character());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/{id}/edit', name: 'app_characters_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||||
|
public function edit(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
return $this->handleForm($request, $em, $character);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/characters/{id}/delete', name: 'app_characters_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Character $character, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('delete-character-'.$character->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$name = $character->getName();
|
||||||
|
$em->remove($character);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleForm(Request $request, EntityManagerInterface $em, Character $character): Response
|
||||||
|
{
|
||||||
|
$isNew = null === $character->getId();
|
||||||
|
$form = $this->createForm(CharacterType::class, $character);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$character->setAttacks($this->attacksFromRequest($request));
|
||||||
|
|
||||||
|
if ($isNew) {
|
||||||
|
$em->persist($character);
|
||||||
|
}
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('%s %s.', $isNew ? 'Added' : 'Saved', $character->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_characters_show', ['id' => $character->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('characters/edit.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form,
|
||||||
|
'is_new' => $isNew,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attack rows aren't a Symfony form field (they're a small repeatable
|
||||||
|
* table, not a single value) - read them straight off the request instead,
|
||||||
|
* dropping any row left completely blank.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, bonus: string, damage: string, range: string}>
|
||||||
|
*/
|
||||||
|
private function attacksFromRequest(Request $request): array
|
||||||
|
{
|
||||||
|
$rows = $request->request->all('attacks');
|
||||||
|
$attacks = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
$bonus = trim((string) ($row['bonus'] ?? ''));
|
||||||
|
$damage = trim((string) ($row['damage'] ?? ''));
|
||||||
|
$range = trim((string) ($row['range'] ?? ''));
|
||||||
|
|
||||||
|
if ('' === $name && '' === $bonus && '' === $damage && '' === $range) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attacks[] = ['name' => $name, 'bonus' => $bonus, 'damage' => $damage, 'range' => $range];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attacks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\CharacterRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player character sheet, close to the paper 5e sheet: identity, combat
|
||||||
|
* stats, ability scores, proficiencies, and the freeform narrative boxes.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: CharacterRepository::class)]
|
||||||
|
#[ORM\Table(name: 'character_sheet')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_character_name', columns: ['name'])]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class Character
|
||||||
|
{
|
||||||
|
/** Skill name => governing ability, in character-sheet order. */
|
||||||
|
public const SKILLS = [
|
||||||
|
'Acrobatics' => 'Dexterity',
|
||||||
|
'Animal Handling' => 'Wisdom',
|
||||||
|
'Arcana' => 'Intelligence',
|
||||||
|
'Athletics' => 'Strength',
|
||||||
|
'Deception' => 'Charisma',
|
||||||
|
'History' => 'Intelligence',
|
||||||
|
'Insight' => 'Wisdom',
|
||||||
|
'Intimidation' => 'Charisma',
|
||||||
|
'Investigation' => 'Intelligence',
|
||||||
|
'Medicine' => 'Wisdom',
|
||||||
|
'Nature' => 'Intelligence',
|
||||||
|
'Perception' => 'Wisdom',
|
||||||
|
'Performance' => 'Charisma',
|
||||||
|
'Persuasion' => 'Charisma',
|
||||||
|
'Religion' => 'Intelligence',
|
||||||
|
'Sleight of Hand' => 'Dexterity',
|
||||||
|
'Stealth' => 'Dexterity',
|
||||||
|
'Survival' => 'Wisdom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The six abilities, in character-sheet order. */
|
||||||
|
public const ABILITIES = ['Strength', 'Dexterity', 'Constitution', 'Intelligence', 'Wisdom', 'Charisma'];
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// --- Identity ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(length: 160)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $playerName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $race = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 80, nullable: true)]
|
||||||
|
private ?string $characterClass = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $level = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120, nullable: true)]
|
||||||
|
private ?string $background = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40, nullable: true)]
|
||||||
|
private ?string $alignment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $experiencePoints = null;
|
||||||
|
|
||||||
|
// --- Combat -------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $armorClass = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $initiative = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 60, nullable: true)]
|
||||||
|
private ?string $speed = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $hitPointMaximum = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $currentHitPoints = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||||
|
private ?int $temporaryHitPoints = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40, nullable: true)]
|
||||||
|
private ?string $hitDice = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
private int $proficiencyBonus = 2;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $inspiration = false;
|
||||||
|
|
||||||
|
// --- Ability scores -----------------------------------------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $strength = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $dexterity = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $constitution = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $intelligence = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $wisdom = 10;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 30)]
|
||||||
|
private int $charisma = 10;
|
||||||
|
|
||||||
|
/** @var list<string> Ability names ("Strength", ...) this character is proficient in saves for. */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $savingThrowProficiencies = [];
|
||||||
|
|
||||||
|
/** @var list<string> Skill names (keys of self::SKILLS) this character is proficient in. */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $skillProficiencies = [];
|
||||||
|
|
||||||
|
// --- Attacks: one row per weapon/spell attack, table-shaped on the sheet ---
|
||||||
|
|
||||||
|
/** @var list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $attacks = [];
|
||||||
|
|
||||||
|
// --- Freeform boxes, straight off the paper sheet -----------------------
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $otherProficienciesLanguages = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $equipment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $featuresTraits = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $spellcasting = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $personalityTraits = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $ideals = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $bonds = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $flaws = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $appearance = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $backstory = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $alliesOrganizations = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $treasure = null;
|
||||||
|
|
||||||
|
// --- DM ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** DM's live notes, written during play. Never touched by editing the sheet. */
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $gameNotes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Computed --------------------------------------------------------
|
||||||
|
|
||||||
|
public function abilityModifier(int $score): int
|
||||||
|
{
|
||||||
|
return (int) floor(($score - 10) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilityScore(string $ability): int
|
||||||
|
{
|
||||||
|
return match ($ability) {
|
||||||
|
'Strength' => $this->strength,
|
||||||
|
'Dexterity' => $this->dexterity,
|
||||||
|
'Constitution' => $this->constitution,
|
||||||
|
'Intelligence' => $this->intelligence,
|
||||||
|
'Wisdom' => $this->wisdom,
|
||||||
|
'Charisma' => $this->charisma,
|
||||||
|
default => 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProficientInSave(string $ability): bool
|
||||||
|
{
|
||||||
|
return \in_array($ability, $this->savingThrowProficiencies, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveModifier(string $ability): int
|
||||||
|
{
|
||||||
|
return $this->abilityModifier($this->abilityScore($ability))
|
||||||
|
+ ($this->isProficientInSave($ability) ? $this->proficiencyBonus : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProficientInSkill(string $skill): bool
|
||||||
|
{
|
||||||
|
return \in_array($skill, $this->skillProficiencies, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function skillModifier(string $skill): int
|
||||||
|
{
|
||||||
|
$ability = self::SKILLS[$skill] ?? 'Strength';
|
||||||
|
|
||||||
|
return $this->abilityModifier($this->abilityScore($ability))
|
||||||
|
+ ($this->isProficientInSkill($skill) ? $this->proficiencyBonus : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passivePerception(): int
|
||||||
|
{
|
||||||
|
return 10 + $this->skillModifier('Perception');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AssetMapper logical path of this character's portrait, e.g.
|
||||||
|
* "image/characters/DarraghOakwanderer.png". The file may not exist.
|
||||||
|
*/
|
||||||
|
public function getPortraitPath(): string
|
||||||
|
{
|
||||||
|
return 'image/characters/'.str_replace(' ', '', $this->name).'.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. "Halfling Ranger 2". */
|
||||||
|
public function getMetaLine(): string
|
||||||
|
{
|
||||||
|
$classLevel = trim(($this->characterClass ?? '').($this->characterClass && $this->level ? ' '.$this->level : ''));
|
||||||
|
|
||||||
|
return implode(' ', array_filter([$this->race, $classLevel]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPlayerName(): ?string
|
||||||
|
{
|
||||||
|
return $this->playerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPlayerName(?string $playerName): static
|
||||||
|
{
|
||||||
|
$this->playerName = $playerName;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRace(): ?string
|
||||||
|
{
|
||||||
|
return $this->race;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRace(?string $race): static
|
||||||
|
{
|
||||||
|
$this->race = $race;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCharacterClass(): ?string
|
||||||
|
{
|
||||||
|
return $this->characterClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCharacterClass(?string $characterClass): static
|
||||||
|
{
|
||||||
|
$this->characterClass = $characterClass;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLevel(): int
|
||||||
|
{
|
||||||
|
return $this->level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLevel(int $level): static
|
||||||
|
{
|
||||||
|
$this->level = $level;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBackground(): ?string
|
||||||
|
{
|
||||||
|
return $this->background;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBackground(?string $background): static
|
||||||
|
{
|
||||||
|
$this->background = $background;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlignment(): ?string
|
||||||
|
{
|
||||||
|
return $this->alignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAlignment(?string $alignment): static
|
||||||
|
{
|
||||||
|
$this->alignment = $alignment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExperiencePoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->experiencePoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExperiencePoints(?int $experiencePoints): static
|
||||||
|
{
|
||||||
|
$this->experiencePoints = $experiencePoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getArmorClass(): ?int
|
||||||
|
{
|
||||||
|
return $this->armorClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArmorClass(?int $armorClass): static
|
||||||
|
{
|
||||||
|
$this->armorClass = $armorClass;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInitiative(): ?int
|
||||||
|
{
|
||||||
|
return $this->initiative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInitiative(?int $initiative): static
|
||||||
|
{
|
||||||
|
$this->initiative = $initiative;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpeed(): ?string
|
||||||
|
{
|
||||||
|
return $this->speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpeed(?string $speed): static
|
||||||
|
{
|
||||||
|
$this->speed = $speed;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHitPointMaximum(): ?int
|
||||||
|
{
|
||||||
|
return $this->hitPointMaximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHitPointMaximum(?int $hitPointMaximum): static
|
||||||
|
{
|
||||||
|
$this->hitPointMaximum = $hitPointMaximum;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentHitPoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->currentHitPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCurrentHitPoints(?int $currentHitPoints): static
|
||||||
|
{
|
||||||
|
$this->currentHitPoints = $currentHitPoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemporaryHitPoints(): ?int
|
||||||
|
{
|
||||||
|
return $this->temporaryHitPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTemporaryHitPoints(?int $temporaryHitPoints): static
|
||||||
|
{
|
||||||
|
$this->temporaryHitPoints = $temporaryHitPoints;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHitDice(): ?string
|
||||||
|
{
|
||||||
|
return $this->hitDice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHitDice(?string $hitDice): static
|
||||||
|
{
|
||||||
|
$this->hitDice = $hitDice;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProficiencyBonus(): int
|
||||||
|
{
|
||||||
|
return $this->proficiencyBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProficiencyBonus(int $proficiencyBonus): static
|
||||||
|
{
|
||||||
|
$this->proficiencyBonus = $proficiencyBonus;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInspiration(): bool
|
||||||
|
{
|
||||||
|
return $this->inspiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInspiration(bool $inspiration): static
|
||||||
|
{
|
||||||
|
$this->inspiration = $inspiration;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStrength(): int
|
||||||
|
{
|
||||||
|
return $this->strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStrength(int $strength): static
|
||||||
|
{
|
||||||
|
$this->strength = $strength;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDexterity(): int
|
||||||
|
{
|
||||||
|
return $this->dexterity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDexterity(int $dexterity): static
|
||||||
|
{
|
||||||
|
$this->dexterity = $dexterity;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConstitution(): int
|
||||||
|
{
|
||||||
|
return $this->constitution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConstitution(int $constitution): static
|
||||||
|
{
|
||||||
|
$this->constitution = $constitution;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIntelligence(): int
|
||||||
|
{
|
||||||
|
return $this->intelligence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIntelligence(int $intelligence): static
|
||||||
|
{
|
||||||
|
$this->intelligence = $intelligence;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWisdom(): int
|
||||||
|
{
|
||||||
|
return $this->wisdom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWisdom(int $wisdom): static
|
||||||
|
{
|
||||||
|
$this->wisdom = $wisdom;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCharisma(): int
|
||||||
|
{
|
||||||
|
return $this->charisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCharisma(int $charisma): static
|
||||||
|
{
|
||||||
|
$this->charisma = $charisma;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getSavingThrowProficiencies(): array
|
||||||
|
{
|
||||||
|
return $this->savingThrowProficiencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $savingThrowProficiencies */
|
||||||
|
public function setSavingThrowProficiencies(array $savingThrowProficiencies): static
|
||||||
|
{
|
||||||
|
$this->savingThrowProficiencies = array_values($savingThrowProficiencies);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getSkillProficiencies(): array
|
||||||
|
{
|
||||||
|
return $this->skillProficiencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $skillProficiencies */
|
||||||
|
public function setSkillProficiencies(array $skillProficiencies): static
|
||||||
|
{
|
||||||
|
$this->skillProficiencies = array_values($skillProficiencies);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
|
public function getAttacks(): array
|
||||||
|
{
|
||||||
|
return $this->attacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{name: string, bonus: string, damage: string, range: string}> $attacks */
|
||||||
|
public function setAttacks(array $attacks): static
|
||||||
|
{
|
||||||
|
$this->attacks = array_values($attacks);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOtherProficienciesLanguages(): ?string
|
||||||
|
{
|
||||||
|
return $this->otherProficienciesLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOtherProficienciesLanguages(?string $otherProficienciesLanguages): static
|
||||||
|
{
|
||||||
|
$this->otherProficienciesLanguages = $otherProficienciesLanguages;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEquipment(): ?string
|
||||||
|
{
|
||||||
|
return $this->equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEquipment(?string $equipment): static
|
||||||
|
{
|
||||||
|
$this->equipment = $equipment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFeaturesTraits(): ?string
|
||||||
|
{
|
||||||
|
return $this->featuresTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFeaturesTraits(?string $featuresTraits): static
|
||||||
|
{
|
||||||
|
$this->featuresTraits = $featuresTraits;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpellcasting(): ?string
|
||||||
|
{
|
||||||
|
return $this->spellcasting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpellcasting(?string $spellcasting): static
|
||||||
|
{
|
||||||
|
$this->spellcasting = $spellcasting;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPersonalityTraits(): ?string
|
||||||
|
{
|
||||||
|
return $this->personalityTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPersonalityTraits(?string $personalityTraits): static
|
||||||
|
{
|
||||||
|
$this->personalityTraits = $personalityTraits;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdeals(): ?string
|
||||||
|
{
|
||||||
|
return $this->ideals;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdeals(?string $ideals): static
|
||||||
|
{
|
||||||
|
$this->ideals = $ideals;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBonds(): ?string
|
||||||
|
{
|
||||||
|
return $this->bonds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBonds(?string $bonds): static
|
||||||
|
{
|
||||||
|
$this->bonds = $bonds;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFlaws(): ?string
|
||||||
|
{
|
||||||
|
return $this->flaws;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFlaws(?string $flaws): static
|
||||||
|
{
|
||||||
|
$this->flaws = $flaws;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAppearance(): ?string
|
||||||
|
{
|
||||||
|
return $this->appearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAppearance(?string $appearance): static
|
||||||
|
{
|
||||||
|
$this->appearance = $appearance;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBackstory(): ?string
|
||||||
|
{
|
||||||
|
return $this->backstory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBackstory(?string $backstory): static
|
||||||
|
{
|
||||||
|
$this->backstory = $backstory;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlliesOrganizations(): ?string
|
||||||
|
{
|
||||||
|
return $this->alliesOrganizations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAlliesOrganizations(?string $alliesOrganizations): static
|
||||||
|
{
|
||||||
|
$this->alliesOrganizations = $alliesOrganizations;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTreasure(): ?string
|
||||||
|
{
|
||||||
|
return $this->treasure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTreasure(?string $treasure): static
|
||||||
|
{
|
||||||
|
$this->treasure = $treasure;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGameNotes(): ?string
|
||||||
|
{
|
||||||
|
return $this->gameNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGameNotes(?string $gameNotes): static
|
||||||
|
{
|
||||||
|
$this->gameNotes = $gameNotes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only editable thing on the Character detail page: the DM's live play notes.
|
||||||
|
*/
|
||||||
|
class CharacterGameNotesType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->add('gameNotes', TextareaType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => false,
|
||||||
|
'attr' => ['rows' => 14, 'placeholder' => 'How this character played at the table…'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => Character::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full character sheet, minus game notes (that lives on the detail page)
|
||||||
|
* and minus attacks (a small repeatable table handled by hand in the
|
||||||
|
* template/controller, since it's a list of rows rather than a single value).
|
||||||
|
*/
|
||||||
|
class CharacterType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$opt = ['required' => false];
|
||||||
|
$ability = ['attr' => ['min' => 1, 'max' => 30]];
|
||||||
|
$block = ['required' => false, 'attr' => ['rows' => 3]];
|
||||||
|
$note = ['required' => false, 'attr' => ['rows' => 2]];
|
||||||
|
|
||||||
|
$builder
|
||||||
|
// Identity
|
||||||
|
->add('name', TextType::class)
|
||||||
|
->add('playerName', TextType::class, $opt + ['label' => 'Player name'])
|
||||||
|
->add('race', TextType::class, $opt + ['help' => 'e.g. Halfling, stout'])
|
||||||
|
->add('characterClass', TextType::class, $opt + ['label' => 'Class', 'help' => 'e.g. Ranger'])
|
||||||
|
->add('level', IntegerType::class, ['attr' => ['min' => 1, 'max' => 20]])
|
||||||
|
->add('background', TextType::class, $opt)
|
||||||
|
->add('alignment', TextType::class, $opt + ['help' => 'e.g. CN'])
|
||||||
|
->add('experiencePoints', IntegerType::class, $opt + ['label' => 'XP'])
|
||||||
|
// Combat
|
||||||
|
->add('armorClass', IntegerType::class, $opt + ['label' => 'AC'])
|
||||||
|
->add('initiative', IntegerType::class, $opt)
|
||||||
|
->add('speed', TextType::class, $opt + ['help' => 'e.g. 25 ft.'])
|
||||||
|
->add('hitPointMaximum', IntegerType::class, $opt + ['label' => 'HP maximum'])
|
||||||
|
->add('currentHitPoints', IntegerType::class, $opt + ['label' => 'Current HP'])
|
||||||
|
->add('temporaryHitPoints', IntegerType::class, $opt + ['label' => 'Temporary HP'])
|
||||||
|
->add('hitDice', TextType::class, $opt + ['help' => 'e.g. 2d10'])
|
||||||
|
->add('proficiencyBonus', IntegerType::class)
|
||||||
|
->add('inspiration', CheckboxType::class, ['required' => false])
|
||||||
|
// Ability scores
|
||||||
|
->add('strength', IntegerType::class, $ability)
|
||||||
|
->add('dexterity', IntegerType::class, $ability)
|
||||||
|
->add('constitution', IntegerType::class, $ability)
|
||||||
|
->add('intelligence', IntegerType::class, $ability)
|
||||||
|
->add('wisdom', IntegerType::class, $ability)
|
||||||
|
->add('charisma', IntegerType::class, $ability)
|
||||||
|
// Proficiencies
|
||||||
|
->add('savingThrowProficiencies', ChoiceType::class, [
|
||||||
|
'choices' => array_combine(Character::ABILITIES, Character::ABILITIES),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Saving throw proficiencies',
|
||||||
|
])
|
||||||
|
->add('skillProficiencies', ChoiceType::class, [
|
||||||
|
'choices' => array_combine(array_keys(Character::SKILLS), array_keys(Character::SKILLS)),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Skill proficiencies',
|
||||||
|
])
|
||||||
|
// Text blocks
|
||||||
|
->add('otherProficienciesLanguages', TextareaType::class, $block + ['label' => 'Other proficiencies & languages'])
|
||||||
|
->add('equipment', TextareaType::class, $block)
|
||||||
|
->add('featuresTraits', TextareaType::class, $block + ['label' => 'Features & traits'])
|
||||||
|
->add('spellcasting', TextareaType::class, $block)
|
||||||
|
->add('personalityTraits', TextareaType::class, $note + ['label' => 'Personality traits'])
|
||||||
|
->add('ideals', TextareaType::class, $note)
|
||||||
|
->add('bonds', TextareaType::class, $note)
|
||||||
|
->add('flaws', TextareaType::class, $note)
|
||||||
|
->add('appearance', TextareaType::class, $block + ['help' => 'Age, height, weight, eyes, skin, hair, description'])
|
||||||
|
->add('backstory', TextareaType::class, $block)
|
||||||
|
->add('alliesOrganizations', TextareaType::class, $block + ['label' => 'Allies & organizations'])
|
||||||
|
->add('treasure', TextareaType::class, $block);
|
||||||
|
// attacks and gameNotes are not part of this form.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => Character::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Character>
|
||||||
|
*/
|
||||||
|
class CharacterRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Character::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Character> */
|
||||||
|
public function findAllOrdered(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['name' => 'ASC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{#
|
||||||
|
# Read-only character sheet. Table-shaped where the paper sheet itself is
|
||||||
|
# (ability scores, skills, attacks); the prose helpers (_line, _section, mod)
|
||||||
|
# are borrowed from the enemy stat block, another 5e sheet.
|
||||||
|
# {% import 'characters/_sheet.html.twig' as cs %}
|
||||||
|
# {{ cs.block(character) }}
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro block(c) %}
|
||||||
|
{% import 'enemy/_statblock.html.twig' as sb %}
|
||||||
|
|
||||||
|
<dl class="char-meta">
|
||||||
|
<div><dt>Race</dt><dd>{{ c.race ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Class & level</dt><dd>{{ c.characterClass ? c.characterClass ~ ' ' ~ c.level : '—' }}</dd></div>
|
||||||
|
<div><dt>Background</dt><dd>{{ c.background ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Alignment</dt><dd>{{ c.alignment ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Player</dt><dd>{{ c.playerName ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Experience</dt><dd>{{ c.experiencePoints is not null ? c.experiencePoints ~ ' XP' : '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="char-meta">
|
||||||
|
<div><dt>Armor Class</dt><dd>{{ c.armorClass ?? '—' }}</dd></div>
|
||||||
|
<div><dt>Initiative</dt><dd>{{ c.initiative is not null ? sb.mod(c.initiative) : '—' }}</dd></div>
|
||||||
|
<div><dt>Speed</dt><dd>{{ c.speed ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Hit Points</dt>
|
||||||
|
<dd>
|
||||||
|
{%- if c.currentHitPoints is not null or c.hitPointMaximum is not null -%}
|
||||||
|
{{ c.currentHitPoints ?? '?' }}{{ c.hitPointMaximum ? ' / ' ~ c.hitPointMaximum }}{{ c.temporaryHitPoints ? ' (+' ~ c.temporaryHitPoints ~ ' temp)' }}
|
||||||
|
{%- else -%}—{%- endif -%}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Hit Dice</dt><dd>{{ c.hitDice ?: '—' }}</dd></div>
|
||||||
|
<div><dt>Proficiency Bonus</dt><dd>{{ sb.mod(c.proficiencyBonus) }}</dd></div>
|
||||||
|
<div><dt>Passive Perception</dt><dd>{{ c.passivePerception }}</dd></div>
|
||||||
|
<div><dt>Inspiration</dt><dd>{{ c.inspiration ? 'Yes' : '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-ability-table">
|
||||||
|
<thead><tr><th>Ability</th><th>Score</th><th>Mod</th><th>Save</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ability in constant('App\\Entity\\Character::ABILITIES') %}
|
||||||
|
{% set score = c.abilityScore(ability) %}
|
||||||
|
<tr class="{{ c.isProficientInSave(ability) ? 'is-proficient' }}">
|
||||||
|
<td>{{ ability }}</td>
|
||||||
|
<td>{{ score }}</td>
|
||||||
|
<td>{{ sb.mod(c.abilityModifier(score)) }}</td>
|
||||||
|
<td>{{ sb.mod(c.saveModifier(ability)) }}{{ c.isProficientInSave(ability) ? ' ●' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-skill-table">
|
||||||
|
<thead><tr><th>Skill</th><th>Abl.</th><th>Mod</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for skill, ability in constant('App\\Entity\\Character::SKILLS') %}
|
||||||
|
<tr class="{{ c.isProficientInSkill(skill) ? 'is-proficient' }}">
|
||||||
|
<td>{{ skill }}{{ c.isProficientInSkill(skill) ? ' ●' }}</td>
|
||||||
|
<td>{{ ability|slice(0, 3) }}</td>
|
||||||
|
<td>{{ sb.mod(c.skillModifier(skill)) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if c.attacks is not empty %}
|
||||||
|
<h3 class="h6 border-bottom pb-1 mt-3">Attacks & Spellcasting</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm char-attack-table">
|
||||||
|
<thead><tr><th>Name</th><th>Atk Bonus</th><th>Damage/Type</th><th>Range</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for atk in c.attacks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ atk.name }}</td>
|
||||||
|
<td>{{ atk.bonus }}</td>
|
||||||
|
<td>{{ atk.damage }}</td>
|
||||||
|
<td>{{ atk.range }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ sb._section('Spellcasting', c.spellcasting) }}
|
||||||
|
{{ sb._section('Features & Traits', c.featuresTraits) }}
|
||||||
|
{{ sb._section('Other Proficiencies & Languages', c.otherProficienciesLanguages) }}
|
||||||
|
{{ sb._section('Equipment', c.equipment) }}
|
||||||
|
|
||||||
|
{% if c.personalityTraits or c.ideals or c.bonds or c.flaws %}
|
||||||
|
<h3 class="h6 border-bottom pb-1 mt-3">Personality</h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-2">
|
||||||
|
<div>{{ sb._line('Traits', c.personalityTraits) }}</div>
|
||||||
|
<div>{{ sb._line('Ideals', c.ideals) }}</div>
|
||||||
|
<div>{{ sb._line('Bonds', c.bonds) }}</div>
|
||||||
|
<div>{{ sb._line('Flaws', c.flaws) }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ sb._section('Appearance', c.appearance) }}
|
||||||
|
{{ sb._section('Backstory', c.backstory) }}
|
||||||
|
{{ sb._section('Allies & Organizations', c.alliesOrganizations) }}
|
||||||
|
{{ sb._section('Treasure', c.treasure) }}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ is_new ? 'New character' : 'Edit ' ~ character.name }} · Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ is_new ? path('app_characters') : path('app_characters_show', {id: character.id}) }}"
|
||||||
|
class="text-decoration-none small">← {{ is_new ? 'All characters' : 'Back to ' ~ character.name }}</a>
|
||||||
|
<h1 class="h3 mb-0">{{ is_new ? 'New character' : 'Edit ' ~ character.name }}</h1>
|
||||||
|
<span class="text-body-secondary">Character sheet — game notes live on the character page.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_start(form) }}
|
||||||
|
<div class="row g-4" style="max-width: 70rem;">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Identity</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.name) }}
|
||||||
|
{{ form_row(form.playerName) }}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8">{{ form_row(form.race) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.level) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">{{ form_row(form.characterClass) }}</div>
|
||||||
|
<div class="col-6">{{ form_row(form.background) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">{{ form_row(form.alignment) }}</div>
|
||||||
|
<div class="col-6">{{ form_row(form.experiencePoints) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Combat</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{ form_row(form.armorClass) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.initiative) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.speed) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{ form_row(form.hitPointMaximum) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.currentHitPoints) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.temporaryHitPoints) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-4">{{ form_row(form.hitDice) }}</div>
|
||||||
|
<div class="col-4">{{ form_row(form.proficiencyBonus) }}</div>
|
||||||
|
<div class="col-4 pt-4">{{ form_row(form.inspiration) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Ability scores & proficiencies</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row row-cols-3 row-cols-md-6 g-2 mb-2">
|
||||||
|
<div>{{ form_row(form.strength) }}</div>
|
||||||
|
<div>{{ form_row(form.dexterity) }}</div>
|
||||||
|
<div>{{ form_row(form.constitution) }}</div>
|
||||||
|
<div>{{ form_row(form.intelligence) }}</div>
|
||||||
|
<div>{{ form_row(form.wisdom) }}</div>
|
||||||
|
<div>{{ form_row(form.charisma) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">{{ form_row(form.savingThrowProficiencies) }}</div>
|
||||||
|
<div class="col-md-8">{{ form_row(form.skillProficiencies) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Attacks & spellcasting</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Atk bonus</th><th>Damage/type</th><th>Range</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for i in 0..5 %}
|
||||||
|
{% set atk = character.attacks[i] ?? null %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][name]" value="{{ atk.name ?? '' }}" placeholder="Weapon or spell"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][bonus]" value="{{ atk.bonus ?? '' }}" placeholder="+5" style="max-width: 6rem"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][damage]" value="{{ atk.damage ?? '' }}" placeholder="1d4+5 piercing"></td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" name="attacks[{{ i }}][range]" value="{{ atk.range ?? '' }}" placeholder="20/60 ft."></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-3">Blank rows are dropped when you save.</div>
|
||||||
|
{{ form_row(form.spellcasting) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Traits & equipment</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.featuresTraits) }}
|
||||||
|
{{ form_row(form.otherProficienciesLanguages) }}
|
||||||
|
{{ form_row(form.equipment) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Personality</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.personalityTraits) }}
|
||||||
|
{{ form_row(form.ideals) }}
|
||||||
|
{{ form_row(form.bonds) }}
|
||||||
|
{{ form_row(form.flaws) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Appearance & backstory</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_row(form.appearance) }}
|
||||||
|
{{ form_row(form.backstory) }}
|
||||||
|
{{ form_row(form.alliesOrganizations) }}
|
||||||
|
{{ form_row(form.treasure) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary">{{ is_new ? 'Add character' : 'Save' }}</button>
|
||||||
|
<a href="{{ is_new ? path('app_characters') : path('app_characters_show', {id: character.id}) }}"
|
||||||
|
class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -3,9 +3,35 @@
|
|||||||
{% block title %}Characters · dmtools{% endblock %}
|
{% block title %}Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{{ include('_partials/page_placeholder.html.twig', {
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
title: 'Characters',
|
<h1 class="h3 mb-0">Characters</h1>
|
||||||
icon: '🧝',
|
<a href="{{ path('app_characters_new') }}" class="btn btn-primary btn-sm">Add character</a>
|
||||||
blurb: 'Player characters will live here — list, sheet details and party overview. Data model to be designed.',
|
</div>
|
||||||
}) }}
|
|
||||||
|
{% if characters is empty %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
|
No party members yet. Use <strong>Add character</strong> to bring in a sheet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-3">
|
||||||
|
{% for c in characters %}
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ path('app_characters_show', {id: c.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body">
|
||||||
|
<div class="char-portrait char-portrait--card">
|
||||||
|
<img src="{{ asset(c.portraitPath) }}" alt="{{ c.name }}" loading="lazy" onerror="this.parentElement.remove()">
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="fw-semibold text-truncate" title="{{ c.name }}">{{ c.name }}</div>
|
||||||
|
<div class="text-body-secondary small">{{ c.metaLine ?: '—' }}</div>
|
||||||
|
{% if c.playerName %}
|
||||||
|
<div class="text-body-secondary small">Played by {{ c.playerName }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ character.name }} · Characters · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% import 'characters/_sheet.html.twig' as cs %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<a href="{{ path('app_characters') }}" class="text-decoration-none small">← All characters</a>
|
||||||
|
<h1 class="h3 mb-0">{{ character.name }}</h1>
|
||||||
|
<span class="text-body-secondary fst-italic">{{ character.metaLine ?: '—' }}</span>
|
||||||
|
{% if character.playerName %}
|
||||||
|
<div class="text-body-secondary small">Played by {{ character.playerName }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ path('app_characters_edit', {id: character.id}) }}" class="btn btn-outline-secondary btn-sm">Edit sheet</a>
|
||||||
|
<form method="post" action="{{ path('app_characters_delete', {id: character.id}) }}"
|
||||||
|
onsubmit="return confirm('Delete “{{ character.name|e('js') }}” from the character list?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-character-' ~ character.id) }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="char-portrait char-portrait--hero">
|
||||||
|
<img src="{{ asset(character.portraitPath) }}" alt="{{ character.name }}" onerror="this.parentElement.remove()">
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ cs.block(character) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Game notes</span>
|
||||||
|
{% if character.updatedAt %}
|
||||||
|
<span class="text-body-secondary small fw-normal">saved {{ character.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_start(form) }}
|
||||||
|
{{ form_row(form.gameNotes) }}
|
||||||
|
<button class="btn btn-primary mt-2">Save notes</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{#
|
{#
|
||||||
# Shared 5e stat-block rendering. Used on the enemy detail page and, folded
|
# Shared 5e stat-block rendering. Used on the enemy detail page and, folded
|
||||||
# up, inside each tracker combatant.
|
# up, inside each tracker combatant; its prose helpers (mod, _line, _section)
|
||||||
|
# are also borrowed by the character sheet (characters/_sheet.html.twig).
|
||||||
# {% import 'enemy/_statblock.html.twig' as sb %}
|
# {% import 'enemy/_statblock.html.twig' as sb %}
|
||||||
# {{ sb.block(enemy) }}
|
# {{ sb.block(enemy) }}
|
||||||
#}
|
#}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Character;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use App\Form\CharacterGameNotesType;
|
||||||
|
use App\Form\CharacterType;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the character templates end to end (no real HTTP/DB) so a broken
|
||||||
|
* Twig variable, macro import or path()/asset() call fails loudly.
|
||||||
|
*/
|
||||||
|
class CharacterTemplatesTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private function darragh(): Character
|
||||||
|
{
|
||||||
|
$c = (new Character())
|
||||||
|
->setName('Darragh Oakwanderer')
|
||||||
|
->setPlayerName('PH')
|
||||||
|
->setRace('Halfling, stout')
|
||||||
|
->setCharacterClass('Ranger')
|
||||||
|
->setLevel(2)
|
||||||
|
->setBackground('Urchin')
|
||||||
|
->setAlignment('CN')
|
||||||
|
->setArmorClass(15)
|
||||||
|
->setInitiative(3)
|
||||||
|
->setSpeed('25 ft.')
|
||||||
|
->setHitDice('2d10')
|
||||||
|
->setStrength(12)->setDexterity(17)->setConstitution(14)
|
||||||
|
->setIntelligence(12)->setWisdom(13)->setCharisma(8)
|
||||||
|
->setSavingThrowProficiencies(['Strength', 'Dexterity'])
|
||||||
|
->setSkillProficiencies(['Sleight of Hand', 'Stealth'])
|
||||||
|
->setAttacks([['name' => 'Dart', 'bonus' => '+5', 'damage' => '1d4+5', 'range' => '20/60 ft.']])
|
||||||
|
->setFeaturesTraits("Stout Resilience\nLucky")
|
||||||
|
->setPersonalityTraits('I eat like a pig and have bad manners');
|
||||||
|
|
||||||
|
(new \ReflectionProperty(Character::class, 'id'))->setValue($c, 1);
|
||||||
|
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pushRequestWithSession(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/characters/1');
|
||||||
|
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||||
|
self::getContainer()->get('request_stack')->push($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowPageRenders(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->pushRequestWithSession();
|
||||||
|
$c = self::getContainer();
|
||||||
|
$character = $this->darragh();
|
||||||
|
|
||||||
|
$form = $c->get('form.factory')->create(CharacterGameNotesType::class, $character);
|
||||||
|
$html = $c->get('twig')->render('characters/show.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Darragh Oakwanderer', $html);
|
||||||
|
self::assertStringContainsString('Halfling, stout Ranger 2', $html);
|
||||||
|
self::assertStringContainsString('Stealth', $html);
|
||||||
|
self::assertStringContainsString('Dart', $html);
|
||||||
|
self::assertStringContainsString('Game notes', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditPageRendersForNewAndExisting(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->pushRequestWithSession();
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
foreach ([true, false] as $isNew) {
|
||||||
|
$character = $isNew ? new Character() : $this->darragh();
|
||||||
|
$form = $c->get('form.factory')->create(CharacterType::class, $character);
|
||||||
|
$html = $c->get('twig')->render('characters/edit.html.twig', [
|
||||||
|
'character' => $character,
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'is_new' => $isNew,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringContainsString($isNew ? 'New character' : 'Edit Darragh Oakwanderer', $html);
|
||||||
|
self::assertStringContainsString('attacks[0][name]', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexPageRendersEmptyAndPopulated(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->pushRequestWithSession();
|
||||||
|
$twig = self::getContainer()->get('twig');
|
||||||
|
|
||||||
|
$empty = $twig->render('characters/index.html.twig', ['characters' => []]);
|
||||||
|
self::assertStringContainsString('No party members yet', $empty);
|
||||||
|
|
||||||
|
$populated = $twig->render('characters/index.html.twig', ['characters' => [$this->darragh()]]);
|
||||||
|
self::assertStringContainsString('Darragh Oakwanderer', $populated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
class CharacterControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testCharactersRequireLogin(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/characters');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Character;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class CharacterTest extends TestCase
|
||||||
|
{
|
||||||
|
private function darragh(): Character
|
||||||
|
{
|
||||||
|
return (new Character())
|
||||||
|
->setName('Darragh Oakwanderer')
|
||||||
|
->setCharacterClass('Ranger')
|
||||||
|
->setLevel(2)
|
||||||
|
->setRace('Halfling, stout')
|
||||||
|
->setProficiencyBonus(2)
|
||||||
|
->setStrength(12)
|
||||||
|
->setDexterity(17)
|
||||||
|
->setWisdom(13)
|
||||||
|
->setSavingThrowProficiencies(['Strength', 'Dexterity'])
|
||||||
|
->setSkillProficiencies(['Sleight of Hand', 'Stealth']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAbilityModifierRoundsDown(): void
|
||||||
|
{
|
||||||
|
$c = new Character();
|
||||||
|
|
||||||
|
self::assertSame(-1, $c->abilityModifier(8));
|
||||||
|
self::assertSame(0, $c->abilityModifier(10));
|
||||||
|
self::assertSame(0, $c->abilityModifier(11));
|
||||||
|
self::assertSame(3, $c->abilityModifier(17));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSkillModifierAddsProficiencyOnlyWhenProficient(): void
|
||||||
|
{
|
||||||
|
$c = $this->darragh();
|
||||||
|
|
||||||
|
// Dex 17 -> +3, proficient (+2 prof) -> +5
|
||||||
|
self::assertSame(5, $c->skillModifier('Stealth'));
|
||||||
|
// Wis 13 -> +1, not proficient in Perception
|
||||||
|
self::assertSame(1, $c->skillModifier('Perception'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaveModifierAddsProficiencyOnlyWhenProficient(): void
|
||||||
|
{
|
||||||
|
$c = $this->darragh();
|
||||||
|
|
||||||
|
self::assertSame(3, $c->saveModifier('Strength')); // Str +1, proficient +2 => +3
|
||||||
|
self::assertSame(5, $c->saveModifier('Dexterity')); // Dex +3, proficient +2 => +5
|
||||||
|
self::assertSame(1, $c->saveModifier('Wisdom')); // Wis +1, not proficient
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPassivePerceptionIsTenPlusPerceptionModifier(): void
|
||||||
|
{
|
||||||
|
$c = $this->darragh(); // Wis +1, not proficient in Perception
|
||||||
|
|
||||||
|
self::assertSame(11, $c->passivePerception());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPortraitPathStripsSpacesFromName(): void
|
||||||
|
{
|
||||||
|
$c = (new Character())->setName('Darragh Oakwanderer');
|
||||||
|
|
||||||
|
self::assertSame('image/characters/DarraghOakwanderer.png', $c->getPortraitPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMetaLineCombinesRaceClassAndLevel(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Halfling, stout Ranger 2', $this->darragh()->getMetaLine());
|
||||||
|
self::assertSame('', (new Character())->getMetaLine());
|
||||||
|
self::assertSame('Halfling', (new Character())->setRace('Halfling')->getMetaLine());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user