feat(combat): add CombatPageComponent and replace the combat/new placeholder route
Wires up the Slice 0.3 combat screen (player/monster HP bars, round display, Angriff action, grouped German combat log, victory/defeat panels) and replaces the Slice 0.2 combat/new placeholder route with combat/:combatId loading CombatPageComponent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,10 @@ export const routes: Routes = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'combat/new',
|
path: 'combat/:combatId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/combat/combat-placeholder-page.component').then(
|
import('./features/combat/combat-page/combat-page.component').then(
|
||||||
(module) => module.CombatPlaceholderPageComponent,
|
(module) => module.CombatPageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<section class="combat-page" aria-label="Kampf">
|
||||||
|
@if (combatStore.combat(); as combat) {
|
||||||
|
<div class="combat-page__scene">
|
||||||
|
<div class="combat-page__combatant combat-page__combatant--player">
|
||||||
|
<img
|
||||||
|
class="combat-page__portrait"
|
||||||
|
src="/images/hud/runtime/CharacterIcon-128.png"
|
||||||
|
[alt]="combat.player.name"
|
||||||
|
/>
|
||||||
|
<div class="combat-page__info">
|
||||||
|
<span class="combat-page__name">{{ combat.player.name }}</span>
|
||||||
|
<div class="combat-page__hp" aria-label="Lebenspunkte">
|
||||||
|
<span>{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
|
||||||
|
<span class="combat-page__hp-track" aria-hidden="true">
|
||||||
|
<span class="combat-page__hp-value" [style.inline-size.%]="playerHpPercent()"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combat-page__round" data-combat-round>Runde {{ combat.round }}</div>
|
||||||
|
|
||||||
|
<div class="combat-page__combatant combat-page__combatant--monster">
|
||||||
|
<picture>
|
||||||
|
@if (runtimeMonsterArtwork(combat.monster.artworkPath); as runtimeArtwork) {
|
||||||
|
<source [srcset]="runtimeArtwork" type="image/jpeg" />
|
||||||
|
}
|
||||||
|
<img
|
||||||
|
class="combat-page__portrait"
|
||||||
|
[src]="combat.monster.artworkPath"
|
||||||
|
[alt]="combat.monster.name"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<div class="combat-page__info">
|
||||||
|
<span class="combat-page__name">{{ combat.monster.name }}</span>
|
||||||
|
<span class="combat-page__level">Stufe {{ combat.monster.level }}</span>
|
||||||
|
<div class="combat-page__hp" aria-label="Lebenspunkte des Gegners">
|
||||||
|
<span>{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
|
||||||
|
<span class="combat-page__hp-track" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
class="combat-page__hp-value combat-page__hp-value--monster"
|
||||||
|
[style.inline-size.%]="monsterHpPercent()"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combat-page__actions">
|
||||||
|
@if (combat.status === 'ACTIVE') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="combat-page__attack"
|
||||||
|
data-combat-attack
|
||||||
|
[disabled]="combatStore.actionPending()"
|
||||||
|
(click)="attack()"
|
||||||
|
>
|
||||||
|
Angriff
|
||||||
|
</button>
|
||||||
|
} @else if (combat.status === 'WON') {
|
||||||
|
<section class="combat-page__result combat-page__result--won" data-combat-result="WON">
|
||||||
|
<h2>Sieg</h2>
|
||||||
|
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
||||||
|
<p class="combat-page__result-hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
|
||||||
|
<button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="combat-page__result combat-page__result--lost" data-combat-result="LOST">
|
||||||
|
<h2>Niederlage</h2>
|
||||||
|
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
|
||||||
|
<button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="combat-page__log" aria-label="Kampfprotokoll">
|
||||||
|
@for (round of logRounds(); track round.round) {
|
||||||
|
<p class="combat-page__log-round">Runde {{ round.round }}</p>
|
||||||
|
@for (event of round.events; track event.sequence) {
|
||||||
|
<p class="combat-page__log-entry">{{ formatEvent(event) }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
} @else if (combatStore.loading()) {
|
||||||
|
<p class="combat-page__loading" role="status">Kampf wird geladen…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (combatStore.error(); as error) {
|
||||||
|
<section class="combat-page__error" role="alert">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__scene {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
min-block-size: clamp(18rem, 40vh, 26rem);
|
||||||
|
padding: var(--ar-space-5);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background-color: #151718;
|
||||||
|
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-page__scene {
|
||||||
|
background-image: image-set(
|
||||||
|
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__scene::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(180deg, rgb(4 6 8 / 0.15), rgb(4 6 8 / 0.6));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__combatant {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__combatant--player {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__combatant--monster {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__portrait {
|
||||||
|
inline-size: clamp(6rem, 14vw, 10rem);
|
||||||
|
block-size: clamp(6rem, 14vw, 10rem);
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-md);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__info {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
min-inline-size: 10rem;
|
||||||
|
color: var(--ar-text);
|
||||||
|
text-shadow: 0 0.1rem 0.4rem rgb(0 0 0 / 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__name {
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__level {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__hp {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__hp-track {
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__hp-value {
|
||||||
|
display: block;
|
||||||
|
block-size: 100%;
|
||||||
|
background: var(--ar-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__hp-value--monster {
|
||||||
|
background: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__round {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
align-self: start;
|
||||||
|
justify-self: center;
|
||||||
|
padding: var(--ar-space-1) var(--ar-space-3);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-gold);
|
||||||
|
background: rgb(9 11 13 / 0.75);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__actions {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__attack {
|
||||||
|
padding: var(--ar-space-3) var(--ar-space-6);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-text);
|
||||||
|
background: linear-gradient(180deg, #263b4b, #17232d);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__attack:hover:not(:disabled) {
|
||||||
|
border-color: #d6b26b;
|
||||||
|
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__attack:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__result {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
padding: var(--ar-space-5);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__result h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__result--won h2 {
|
||||||
|
color: var(--ar-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__result--lost h2 {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__result-hint {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__log {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
max-block-size: 16rem;
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: var(--ar-panel-muted);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__log-round {
|
||||||
|
margin: var(--ar-space-2) 0 0;
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__log-entry {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__loading,
|
||||||
|
.combat-page__error {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
border-color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__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) {
|
||||||
|
.combat-page__hp-value {
|
||||||
|
transition: inline-size var(--ar-motion-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 720px) {
|
||||||
|
.combat-page__scene {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-page__combatant--player,
|
||||||
|
.combat-page__combatant--monster {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,92 @@
|
|||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import type { CombatEvent } from '../../../core/api/game-api.models';
|
||||||
|
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
|
||||||
|
import { CombatStore } from '../combat.store';
|
||||||
|
|
||||||
|
interface CombatLogRound {
|
||||||
|
round: number;
|
||||||
|
events: CombatEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 runtimeMonsterArtwork(artworkPath: string): string | undefined {
|
||||||
|
return runtimeMonsterArtworkPath(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');
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user