feat: add EncounterCard component for hunt encounter selection
Task 9 of Playable Slice 0.2 (First Hunt). Artwork-forward encounter card reusing travel-panel's dark-fantasy panel chrome (tokens, border, gradient, button style). Emits encounter.id (never monster.key) on Angreifen click, preserving the frontend security boundary.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<article class="encounter-card">
|
||||
<div class="encounter-card__artwork-frame">
|
||||
<img
|
||||
class="encounter-card__artwork"
|
||||
[src]="encounter.monster.artworkPath"
|
||||
[alt]="encounter.monster.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="encounter-card__body">
|
||||
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
|
||||
<span class="encounter-card__level">Stufe {{ encounter.monster.level }}</span>
|
||||
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
|
||||
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,108 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.encounter-card {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
border-radius: var(--ar-radius-md);
|
||||
background:
|
||||
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
}
|
||||
|
||||
.encounter-card__artwork-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-block-end: 1px solid rgb(155 122 66 / 0.45);
|
||||
background: #050607;
|
||||
}
|
||||
|
||||
.encounter-card__artwork-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 -3.5rem 3rem -1.5rem rgb(0 0 0 / 0.75);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.encounter-card__artwork {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.encounter-card__body {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--ar-space-2);
|
||||
padding: var(--ar-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.encounter-card__name {
|
||||
margin: 0;
|
||||
color: var(--ar-text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.encounter-card__level {
|
||||
color: var(--ar-gold);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.encounter-card__danger {
|
||||
margin-block: var(--ar-space-1);
|
||||
}
|
||||
|
||||
.encounter-card__attack {
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--ar-space-2);
|
||||
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);
|
||||
cursor: pointer;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.encounter-card__attack:hover {
|
||||
border-color: #d6b26b;
|
||||
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||
}
|
||||
|
||||
.encounter-card__attack:focus-visible {
|
||||
outline: 2px solid var(--ar-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.encounter-card {
|
||||
transition:
|
||||
border-color var(--ar-motion-base),
|
||||
box-shadow var(--ar-motion-base);
|
||||
}
|
||||
|
||||
.encounter-card:hover {
|
||||
border-color: #d6b26b;
|
||||
box-shadow:
|
||||
var(--ar-shadow-raised),
|
||||
0 0 1.1rem rgb(214 178 107 / 0.35);
|
||||
}
|
||||
|
||||
.encounter-card__attack {
|
||||
transition:
|
||||
border-color var(--ar-motion-fast),
|
||||
background var(--ar-motion-fast);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import type { HuntEncounter } from '../../../core/api/game-api.models';
|
||||
import { EncounterCardComponent } from './encounter-card.component';
|
||||
|
||||
const dawnwolfEncounter: HuntEncounter = {
|
||||
id: 'encounter-id-9f2c',
|
||||
monster: {
|
||||
key: 'dawnwolf',
|
||||
name: 'Dämmerwolf',
|
||||
level: 3,
|
||||
artworkPath: '/images/enemies/Dawnwolf.png',
|
||||
},
|
||||
dangerRating: 'MATCH',
|
||||
};
|
||||
|
||||
describe('EncounterCardComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EncounterCardComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('renders the monster name, level, danger label, and artwork', () => {
|
||||
const fixture = TestBed.createComponent(EncounterCardComponent);
|
||||
fixture.componentRef.setInput('encounter', dawnwolfEncounter);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const img = element.querySelector('img');
|
||||
|
||||
expect(element.textContent).toContain('Dämmerwolf');
|
||||
expect(element.textContent).toContain('Stufe 3');
|
||||
expect(element.textContent).toContain('Passend');
|
||||
expect(img?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png');
|
||||
expect(img?.getAttribute('alt')).toBe('Dämmerwolf');
|
||||
});
|
||||
|
||||
it('emits the encounter id (never the monster key) when Angreifen is clicked', () => {
|
||||
const fixture = TestBed.createComponent(EncounterCardComponent);
|
||||
fixture.componentRef.setInput('encounter', dawnwolfEncounter);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitted = vi.fn();
|
||||
fixture.componentInstance.attack.subscribe(emitted);
|
||||
|
||||
const button = Array.from(fixture.nativeElement.querySelectorAll('button')).find(
|
||||
(candidate): candidate is HTMLButtonElement =>
|
||||
candidate instanceof HTMLButtonElement && candidate.textContent?.trim() === 'Angreifen',
|
||||
);
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toHaveBeenCalledOnce();
|
||||
expect(emitted).toHaveBeenCalledWith(dawnwolfEncounter.id);
|
||||
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
|
||||
expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { HuntEncounter } from '../../../core/api/game-api.models';
|
||||
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-encounter-card',
|
||||
imports: [DangerBadgeComponent],
|
||||
templateUrl: './encounter-card.component.html',
|
||||
styleUrl: './encounter-card.component.scss',
|
||||
})
|
||||
export class EncounterCardComponent {
|
||||
@Input({ required: true }) encounter!: HuntEncounter;
|
||||
@Output() readonly attack = new EventEmitter<string>();
|
||||
|
||||
protected onAttack(): void {
|
||||
this.attack.emit(this.encounter.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user