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:
Frank
2026-09-22 08:55:28 +02:00
co-authored by Claude Sonnet 5
parent bdf676f1dd
commit 9052218927
18 changed files with 1628 additions and 9 deletions
+29
View File
@@ -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]);
}
}
+91
View File
@@ -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]);
}
}