Add Enemies section: D&D stat-block table + detail/edit pages
- Enemy entity: full 5e stat block (size/type/subtype/alignment, AC, HP,
speed, six ability scores, CR/XP/prof, saves/skills/senses/languages,
damage & condition modifiers, traits/actions/bonus/reactions/legendary,
description, source) plus a DM game-notes field and updated_at.
- CreatureSize / CreatureType enums.
- /enemies: server-side DataTable (name, size, type, CR, AC, HP, notes),
"Add enemy" button.
- /enemies/{id}: stat block rendered read-only + editable game notes.
- /enemies/new and /enemies/{id}/edit: the full stat-block form (no notes).
- "Enemies" nav item between NPC's and Storyline.
- Table left empty on purpose.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Enemy;
|
||||
use App\Form\EnemyGameNotesType;
|
||||
use App\Form\EnemyType;
|
||||
use App\Repository\EnemyRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class EnemyController extends AbstractController
|
||||
{
|
||||
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('enemy/index.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side processing endpoint for the DataTable on /enemies.
|
||||
*/
|
||||
#[Route('/enemies/data', name: 'app_enemies_data', methods: ['GET'])]
|
||||
public function data(Request $request, EnemyRepository $repo): JsonResponse
|
||||
{
|
||||
$q = $request->query;
|
||||
$draw = (int) $q->get('draw', 1);
|
||||
$start = (int) $q->get('start', 0);
|
||||
$length = (int) $q->get('length', 10);
|
||||
$search = (string) ($q->all('search')['value'] ?? '');
|
||||
|
||||
$columns = $q->all('columns');
|
||||
$order = $q->all('order')[0] ?? ['column' => 0, 'dir' => 'asc'];
|
||||
$sortKey = $columns[(int) ($order['column'] ?? 0)]['data'] ?? 'name';
|
||||
$sortDir = (string) ($order['dir'] ?? 'asc');
|
||||
|
||||
$result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length);
|
||||
|
||||
$data = array_map(static fn (Enemy $e) => [
|
||||
'id' => $e->getId(),
|
||||
'name' => $e->getName(),
|
||||
'size' => $e->getSize()->value,
|
||||
'type' => $e->getType()->value,
|
||||
'challengeRating' => $e->getChallengeRating(),
|
||||
'armorClass' => $e->getArmorClass(),
|
||||
'hitPoints' => $e->getHitPoints(),
|
||||
'gameNotes' => $e->getGameNotes(),
|
||||
'updatedAt' => $e->getUpdatedAt()?->format('Y-m-d H:i'),
|
||||
], $result['data']);
|
||||
|
||||
return $this->json([
|
||||
'draw' => $draw,
|
||||
'recordsTotal' => $result['recordsTotal'],
|
||||
'recordsFiltered' => $result['recordsFiltered'],
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-play detail page: the stat block read-only, plus the game-notes field.
|
||||
*/
|
||||
#[Route('/enemies/{id}', name: 'app_enemies_show', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||
public function show(Request $request, Enemy $enemy, EntityManagerInterface $em): Response
|
||||
{
|
||||
$form = $this->createForm(EnemyGameNotesType::class, $enemy);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Game notes saved.');
|
||||
|
||||
return $this->redirectToRoute('app_enemies_show', ['id' => $enemy->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('enemy/show.html.twig', [
|
||||
'enemy' => $enemy,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/enemies/new', name: 'app_enemies_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
return $this->handleForm($request, $em, new Enemy());
|
||||
}
|
||||
|
||||
#[Route('/enemies/{id}/edit', name: 'app_enemies_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, Enemy $enemy, EntityManagerInterface $em): Response
|
||||
{
|
||||
return $this->handleForm($request, $em, $enemy);
|
||||
}
|
||||
|
||||
private function handleForm(Request $request, EntityManagerInterface $em, Enemy $enemy): Response
|
||||
{
|
||||
$isNew = null === $enemy->getId();
|
||||
$form = $this->createForm(EnemyType::class, $enemy);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($isNew) {
|
||||
$em->persist($enemy);
|
||||
}
|
||||
$em->flush();
|
||||
$this->addFlash('success', sprintf('%s %s.', $isNew ? 'Added' : 'Saved', $enemy->getName()));
|
||||
|
||||
return $this->redirectToRoute('app_enemies_show', ['id' => $enemy->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('enemy/edit.html.twig', [
|
||||
'enemy' => $enemy,
|
||||
'form' => $form,
|
||||
'is_new' => $isNew,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\CreatureSize;
|
||||
use App\Enum\CreatureType;
|
||||
use App\Repository\EnemyRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EnemyRepository::class)]
|
||||
#[ORM\Table(name: 'enemy')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_enemy_name', columns: ['name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Enemy
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
// --- Identity ---------------------------------------------------------
|
||||
|
||||
#[ORM\Column(length: 160)]
|
||||
#[Assert\NotBlank]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(enumType: CreatureSize::class)]
|
||||
private CreatureSize $size = CreatureSize::Medium;
|
||||
|
||||
#[ORM\Column(enumType: CreatureType::class)]
|
||||
private CreatureType $type = CreatureType::Humanoid;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
private ?string $subtype = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
private ?string $alignment = null;
|
||||
|
||||
// --- Defenses / core ------------------------------------------------
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
#[Assert\PositiveOrZero]
|
||||
private int $armorClass = 10;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
private ?string $armorClassNote = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
#[Assert\PositiveOrZero]
|
||||
private int $hitPoints = 1;
|
||||
|
||||
#[ORM\Column(length: 60, nullable: true)]
|
||||
private ?string $hitDice = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
private ?string $speed = null;
|
||||
|
||||
// --- 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;
|
||||
|
||||
// --- Challenge ----------------------------------------------------
|
||||
|
||||
/** Fractional for weak creatures ("1/8", "1/4", "1/2"), so a string. */
|
||||
#[ORM\Column(length: 12, nullable: true)]
|
||||
private ?string $challengeRating = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true)]
|
||||
private ?int $experiencePoints = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
private ?int $proficiencyBonus = null;
|
||||
|
||||
// --- Secondary stats (free text) --------------------------------
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
private ?string $savingThrows = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $skills = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $damageVulnerabilities = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $damageResistances = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $damageImmunities = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $conditionImmunities = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $senses = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $languages = null;
|
||||
|
||||
// --- Text blocks ------------------------------------------------
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $traits = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $actions = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $bonusActions = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reactions = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $legendaryActions = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
private ?string $source = null;
|
||||
|
||||
// --- DM ------------------------------------------------------
|
||||
|
||||
/** DM's live notes, written during play. Never touched by an importer. */
|
||||
#[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();
|
||||
}
|
||||
|
||||
/** e.g. "Large Fiend (demon), chaotic evil". */
|
||||
public function getMetaLine(): string
|
||||
{
|
||||
$type = $this->type->value;
|
||||
if ($this->subtype) {
|
||||
$type .= ' ('.$this->subtype.')';
|
||||
}
|
||||
$line = $this->size->value.' '.$type;
|
||||
if ($this->alignment) {
|
||||
$line .= ', '.$this->alignment;
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
public function abilityModifier(int $score): int
|
||||
{
|
||||
return (int) floor(($score - 10) / 2);
|
||||
}
|
||||
|
||||
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 getSize(): CreatureSize
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function setSize(CreatureSize $size): static
|
||||
{
|
||||
$this->size = $size;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): CreatureType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(CreatureType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubtype(): ?string
|
||||
{
|
||||
return $this->subtype;
|
||||
}
|
||||
|
||||
public function setSubtype(?string $subtype): static
|
||||
{
|
||||
$this->subtype = $subtype;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlignment(): ?string
|
||||
{
|
||||
return $this->alignment;
|
||||
}
|
||||
|
||||
public function setAlignment(?string $alignment): static
|
||||
{
|
||||
$this->alignment = $alignment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArmorClass(): int
|
||||
{
|
||||
return $this->armorClass;
|
||||
}
|
||||
|
||||
public function setArmorClass(int $armorClass): static
|
||||
{
|
||||
$this->armorClass = $armorClass;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArmorClassNote(): ?string
|
||||
{
|
||||
return $this->armorClassNote;
|
||||
}
|
||||
|
||||
public function setArmorClassNote(?string $armorClassNote): static
|
||||
{
|
||||
$this->armorClassNote = $armorClassNote;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHitPoints(): int
|
||||
{
|
||||
return $this->hitPoints;
|
||||
}
|
||||
|
||||
public function setHitPoints(int $hitPoints): static
|
||||
{
|
||||
$this->hitPoints = $hitPoints;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHitDice(): ?string
|
||||
{
|
||||
return $this->hitDice;
|
||||
}
|
||||
|
||||
public function setHitDice(?string $hitDice): static
|
||||
{
|
||||
$this->hitDice = $hitDice;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSpeed(): ?string
|
||||
{
|
||||
return $this->speed;
|
||||
}
|
||||
|
||||
public function setSpeed(?string $speed): static
|
||||
{
|
||||
$this->speed = $speed;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getChallengeRating(): ?string
|
||||
{
|
||||
return $this->challengeRating;
|
||||
}
|
||||
|
||||
public function setChallengeRating(?string $challengeRating): static
|
||||
{
|
||||
$this->challengeRating = $challengeRating;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExperiencePoints(): ?int
|
||||
{
|
||||
return $this->experiencePoints;
|
||||
}
|
||||
|
||||
public function setExperiencePoints(?int $experiencePoints): static
|
||||
{
|
||||
$this->experiencePoints = $experiencePoints;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProficiencyBonus(): ?int
|
||||
{
|
||||
return $this->proficiencyBonus;
|
||||
}
|
||||
|
||||
public function setProficiencyBonus(?int $proficiencyBonus): static
|
||||
{
|
||||
$this->proficiencyBonus = $proficiencyBonus;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSavingThrows(): ?string
|
||||
{
|
||||
return $this->savingThrows;
|
||||
}
|
||||
|
||||
public function setSavingThrows(?string $savingThrows): static
|
||||
{
|
||||
$this->savingThrows = $savingThrows;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkills(): ?string
|
||||
{
|
||||
return $this->skills;
|
||||
}
|
||||
|
||||
public function setSkills(?string $skills): static
|
||||
{
|
||||
$this->skills = $skills;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDamageVulnerabilities(): ?string
|
||||
{
|
||||
return $this->damageVulnerabilities;
|
||||
}
|
||||
|
||||
public function setDamageVulnerabilities(?string $damageVulnerabilities): static
|
||||
{
|
||||
$this->damageVulnerabilities = $damageVulnerabilities;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDamageResistances(): ?string
|
||||
{
|
||||
return $this->damageResistances;
|
||||
}
|
||||
|
||||
public function setDamageResistances(?string $damageResistances): static
|
||||
{
|
||||
$this->damageResistances = $damageResistances;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDamageImmunities(): ?string
|
||||
{
|
||||
return $this->damageImmunities;
|
||||
}
|
||||
|
||||
public function setDamageImmunities(?string $damageImmunities): static
|
||||
{
|
||||
$this->damageImmunities = $damageImmunities;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConditionImmunities(): ?string
|
||||
{
|
||||
return $this->conditionImmunities;
|
||||
}
|
||||
|
||||
public function setConditionImmunities(?string $conditionImmunities): static
|
||||
{
|
||||
$this->conditionImmunities = $conditionImmunities;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSenses(): ?string
|
||||
{
|
||||
return $this->senses;
|
||||
}
|
||||
|
||||
public function setSenses(?string $senses): static
|
||||
{
|
||||
$this->senses = $senses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLanguages(): ?string
|
||||
{
|
||||
return $this->languages;
|
||||
}
|
||||
|
||||
public function setLanguages(?string $languages): static
|
||||
{
|
||||
$this->languages = $languages;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTraits(): ?string
|
||||
{
|
||||
return $this->traits;
|
||||
}
|
||||
|
||||
public function setTraits(?string $traits): static
|
||||
{
|
||||
$this->traits = $traits;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActions(): ?string
|
||||
{
|
||||
return $this->actions;
|
||||
}
|
||||
|
||||
public function setActions(?string $actions): static
|
||||
{
|
||||
$this->actions = $actions;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBonusActions(): ?string
|
||||
{
|
||||
return $this->bonusActions;
|
||||
}
|
||||
|
||||
public function setBonusActions(?string $bonusActions): static
|
||||
{
|
||||
$this->bonusActions = $bonusActions;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReactions(): ?string
|
||||
{
|
||||
return $this->reactions;
|
||||
}
|
||||
|
||||
public function setReactions(?string $reactions): static
|
||||
{
|
||||
$this->reactions = $reactions;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLegendaryActions(): ?string
|
||||
{
|
||||
return $this->legendaryActions;
|
||||
}
|
||||
|
||||
public function setLegendaryActions(?string $legendaryActions): static
|
||||
{
|
||||
$this->legendaryActions = $legendaryActions;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSource(): ?string
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function setSource(?string $source): static
|
||||
{
|
||||
$this->source = $source;
|
||||
|
||||
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,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum CreatureSize: string
|
||||
{
|
||||
case Tiny = 'Tiny';
|
||||
case Small = 'Small';
|
||||
case Medium = 'Medium';
|
||||
case Large = 'Large';
|
||||
case Huge = 'Huge';
|
||||
case Gargantuan = 'Gargantuan';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum CreatureType: string
|
||||
{
|
||||
case Aberration = 'Aberration';
|
||||
case Beast = 'Beast';
|
||||
case Celestial = 'Celestial';
|
||||
case Construct = 'Construct';
|
||||
case Dragon = 'Dragon';
|
||||
case Elemental = 'Elemental';
|
||||
case Fey = 'Fey';
|
||||
case Fiend = 'Fiend';
|
||||
case Giant = 'Giant';
|
||||
case Humanoid = 'Humanoid';
|
||||
case Monstrosity = 'Monstrosity';
|
||||
case Ooze = 'Ooze';
|
||||
case Plant = 'Plant';
|
||||
case Undead = 'Undead';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Enemy;
|
||||
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 Enemy detail page: the DM's live play notes.
|
||||
*/
|
||||
class EnemyGameNotesType 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 enemy played at the table…'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults(['data_class' => Enemy::class]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Enemy;
|
||||
use App\Enum\CreatureSize;
|
||||
use App\Enum\CreatureType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
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;
|
||||
|
||||
class EnemyType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$enum = static fn (string $class) => [
|
||||
'class' => $class,
|
||||
'choice_label' => static fn ($case) => $case->value,
|
||||
];
|
||||
$opt = ['required' => false];
|
||||
$ability = ['attr' => ['min' => 1, 'max' => 30]];
|
||||
$block = ['required' => false, 'attr' => ['rows' => 3]];
|
||||
|
||||
$builder
|
||||
// Identity
|
||||
->add('name', TextType::class)
|
||||
->add('size', EnumType::class, $enum(CreatureSize::class))
|
||||
->add('type', EnumType::class, $enum(CreatureType::class))
|
||||
->add('subtype', TextType::class, $opt + ['help' => 'e.g. goblinoid, shapechanger'])
|
||||
->add('alignment', TextType::class, $opt + ['help' => 'e.g. chaotic evil, unaligned'])
|
||||
// Defenses
|
||||
->add('armorClass', IntegerType::class)
|
||||
->add('armorClassNote', TextType::class, $opt + ['label' => 'AC note', 'help' => 'e.g. natural armor'])
|
||||
->add('hitPoints', IntegerType::class)
|
||||
->add('hitDice', TextType::class, $opt + ['help' => 'e.g. 7d8 + 14'])
|
||||
->add('speed', TextType::class, $opt + ['help' => 'e.g. 30 ft., fly 60 ft.'])
|
||||
// Abilities
|
||||
->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)
|
||||
// Challenge
|
||||
->add('challengeRating', TextType::class, $opt + ['label' => 'CR', 'help' => 'e.g. 1/4, 5'])
|
||||
->add('experiencePoints', IntegerType::class, $opt + ['label' => 'XP'])
|
||||
->add('proficiencyBonus', IntegerType::class, $opt)
|
||||
// Secondary stats
|
||||
->add('savingThrows', TextType::class, $opt + ['help' => 'e.g. Dex +5, Con +6'])
|
||||
->add('skills', TextType::class, $opt + ['help' => 'e.g. Perception +4, Stealth +6'])
|
||||
->add('damageVulnerabilities', TextType::class, $opt)
|
||||
->add('damageResistances', TextType::class, $opt)
|
||||
->add('damageImmunities', TextType::class, $opt)
|
||||
->add('conditionImmunities', TextType::class, $opt)
|
||||
->add('senses', TextType::class, $opt + ['help' => 'e.g. darkvision 60 ft., passive Perception 12'])
|
||||
->add('languages', TextType::class, $opt)
|
||||
// Text blocks
|
||||
->add('traits', TextareaType::class, $block + ['help' => 'Special abilities'])
|
||||
->add('actions', TextareaType::class, $block)
|
||||
->add('bonusActions', TextareaType::class, $block)
|
||||
->add('reactions', TextareaType::class, $block)
|
||||
->add('legendaryActions', TextareaType::class, $block)
|
||||
->add('description', TextareaType::class, ['required' => false, 'attr' => ['rows' => 4], 'help' => 'Lore / flavour'])
|
||||
->add('source', TextType::class, $opt + ['help' => 'e.g. Monster Manual p. 195, homebrew']);
|
||||
// gameNotes lives on the detail page, not this form.
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults(['data_class' => Enemy::class]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Enemy;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Enemy>
|
||||
*/
|
||||
class EnemyRepository extends ServiceEntityRepository
|
||||
{
|
||||
/** Fields the DataTable may sort on (column key => DQL field). */
|
||||
public const SORTABLE = [
|
||||
'name' => 'e.name',
|
||||
'size' => 'e.size',
|
||||
'type' => 'e.type',
|
||||
'challengeRating' => 'e.challengeRating',
|
||||
'armorClass' => 'e.armorClass',
|
||||
'hitPoints' => 'e.hitPoints',
|
||||
'updatedAt' => 'e.updatedAt',
|
||||
];
|
||||
|
||||
/** Fields the global search box matches against (LIKE, OR-ed). */
|
||||
private const SEARCHABLE = [
|
||||
'e.name', 'e.type', 'e.subtype', 'e.alignment', 'e.challengeRating',
|
||||
'e.traits', 'e.actions', 'e.description', 'e.gameNotes', 'e.source',
|
||||
];
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Enemy::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data: list<Enemy>, recordsTotal: int, recordsFiltered: int}
|
||||
*/
|
||||
public function findForDataTable(
|
||||
?string $search,
|
||||
string $sortKey,
|
||||
string $sortDir,
|
||||
int $start,
|
||||
int $length,
|
||||
): array {
|
||||
$sortField = self::SORTABLE[$sortKey] ?? 'e.name';
|
||||
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
|
||||
$length = $length < 0 ? 200 : min($length, 200);
|
||||
|
||||
$qb = $this->createQueryBuilder('e');
|
||||
|
||||
if (null !== $search && '' !== trim($search)) {
|
||||
$ors = [];
|
||||
foreach (self::SEARCHABLE as $i => $field) {
|
||||
$ors[] = $field.' LIKE :s'.$i;
|
||||
$qb->setParameter('s'.$i, '%'.trim($search).'%');
|
||||
}
|
||||
$qb->andWhere('('.implode(' OR ', $ors).')');
|
||||
}
|
||||
|
||||
$filtered = (int) (clone $qb)->select('COUNT(e.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$rows = $qb
|
||||
->orderBy($sortField, $sortDir)
|
||||
->addOrderBy('e.name', 'ASC')
|
||||
->setFirstResult(max(0, $start))
|
||||
->setMaxResults($length)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'recordsTotal' => $this->count([]),
|
||||
'recordsFiltered' => $filtered,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user