feat: render interactive ashen fields world
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 9 Report: World UI
|
||||||
|
|
||||||
|
## Reference inventory
|
||||||
|
|
||||||
|
- `world-travel-screen.png`: persistent top bar and left navigation, a dominant central map, labelled current/reachable nodes with a dotted path, dense right context, and a bottom-centred travel decision panel.
|
||||||
|
- `hunting-screen.png`: confirms the shared dark iron/bronze material treatment, restrained gold highlights, readable serif display hierarchy, and dense information without white cards or modern pills.
|
||||||
|
- `combat-screen.png`: confirms the same persistent shell, dark desaturated palette, thin ornate borders, and blue only as a controlled active-state accent.
|
||||||
|
|
||||||
|
The reference screenshots were inspected but are not shipped or referenced by the application.
|
||||||
|
|
||||||
|
## Artwork and layout decision
|
||||||
|
|
||||||
|
- No ImageGen was used: the controller ruling makes the delivered user artwork final.
|
||||||
|
- `/images/backgrounds/map_ashen_realm.png` is the dominant WorldPage scene. Its wide path, city, watchtower, and ash-field composition provides a clearer interactive map than either location image.
|
||||||
|
- The server-delivered location paths are `/images/backgrounds/Suedtor.png` and `/images/backgrounds/Aschestrasse.png`; the ContextPanel renders the authoritative current-location image, so it changes only after server-confirmed arrival.
|
||||||
|
- This deliberately supersedes the older brief wording that asked for generated assets and API artwork as the main scene background. It preserves the binding controller ruling while retaining the server-provided artwork as context.
|
||||||
|
|
||||||
|
## Delivered behavior
|
||||||
|
|
||||||
|
- `WorldPageComponent` loads `WorldStore` on init, renders a code-native SVG connection and two code-native location buttons, shows loading/error/retry states, and never advances location locally.
|
||||||
|
- `LocationNodeComponent` exposes current, selected, reachable, disabled, hover, and focus states with textual labels.
|
||||||
|
- `TravelPanelComponent` presents no-selection guidance, selected target/travel-time/danger with `Reise beginnen`, and an arrivesAt-derived travelling countdown with disabled action.
|
||||||
|
- `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork.
|
||||||
|
- Seed paths now match the delivered assets and are covered by the API seed test.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths.
|
||||||
|
- Focused WorldPage test: 4/4 pass.
|
||||||
|
- Full web tests: 18/18 pass.
|
||||||
|
- Affected API seed tests: 2/2 pass.
|
||||||
|
- `npm run build:web` and `npm run build:api` pass.
|
||||||
|
|
||||||
|
## Remaining concern
|
||||||
|
|
||||||
|
Rendered browser fidelity QA is intentionally deferred to Task 11, as instructed. The Browser plugin is unavailable and no Playwright fallback was started for this task.
|
||||||
@@ -115,6 +115,18 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||||
);
|
);
|
||||||
expect(locationRepository.rows).toHaveLength(2);
|
expect(locationRepository.rows).toHaveLength(2);
|
||||||
|
expect(locationRepository.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'south-gate',
|
||||||
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'burned-road',
|
||||||
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(connectionRepository.rows).toHaveLength(2);
|
expect(connectionRepository.rows).toHaveLength(2);
|
||||||
expect(characterRepository.rows).toHaveLength(1);
|
expect(characterRepository.rows).toHaveLength(1);
|
||||||
expect(characterRepository.rows[0]).toEqual(
|
expect(characterRepository.rows[0]).toEqual(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
dangerLevel: 0,
|
dangerLevel: 0,
|
||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/assets/locations/south-gate.webp',
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: BURNED_ROAD_ID,
|
id: BURNED_ROAD_ID,
|
||||||
@@ -39,7 +39,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
dangerLevel: 1,
|
dangerLevel: 1,
|
||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/assets/locations/burned-road.webp',
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let southGateId = SOUTH_GATE_ID;
|
let southGateId = SOUTH_GATE_ID;
|
||||||
|
|||||||
BIN
apps/web/public/images/backgrounds/Aschestrasse.png
Normal file
BIN
apps/web/public/images/backgrounds/Aschestrasse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/backgrounds/Suedtor.png
Normal file
BIN
apps/web/public/images/backgrounds/Suedtor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/backgrounds/map_ashen_realm.png
Normal file
BIN
apps/web/public/images/backgrounds/map_ashen_realm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -8,9 +8,13 @@ import { routes } from './app.routes';
|
|||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
let character: WritableSignal<CharacterResponse | null>;
|
let character: WritableSignal<CharacterResponse | null>;
|
||||||
|
let currentLocation: WritableSignal<null>;
|
||||||
|
let selectedConnection: WritableSignal<null>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
character = signal<CharacterResponse | null>(null);
|
character = signal<CharacterResponse | null>(null);
|
||||||
|
currentLocation = signal(null);
|
||||||
|
selectedConnection = signal(null);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppShellComponent],
|
imports: [AppShellComponent],
|
||||||
@@ -18,7 +22,7 @@ describe('App', () => {
|
|||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{
|
{
|
||||||
provide: WorldStore,
|
provide: WorldStore,
|
||||||
useValue: { character },
|
useValue: { character, currentLocation, selectedConnection },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|||||||
17
apps/web/src/app/features/world/location-node.component.html
Normal file
17
apps/web/src/app/features/world/location-node.component.html
Normal 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>
|
||||||
65
apps/web/src/app/features/world/location-node.component.scss
Normal file
65
apps/web/src/app/features/world/location-node.component.scss
Normal 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);
|
||||||
|
}
|
||||||
15
apps/web/src/app/features/world/location-node.component.ts
Normal file
15
apps/web/src/app/features/world/location-node.component.ts
Normal 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>();
|
||||||
|
}
|
||||||
42
apps/web/src/app/features/world/travel-panel.component.html
Normal file
42
apps/web/src/app/features/world/travel-panel.component.html
Normal 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>
|
||||||
90
apps/web/src/app/features/world/travel-panel.component.scss
Normal file
90
apps/web/src/app/features/world/travel-panel.component.scss
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/web/src/app/features/world/travel-panel.component.ts
Normal file
32
apps/web/src/app/features/world/travel-panel.component.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/web/src/app/features/world/world-page.component.html
Normal file
64
apps/web/src/app/features/world/world-page.component.html
Normal 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>
|
||||||
164
apps/web/src/app/features/world/world-page.component.scss
Normal file
164
apps/web/src/app/features/world/world-page.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
apps/web/src/app/features/world/world-page.component.spec.ts
Normal file
127
apps/web/src/app/features/world/world-page.component.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
@Component({
|
||||||
selector: 'app-world-page',
|
selector: 'app-world-page',
|
||||||
template: '<section class="world-route" aria-label="Weltansicht"></section>',
|
imports: [LocationNodeComponent, TravelPanelComponent],
|
||||||
styles: [
|
templateUrl: './world-page.component.html',
|
||||||
`
|
styleUrl: './world-page.component.scss',
|
||||||
.world-route {
|
|
||||||
min-block-size: 100%;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,37 @@
|
|||||||
<aside class="context-panel" aria-label="Gebietsinformationen">
|
<aside class="context-panel" aria-label="Gebietsinformationen">
|
||||||
<span class="context-panel__eyebrow">Gebiet</span>
|
@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>
|
<p class="context-panel__hint">Informationen erscheinen mit der Weltansicht.</p>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.025), transparent 16%), var(--ar-panel);
|
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 {
|
.context-panel__eyebrow {
|
||||||
display: block;
|
display: block;
|
||||||
padding-block-end: var(--ar-space-3);
|
padding-block-end: var(--ar-space-3);
|
||||||
@@ -24,3 +32,53 @@
|
|||||||
font-size: var(--ar-font-sm);
|
font-size: var(--ar-font-sm);
|
||||||
line-height: 1.55;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { WorldStore } from '../../features/world/world.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-panel',
|
selector: 'app-context-panel',
|
||||||
templateUrl: './context-panel.component.html',
|
templateUrl: './context-panel.component.html',
|
||||||
styleUrl: './context-panel.component.scss',
|
styleUrl: './context-panel.component.scss',
|
||||||
})
|
})
|
||||||
export class ContextPanelComponent {}
|
export class ContextPanelComponent {
|
||||||
|
protected readonly worldStore = inject(WorldStore);
|
||||||
|
}
|
||||||
|
|||||||
1138
docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md
Normal file
1138
docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user