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.
This commit is contained in:
@@ -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<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;
|
||||
});
|
||||
}
|
||||
|
||||
protected attack(): void {
|
||||
void this.combatStore.attack();
|
||||
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 {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private async loadFromRoute(): Promise<void> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user