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:
@@ -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.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user