diff --git a/assets/app.js b/assets/app.js index ca0f83b..b9b938e 100644 --- a/assets/app.js +++ b/assets/app.js @@ -42,6 +42,16 @@ function columnFromTh(th, rowUrl) { const short = s.length > 70 ? s.slice(0, 70) + '…' : s; return `${esc(short)}`; }; + } else if (th.dataset.format === 'pick') { + // a row-select checkbox + quantity, for "add these to the tracker" + col.render = (val, type, row) => { + if (type !== 'display') return ''; + return ` + + + `; + }; } else { col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? '')); } diff --git a/assets/controllers/tracker_picker_controller.js b/assets/controllers/tracker_picker_controller.js new file mode 100644 index 0000000..9c9d879 --- /dev/null +++ b/assets/controllers/tracker_picker_controller.js @@ -0,0 +1,41 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Collects the ticked rows (+ their quantity) from a DataTable and POSTs them + * to the combat tracker. + * + *
+ * + *
+ */ +export default class extends Controller { + static values = { url: String, token: String }; + + submit() { + const picks = [...document.querySelectorAll('.track-check:checked')].map((cb) => { + const qtyEl = cb.closest('.track-pick')?.querySelector('.track-qty'); + const qty = Math.max(1, Math.min(50, parseInt(qtyEl?.value ?? '1', 10) || 1)); + return { id: cb.value, qty }; + }); + + if (picks.length === 0) { + window.alert('Tick at least one enemy first.'); + return; + } + + const form = document.createElement('form'); + form.method = 'post'; + form.action = this.urlValue; + form.insertAdjacentHTML('beforeend', + ``); + picks.forEach((p, i) => { + form.insertAdjacentHTML('beforeend', + `` + + ``); + }); + document.body.appendChild(form); + form.requestSubmit(); + } +} diff --git a/migrations/Version20260906171352.php b/migrations/Version20260906171352.php new file mode 100644 index 0000000..af8cff9 --- /dev/null +++ b/migrations/Version20260906171352.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE combatant (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(120) NOT NULL, max_hp SMALLINT NOT NULL, current_hp SMALLINT NOT NULL, position SMALLINT NOT NULL, added_at DATETIME NOT NULL, enemy_id INT DEFAULT NULL, INDEX IDX_807DA381900C982F (enemy_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); + $this->addSql('ALTER TABLE combatant ADD CONSTRAINT FK_807DA381900C982F FOREIGN KEY (enemy_id) REFERENCES enemy (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE combatant DROP FOREIGN KEY FK_807DA381900C982F'); + $this->addSql('DROP TABLE combatant'); + } +} diff --git a/src/Controller/TrackerController.php b/src/Controller/TrackerController.php new file mode 100644 index 0000000..24b10d6 --- /dev/null +++ b/src/Controller/TrackerController.php @@ -0,0 +1,108 @@ +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'); + } +} diff --git a/src/Entity/Combatant.php b/src/Entity/Combatant.php new file mode 100644 index 0000000..a3115a3 --- /dev/null +++ b/src/Entity/Combatant.php @@ -0,0 +1,140 @@ +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; + } +} diff --git a/src/Repository/CombatantRepository.php b/src/Repository/CombatantRepository.php new file mode 100644 index 0000000..c4cd882 --- /dev/null +++ b/src/Repository/CombatantRepository.php @@ -0,0 +1,38 @@ + + */ +class CombatantRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Combatant::class); + } + + /** @return list */ + 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]); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index eaf10bd..529f370 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -30,6 +30,7 @@ { route: 'app_characters', label: 'Characters' }, { route: 'app_npcs', label: "NPC's" }, { route: 'app_enemies', label: 'Enemies' }, + { route: 'app_tracker', label: 'Tracker' }, { route: 'app_art', label: 'Art' }, { route: 'app_storyline', label: 'Storyline' }, ] %} diff --git a/templates/enemy/index.html.twig b/templates/enemy/index.html.twig index 1252871..d903b33 100644 --- a/templates/enemy/index.html.twig +++ b/templates/enemy/index.html.twig @@ -3,9 +3,17 @@ {% block title %}Enemies · dmtools{% endblock %} {% block body %} -
+

Enemies

- Add enemy +
+ + Add enemy +
@@ -22,6 +30,7 @@ CR AC Game notes + Track diff --git a/templates/tracker/index.html.twig b/templates/tracker/index.html.twig new file mode 100644 index 0000000..d8d289d --- /dev/null +++ b/templates/tracker/index.html.twig @@ -0,0 +1,81 @@ +{% extends 'base.html.twig' %} + +{% block title %}Tracker · dmtools{% endblock %} + +{% block body %} +
+

Combat tracker + {% if combatants %}· {{ combatants|length }}{% endif %} +

+
+ Add from Enemies + {% if combatants %} +
+ + +
+ {% endif %} +
+
+ + {% if not combatants %} +
+
+ No combatants yet. Go to Enemies, tick the ones you want (set a count for + multiples), and choose Add to tracker. +
+
+ {% else %} +
+ {% for c in combatants %} + {% set pct = c.hpPercent %} + {% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %} +
+
+
+
+
+ {{ c.label }} + {% if c.isDown %}Down{% endif %} +
+ {% if c.enemy %} + stat block + {% endif %} +
+ +
+
+ + {{ c.currentHp }} / {{ c.maxHp }} + +
+
+
+
+
+ +
+
+
+ + + +
+
+ + +
+
+
+
+
+
+ {% endfor %} +
+ {% endif %} +{% endblock %}