feat(web): add the local location view at /location

The screen a player stands on between activities: name, region, scene
artwork with hotspots pinned by percentage, a four-button action bar and
a context sidebar covering identity, danger, encounters, interactions and
rewards.

It owns no knowledge of any particular place. Hotspots and actions are
routed by interaction type: HUNT and MAP hand off to the existing hunt
and map screens, and everything that reveals text goes through the
server-authoritative interaction endpoint. A second location therefore
renders by supplying different content, which the Südtor case in the page
spec exercises.

The shell drops its generic area rail on /location, where the screen's
own sidebar says the same thing better, and Ort joins the navigation as
its first entry. Root and unknown routes now land on the location rather
than the map: arriving somewhere should mean arriving at a place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 10:51:08 +02:00
parent c7e7e97252
commit 6f0020137b
28 changed files with 1784 additions and 12 deletions

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
LocationInteractionType,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../../core/api/game-api.models';
import { LocalLocationStore } from '../local-location.store';
import { LocationInteractionPanelComponent } from '../location-interaction-panel/location-interaction-panel.component';
import { LocationIconComponent } from '../location-icon/location-icon.component';
import { LocationPoiComponent } from '../location-poi/location-poi.component';
import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.component';
const RUNTIME_ARTWORK: Readonly<Record<string, string>> = {
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
};
/**
* The screen the player stands on between activities.
*
* It renders whatever the current location's content describes and owns no
* knowledge of any particular place. Hunting and travel are not reimplemented
* here: HUNT and MAP hotspots hand off to the existing screens, and everything
* that reveals text goes through the server-authoritative interaction endpoint.
*/
@Component({
selector: 'app-location-page',
imports: [
LocationIconComponent,
LocationInteractionPanelComponent,
LocationPoiComponent,
LocationSidebarComponent,
],
templateUrl: './location-page.component.html',
styleUrl: './location-page.component.scss',
})
export class LocationPageComponent implements OnInit {
protected readonly store = inject(LocalLocationStore);
private readonly router = inject(Router);
ngOnInit(): void {
if (this.store.location() === null) {
void this.store.load();
}
}
protected retry(): void {
void this.store.load();
}
protected activatePoi(poi: LocationPointOfInterest): void {
this.dispatch(poi.type, poi.key);
}
protected activateAction(action: LocationPrimaryAction): void {
this.dispatch(action.type, action.poiKey ?? action.key);
}
protected runtimeArtwork(artworkPath: string): string | undefined {
return RUNTIME_ARTWORK[artworkPath];
}
/** True while this control's own interaction is in flight. */
protected isPending(key: string | undefined): boolean {
return key !== undefined && this.store.interactionPending() === key;
}
protected get busy(): boolean {
return this.store.interactionPending() !== null;
}
/**
* Routes a hotspot or action by its interaction type. Navigation types leave
* for the screen that owns them; every other type asks the server what
* happened. Unimplemented types are ignored rather than faked.
*/
private dispatch(type: LocationInteractionType, interactionKey: string): void {
switch (type) {
case 'HUNT':
void this.router.navigate(['/hunt']);
return;
case 'MAP':
case 'TRAVEL':
void this.router.navigate(['/world']);
return;
case 'INVESTIGATE':
case 'SEARCH':
case 'NPC':
void this.store.runInteraction(interactionKey);
return;
default:
return;
}
}
}