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:
Frank
2026-09-06 19:16:12 +02:00
co-authored by Claude Sonnet 5
parent e417ff9655
commit 9854e6c034
9 changed files with 461 additions and 2 deletions
+140
View File
@@ -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;
}
}