Translates formatEvent() and monsterIntentLabel() combat-log templates, default participant-name fallbacks (Du/Der Gegner -> You/The enemy), and every static string in the combat page template: round header, action buttons, outcome headings, rewards panel, post-combat buttons, log panel, and loading/error/aria-label text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
|
|
import {
|
|
combatMonsterSpriteScale,
|
|
monsterCutoutPath,
|
|
monsterIconPath,
|
|
runtimeMonsterArtworkPath,
|
|
} from '../../../shared/monster-artwork';
|
|
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
|
|
import { WorldStore } from '../../world/world.store';
|
|
import { CombatStore } from '../combat.store';
|
|
|
|
interface CombatLogRound {
|
|
round: number;
|
|
events: CombatEvent[];
|
|
}
|
|
|
|
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';
|
|
|
|
// 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 = 1260;
|
|
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
|
|
const STAGE_SHAKE_MS = 200;
|
|
|
|
// Actions that land a blow on the monster this round -- everything else
|
|
// (DEFEND, POTION) skips the swing wind-up so the player sprite doesn't
|
|
// mime an attack it didn't make.
|
|
const DAMAGING_ACTIONS: ReadonlySet<CombatAction> = new Set(['ATTACK', 'HEAVY_STRIKE', 'SHIELD_BASH']);
|
|
|
|
@Component({
|
|
selector: 'app-combat-page',
|
|
templateUrl: './combat-page.component.html',
|
|
styleUrl: './combat-page.component.scss',
|
|
imports: [ItemCardComponent],
|
|
})
|
|
export class CombatPageComponent implements OnInit {
|
|
protected readonly combatStore = inject(CombatStore);
|
|
private readonly worldStore = inject(WorldStore);
|
|
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);
|
|
|
|
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
|
|
// it was too much for every single round. Call `shakeStage()` to bring it
|
|
// back for a specific ability.
|
|
private readonly stageShaking = signal(false);
|
|
|
|
protected readonly combat = this.displayed.asReadonly();
|
|
protected readonly phase = signal<CombatPhase>('idle');
|
|
protected readonly monsterPhase = signal<MonsterPhase>('idle');
|
|
protected readonly stageShake = this.stageShaking.asReadonly();
|
|
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 performAction(action: CombatAction): Promise<void> {
|
|
const before = this.displayed();
|
|
if (!before || this.busy()) {
|
|
return;
|
|
}
|
|
|
|
this.replaying.set(true);
|
|
try {
|
|
const damaging = DAMAGING_ACTIONS.has(action);
|
|
this.phase.set(damaging ? 'attacking' : 'idle');
|
|
this.monsterPhase.set('idle');
|
|
const swing = this.wait(SWING_MS);
|
|
await this.combatStore.performAction(action);
|
|
await swing;
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
this.phase.set('idle');
|
|
|
|
const after = this.combatStore.combat();
|
|
if (!after) {
|
|
return;
|
|
}
|
|
|
|
if (after.status === 'WON') {
|
|
// The server already granted silver and any item drops; pull the
|
|
// authoritative character so the HUD matches (spec §35). Renown is
|
|
// not granted here -- it comes from milestones only.
|
|
void this.worldStore.refreshCharacter();
|
|
}
|
|
|
|
const roundEvents = after.events.filter((event) => event.round === before.round);
|
|
const dealtDamage = roundEvents.some(
|
|
(event) => event.source === 'PLAYER' && event.target === 'MONSTER' && event.type === 'DAMAGE',
|
|
);
|
|
this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle');
|
|
|
|
const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER');
|
|
if (!monsterEvent) {
|
|
// No reply this round: either the fight just ended, or SHIELD_BASH
|
|
// interrupted the monster's turn outright.
|
|
this.displayed.set(after);
|
|
return;
|
|
}
|
|
|
|
// Show what the player's own action produced, holding back the
|
|
// monster's reply -- including whether it just started telegraphing.
|
|
// A POTION heal lands from the player's own action, before the
|
|
// monster's reply, so it must show up here rather than being folded
|
|
// into the delayed riposte reveal.
|
|
const healEvent = roundEvents.find(
|
|
(event) => event.type === 'HEAL' && event.sequence < monsterEvent.sequence,
|
|
);
|
|
const intermediatePlayer = healEvent
|
|
? {
|
|
...before.player,
|
|
currentHp: Math.min(before.player.maxHp, before.player.currentHp + (healEvent.amount ?? 0)),
|
|
potionsRemaining: after.player.potionsRemaining,
|
|
}
|
|
: before.player;
|
|
|
|
this.displayed.set({
|
|
...after,
|
|
player: intermediatePlayer,
|
|
monster: {
|
|
...(dealtDamage ? after.monster : before.monster),
|
|
pendingIntent: before.monster.pendingIntent,
|
|
},
|
|
events: after.events.filter((event) => event.sequence < monsterEvent.sequence),
|
|
});
|
|
|
|
await this.wait(RIPOSTE_DELAY_MS);
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
if (monsterEvent.type === 'TELEGRAPH') {
|
|
this.displayed.set(after);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
|
|
protected shakeStage(): void {
|
|
this.stageShaking.set(true);
|
|
setTimeout(() => {
|
|
if (!this.destroyed) {
|
|
this.stageShaking.set(false);
|
|
}
|
|
}, STAGE_SHAKE_MS);
|
|
}
|
|
|
|
protected retry(): void {
|
|
void this.loadFromRoute();
|
|
}
|
|
|
|
protected goToHunt(): void {
|
|
void this.router.navigate(['/hunt']);
|
|
}
|
|
|
|
// The location is the screen a fight resolves back into. It sits beside
|
|
// "Keep Hunting" rather than replacing it, so the hunt loop keeps its
|
|
// one-click rhythm.
|
|
protected goToLocation(): void {
|
|
void this.router.navigate(['/location']);
|
|
}
|
|
|
|
protected goToInventory(): void {
|
|
void this.router.navigate(['/inventory']);
|
|
}
|
|
|
|
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
|
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
|
}
|
|
|
|
protected monsterSpriteScale(monsterKey: string): number {
|
|
return combatMonsterSpriteScale(monsterKey);
|
|
}
|
|
|
|
protected monsterIcon(monsterKey: string, artworkPath: string): string {
|
|
return monsterIconPath(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 monsterIntentLabel(): string | null {
|
|
const combat = this.displayed();
|
|
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
|
|
return null;
|
|
}
|
|
return `${combat.monster.name} is winding up a Heavy Strike.`;
|
|
}
|
|
|
|
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 ?? 'You';
|
|
const monsterName = combat?.monster.name ?? 'The enemy';
|
|
|
|
if (event.type === 'DAMAGE') {
|
|
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
|
|
const defender = event.target === 'PLAYER' ? playerName : monsterName;
|
|
return `${attacker} hits ${defender} for ${event.amount} damage.`;
|
|
}
|
|
|
|
if (event.type === 'HEAL') {
|
|
return `${playerName} drinks a potion and heals ${event.amount} HP.`;
|
|
}
|
|
|
|
if (event.type === 'DEFEND') {
|
|
return `${playerName} braces to defend.`;
|
|
}
|
|
|
|
if (event.type === 'TELEGRAPH') {
|
|
return `${monsterName} is winding up a Heavy Strike.`;
|
|
}
|
|
|
|
if (event.type === 'INTERRUPT') {
|
|
return `${playerName} interrupts ${monsterName}'s attack.`;
|
|
}
|
|
|
|
if (event.type === 'COMBAT_WON') {
|
|
return `${monsterName} has been defeated.`;
|
|
}
|
|
|
|
return `${playerName} has been defeated.`;
|
|
}
|
|
|
|
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.phase.set('idle');
|
|
this.monsterPhase.set('idle');
|
|
this.displayed.set(this.combatStore.combat());
|
|
}
|
|
}
|
|
}
|