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:
@@ -48,6 +48,10 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (monsterIntentLabel(); as intent) {
|
||||
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
|
||||
}
|
||||
|
||||
<div class="combat__field">
|
||||
<div
|
||||
class="sprite sprite--player"
|
||||
@@ -73,12 +77,56 @@
|
||||
class="action"
|
||||
data-combat-attack
|
||||
[disabled]="busy()"
|
||||
(click)="attack()"
|
||||
(click)="performAction('ATTACK')"
|
||||
>
|
||||
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
|
||||
<span class="action__label">Angriff</span>
|
||||
<span class="action__key">1</span>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof signal<boolean>>;
|
||||
error: ReturnType<typeof signal<string | null>>;
|
||||
loadCombat: ReturnType<typeof vi.fn>;
|
||||
attack: ReturnType<typeof vi.fn>;
|
||||
performAction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||
let router: Router;
|
||||
@@ -52,7 +53,7 @@ describe('CombatPageComponent', () => {
|
||||
actionPending: signal(false),
|
||||
error: signal<string | null>(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<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 () => {
|
||||
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<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 () => {
|
||||
@@ -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<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 () => {
|
||||
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',
|
||||
|
||||
@@ -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