inventory

This commit is contained in:
Bastian Wagner
2026-08-21 12:52:44 +02:00
parent a376fb7128
commit 987242541d
34 changed files with 3569 additions and 1814 deletions

View File

@@ -19,6 +19,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
itemDefinition: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
rarity: ItemRarity.COMMON,
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
@@ -64,6 +65,7 @@ describe('InventoryService', () => {
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,

View File

@@ -13,6 +13,7 @@ export interface InventoryItemDto {
item: {
key: string;
name: string;
description: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
@@ -56,6 +57,7 @@ export class InventoryService {
item: {
key: characterItem.itemDefinition.key,
name: characterItem.itemDefinition.name,
description: characterItem.itemDefinition.description,
rarity: characterItem.itemDefinition.rarity,
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
requiredLevel: characterItem.itemDefinition.requiredLevel,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -23,6 +23,7 @@ describe('App', () => {
{ path: 'location', children: [] },
{ path: 'world', children: [] },
{ path: 'hunt', children: [] },
{ path: 'inventory', children: [] },
]),
{
provide: WorldStore,
@@ -112,6 +113,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);

View File

@@ -228,6 +228,7 @@ export interface InventoryItem {
item: {
key: string;
name: string;
description: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;

View File

@@ -1,60 +1,85 @@
@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 }} · Stufe {{ item.item.requiredLevel }}</p>
}
</div>
</div>
<section class="ar-panel detail" aria-label="Gewählter Gegenstand">
<h2 class="ar-panel__title">Gewählter Gegenstand</h2>
@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>
@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 (isEquippable()) {
<dl class="detail__meta">
<div class="detail__stat">
<dt>Benötigte Stufe</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>
}
<span class="detail__value" [class.detail__value--unmet]="!meetsLevelRequirement()">
{{ item.item.requiredLevel }}
</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 if (!meetsLevelRequirement()) {
<button type="button" class="inventory-detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="inventory-detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
</dl>
}
</div>
</article>
} @else {
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
@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 if (!meetsLevelRequirement()) {
<button type="button" class="detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
</section>

View File

@@ -2,99 +2,177 @@
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;
white-space: nowrap;
}
.detail__value {
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.inventory-detail__diff--positive {
color: var(--ar-success);
}
.inventory-detail__diff--negative {
.detail__value--unmet {
color: var(--ar-danger);
}
.inventory-detail__actions {
display: flex;
justify-content: center;
/* 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;
}
.inventory-detail__equipped {
.detail__diff--positive {
color: var(--ar-success);
}
.detail__diff--negative {
color: var(--ar-danger);
}
/* ---------- 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;
}
/* ---------- 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 +181,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',
requiredLevel: 1,
@@ -28,6 +29,7 @@ const banditBlade: InventoryItem = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
@@ -70,6 +72,19 @@ describe('InventoryDetailPanelComponent', () => {
expect(element.querySelector('[data-detail-equip]')).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;
@@ -49,6 +40,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,59 +1,123 @@
<!-- 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()"
[characterLevel]="characterLevel()"
[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) {
@@ -63,3 +127,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,309 @@
// 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/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: left center;
background-size: 600% 100%;
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
}
.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 +311,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',
requiredLevel: 1,
@@ -34,6 +35,7 @@ const inventory: InventoryResponse = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
@@ -176,6 +178,7 @@ describe('InventoryPageComponent', () => {
item: {
key: 'iron-helm',
name: 'Eiserner Helm',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'HEAD',
requiredLevel: 1,

View File

@@ -1,44 +1,61 @@
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;
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
/**
* 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) {
@@ -62,11 +79,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',
requiredLevel: 1,
@@ -33,6 +34,7 @@ const inventory: InventoryResponse = {
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,

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.1%;
block-size: 100%;
/* Percentage padding resolves against width — which is what the side insets
want anyway. */
padding-inline: 18.7% 17.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

@@ -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,