feat(web): show monster guard and rage on the combat screen

This commit is contained in:
Bastian Wagner
2026-08-23 16:44:07 +02:00
parent 2489b9308f
commit b2a7fc59d0
8 changed files with 119 additions and 42 deletions

View File

@@ -170,7 +170,10 @@ export type CombatEventType =
| 'STATUS_DAMAGE' | 'STATUS_DAMAGE'
| 'STATUS_EXPIRED' | 'STATUS_EXPIRED'
| 'COMBAT_WON' | 'COMBAT_WON'
| 'COMBAT_LOST'; | 'COMBAT_LOST'
| 'GUARD_RAISED'
| 'GUARD_ENDED'
| 'ENRAGED';
export type StatusEffectType = 'BLEED'; export type StatusEffectType = 'BLEED';
export type CombatSide = 'PLAYER' | 'MONSTER'; export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'; export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
@@ -209,6 +212,9 @@ export interface CombatMonster {
currentHp: number; currentHp: number;
artworkPath: string; artworkPath: string;
pendingIntent: CombatMonsterIntent | null; pendingIntent: CombatMonsterIntent | null;
/** Rounds the monster's raised guard still covers, or null when open. */
guardRemainingRounds: number | null;
enraged: boolean;
} }
export interface Combat { export interface Combat {

View File

@@ -25,6 +25,8 @@ const runningCombat: Combat = {
currentHp: 30, currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png', artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null, pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
}, },
events: [], events: [],
rewards: null, rewards: null,

View File

@@ -69,6 +69,16 @@
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p> <p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
} }
@if (monsterGuardLabel(); as guard) {
<p class="combat__telegraph" data-combat-guard role="status">{{ guard }}</p>
}
@if (monsterIsEnraged()) {
<p class="combat__telegraph" data-combat-enraged role="status">
{{ combat.monster.name }} is enraged.
</p>
}
<div class="combat__field"> <div class="combat__field">
<div <div
class="sprite sprite--player" class="sprite sprite--player"

View File

@@ -413,7 +413,7 @@
z-index: 2; z-index: 2;
margin: 0; margin: 0;
padding: var(--ar-space-2) var(--ar-space-4); padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-gold); border: 1px solid currentColor;
border-radius: var(--ar-radius-sm); border-radius: var(--ar-radius-sm);
background: rgb(9 11 13 / 0.85); background: rgb(9 11 13 / 0.85);
color: var(--ar-gold); color: var(--ar-gold);
@@ -423,6 +423,17 @@
text-align: center; text-align: center;
} }
// The guard and enrage banners are the same shape and role as the telegraph
// above -- same class, same rule -- only the accent changes, picked off the
// data attribute the tests already hook into.
[data-combat-guard] {
color: var(--ar-blue);
}
[data-combat-enraged] {
color: var(--ar-danger);
}
.action { .action {
position: relative; position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem); inline-size: clamp(6.5rem, 11vw, 8.5rem);
@@ -709,46 +720,7 @@
justify-content: flex-start; justify-content: flex-start;
} }
/* ---------- ongoing effects ---------- */ .fighter--monster .fighter__meter {
/* Sits directly under the health bar it is eating away at, so the cause of
the drain is next to the number that drops (spec §4). */
.statuses {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--ar-border);
border-radius: 0.15rem;
background: rgb(0 0 0 / 0.35);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
}
/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and
the shape carries the meaning on its own. */
.status__glyph {
inline-size: 0.55rem;
block-size: 0.7rem;
background: currentcolor;
clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%);
}
.status--bleed {
border-color: rgb(158 46 42 / 0.75);
color: #d8736c;
}
.fighter--monster .fighter__meter {
justify-items: start; justify-items: start;
text-align: start; text-align: start;
} }

View File

@@ -37,6 +37,8 @@ const activeCombat: Combat = {
currentHp: 31, currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null, pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
}, },
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 },
@@ -197,6 +199,61 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
}); });
const veteran: Combat = {
...activeCombat,
monster: { ...activeCombat.monster, name: 'Raider Veteran' },
};
it('announces a raised guard with the rounds it still covers', async () => {
const fixture = await setup({
...veteran,
monster: { ...veteran.monster, guardRemainingRounds: 2 },
});
const banner: HTMLElement | null = fixture.nativeElement.querySelector(
'[data-combat-guard]',
);
expect(banner?.textContent).toContain('Raider Veteran');
expect(banner?.textContent).toContain('2');
});
it('says nothing about a guard when the monster is open', async () => {
const fixture = await setup(activeCombat);
expect(
fixture.nativeElement.querySelector('[data-combat-guard]'),
).toBeNull();
});
it('marks an enraged monster', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, enraged: true },
});
expect(
fixture.nativeElement.querySelector('[data-combat-enraged]'),
).not.toBeNull();
});
it('reads the new events back in the log', async () => {
const fixture = await setup({
...veteran,
events: [
{ round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 },
{ round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' },
{ round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' },
],
});
const log = (
fixture.nativeElement as HTMLElement
).querySelector('.combat__log')?.textContent;
expect(log).toContain('Raider Veteran raises its guard.');
expect(log).toContain('Raider Veteran turns savage.');
expect(log).toContain("Raider Veteran's guard drops.");
});
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => { it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
const fixture = await setup({ const fixture = await setup({
...activeCombat, ...activeCombat,

View File

@@ -323,6 +323,20 @@ export class CombatPageComponent implements OnInit {
return `${combat.monster.name} is winding up a Heavy Strike.`; return `${combat.monster.name} is winding up a Heavy Strike.`;
} }
protected monsterGuardLabel(): string | null {
const combat = this.displayed();
const rounds = combat?.monster.guardRemainingRounds;
if (!combat || !rounds) {
return null;
}
const roundWord = rounds === 1 ? 'round' : 'rounds';
return `${combat.monster.name} is covering — ${rounds} ${roundWord}. Shield Bash breaks it.`;
}
protected monsterIsEnraged(): boolean {
return this.displayed()?.monster.enraged ?? false;
}
protected logRounds(): CombatLogRound[] { protected logRounds(): CombatLogRound[] {
const combat = this.displayed(); const combat = this.displayed();
if (!combat) { if (!combat) {
@@ -366,6 +380,18 @@ export class CombatPageComponent implements OnInit {
return `${playerName} interrupts ${monsterName}'s attack.`; return `${playerName} interrupts ${monsterName}'s attack.`;
} }
if (event.type === 'GUARD_RAISED') {
return `${monsterName} raises its guard.`;
}
if (event.type === 'GUARD_ENDED') {
return `${monsterName}'s guard drops.`;
}
if (event.type === 'ENRAGED') {
return `${monsterName} turns savage.`;
}
const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect'; const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect';
if (event.type === 'STATUS_APPLIED') { if (event.type === 'STATUS_APPLIED') {

View File

@@ -26,6 +26,8 @@ const startedCombat: Combat = {
currentHp: 45, currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null, pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
}, },
events: [], events: [],
rewards: null, rewards: null,

View File

@@ -88,6 +88,8 @@ const startedCombat: Combat = {
currentHp: 75, currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png', artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null, pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
}, },
events: [], events: [],
rewards: null, rewards: null,