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 = 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(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('idle'); protected readonly monsterPhase = signal('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 { 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(); 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } private async loadFromRoute(): Promise { 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()); } } }