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

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