This commit is contained in:
Bastian Wagner
2026-08-20 09:47:24 +02:00
parent 67237f5ad8
commit b0e769d0da
12 changed files with 3904 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
<section class="combat" aria-label="Kampf">
@if (combat(); as combat) {
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
<header class="combat__status">
<div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" />

View File

@@ -20,7 +20,12 @@
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
// The sprites are sized as a share of the stage, so the stage has to carry a
// height of its own. With only a min-block-size the tallest cut-out -- the
// road bandit -- sized the battlefield row from its intrinsic height and
// pushed the page into a scrollbar. The subtracted 12.5rem is the shell
// chrome around the main column: top bar, footer and its own padding.
block-size: max(24rem, calc(100dvh - 12.5rem));
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
@@ -182,7 +187,11 @@
position: relative;
z-index: 1;
display: grid;
// The single row is spelled out rather than left implicit: only then is it a
// definite height, and only then do the sprites' percentage heights resolve
// against the battlefield instead of against their own artwork.
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr);
align-items: end;
min-block-size: 0;
}
@@ -602,7 +611,7 @@
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
block-size: clamp(20rem, 55dvh, 34rem);
}
.combat__log {

View File

@@ -162,7 +162,8 @@ describe('CombatPageComponent', () => {
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
// The stage jolt is wired up but no longer fires on an ordinary hit.
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('90 / 100');
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);

View File

@@ -28,6 +28,8 @@ const SWING_MS = 540;
const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200;
@Component({
selector: 'app-combat-page',
@@ -47,9 +49,15 @@ export class CombatPageComponent implements OnInit {
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
// it was too much for every single round. Call `shakeStage()` to bring it
// back for a specific ability.
private readonly stageShaking = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('idle');
protected readonly stageShake = this.stageShaking.asReadonly();
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
@@ -125,6 +133,16 @@ export class CombatPageComponent implements OnInit {
}
}
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
protected shakeStage(): void {
this.stageShaking.set(true);
setTimeout(() => {
if (!this.destroyed) {
this.stageShaking.set(false);
}
}, STAGE_SHAKE_MS);
}
protected retry(): void {
void this.loadFromRoute();
}