feat(web): add reward models and the reusable item card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@ describe('App', () => {
|
|||||||
name: 'Mara Ashfall',
|
name: 'Mara Ashfall',
|
||||||
level: 7,
|
level: 7,
|
||||||
experience: 320,
|
experience: 320,
|
||||||
|
silver: 150,
|
||||||
currentHp: 52,
|
currentHp: 52,
|
||||||
maxHp: 80,
|
maxHp: 80,
|
||||||
attack: 12,
|
attack: 12,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface CharacterResponse {
|
|||||||
name: string;
|
name: string;
|
||||||
level: number;
|
level: number;
|
||||||
experience: number;
|
experience: number;
|
||||||
|
silver: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
@@ -105,4 +106,26 @@ export interface Combat {
|
|||||||
player: CombatPlayer;
|
player: CombatPlayer;
|
||||||
monster: CombatMonster;
|
monster: CombatMonster;
|
||||||
events: CombatEvent[];
|
events: CombatEvent[];
|
||||||
|
rewards: CombatReward | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC';
|
||||||
|
|
||||||
|
export interface RewardItemSummary {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatRewardItem {
|
||||||
|
characterItemId: string;
|
||||||
|
item: RewardItemSummary;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatReward {
|
||||||
|
experience: number;
|
||||||
|
silver: number;
|
||||||
|
items: CombatRewardItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const activeCombat: Combat = {
|
|||||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||||
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||||
],
|
],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const startedCombat: Combat = {
|
|||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const afterAttack: Combat = {
|
const afterAttack: Combat = {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const startedCombat: Combat = {
|
|||||||
artworkPath: '/images/enemies/RoadBandit.png',
|
artworkPath: '/images/enemies/RoadBandit.png',
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('HuntPageComponent', () => {
|
describe('HuntPageComponent', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const character: CharacterResponse = {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 100,
|
||||||
attack: 6,
|
attack: 6,
|
||||||
|
|||||||
10
apps/web/src/app/shared/item-card/item-card.component.html
Normal file
10
apps/web/src/app/shared/item-card/item-card.component.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<article class="item-card" [class]="rarityModifier()">
|
||||||
|
<div class="item-card__frame">
|
||||||
|
<img class="item-card__icon" data-item-icon [src]="item().iconPath" [alt]="item().name" />
|
||||||
|
@if (quantity() > 1) {
|
||||||
|
<span class="item-card__quantity" data-item-quantity>×{{ quantity() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="item-card__name" data-item-name>{{ item().name }}</p>
|
||||||
|
<p class="item-card__rarity" data-item-rarity>{{ rarityLabel() }}</p>
|
||||||
|
</article>
|
||||||
66
apps/web/src/app/shared/item-card/item-card.component.scss
Normal file
66
apps/web/src/app/shared/item-card/item-card.component.scss
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
.item-card {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
justify-items: center;
|
||||||
|
inline-size: 8.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__frame {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 6rem;
|
||||||
|
block-size: 6rem;
|
||||||
|
padding: var(--ar-space-1);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__icon {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__quantity {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0.1rem;
|
||||||
|
inset-inline-end: 0.25rem;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
text-shadow: 0 0 0.3rem #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__rarity {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restrained rarity emphasis: the frame edge carries it, nothing flashes. */
|
||||||
|
.item-card--rare .item-card__frame {
|
||||||
|
border-color: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--rare .item-card__rarity {
|
||||||
|
color: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--epic .item-card__frame {
|
||||||
|
border-color: var(--ar-border-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--epic .item-card__rarity {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import type { RewardItemSummary } from '../../core/api/game-api.models';
|
||||||
|
import { ItemCardComponent } from './item-card.component';
|
||||||
|
|
||||||
|
const banditBlade: RewardItemSummary = {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(item: RewardItemSummary, quantity = 1) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [ItemCardComponent] }).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ItemCardComponent);
|
||||||
|
fixture.componentRef.setInput('item', item);
|
||||||
|
fixture.componentRef.setInput('quantity', quantity);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ItemCardComponent', () => {
|
||||||
|
it('renders the icon, name, and German rarity label', async () => {
|
||||||
|
const fixture = await setup(banditBlade);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
const icon = element.querySelector<HTMLImageElement>('[data-item-icon]');
|
||||||
|
expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png');
|
||||||
|
expect(icon?.getAttribute('alt')).toBe('Räuberklinge');
|
||||||
|
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
|
||||||
|
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels the other rarities in German too', async () => {
|
||||||
|
const rare = await setup({ ...banditBlade, rarity: 'RARE' });
|
||||||
|
expect(
|
||||||
|
(rare.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||||
|
).toContain('Selten');
|
||||||
|
|
||||||
|
const epic = await setup({ ...banditBlade, rarity: 'EPIC' });
|
||||||
|
expect(
|
||||||
|
(epic.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||||
|
).toContain('Episch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the quantity for a single item and shows it for a stack', async () => {
|
||||||
|
const single = await setup(banditBlade, 1);
|
||||||
|
expect((single.nativeElement as HTMLElement).querySelector('[data-item-quantity]')).toBeNull();
|
||||||
|
|
||||||
|
const stack = await setup(banditBlade, 3);
|
||||||
|
expect(
|
||||||
|
(stack.nativeElement as HTMLElement).querySelector('[data-item-quantity]')?.textContent,
|
||||||
|
).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers no equip or comparison affordance yet', async () => {
|
||||||
|
const fixture = await setup(banditBlade);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('button')).toBeNull();
|
||||||
|
expect(element.textContent).not.toContain('Anlegen');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
apps/web/src/app/shared/item-card/item-card.component.ts
Normal file
27
apps/web/src/app/shared/item-card/item-card.component.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import type { ItemRarity, RewardItemSummary } from '../../core/api/game-api.models';
|
||||||
|
|
||||||
|
export const RARITY_LABELS: Readonly<Record<ItemRarity, string>> = {
|
||||||
|
COMMON: 'Gewöhnlich',
|
||||||
|
RARE: 'Selten',
|
||||||
|
EPIC: 'Episch',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact item presentation for loot (spec §33).
|
||||||
|
*
|
||||||
|
* Deliberately read-only: equipping, stat comparison, and slot replacement
|
||||||
|
* belong to Slice 0.5.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-item-card',
|
||||||
|
templateUrl: './item-card.component.html',
|
||||||
|
styleUrl: './item-card.component.scss',
|
||||||
|
})
|
||||||
|
export class ItemCardComponent {
|
||||||
|
readonly item = input.required<RewardItemSummary>();
|
||||||
|
readonly quantity = input(1);
|
||||||
|
|
||||||
|
protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]);
|
||||||
|
protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user