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:
@@ -42,6 +42,16 @@ function columnFromTh(th, rowUrl) {
|
|||||||
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
|
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
|
||||||
return `<span title="${esc(s)}">${esc(short)}</span>`;
|
return `<span title="${esc(s)}">${esc(short)}</span>`;
|
||||||
};
|
};
|
||||||
|
} 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 `<span class="track-pick d-inline-flex align-items-center gap-2">
|
||||||
|
<input type="checkbox" class="form-check-input track-check" value="${esc(row.id)}" aria-label="Select">
|
||||||
|
<input type="number" class="form-control form-control-sm track-qty" value="1" min="1" max="50"
|
||||||
|
style="width: 3.75rem" aria-label="Count">
|
||||||
|
</span>`;
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
|
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <div data-controller="tracker-picker"
|
||||||
|
* data-tracker-picker-url-value="/tracker/add"
|
||||||
|
* data-tracker-picker-token-value="...">
|
||||||
|
* <button data-action="tracker-picker#submit">Add to tracker</button>
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
`<input type="hidden" name="_token" value="${this.tokenValue}">`);
|
||||||
|
picks.forEach((p, i) => {
|
||||||
|
form.insertAdjacentHTML('beforeend',
|
||||||
|
`<input type="hidden" name="picks[${i}][id]" value="${p.id}">` +
|
||||||
|
`<input type="hidden" name="picks[${i}][qty]" value="${p.qty}">`);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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 Version20260906171352 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create combatant table (running HP tracker)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
{ route: 'app_characters', label: 'Characters' },
|
{ route: 'app_characters', label: 'Characters' },
|
||||||
{ route: 'app_npcs', label: "NPC's" },
|
{ route: 'app_npcs', label: "NPC's" },
|
||||||
{ route: 'app_enemies', label: 'Enemies' },
|
{ route: 'app_enemies', label: 'Enemies' },
|
||||||
|
{ route: 'app_tracker', label: 'Tracker' },
|
||||||
{ route: 'app_art', label: 'Art' },
|
{ route: 'app_art', label: 'Art' },
|
||||||
{ route: 'app_storyline', label: 'Storyline' },
|
{ route: 'app_storyline', label: 'Storyline' },
|
||||||
] %}
|
] %}
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
{% block title %}Enemies · dmtools{% endblock %}
|
{% block title %}Enemies · dmtools{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<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>
|
||||||
<a href="{{ path('app_enemies_new') }}" class="btn btn-primary btn-sm">Add enemy</a>
|
<div class="d-flex gap-2"
|
||||||
|
data-controller="tracker-picker"
|
||||||
|
data-tracker-picker-url-value="{{ path('app_tracker_add') }}"
|
||||||
|
data-tracker-picker-token-value="{{ csrf_token('track-add') }}">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-action="tracker-picker#submit">
|
||||||
|
Add to tracker
|
||||||
|
</button>
|
||||||
|
<a href="{{ path('app_enemies_new') }}" class="btn btn-primary btn-sm">Add enemy</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -22,6 +30,7 @@
|
|||||||
<th data-data="challengeRating">CR</th>
|
<th data-data="challengeRating">CR</th>
|
||||||
<th data-data="armorClass">AC</th>
|
<th data-data="armorClass">AC</th>
|
||||||
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
|
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
|
||||||
|
<th data-data="id" data-format="pick" data-orderable="false">Track</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Tracker · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
|
||||||
|
<h1 class="h3 mb-0">Combat tracker
|
||||||
|
{% if combatants %}<span class="text-body-secondary fs-6">· {{ combatants|length }}</span>{% endif %}
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ path('app_enemies') }}" class="btn btn-outline-secondary btn-sm">Add from Enemies</a>
|
||||||
|
{% if combatants %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not combatants %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<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
|
||||||
|
multiples), and choose <strong>Add to tracker</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for c in 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_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