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:
@@ -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 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.',
|
||||
}) }}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">Characters</h1>
|
||||
<a href="{{ path('app_characters_new') }}" class="btn btn-primary btn-sm">Add character</a>
|
||||
</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user