Combat tracker: multiple named encounters
- Encounter entity; Combatant now belongs to an encounter (ON DELETE
CASCADE). Migration adds encounter_id, rehoming any existing combatants.
- /tracker lists encounters (name, count, last updated) with a "New
encounter" form; /tracker/{id} is the HP tracker for one encounter.
- Encounters can be renamed, have their combatants cleared, or be deleted.
- The Enemies list gains an encounter picker next to "Add to tracker"
(existing encounter or "— New encounter —", which prompts for a name);
an encounter's "Add enemies" link pre-selects it via ?encounter=.
- Routes for combatant HP/remove moved under /tracker/combatant/ to keep
the numeric {id} for encounters.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,11 +25,21 @@ export default class extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const select = this.element.querySelector('.encounter-select');
|
||||||
|
let encounter = select ? select.value : 'new';
|
||||||
|
let name = '';
|
||||||
|
if (encounter === 'new') {
|
||||||
|
name = window.prompt('Name for the new encounter', 'Encounter');
|
||||||
|
if (name === null) return; // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'post';
|
form.method = 'post';
|
||||||
form.action = this.urlValue;
|
form.action = this.urlValue;
|
||||||
form.insertAdjacentHTML('beforeend',
|
form.insertAdjacentHTML('beforeend',
|
||||||
`<input type="hidden" name="_token" value="${this.tokenValue}">`);
|
`<input type="hidden" name="_token" value="${this.tokenValue}">` +
|
||||||
|
`<input type="hidden" name="encounter" value="${encounter}">` +
|
||||||
|
`<input type="hidden" name="name" value="${name.replace(/"/g, '"')}">`);
|
||||||
picks.forEach((p, i) => {
|
picks.forEach((p, i) => {
|
||||||
form.insertAdjacentHTML('beforeend',
|
form.insertAdjacentHTML('beforeend',
|
||||||
`<input type="hidden" name="picks[${i}][id]" value="${p.id}">` +
|
`<input type="hidden" name="picks[${i}][id]" value="${p.id}">` +
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260906172225 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add encounter table; combatants belong to an encounter';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE encounter (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||||
|
|
||||||
|
// add nullable first, rehome any pre-existing combatants, then enforce
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD encounter_id INT DEFAULT NULL');
|
||||||
|
$this->addSql("INSERT INTO encounter (name, created_at, updated_at) SELECT 'Combat', NOW(), NOW() FROM DUAL WHERE EXISTS (SELECT 1 FROM combatant WHERE encounter_id IS NULL)");
|
||||||
|
$this->addSql('UPDATE combatant SET encounter_id = (SELECT MAX(id) FROM encounter) WHERE encounter_id IS NULL');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE combatant ADD CONSTRAINT FK_807DA381D6E2FADC FOREIGN KEY (encounter_id) REFERENCES encounter (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_807DA381D6E2FADC ON combatant (encounter_id)');
|
||||||
|
$this->addSql('ALTER TABLE combatant CHANGE encounter_id encounter_id INT NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP FOREIGN KEY FK_807DA381D6E2FADC');
|
||||||
|
$this->addSql('DROP INDEX IDX_807DA381D6E2FADC ON combatant');
|
||||||
|
$this->addSql('ALTER TABLE combatant DROP encounter_id');
|
||||||
|
$this->addSql('DROP TABLE encounter');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Entity\Enemy;
|
|||||||
use App\Form\EnemyGameNotesType;
|
use App\Form\EnemyGameNotesType;
|
||||||
use App\Form\EnemyType;
|
use App\Form\EnemyType;
|
||||||
use App\Repository\EnemyRepository;
|
use App\Repository\EnemyRepository;
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -18,9 +19,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class EnemyController extends AbstractController
|
class EnemyController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
|
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
|
||||||
public function index(): Response
|
public function index(Request $request, EncounterRepository $encounters): Response
|
||||||
{
|
{
|
||||||
return $this->render('enemy/index.html.twig');
|
return $this->render('enemy/index.html.twig', [
|
||||||
|
'encounters' => $encounters->recent(),
|
||||||
|
'selected_encounter' => $request->query->get('encounter'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Combatant;
|
use App\Entity\Combatant;
|
||||||
use App\Repository\CombatantRepository;
|
use App\Entity\Encounter;
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
use App\Repository\EnemyRepository;
|
use App\Repository\EnemyRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -16,25 +17,48 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class TrackerController extends AbstractController
|
class TrackerController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/tracker', name: 'app_tracker', methods: ['GET'])]
|
#[Route('/tracker', name: 'app_tracker', methods: ['GET'])]
|
||||||
public function index(CombatantRepository $repo): Response
|
public function index(EncounterRepository $repo): Response
|
||||||
{
|
{
|
||||||
return $this->render('tracker/index.html.twig', [
|
return $this->render('tracker/index.html.twig', [
|
||||||
'combatants' => $repo->tracker(),
|
'encounters' => $repo->recent(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#[Route('/tracker/new', name: 'app_tracker_new', methods: ['POST'])]
|
||||||
* Add selected enemies (with quantities) from the enemy list to the tracker.
|
public function new(Request $request, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
* Body: picks[i][id], picks[i][qty].
|
{
|
||||||
*/
|
if (!$this->isCsrfTokenValid('tracker-new', (string) $request->request->get('_token'))) {
|
||||||
|
return $this->redirectToRoute('app_tracker');
|
||||||
|
}
|
||||||
|
$encounter = new Encounter();
|
||||||
|
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->persist($encounter);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/tracker/add', name: 'app_tracker_add', methods: ['POST'])]
|
#[Route('/tracker/add', name: 'app_tracker_add', methods: ['POST'])]
|
||||||
public function add(Request $request, EnemyRepository $enemies, CombatantRepository $repo, EntityManagerInterface $em): Response
|
public function add(Request $request, EnemyRepository $enemies, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
if (!$this->isCsrfTokenValid('track-add', (string) $request->request->get('_token'))) {
|
if (!$this->isCsrfTokenValid('track-add', (string) $request->request->get('_token'))) {
|
||||||
return $this->redirectToRoute('app_enemies');
|
return $this->redirectToRoute('app_enemies');
|
||||||
}
|
}
|
||||||
|
|
||||||
$pos = $repo->nextPosition();
|
$target = (string) $request->request->get('encounter', 'new');
|
||||||
|
if ('new' === $target || '' === $target) {
|
||||||
|
$encounter = (new Encounter())->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->persist($encounter);
|
||||||
|
} else {
|
||||||
|
$encounter = $repo->find((int) $target);
|
||||||
|
if (!$encounter) {
|
||||||
|
return $this->redirectToRoute('app_enemies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$em->flush(); // so nextPosition / counts see a real id
|
||||||
|
|
||||||
|
$combatantRepo = $em->getRepository(Combatant::class);
|
||||||
|
$pos = $combatantRepo->nextPosition($encounter);
|
||||||
$added = 0;
|
$added = 0;
|
||||||
foreach ($request->request->all('picks') as $pick) {
|
foreach ($request->request->all('picks') as $pick) {
|
||||||
$enemy = isset($pick['id']) ? $enemies->find((int) $pick['id']) : null;
|
$enemy = isset($pick['id']) ? $enemies->find((int) $pick['id']) : null;
|
||||||
@@ -42,32 +66,80 @@ class TrackerController extends AbstractController
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$qty = max(1, min(50, (int) ($pick['qty'] ?? 1)));
|
$qty = max(1, min(50, (int) ($pick['qty'] ?? 1)));
|
||||||
$existing = $repo->countFromEnemy($enemy);
|
$existing = $combatantRepo->countFromEnemy($encounter, $enemy);
|
||||||
|
|
||||||
for ($i = 1; $i <= $qty; ++$i) {
|
for ($i = 1; $i <= $qty; ++$i) {
|
||||||
$n = $existing + $i;
|
$label = ($existing + $qty > 1)
|
||||||
$label = ($existing + $qty > 1) ? sprintf('%s %d', $enemy->getName(), $n) : $enemy->getName();
|
? sprintf('%s %d', $enemy->getName(), $existing + $i)
|
||||||
|
: $enemy->getName();
|
||||||
|
|
||||||
$c = (new Combatant())
|
$encounter->addCombatant(
|
||||||
|
(new Combatant())
|
||||||
->setEnemy($enemy)
|
->setEnemy($enemy)
|
||||||
->setLabel($label)
|
->setLabel($label)
|
||||||
->setMaxHp($enemy->getHitPoints())
|
->setMaxHp($enemy->getHitPoints())
|
||||||
->setCurrentHp($enemy->getHitPoints())
|
->setCurrentHp($enemy->getHitPoints())
|
||||||
->setPosition($pos++);
|
->setPosition($pos++)
|
||||||
$em->persist($c);
|
);
|
||||||
++$added;
|
++$added;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$encounter->touch();
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
if ($added) {
|
if ($added) {
|
||||||
$this->addFlash('success', sprintf('Added %d combatant%s to the tracker.', $added, 1 === $added ? '' : 's'));
|
$this->addFlash('success', sprintf('Added %d combatant%s.', $added, 1 === $added ? '' : 's'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}', name: 'app_tracker_show', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
|
public function show(Encounter $encounter): Response
|
||||||
|
{
|
||||||
|
return $this->render('tracker/show.html.twig', ['encounter' => $encounter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/rename', name: 'app_tracker_rename', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function rename(Request $request, Encounter $encounter, EncounterRepository $repo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/clear', name: 'app_tracker_clear', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function clear(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
foreach ($encounter->getCombatants()->toArray() as $c) {
|
||||||
|
$encounter->removeCombatant($c);
|
||||||
|
$em->remove($c);
|
||||||
|
}
|
||||||
|
$encounter->touch();
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Combatants cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/tracker/{id}/delete', name: 'app_tracker_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function delete(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$em->remove($encounter);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Deleted "%s".', $encounter->getName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_tracker');
|
return $this->redirectToRoute('app_tracker');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/tracker/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
|
#[Route('/tracker/combatant/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
public function hp(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
public function hp(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
$amount = abs((int) $request->request->get('amount', 0));
|
$amount = abs((int) $request->request->get('amount', 0));
|
||||||
@@ -75,34 +147,33 @@ class TrackerController extends AbstractController
|
|||||||
'heal' === $request->request->get('mode')
|
'heal' === $request->request->get('mode')
|
||||||
? $combatant->applyHealing($amount)
|
? $combatant->applyHealing($amount)
|
||||||
: $combatant->applyDamage($amount);
|
: $combatant->applyDamage($amount);
|
||||||
|
$combatant->getEncounter()?->touch();
|
||||||
$em->flush();
|
$em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_tracker');
|
return $this->redirectToRoute('app_tracker_show', ['id' => $combatant->getEncounter()?->getId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/tracker/{id}/remove', name: 'app_tracker_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
|
#[Route('/tracker/combatant/{id}/remove', name: 'app_tracker_combatant_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
public function remove(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
public function removeCombatant(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
|
$encounterId = $combatant->getEncounter()?->getId();
|
||||||
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||||
|
$combatant->getEncounter()?->touch();
|
||||||
$em->remove($combatant);
|
$em->remove($combatant);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_tracker');
|
return $this->redirectToRoute('app_tracker_show', ['id' => $encounterId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/tracker/clear', name: 'app_tracker_clear', methods: ['POST'])]
|
private function cleanName(mixed $raw, EncounterRepository $repo): string
|
||||||
public function clear(Request $request, CombatantRepository $repo, EntityManagerInterface $em): Response
|
|
||||||
{
|
{
|
||||||
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
$name = trim((string) $raw);
|
||||||
foreach ($repo->findAll() as $c) {
|
if ('' !== $name) {
|
||||||
$em->remove($c);
|
return mb_substr($name, 0, 120);
|
||||||
}
|
|
||||||
$em->flush();
|
|
||||||
$this->addFlash('success', 'Tracker cleared.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_tracker');
|
return 'Encounter '.(\count($repo->findAll()) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class Combatant
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Encounter::class, inversedBy: 'combatants')]
|
||||||
|
#[ORM\JoinColumn(name: 'encounter_id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Encounter $encounter = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Enemy::class)]
|
#[ORM\ManyToOne(targetEntity: Enemy::class)]
|
||||||
#[ORM\JoinColumn(name: 'enemy_id', onDelete: 'SET NULL', nullable: true)]
|
#[ORM\JoinColumn(name: 'enemy_id', onDelete: 'SET NULL', nullable: true)]
|
||||||
private ?Enemy $enemy = null;
|
private ?Enemy $enemy = null;
|
||||||
@@ -73,6 +77,18 @@ class Combatant
|
|||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEncounter(): ?Encounter
|
||||||
|
{
|
||||||
|
return $this->encounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEncounter(?Encounter $encounter): static
|
||||||
|
{
|
||||||
|
$this->encounter = $encounter;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getEnemy(): ?Enemy
|
public function getEnemy(): ?Enemy
|
||||||
{
|
{
|
||||||
return $this->enemy;
|
return $this->enemy;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EncounterRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named combat: a list of combatants with their HP, kept for prep and reuse.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: EncounterRepository::class)]
|
||||||
|
#[ORM\Table(name: 'encounter')]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class Encounter
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/** @var Collection<int, Combatant> */
|
||||||
|
#[ORM\OneToMany(mappedBy: 'encounter', targetEntity: Combatant::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['position' => 'ASC', 'id' => 'ASC'])]
|
||||||
|
private Collection $combatants;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
$this->combatants = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Combatant> */
|
||||||
|
public function getCombatants(): Collection
|
||||||
|
{
|
||||||
|
return $this->combatants;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCombatant(Combatant $combatant): static
|
||||||
|
{
|
||||||
|
if (!$this->combatants->contains($combatant)) {
|
||||||
|
$this->combatants->add($combatant);
|
||||||
|
$combatant->setEncounter($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCombatant(Combatant $combatant): static
|
||||||
|
{
|
||||||
|
$this->combatants->removeElement($combatant);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\Combatant;
|
use App\Entity\Combatant;
|
||||||
|
use App\Entity\Encounter;
|
||||||
use App\Entity\Enemy;
|
use App\Entity\Enemy;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -17,22 +18,17 @@ class CombatantRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, Combatant::class);
|
parent::__construct($registry, Combatant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<Combatant> */
|
public function nextPosition(Encounter $encounter): int
|
||||||
public function tracker(): array
|
|
||||||
{
|
|
||||||
return $this->findBy([], ['position' => 'ASC', 'id' => 'ASC']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function nextPosition(): int
|
|
||||||
{
|
{
|
||||||
return 1 + (int) $this->createQueryBuilder('c')
|
return 1 + (int) $this->createQueryBuilder('c')
|
||||||
->select('COALESCE(MAX(c.position), 0)')
|
->select('COALESCE(MAX(c.position), 0)')
|
||||||
|
->andWhere('c.encounter = :e')->setParameter('e', $encounter)
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many combatants already come from this enemy (for numbering). */
|
/** How many combatants in this encounter already come from this enemy (for numbering). */
|
||||||
public function countFromEnemy(Enemy $enemy): int
|
public function countFromEnemy(Encounter $encounter, Enemy $enemy): int
|
||||||
{
|
{
|
||||||
return (int) $this->count(['enemy' => $enemy]);
|
return (int) $this->count(['encounter' => $encounter, 'enemy' => $enemy]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Encounter;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Encounter>
|
||||||
|
*/
|
||||||
|
class EncounterRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Encounter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Encounter> most-recent first */
|
||||||
|
public function recent(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['updatedAt' => 'DESC', 'id' => 'DESC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,16 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
||||||
<h1 class="h3 mb-0">Enemies</h1>
|
<h1 class="h3 mb-0">Enemies</h1>
|
||||||
<div class="d-flex gap-2"
|
<div class="d-flex gap-2 align-items-center flex-wrap"
|
||||||
data-controller="tracker-picker"
|
data-controller="tracker-picker"
|
||||||
data-tracker-picker-url-value="{{ path('app_tracker_add') }}"
|
data-tracker-picker-url-value="{{ path('app_tracker_add') }}"
|
||||||
data-tracker-picker-token-value="{{ csrf_token('track-add') }}">
|
data-tracker-picker-token-value="{{ csrf_token('track-add') }}">
|
||||||
|
<select class="form-select form-select-sm encounter-select" style="width: auto;" aria-label="Encounter">
|
||||||
|
<option value="new"{{ selected_encounter is null ? ' selected' }}>— New encounter —</option>
|
||||||
|
{% for e in encounters %}
|
||||||
|
<option value="{{ e.id }}"{{ selected_encounter == e.id ? ' selected' }}>{{ e.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-action="tracker-picker#submit">
|
<button type="button" class="btn btn-outline-primary btn-sm" data-action="tracker-picker#submit">
|
||||||
Add to tracker
|
Add to tracker
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,77 +4,35 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
||||||
<h1 class="h3 mb-0">Combat tracker
|
<h1 class="h3 mb-0">Encounters</h1>
|
||||||
{% if combatants %}<span class="text-body-secondary fs-6">· {{ combatants|length }}</span>{% endif %}
|
<form method="post" action="{{ path('app_tracker_new') }}" class="d-flex gap-2">
|
||||||
</h1>
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-new') }}">
|
||||||
<div class="d-flex gap-2">
|
<input type="text" name="name" class="form-control form-control-sm" style="width: 14rem;"
|
||||||
<a href="{{ path('app_enemies') }}" class="btn btn-outline-secondary btn-sm">Add from Enemies</a>
|
placeholder="New encounter name" aria-label="New encounter name">
|
||||||
{% if combatants %}
|
<button class="btn btn-primary btn-sm text-nowrap">New encounter</button>
|
||||||
<form method="post" action="{{ path('app_tracker_clear') }}"
|
|
||||||
onsubmit="return confirm('Clear the whole tracker?')">
|
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
|
||||||
<button class="btn btn-outline-danger btn-sm">Clear all</button>
|
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not combatants %}
|
{% if not encounters %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body text-center text-body-secondary py-5">
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
No combatants yet. Go to <strong>Enemies</strong>, tick the ones you want (set a count for
|
No encounters yet. Name one above, or go to <strong>Enemies</strong>, tick the combatants
|
||||||
multiples), and choose <strong>Add to tracker</strong>.
|
you want and add them to a new encounter.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="list-group shadow-sm">
|
||||||
{% for c in combatants %}
|
{% for e in encounters %}
|
||||||
{% set pct = c.hpPercent %}
|
<a href="{{ path('app_tracker_show', {id: e.id}) }}"
|
||||||
{% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %}
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
<div class="card shadow-sm {{ c.isDown ? 'opacity-75' : '' }}">
|
<span>
|
||||||
<div class="card-body py-2">
|
<span class="fw-semibold">{{ e.name }}</span>
|
||||||
<div class="row align-items-center g-2">
|
<span class="text-body-secondary small">
|
||||||
<div class="col-12 col-md-4">
|
· {{ e.combatants|length }} combatant{{ e.combatants|length == 1 ? '' : 's' }}
|
||||||
<div class="fw-semibold {{ c.isDown ? 'text-decoration-line-through' : '' }}">
|
|
||||||
{{ c.label }}
|
|
||||||
{% if c.isDown %}<span class="badge text-bg-secondary ms-1">Down</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if c.enemy %}
|
|
||||||
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
|
|
||||||
class="small text-decoration-none text-body-secondary">stat block</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-md-4">
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<span class="font-monospace" style="min-width: 5rem;">
|
|
||||||
<span class="fs-5 fw-semibold">{{ c.currentHp }}</span> / {{ c.maxHp }}
|
|
||||||
</span>
|
</span>
|
||||||
<div class="progress flex-grow-1" role="progressbar" style="height: 10px;">
|
</span>
|
||||||
<div class="progress-bar {{ bar }}" style="width: {{ pct }}%"></div>
|
<span class="text-body-secondary small">updated {{ e.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-md-4">
|
|
||||||
<div class="d-flex align-items-center gap-1 justify-content-md-end flex-wrap">
|
|
||||||
<form method="post" action="{{ path('app_tracker_hp', {id: c.id}) }}"
|
|
||||||
class="d-flex align-items-center gap-1">
|
|
||||||
<input type="number" name="amount" min="0" inputmode="numeric"
|
|
||||||
class="form-control form-control-sm" style="width: 4.5rem;"
|
|
||||||
placeholder="HP" aria-label="Amount">
|
|
||||||
<button name="mode" value="damage" class="btn btn-sm btn-outline-danger">Damage</button>
|
|
||||||
<button name="mode" value="heal" class="btn btn-sm btn-outline-success">Heal</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{{ path('app_tracker_remove', {id: c.id}) }}">
|
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" title="Remove" aria-label="Remove">×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ encounter.name }} · Tracker · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<a href="{{ path('app_tracker') }}" class="text-decoration-none small">← All encounters</a>
|
||||||
|
<form method="post" action="{{ path('app_tracker_rename', {id: encounter.id}) }}"
|
||||||
|
class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
||||||
|
<input type="text" name="name" value="{{ encounter.name }}"
|
||||||
|
class="form-control form-control-lg fw-semibold border-0 px-0" style="width: min(20rem, 70vw);"
|
||||||
|
aria-label="Encounter name">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary">Rename</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="{{ path('app_enemies', {encounter: encounter.id}) }}" class="btn btn-outline-secondary btn-sm">Add enemies</a>
|
||||||
|
{% if encounter.combatants|length %}
|
||||||
|
<form method="post" action="{{ path('app_tracker_clear', {id: encounter.id}) }}"
|
||||||
|
onsubmit="return confirm('Remove all combatants from this encounter?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Clear combatants</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ path('app_tracker_delete', {id: encounter.id}) }}"
|
||||||
|
onsubmit="return confirm('Delete the “{{ encounter.name|e('js') }}” encounter?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Delete encounter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not encounter.combatants|length %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-body-secondary py-5">
|
||||||
|
No combatants. Use <strong>Add enemies</strong> to pick some from the list.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for c in encounter.combatants %}
|
||||||
|
{% set pct = c.hpPercent %}
|
||||||
|
{% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %}
|
||||||
|
<div class="card shadow-sm {{ c.isDown ? 'opacity-75' : '' }}">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row align-items-center g-2">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="fw-semibold {{ c.isDown ? 'text-decoration-line-through' : '' }}">
|
||||||
|
{{ c.label }}
|
||||||
|
{% if c.isDown %}<span class="badge text-bg-secondary ms-1">Down</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if c.enemy %}
|
||||||
|
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
|
||||||
|
class="small text-decoration-none text-body-secondary">stat block</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="font-monospace" style="min-width: 5rem;">
|
||||||
|
<span class="fs-5 fw-semibold">{{ c.currentHp }}</span> / {{ c.maxHp }}
|
||||||
|
</span>
|
||||||
|
<div class="progress flex-grow-1" role="progressbar" style="height: 10px;">
|
||||||
|
<div class="progress-bar {{ bar }}" style="width: {{ pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="d-flex align-items-center gap-1 justify-content-md-end flex-wrap">
|
||||||
|
<form method="post" action="{{ path('app_tracker_hp', {id: c.id}) }}"
|
||||||
|
class="d-flex align-items-center gap-1">
|
||||||
|
<input type="number" name="amount" min="0" inputmode="numeric"
|
||||||
|
class="form-control form-control-sm" style="width: 4.5rem;"
|
||||||
|
placeholder="HP" aria-label="Amount">
|
||||||
|
<button name="mode" value="damage" class="btn btn-sm btn-outline-danger">Damage</button>
|
||||||
|
<button name="mode" value="heal" class="btn btn-sm btn-outline-success">Heal</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ path('app_tracker_combatant_remove', {id: c.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title="Remove" aria-label="Remove">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user