animation

This commit is contained in:
Bastian Wagner
2026-08-19 23:24:36 +02:00
parent 76e8ef4320
commit fd9852bfbf
9 changed files with 220 additions and 92 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -2,9 +2,9 @@ import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angula
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatEvent } from '../../../core/api/game-api.models'; import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
import { import {
combatMonsterIconPath,
combatMonsterSpritePath,
combatMonsterSpriteScale, combatMonsterSpriteScale,
monsterCutoutPath,
monsterIconPath,
runtimeMonsterArtworkPath, runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork'; } from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store'; import { CombatStore } from '../combat.store';
@@ -125,7 +125,7 @@ export class CombatPageComponent implements OnInit {
} }
protected monsterSprite(monsterKey: string, artworkPath: string): string { protected monsterSprite(monsterKey: string, artworkPath: string): string {
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
} }
protected monsterSpriteScale(monsterKey: string): number { protected monsterSpriteScale(monsterKey: string): number {
@@ -133,7 +133,7 @@ export class CombatPageComponent implements OnInit {
} }
protected monsterIcon(monsterKey: string, artworkPath: string): string { protected monsterIcon(monsterKey: string, artworkPath: string): string {
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; return monsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
} }
protected playerHpPercent(): number { protected playerHpPercent(): number {

View File

@@ -1,22 +1,26 @@
<article class="encounter-card"> <article class="encounter-card">
<div class="encounter-card__artwork-frame"> <div class="encounter-card__crest">
<picture> @if (iconPath(); as icon) {
@if (runtimeArtworkPath(encounter.monster.artworkPath); as runtimeArtwork) { <img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
<source [srcset]="runtimeArtwork" type="image/jpeg" />
} }
</div>
<div class="encounter-card__artwork-frame">
<img <img
class="encounter-card__artwork" class="encounter-card__artwork"
[src]="encounter.monster.artworkPath" [src]="artworkPath()"
[alt]="encounter.monster.name" [alt]="encounter.monster.name"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
</picture>
</div> </div>
<div class="encounter-card__body">
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3> <h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
<div class="encounter-card__meta">
<span class="encounter-card__level">Stufe {{ encounter.monster.level }}</span> <span class="encounter-card__level">Stufe {{ encounter.monster.level }}</span>
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" /> <app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
</div> </div>
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
</article> </article>

View File

@@ -2,106 +2,156 @@
display: block; display: block;
} }
/*
* The card is one painted frame, so every slot is placed as a share of that
* artwork rather than flowed: percentages keep the crest, the artwork panel,
* the two text bands and the blue plaque on their painted counterparts at any
* card width. Coordinates come from the 560x957 frame derivative, which is
* cut to the frame's silhouette so no black surround shows on the panel.
*/
.encounter-card { .encounter-card {
display: grid; position: relative;
overflow: hidden; container-type: inline-size;
border: 1px solid var(--ar-border-highlight); max-inline-size: 20rem;
border-radius: var(--ar-radius-md); margin-inline: auto;
background: aspect-ratio: 560 / 957;
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96); background-image: url('/images/hud/runtime/enemy-card-560.webp');
box-shadow: var(--ar-shadow-raised); background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
} }
/* ---------- crest ---------- */
.encounter-card__crest {
position: absolute;
inset-block-start: 9%;
inset-inline-start: 50%;
inline-size: 16.8%;
aspect-ratio: 1;
overflow: hidden;
border-radius: 50%;
transform: translate(-50%, -50%);
}
/* Scaled past the frame so the icon's own painted ring is cropped away and
only the portrait sits inside the crest ring. */
.encounter-card__crest-icon {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
scale: 1.22;
}
/* ---------- artwork ---------- */
.encounter-card__artwork-frame { .encounter-card__artwork-frame {
position: relative;
overflow: hidden;
aspect-ratio: 4 / 3;
border-block-end: 1px solid rgb(155 122 66 / 0.45);
background: var(--ar-bg);
}
.encounter-card__artwork-frame::after {
content: '';
position: absolute; position: absolute;
inset: 0; inset-block: 12.6% 40.4%;
box-shadow: inset 0 -3.5rem 3rem -1.5rem rgb(0 0 0 / 0.75); inset-inline: 15% 14.6%;
pointer-events: none; display: grid;
padding: 4% 6% 2%;
place-items: end center;
} }
.encounter-card__artwork { .encounter-card__artwork {
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
object-fit: cover; object-fit: contain;
object-position: center; object-position: bottom;
filter: drop-shadow(0 0.35rem 0.7rem rgb(0 0 0 / 0.75));
} }
.encounter-card__body { /* ---------- text bands ---------- */
display: grid;
justify-items: center;
gap: var(--ar-space-2);
padding: var(--ar-space-4);
text-align: center;
}
.encounter-card__name { .encounter-card__name {
position: absolute;
inset-block-start: 62.8%;
inset-inline: 15% 14.6%;
display: grid;
block-size: 5.4%;
margin: 0; margin: 0;
overflow: hidden;
place-items: center;
color: var(--ar-text); color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1.4rem; font-size: clamp(0.85rem, 5.4cqw, 1.35rem);
font-weight: 400; font-weight: 400;
line-height: 1;
text-shadow: 0 0.1rem 0.4rem rgb(0 0 0 / 0.9);
white-space: nowrap;
}
.encounter-card__meta {
position: absolute;
inset-block-start: 69.2%;
inset-inline: 12% 12%;
display: grid;
block-size: 8.8%;
gap: 1.2cqw;
place-content: center;
justify-items: center;
} }
.encounter-card__level { .encounter-card__level {
color: var(--ar-gold); color: var(--ar-gold);
font-size: var(--ar-font-sm); font-size: clamp(0.6rem, 3.5cqw, 0.85rem);
letter-spacing: 0.06em; letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase; text-transform: uppercase;
} }
/* The badge sizes itself off the shared token, so scaling it with the card is
a matter of redefining that token inside the card. */
.encounter-card__danger { .encounter-card__danger {
margin-block: var(--ar-space-1); --ar-font-sm: clamp(0.6rem, 3.4cqw, 0.8125rem);
} }
/* ---------- attack plaque ---------- */
.encounter-card__attack { .encounter-card__attack {
inline-size: 100%; position: absolute;
margin-block-start: var(--ar-space-2); inset-block-start: 81.1%;
padding: var(--ar-space-2) var(--ar-space-4); inset-inline-start: 11.8%;
border: 1px solid var(--ar-border-highlight); inline-size: 77.1%;
border-radius: var(--ar-radius-sm); block-size: 10.4%;
padding: 0;
border: 0;
border-radius: 0.2rem;
color: var(--ar-text); color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d); background: transparent;
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem; font-size: clamp(0.8rem, 4.6cqw, 1.15rem);
letter-spacing: 0.02em; letter-spacing: 0.08em;
text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85);
} }
.encounter-card__attack:hover { .encounter-card__attack:hover {
border-color: #d6b26b; color: #f4ecda;
background: linear-gradient(180deg, #315067, #1a2c3a); box-shadow: inset 0 0 1.1rem rgb(150 200 245 / 0.5);
}
.encounter-card__attack:active {
box-shadow: inset 0 0.15rem 0.7rem rgb(0 0 0 / 0.6);
} }
.encounter-card__attack:focus-visible { .encounter-card__attack:focus-visible {
outline: 2px solid var(--ar-blue); outline: 2px solid var(--ar-blue);
outline-offset: 2px; outline-offset: -3px;
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.encounter-card { .encounter-card {
transition: transition: filter var(--ar-motion-base);
border-color var(--ar-motion-base),
box-shadow var(--ar-motion-base);
} }
.encounter-card:hover { .encounter-card:hover {
border-color: #d6b26b; filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3));
box-shadow:
var(--ar-shadow-raised),
0 0 1.1rem rgb(214 178 107 / 0.35);
} }
.encounter-card__attack { .encounter-card__attack {
transition: transition:
border-color var(--ar-motion-fast), color var(--ar-motion-fast),
background var(--ar-motion-fast); box-shadow var(--ar-motion-fast);
} }
} }

View File

@@ -14,6 +14,24 @@ const dawnwolfEncounter: HuntEncounter = {
dangerRating: 'MATCH', dangerRating: 'MATCH',
}; };
const ashRatEncounter: HuntEncounter = {
id: 'encounter-id-4b71',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
};
function render(encounter: HuntEncounter): HTMLElement {
const fixture = TestBed.createComponent(EncounterCardComponent);
fixture.componentRef.setInput('encounter', encounter);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('EncounterCardComponent', () => { describe('EncounterCardComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -22,18 +40,34 @@ describe('EncounterCardComponent', () => {
}); });
it('renders the monster name, level, danger label, and artwork', () => { it('renders the monster name, level, danger label, and artwork', () => {
const fixture = TestBed.createComponent(EncounterCardComponent); const element = render(dawnwolfEncounter);
fixture.componentRef.setInput('encounter', dawnwolfEncounter); const artwork = element.querySelector('.encounter-card__artwork');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const img = element.querySelector('img');
expect(element.textContent).toContain('Dämmerwolf'); expect(element.textContent).toContain('Dämmerwolf');
expect(element.textContent).toContain('Stufe 3'); expect(element.textContent).toContain('Stufe 3');
expect(element.textContent).toContain('Passend'); expect(element.textContent).toContain('Passend');
expect(img?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png'); expect(artwork?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png');
expect(img?.getAttribute('alt')).toBe('Dämmerwolf'); expect(artwork?.getAttribute('alt')).toBe('Dämmerwolf');
});
it('shows the background-free cut-out instead of the framed artwork when one exists', () => {
const element = render(ashRatEncounter);
const artwork = element.querySelector('.encounter-card__artwork');
expect(artwork?.getAttribute('src')).toBe('/images/combat/sprites/ash-rat-760.png');
});
it('shows the monster icon in the crest when one exists', () => {
const element = render(ashRatEncounter);
const crest = element.querySelector('.encounter-card__crest-icon');
expect(crest?.getAttribute('src')).toBe('/images/combat/icons/ash-rat-128.png');
});
it('leaves the crest empty for a monster without an icon', () => {
const element = render(dawnwolfEncounter);
expect(element.querySelector('.encounter-card__crest-icon')).toBeNull();
}); });
it('emits the encounter id (never the monster key) when Angreifen is clicked', () => { it('emits the encounter id (never the monster key) when Angreifen is clicked', () => {

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models'; import { HuntEncounter } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork'; import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component'; import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
@Component({ @Component({
@@ -17,7 +17,15 @@ export class EncounterCardComponent {
this.attack.emit(this.encounter.id); this.attack.emit(this.encounter.id);
} }
protected runtimeArtworkPath(artworkPath: string): string | undefined { // The frame paints its own artwork panel, so the monster has to be a
return runtimeMonsterArtworkPath(artworkPath); // background-free cut-out. Monsters without one fall back to their framed
// artwork, which still reads inside the panel.
protected artworkPath(): string {
const monster = this.encounter.monster;
return monsterCutoutPath(monster.key) ?? monster.artworkPath;
}
protected iconPath(): string | undefined {
return monsterIconPath(this.encounter.monster.key);
} }
} }

View File

@@ -179,8 +179,16 @@ describe('HuntPageComponent', () => {
const cards = element.querySelectorAll('app-encounter-card'); const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3); expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/); expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2); expect(
expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1); element.querySelectorAll(
'.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]',
).length,
).toBe(2);
expect(
element.querySelectorAll(
'.encounter-card__artwork[src="/images/combat/sprites/road-bandit-620.png"]',
).length,
).toBe(1);
expect(element.textContent).toContain('Stufe 1'); expect(element.textContent).toContain('Stufe 1');
expect(element.textContent).toContain('Stufe 3'); expect(element.textContent).toContain('Stufe 3');
}); });

View File

@@ -1,4 +1,4 @@
import { runtimeMonsterArtworkPath } from './monster-artwork'; import { monsterCutoutPath, monsterIconPath, runtimeMonsterArtworkPath } from './monster-artwork';
describe('runtimeMonsterArtworkPath', () => { describe('runtimeMonsterArtworkPath', () => {
it('returns the optimized JPEG derivative for a known monster artwork path', () => { it('returns the optimized JPEG derivative for a known monster artwork path', () => {
@@ -11,3 +11,25 @@ describe('runtimeMonsterArtworkPath', () => {
expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined(); expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
}); });
}); });
describe('monsterCutoutPath', () => {
it('returns the background-free cut-out for a known monster key', () => {
expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png');
});
it('returns undefined for a monster without a cut-out', () => {
expect(monsterCutoutPath('dawnwolf')).toBeUndefined();
});
});
describe('monsterIconPath', () => {
it('returns the medallion icon for a known monster key', () => {
expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png');
expect(monsterIconPath('road-bandit')).toBe('/images/combat/icons/road-bandit-128.png');
});
it('returns undefined for a monster without an icon', () => {
expect(monsterIconPath('dawnwolf')).toBeUndefined();
});
});

View File

@@ -7,12 +7,14 @@ export function runtimeMonsterArtworkPath(artworkPath: string): string | undefin
return RUNTIME_MONSTER_ARTWORK[artworkPath]; return RUNTIME_MONSTER_ARTWORK[artworkPath];
} }
const COMBAT_MONSTER_SPRITE: Readonly<Record<string, string>> = { // Background-free body art and medallion icons, shared by the combat stage
// and the hunt encounter cards.
const MONSTER_CUTOUT: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/sprites/ash-rat-760.png', 'ash-rat': '/images/combat/sprites/ash-rat-760.png',
'road-bandit': '/images/combat/sprites/road-bandit-620.png', 'road-bandit': '/images/combat/sprites/road-bandit-620.png',
}; };
const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = { const MONSTER_ICON: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/icons/ash-rat-128.png', 'ash-rat': '/images/combat/icons/ash-rat-128.png',
'road-bandit': '/images/combat/icons/road-bandit-128.png', 'road-bandit': '/images/combat/icons/road-bandit-128.png',
}; };
@@ -26,14 +28,14 @@ const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
const DEFAULT_MONSTER_SCALE = 0.6; const DEFAULT_MONSTER_SCALE = 0.6;
export function combatMonsterSpritePath(monsterKey: string): string | undefined { export function monsterCutoutPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_SPRITE[monsterKey]; return MONSTER_CUTOUT[monsterKey];
} }
export function combatMonsterSpriteScale(monsterKey: string): number { export function combatMonsterSpriteScale(monsterKey: string): number {
return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE; return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE;
} }
export function combatMonsterIconPath(monsterKey: string): string | undefined { export function monsterIconPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_ICON[monsterKey]; return MONSTER_ICON[monsterKey];
} }