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:
@@ -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<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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user