Merge branch 'master' into worktree-playable-slice-0.6.5-renown-reputation

Reconciles Slice 0.6.5 (Renown & Reputation Foundation) against master's
persistent-HP-and-regeneration slice, which landed independently and
touches several of the same files (Character entity, CombatService,
EquipmentService, the inventory detail panel).

Conflict resolutions:
- CharacterStatsService/EquipmentService constructor wiring: kept
  master's CharacterVitalsService injection, which this branch's
  version of the same files didn't have yet.
- CombatService.performAction: kept master's HP-guard logic
  (characterTooWounded, vitals pause-on-enter) alongside this branch's
  multi-line calculate() call style.
- Inventory detail panel (.html/.ts/.scss/.spec.ts): master had
  redesigned the panel (wrapping section, rarity styling, flavour
  text, a shared inventory.labels.ts) on top of the OLD level-gated
  component, since this branch's removal of the level gate (R4, Task
  8/14) hadn't reached master yet. Kept master's visual redesign in
  full, but with the level-gate concept removed throughout: no
  requiredLevel stat block, no meetsLevelRequirement() branch in the
  equip button, no now-dead .detail__value--unmet SCSS rule. Kept both
  branches' independent tests (non-equippable-item, flavour-text).
- inventory-page.component.ts: dropped master's dead characterLevel
  computed (nothing in the template read it, and the level concept is
  gone); kept its independent bagCells/bagUsed/bagCapacity grid
  feature, which has nothing to do with renown or level.

Post-merge fixture repairs (three files failed the Angular bundle
compile because they predate master's hpRegenPerSecond/hpRegenSince
fields or master's item description field, neither conflict-marked
since git considered them non-overlapping edits):
- app.spec.ts: a 'renders loaded character values' test added on
  master after this branch forked still used the abolished level/
  experience fields on its decoy fixture -- retargeted to renown.
- inventory-detail-panel.component.spec.ts: the ashPelt fixture added
  by this branch's final-review follow-up predates master's required
  description field.
- top-bar.component.spec.ts: this branch's fixture predates master's
  required hpRegenPerSecond/hpRegenSince fields.

No database migration touches the same column: master's
1792000000000-AddHpRegeneration only adds characters.hp_regen_since,
independent of this slice's 1791000000000-CreateRenownAndReputation.
Timestamp ordering between the two was already correct with no rename
needed.

Verified: API 288/288 (267 from this slice + 21 from master), API
build zero errors, web 237/237 (230 from this slice + 7 from master).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-21 19:23:10 +02:00
75 changed files with 9250 additions and 1840 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -8,11 +8,13 @@ import { routes } from './app.routes';
describe('App', () => {
let character: WritableSignal<CharacterResponse | null>;
let displayedCharacter: WritableSignal<CharacterResponse | null>;
let currentLocation: WritableSignal<null>;
let selectedConnection: WritableSignal<null>;
beforeEach(async () => {
character = signal<CharacterResponse | null>(null);
displayedCharacter = signal<CharacterResponse | null>(null);
currentLocation = signal(null);
selectedConnection = signal(null);
@@ -23,10 +25,11 @@ describe('App', () => {
{ path: 'location', children: [] },
{ path: 'world', children: [] },
{ path: 'hunt', children: [] },
{ path: 'inventory', children: [] },
]),
{
provide: WorldStore,
useValue: { character, currentLocation, selectedConnection },
useValue: { character, displayedCharacter, currentLocation, selectedConnection },
},
],
}).compileComponents();
@@ -112,6 +115,16 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('drops the shell context rail on /inventory, which needs all three of its own columns', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/inventory');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('keeps the shell context rail on the map', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
@@ -138,7 +151,19 @@ describe('App', () => {
});
it('renders loaded character values supplied by the WorldStore', () => {
character.set({
const decoy: CharacterResponse = {
id: 'stale-id',
name: 'Stale Decoy',
renown: 1,
silver: 0,
currentHp: 1,
maxHp: 1,
attack: 1,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
};
const value: CharacterResponse = {
id: 'character-id',
name: 'Mara Ashfall',
renown: 7,
@@ -146,8 +171,12 @@ describe('App', () => {
currentHp: 52,
maxHp: 80,
attack: 12,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
});
};
character.set(decoy);
displayedCharacter.set(value);
const fixture = TestBed.createComponent(AppShellComponent);
fixture.detectChanges();

View File

@@ -12,6 +12,8 @@ export interface CharacterResponse {
currentHp: number;
maxHp: number;
attack: number;
hpRegenPerSecond: number;
hpRegenSince: string | null;
currentLocation: LocationSummary;
}
@@ -226,6 +228,7 @@ export interface InventoryItem {
item: {
key: string;
name: string;
description: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
weaponDamage: number;

View File

@@ -84,6 +84,22 @@ describe('CombatStore', () => {
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
it('maps CHARACTER_TOO_WOUNDED to its German message', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'CHARACTER_TOO_WOUNDED', message: 'Too wounded.' },
}),
),
);
await store.startCombat('encounter-1');
expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.');
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue(
throwError(

View File

@@ -13,6 +13,7 @@ const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',

View File

@@ -1,56 +1,68 @@
@if (item(); as item) {
<article class="inventory-detail" aria-label="Gegenstandsdetails">
<div class="inventory-detail__header">
<img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
<div>
<h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
<p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
@if (slotLabel(); as slot) {
<p class="inventory-detail__meta">{{ slot }}</p>
<section class="ar-panel detail" aria-label="Gewählter Gegenstand">
<h2 class="ar-panel__title">Gewählter Gegenstand</h2>
@if (item(); as item) {
<article
class="detail__body"
[class.detail__body--rare]="item.item.rarity === 'RARE'"
[class.detail__body--epic]="item.item.rarity === 'EPIC'"
>
<header class="detail__head">
<span class="detail__portrait">
<img [src]="item.item.iconPath" [alt]="item.item.name" />
</span>
<div class="detail__ident">
<h3 class="detail__name" data-detail-name>{{ item.item.name }}</h3>
<p class="detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
<p class="detail__slot">{{ slotLabel() ?? typeLabel() }}</p>
</div>
</header>
@if (statRows().length) {
<dl class="detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="detail__stat">
<dt>{{ row.label }}</dt>
<dd>
<span class="detail__value">{{ row.value }}</span>
@if (row.diff !== null && row.diff !== 0) {
<span
class="detail__diff"
[class.detail__diff--positive]="row.diff > 0"
[class.detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
@if (item.item.description) {
<p class="detail__flavour">{{ item.item.description }}</p>
}
<div class="detail__actions">
@if (item.equipped) {
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="detail__note">Nicht ausrüstbar</span>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</div>
@if (statRows().length) {
<dl class="inventory-detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="inventory-detail__stat">
<dt>{{ row.label }}</dt>
<dd>
{{ row.value }}
@if (row.diff !== null && row.diff !== 0) {
<span
class="inventory-detail__diff"
[class.inventory-detail__diff--positive]="row.diff > 0"
[class.inventory-detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
<div class="inventory-detail__actions">
@if (item.equipped) {
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="inventory-detail__note">Nicht ausrüstbar</span>
} @else {
<button
type="button"
class="inventory-detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
</article>
} @else {
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
</section>

View File

@@ -2,99 +2,174 @@
display: block;
}
.inventory-detail {
padding: 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);
.detail {
block-size: 100%;
}
.inventory-detail__header {
.detail__body {
display: grid;
gap: var(--ar-space-3);
/* Rarity tints the name and the icon frame, nothing else — the numbers stay
the loudest thing in the panel. */
--detail-rarity: var(--ar-text);
--detail-rarity-edge: var(--ar-border-highlight);
}
.detail__body--rare {
--detail-rarity: var(--ar-blue);
--detail-rarity-edge: var(--ar-blue);
}
.detail__body--epic {
--detail-rarity: var(--ar-gold);
--detail-rarity-edge: var(--ar-gold);
}
/* ---------- identity ---------- */
.detail__head {
display: flex;
gap: var(--ar-space-3);
align-items: center;
margin-block-end: var(--ar-space-3);
align-items: flex-start;
}
.inventory-detail__icon {
inline-size: 4rem;
block-size: 4rem;
padding: var(--ar-space-1);
border: 1px solid var(--ar-border);
.detail__portrait {
display: grid;
flex: 0 0 auto;
place-items: center;
inline-size: 4.25rem;
block-size: 4.25rem;
padding: 0.3rem;
border: 1px solid var(--detail-rarity-edge);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.6rem rgb(0 0 0 / 0.8);
}
.detail__portrait img {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
.inventory-detail__name {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
font-weight: 400;
.detail__ident {
min-inline-size: 0;
padding-block-start: 0.15rem;
}
.inventory-detail__rarity {
.detail__name {
margin: 0;
color: var(--detail-rarity);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.1rem;
font-weight: 400;
line-height: 1.2;
}
.detail__rarity {
margin: 0.2rem 0 0;
color: var(--detail-rarity);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.85;
}
.detail__slot {
margin: 0.15rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inventory-detail__meta {
margin: 0.25rem 0 0;
/* ---------- numbers ---------- */
.detail__stats,
.detail__meta {
display: grid;
gap: 0.35rem;
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__stat {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--ar-space-3);
}
.detail__stat dt {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.inventory-detail__stats {
display: grid;
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);
}
.inventory-detail__stat {
.detail__stat dd {
display: flex;
justify-content: space-between;
}
.inventory-detail__stat dt {
color: var(--ar-text-muted);
}
.inventory-detail__stat dd {
gap: 0.4rem;
align-items: baseline;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
white-space: nowrap;
}
.inventory-detail__diff--positive {
.detail__value {
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
/* The sign is carried by the text, never by colour alone (spec §52). */
.detail__diff {
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.detail__diff--positive {
color: var(--ar-success);
}
.inventory-detail__diff--negative {
.detail__diff--negative {
color: var(--ar-danger);
}
.inventory-detail__actions {
display: flex;
justify-content: center;
/* ---------- flavour ---------- */
.detail__flavour {
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
font-style: italic;
line-height: 1.5;
}
.inventory-detail__equipped {
/* ---------- action ---------- */
.detail__actions {
display: grid;
justify-items: center;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__equipped {
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.08em;
}
.inventory-detail__note {
.detail__note {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
.inventory-detail__equip {
/* Same steel-blue plate as the travel button: the one committing action. */
.detail__equip {
inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
@@ -103,22 +178,25 @@
background: linear-gradient(180deg, #263b4b, #17232d);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
letter-spacing: 0.06em;
}
.inventory-detail__equip:hover:not(:disabled) {
.detail__equip:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.inventory-detail__equip:disabled {
.detail__equip:disabled {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: #1a1c1d;
}
.inventory-detail__empty {
padding: var(--ar-space-4);
.detail__empty {
margin: 0;
padding-block: var(--ar-space-5);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
text-align: center;
}

View File

@@ -10,6 +10,7 @@ const wornSword: InventoryItem = {
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 8,
@@ -27,6 +28,7 @@ const banditBlade: InventoryItem = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 11,
@@ -44,6 +46,7 @@ const ashPelt: InventoryItem = {
item: {
key: 'ash-pelt',
name: 'Aschenfell',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: null,
weaponDamage: 0,
@@ -92,6 +95,19 @@ describe('InventoryDetailPanelComponent', () => {
expect(element.querySelector('[data-detail-equipped]')).toBeNull();
});
it('shows the item flavour text from the server', async () => {
const fixture = await setup({
item: {
...banditBlade,
item: { ...banditBlade.item, description: 'Eine grob gezahnte Klinge.' },
},
});
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Eine grob gezahnte Klinge.',
);
});
it('shows the stat comparison against the equipped item in the same slot', async () => {
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';

View File

@@ -1,16 +1,7 @@
import { Component, computed, input, output } from '@angular/core';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import type { InventoryItem } from '../../core/api/game-api.models';
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
import { SLOT_LABELS } from './inventory.labels';
interface StatRow {
label: string;
@@ -48,6 +39,9 @@ export class InventoryDetailPanelComponent {
return slot ? SLOT_LABELS[slot] : null;
});
/** Stand-in for items with no slot: the API does not expose an item type. */
protected readonly typeLabel = computed(() => 'Gegenstand');
protected readonly statRows = computed<StatRow[]>(() => {
const item = this.item();
if (!item) {

View File

@@ -1,58 +1,122 @@
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar">
<h1 class="inventory-page__title">Inventar</h1>
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) {
<section class="inventory-page__grid" aria-label="Gegenstände">
@for (entry of inventory.items; track entry.id) {
<button
type="button"
class="inventory-page__slot"
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
(click)="selectItem(entry.id)"
>
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
@if (entry.equipped) {
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
}
</button>
} @empty {
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
}
</section>
<div class="inventory-page__columns">
<!-- ---------- paper doll + derived stats ---------- -->
<div class="inventory-page__rail">
<section class="ar-panel panel--doll" aria-label="Ausrüstung">
<h2 class="ar-panel__title">Ausrüstung</h2>
<aside class="inventory-page__side" aria-label="Details und Ausrüstung">
@if (inventoryStore.equipment(); as equipment) {
<div class="doll inventory-page__equipment-list">
<div class="doll__column">
@for (slot of dollLeft; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__figure" role="img" aria-label="Ausgerüsteter Charakter"></div>
<div class="doll__column">
@for (slot of dollRight; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__below">
<ng-container
*ngTemplateOutlet="
dollSlot;
context: { label: slotLabels[dollBelow], entry: equipment.slots[dollBelow] }
"
/>
</div>
</div>
}
</section>
@if (inventoryStore.equipment(); as equipment) {
<section class="ar-panel panel--values" aria-label="Werte">
<h2 class="ar-panel__title">Werte</h2>
<dl class="values" data-inventory-stats>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Leben</dt>
<dd>{{ equipment.stats.maxHp }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Angriff</dt>
<dd>{{ equipment.stats.attack }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Waffenschaden</dt>
<dd>{{ equipment.stats.weaponDamage }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Rüstung</dt>
<dd>{{ equipment.stats.armor }}</dd>
</div>
</dl>
</section>
}
</div>
<!-- ---------- the bag ---------- -->
<section class="ar-panel panel--bag" aria-label="Gegenstände">
<h2 class="ar-panel__title">Beutel</h2>
<div class="bag">
@for (entry of bagCells(); track $index) {
@if (entry) {
<button
type="button"
class="cell inventory-page__slot"
[class.cell--selected]="inventoryStore.selectedItemId() === entry.id"
[class.cell--rare]="entry.item.rarity === 'RARE'"
[class.cell--epic]="entry.item.rarity === 'EPIC'"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-label]="entry.item.name"
(click)="selectItem(entry.id)"
>
<img class="cell__icon" [src]="entry.item.iconPath" alt="" />
@if (entry.quantity > 1) {
<span class="cell__quantity">{{ entry.quantity }}</span>
}
@if (entry.equipped) {
<span class="cell__equipped" data-slot-equipped>
<span class="visually-hidden">Ausgerüstet</span>
</span>
}
</button>
} @else {
<div class="cell cell--empty" aria-hidden="true"></div>
}
}
</div>
<footer class="bag__footer">
<span class="bag__count">{{ bagUsed() }} / {{ bagCapacity() }} Plätze belegt</span>
@if (bagUsed() === 0) {
<span class="bag__hint" data-inventory-empty>Noch keine Gegenstände gefunden.</span>
}
</footer>
</section>
<!-- ---------- selected item ---------- -->
<app-inventory-detail-panel
class="inventory-page__detail"
[item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()"
[busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)"
/>
@if (inventoryStore.equipment(); as equipment) {
<section class="inventory-page__equipment" aria-label="Ausrüstung">
<h3>Ausrüstung</h3>
<ul class="inventory-page__equipment-list">
@for (slot of slotOrder; track slot) {
<li>
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
<span class="inventory-page__equipment-slot-value">
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
</span>
</li>
}
</ul>
<dl class="inventory-page__stats" data-inventory-stats>
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
</dl>
</section>
}
</aside>
</div>
}
@if (inventoryStore.error(); as error) {
@@ -62,3 +126,29 @@
</section>
}
</section>
<!-- One equipment socket: label above, framed icon below. Filled sockets are
buttons so the equipped piece can be inspected like any other item. -->
<ng-template #dollSlot let-label="label" let-entry="entry">
<div class="socket">
<span class="socket__label">{{ label }}</span>
@if (entry) {
<button
type="button"
class="socket__frame socket__frame--filled"
[class.socket__frame--rare]="entry.item.rarity === 'RARE'"
[class.socket__frame--epic]="entry.item.rarity === 'EPIC'"
[class.socket__frame--selected]="inventoryStore.selectedItemId() === entry.characterItemId"
[attr.aria-label]="label + ': ' + entry.item.name"
(click)="selectItem(entry.characterItemId)"
>
<img [src]="entry.item.iconPath" alt="" />
</button>
<span class="visually-hidden">{{ entry.item.name }}</span>
} @else {
<span class="socket__frame socket__frame--empty">
<span class="visually-hidden">Leer</span>
</span>
}
</div>
</ng-template>

View File

@@ -1,121 +1,310 @@
// apps/web/src/app/features/inventory/inventory-page.component.scss
:host {
display: block;
block-size: 100%;
}
/* Fits the viewport instead of growing it, so the bag scrolls inside its own
panel rather than dragging the whole screen down. Same measure the combat
stage uses for the shell chrome above and below. */
.inventory-page {
display: grid;
grid-template-columns: 1fr 20rem;
gap: var(--ar-space-5);
align-items: start;
padding: var(--ar-space-5);
}
.inventory-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--ar-space-3);
}
.inventory-page__slot {
position: relative;
padding: var(--ar-space-2);
border: 1px solid transparent;
border-radius: var(--ar-radius-sm);
background: transparent;
}
.inventory-page__slot--selected {
border-color: var(--ar-border-highlight);
background: rgb(155 122 66 / 0.12);
}
.inventory-page__equipped-badge {
position: absolute;
inset-block-start: 0.1rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.05rem 0.4rem;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.9);
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__empty {
grid-column: 1 / -1;
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-page__side {
display: grid;
gap: var(--ar-space-4);
}
.inventory-page__equipment {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.inventory-page__equipment h3 {
margin: 0 0 var(--ar-space-2);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__equipment-list {
display: grid;
gap: var(--ar-space-1);
margin: 0 0 var(--ar-space-3);
padding: 0;
list-style: none;
}
.inventory-page__equipment-list li {
display: flex;
flex-direction: column;
block-size: max(34rem, calc(100dvh - 12rem));
min-block-size: 0;
overflow: hidden;
}
.inventory-page__title {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 2.6vw, 2rem);
font-weight: 400;
letter-spacing: 0.04em;
text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8);
}
/* Three rails: what you wear, what you carry, what you are looking at. */
.inventory-page__columns {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(18rem, 21rem) minmax(0, 1fr) minmax(16rem, 19rem);
gap: var(--ar-space-4);
align-items: stretch;
min-block-size: 0;
}
/* The doll takes the slack; the derived values sit under it like a plate. */
.inventory-page__rail {
display: grid;
grid-template-rows: 1fr auto;
gap: var(--ar-space-4);
min-block-size: 0;
}
/* Panel chrome (.ar-panel) lives in styles.scss — every screen shares it. */
.panel--doll {
display: grid;
grid-template-rows: auto 1fr;
min-block-size: 0;
}
/* Title, cells, then the count pinned to the bottom edge. */
.panel--bag {
display: grid;
grid-template-rows: auto auto 1fr;
min-block-size: 0;
}
/* ---------- paper doll ---------- */
.doll {
display: grid;
grid-template-columns: auto minmax(3.5rem, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--ar-space-2) var(--ar-space-3);
min-block-size: 0;
}
.doll__column {
display: grid;
grid-row: 1;
gap: var(--ar-space-3);
align-content: center;
}
/* The same warrior that fights on the combat screen stands here wearing the
result. Frame 0 of the six-frame attack sheet is the resting stance; the box
is kept square so that frame lands at its own proportions and never smears. */
.doll__figure {
grid-row: 1;
grid-column: 2;
z-index: 0;
place-self: center;
aspect-ratio: 1;
/* Wider than its column: the character stands behind the sockets rather than
squeezed between them, as in the concept. */
inline-size: 165%;
max-block-size: 100%;
pointer-events: none;
background-image: url('/images/character/female-320.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
height: 100%;
}
.doll__below {
grid-row: 2;
grid-column: 2;
justify-self: center;
}
.socket {
position: relative;
z-index: 1;
display: grid;
gap: 0.15rem;
justify-items: center;
}
.socket__label {
color: var(--ar-text-muted);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.socket__frame {
display: grid;
place-items: center;
inline-size: 3.35rem;
block-size: 3.35rem;
padding: 0.2rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.75);
}
.socket__frame img {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
/* An empty socket is a recess, not a pattern — quiet enough that a filled
one is what the eye lands on. */
.socket__frame--empty {
border-color: rgb(85 74 57 / 0.55);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.socket__frame--filled {
border-color: var(--ar-border-highlight);
}
.socket__frame--filled:hover {
border-color: var(--ar-gold);
}
.socket__frame--rare {
border-color: var(--ar-blue);
}
.socket__frame--epic {
border-color: var(--ar-gold);
}
.socket__frame--selected {
outline: 1px solid var(--ar-blue);
outline-offset: 1px;
}
/* ---------- values ---------- */
.values {
display: grid;
gap: 0.1rem;
margin: 0;
}
.values__row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-block: var(--ar-space-1);
border-block-end: 1px solid rgb(85 74 57 / 0.4);
gap: var(--ar-space-3);
padding-block: 0.3rem;
}
.values__row + .values__row {
border-block-start: 1px solid rgb(85 74 57 / 0.32);
}
.values dt {
display: flex;
align-items: baseline;
gap: var(--ar-space-2);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.inventory-page__equipment-slot-label {
color: var(--ar-text-muted);
.values__glyph {
color: var(--ar-border-highlight);
font-size: 0.8rem;
}
.inventory-page__stats {
display: grid;
gap: var(--ar-space-1);
margin: 0;
padding-block-start: var(--ar-space-2);
border-block-start: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-page__stats div {
display: flex;
justify-content: space-between;
}
.inventory-page__stats dt {
color: var(--ar-text-muted);
}
.inventory-page__stats dd {
.values dd {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-variant-numeric: tabular-nums;
}
/* ---------- the bag ---------- */
.bag {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: var(--ar-space-2);
align-content: start;
min-block-size: 0;
overflow-y: auto;
scrollbar-color: var(--ar-border) transparent;
scrollbar-width: thin;
/* Room for the scrollbar so cells never sit under it. */
padding-inline-end: var(--ar-space-2);
}
.cell {
position: relative;
aspect-ratio: 1;
padding: 0.42rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7);
}
.cell--empty {
border-color: rgb(85 74 57 / 0.45);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.cell.inventory-page__slot:hover {
border-color: var(--ar-border-highlight);
}
.cell--rare {
border-color: rgb(92 169 216 / 0.75);
}
.cell--epic {
border-color: rgb(201 164 95 / 0.8);
}
.cell--selected {
border-color: var(--ar-blue);
box-shadow:
inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7),
0 0 0 1px var(--ar-blue),
0 0 0.7rem rgb(92 169 216 / 0.45);
}
.cell__icon {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
.cell__quantity {
position: absolute;
inset-block-end: 0.1rem;
inset-inline-end: 0.25rem;
color: var(--ar-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 0.3rem #000, 0 0 0.2rem #000;
}
/* Equipped pieces live on the doll; in the bag they carry a quiet gold notch. */
.cell__equipped {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 0;
block-size: 0;
border-block-start: 0.55rem solid var(--ar-gold);
border-inline-end: 0.55rem solid transparent;
}
.bag__footer {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
align-items: end;
justify-content: space-between;
align-self: end;
inline-size: 100%;
margin-block-start: var(--ar-space-4);
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.bag__count {
font-variant-numeric: tabular-nums;
}
.bag__hint {
font-style: italic;
}
/* ---------- notices ---------- */
.inventory-page__notice {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
@@ -123,11 +312,86 @@
}
.inventory-page__notice--error {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-4);
align-items: center;
justify-content: space-between;
margin-block-start: var(--ar-space-4);
border: 1px solid var(--ar-danger);
background: var(--ar-panel);
color: var(--ar-danger);
text-align: start;
}
@media (width < 960px) {
.inventory-page__notice--error p {
margin: 0;
}
.inventory-page__notice--error button {
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, #23282c, #14181b);
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- responsive ---------- */
/* Once the columns start stacking the screen is taller than the viewport, so
the page goes back to scrolling as a whole and the bag stops scrolling
inside itself — two nested scrollers would fight each other. */
@media (width < 76rem) {
.inventory-page {
grid-template-columns: 1fr;
block-size: auto;
overflow: visible;
}
.inventory-page__columns {
grid-template-columns: minmax(16rem, 18rem) minmax(0, 1fr);
align-items: start;
}
.inventory-page__detail {
grid-column: 1 / -1;
}
.bag {
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow-y: visible;
}
.panel--doll,
.panel--bag {
grid-template-rows: none;
}
.doll {
grid-template-rows: none;
}
.doll__figure {
inline-size: 100%;
min-block-size: 12rem;
}
}
@media (width < 52rem) {
.inventory-page {
padding: var(--ar-space-3);
}
.inventory-page__columns {
grid-template-columns: minmax(0, 1fr);
}
.bag {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}

View File

@@ -17,6 +17,7 @@ const inventory: InventoryResponse = {
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 8,
@@ -33,6 +34,7 @@ const inventory: InventoryResponse = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 11,
@@ -66,6 +68,8 @@ const character: CharacterResponse = {
currentHp: 100,
maxHp: 100,
attack: 6,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
};
@@ -173,6 +177,7 @@ describe('InventoryPageComponent', () => {
item: {
key: 'iron-helm',
name: 'Eiserner Helm',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'HEAD',
weaponDamage: 0,

View File

@@ -1,42 +1,59 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot } from '../../core/api/game-api.models';
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { SLOT_LABELS } from './inventory.labels';
import { InventoryStore } from './inventory.store';
const SLOT_ORDER: readonly EquipmentSlot[] = [
'WEAPON',
'HEAD',
'CHEST',
'HANDS',
'LEGS',
'FEET',
'AMULET',
];
/**
* The paper doll reads as a body: weapon hand and worn trinkets down the left,
* armour down the right, legs beneath the figure. Only the seven slots the
* game actually models appear — no decorative rings or offhand (spec §38).
*/
const DOLL_LEFT: readonly EquipmentSlot[] = ['WEAPON', 'AMULET', 'HANDS'];
const DOLL_RIGHT: readonly EquipmentSlot[] = ['HEAD', 'CHEST', 'FEET'];
const DOLL_BELOW: EquipmentSlot = 'LEGS';
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
// Every slot, in the order the equipment list announces them.
const SLOT_ORDER: readonly EquipmentSlot[] = [...DOLL_LEFT, ...DOLL_RIGHT, DOLL_BELOW];
// The bag is drawn as a fixed grid so it reads as a container with room left,
// not as a list that happens to be short. Capacity is not enforced yet
// (spec §31): the empty cells are structure, not a limit.
const BAG_COLUMNS = 8;
const BAG_MIN_CELLS = 64;
@Component({
selector: 'app-inventory-page',
imports: [ItemCardComponent, InventoryDetailPanelComponent],
imports: [NgTemplateOutlet, InventoryDetailPanelComponent],
templateUrl: './inventory-page.component.html',
styleUrl: './inventory-page.component.scss',
})
export class InventoryPageComponent implements OnInit {
protected readonly inventoryStore = inject(InventoryStore);
private readonly worldStore = inject(WorldStore);
protected readonly dollLeft = DOLL_LEFT;
protected readonly dollRight = DOLL_RIGHT;
protected readonly dollBelow = DOLL_BELOW;
protected readonly slotOrder = SLOT_ORDER;
protected readonly slotLabels = SLOT_LABELS;
/**
* Pads the owned items out to a full rectangle of cells. `null` is an empty
* slot; the grid never ends on a ragged row.
*/
protected readonly bagCells = computed<(InventoryItem | null)[]>(() => {
const items = this.inventoryStore.inventory()?.items ?? [];
const filled = Math.ceil(items.length / BAG_COLUMNS) * BAG_COLUMNS;
const total = Math.max(BAG_MIN_CELLS, filled);
return Array.from({ length: total }, (_, index) => items[index] ?? null);
});
protected readonly bagUsed = computed(() => this.inventoryStore.inventory()?.items.length ?? 0);
protected readonly bagCapacity = computed(() => this.bagCells().length);
protected readonly equippedItemInSelectedSlot = computed(() => {
const selected = this.inventoryStore.selectedItem();
if (!selected?.item.equipmentSlot) {
@@ -60,11 +77,11 @@ export class InventoryPageComponent implements OnInit {
this.inventoryStore.selectItem(itemId);
}
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
protected retry(): void {
void this.inventoryStore.load();
}
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
}

View File

@@ -0,0 +1,12 @@
import type { EquipmentSlot } from '../../core/api/game-api.models';
/** German slot names, shared by the paper doll and the item detail panel. */
export const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};

View File

@@ -16,6 +16,7 @@ const inventory: InventoryResponse = {
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 8,
@@ -32,6 +33,7 @@ const inventory: InventoryResponse = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 11,

View File

@@ -19,6 +19,8 @@ const character: CharacterResponse = {
currentHp: 100,
maxHp: 100,
attack: 6,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
};
@@ -371,4 +373,71 @@ describe('WorldStore', () => {
expect(store.character()?.silver).toBe(0);
expect(store.error()).toBeNull();
});
describe('HP regeneration display', () => {
it('counts displayedCharacter up once per second while an anchor is set', async () => {
const wounded: CharacterResponse = {
...character,
currentHp: 40,
maxHp: 100,
hpRegenSince: '2026-08-18T10:00:00.000Z',
};
api.getCharacter.mockReturnValue(of(wounded));
await store.load();
expect(store.displayedCharacter()?.currentHp).toBe(40);
await vi.advanceTimersByTimeAsync(3_000);
expect(store.displayedCharacter()?.currentHp).toBe(43);
});
it('stops at maxHp instead of counting past it', async () => {
const almostHealed: CharacterResponse = {
...character,
currentHp: 99,
maxHp: 100,
hpRegenSince: '2026-08-18T10:00:00.000Z',
};
api.getCharacter.mockReturnValue(of(almostHealed));
await store.load();
await vi.advanceTimersByTimeAsync(5_000);
expect(store.displayedCharacter()?.currentHp).toBe(100);
});
it('does not tick while regeneration is paused', async () => {
const paused: CharacterResponse = {
...character,
currentHp: 40,
maxHp: 100,
hpRegenSince: null,
};
api.getCharacter.mockReturnValue(of(paused));
await store.load();
await vi.advanceTimersByTimeAsync(5_000);
expect(store.displayedCharacter()?.currentHp).toBe(40);
});
it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => {
api.getCharacter.mockReturnValue(of(character));
await store.load();
const stillWounded: CharacterResponse = {
...character,
currentHp: 10,
maxHp: 100,
hpRegenSince: '2026-08-18T10:00:00.000Z',
};
api.getCharacter.mockReturnValue(of(stillWounded));
await store.refreshCharacter();
await vi.advanceTimersByTimeAsync(4_000);
expect(store.displayedCharacter()?.currentHp).toBe(14);
});
});
});

View File

@@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
@Injectable({ providedIn: 'root' })
export class WorldStore implements OnDestroy {
private readonly characterState = signal<CharacterResponse | null>(null);
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
private readonly currentLocationState = signal<CurrentLocationResponse | null>(null);
private readonly selectedConnectionState = signal<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null);
@@ -32,11 +33,13 @@ export class WorldStore implements OnDestroy {
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | undefined;
private regenTimer: ReturnType<typeof setInterval> | undefined;
private travelRetryTimer: ReturnType<typeof setTimeout> | undefined;
private travelPollInFlight = false;
private destroyed = false;
readonly character = this.characterState.asReadonly();
readonly displayedCharacter = this.displayedCharacterState.asReadonly();
readonly currentLocation = this.currentLocationState.asReadonly();
readonly selectedConnection = this.selectedConnectionState.asReadonly();
readonly currentTravel = this.currentTravelState.asReadonly();
@@ -62,7 +65,7 @@ export class WorldStore implements OnDestroy {
return;
}
this.characterState.set(character);
this.applyCharacter(character);
this.currentLocationState.set(location);
this.selectedConnectionState.set(null);
await this.setCurrentTravel(travel);
@@ -102,7 +105,7 @@ export class WorldStore implements OnDestroy {
try {
const character = await firstValueFrom(this.api.getCharacter());
if (!this.destroyed) {
this.characterState.set(character);
this.applyCharacter(character);
}
} catch {
// Keep the previous character; the next load() will resync.
@@ -144,6 +147,7 @@ export class WorldStore implements OnDestroy {
ngOnDestroy(): void {
this.destroyed = true;
this.stopCountdown();
this.stopRegenTicker();
this.clearTravelRetry();
}
@@ -259,11 +263,49 @@ export class WorldStore implements OnDestroy {
return;
}
this.characterState.set(character);
this.applyCharacter(character);
this.currentLocationState.set(location);
this.selectedConnectionState.set(null);
}
private applyCharacter(character: CharacterResponse): void {
this.characterState.set(character);
this.stopRegenTicker();
this.refreshDisplayedCharacter(character);
if (character.hpRegenSince !== null) {
this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000);
}
}
private refreshDisplayedCharacter(character: CharacterResponse): void {
if (this.destroyed) {
return;
}
const currentHp = this.computeDisplayedHp(character);
this.displayedCharacterState.set({ ...character, currentHp });
if (currentHp >= character.maxHp) {
this.stopRegenTicker();
}
}
private computeDisplayedHp(character: CharacterResponse): number {
if (character.hpRegenSince === null) {
return character.currentHp;
}
const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000);
const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond);
return Math.min(character.maxHp, character.currentHp + regenerated);
}
private stopRegenTicker(): void {
if (this.regenTimer !== undefined) {
clearInterval(this.regenTimer);
this.regenTimer = undefined;
}
}
private stopCountdown(): void {
if (this.countdownTimer !== undefined) {
clearInterval(this.countdownTimer);

View File

@@ -1,5 +1,5 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.character()" />
<app-top-bar [character]="worldStore.displayedCharacter()" />
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
<app-side-navigation />

View File

@@ -32,5 +32,10 @@ export class AppShellComponent {
// squeeze the artwork the screen is built around.
private readonly atLocation = isActive('/location', this.router);
protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation();
// The inventory is itself three columns wide — doll, bag, selected item —
// and the area panel would push the bag down to a couple of cells a row.
private readonly atInventory = isActive('/inventory', this.router);
protected readonly showContextPanel = () =>
!this.inCombat() && !this.atLocation() && !this.atInventory();
}

View File

@@ -1,38 +1,57 @@
/* The rail is the painted `sidepanel-left.png`: an ornate frame with six button
plates down the top and a dragon crest at the foot. The buttons below are
laid out in percentages so they land on those painted plates at any height —
the artwork stretches to the rail, and the tracks stretch with it.
Plate geometry measured from the art (see the ratios in the grid below). */
:host {
display: block;
background: var(--ar-panel);
background: var(--ar-bg) url('/assets/hud-elements/sidepanel-left.png') center / 100% 100%
no-repeat;
}
.side-navigation {
display: grid;
align-content: start;
padding-block: var(--ar-space-3);
/* A lead-in, then one track per painted plate. */
grid-template-rows: 5.9% repeat(6, 8%);
row-gap: 1.3%;
block-size: 100%;
/* Percentage padding resolves against width — which is what the side insets
want anyway. */
padding-inline: 10.7% 15.1%;
}
/* Occupies the lead-in track. Without it the first button auto-places there and
the whole stack sits one plate too high. */
.side-navigation::before {
grid-row: 1;
content: '';
}
.side-navigation__item {
display: flex;
grid-row: span 1;
gap: var(--ar-space-2);
align-items: center;
gap: var(--ar-space-3);
inline-size: 100%;
min-block-size: 4.3rem;
padding: var(--ar-space-3) var(--ar-space-4);
justify-content: center;
min-inline-size: 0;
padding: 0 var(--ar-space-2);
border: 0;
border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent);
border-inline-start: 3px solid transparent;
color: var(--ar-text-muted);
/* The plate underneath is the button's frame; nothing is drawn on top. */
background: transparent;
font: inherit;
text-align: start;
text-decoration: none;
transition:
color var(--ar-motion-fast),
background var(--ar-motion-fast),
border-color var(--ar-motion-fast);
text-shadow var(--ar-motion-fast),
filter var(--ar-motion-fast);
}
.side-navigation__item img {
inline-size: 2.25rem;
block-size: 2.25rem;
flex: none;
inline-size: 1.9rem;
block-size: 1.9rem;
object-fit: cover;
border-radius: 50%;
}
@@ -42,8 +61,8 @@
display: grid;
flex: none;
place-items: center;
inline-size: 2.25rem;
block-size: 2.25rem;
inline-size: 1.9rem;
block-size: 1.9rem;
border: 1px solid var(--ar-border-highlight);
border-radius: 50%;
color: var(--ar-gold);
@@ -51,37 +70,58 @@
}
.side-navigation__glyph app-location-icon {
inline-size: 1.25rem;
block-size: 1.25rem;
inline-size: 1.1rem;
block-size: 1.1rem;
}
.side-navigation__item span {
overflow: hidden;
font-size: 0.95rem;
letter-spacing: 0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Active reads as the plate catching light, not as a bar bolted beside it. */
.side-navigation__item--active {
border-inline-start-color: var(--ar-blue);
color: var(--ar-text);
background: linear-gradient(90deg, rgb(92 169 216 / 0.2), transparent);
box-shadow: inset 0 0 1.25rem rgb(92 169 216 / 0.08);
color: var(--ar-gold);
filter: drop-shadow(0 0 0.5rem rgb(201 164 95 / 0.45));
text-shadow: 0 0 0.6rem rgb(201 164 95 / 0.5);
}
.side-navigation__item:not(:disabled):hover,
.side-navigation__item:not(:disabled):focus-visible {
color: var(--ar-text);
background: rgb(255 255 255 / 0.04);
}
.side-navigation__item:disabled {
opacity: 0.52;
opacity: 0.42;
}
/* Narrow: the tall painted rail no longer fits, so the bar goes horizontal and
drops the artwork rather than squashing it. */
@media (width < 620px) {
:host {
background: var(--ar-panel);
}
.side-navigation {
grid-template-columns: repeat(5, minmax(0, 1fr));
padding: 0;
grid-template-rows: none;
grid-template-columns: repeat(6, minmax(0, 1fr));
row-gap: 0;
block-size: auto;
padding-inline: 0;
}
.side-navigation::before {
display: none;
}
.side-navigation__item {
justify-content: center;
min-block-size: 3.5rem;
padding: var(--ar-space-2);
border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent);
}
.side-navigation__item span {

View File

@@ -1,6 +1,6 @@
<header class="top-bar">
<div class="top-bar__character">
<img class="top-bar__portrait" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<img class="top-bar__portrait" src="/images/character/female-portrait-256.png" alt="" />
@if (character(); as character) {
<div class="top-bar__identity">
<span class="top-bar__name">{{ character.name }}</span>

View File

@@ -11,6 +11,8 @@ function characterFixture(overrides: Partial<CharacterResponse> = {}): Character
currentHp: 80,
maxHp: 100,
attack: 10,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'location-1', key: 'aschenfelder', name: 'Aschenfelder' },
...overrides,
};

View File

@@ -83,6 +83,85 @@ img {
max-inline-size: 100%;
}
/* Announced to screen readers, invisible on screen. */
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
/* ---------- panel chrome ----------
The forged frame every game panel sits in. The art is `panel-frame.png`,
derived from the hand-made `panel-bg.png` by `tools/asset-gen.mjs`: the
centre-edge gems are painted out there so the frame can be nine-sliced to
any panel size, and put back below as real ornaments. */
.ar-panel {
position: relative;
padding: var(--ar-space-3) var(--ar-space-4);
/* Fallback if the art fails to load — the frame is transparent border. */
border: 1.6rem solid transparent;
border-image: url('/assets/hud-elements/panel-frame.png') 96 fill stretch;
background-clip: padding-box;
filter: drop-shadow(0 0.4rem 1rem rgb(0 0 0 / 0.55));
}
/* The gem the nine-slice cannot carry, restored at the true centre of the
top and bottom edge. */
.ar-panel::before,
.ar-panel::after {
position: absolute;
inset-inline-start: 50%;
inline-size: 2.6rem;
block-size: 2.35rem;
content: '';
background: url('/assets/hud-elements/panel-ornament.png') center / contain no-repeat;
pointer-events: none;
translate: -50% 0;
}
.ar-panel::before {
inset-block-start: -1.5rem;
}
.ar-panel::after {
inset-block-end: -1.5rem;
rotate: 180deg;
}
.ar-panel__title {
position: relative;
margin: 0 0 var(--ar-space-4);
padding-block-end: var(--ar-space-2);
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.82rem;
font-weight: 400;
letter-spacing: 0.22em;
text-align: center;
text-transform: uppercase;
}
.ar-panel__title::after {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
block-size: 1px;
content: '';
background: linear-gradient(
90deg,
transparent,
rgb(155 122 66 / 0.75) 22%,
rgb(155 122 66 / 0.75) 78%,
transparent
);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,