From 67237f5ad85864f715e1995e21cb443f7dff0476 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 23:42:18 +0200 Subject: [PATCH] effekte --- .../combat-page/combat-page.component.html | 4 +- .../combat-page/combat-page.component.scss | 144 +++++++++++++++++- .../combat-page/combat-page.component.spec.ts | 21 ++- .../combat-page/combat-page.component.ts | 11 ++ 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 3155686..8d2a1c4 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -1,6 +1,6 @@
@if (combat(); as combat) { -
+
@@ -58,6 +58,8 @@ >
{ const element = fixture.nativeElement as HTMLElement; const sprite = element.querySelector('.sprite--player'); + const monster = element.querySelector('.sprite--monster'); + const stage = element.querySelector('.combat__stage'); element.querySelector('[data-combat-attack]')?.click(); fixture.detectChanges(); 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('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); fixture.detectChanges(); 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('95 / 100'); // Only round 1's identical line is logged so far, not round 2's. @@ -154,12 +160,17 @@ describe('CombatPageComponent', () => { await vi.advanceTimersByTimeAsync(260); fixture.detectChanges(); 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(countOccurrences(element.textContent, monsterHitLine)).toBe(2); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); 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 () => { @@ -184,9 +195,17 @@ describe('CombatPageComponent', () => { await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); + const monster = element.querySelector('.sprite--monster'); 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.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 () => { diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index e548d07..320c240 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -15,6 +15,10 @@ interface CombatLogRound { } 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'; @@ -45,6 +49,7 @@ export class CombatPageComponent implements OnInit { protected readonly combat = this.displayed.asReadonly(); protected readonly phase = signal('idle'); + protected readonly monsterPhase = signal('idle'); protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending()); protected readonly playerIcon = PLAYER_ICON; @@ -67,6 +72,7 @@ export class CombatPageComponent implements OnInit { this.replaying.set(true); try { this.phase.set('attacking'); + this.monsterPhase.set('idle'); const swing = this.wait(SWING_MS); await this.combatStore.attack(); await swing; @@ -74,6 +80,7 @@ export class CombatPageComponent implements OnInit { return; } this.phase.set('idle'); + this.monsterPhase.set('flinch'); const after = this.combatStore.combat(); if (!after) { @@ -103,12 +110,14 @@ export class CombatPageComponent implements OnInit { } this.phase.set('hit'); + this.monsterPhase.set('lunge'); this.displayed.set(after); await this.wait(RECOIL_MS); if (this.destroyed) { return; } this.phase.set('idle'); + this.monsterPhase.set('idle'); } finally { if (!this.destroyed) { this.replaying.set(false); @@ -192,6 +201,8 @@ export class CombatPageComponent implements OnInit { await this.combatStore.loadCombat(combatId); if (!this.destroyed) { + this.phase.set('idle'); + this.monsterPhase.set('idle'); this.displayed.set(this.combatStore.combat()); } }