Add combat HP tracker
- one running tracker (Combatant rows); no encounters to manage - Enemies list gets a "Track" column (checkbox + count) and an "Add to tracker" button; picking N of an enemy makes "Goblin 1..N", numbering continues if some are already on the tracker - /tracker: per-combatant current/max HP with an HP bar, Damage / Heal by amount, remove one, "Clear all". HP is clamped to [0, max]; 0 HP shows a "Down" badge. Stats are snapshotted from the Enemy on add. - "Tracker" nav item; tracker_picker Stimulus controller; app.js gains a data-format="pick" cell renderer Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Combatant;
|
||||
use App\Repository\CombatantRepository;
|
||||
use App\Repository\EnemyRepository;
|
||||
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;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TrackerController extends AbstractController
|
||||
{
|
||||
#[Route('/tracker', name: 'app_tracker', methods: ['GET'])]
|
||||
public function index(CombatantRepository $repo): Response
|
||||
{
|
||||
return $this->render('tracker/index.html.twig', [
|
||||
'combatants' => $repo->tracker(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add selected enemies (with quantities) from the enemy list to the tracker.
|
||||
* Body: picks[i][id], picks[i][qty].
|
||||
*/
|
||||
#[Route('/tracker/add', name: 'app_tracker_add', methods: ['POST'])]
|
||||
public function add(Request $request, EnemyRepository $enemies, CombatantRepository $repo, EntityManagerInterface $em): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('track-add', (string) $request->request->get('_token'))) {
|
||||
return $this->redirectToRoute('app_enemies');
|
||||
}
|
||||
|
||||
$pos = $repo->nextPosition();
|
||||
$added = 0;
|
||||
foreach ($request->request->all('picks') as $pick) {
|
||||
$enemy = isset($pick['id']) ? $enemies->find((int) $pick['id']) : null;
|
||||
if (!$enemy) {
|
||||
continue;
|
||||
}
|
||||
$qty = max(1, min(50, (int) ($pick['qty'] ?? 1)));
|
||||
$existing = $repo->countFromEnemy($enemy);
|
||||
|
||||
for ($i = 1; $i <= $qty; ++$i) {
|
||||
$n = $existing + $i;
|
||||
$label = ($existing + $qty > 1) ? sprintf('%s %d', $enemy->getName(), $n) : $enemy->getName();
|
||||
|
||||
$c = (new Combatant())
|
||||
->setEnemy($enemy)
|
||||
->setLabel($label)
|
||||
->setMaxHp($enemy->getHitPoints())
|
||||
->setCurrentHp($enemy->getHitPoints())
|
||||
->setPosition($pos++);
|
||||
$em->persist($c);
|
||||
++$added;
|
||||
}
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
if ($added) {
|
||||
$this->addFlash('success', sprintf('Added %d combatant%s to the tracker.', $added, 1 === $added ? '' : 's'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_tracker');
|
||||
}
|
||||
|
||||
#[Route('/tracker/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function hp(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||
{
|
||||
$amount = abs((int) $request->request->get('amount', 0));
|
||||
if ($amount > 0) {
|
||||
'heal' === $request->request->get('mode')
|
||||
? $combatant->applyHealing($amount)
|
||||
: $combatant->applyDamage($amount);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_tracker');
|
||||
}
|
||||
|
||||
#[Route('/tracker/{id}/remove', name: 'app_tracker_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function remove(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
|
||||
{
|
||||
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||
$em->remove($combatant);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_tracker');
|
||||
}
|
||||
|
||||
#[Route('/tracker/clear', name: 'app_tracker_clear', methods: ['POST'])]
|
||||
public function clear(Request $request, CombatantRepository $repo, EntityManagerInterface $em): Response
|
||||
{
|
||||
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
|
||||
foreach ($repo->findAll() as $c) {
|
||||
$em->remove($c);
|
||||
}
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Tracker cleared.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_tracker');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\CombatantRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* One line on the running combat HP tracker. Stats are copied from the Enemy
|
||||
* when added, so editing the Enemy mid-fight doesn't change the tracker.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CombatantRepository::class)]
|
||||
#[ORM\Table(name: 'combatant')]
|
||||
class Combatant
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Enemy::class)]
|
||||
#[ORM\JoinColumn(name: 'enemy_id', onDelete: 'SET NULL', nullable: true)]
|
||||
private ?Enemy $enemy = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
private string $label = '';
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $maxHp = 1;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $currentHp = 1;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $addedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function isDown(): bool
|
||||
{
|
||||
return $this->currentHp <= 0;
|
||||
}
|
||||
|
||||
/** 0-100, for the HP bar. */
|
||||
public function hpPercent(): int
|
||||
{
|
||||
if ($this->maxHp <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round(max(0, min($this->currentHp, $this->maxHp)) / $this->maxHp * 100);
|
||||
}
|
||||
|
||||
public function applyDamage(int $amount): void
|
||||
{
|
||||
$this->currentHp = max(0, $this->currentHp - abs($amount));
|
||||
}
|
||||
|
||||
public function applyHealing(int $amount): void
|
||||
{
|
||||
$this->currentHp = min($this->maxHp, $this->currentHp + abs($amount));
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEnemy(): ?Enemy
|
||||
{
|
||||
return $this->enemy;
|
||||
}
|
||||
|
||||
public function setEnemy(?Enemy $enemy): static
|
||||
{
|
||||
$this->enemy = $enemy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxHp(): int
|
||||
{
|
||||
return $this->maxHp;
|
||||
}
|
||||
|
||||
public function setMaxHp(int $maxHp): static
|
||||
{
|
||||
$this->maxHp = $maxHp;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCurrentHp(): int
|
||||
{
|
||||
return $this->currentHp;
|
||||
}
|
||||
|
||||
public function setCurrentHp(int $currentHp): static
|
||||
{
|
||||
$this->currentHp = $currentHp;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->addedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Combatant;
|
||||
use App\Entity\Enemy;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Combatant>
|
||||
*/
|
||||
class CombatantRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Combatant::class);
|
||||
}
|
||||
|
||||
/** @return list<Combatant> */
|
||||
public function tracker(): array
|
||||
{
|
||||
return $this->findBy([], ['position' => 'ASC', 'id' => 'ASC']);
|
||||
}
|
||||
|
||||
public function nextPosition(): int
|
||||
{
|
||||
return 1 + (int) $this->createQueryBuilder('c')
|
||||
->select('COALESCE(MAX(c.position), 0)')
|
||||
->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/** How many combatants already come from this enemy (for numbering). */
|
||||
public function countFromEnemy(Enemy $enemy): int
|
||||
{
|
||||
return (int) $this->count(['enemy' => $enemy]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user