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

@@ -48,6 +48,10 @@
</div> </div>
</header> </header>
@if (monsterIntentLabel(); as intent) {
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
}
<div class="combat__field"> <div class="combat__field">
<div <div
class="sprite sprite--player" class="sprite sprite--player"
@@ -73,12 +77,56 @@
class="action" class="action"
data-combat-attack data-combat-attack
[disabled]="busy()" [disabled]="busy()"
(click)="attack()" (click)="performAction('ATTACK')"
> >
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" /> <img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span> <span class="action__label">Angriff</span>
<span class="action__key">1</span> <span class="action__key">1</span>
</button> </button>
<button
type="button"
class="action"
data-combat-heavy-strike
[disabled]="busy()"
(click)="performAction('HEAVY_STRIKE')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Schwerer Hieb</span>
<span class="action__key">2</span>
</button>
<button
type="button"
class="action"
data-combat-shield-bash
[disabled]="busy()"
(click)="performAction('SHIELD_BASH')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Schildstoß</span>
<span class="action__key">3</span>
</button>
<button
type="button"
class="action"
data-combat-defend
[disabled]="busy()"
(click)="performAction('DEFEND')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Verteidigen</span>
<span class="action__key">4</span>
</button>
<button
type="button"
class="action"
data-combat-potion
[disabled]="busy() || combat.player.potionsRemaining <= 0"
(click)="performAction('POTION')"
>
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__key">5</span>
</button>
} }
</footer> </footer>

View File

@@ -363,10 +363,27 @@
position: relative; position: relative;
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--ar-space-3); gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem); min-block-size: clamp(6.5rem, 11vw, 8.5rem);
} }
.combat__telegraph {
position: relative;
z-index: 2;
margin: 0;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-gold);
border-radius: var(--ar-radius-sm);
background: rgb(9 11 13 / 0.85);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1rem, 1.6vw, 1.15rem);
letter-spacing: 0.03em;
text-align: center;
}
.action { .action {
position: relative; position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem); inline-size: clamp(6.5rem, 11vw, 8.5rem);

View File

@@ -11,7 +11,7 @@ const activeCombat: Combat = {
id: 'combat-1', id: 'combat-1',
status: 'ACTIVE', status: 'ACTIVE',
round: 2, round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Aschenratte',
@@ -19,6 +19,7 @@ const activeCombat: Combat = {
maxHp: 45, maxHp: 45,
currentHp: 31, currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
}, },
events: [ events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
@@ -40,7 +41,7 @@ describe('CombatPageComponent', () => {
actionPending: ReturnType<typeof signal<boolean>>; actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>; error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>; loadCombat: ReturnType<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>; performAction: ReturnType<typeof vi.fn>;
}; };
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> }; let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let router: Router; let router: Router;
@@ -52,7 +53,7 @@ describe('CombatPageComponent', () => {
actionPending: signal(false), actionPending: signal(false),
error: signal<string | null>(null), error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()), loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()), performAction: vi.fn(() => Promise.resolve()),
}; };
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
@@ -102,6 +103,82 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeTruthy(); expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
}); });
it('shows all five combat actions with their German labels', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
});
it('sends the matching action for each of the four new buttons', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-heavy-strike]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('POTION');
});
it('disables the potion button once both potions are used', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, potionsRemaining: 0 },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(false);
});
it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Aschenratte bereitet Schweren Hieb vor.',
);
});
it('shows no telegraph banner when nothing is pending', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
});
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
const fixture = await setup({
...activeCombat,
events: [
{ round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 },
{ round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
{ round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.');
expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.');
expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.');
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
});
it('renders the structured events as readable German combat-log entries', async () => { it('renders the structured events as readable German combat-log entries', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -110,13 +187,13 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.'); expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
}); });
it('calls combatStore.attack() when Angriff is clicked', async () => { it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click(); element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.attack).toHaveBeenCalledOnce(); expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK');
}); });
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => { it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
@@ -132,7 +209,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
], ],
}; };
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(resolvedRound); combatStore.combat.set(resolvedRound);
}); });
vi.useFakeTimers(); vi.useFakeTimers();
@@ -179,6 +256,70 @@ describe('CombatPageComponent', () => {
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false); expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
}); });
it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => {
const fixture = await setup(activeCombat);
const telegraphed: Combat = {
...activeCombat,
round: 3,
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
],
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(telegraphed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor');
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const interrupted: Combat = {
...activeCombat,
round: 3,
monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 },
{ round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(interrupted);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('skips the recoil when the round ends without the monster striking back', async () => { it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const won: Combat = { const won: Combat = {
@@ -191,7 +332,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
], ],
}; };
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(won); combatStore.combat.set(won);
}); });
vi.useFakeTimers(); vi.useFakeTimers();
@@ -216,7 +357,7 @@ describe('CombatPageComponent', () => {
it('refreshes the character from the server once a combat is won', async () => { it('refreshes the character from the server once a combat is won', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set({ combatStore.combat.set({
...activeCombat, ...activeCombat,
status: 'WON', status: 'WON',

View File

@@ -1,6 +1,6 @@
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; 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 { import {
combatMonsterSpriteScale, combatMonsterSpriteScale,
monsterCutoutPath, monsterCutoutPath,
@@ -33,6 +33,11 @@ const RIPOSTE_DELAY_MS = 1260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet. // Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200; 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({ @Component({
selector: 'app-combat-page', selector: 'app-combat-page',
templateUrl: './combat-page.component.html', templateUrl: './combat-page.component.html',
@@ -75,24 +80,24 @@ export class CombatPageComponent implements OnInit {
void this.loadFromRoute(); void this.loadFromRoute();
} }
protected async attack(): Promise<void> { protected async performAction(action: CombatAction): Promise<void> {
const before = this.displayed(); const before = this.displayed();
if (!before || this.busy()) { if (!before) {
return; return;
} }
this.replaying.set(true); this.replaying.set(true);
try { try {
this.phase.set('attacking'); const damaging = DAMAGING_ACTIONS.has(action);
this.phase.set(damaging ? 'attacking' : 'idle');
this.monsterPhase.set('idle'); this.monsterPhase.set('idle');
const swing = this.wait(SWING_MS); const swing = this.wait(SWING_MS);
await this.combatStore.attack(); await this.combatStore.performAction(action);
await swing; await swing;
if (this.destroyed) { if (this.destroyed) {
return; return;
} }
this.phase.set('idle'); this.phase.set('idle');
this.monsterPhase.set('flinch');
const after = this.combatStore.combat(); const after = this.combatStore.combat();
if (!after) { if (!after) {
@@ -105,21 +110,30 @@ export class CombatPageComponent implements OnInit {
void this.worldStore.refreshCharacter(); void this.worldStore.refreshCharacter();
} }
const riposte = after.events.find( const roundEvents = after.events.filter((event) => event.round === before.round);
(event) => const dealtDamage = roundEvents.some(
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER', (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); this.displayed.set(after);
return; 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({ this.displayed.set({
...after, ...after,
player: before.player, 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); await this.wait(RIPOSTE_DELAY_MS);
@@ -127,6 +141,11 @@ export class CombatPageComponent implements OnInit {
return; return;
} }
if (monsterEvent.type === 'TELEGRAPH') {
this.displayed.set(after);
return;
}
this.phase.set('hit'); this.phase.set('hit');
this.monsterPhase.set('lunge'); this.monsterPhase.set('lunge');
this.displayed.set(after); this.displayed.set(after);
@@ -194,6 +213,14 @@ export class CombatPageComponent implements OnInit {
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; 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[] { protected logRounds(): CombatLogRound[] {
const combat = this.displayed(); const combat = this.displayed();
if (!combat) { if (!combat) {
@@ -221,6 +248,22 @@ export class CombatPageComponent implements OnInit {
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`; 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') { if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`; return `${monsterName} wurde besiegt.`;
} }