This commit is contained in:
Bastian Wagner
2026-08-19 23:42:18 +02:00
parent fd9852bfbf
commit 67237f5ad8
4 changed files with 175 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
<section class="combat" aria-label="Kampf"> <section class="combat" aria-label="Kampf">
@if (combat(); as combat) { @if (combat(); as combat) {
<div class="combat__stage"> <div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
<header class="combat__status"> <header class="combat__status">
<div class="fighter fighter--player"> <div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" /> <img class="fighter__icon" [src]="playerIcon" alt="" />
@@ -58,6 +58,8 @@
></div> ></div>
<img <img
class="sprite sprite--monster" class="sprite sprite--monster"
[class.sprite--flinch]="monsterPhase() === 'flinch'"
[class.sprite--lunge]="monsterPhase() === 'lunge'"
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)" [style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)" [src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name" [alt]="combat.monster.name"

View File

@@ -208,7 +208,7 @@
background-size: 600% 100%; background-size: 600% 100%;
} }
.sprite--hit { .sprite--player.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png'); background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
} }
@@ -226,6 +226,126 @@
.sprite--monster { .sprite--monster {
justify-self: end; justify-self: end;
block-size: calc(var(--sprite-scale, 0.6) * 100%); block-size: calc(var(--sprite-scale, 0.6) * 100%);
// Every beat below scales and rotates the cut-out. Pivoting at the feet keeps
// it standing on the same spot instead of sinking behind the action bar.
transform-origin: bottom center;
}
/* The monster is a single cut-out, so every beat below is transform-only. All
of them animate translate/rotate/scale rather than `transform`, and any step
that touches `filter` has to repeat the base drop-shadow from `.sprite`,
because a keyframe filter replaces the whole list. */
/* Getting hit: a short recoil to the right, away from the player, with a red
glow pulse. 240ms so it finishes inside the 260ms riposte pause. */
@keyframes monster-flinch {
0% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
20% {
translate: 0.45rem -0.12rem;
rotate: 1.1deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7))
drop-shadow(0 0 0.65rem rgb(199 75 66 / 0.6));
}
45% {
translate: -0.28rem 0;
rotate: -1deg;
}
70% {
translate: 0.18rem 0;
rotate: 0.5deg;
}
100% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
}
/* Striking back: a brief wind-up away from the player, then a lunge left. The
impact sits at 28% of 540ms so it reads against the warrior hit sheet. */
@keyframes monster-lunge {
0% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
12% {
translate: 0.55rem 0;
rotate: 1.4deg;
scale: 0.985;
}
28% {
translate: -2rem -0.45rem;
rotate: -2.4deg;
scale: 1.05;
}
55% {
translate: -0.8rem -0.1rem;
rotate: -0.8deg;
scale: 1.015;
}
100% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
}
/* Idle: deliberately tiny, just enough that the cut-out does not read as card-
board. The spec rules out permanent *strong* animation, not a slow breath. */
@keyframes monster-breathe {
from {
translate: 0 0;
scale: 1;
}
to {
translate: 0 -0.4%;
scale: 1.004;
}
}
/* Stage jolt when the player takes the blow. The slight scale gives the offset
somewhere to go: the stage clips its children, but its own border moves too,
so without it the shift would open a sliver at the frame. */
@keyframes stage-shake {
0%,
100% {
translate: 0 0;
scale: 1;
}
15% {
translate: -0.35% 0.08rem;
scale: 1.012;
}
35% {
translate: 0.3% -0.06rem;
scale: 1.012;
}
60% {
translate: -0.18% 0.04rem;
scale: 1.009;
}
80% {
translate: 0.1% 0;
scale: 1.004;
}
} }
/* ---------- action bar ---------- */ /* ---------- action bar ---------- */
@@ -439,11 +559,29 @@
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.sprite--attacking, .sprite--player.sprite--attacking,
.sprite--hit { .sprite--player.sprite--hit {
animation: warrior-frames 540ms steps(6) 1; animation: warrior-frames 540ms steps(6) 1;
} }
/* Order matters: the state classes below share translate/rotate/scale with
the breathing loop, so their `animation` shorthand has to come last. */
.sprite--monster {
animation: monster-breathe 5.5s ease-in-out infinite alternate;
}
.sprite--flinch {
animation: monster-flinch 240ms ease-out 1;
}
.sprite--lunge {
animation: monster-lunge 540ms cubic-bezier(0.22, 0.9, 0.3, 1) 1;
}
.combat__stage--shaken {
animation: stage-shake 200ms ease-out 1;
}
.bar__fill { .bar__fill {
transition: inline-size var(--ar-motion-base); transition: inline-size var(--ar-motion-base);
} }

View File

@@ -134,17 +134,23 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const sprite = element.querySelector('.sprite--player'); const sprite = element.querySelector('.sprite--player');
const monster = element.querySelector('.sprite--monster');
const stage = element.querySelector('.combat__stage');
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click(); element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
fixture.detectChanges(); fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(true); expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(element.textContent).toContain('31 / 45'); expect(element.textContent).toContain('31 / 45');
expect(element.textContent).toContain('95 / 100'); expect(element.textContent).toContain('95 / 100');
// Swing lands: the monster loses HP, the player's own loss is held back. // Swing lands: the monster loses HP and flinches, its own loss is held back.
await vi.advanceTimersByTimeAsync(540); await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges(); fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(false); expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('17 / 45'); expect(element.textContent).toContain('17 / 45');
expect(element.textContent).toContain('95 / 100'); expect(element.textContent).toContain('95 / 100');
// Only round 1's identical line is logged so far, not round 2's. // Only round 1's identical line is logged so far, not round 2's.
@@ -154,12 +160,17 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(260); await vi.advanceTimersByTimeAsync(260);
fixture.detectChanges(); fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true); 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);
expect(element.textContent).toContain('90 / 100'); expect(element.textContent).toContain('90 / 100');
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2); expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
await vi.advanceTimersByTimeAsync(540); await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges(); fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(false); expect(sprite?.classList.contains('sprite--hit')).toBe(false);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
}); });
it('skips the recoil when the round ends without the monster striking back', async () => { it('skips the recoil when the round ends without the monster striking back', async () => {
@@ -184,9 +195,17 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540); await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges(); fixture.detectChanges();
const monster = element.querySelector('.sprite--monster');
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false); expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
// The killing blow still registers on the monster, it just never lunges back.
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('0 / 45'); expect(element.textContent).toContain('0 / 45');
await vi.advanceTimersByTimeAsync(800);
fixture.detectChanges();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
}); });
it('disables Angriff while an action is pending', async () => { it('disables Angriff while an action is pending', async () => {

View File

@@ -15,6 +15,10 @@ interface CombatLogRound {
} }
type CombatPhase = 'idle' | 'attacking' | 'hit'; type CombatPhase = 'idle' | 'attacking' | 'hit';
// The monster is a single cut-out with no sheets, so its beats are pure
// CSS transforms and run offset from the player's: it flinches when the
// player's blow lands and lunges while the player is recoiling.
type MonsterPhase = 'idle' | 'flinch' | 'lunge';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png'; const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
@@ -45,6 +49,7 @@ export class CombatPageComponent implements OnInit {
protected readonly combat = this.displayed.asReadonly(); protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle'); protected readonly phase = signal<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('idle');
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending()); protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON; protected readonly playerIcon = PLAYER_ICON;
@@ -67,6 +72,7 @@ export class CombatPageComponent implements OnInit {
this.replaying.set(true); this.replaying.set(true);
try { try {
this.phase.set('attacking'); this.phase.set('attacking');
this.monsterPhase.set('idle');
const swing = this.wait(SWING_MS); const swing = this.wait(SWING_MS);
await this.combatStore.attack(); await this.combatStore.attack();
await swing; await swing;
@@ -74,6 +80,7 @@ export class CombatPageComponent implements OnInit {
return; return;
} }
this.phase.set('idle'); this.phase.set('idle');
this.monsterPhase.set('flinch');
const after = this.combatStore.combat(); const after = this.combatStore.combat();
if (!after) { if (!after) {
@@ -103,12 +110,14 @@ export class CombatPageComponent implements OnInit {
} }
this.phase.set('hit'); this.phase.set('hit');
this.monsterPhase.set('lunge');
this.displayed.set(after); this.displayed.set(after);
await this.wait(RECOIL_MS); await this.wait(RECOIL_MS);
if (this.destroyed) { if (this.destroyed) {
return; return;
} }
this.phase.set('idle'); this.phase.set('idle');
this.monsterPhase.set('idle');
} finally { } finally {
if (!this.destroyed) { if (!this.destroyed) {
this.replaying.set(false); this.replaying.set(false);
@@ -192,6 +201,8 @@ export class CombatPageComponent implements OnInit {
await this.combatStore.loadCombat(combatId); await this.combatStore.loadCombat(combatId);
if (!this.destroyed) { if (!this.destroyed) {
this.phase.set('idle');
this.monsterPhase.set('idle');
this.displayed.set(this.combatStore.combat()); this.displayed.set(this.combatStore.combat());
} }
} }