Merge branch 'worktree-slice-0.3-first-combat'

This commit is contained in:
Bastian Wagner
2026-08-19 22:47:34 +02:00
21 changed files with 522 additions and 59 deletions

View File

@@ -45,7 +45,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumError": "12kB"
}
],
"outputHashing": "all"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -65,6 +65,14 @@ describe('GameApiService', () => {
request.flush({});
});
it('gets the running combat from the literal active route', () => {
service.getActiveCombat().subscribe();
const request = http.expectOne('/api/combats/active');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe();

View File

@@ -42,6 +42,10 @@ export class GameApiService {
return this.http.get<Combat>(`/api/combats/${combatId}`);
}
getActiveCombat(): Observable<Combat | null> {
return this.http.get<Combat | null>('/api/combats/active');
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}

View File

@@ -1,5 +1,5 @@
<section class="combat" aria-label="Kampf">
@if (combatStore.combat(); as combat) {
@if (combat(); as combat) {
<div class="combat__stage">
<header class="combat__status">
<div class="fighter fighter--player">
@@ -49,9 +49,16 @@
</header>
<div class="combat__field">
<img class="sprite sprite--player" [src]="playerSprite" [alt]="combat.player.name" />
<div
class="sprite sprite--player"
[class.sprite--attacking]="phase() === 'attacking'"
[class.sprite--hit]="phase() === 'hit'"
role="img"
[attr.aria-label]="combat.player.name"
></div>
<img
class="sprite sprite--monster"
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name"
/>
@@ -63,7 +70,7 @@
type="button"
class="action"
data-combat-attack
[disabled]="combatStore.actionPending()"
[disabled]="busy()"
(click)="attack()"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />

View File

@@ -12,9 +12,12 @@
/* ---------- stage ---------- */
// The stage is a container so the status row reflows on its own width: the
// surrounding rails can squeeze it narrow while the viewport is still wide.
.combat__stage {
position: relative;
display: grid;
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
@@ -126,7 +129,7 @@
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0.6));
background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
@@ -186,21 +189,43 @@
.sprite {
display: block;
max-block-size: 100%;
max-inline-size: 100%;
inline-size: auto;
object-fit: contain;
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75));
object-position: bottom;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7));
}
/* Six 384px frames laid out horizontally; frame 0 is the resting stance. */
.sprite--player {
justify-self: start;
block-size: clamp(11rem, 30vh, 19rem);
block-size: 80%;
aspect-ratio: 1;
transform: scaleX(-1);
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: 0% 0;
background-size: 600% 100%;
}
.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
/* 6 frames across a 600%-wide sheet land on 0/20/40/60/80/100%. */
@keyframes warrior-frames {
from {
background-position: 0% 0;
}
to {
background-position: 120% 0;
}
}
.sprite--monster {
justify-self: end;
block-size: clamp(9rem, 24vh, 15rem);
block-size: calc(var(--sprite-scale, 0.6) * 100%);
}
/* ---------- action bar ---------- */
@@ -224,21 +249,23 @@
cursor: pointer;
}
.action__icon {
.action__icon,
.action__label,
.action__key {
position: absolute;
inset-block-start: 32%;
inset-inline-start: 50%;
inline-size: 34%;
translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
position: absolute;
inset-block-start: 62%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
@@ -247,10 +274,7 @@
}
.action__key {
position: absolute;
inset-block-start: 90%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
@@ -317,8 +341,8 @@
font-style: italic;
}
.outcome__button {
margin-block-start: var(--ar-space-2);
.outcome__button,
.combat__notice--error button {
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
@@ -329,7 +353,12 @@
letter-spacing: 0.04em;
}
.outcome__button:hover {
.outcome__button {
margin-block-start: var(--ar-space-2);
}
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
@@ -407,15 +436,14 @@
.combat__notice--error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (prefers-reduced-motion: no-preference) {
.sprite--attacking,
.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
@@ -444,7 +472,9 @@
}
}
@media (width < 40rem) {
// Below this the three-column status row cannot hold two names and two bars
// side by side, so the fighters stack under a centred round marker.
@container (width < 38rem) {
.combat__status {
grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2);
@@ -469,11 +499,7 @@
inset-inline-end: auto;
}
.sprite--player {
block-size: clamp(8rem, 22vh, 12rem);
}
.sprite--monster {
block-size: clamp(7rem, 18vh, 10rem);
.fighter__meter {
max-inline-size: none;
}
}

View File

@@ -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);

View File

@@ -1,9 +1,10 @@
import { Component, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { CombatEvent } from '../../../core/api/game-api.models';
import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterIconPath,
combatMonsterSpritePath,
combatMonsterSpriteScale,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';
@@ -13,9 +14,17 @@ interface CombatLogRound {
events: CombatEvent[];
}
const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png';
type CombatPhase = 'idle' | 'attacking' | 'hit';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
// Must stay in step with the sprite-sheet animations in the stylesheet: the
// swing and the recoil each run six frames over these durations.
const SWING_MS = 540;
const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 260;
@Component({
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
@@ -25,46 +34,120 @@ export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private destroyed = false;
ngOnInit(): void {
this.loadFromRoute();
// The server resolves a whole round at once. `combat` is what the screen is
// currently showing, so the round can be played back a beat at a time
// instead of both blows landing together.
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
protected attack(): void {
void this.combatStore.attack();
ngOnInit(): void {
void this.loadFromRoute();
}
protected async attack(): Promise<void> {
const before = this.displayed();
if (!before || this.busy()) {
return;
}
this.replaying.set(true);
try {
this.phase.set('attacking');
const swing = this.wait(SWING_MS);
await this.combatStore.attack();
await swing;
if (this.destroyed) {
return;
}
this.phase.set('idle');
const after = this.combatStore.combat();
if (!after) {
return;
}
const riposte = after.events.find(
(event) =>
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
);
if (!riposte) {
this.displayed.set(after);
return;
}
// Show the blow the player just landed, holding back the monster's reply.
this.displayed.set({
...after,
player: before.player,
events: after.events.filter((event) => event.sequence < riposte.sequence),
});
await this.wait(RIPOSTE_DELAY_MS);
if (this.destroyed) {
return;
}
this.phase.set('hit');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
return;
}
this.phase.set('idle');
} finally {
if (!this.destroyed) {
this.replaying.set(false);
}
}
}
protected retry(): void {
this.loadFromRoute();
void this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
protected readonly playerSprite = PLAYER_SPRITE;
protected readonly playerIcon = PLAYER_ICON;
protected monsterSprite(monsterKey: string, artworkPath: string): string {
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected monsterSpriteScale(monsterKey: string): number {
return combatMonsterSpriteScale(monsterKey);
}
protected monsterIcon(monsterKey: string, artworkPath: string): string {
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected playerHpPercent(): number {
const combat = this.combatStore.combat();
const combat = this.displayed();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.combatStore.combat();
const combat = this.displayed();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected logRounds(): CombatLogRound[] {
const combat = this.combatStore.combat();
const combat = this.displayed();
if (!combat) {
return [];
}
@@ -80,7 +163,7 @@ export class CombatPageComponent implements OnInit {
}
protected formatEvent(event: CombatEvent): string {
const combat = this.combatStore.combat();
const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
@@ -97,10 +180,19 @@ export class CombatPageComponent implements OnInit {
return `${playerName} wurde im Kampf besiegt.`;
}
private loadFromRoute(): void {
private wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async loadFromRoute(): Promise<void> {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (combatId) {
void this.combatStore.loadCombat(combatId);
if (!combatId) {
return;
}
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.displayed.set(this.combatStore.combat());
}
}
}

View File

@@ -37,6 +37,7 @@ describe('CombatStore', () => {
let api: {
startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>;
getActiveCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>;
};
let store: CombatStore;
@@ -45,6 +46,7 @@ describe('CombatStore', () => {
api = {
startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)),
getActiveCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)),
};
@@ -77,6 +79,37 @@ describe('CombatStore', () => {
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
const active = await store.loadActiveCombat();
expect(api.getActiveCombat).toHaveBeenCalledOnce();
expect(active).toEqual(startedCombat);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
expect(store.errorCode()).toBeNull();
});
it('resolves null and keeps the combat empty when no fight is running', async () => {
api.getActiveCombat.mockReturnValue(of(null));
const active = await store.loadActiveCombat();
expect(active).toBeNull();
expect(store.combat()).toBeNull();
});
it('loads a combat by id', async () => {

View File

@@ -24,24 +24,46 @@ export class CombatStore {
private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null);
private readonly errorCodeState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly();
readonly errorCode = this.errorCodeState.asReadonly();
constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.loadingState.set(false);
}
}
// Resolves the combat the character is already in, so an attack rejected with
// COMBAT_ALREADY_ACTIVE can rejoin that fight instead of dead-ending.
async loadActiveCombat(): Promise<Combat | null> {
this.loadingState.set(true);
try {
const combat = await firstValueFrom(this.api.getActiveCombat());
if (combat) {
this.combatState.set(combat);
this.clearError();
}
return combat;
} catch (error) {
this.setError(error);
return null;
} finally {
this.loadingState.set(false);
}
@@ -49,13 +71,13 @@ export class CombatStore {
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.loadingState.set(false);
}
@@ -68,13 +90,13 @@ export class CombatStore {
}
this.actionPendingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
@@ -82,6 +104,20 @@ export class CombatStore {
clearError(): void {
this.errorState.set(null);
this.errorCodeState.set(null);
}
private setError(error: unknown): void {
this.errorCodeState.set(this.toErrorCode(error));
this.errorState.set(this.toErrorMessage(error));
}
private toErrorCode(error: unknown): string | null {
if (error instanceof HttpErrorResponse) {
return (error.error as { code?: string } | null)?.code ?? null;
}
return null;
}
private toErrorMessage(error: unknown): string {

View File

@@ -98,7 +98,9 @@ describe('HuntPageComponent', () => {
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
errorCode: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
loadActiveCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
@@ -118,7 +120,9 @@ describe('HuntPageComponent', () => {
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
errorCode: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
loadActiveCombat: vi.fn(() => Promise.resolve(null)),
clearError: vi.fn(),
};
@@ -226,6 +230,47 @@ describe('HuntPageComponent', () => {
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
});
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('does not look for a running combat when the attack fails for another reason', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');

View File

@@ -48,6 +48,16 @@ export class HuntPageComponent implements OnInit {
const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
return;
}
// A fight already running is not a dead end: rejoin it rather than
// leaving the player stuck behind an error they cannot act on.
if (this.combatStore.errorCode() === 'COMBAT_ALREADY_ACTIVE') {
const active = await this.combatStore.loadActiveCombat();
if (active) {
void this.router.navigate(['/combat', active.id]);
}
}
}

View File

@@ -17,10 +17,23 @@ const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
'road-bandit': '/images/combat/icons/road-bandit-128.png',
};
// Share of the battlefield height each monster sprite occupies, so a hulking
// bandit and a low-slung rat keep believable proportions against the player.
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
};
const DEFAULT_MONSTER_SCALE = 0.6;
export function combatMonsterSpritePath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_SPRITE[monsterKey];
}
export function combatMonsterSpriteScale(monsterKey: string): number {
return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE;
}
export function combatMonsterIconPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_ICON[monsterKey];
}