Merge branch 'master' into slice/0.9-first-quest-and-bag-tutorial

master turned the gate watch into painted scenery with its own portrait and
result image; Slice 0.9 turns that same hotspot into a real NPC. Kept the
NPC doorway and dropped the result text and image with it -- a hotspot
carries one or the other, never both.

The warden now uses master's `graufurt-gate-watch.png` instead of the raw
copy this branch added: same figure, already optimized. Borin keeps master's
optimized portrait for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-23 08:58:21 +02:00
25 changed files with 98 additions and 13 deletions

View File

@@ -88,6 +88,7 @@ export interface LocationInteractionResult {
interactionKey: string;
title: string;
text: string;
img?: string;
}
export interface CurrentLocationResponse {

View File

@@ -39,7 +39,7 @@
.merchant__portrait {
inline-size: 7rem;
block-size: 7rem;
object-fit: cover;
object-fit: contain;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted);

View File

@@ -7,8 +7,21 @@
data-interaction-panel
>
@if (result) {
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
<div class="interaction-panel__body">
@if (result.img) {
<img
class="interaction-panel__portrait"
data-interaction-portrait
[src]="result.img"
[alt]="result.title"
(error)="($any($event.target).style.visibility = 'hidden')"
/>
}
<div class="interaction-panel__copy">
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
</div>
</div>
} @else {
<h2 class="interaction-panel__title">Nothing to discover</h2>
<p class="interaction-panel__text interaction-panel__text--error" role="alert">

View File

@@ -22,6 +22,27 @@
pointer-events: auto;
}
.interaction-panel__body {
display: flex;
align-items: flex-start;
gap: var(--ar-space-4);
}
.interaction-panel__portrait {
flex: 0 0 auto;
inline-size: 5rem;
block-size: 5rem;
object-fit: contain;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted);
}
.interaction-panel__copy {
display: grid;
gap: var(--ar-space-3);
}
.interaction-panel__title {
margin: 0;
color: var(--ar-gold);

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LocationInteractionPanelComponent } from './location-interaction-panel.component';
async function setup(inputs: {
result?: { interactionKey: string; title: string; text: string } | null;
result?: { interactionKey: string; title: string; text: string; img?: string } | null;
error?: string | null;
}): Promise<{
fixture: ComponentFixture<LocationInteractionPanelComponent>;
@@ -62,6 +62,31 @@ describe('LocationInteractionPanelComponent', () => {
expect(element.querySelectorAll('button')).toHaveLength(1);
});
it("shows the NPC's portrait when the result carries one", async () => {
const { element } = await setup({
result: {
interactionKey: 'gate-watch',
title: 'Gate Watch',
text: 'Beyond the gate, Graufurt\'s protection ends.',
img: '/images/npcs/graufurt-gate-watch.png',
},
});
const portrait = element.querySelector<HTMLImageElement>(
'[data-interaction-portrait]',
);
expect(portrait?.src).toContain('/images/npcs/graufurt-gate-watch.png');
expect(portrait?.alt).toBe('Gate Watch');
});
it('shows no portrait when the result carries none', async () => {
const { element } = await setup({
result: { interactionKey: 'inspect-tracks', title: 'Suspicious Tracks', text: 'X' },
});
expect(element.querySelector('[data-interaction-portrait]')).toBeNull();
});
it('reports a rejected interaction in place instead of navigating away', async () => {
const { element } = await setup({ error: "There's nothing to discover here." });

View File

@@ -10,7 +10,7 @@
preserveAspectRatio="none"
aria-hidden="true"
>
<path d="M 21 62 C 36 64, 51 50, 68 43" pathLength="1" />
<path d="M 21.71 37.61 C 26 39, 29 50, 32.70 51.55" pathLength="1" />
</svg>
<app-location-node

View File

@@ -91,11 +91,11 @@
}
.world-page__node--south-gate {
inset: 58% auto auto 12%;
inset: 35% auto auto 16%;
}
.world-page__node--burned-road {
inset: 39% auto auto 60%;
inset: 49% auto auto 27%;
}
.world-page__travel-panel {

View File

@@ -2,10 +2,10 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Web</title>
<title>Ashen Realms</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" href="favicon.png">
</head>
<body>
<app-root></app-root>