feat(combat): play the round a beat at a time with attack and hit frames

The server resolves a whole round in one call, so both blows used to land
at the same instant. The page now keeps its own view of the combat and
plays the round back: the swing animates, the monster's HP and log line
land, then after a beat the monster strikes and the player recoils.

Both animations are six-frame sprite sheets driven by steps(6), which is
why the phase durations mirror the stylesheet.

The status row reflows on the stage's own width via a container query —
the side rails can squeeze it narrow while the viewport is still wide,
which previously overlapped the round marker with the player's name. The
component-style budget moves to 12kB to fit this screen's stylesheet.
This commit is contained in:
Bastian Wagner
2026-08-19 22:46:17 +02:00
parent 54f9d59ef4
commit 6c4b7d29d0
5 changed files with 265 additions and 52 deletions

View File

@@ -12,9 +12,12 @@
/* ---------- stage ---------- */
// The stage is a container so the status row reflows on its own width: the
// surrounding rails can squeeze it narrow while the viewport is still wide.
.combat__stage {
position: relative;
display: grid;
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
@@ -126,7 +129,7 @@
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0.6));
background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
@@ -186,21 +189,43 @@
.sprite {
display: block;
max-block-size: 100%;
max-inline-size: 100%;
inline-size: auto;
object-fit: contain;
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75));
object-position: bottom;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7));
}
/* Six 384px frames laid out horizontally; frame 0 is the resting stance. */
.sprite--player {
justify-self: start;
block-size: clamp(11rem, 30vh, 19rem);
block-size: 80%;
aspect-ratio: 1;
transform: scaleX(-1);
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: 0% 0;
background-size: 600% 100%;
}
.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
/* 6 frames across a 600%-wide sheet land on 0/20/40/60/80/100%. */
@keyframes warrior-frames {
from {
background-position: 0% 0;
}
to {
background-position: 120% 0;
}
}
.sprite--monster {
justify-self: end;
block-size: clamp(9rem, 24vh, 15rem);
block-size: calc(var(--sprite-scale, 0.6) * 100%);
}
/* ---------- action bar ---------- */
@@ -224,21 +249,23 @@
cursor: pointer;
}
.action__icon {
.action__icon,
.action__label,
.action__key {
position: absolute;
inset-block-start: 32%;
inset-inline-start: 50%;
inline-size: 34%;
translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
position: absolute;
inset-block-start: 62%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
@@ -247,10 +274,7 @@
}
.action__key {
position: absolute;
inset-block-start: 90%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
@@ -317,8 +341,8 @@
font-style: italic;
}
.outcome__button {
margin-block-start: var(--ar-space-2);
.outcome__button,
.combat__notice--error button {
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
@@ -329,7 +353,12 @@
letter-spacing: 0.04em;
}
.outcome__button:hover {
.outcome__button {
margin-block-start: var(--ar-space-2);
}
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
@@ -407,15 +436,14 @@
.combat__notice--error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (prefers-reduced-motion: no-preference) {
.sprite--attacking,
.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
@@ -444,7 +472,9 @@
}
}
@media (width < 40rem) {
// Below this the three-column status row cannot hold two names and two bars
// side by side, so the fighters stack under a centred round marker.
@container (width < 38rem) {
.combat__status {
grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2);
@@ -469,11 +499,7 @@
inset-inline-end: auto;
}
.sprite--player {
block-size: clamp(8rem, 22vh, 12rem);
}
.sprite--monster {
block-size: clamp(7rem, 18vh, 10rem);
.fighter__meter {
max-inline-size: none;
}
}