inventory
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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);
|
||||
|
||||
@@ -228,6 +228,7 @@ export interface InventoryItem {
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: ItemRarity;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
requiredLevel: number;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/web/src/app/features/inventory/inventory.labels.ts
Normal file
12
apps/web/src/app/features/inventory/inventory.labels.ts
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user