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.`;
}