feat(combat): play the round a beat at a time with attack and hit frames
The server resolves a whole round in one call, so both blows used to land at the same instant. The page now keeps its own view of the combat and plays the round back: the swing animates, the monster's HP and log line land, then after a beat the monster strikes and the player recoils. Both animations are six-frame sprite sheets driven by steps(6), which is why the phase durations mirror the stylesheet. The status row reflows on the stage's own width via a container query — the side rails can squeeze it narrow while the viewport is still wide, which previously overlapped the round marker with the player's name. The component-style budget moves to 12kB to fit this screen's stylesheet.
This commit is contained in:
@@ -25,6 +25,12 @@ const activeCombat: Combat = {
|
||||
],
|
||||
};
|
||||
|
||||
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
||||
|
||||
function countOccurrences(haystack: string | null, needle: string): number {
|
||||
return haystack ? haystack.split(needle).length - 1 : 0;
|
||||
}
|
||||
|
||||
describe('CombatPageComponent', () => {
|
||||
let combatStore: {
|
||||
combat: ReturnType<typeof signal<Combat | null>>;
|
||||
@@ -63,9 +69,16 @@ describe('CombatPageComponent', () => {
|
||||
|
||||
const fixture = TestBed.createComponent(CombatPageComponent);
|
||||
fixture.detectChanges();
|
||||
// The route load resolves on the microtask queue before the combat renders.
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads the combat from the route param on init', async () => {
|
||||
await setup(activeCombat);
|
||||
|
||||
@@ -101,6 +114,81 @@ describe('CombatPageComponent', () => {
|
||||
expect(combatStore.attack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
const resolvedRound: Combat = {
|
||||
...activeCombat,
|
||||
round: 3,
|
||||
player: { ...activeCombat.player, currentHp: 90 },
|
||||
monster: { ...activeCombat.monster, currentHp: 17 },
|
||||
events: [
|
||||
...activeCombat.events,
|
||||
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||
],
|
||||
};
|
||||
combatStore.attack.mockImplementation(async () => {
|
||||
combatStore.combat.set(resolvedRound);
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const sprite = element.querySelector('.sprite--player');
|
||||
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
|
||||
expect(element.textContent).toContain('31 / 45');
|
||||
expect(element.textContent).toContain('95 / 100');
|
||||
|
||||
// Swing lands: the monster loses HP, the player's own loss is held back.
|
||||
await vi.advanceTimersByTimeAsync(540);
|
||||
fixture.detectChanges();
|
||||
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
|
||||
expect(element.textContent).toContain('17 / 45');
|
||||
expect(element.textContent).toContain('95 / 100');
|
||||
// Only round 1's identical line is logged so far, not round 2's.
|
||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
|
||||
|
||||
// The monster strikes back after the beat.
|
||||
await vi.advanceTimersByTimeAsync(260);
|
||||
fixture.detectChanges();
|
||||
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
||||
expect(element.textContent).toContain('90 / 100');
|
||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(540);
|
||||
fixture.detectChanges();
|
||||
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the recoil when the round ends without the monster striking back', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
const won: Combat = {
|
||||
...activeCombat,
|
||||
status: 'WON',
|
||||
monster: { ...activeCombat.monster, currentHp: 0 },
|
||||
events: [
|
||||
...activeCombat.events,
|
||||
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 },
|
||||
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
|
||||
],
|
||||
};
|
||||
combatStore.attack.mockImplementation(async () => {
|
||||
combatStore.combat.set(won);
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||
await vi.advanceTimersByTimeAsync(540);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
|
||||
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
||||
expect(element.textContent).toContain('0 / 45');
|
||||
});
|
||||
|
||||
it('disables Angriff while an action is pending', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
combatStore.actionPending.set(true);
|
||||
|
||||
Reference in New Issue
Block a user