feat(web): add inventory item detail/comparison panel
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
@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>
|
||||||
|
|
||||||
|
@if (statRows().length) {
|
||||||
|
<dl class="inventory-detail__stats" data-detail-stats>
|
||||||
|
@for (row of statRows(); track row.label) {
|
||||||
|
<div class="inventory-detail__stat">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ row.value }}
|
||||||
|
@if (row.diff !== null && row.diff !== 0) {
|
||||||
|
<span
|
||||||
|
class="inventory-detail__diff"
|
||||||
|
[class.inventory-detail__diff--positive]="row.diff > 0"
|
||||||
|
[class.inventory-detail__diff--negative]="row.diff < 0"
|
||||||
|
>
|
||||||
|
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="inventory-detail__actions">
|
||||||
|
@if (item.equipped) {
|
||||||
|
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
|
||||||
|
} @else if (!isEquippable()) {
|
||||||
|
<span class="inventory-detail__note">Nicht ausrüstbar</span>
|
||||||
|
} @else 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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
} @else {
|
||||||
|
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
:host {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
align-items: center;
|
||||||
|
margin-block-end: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__icon {
|
||||||
|
inline-size: 4rem;
|
||||||
|
block-size: 4rem;
|
||||||
|
padding: var(--ar-space-1);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__rarity {
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stat dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stat dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__diff--positive {
|
||||||
|
color: var(--ar-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__diff--negative {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equipped {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__note {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip {
|
||||||
|
inline-size: 100%;
|
||||||
|
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, #263b4b, #17232d);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip:hover:not(:disabled) {
|
||||||
|
border-color: #d6b26b;
|
||||||
|
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip:disabled {
|
||||||
|
border-color: var(--ar-border);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
background: #1a1c1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__empty {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
|
||||||
|
const wornSword: InventoryItem = {
|
||||||
|
id: 'item-sword',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const banditBlade: InventoryItem = {
|
||||||
|
id: 'item-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(overrides: {
|
||||||
|
item?: InventoryItem | null;
|
||||||
|
equippedItemInSlot?: InventoryItem | null;
|
||||||
|
characterLevel?: number;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
|
||||||
|
fixture.componentRef.setInput('item', overrides.item ?? null);
|
||||||
|
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
|
||||||
|
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
|
||||||
|
fixture.componentRef.setInput('busy', overrides.busy ?? false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryDetailPanelComponent', () => {
|
||||||
|
it('shows a placeholder when nothing is selected', async () => {
|
||||||
|
const fixture = await setup({ item: null });
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
|
||||||
|
const fixture = await setup({ item: wornSword });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
|
||||||
|
expect(element.querySelector('[data-detail-equip]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('11');
|
||||||
|
expect(text).toContain('+3');
|
||||||
|
expect(text).toContain('+1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
|
||||||
|
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.textContent).toContain('Benötigt Stufe 5');
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));
|
||||||
|
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();
|
||||||
|
|
||||||
|
expect(emitted).toEqual(['item-blade']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the equip button while busy', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade, busy: true });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import type { EquipmentSlot, 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatRow {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
diff: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor';
|
||||||
|
const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [
|
||||||
|
{ label: 'Waffenschaden', key: 'weaponDamage' },
|
||||||
|
{ label: 'Angriff', key: 'bonusAttack' },
|
||||||
|
{ label: 'Leben', key: 'bonusHp' },
|
||||||
|
{ label: 'Rüstung', key: 'bonusArmor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Selected-item details and equip comparison (spec §32–37). */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inventory-detail-panel',
|
||||||
|
templateUrl: './inventory-detail-panel.component.html',
|
||||||
|
styleUrl: './inventory-detail-panel.component.scss',
|
||||||
|
})
|
||||||
|
export class InventoryDetailPanelComponent {
|
||||||
|
readonly item = input<InventoryItem | null>(null);
|
||||||
|
readonly equippedItemInSlot = input<InventoryItem | null>(null);
|
||||||
|
readonly characterLevel = input(1);
|
||||||
|
readonly busy = input(false);
|
||||||
|
readonly equip = output<string>();
|
||||||
|
|
||||||
|
protected readonly rarityLabel = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? RARITY_LABELS[item.item.rarity] : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly slotLabel = computed(() => {
|
||||||
|
const slot = this.item()?.item.equipmentSlot;
|
||||||
|
return slot ? SLOT_LABELS[slot] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly statRows = computed<StatRow[]>(() => {
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const compareTo = this.equippedItemInSlot();
|
||||||
|
const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;
|
||||||
|
|
||||||
|
return STAT_LABELS.map(({ label, key }) => ({
|
||||||
|
label,
|
||||||
|
value: item.item[key],
|
||||||
|
diff: comparable ? item.item[key] - comparable[key] : null,
|
||||||
|
})).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);
|
||||||
|
|
||||||
|
protected readonly meetsLevelRequirement = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? item.item.requiredLevel <= this.characterLevel() : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onEquip(): void {
|
||||||
|
const item = this.item();
|
||||||
|
if (item) {
|
||||||
|
this.equip.emit(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user