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:
97
apps/web/src/app/features/world/local-location.store.ts
Normal file
97
apps/web/src/app/features/world/local-location.store.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
LocationInteractionResult,
|
||||
LocationPointOfInterest,
|
||||
LocationPrimaryAction,
|
||||
} from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
import { WorldStore } from './world.store';
|
||||
|
||||
const GENERIC_INTERACTION_ERROR = 'Diese Handlung ist gerade nicht möglich.';
|
||||
|
||||
// Mirrors the codes in `apps/api/src/world/world.errors.ts`.
|
||||
const INTERACTION_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
LOCATION_INTERACTION_UNAVAILABLE: 'Hier gibt es dazu nichts zu entdecken.',
|
||||
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
|
||||
};
|
||||
|
||||
/**
|
||||
* State for the local location view.
|
||||
*
|
||||
* Location data comes from `WorldStore`, not from a second fetch path: that
|
||||
* store already settles due travel before answering, so arriving at a place
|
||||
* and looking around cannot disagree about where the character is. This store
|
||||
* adds only what is local to the screen — the open interaction result.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalLocationStore {
|
||||
private readonly api = inject(GameApiService);
|
||||
private readonly worldStore = inject(WorldStore);
|
||||
|
||||
private readonly interactionResultState = signal<LocationInteractionResult | null>(null);
|
||||
private readonly interactionErrorState = signal<string | null>(null);
|
||||
private readonly interactionPendingState = signal<string | null>(null);
|
||||
|
||||
readonly location = this.worldStore.currentLocation;
|
||||
readonly loading = this.worldStore.loading;
|
||||
readonly error = this.worldStore.error;
|
||||
|
||||
readonly interactionResult = this.interactionResultState.asReadonly();
|
||||
readonly interactionError = this.interactionErrorState.asReadonly();
|
||||
/** Key of the interaction currently in flight, so only that control busies. */
|
||||
readonly interactionPending = this.interactionPendingState.asReadonly();
|
||||
readonly interactionOpen = computed(
|
||||
() => this.interactionResultState() !== null || this.interactionErrorState() !== null,
|
||||
);
|
||||
|
||||
load(): Promise<void> {
|
||||
return this.worldStore.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a hotspot or action that reveals text. Navigation types (HUNT, MAP)
|
||||
* never reach here — the page routes those itself.
|
||||
*/
|
||||
async runInteraction(interactionKey: string): Promise<void> {
|
||||
if (this.interactionPendingState() !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.interactionPendingState.set(interactionKey);
|
||||
this.interactionResultState.set(null);
|
||||
this.interactionErrorState.set(null);
|
||||
|
||||
try {
|
||||
this.interactionResultState.set(
|
||||
await firstValueFrom(this.api.runLocationInteraction(interactionKey)),
|
||||
);
|
||||
} catch (error) {
|
||||
this.interactionErrorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.interactionPendingState.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
closeInteraction(): void {
|
||||
this.interactionResultState.set(null);
|
||||
this.interactionErrorState.set(null);
|
||||
}
|
||||
|
||||
/** The hotspot an action mirrors, so an action bar entry can highlight it. */
|
||||
interactionKeyOf(
|
||||
action: LocationPrimaryAction | LocationPointOfInterest,
|
||||
): string | undefined {
|
||||
return 'poiKey' in action ? action.poiKey : action.key;
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const code = (error.error as { code?: string } | null)?.code;
|
||||
return (code && INTERACTION_ERROR_MESSAGES[code]) || GENERIC_INTERACTION_ERROR;
|
||||
}
|
||||
|
||||
return GENERIC_INTERACTION_ERROR;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user