diff --git a/assets/characters/Darrah_Oakwanderer.pdf b/assets/characters/Darrah_Oakwanderer.pdf new file mode 100644 index 0000000..0cdc0a8 Binary files /dev/null and b/assets/characters/Darrah_Oakwanderer.pdf differ diff --git a/assets/characters/Darrah_Oakwanderer.png b/assets/characters/Darrah_Oakwanderer.png new file mode 100644 index 0000000..25d7137 Binary files /dev/null and b/assets/characters/Darrah_Oakwanderer.png differ diff --git a/assets/image/characters/DarraghOakwanderer.png b/assets/image/characters/DarraghOakwanderer.png new file mode 100644 index 0000000..b0d906a Binary files /dev/null and b/assets/image/characters/DarraghOakwanderer.png differ diff --git a/assets/styles/app.css b/assets/styles/app.css index e78a68e..a680590 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -154,6 +154,54 @@ table.dataTable { .art-reorder-status.is-visible { opacity: 0.92; } .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 --------------------------------- */ [data-bs-toggle="collapse"] .sb-caret { display: inline-block; diff --git a/migrations/Version20260922064937.php b/migrations/Version20260922064937.php new file mode 100644 index 0000000..2b3cafb --- /dev/null +++ b/migrations/Version20260922064937.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/src/Controller/CharacterController.php b/src/Controller/CharacterController.php index 260cf6a..50b6ba8 100644 --- a/src/Controller/CharacterController.php +++ b/src/Controller/CharacterController.php @@ -2,7 +2,13 @@ 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\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -10,9 +16,111 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('IS_AUTHENTICATED_FULLY')] class CharacterController extends AbstractController { - #[Route('/characters', name: 'app_characters')] - public function index(): Response + #[Route('/characters', name: 'app_characters', methods: ['GET'])] + 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 + */ + 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; } } diff --git a/src/Entity/Character.php b/src/Entity/Character.php new file mode 100644 index 0000000..a939760 --- /dev/null +++ b/src/Entity/Character.php @@ -0,0 +1,749 @@ + 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 Ability names ("Strength", ...) this character is proficient in saves for. */ + #[ORM\Column(type: Types::JSON)] + private array $savingThrowProficiencies = []; + + /** @var list 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 */ + #[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 */ + public function getSavingThrowProficiencies(): array + { + return $this->savingThrowProficiencies; + } + + /** @param list $savingThrowProficiencies */ + public function setSavingThrowProficiencies(array $savingThrowProficiencies): static + { + $this->savingThrowProficiencies = array_values($savingThrowProficiencies); + + return $this; + } + + /** @return list */ + public function getSkillProficiencies(): array + { + return $this->skillProficiencies; + } + + /** @param list $skillProficiencies */ + public function setSkillProficiencies(array $skillProficiencies): static + { + $this->skillProficiencies = array_values($skillProficiencies); + + return $this; + } + + /** @return list */ + public function getAttacks(): array + { + return $this->attacks; + } + + /** @param list $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; + } +} diff --git a/src/Form/CharacterGameNotesType.php b/src/Form/CharacterGameNotesType.php new file mode 100644 index 0000000..f08c1c6 --- /dev/null +++ b/src/Form/CharacterGameNotesType.php @@ -0,0 +1,29 @@ +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]); + } +} diff --git a/src/Form/CharacterType.php b/src/Form/CharacterType.php new file mode 100644 index 0000000..5c9f663 --- /dev/null +++ b/src/Form/CharacterType.php @@ -0,0 +1,91 @@ + 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]); + } +} diff --git a/src/Repository/CharacterRepository.php b/src/Repository/CharacterRepository.php new file mode 100644 index 0000000..fbf45a3 --- /dev/null +++ b/src/Repository/CharacterRepository.php @@ -0,0 +1,24 @@ + + */ +class CharacterRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Character::class); + } + + /** @return list */ + public function findAllOrdered(): array + { + return $this->findBy([], ['name' => 'ASC']); + } +} diff --git a/templates/characters/_sheet.html.twig b/templates/characters/_sheet.html.twig new file mode 100644 index 0000000..b036836 --- /dev/null +++ b/templates/characters/_sheet.html.twig @@ -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 %} + +
+
Race
{{ c.race ?: '—' }}
+
Class & level
{{ c.characterClass ? c.characterClass ~ ' ' ~ c.level : '—' }}
+
Background
{{ c.background ?: '—' }}
+
Alignment
{{ c.alignment ?: '—' }}
+
Player
{{ c.playerName ?: '—' }}
+
Experience
{{ c.experiencePoints is not null ? c.experiencePoints ~ ' XP' : '—' }}
+
+ +
+
Armor Class
{{ c.armorClass ?? '—' }}
+
Initiative
{{ c.initiative is not null ? sb.mod(c.initiative) : '—' }}
+
Speed
{{ c.speed ?: '—' }}
+
Hit Points
+
+ {%- 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 -%} +
+
+
Hit Dice
{{ c.hitDice ?: '—' }}
+
Proficiency Bonus
{{ sb.mod(c.proficiencyBonus) }}
+
Passive Perception
{{ c.passivePerception }}
+
Inspiration
{{ c.inspiration ? 'Yes' : '—' }}
+
+ +
+
+
+ + + + {% for ability in constant('App\\Entity\\Character::ABILITIES') %} + {% set score = c.abilityScore(ability) %} + + + + + + + {% endfor %} + +
AbilityScoreModSave
{{ ability }}{{ score }}{{ sb.mod(c.abilityModifier(score)) }}{{ sb.mod(c.saveModifier(ability)) }}{{ c.isProficientInSave(ability) ? ' ●' }}
+
+
+
+
+ + + + {% for skill, ability in constant('App\\Entity\\Character::SKILLS') %} + + + + + + {% endfor %} + +
SkillAbl.Mod
{{ skill }}{{ c.isProficientInSkill(skill) ? ' ●' }}{{ ability|slice(0, 3) }}{{ sb.mod(c.skillModifier(skill)) }}
+
+
+
+ + {% if c.attacks is not empty %} +

Attacks & Spellcasting

+
+ + + + {% for atk in c.attacks %} + + + + + + + {% endfor %} + +
NameAtk BonusDamage/TypeRange
{{ atk.name }}{{ atk.bonus }}{{ atk.damage }}{{ atk.range }}
+
+ {% 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 %} +

Personality

+
+
{{ sb._line('Traits', c.personalityTraits) }}
+
{{ sb._line('Ideals', c.ideals) }}
+
{{ sb._line('Bonds', c.bonds) }}
+
{{ sb._line('Flaws', c.flaws) }}
+
+ {% endif %} + + {{ sb._section('Appearance', c.appearance) }} + {{ sb._section('Backstory', c.backstory) }} + {{ sb._section('Allies & Organizations', c.alliesOrganizations) }} + {{ sb._section('Treasure', c.treasure) }} +{% endmacro %} diff --git a/templates/characters/edit.html.twig b/templates/characters/edit.html.twig new file mode 100644 index 0000000..7a62bcb --- /dev/null +++ b/templates/characters/edit.html.twig @@ -0,0 +1,150 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ is_new ? 'New character' : 'Edit ' ~ character.name }} · Characters · dmtools{% endblock %} + +{% block body %} +
+ ← {{ is_new ? 'All characters' : 'Back to ' ~ character.name }} +

{{ is_new ? 'New character' : 'Edit ' ~ character.name }}

+ Character sheet — game notes live on the character page. +
+ + {{ form_start(form) }} +
+
+
+
Identity
+
+ {{ form_row(form.name) }} + {{ form_row(form.playerName) }} +
+
{{ form_row(form.race) }}
+
{{ form_row(form.level) }}
+
+
+
{{ form_row(form.characterClass) }}
+
{{ form_row(form.background) }}
+
+
+
{{ form_row(form.alignment) }}
+
{{ form_row(form.experiencePoints) }}
+
+
+
+
+ +
+
+
Combat
+
+
+
{{ form_row(form.armorClass) }}
+
{{ form_row(form.initiative) }}
+
{{ form_row(form.speed) }}
+
+
+
{{ form_row(form.hitPointMaximum) }}
+
{{ form_row(form.currentHitPoints) }}
+
{{ form_row(form.temporaryHitPoints) }}
+
+
+
{{ form_row(form.hitDice) }}
+
{{ form_row(form.proficiencyBonus) }}
+
{{ form_row(form.inspiration) }}
+
+
+
+
+ +
+
+
Ability scores & proficiencies
+
+
+
{{ form_row(form.strength) }}
+
{{ form_row(form.dexterity) }}
+
{{ form_row(form.constitution) }}
+
{{ form_row(form.intelligence) }}
+
{{ form_row(form.wisdom) }}
+
{{ form_row(form.charisma) }}
+
+
+
{{ form_row(form.savingThrowProficiencies) }}
+
{{ form_row(form.skillProficiencies) }}
+
+
+
+
+ +
+
+
Attacks & spellcasting
+
+
+ + + + + + {% for i in 0..5 %} + {% set atk = character.attacks[i] ?? null %} + + + + + + + {% endfor %} + +
NameAtk bonusDamage/typeRange
+
+
Blank rows are dropped when you save.
+ {{ form_row(form.spellcasting) }} +
+
+
+ +
+
+
Traits & equipment
+
+ {{ form_row(form.featuresTraits) }} + {{ form_row(form.otherProficienciesLanguages) }} + {{ form_row(form.equipment) }} +
+
+
+ +
+
+
Personality
+
+ {{ form_row(form.personalityTraits) }} + {{ form_row(form.ideals) }} + {{ form_row(form.bonds) }} + {{ form_row(form.flaws) }} +
+
+
+ +
+
+
Appearance & backstory
+
+ {{ form_row(form.appearance) }} + {{ form_row(form.backstory) }} + {{ form_row(form.alliesOrganizations) }} + {{ form_row(form.treasure) }} +
+
+
+
+ +
+ + Cancel +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/characters/index.html.twig b/templates/characters/index.html.twig index 94e7fa7..4c0460a 100644 --- a/templates/characters/index.html.twig +++ b/templates/characters/index.html.twig @@ -3,9 +3,35 @@ {% block title %}Characters · dmtools{% endblock %} {% block body %} - {{ include('_partials/page_placeholder.html.twig', { - title: 'Characters', - icon: '🧝', - blurb: 'Player characters will live here — list, sheet details and party overview. Data model to be designed.', - }) }} +
+

Characters

+ Add character +
+ + {% if characters is empty %} +
+
+ No party members yet. Use Add character to bring in a sheet. +
+
+ {% else %} + + {% endif %} {% endblock %} diff --git a/templates/characters/show.html.twig b/templates/characters/show.html.twig new file mode 100644 index 0000000..88db137 --- /dev/null +++ b/templates/characters/show.html.twig @@ -0,0 +1,56 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ character.name }} · Characters · dmtools{% endblock %} + +{% import 'characters/_sheet.html.twig' as cs %} + +{% block body %} +
+
+ ← All characters +

{{ character.name }}

+ {{ character.metaLine ?: '—' }} + {% if character.playerName %} +
Played by {{ character.playerName }}
+ {% endif %} +
+
+ Edit sheet +
+ + +
+
+
+ +
+
+
+ {{ character.name }} +
+
+
+ {{ cs.block(character) }} +
+
+
+ +
+
+
+ Game notes + {% if character.updatedAt %} + saved {{ character.updatedAt|date('Y-m-d H:i') }} + {% endif %} +
+
+ {{ form_start(form) }} + {{ form_row(form.gameNotes) }} + + {{ form_end(form) }} +
+
+
+
+{% endblock %} diff --git a/templates/enemy/_statblock.html.twig b/templates/enemy/_statblock.html.twig index 66015a3..8b233f9 100644 --- a/templates/enemy/_statblock.html.twig +++ b/templates/enemy/_statblock.html.twig @@ -1,6 +1,7 @@ {# # 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 %} # {{ sb.block(enemy) }} #} diff --git a/tests/Character/CharacterTemplatesTest.php b/tests/Character/CharacterTemplatesTest.php new file mode 100644 index 0000000..3e82acc --- /dev/null +++ b/tests/Character/CharacterTemplatesTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/tests/Controller/CharacterControllerTest.php b/tests/Controller/CharacterControllerTest.php new file mode 100644 index 0000000..df8427d --- /dev/null +++ b/tests/Controller/CharacterControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/characters'); + + self::assertResponseRedirects('/login'); + } +} diff --git a/tests/Entity/CharacterTest.php b/tests/Entity/CharacterTest.php new file mode 100644 index 0000000..8d76765 --- /dev/null +++ b/tests/Entity/CharacterTest.php @@ -0,0 +1,74 @@ +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()); + } +}