From 6c4b7d29d0b0271cabd75de0a4e8c861b75e10ca Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 22:46:17 +0200 Subject: [PATCH] feat(combat): play the round a beat at a time with attack and hit frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/angular.json | 2 +- .../combat-page/combat-page.component.html | 13 +- .../combat-page/combat-page.component.scss | 86 ++++++++---- .../combat-page/combat-page.component.spec.ts | 88 ++++++++++++ .../combat-page/combat-page.component.ts | 128 +++++++++++++++--- 5 files changed, 265 insertions(+), 52 deletions(-) diff --git a/apps/web/angular.json b/apps/web/angular.json index 190034b..e7794d4 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -45,7 +45,7 @@ { "type": "anyComponentStyle", "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumError": "12kB" } ], "outputHashing": "all" 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 d6349a3..3155686 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,5 +1,5 @@
- @if (combatStore.combat(); as combat) { + @if (combat(); as combat) {
@@ -49,9 +49,16 @@
- + @@ -63,7 +70,7 @@ type="button" class="action" data-combat-attack - [disabled]="combatStore.actionPending()" + [disabled]="busy()" (click)="attack()" > diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 1401051..b487044 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -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; } } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index cafe065..0bda59f 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -25,6 +25,12 @@ const activeCombat: Combat = { ], }; +const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.'; + +function countOccurrences(haystack: string | null, needle: string): number { + return haystack ? haystack.split(needle).length - 1 : 0; +} + describe('CombatPageComponent', () => { let combatStore: { combat: ReturnType>; @@ -63,9 +69,16 @@ describe('CombatPageComponent', () => { const fixture = TestBed.createComponent(CombatPageComponent); fixture.detectChanges(); + // The route load resolves on the microtask queue before the combat renders. + await fixture.whenStable(); + fixture.detectChanges(); return fixture; } + afterEach(() => { + vi.useRealTimers(); + }); + it('loads the combat from the route param on init', async () => { await setup(activeCombat); @@ -101,6 +114,81 @@ describe('CombatPageComponent', () => { expect(combatStore.attack).toHaveBeenCalledOnce(); }); + it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => { + const fixture = await setup(activeCombat); + const resolvedRound: Combat = { + ...activeCombat, + round: 3, + player: { ...activeCombat.player, currentHp: 90 }, + monster: { ...activeCombat.monster, currentHp: 17 }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, + { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, + ], + }; + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set(resolvedRound); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + const sprite = element.querySelector('.sprite--player'); + element.querySelector('[data-combat-attack]')?.click(); + fixture.detectChanges(); + + expect(sprite?.classList.contains('sprite--attacking')).toBe(true); + 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. + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + expect(sprite?.classList.contains('sprite--attacking')).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. + expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1); + + // The monster strikes back after the beat. + await vi.advanceTimersByTimeAsync(260); + fixture.detectChanges(); + expect(sprite?.classList.contains('sprite--hit')).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); + }); + + it('skips the recoil when the round ends without the monster striking back', async () => { + const fixture = await setup(activeCombat); + const won: Combat = { + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, + { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, + ], + }; + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set(won); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('[data-combat-attack]')?.click(); + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false); + expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); + expect(element.textContent).toContain('0 / 45'); + }); + it('disables Angriff while an action is pending', async () => { const fixture = await setup(activeCombat); combatStore.actionPending.set(true); 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 78e9194..8eea821 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 @@ -1,9 +1,10 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import type { CombatEvent } from '../../../core/api/game-api.models'; +import type { Combat, CombatEvent } from '../../../core/api/game-api.models'; import { combatMonsterIconPath, combatMonsterSpritePath, + combatMonsterSpriteScale, runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; import { CombatStore } from '../combat.store'; @@ -13,9 +14,17 @@ interface CombatLogRound { events: CombatEvent[]; } -const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png'; +type CombatPhase = 'idle' | 'attacking' | 'hit'; + const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png'; +// Must stay in step with the sprite-sheet animations in the stylesheet: the +// swing and the recoil each run six frames over these durations. +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; + @Component({ selector: 'app-combat-page', templateUrl: './combat-page.component.html', @@ -25,46 +34,120 @@ export class CombatPageComponent implements OnInit { protected readonly combatStore = inject(CombatStore); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; - ngOnInit(): void { - this.loadFromRoute(); + // The server resolves a whole round at once. `combat` is what the screen is + // currently showing, so the round can be played back a beat at a time + // instead of both blows landing together. + private readonly displayed = signal(null); + private readonly replaying = signal(false); + + protected readonly combat = this.displayed.asReadonly(); + protected readonly phase = signal('idle'); + protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending()); + protected readonly playerIcon = PLAYER_ICON; + + constructor() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); } - protected attack(): void { - void this.combatStore.attack(); + ngOnInit(): void { + void this.loadFromRoute(); + } + + protected async attack(): Promise { + const before = this.displayed(); + if (!before || this.busy()) { + return; + } + + this.replaying.set(true); + try { + this.phase.set('attacking'); + const swing = this.wait(SWING_MS); + await this.combatStore.attack(); + await swing; + if (this.destroyed) { + return; + } + this.phase.set('idle'); + + const after = this.combatStore.combat(); + if (!after) { + return; + } + + const riposte = after.events.find( + (event) => + event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER', + ); + + if (!riposte) { + this.displayed.set(after); + return; + } + + // Show the blow the player just landed, holding back the monster's reply. + this.displayed.set({ + ...after, + player: before.player, + events: after.events.filter((event) => event.sequence < riposte.sequence), + }); + + await this.wait(RIPOSTE_DELAY_MS); + if (this.destroyed) { + return; + } + + this.phase.set('hit'); + this.displayed.set(after); + await this.wait(RECOIL_MS); + if (this.destroyed) { + return; + } + this.phase.set('idle'); + } finally { + if (!this.destroyed) { + this.replaying.set(false); + } + } } protected retry(): void { - this.loadFromRoute(); + void this.loadFromRoute(); } protected goToHunt(): void { void this.router.navigate(['/hunt']); } - protected readonly playerSprite = PLAYER_SPRITE; - protected readonly playerIcon = PLAYER_ICON; - protected monsterSprite(monsterKey: string, artworkPath: string): string { return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } + protected monsterSpriteScale(monsterKey: string): number { + return combatMonsterSpriteScale(monsterKey); + } + protected monsterIcon(monsterKey: string, artworkPath: string): string { return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } protected playerHpPercent(): number { - const combat = this.combatStore.combat(); + const combat = this.displayed(); return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0; } protected monsterHpPercent(): number { - const combat = this.combatStore.combat(); + const combat = this.displayed(); return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; } protected logRounds(): CombatLogRound[] { - const combat = this.combatStore.combat(); + const combat = this.displayed(); if (!combat) { return []; } @@ -80,7 +163,7 @@ export class CombatPageComponent implements OnInit { } protected formatEvent(event: CombatEvent): string { - const combat = this.combatStore.combat(); + const combat = this.displayed(); const playerName = combat?.player.name ?? 'Du'; const monsterName = combat?.monster.name ?? 'Der Gegner'; @@ -97,10 +180,19 @@ export class CombatPageComponent implements OnInit { return `${playerName} wurde im Kampf besiegt.`; } - private loadFromRoute(): void { + private wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async loadFromRoute(): Promise { const combatId = this.route.snapshot.paramMap.get('combatId'); - if (combatId) { - void this.combatStore.loadCombat(combatId); + if (!combatId) { + return; + } + + await this.combatStore.loadCombat(combatId); + if (!this.destroyed) { + this.displayed.set(this.combatStore.combat()); } } }