Files
ashen-realms/apps/web/src/app/features/combat/combat-page/combat-page.component.ts
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

199 lines
5.7 KiB
TypeScript

import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterIconPath,
combatMonsterSpritePath,
combatMonsterSpriteScale,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
round: number;
events: CombatEvent[];
}
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',
styleUrl: './combat-page.component.scss',
})
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;
// 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<Combat | null>(null);
private readonly replaying = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
ngOnInit(): void {
void this.loadFromRoute();
}
protected async attack(): Promise<void> {
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 {
void this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
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.displayed();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.displayed();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected logRounds(): CombatLogRound[] {
const combat = this.displayed();
if (!combat) {
return [];
}
const rounds = new Map<number, CombatEvent[]>();
for (const event of combat.events) {
const events = rounds.get(event.round) ?? [];
events.push(event);
rounds.set(event.round, events);
}
return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events }));
}
protected formatEvent(event: CombatEvent): string {
const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
if (event.type === 'DAMAGE') {
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
const defender = event.target === 'PLAYER' ? playerName : monsterName;
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}
return `${playerName} wurde im Kampf besiegt.`;
}
private wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async loadFromRoute(): Promise<void> {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (!combatId) {
return;
}
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.displayed.set(this.combatStore.combat());
}
}
}