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