Files
ashen-realms/apps/web/src/app/features/combat/combat-page/combat-page.component.scss
Bastian Wagner 6c4b7d29d0 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.
2026-08-19 22:46:17 +02:00

506 lines
10 KiB
SCSS

:host {
display: block;
block-size: 100%;
}
.combat {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 17rem);
gap: var(--ar-space-4);
block-size: 100%;
}
/* ---------- 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;
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: var(--ar-bg);
background-image: url('/images/backgrounds/Aschestrasse.png');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
@supports (
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
)
) {
.combat__stage {
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
);
}
}
.combat__stage::before {
position: absolute;
z-index: 0;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgb(4 6 8 / 0.72) 0%, rgb(4 6 8 / 0.1) 26%, rgb(4 6 8 / 0.66) 100%);
pointer-events: none;
}
/* ---------- combatant status ---------- */
.combat__status {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: var(--ar-space-4);
}
.fighter {
display: flex;
align-items: center;
gap: var(--ar-space-3);
min-inline-size: 0;
}
.fighter--monster {
justify-content: flex-end;
}
.fighter__icon {
flex: 0 0 auto;
inline-size: clamp(2.75rem, 5vw, 3.75rem);
block-size: clamp(2.75rem, 5vw, 3.75rem);
object-fit: cover;
border-radius: 50%;
box-shadow:
0 0 0 1px var(--ar-border-highlight),
0 0.3rem 0.9rem rgb(0 0 0 / 0.8);
}
.fighter__meter {
display: grid;
gap: 0.3rem;
min-inline-size: 0;
flex: 1 1 auto;
max-inline-size: 22rem;
}
.fighter--monster .fighter__meter {
justify-items: end;
text-align: end;
}
.fighter__name {
margin: 0;
overflow: hidden;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.5vw, 1.15rem);
letter-spacing: 0.02em;
text-overflow: ellipsis;
text-shadow: 0 0.1rem 0.5rem rgb(0 0 0 / 0.95);
white-space: nowrap;
}
.fighter__level {
color: var(--ar-gold);
font-family: system-ui, sans-serif;
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ---------- hp bars ---------- */
.bar {
position: relative;
display: block;
inline-size: 100%;
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
.bar__fill {
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
display: block;
background: linear-gradient(180deg, #d0564a, #8e2b23);
}
.bar--monster .bar__fill {
inset-inline-start: auto;
inset-inline-end: 0;
}
.bar__text {
position: relative;
z-index: 1;
display: grid;
block-size: 100%;
place-items: center;
color: var(--ar-text);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-shadow: 0 0.05rem 0.25rem rgb(0 0 0 / 1);
}
/* ---------- round marker ---------- */
.combat__round {
margin: 0;
align-self: center;
padding: var(--ar-space-1) var(--ar-space-5);
border-block: 1px solid var(--ar-border-highlight);
color: var(--ar-gold);
background: linear-gradient(90deg, transparent, rgb(9 11 13 / 0.9) 18%, rgb(9 11 13 / 0.9) 82%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.4vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
}
/* ---------- battlefield ---------- */
.combat__field {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
min-block-size: 0;
}
.sprite {
display: block;
max-inline-size: 100%;
inline-size: auto;
object-fit: contain;
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: 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: calc(var(--sprite-scale, 0.6) * 100%);
}
/* ---------- action bar ---------- */
.combat__actions {
position: relative;
z-index: 2;
display: flex;
gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
}
.action {
position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem);
aspect-ratio: 1;
padding: 0;
border: 0;
background: transparent url('/images/hud/runtime/fight-action-frame-360.png') center / 100% 100%
no-repeat;
cursor: pointer;
}
.action__icon,
.action__label,
.action__key {
position: absolute;
inset-inline-start: 50%;
translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
inset-block-start: 62%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
letter-spacing: 0.03em;
white-space: nowrap;
}
.action__key {
inset-block-start: 90%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.action:hover:not(:disabled) .action__label,
.action:focus-visible .action__label {
color: var(--ar-gold);
}
.action:focus-visible {
outline: 2px solid var(--ar-gold);
outline-offset: 3px;
}
.action:disabled {
cursor: progress;
opacity: 0.55;
}
/* ---------- outcome overlay ---------- */
.outcome {
position: absolute;
z-index: 3;
inset-block-start: 50%;
inset-inline-start: 50%;
display: grid;
gap: var(--ar-space-2);
justify-items: center;
inline-size: min(26rem, 80%);
padding: var(--ar-space-5);
translate: -50% -50%;
border: 1px solid var(--ar-border-highlight);
background: rgb(9 11 13 / 0.94);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.outcome p {
margin: 0;
color: var(--ar-text-muted);
}
.outcome__title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
.outcome--lost .outcome__title {
color: var(--ar-danger);
}
.outcome__hint {
font-size: var(--ar-font-sm);
font-style: italic;
}
.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);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.04em;
}
.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);
}
/* ---------- combat log ---------- */
.combat__log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel-muted);
box-shadow: var(--ar-shadow-raised);
}
.combat__log-title {
margin: 0;
padding: var(--ar-space-3) var(--ar-space-4);
border-block-end: 1px solid var(--ar-border);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-body {
padding: var(--ar-space-3) var(--ar-space-4) var(--ar-space-4);
overflow-y: auto;
}
.combat__log-round {
margin: var(--ar-space-4) 0 var(--ar-space-2);
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-round:first-child {
margin-block-start: 0;
}
.combat__log-entry {
margin: 0 0 var(--ar-space-1);
padding-inline-start: var(--ar-space-3);
border-inline-start: 2px solid var(--ar-danger);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.45;
}
.combat__log-entry--player {
border-inline-start-color: var(--ar-border-highlight);
}
/* ---------- notices ---------- */
.combat__notice {
grid-column: 1 / -1;
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.combat__notice--error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
border-color: var(--ar-danger);
}
.combat__notice--error button {
flex: 0 0 auto;
}
@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);
}
.action__label {
transition: color var(--ar-motion-fast);
}
}
@media (width < 60rem) {
:host,
.combat {
block-size: auto;
}
.combat {
grid-template-columns: minmax(0, 1fr);
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
}
.combat__log {
max-block-size: 18rem;
}
}
// 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);
}
.combat__round {
order: -1;
justify-self: center;
}
.fighter--monster {
justify-content: flex-start;
}
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}
.bar--monster .bar__fill {
inset-inline-start: 0;
inset-inline-end: auto;
}
.fighter__meter {
max-inline-size: none;
}
}