feat: render interactive ashen fields world

This commit is contained in:
Bastian Wagner
2026-08-19 08:25:42 +02:00
parent fce03e220d
commit 1e2bfe6e4b
21 changed files with 1933 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -8,9 +8,13 @@ import { routes } from './app.routes';
describe('App', () => {
let character: WritableSignal<CharacterResponse | null>;
let currentLocation: WritableSignal<null>;
let selectedConnection: WritableSignal<null>;
beforeEach(async () => {
character = signal<CharacterResponse | null>(null);
currentLocation = signal(null);
selectedConnection = signal(null);
await TestBed.configureTestingModule({
imports: [AppShellComponent],
@@ -18,7 +22,7 @@ describe('App', () => {
provideRouter([]),
{
provide: WorldStore,
useValue: { character },
useValue: { character, currentLocation, selectedConnection },
},
],
}).compileComponents();

View File

@@ -0,0 +1,17 @@
<button
type="button"
class="location-node"
[class.location-node--current]="current"
[class.location-node--selected]="selected"
[disabled]="disabled"
[attr.aria-current]="current ? 'location' : null"
[attr.aria-pressed]="current ? null : selected"
[attr.data-location-key]="location.key"
(click)="choose.emit()"
>
<span class="location-node__marker" aria-hidden="true"></span>
<span class="location-node__name">{{ location.name }}</span>
<span class="location-node__state">{{
current ? 'Aktueller Ort' : selected ? 'Ausgewähltes Ziel' : 'Erreichbar'
}}</span>
</button>

View File

@@ -0,0 +1,65 @@
:host {
display: block;
}
.location-node {
display: grid;
justify-items: center;
gap: 0.15rem;
min-inline-size: 8.5rem;
padding: 0;
border: 0;
color: var(--ar-text);
background: transparent;
cursor: pointer;
font: inherit;
text-align: center;
text-shadow: 0 0.15rem 0.55rem rgb(0 0 0 / 0.95);
}
.location-node:disabled {
cursor: default;
opacity: 1;
}
.location-node__marker {
inline-size: 1.35rem;
block-size: 1.35rem;
border: 0.18rem solid var(--ar-gold);
border-radius: 50%;
background: radial-gradient(circle, #211b13 26%, #8d6533 30%, #17191b 68%);
box-shadow:
0 0 0 0.15rem rgb(6 8 9 / 0.75),
0 0 0.75rem rgb(208 162 84 / 0.42);
}
.location-node__name {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
line-height: 1.2;
}
.location-node__state {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
font-weight: 700;
}
.location-node--current .location-node__marker {
border-color: var(--ar-blue);
box-shadow:
0 0 0 0.15rem rgb(6 8 9 / 0.75),
0 0 0.9rem rgb(92 169 216 / 0.8);
}
.location-node--current .location-node__state {
color: var(--ar-success);
}
.location-node--selected .location-node__marker,
.location-node:not(:disabled):hover .location-node__marker {
border-color: #e1bd72;
box-shadow:
0 0 0 0.15rem rgb(6 8 9 / 0.75),
0 0 1rem rgb(225 189 114 / 0.85);
}

View File

@@ -0,0 +1,15 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LocationSummary } from '../../core/api/game-api.models';
@Component({
selector: 'app-location-node',
templateUrl: './location-node.component.html',
styleUrl: './location-node.component.scss',
})
export class LocationNodeComponent {
@Input({ required: true }) location!: LocationSummary;
@Input() current = false;
@Input() selected = false;
@Input() disabled = false;
@Output() readonly choose = new EventEmitter<void>();
}

View File

@@ -0,0 +1,42 @@
<section class="travel-panel" aria-live="polite">
@if (travellingTravel; as travel) {
<span class="travel-panel__eyebrow">REISE LÄUFT</span>
<h2>Reiseziel: {{ travel.targetLocation.name }}</h2>
<dl class="travel-panel__details">
<div>
<dt>Ankunft in</dt>
<dd>{{ formatDuration(remainingSeconds ?? 0) }}</dd>
</div>
<div>
<dt>Ziel</dt>
<dd>{{ travel.targetLocation.name }}</dd>
</div>
</dl>
<button type="button" data-travel-start disabled>Reise läuft</button>
} @else if (selectedConnection; as connection) {
<span class="travel-panel__eyebrow">REISEN</span>
<h2>Zur {{ connection.targetLocation.name }} reisen?</h2>
<dl class="travel-panel__details">
<div>
<dt>Ziel</dt>
<dd>{{ connection.targetLocation.name }}</dd>
</div>
<div>
<dt>Reisezeit</dt>
<dd>{{ formatDuration(connection.travelDurationSeconds) }}</dd>
</div>
<div>
<dt>Gefahr</dt>
<dd [class.travel-panel__danger--low]="connection.danger === 'LOW'">
{{ dangerLabel(connection.danger) }}
</dd>
</div>
</dl>
<button type="button" data-travel-start [disabled]="busy" (click)="travelStart.emit()">
Reise beginnen
</button>
} @else {
<span class="travel-panel__eyebrow">REISEN</span>
<p class="travel-panel__instruction">Wähle einen erreichbaren Ort auf der Karte.</p>
}
</section>

View File

@@ -0,0 +1,90 @@
:host {
display: block;
}
.travel-panel {
padding: var(--ar-space-3) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
background:
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.travel-panel__eyebrow {
color: var(--ar-gold);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
}
.travel-panel h2 {
margin: var(--ar-space-1) 0 var(--ar-space-3);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
font-weight: 400;
}
.travel-panel__details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--ar-space-2);
margin: 0 0 var(--ar-space-3);
padding-block: var(--ar-space-2);
border-block: 1px solid rgb(155 122 66 / 0.45);
}
.travel-panel__details div {
min-inline-size: 0;
}
.travel-panel dt {
color: var(--ar-text-muted);
font-size: 0.7rem;
}
.travel-panel dd {
margin: var(--ar-space-1) 0 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
}
.travel-panel__danger--low {
color: var(--ar-success);
}
.travel-panel button {
inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4);
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: 1rem;
}
.travel-panel button:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.travel-panel button:disabled {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: #1a1c1d;
cursor: not-allowed;
}
.travel-panel__instruction {
margin: var(--ar-space-2) 0 0;
color: var(--ar-text-muted);
}
@media (width < 720px) {
.travel-panel__details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,32 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CurrentLocationConnection, CurrentTravel } from '../../core/api/game-api.models';
@Component({
selector: 'app-travel-panel',
templateUrl: './travel-panel.component.html',
styleUrl: './travel-panel.component.scss',
})
export class TravelPanelComponent {
@Input() selectedConnection: CurrentLocationConnection | null = null;
@Input() currentTravel: CurrentTravel | null = null;
@Input() remainingSeconds: number | null = null;
@Input() busy = false;
@Output() readonly travelStart = new EventEmitter<void>();
protected get travellingTravel(): Extract<CurrentTravel, { status: 'TRAVELLING' }> | null {
return this.currentTravel?.status === 'TRAVELLING' ? this.currentTravel : null;
}
protected formatDuration(seconds: number): string {
const clampedSeconds = Math.max(0, seconds);
const hours = Math.floor(clampedSeconds / 3_600);
const minutes = Math.floor((clampedSeconds % 3_600) / 60);
const remainder = clampedSeconds % 60;
return [hours, minutes, remainder].map((value) => value.toString().padStart(2, '0')).join(':');
}
protected dangerLabel(danger: CurrentLocationConnection['danger']): string {
return danger === 'LOW' ? 'Niedrig' : 'Hoch';
}
}

View File

@@ -0,0 +1,64 @@
<section class="world-page" aria-label="Weltansicht">
@if (worldStore.currentLocation(); as location) {
<section
class="world-page__scene"
[style.background-image]="mapBackground"
[attr.aria-label]="'Weltkarte bei ' + location.name"
>
<div class="world-page__atmosphere" aria-hidden="true"></div>
<p class="world-page__location-title">{{ location.name }}</p>
<svg
class="world-page__path"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path d="M 21 62 C 36 64, 51 50, 68 43" pathLength="1" />
</svg>
<app-location-node
class="world-page__node world-page__node--current"
[location]="{ id: location.id, key: location.key, name: location.name }"
[current]="true"
[disabled]="true"
/>
@for (connection of location.connections; track connection.targetLocation.id) {
<app-location-node
class="world-page__node world-page__node--reachable"
[location]="connection.targetLocation"
[selected]="
worldStore.selectedConnection()?.targetLocation?.id === connection.targetLocation.id
"
[disabled]="worldStore.currentTravel()?.status === 'TRAVELLING'"
(choose)="selectConnection(connection)"
/>
}
<app-travel-panel
class="world-page__travel-panel"
[selectedConnection]="worldStore.selectedConnection()"
[currentTravel]="worldStore.currentTravel()"
[remainingSeconds]="worldStore.remainingSeconds()"
[busy]="worldStore.loading()"
(travelStart)="worldStore.startTravel()"
/>
</section>
} @else {
<section class="world-page__empty" aria-live="polite">
<p>Weltkarte wird vorbereitet.</p>
</section>
}
@if (worldStore.loading()) {
<p class="world-page__loading" role="status">Weltzustand wird geladen…</p>
}
@if (worldStore.error(); as error) {
<section class="world-page__error" role="alert">
<p>{{ error }}</p>
<button type="button" (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,164 @@
:host {
display: block;
min-block-size: 100%;
}
.world-page {
position: relative;
min-block-size: 100%;
}
.world-page__scene {
position: relative;
isolation: isolate;
min-block-size: clamp(30rem, 67vh, 44rem);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: #151718;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
.world-page__scene::after {
position: absolute;
z-index: -1;
inset: 0;
content: '';
background: linear-gradient(180deg, rgb(4 6 8 / 0.1), rgb(4 6 8 / 0.45));
pointer-events: none;
}
.world-page__atmosphere {
position: absolute;
z-index: -1;
inset: 16% -16%;
background:
radial-gradient(circle at 28% 58%, rgb(210 103 47 / 0.24) 0 0.1rem, transparent 0.22rem),
radial-gradient(circle at 72% 43%, rgb(220 131 56 / 0.2) 0 0.08rem, transparent 0.18rem),
linear-gradient(90deg, transparent, rgb(183 188 194 / 0.08), transparent);
opacity: 0.7;
pointer-events: none;
animation: world-atmosphere-drift 10s ease-in-out infinite alternate;
}
.world-page__location-title {
position: absolute;
inset: var(--ar-space-5) auto auto var(--ar-space-5);
max-inline-size: calc(100% - 3rem);
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.35rem, 2.3vw, 2.2rem);
text-shadow: 0 0.15rem 0.7rem rgb(0 0 0 / 0.9);
}
.world-page__path {
position: absolute;
z-index: 1;
inline-size: 100%;
block-size: 100%;
overflow: visible;
pointer-events: none;
}
.world-page__path path {
fill: none;
stroke: var(--ar-gold);
stroke-width: 0.55;
stroke-dasharray: 0.012 0.02;
filter: drop-shadow(0 0 0.3rem rgb(0 0 0 / 0.85));
opacity: 0.9;
}
.world-page__node {
position: absolute;
z-index: 2;
}
.world-page__node--current {
inset: 58% auto auto 12%;
}
.world-page__node--reachable {
inset: 39% auto auto 60%;
}
.world-page__travel-panel {
position: absolute;
z-index: 3;
inset: auto 50% var(--ar-space-5) auto;
inline-size: min(29rem, calc(100% - 2rem));
transform: translateX(50%);
}
.world-page__loading,
.world-page__error,
.world-page__empty {
margin: var(--ar-space-4) 0 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.world-page__loading,
.world-page__empty {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
}
.world-page__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
padding: var(--ar-space-3) var(--ar-space-4);
border-color: var(--ar-danger);
}
.world-page__error p {
margin: 0;
}
.world-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;
}
@keyframes world-atmosphere-drift {
from {
opacity: 0.35;
transform: translateX(-2%);
}
to {
opacity: 0.75;
transform: translateX(2%);
}
}
@media (width < 720px) {
.world-page__scene {
min-block-size: 34rem;
}
.world-page__node--current {
inset-inline-start: 6%;
}
.world-page__node--reachable {
inset-inline-start: 51%;
}
}
@media (prefers-reduced-motion: reduce) {
.world-page__atmosphere {
animation: none;
}
}

View File

@@ -0,0 +1,127 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type {
CurrentLocationConnection,
CurrentLocationResponse,
CurrentTravel,
} from '../../core/api/game-api.models';
import { WorldStore } from './world.store';
import { WorldPageComponent } from './world-page.component';
const burnedRoadConnection: CurrentLocationConnection = {
targetLocation: {
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
},
travelDurationSeconds: 10,
danger: 'LOW',
};
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [burnedRoadConnection],
};
describe('WorldPageComponent', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
let store: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
currentTravel: ReturnType<typeof signal<CurrentTravel | null>>;
remainingSeconds: ReturnType<typeof signal<number | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
load: () => Promise<void>;
selectConnection: (connection: CurrentLocationConnection | null) => void;
startTravel: () => Promise<void>;
};
beforeEach(async () => {
selectedConnection = signal<CurrentLocationConnection | null>(null);
store = {
currentLocation: signal(southGate),
selectedConnection,
currentTravel: signal<CurrentTravel | null>({ status: 'IDLE' }),
remainingSeconds: signal<number | null>(null),
loading: signal(false),
error: signal<string | null>(null),
load: vi.fn(() => Promise.resolve()),
selectConnection: vi.fn((connection: CurrentLocationConnection | null) =>
selectedConnection.set(connection),
),
startTravel: vi.fn(() => Promise.resolve()),
};
await TestBed.configureTestingModule({
imports: [WorldPageComponent],
providers: [{ provide: WorldStore, useValue: store }],
}).compileComponents();
});
it('loads world state on initialization', () => {
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
expect(store.load).toHaveBeenCalledOnce();
});
it('selects the burned-road connection and displays its travel details', () => {
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-location-key="burned-road"]')?.click();
fixture.detectChanges();
expect(store.selectConnection).toHaveBeenCalledWith(burnedRoadConnection);
expect(element.textContent).toContain('Ziel');
expect(element.textContent).toContain('Verbrannte Straße');
expect(element.textContent).toContain('00:00:10');
expect(element.textContent).toContain('Niedrig');
});
it('starts server-authoritative travel from the travel panel', () => {
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
store.selectConnection(burnedRoadConnection);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-travel-start]')?.click();
expect(store.startTravel).toHaveBeenCalledOnce();
});
it('renders the server-derived countdown while travel is active without enabling another start', () => {
store.currentTravel.set({
status: 'TRAVELLING',
originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
targetLocation: burnedRoadConnection.targetLocation,
startedAt: '2026-08-19T06:00:00.000Z',
arrivesAt: '2026-08-19T06:00:06.000Z',
});
store.remainingSeconds.set(6);
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Reiseziel: Verbrannte Straße');
expect(element.textContent).toContain('Ankunft in');
expect(element.textContent).toContain('00:00:06');
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
});
});

View File

@@ -1,14 +1,30 @@
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CurrentLocationConnection } from '../../core/api/game-api.models';
import { LocationNodeComponent } from './location-node.component';
import { TravelPanelComponent } from './travel-panel.component';
import { WorldStore } from './world.store';
@Component({
selector: 'app-world-page',
template: '<section class="world-route" aria-label="Weltansicht"></section>',
styles: [
`
.world-route {
min-block-size: 100%;
}
`,
],
imports: [LocationNodeComponent, TravelPanelComponent],
templateUrl: './world-page.component.html',
styleUrl: './world-page.component.scss',
})
export class WorldPageComponent {}
export class WorldPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
protected readonly mapBackground = "url('/images/backgrounds/map_ashen_realm.png')";
ngOnInit(): void {
void this.worldStore.load();
}
protected selectConnection(connection: CurrentLocationConnection): void {
if (this.worldStore.currentTravel()?.status !== 'TRAVELLING') {
this.worldStore.selectConnection(connection);
}
}
protected retry(): void {
void this.worldStore.load();
}
}

View File

@@ -1,4 +1,37 @@
<aside class="context-panel" aria-label="Gebietsinformationen">
<span class="context-panel__eyebrow">Gebiet</span>
<p class="context-panel__hint">Informationen erscheinen mit der Weltansicht.</p>
@if (worldStore.currentLocation(); as location) {
<span class="context-panel__eyebrow">GEBIETSINFO</span>
<h2>{{ location.name }}</h2>
@if (worldStore.selectedConnection(); as selected) {
<p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p>
}
<img
class="context-panel__artwork"
[src]="location.artworkPath"
[alt]="'Ortsansicht: ' + location.name"
/>
<p class="context-panel__description">{{ location.description }}</p>
<dl class="context-panel__facts">
<div>
<dt>Empfohlene Stufe</dt>
<dd>{{ location.minRecommendedLevel }}{{ location.maxRecommendedLevel }}</dd>
</div>
<div>
<dt>Sicherheit</dt>
<dd [class.context-panel__safe]="location.isSafe">
{{ location.isSafe ? 'Sicherer Ort' : 'Gefährliches Gebiet' }}
</dd>
</div>
<div>
<dt>Jagd</dt>
<dd>{{ location.huntingEnabled ? 'Jagd möglich' : 'Keine Jagd' }}</dd>
</div>
</dl>
} @else {
<span class="context-panel__eyebrow">GEBIETSINFO</span>
<p class="context-panel__hint">Informationen erscheinen mit der Weltansicht.</p>
}
</aside>

View File

@@ -10,6 +10,14 @@
background: linear-gradient(180deg, rgb(255 255 255 / 0.025), transparent 16%), var(--ar-panel);
}
.context-panel h2 {
margin: var(--ar-space-2) 0 var(--ar-space-3);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.55rem;
font-weight: 400;
line-height: 1.15;
}
.context-panel__eyebrow {
display: block;
padding-block-end: var(--ar-space-3);
@@ -24,3 +32,53 @@
font-size: var(--ar-font-sm);
line-height: 1.55;
}
.context-panel__selection {
margin: 0 0 var(--ar-space-3);
color: var(--ar-gold);
font-size: var(--ar-font-sm);
}
.context-panel__artwork {
display: block;
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--ar-space-3);
border: 1px solid var(--ar-border);
object-fit: cover;
}
.context-panel__description {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.55;
}
.context-panel__facts {
display: grid;
gap: var(--ar-space-3);
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid var(--ar-border);
}
.context-panel__facts div {
display: flex;
justify-content: space-between;
gap: var(--ar-space-3);
}
.context-panel__facts dt {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.context-panel__facts dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.context-panel__safe {
color: var(--ar-success);
}

View File

@@ -1,8 +1,11 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { WorldStore } from '../../features/world/world.store';
@Component({
selector: 'app-context-panel',
templateUrl: './context-panel.component.html',
styleUrl: './context-panel.component.scss',
})
export class ContextPanelComponent {}
export class ContextPanelComponent {
protected readonly worldStore = inject(WorldStore);
}