diff --git a/apps/web/public/images/hud/runtime/enemy-card-560.webp b/apps/web/public/images/hud/runtime/enemy-card-560.webp new file mode 100644 index 0000000..0724d10 Binary files /dev/null and b/apps/web/public/images/hud/runtime/enemy-card-560.webp differ diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 8eea821..e548d07 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -2,9 +2,9 @@ import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import type { Combat, CombatEvent } from '../../../core/api/game-api.models'; import { - combatMonsterIconPath, - combatMonsterSpritePath, combatMonsterSpriteScale, + monsterCutoutPath, + monsterIconPath, runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; import { CombatStore } from '../combat.store'; @@ -125,7 +125,7 @@ export class CombatPageComponent implements OnInit { } protected monsterSprite(monsterKey: string, artworkPath: string): string { - return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; + return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } protected monsterSpriteScale(monsterKey: string): number { @@ -133,7 +133,7 @@ export class CombatPageComponent implements OnInit { } protected monsterIcon(monsterKey: string, artworkPath: string): string { - return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; + return monsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } protected playerHpPercent(): number { diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html index ee418b4..63ab368 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html @@ -1,22 +1,26 @@
-
- - @if (runtimeArtworkPath(encounter.monster.artworkPath); as runtimeArtwork) { - - } - - +
+ @if (iconPath(); as icon) { + + }
-
-

{{ encounter.monster.name }}

+ +
+ +
+ +

{{ encounter.monster.name }}

+ +
Stufe {{ encounter.monster.level }} -
+ +
diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss index b20de9e..c2542f3 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss @@ -2,106 +2,156 @@ 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 { - 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); + position: relative; + container-type: inline-size; + max-inline-size: 20rem; + margin-inline: auto; + aspect-ratio: 560 / 957; + background-image: url('/images/hud/runtime/enemy-card-560.webp'); + 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 { - 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; - inset: 0; - box-shadow: inset 0 -3.5rem 3rem -1.5rem rgb(0 0 0 / 0.75); - pointer-events: none; + inset-block: 12.6% 40.4%; + inset-inline: 15% 14.6%; + display: grid; + padding: 4% 6% 2%; + place-items: end center; } .encounter-card__artwork { inline-size: 100%; block-size: 100%; - object-fit: cover; - object-position: center; + object-fit: contain; + object-position: bottom; + filter: drop-shadow(0 0.35rem 0.7rem rgb(0 0 0 / 0.75)); } -.encounter-card__body { - display: grid; - justify-items: center; - gap: var(--ar-space-2); - padding: var(--ar-space-4); - text-align: center; -} +/* ---------- text bands ---------- */ .encounter-card__name { + position: absolute; + inset-block-start: 62.8%; + inset-inline: 15% 14.6%; + display: grid; + block-size: 5.4%; margin: 0; + overflow: hidden; + place-items: center; color: var(--ar-text); font-family: Georgia, 'Times New Roman', serif; - font-size: 1.4rem; + font-size: clamp(0.85rem, 5.4cqw, 1.35rem); 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 { color: var(--ar-gold); - font-size: var(--ar-font-sm); - letter-spacing: 0.06em; + font-size: clamp(0.6rem, 3.5cqw, 0.85rem); + letter-spacing: 0.08em; + line-height: 1; 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 { - margin-block: var(--ar-space-1); + --ar-font-sm: clamp(0.6rem, 3.4cqw, 0.8125rem); } +/* ---------- attack plaque ---------- */ + .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); + position: absolute; + inset-block-start: 81.1%; + inset-inline-start: 11.8%; + inline-size: 77.1%; + block-size: 10.4%; + padding: 0; + border: 0; + border-radius: 0.2rem; color: var(--ar-text); - background: linear-gradient(180deg, #263b4b, #17232d); + background: transparent; font-family: Georgia, 'Times New Roman', serif; - font-size: 1rem; - letter-spacing: 0.02em; + font-size: clamp(0.8rem, 4.6cqw, 1.15rem); + letter-spacing: 0.08em; + text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85); } .encounter-card__attack:hover { - border-color: #d6b26b; - background: linear-gradient(180deg, #315067, #1a2c3a); + color: #f4ecda; + 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 { outline: 2px solid var(--ar-blue); - outline-offset: 2px; + outline-offset: -3px; } @media (prefers-reduced-motion: no-preference) { .encounter-card { - transition: - border-color var(--ar-motion-base), - box-shadow var(--ar-motion-base); + transition: filter 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); + filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3)); } .encounter-card__attack { transition: - border-color var(--ar-motion-fast), - background var(--ar-motion-fast); + color var(--ar-motion-fast), + box-shadow var(--ar-motion-fast); } } diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts index e5459c7..b070354 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts @@ -14,6 +14,24 @@ const dawnwolfEncounter: HuntEncounter = { 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', () => { beforeEach(async () => { await TestBed.configureTestingModule({ @@ -22,18 +40,34 @@ describe('EncounterCardComponent', () => { }); 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'); + const element = render(dawnwolfEncounter); + const artwork = element.querySelector('.encounter-card__artwork'); 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'); + expect(artwork?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png'); + 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', () => { diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts index 0ac0e6e..4a51eee 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; 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'; @Component({ @@ -17,7 +17,15 @@ export class EncounterCardComponent { this.attack.emit(this.encounter.id); } - protected runtimeArtworkPath(artworkPath: string): string | undefined { - return runtimeMonsterArtworkPath(artworkPath); + // The frame paints its own artwork panel, so the monster has to be a + // 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); } } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index f39f1a0..b8c5963 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -179,8 +179,16 @@ describe('HuntPageComponent', () => { const cards = element.querySelectorAll('app-encounter-card'); expect(cards.length).toBe(3); 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(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1); + expect( + 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 3'); }); diff --git a/apps/web/src/app/shared/monster-artwork.spec.ts b/apps/web/src/app/shared/monster-artwork.spec.ts index eb90bbb..8e30452 100644 --- a/apps/web/src/app/shared/monster-artwork.spec.ts +++ b/apps/web/src/app/shared/monster-artwork.spec.ts @@ -1,4 +1,4 @@ -import { runtimeMonsterArtworkPath } from './monster-artwork'; +import { monsterCutoutPath, monsterIconPath, runtimeMonsterArtworkPath } from './monster-artwork'; describe('runtimeMonsterArtworkPath', () => { 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(); }); }); + +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(); + }); +}); diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts index 45d3ce8..770d824 100644 --- a/apps/web/src/app/shared/monster-artwork.ts +++ b/apps/web/src/app/shared/monster-artwork.ts @@ -7,12 +7,14 @@ export function runtimeMonsterArtworkPath(artworkPath: string): string | undefin return RUNTIME_MONSTER_ARTWORK[artworkPath]; } -const COMBAT_MONSTER_SPRITE: Readonly> = { +// Background-free body art and medallion icons, shared by the combat stage +// and the hunt encounter cards. +const MONSTER_CUTOUT: Readonly> = { 'ash-rat': '/images/combat/sprites/ash-rat-760.png', 'road-bandit': '/images/combat/sprites/road-bandit-620.png', }; -const COMBAT_MONSTER_ICON: Readonly> = { +const MONSTER_ICON: Readonly> = { 'ash-rat': '/images/combat/icons/ash-rat-128.png', 'road-bandit': '/images/combat/icons/road-bandit-128.png', }; @@ -26,14 +28,14 @@ const COMBAT_MONSTER_SCALE: Readonly> = { const DEFAULT_MONSTER_SCALE = 0.6; -export function combatMonsterSpritePath(monsterKey: string): string | undefined { - return COMBAT_MONSTER_SPRITE[monsterKey]; +export function monsterCutoutPath(monsterKey: string): string | undefined { + return MONSTER_CUTOUT[monsterKey]; } export function combatMonsterSpriteScale(monsterKey: string): number { return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE; } -export function combatMonsterIconPath(monsterKey: string): string | undefined { - return COMBAT_MONSTER_ICON[monsterKey]; +export function monsterIconPath(monsterKey: string): string | undefined { + return MONSTER_ICON[monsterKey]; }