feat(combat): add the five-action bar, telegraph banner, and generalized round animation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 22:19:13 +02:00
parent b8d00278b8
commit 0126d1dea1
4 changed files with 270 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
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 type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterSpriteScale,
monsterCutoutPath,
@@ -33,6 +33,11 @@ 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',
@@ -75,24 +80,24 @@ export class CombatPageComponent implements OnInit {
void this.loadFromRoute();
}
protected async attack(): Promise<void> {
protected async performAction(action: CombatAction): Promise<void> {
const before = this.displayed();
if (!before || this.busy()) {
if (!before) {
return;
}
this.replaying.set(true);
try {
this.phase.set('attacking');
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.attack();
await this.combatStore.performAction(action);
await swing;
if (this.destroyed) {
return;
}
this.phase.set('idle');
this.monsterPhase.set('flinch');
const after = this.combatStore.combat();
if (!after) {
@@ -105,21 +110,30 @@ export class CombatPageComponent implements OnInit {
void this.worldStore.refreshCharacter();
}
const riposte = after.events.find(
(event) =>
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
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');
if (!riposte) {
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 the blow the player just landed, holding back the monster's reply.
// Show what the player's own action produced, holding back the
// monster's reply -- including whether it just started telegraphing.
this.displayed.set({
...after,
player: before.player,
events: after.events.filter((event) => event.sequence < riposte.sequence),
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);
@@ -127,6 +141,11 @@ export class CombatPageComponent implements OnInit {
return;
}
if (monsterEvent.type === 'TELEGRAPH') {
this.displayed.set(after);
return;
}
this.phase.set('hit');
this.monsterPhase.set('lunge');
this.displayed.set(after);
@@ -194,6 +213,14 @@ export class CombatPageComponent implements OnInit {
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} bereitet Schweren Hieb vor.`;
}
protected logRounds(): CombatLogRound[] {
const combat = this.displayed();
if (!combat) {
@@ -221,6 +248,22 @@ export class CombatPageComponent implements OnInit {
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
}
if (event.type === 'HEAL') {
return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`;
}
if (event.type === 'DEFEND') {
return `${playerName} geht in die Verteidigung.`;
}
if (event.type === 'TELEGRAPH') {
return `${monsterName} bereitet Schweren Hieb vor.`;
}
if (event.type === 'INTERRUPT') {
return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}