From 1fd62cddde3e9238320c11e231eb77a51230887f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 16:53:59 +0200 Subject: [PATCH] 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 --- apps/web/src/app/app.routes.ts | 6 +- .../combat-page/combat-page.component.html | 95 +++++++ .../combat-page/combat-page.component.scss | 263 ++++++++++++++++++ .../combat-page/combat-page.component.spec.ts | 153 ++++++++++ .../combat-page/combat-page.component.ts | 92 ++++++ .../combat-placeholder-page.component.ts | 72 ----- 6 files changed, 606 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/app/features/combat/combat-page/combat-page.component.html create mode 100644 apps/web/src/app/features/combat/combat-page/combat-page.component.scss create mode 100644 apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts create mode 100644 apps/web/src/app/features/combat/combat-page/combat-page.component.ts delete mode 100644 apps/web/src/app/features/combat/combat-placeholder-page.component.ts diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 58c1bc8..8a0c082 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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, ), }, ], diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html new file mode 100644 index 0000000..3dec31f --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -0,0 +1,95 @@ +
+ @if (combatStore.combat(); as combat) { +
+
+ +
+ {{ combat.player.name }} +
+ {{ combat.player.currentHp }} / {{ combat.player.maxHp }} + +
+
+
+ +
Runde {{ combat.round }}
+ +
+ + @if (runtimeMonsterArtwork(combat.monster.artworkPath); as runtimeArtwork) { + + } + + +
+ {{ combat.monster.name }} + Stufe {{ combat.monster.level }} +
+ {{ combat.monster.currentHp }} / {{ combat.monster.maxHp }} + +
+
+
+
+ +
+ @if (combat.status === 'ACTIVE') { + + } @else if (combat.status === 'WON') { +
+

Sieg

+

{{ combat.monster.name }} wurde besiegt.

+

Belohnungen werden im nächsten Schritt verarbeitet.

+ +
+ } @else { +
+

Niederlage

+

{{ combat.player.name }} wurde im Kampf besiegt.

+ +
+ } +
+ + + } @else if (combatStore.loading()) { +

Kampf wird geladen…

+ } + + @if (combatStore.error(); as error) { + + } +
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss new file mode 100644 index 0000000..4000097 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts new file mode 100644 index 0000000..cafe065 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -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>; + loading: ReturnType>; + actionPending: ReturnType>; + error: ReturnType>; + loadCombat: ReturnType; + attack: ReturnType; + }; + let router: Router; + + async function setup(combat: Combat | null) { + combatStore = { + combat: signal(combat), + loading: signal(false), + actionPending: signal(false), + error: signal(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('[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('[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('[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('[data-combat-retry]')?.click(); + + expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts new file mode 100644 index 0000000..19eaa4c --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -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(); + 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); + } + } +} diff --git a/apps/web/src/app/features/combat/combat-placeholder-page.component.ts b/apps/web/src/app/features/combat/combat-placeholder-page.component.ts deleted file mode 100644 index bffeff5..0000000 --- a/apps/web/src/app/features/combat/combat-placeholder-page.component.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; - -@Component({ - selector: 'app-combat-placeholder-page', - template: ` -
- KAMPF -

Vorbereitung auf den Kampf

-

- 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. -

- @if (encounterId) { -

- Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}… -

- } -
- `, - 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'); -}