From 0126d1dea10f515135b4620e0a9fa46dee78cea3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 22:19:13 +0200 Subject: [PATCH] feat(combat): add the five-action bar, telegraph banner, and generalized round animation Co-Authored-By: Claude Sonnet 5 --- .../combat-page/combat-page.component.html | 50 +++++- .../combat-page/combat-page.component.scss | 17 ++ .../combat-page/combat-page.component.spec.ts | 157 +++++++++++++++++- .../combat-page/combat-page.component.ts | 67 ++++++-- 4 files changed, 270 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 8b05fa4..72c221b 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -48,6 +48,10 @@ + @if (monsterIntentLabel(); as intent) { +

{{ intent }}

+ } +
Angriff 1 + + + + } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 2492327..5ec6122 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -363,10 +363,27 @@ position: relative; z-index: 2; display: flex; + flex-wrap: wrap; + justify-content: center; gap: var(--ar-space-3); 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 { position: relative; inline-size: clamp(6.5rem, 11vw, 8.5rem); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 976b049..6ea1da3 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -11,7 +11,7 @@ const activeCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 2, - player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 }, + player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 }, monster: { key: 'ash-rat', name: 'Aschenratte', @@ -19,6 +19,7 @@ const activeCombat: Combat = { maxHp: 45, currentHp: 31, artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, }, events: [ { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, @@ -40,7 +41,7 @@ describe('CombatPageComponent', () => { actionPending: ReturnType>; error: ReturnType>; loadCombat: ReturnType; - attack: ReturnType; + performAction: ReturnType; }; let worldStore: { refreshCharacter: ReturnType }; let router: Router; @@ -52,7 +53,7 @@ describe('CombatPageComponent', () => { actionPending: signal(false), error: signal(null), loadCombat: vi.fn(() => Promise.resolve()), - attack: vi.fn(() => Promise.resolve()), + performAction: vi.fn(() => Promise.resolve()), }; worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; @@ -102,6 +103,82 @@ describe('CombatPageComponent', () => { 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('[data-combat-heavy-strike]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE'); + + element.querySelector('[data-combat-shield-bash]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH'); + + element.querySelector('[data-combat-defend]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND'); + + element.querySelector('[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('[data-combat-potion]')?.disabled).toBe(true); + expect(element.querySelector('[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 () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; @@ -110,13 +187,13 @@ describe('CombatPageComponent', () => { 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 element = fixture.nativeElement as HTMLElement; element.querySelector('[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 () => { @@ -132,7 +209,7 @@ describe('CombatPageComponent', () => { { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, ], }; - combatStore.attack.mockImplementation(async () => { + combatStore.performAction.mockImplementation(async () => { combatStore.combat.set(resolvedRound); }); vi.useFakeTimers(); @@ -179,6 +256,70 @@ describe('CombatPageComponent', () => { 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('[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('[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 () => { const fixture = await setup(activeCombat); const won: Combat = { @@ -191,7 +332,7 @@ describe('CombatPageComponent', () => { { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, ], }; - combatStore.attack.mockImplementation(async () => { + combatStore.performAction.mockImplementation(async () => { combatStore.combat.set(won); }); vi.useFakeTimers(); @@ -216,7 +357,7 @@ describe('CombatPageComponent', () => { it('refreshes the character from the server once a combat is won', async () => { const fixture = await setup(activeCombat); - combatStore.attack.mockImplementation(async () => { + combatStore.performAction.mockImplementation(async () => { combatStore.combat.set({ ...activeCombat, status: 'WON', diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 8a5a8da..c1f7890 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -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 = 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 { + protected async performAction(action: CombatAction): Promise { 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.`; }