Merge branch 'worktree-slice-0.3-first-combat'
BIN
apps/web/public/images/combat/icons/ash-rat-128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/images/combat/icons/road-bandit-128.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/images/combat/sprites/ash-rat-760.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/web/public/images/combat/sprites/road-bandit-620.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
apps/web/public/images/combat/sprites/warrior-attack-512.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/web/public/images/hud/runtime/AttackIcon-96.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
apps/web/public/images/hud/runtime/fight-action-frame-360.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
@@ -22,10 +22,10 @@ export const routes: Routes = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'combat/new',
|
||||
path: 'combat/:combatId',
|
||||
loadComponent: () =>
|
||||
import('./features/combat/combat-placeholder-page.component').then(
|
||||
(module) => module.CombatPlaceholderPageComponent,
|
||||
import('./features/combat/combat-page/combat-page.component').then(
|
||||
(module) => module.CombatPageComponent,
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -68,3 +68,41 @@ export interface HuntResult {
|
||||
location: LocationSummary;
|
||||
encounters: HuntEncounter[];
|
||||
}
|
||||
|
||||
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
|
||||
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
|
||||
export type CombatSide = 'PLAYER' | 'MONSTER';
|
||||
export type CombatAction = 'ATTACK';
|
||||
|
||||
export interface CombatEvent {
|
||||
round: number;
|
||||
sequence: number;
|
||||
type: CombatEventType;
|
||||
source: CombatSide;
|
||||
target: CombatSide;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface CombatPlayer {
|
||||
name: string;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
}
|
||||
|
||||
export interface CombatMonster {
|
||||
key: string;
|
||||
name: string;
|
||||
level: number;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
artworkPath: string;
|
||||
}
|
||||
|
||||
export interface Combat {
|
||||
id: string;
|
||||
status: CombatStatus;
|
||||
round: number;
|
||||
player: CombatPlayer;
|
||||
monster: CombatMonster;
|
||||
events: CombatEvent[];
|
||||
}
|
||||
|
||||
@@ -47,4 +47,30 @@ describe('GameApiService', () => {
|
||||
expect(request.request.body).toEqual({ targetLocationId: 'target-uuid' });
|
||||
request.flush({ status: 'IDLE' });
|
||||
});
|
||||
|
||||
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
|
||||
service.startCombat('encounter-uuid').subscribe();
|
||||
|
||||
const request = http.expectOne('/api/hunt-encounters/encounter-uuid/attack');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body).toEqual({});
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('gets a combat by id', () => {
|
||||
service.getCombat('combat-uuid').subscribe();
|
||||
|
||||
const request = http.expectOne('/api/combats/combat-uuid');
|
||||
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();
|
||||
|
||||
const request = http.expectOne('/api/combats/combat-uuid/actions');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body).toEqual({ action: 'ATTACK' });
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CharacterResponse, CurrentLocationResponse, CurrentTravel, HuntResult } from './game-api.models';
|
||||
import {
|
||||
CharacterResponse,
|
||||
Combat,
|
||||
CombatAction,
|
||||
CurrentLocationResponse,
|
||||
CurrentTravel,
|
||||
HuntResult,
|
||||
} from './game-api.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GameApiService {
|
||||
@@ -26,4 +33,16 @@ export class GameApiService {
|
||||
startHunt(): Observable<HuntResult> {
|
||||
return this.http.post<HuntResult>('/api/hunts', {});
|
||||
}
|
||||
|
||||
startCombat(encounterId: string): Observable<Combat> {
|
||||
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
|
||||
}
|
||||
|
||||
getCombat(combatId: string): Observable<Combat> {
|
||||
return this.http.get<Combat>(`/api/combats/${combatId}`);
|
||||
}
|
||||
|
||||
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
||||
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<section class="combat" aria-label="Kampf">
|
||||
@if (combatStore.combat(); as combat) {
|
||||
<div class="combat__stage">
|
||||
<header class="combat__status">
|
||||
<div class="fighter fighter--player">
|
||||
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
||||
<div class="fighter__meter">
|
||||
<p class="fighter__name">{{ combat.player.name }}</p>
|
||||
<div
|
||||
class="bar bar--player"
|
||||
role="progressbar"
|
||||
[attr.aria-label]="'Lebenspunkte ' + combat.player.name"
|
||||
[attr.aria-valuenow]="combat.player.currentHp"
|
||||
[attr.aria-valuemin]="0"
|
||||
[attr.aria-valuemax]="combat.player.maxHp"
|
||||
>
|
||||
<span class="bar__fill" [style.inline-size.%]="playerHpPercent()"></span>
|
||||
<span class="bar__text">{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="combat__round" data-combat-round>Runde {{ combat.round }}</p>
|
||||
|
||||
<div class="fighter fighter--monster">
|
||||
<div class="fighter__meter">
|
||||
<p class="fighter__name">
|
||||
{{ combat.monster.name }}
|
||||
<span class="fighter__level">Stufe {{ combat.monster.level }}</span>
|
||||
</p>
|
||||
<div
|
||||
class="bar bar--monster"
|
||||
role="progressbar"
|
||||
[attr.aria-label]="'Lebenspunkte ' + combat.monster.name"
|
||||
[attr.aria-valuenow]="combat.monster.currentHp"
|
||||
[attr.aria-valuemin]="0"
|
||||
[attr.aria-valuemax]="combat.monster.maxHp"
|
||||
>
|
||||
<span class="bar__fill" [style.inline-size.%]="monsterHpPercent()"></span>
|
||||
<span class="bar__text">{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
class="fighter__icon"
|
||||
[src]="monsterIcon(combat.monster.key, combat.monster.artworkPath)"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="combat__field">
|
||||
<img class="sprite sprite--player" [src]="playerSprite" [alt]="combat.player.name" />
|
||||
<img
|
||||
class="sprite sprite--monster"
|
||||
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
|
||||
[alt]="combat.monster.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="combat__actions">
|
||||
@if (combat.status === 'ACTIVE') {
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
data-combat-attack
|
||||
[disabled]="combatStore.actionPending()"
|
||||
(click)="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>
|
||||
}
|
||||
</footer>
|
||||
|
||||
@if (combat.status === 'WON') {
|
||||
<div class="outcome outcome--won" data-combat-result="WON">
|
||||
<h2 class="outcome__title">Sieg</h2>
|
||||
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
||||
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
|
||||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||
Zur Jagd
|
||||
</button>
|
||||
</div>
|
||||
} @else if (combat.status === 'LOST') {
|
||||
<div class="outcome outcome--lost" data-combat-result="LOST">
|
||||
<h2 class="outcome__title">Niederlage</h2>
|
||||
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
|
||||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||
Zur Jagd
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside class="combat__log" aria-label="Kampfprotokoll">
|
||||
<h2 class="combat__log-title">Kampflog</h2>
|
||||
<div class="combat__log-body">
|
||||
@for (round of logRounds(); track round.round) {
|
||||
<p class="combat__log-round">Runde {{ round.round }}</p>
|
||||
@for (event of round.events; track event.sequence) {
|
||||
<p class="combat__log-entry" [class.combat__log-entry--player]="event.source === 'PLAYER'">
|
||||
{{ formatEvent(event) }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
} @else if (combatStore.loading()) {
|
||||
<p class="combat__notice" role="status">Kampf wird geladen…</p>
|
||||
}
|
||||
|
||||
@if (combatStore.error(); as error) {
|
||||
<section class="combat__notice combat__notice--error" role="alert">
|
||||
<p>{{ error }}</p>
|
||||
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,479 @@
|
||||
:host {
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.combat {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 17rem);
|
||||
gap: var(--ar-space-4);
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* ---------- stage ---------- */
|
||||
|
||||
.combat__stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: var(--ar-space-4);
|
||||
min-block-size: 26rem;
|
||||
padding: var(--ar-space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ar-border);
|
||||
background-color: var(--ar-bg);
|
||||
background-image: url('/images/backgrounds/Aschestrasse.png');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
}
|
||||
|
||||
@supports (
|
||||
background-image: image-set(
|
||||
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
|
||||
)
|
||||
) {
|
||||
.combat__stage {
|
||||
background-image: image-set(
|
||||
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.combat__stage::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background:
|
||||
linear-gradient(180deg, rgb(4 6 8 / 0.72) 0%, rgb(4 6 8 / 0.1) 26%, rgb(4 6 8 / 0.66) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- combatant status ---------- */
|
||||
|
||||
.combat__status {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--ar-space-4);
|
||||
}
|
||||
|
||||
.fighter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ar-space-3);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.fighter--monster {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fighter__icon {
|
||||
flex: 0 0 auto;
|
||||
inline-size: clamp(2.75rem, 5vw, 3.75rem);
|
||||
block-size: clamp(2.75rem, 5vw, 3.75rem);
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--ar-border-highlight),
|
||||
0 0.3rem 0.9rem rgb(0 0 0 / 0.8);
|
||||
}
|
||||
|
||||
.fighter__meter {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-inline-size: 0;
|
||||
flex: 1 1 auto;
|
||||
max-inline-size: 22rem;
|
||||
}
|
||||
|
||||
.fighter--monster .fighter__meter {
|
||||
justify-items: end;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.fighter__name {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ar-text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(0.95rem, 1.5vw, 1.15rem);
|
||||
letter-spacing: 0.02em;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 0.1rem 0.5rem rgb(0 0 0 / 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fighter__level {
|
||||
color: var(--ar-gold);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- hp bars ---------- */
|
||||
|
||||
.bar {
|
||||
position: relative;
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
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));
|
||||
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
|
||||
}
|
||||
|
||||
.bar__fill {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
display: block;
|
||||
background: linear-gradient(180deg, #d0564a, #8e2b23);
|
||||
}
|
||||
|
||||
.bar--monster .bar__fill {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.bar__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
block-size: 100%;
|
||||
place-items: center;
|
||||
color: var(--ar-text);
|
||||
font-size: var(--ar-font-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.04em;
|
||||
text-shadow: 0 0.05rem 0.25rem rgb(0 0 0 / 1);
|
||||
}
|
||||
|
||||
/* ---------- round marker ---------- */
|
||||
|
||||
.combat__round {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
padding: var(--ar-space-1) var(--ar-space-5);
|
||||
border-block: 1px solid var(--ar-border-highlight);
|
||||
color: var(--ar-gold);
|
||||
background: linear-gradient(90deg, transparent, rgb(9 11 13 / 0.9) 18%, rgb(9 11 13 / 0.9) 82%, transparent);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(0.95rem, 1.4vw, 1.1rem);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- battlefield ---------- */
|
||||
|
||||
.combat__field {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: end;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.sprite {
|
||||
display: block;
|
||||
max-block-size: 100%;
|
||||
inline-size: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75));
|
||||
}
|
||||
|
||||
.sprite--player {
|
||||
justify-self: start;
|
||||
block-size: clamp(11rem, 30vh, 19rem);
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.sprite--monster {
|
||||
justify-self: end;
|
||||
block-size: clamp(9rem, 24vh, 15rem);
|
||||
}
|
||||
|
||||
/* ---------- action bar ---------- */
|
||||
|
||||
.combat__actions {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: var(--ar-space-3);
|
||||
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
|
||||
}
|
||||
|
||||
.action {
|
||||
position: relative;
|
||||
inline-size: clamp(6.5rem, 11vw, 8.5rem);
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent url('/images/hud/runtime/fight-action-frame-360.png') center / 100% 100%
|
||||
no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action__icon {
|
||||
position: absolute;
|
||||
inset-block-start: 32%;
|
||||
inset-inline-start: 50%;
|
||||
inline-size: 34%;
|
||||
translate: -50% -50%;
|
||||
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);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) .action__label,
|
||||
.action:focus-visible .action__label {
|
||||
color: var(--ar-gold);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--ar-gold);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ---------- outcome overlay ---------- */
|
||||
|
||||
.outcome {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
display: grid;
|
||||
gap: var(--ar-space-2);
|
||||
justify-items: center;
|
||||
inline-size: min(26rem, 80%);
|
||||
padding: var(--ar-space-5);
|
||||
translate: -50% -50%;
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
background: rgb(9 11 13 / 0.94);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outcome p {
|
||||
margin: 0;
|
||||
color: var(--ar-text-muted);
|
||||
}
|
||||
|
||||
.outcome__title {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.outcome--won .outcome__title {
|
||||
color: var(--ar-gold);
|
||||
}
|
||||
|
||||
.outcome--lost .outcome__title {
|
||||
color: var(--ar-danger);
|
||||
}
|
||||
|
||||
.outcome__hint {
|
||||
font-size: var(--ar-font-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outcome__button {
|
||||
margin-block-start: var(--ar-space-2);
|
||||
padding: var(--ar-space-2) var(--ar-space-5);
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
border-radius: var(--ar-radius-sm);
|
||||
color: var(--ar-text);
|
||||
background: linear-gradient(180deg, #23282c, #14181b);
|
||||
cursor: pointer;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.outcome__button:hover {
|
||||
border-color: var(--ar-gold);
|
||||
color: var(--ar-gold);
|
||||
}
|
||||
|
||||
/* ---------- combat log ---------- */
|
||||
|
||||
.combat__log {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-block-size: 0;
|
||||
border: 1px solid var(--ar-border);
|
||||
background: var(--ar-panel-muted);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
}
|
||||
|
||||
.combat__log-title {
|
||||
margin: 0;
|
||||
padding: var(--ar-space-3) var(--ar-space-4);
|
||||
border-block-end: 1px solid var(--ar-border);
|
||||
color: var(--ar-gold);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.combat__log-body {
|
||||
padding: var(--ar-space-3) var(--ar-space-4) var(--ar-space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.combat__log-round {
|
||||
margin: var(--ar-space-4) 0 var(--ar-space-2);
|
||||
color: var(--ar-gold);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.combat__log-round:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.combat__log-entry {
|
||||
margin: 0 0 var(--ar-space-1);
|
||||
padding-inline-start: var(--ar-space-3);
|
||||
border-inline-start: 2px solid var(--ar-danger);
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.combat__log-entry--player {
|
||||
border-inline-start-color: var(--ar-border-highlight);
|
||||
}
|
||||
|
||||
/* ---------- notices ---------- */
|
||||
|
||||
.combat__notice {
|
||||
grid-column: 1 / -1;
|
||||
padding: var(--ar-space-4);
|
||||
border: 1px solid var(--ar-border);
|
||||
background: var(--ar-panel);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
}
|
||||
|
||||
.combat__notice--error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ar-space-4);
|
||||
border-color: var(--ar-danger);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.bar__fill {
|
||||
transition: inline-size var(--ar-motion-base);
|
||||
}
|
||||
|
||||
.action__label {
|
||||
transition: color var(--ar-motion-fast);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 60rem) {
|
||||
:host,
|
||||
.combat {
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.combat {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.combat__stage {
|
||||
min-block-size: clamp(24rem, 55vh, 34rem);
|
||||
}
|
||||
|
||||
.combat__log {
|
||||
max-block-size: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
.combat__status {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--ar-space-2);
|
||||
}
|
||||
|
||||
.combat__round {
|
||||
order: -1;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.fighter--monster {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fighter--monster .fighter__meter {
|
||||
justify-items: start;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.bar--monster .bar__fill {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.sprite--player {
|
||||
block-size: clamp(8rem, 22vh, 12rem);
|
||||
}
|
||||
|
||||
.sprite--monster {
|
||||
block-size: clamp(7rem, 18vh, 10rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import type { Combat } from '../../../core/api/game-api.models';
|
||||
import { CombatStore } from '../combat.store';
|
||||
import { CombatPageComponent } from './combat-page.component';
|
||||
|
||||
const activeCombat: Combat = {
|
||||
id: 'combat-1',
|
||||
status: 'ACTIVE',
|
||||
round: 2,
|
||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
|
||||
monster: {
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
currentHp: 31,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
},
|
||||
events: [
|
||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('CombatPageComponent', () => {
|
||||
let combatStore: {
|
||||
combat: ReturnType<typeof signal<Combat | null>>;
|
||||
loading: ReturnType<typeof signal<boolean>>;
|
||||
actionPending: ReturnType<typeof signal<boolean>>;
|
||||
error: ReturnType<typeof signal<string | null>>;
|
||||
loadCombat: ReturnType<typeof vi.fn>;
|
||||
attack: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let router: Router;
|
||||
|
||||
async function setup(combat: Combat | null) {
|
||||
combatStore = {
|
||||
combat: signal(combat),
|
||||
loading: signal(false),
|
||||
actionPending: signal(false),
|
||||
error: signal<string | null>(null),
|
||||
loadCombat: vi.fn(() => Promise.resolve()),
|
||||
attack: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CombatPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: CombatStore, useValue: combatStore },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(CombatPageComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('loads the combat from the route param on init', async () => {
|
||||
await setup(activeCombat);
|
||||
|
||||
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
|
||||
});
|
||||
|
||||
it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain('Aric Duskwalker');
|
||||
expect(element.textContent).toContain('95 / 100');
|
||||
expect(element.textContent).toContain('Aschenratte');
|
||||
expect(element.textContent).toContain('31 / 45');
|
||||
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
|
||||
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the structured events as readable German combat-log entries', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
|
||||
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
|
||||
});
|
||||
|
||||
it('calls combatStore.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();
|
||||
});
|
||||
|
||||
it('disables Angriff while an action is pending', async () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
combatStore.actionPending.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the victory state and hides Angriff when the combat is WON', async () => {
|
||||
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
||||
expect(element.textContent).toContain('Sieg');
|
||||
expect(element.querySelector('[data-combat-attack]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
|
||||
const fixture = await setup({ ...activeCombat, status: 'LOST' });
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
|
||||
expect(element.textContent).toContain('Niederlage');
|
||||
expect(element.querySelector('[data-combat-attack]')).toBeNull();
|
||||
});
|
||||
|
||||
it('navigates to /hunt from the victory screen', async () => {
|
||||
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
|
||||
});
|
||||
|
||||
it('shows an error and retries loading the combat', async () => {
|
||||
const fixture = await setup(null);
|
||||
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Dieser Kampf wurde nicht gefunden.',
|
||||
);
|
||||
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();
|
||||
|
||||
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import type { CombatEvent } from '../../../core/api/game-api.models';
|
||||
import {
|
||||
combatMonsterIconPath,
|
||||
combatMonsterSpritePath,
|
||||
runtimeMonsterArtworkPath,
|
||||
} from '../../../shared/monster-artwork';
|
||||
import { CombatStore } from '../combat.store';
|
||||
|
||||
interface CombatLogRound {
|
||||
round: number;
|
||||
events: CombatEvent[];
|
||||
}
|
||||
|
||||
const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png';
|
||||
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
|
||||
|
||||
@Component({
|
||||
selector: 'app-combat-page',
|
||||
templateUrl: './combat-page.component.html',
|
||||
styleUrl: './combat-page.component.scss',
|
||||
})
|
||||
export class CombatPageComponent implements OnInit {
|
||||
protected readonly combatStore = inject(CombatStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadFromRoute();
|
||||
}
|
||||
|
||||
protected attack(): void {
|
||||
void this.combatStore.attack();
|
||||
}
|
||||
|
||||
protected retry(): 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 monsterIcon(monsterKey: string, artworkPath: string): string {
|
||||
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
||||
}
|
||||
|
||||
protected playerHpPercent(): number {
|
||||
const combat = this.combatStore.combat();
|
||||
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
|
||||
}
|
||||
|
||||
protected monsterHpPercent(): number {
|
||||
const combat = this.combatStore.combat();
|
||||
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
|
||||
}
|
||||
|
||||
protected logRounds(): CombatLogRound[] {
|
||||
const combat = this.combatStore.combat();
|
||||
if (!combat) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rounds = new Map<number, CombatEvent[]>();
|
||||
for (const event of combat.events) {
|
||||
const events = rounds.get(event.round) ?? [];
|
||||
events.push(event);
|
||||
rounds.set(event.round, events);
|
||||
}
|
||||
|
||||
return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events }));
|
||||
}
|
||||
|
||||
protected formatEvent(event: CombatEvent): string {
|
||||
const combat = this.combatStore.combat();
|
||||
const playerName = combat?.player.name ?? 'Du';
|
||||
const monsterName = combat?.monster.name ?? 'Der Gegner';
|
||||
|
||||
if (event.type === 'DAMAGE') {
|
||||
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
|
||||
const defender = event.target === 'PLAYER' ? playerName : monsterName;
|
||||
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
|
||||
}
|
||||
|
||||
if (event.type === 'COMBAT_WON') {
|
||||
return `${monsterName} wurde besiegt.`;
|
||||
}
|
||||
|
||||
return `${playerName} wurde im Kampf besiegt.`;
|
||||
}
|
||||
|
||||
private loadFromRoute(): void {
|
||||
const combatId = this.route.snapshot.paramMap.get('combatId');
|
||||
if (combatId) {
|
||||
void this.combatStore.loadCombat(combatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-combat-placeholder-page',
|
||||
template: `
|
||||
<section class="combat-placeholder" aria-label="Kampfvorbereitung">
|
||||
<span class="combat-placeholder__eyebrow">KAMPF</span>
|
||||
<h2>Vorbereitung auf den Kampf</h2>
|
||||
<p>
|
||||
Die Klinge ist gezogen, der Gegner steht bereit – doch das eigentliche Gefecht liegt noch
|
||||
vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem
|
||||
späteren Schritt folgt.
|
||||
</p>
|
||||
@if (encounterId) {
|
||||
<p class="combat-placeholder__id" data-encounter-id>
|
||||
Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}…
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.combat-placeholder {
|
||||
display: grid;
|
||||
gap: var(--ar-space-3);
|
||||
max-inline-size: 40rem;
|
||||
margin: var(--ar-space-6) auto;
|
||||
padding: var(--ar-space-5);
|
||||
border: 1px solid var(--ar-border);
|
||||
background: var(--ar-panel);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.combat-placeholder__eyebrow {
|
||||
color: var(--ar-gold);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.combat-placeholder h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.combat-placeholder p {
|
||||
margin: 0;
|
||||
color: var(--ar-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.combat-placeholder__id {
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CombatPlaceholderPageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId');
|
||||
}
|
||||
161
apps/web/src/app/features/combat/combat.store.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { from, of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import type { Combat } from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
import { CombatStore } from './combat.store';
|
||||
|
||||
const startedCombat: Combat = {
|
||||
id: 'combat-1',
|
||||
status: 'ACTIVE',
|
||||
round: 1,
|
||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
|
||||
monster: {
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
currentHp: 45,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
|
||||
const afterAttack: Combat = {
|
||||
...startedCombat,
|
||||
round: 2,
|
||||
player: { ...startedCombat.player, currentHp: 95 },
|
||||
monster: { ...startedCombat.monster, currentHp: 31 },
|
||||
events: [
|
||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('CombatStore', () => {
|
||||
let api: {
|
||||
startCombat: ReturnType<typeof vi.fn>;
|
||||
getCombat: ReturnType<typeof vi.fn>;
|
||||
performCombatAction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let store: CombatStore;
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
startCombat: vi.fn(() => of(startedCombat)),
|
||||
getCombat: vi.fn(() => of(startedCombat)),
|
||||
performCombatAction: vi.fn(() => of(afterAttack)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [CombatStore, { provide: GameApiService, useValue: api }],
|
||||
});
|
||||
store = TestBed.inject(CombatStore);
|
||||
});
|
||||
|
||||
it('starts a combat and stores it', async () => {
|
||||
await store.startCombat('encounter-1');
|
||||
|
||||
expect(api.startCombat).toHaveBeenCalledWith('encounter-1');
|
||||
expect(store.combat()).toEqual(startedCombat);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears any previous combat and reports the mapped error when starting fails', async () => {
|
||||
api.startCombat.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 409,
|
||||
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startCombat('encounter-1');
|
||||
|
||||
expect(store.combat()).toBeNull();
|
||||
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
|
||||
});
|
||||
|
||||
it('loads a combat by id', async () => {
|
||||
await store.loadCombat('combat-1');
|
||||
|
||||
expect(api.getCombat).toHaveBeenCalledWith('combat-1');
|
||||
expect(store.combat()).toEqual(startedCombat);
|
||||
});
|
||||
|
||||
it('reports the mapped error when loading an unknown combat', async () => {
|
||||
api.getCombat.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 404,
|
||||
error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.loadCombat('unknown');
|
||||
|
||||
expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
|
||||
});
|
||||
|
||||
it('sends only the ATTACK action and replaces combat with the server response', async () => {
|
||||
await store.startCombat('encounter-1');
|
||||
|
||||
await store.attack();
|
||||
|
||||
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
|
||||
expect(store.combat()).toEqual(afterAttack);
|
||||
});
|
||||
|
||||
it('does nothing when attacking without a loaded combat', async () => {
|
||||
await store.attack();
|
||||
|
||||
expect(api.performCombatAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a second attack while the first is still pending', async () => {
|
||||
await store.startCombat('encounter-1');
|
||||
let resolveAttack!: (value: Combat) => void;
|
||||
api.performCombatAction.mockReturnValue(
|
||||
from(
|
||||
new Promise<Combat>((resolve) => {
|
||||
resolveAttack = resolve;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const first = store.attack();
|
||||
expect(store.actionPending()).toBe(true);
|
||||
const second = store.attack();
|
||||
|
||||
resolveAttack(afterAttack);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(api.performCombatAction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears actionPending after a failed attack and keeps the previous combat state', async () => {
|
||||
await store.startCombat('encounter-1');
|
||||
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
||||
|
||||
await store.attack();
|
||||
|
||||
expect(store.actionPending()).toBe(false);
|
||||
expect(store.combat()).toEqual(startedCombat);
|
||||
expect(store.error()).toBe('Netzwerkfehler');
|
||||
});
|
||||
|
||||
it('clears the error message', async () => {
|
||||
api.startCombat.mockReturnValue(throwError(() => new Error('x')));
|
||||
await store.startCombat('encounter-1');
|
||||
expect(store.error()).not.toBeNull();
|
||||
|
||||
store.clearError();
|
||||
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
95
apps/web/src/app/features/combat/combat.store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Combat } from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
|
||||
|
||||
// Mirrors the combat error codes returned by the combat endpoints.
|
||||
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
|
||||
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
|
||||
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
|
||||
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
|
||||
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
|
||||
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
|
||||
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
|
||||
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CombatStore {
|
||||
private readonly combatState = signal<Combat | null>(null);
|
||||
private readonly loadingState = signal(false);
|
||||
private readonly actionPendingState = signal(false);
|
||||
private readonly errorState = signal<string | null>(null);
|
||||
|
||||
readonly combat = this.combatState.asReadonly();
|
||||
readonly loading = this.loadingState.asReadonly();
|
||||
readonly actionPending = this.actionPendingState.asReadonly();
|
||||
readonly error = this.errorState.asReadonly();
|
||||
|
||||
constructor(private readonly api: GameApiService) {}
|
||||
|
||||
async startCombat(encounterId: string): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
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));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCombat(combatId: string): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const combat = await firstValueFrom(this.api.getCombat(combatId));
|
||||
this.combatState.set(combat);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async attack(): Promise<void> {
|
||||
const combat = this.combatState();
|
||||
if (!combat || this.actionPendingState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionPendingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
|
||||
this.combatState.set(updated);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.actionPendingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.errorState.set(null);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const code = (error.error as { code?: string } | null)?.code;
|
||||
return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { HuntEncounter } from '../../../core/api/game-api.models';
|
||||
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
|
||||
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
|
||||
|
||||
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
|
||||
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
|
||||
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-encounter-card',
|
||||
imports: [DangerBadgeComponent],
|
||||
@@ -22,6 +18,6 @@ export class EncounterCardComponent {
|
||||
}
|
||||
|
||||
protected runtimeArtworkPath(artworkPath: string): string | undefined {
|
||||
return runtimeArtworkPaths[artworkPath];
|
||||
return runtimeMonsterArtworkPath(artworkPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +64,11 @@
|
||||
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (combatStore.error(); as combatError) {
|
||||
<section class="hunt-page__error" role="alert">
|
||||
<p>{{ combatError }}</p>
|
||||
<button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
|
||||
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
|
||||
import { CombatStore } from '../../combat/combat.store';
|
||||
import { WorldStore } from '../../world/world.store';
|
||||
import { HuntingStore } from '../hunting.store';
|
||||
import { HuntPageComponent } from './hunt-page.component';
|
||||
@@ -42,12 +44,7 @@ const threeEncounterHunt: HuntResult = {
|
||||
encounters: [
|
||||
{
|
||||
id: 'encounter-1',
|
||||
monster: {
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
artworkPath: '/images/enemies/AshRat.png',
|
||||
},
|
||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
},
|
||||
{
|
||||
@@ -62,17 +59,28 @@ const threeEncounterHunt: HuntResult = {
|
||||
},
|
||||
{
|
||||
id: 'encounter-3',
|
||||
monster: {
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
artworkPath: '/images/enemies/AshRat.png',
|
||||
},
|
||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const startedCombat: Combat = {
|
||||
id: 'combat-2',
|
||||
status: 'ACTIVE',
|
||||
round: 1,
|
||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
|
||||
monster: {
|
||||
key: 'road-bandit',
|
||||
name: 'Straßenräuber',
|
||||
level: 3,
|
||||
maxHp: 75,
|
||||
currentHp: 75,
|
||||
artworkPath: '/images/enemies/RoadBandit.png',
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
|
||||
describe('HuntPageComponent', () => {
|
||||
let worldStore: {
|
||||
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
|
||||
@@ -87,6 +95,12 @@ describe('HuntPageComponent', () => {
|
||||
refreshHunt: ReturnType<typeof vi.fn>;
|
||||
selectEncounter: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let combatStore: {
|
||||
combat: ReturnType<typeof signal<Combat | null>>;
|
||||
error: ReturnType<typeof signal<string | null>>;
|
||||
startCombat: ReturnType<typeof vi.fn>;
|
||||
clearError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let router: Router;
|
||||
|
||||
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
|
||||
@@ -101,6 +115,12 @@ describe('HuntPageComponent', () => {
|
||||
refreshHunt: vi.fn(() => Promise.resolve()),
|
||||
selectEncounter: vi.fn(),
|
||||
};
|
||||
combatStore = {
|
||||
combat: signal<Combat | null>(null),
|
||||
error: signal<string | null>(null),
|
||||
startCombat: vi.fn(() => Promise.resolve()),
|
||||
clearError: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HuntPageComponent],
|
||||
@@ -108,6 +128,7 @@ describe('HuntPageComponent', () => {
|
||||
provideRouter([]),
|
||||
{ provide: WorldStore, useValue: worldStore },
|
||||
{ provide: HuntingStore, useValue: huntingStore },
|
||||
{ provide: CombatStore, useValue: combatStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -124,9 +145,7 @@ describe('HuntPageComponent', () => {
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain('Keine Jagd verfügbar');
|
||||
expect(element.textContent).toContain(
|
||||
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
|
||||
);
|
||||
expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
|
||||
expect(
|
||||
Array.from(element.querySelectorAll('button')).some(
|
||||
(button) => button.textContent?.trim() === 'Jagd beginnen',
|
||||
@@ -171,8 +190,11 @@ describe('HuntPageComponent', () => {
|
||||
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => {
|
||||
it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
|
||||
const fixture = await setup(burnedRoad, threeEncounterHunt);
|
||||
combatStore.startCombat.mockImplementation(async () => {
|
||||
combatStore.combat.set(startedCombat);
|
||||
});
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
|
||||
@@ -181,14 +203,43 @@ describe('HuntPageComponent', () => {
|
||||
expect(attackButtons.length).toBe(3);
|
||||
|
||||
attackButtons[1].click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], {
|
||||
queryParams: { encounterId: 'encounter-2' },
|
||||
});
|
||||
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
|
||||
queryParams: { encounterId: 'road-bandit' },
|
||||
});
|
||||
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
|
||||
expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
|
||||
});
|
||||
|
||||
it('does not navigate when starting the combat fails', async () => {
|
||||
const fixture = await setup(burnedRoad, threeEncounterHunt);
|
||||
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();
|
||||
|
||||
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
|
||||
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.');
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
|
||||
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
|
||||
|
||||
expect(combatStore.clearError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not trigger a hunt automatically on page entry', async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CombatStore } from '../../combat/combat.store';
|
||||
import { WorldStore } from '../../world/world.store';
|
||||
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
|
||||
import { HuntingStore } from '../hunting.store';
|
||||
import { WorldStore } from '../../world/world.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hunt-page',
|
||||
@@ -13,6 +14,7 @@ import { WorldStore } from '../../world/world.store';
|
||||
export class HuntPageComponent implements OnInit {
|
||||
protected readonly worldStore = inject(WorldStore);
|
||||
protected readonly huntingStore = inject(HuntingStore);
|
||||
protected readonly combatStore = inject(CombatStore);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -41,8 +43,15 @@ export class HuntPageComponent implements OnInit {
|
||||
void this.router.navigate(['/world']);
|
||||
}
|
||||
|
||||
protected onAttack(encounterId: string): void {
|
||||
this.huntingStore.selectEncounter(encounterId);
|
||||
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } });
|
||||
protected async onAttack(encounterId: string): Promise<void> {
|
||||
await this.combatStore.startCombat(encounterId);
|
||||
const combat = this.combatStore.combat();
|
||||
if (combat) {
|
||||
void this.router.navigate(['/combat', combat.id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected dismissCombatError(): void {
|
||||
this.combatStore.clearError();
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/shared/monster-artwork.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { runtimeMonsterArtworkPath } from './monster-artwork';
|
||||
|
||||
describe('runtimeMonsterArtworkPath', () => {
|
||||
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
|
||||
expect(runtimeMonsterArtworkPath('/images/monsters/ash-rat.png')).toBe(
|
||||
'/images/monsters/runtime/ash-rat-560.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for an artwork path with no runtime derivative', () => {
|
||||
expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
26
apps/web/src/app/shared/monster-artwork.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
|
||||
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
|
||||
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
||||
};
|
||||
|
||||
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
|
||||
return RUNTIME_MONSTER_ARTWORK[artworkPath];
|
||||
}
|
||||
|
||||
const COMBAT_MONSTER_SPRITE: Readonly<Record<string, string>> = {
|
||||
'ash-rat': '/images/combat/sprites/ash-rat-760.png',
|
||||
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
|
||||
};
|
||||
|
||||
const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
|
||||
'ash-rat': '/images/combat/icons/ash-rat-128.png',
|
||||
'road-bandit': '/images/combat/icons/road-bandit-128.png',
|
||||
};
|
||||
|
||||
export function combatMonsterSpritePath(monsterKey: string): string | undefined {
|
||||
return COMBAT_MONSTER_SPRITE[monsterKey];
|
||||
}
|
||||
|
||||
export function combatMonsterIconPath(monsterKey: string): string | undefined {
|
||||
return COMBAT_MONSTER_ICON[monsterKey];
|
||||
}
|
||||