effekte
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<section class="combat" aria-label="Kampf">
|
<section class="combat" aria-label="Kampf">
|
||||||
@if (combat(); as combat) {
|
@if (combat(); as combat) {
|
||||||
<div class="combat__stage">
|
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
|
||||||
<header class="combat__status">
|
<header class="combat__status">
|
||||||
<div class="fighter fighter--player">
|
<div class="fighter fighter--player">
|
||||||
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
></div>
|
></div>
|
||||||
<img
|
<img
|
||||||
class="sprite sprite--monster"
|
class="sprite sprite--monster"
|
||||||
|
[class.sprite--flinch]="monsterPhase() === 'flinch'"
|
||||||
|
[class.sprite--lunge]="monsterPhase() === 'lunge'"
|
||||||
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
|
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
|
||||||
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
|
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
|
||||||
[alt]="combat.monster.name"
|
[alt]="combat.monster.name"
|
||||||
|
|||||||
@@ -208,7 +208,7 @@
|
|||||||
background-size: 600% 100%;
|
background-size: 600% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sprite--hit {
|
.sprite--player.sprite--hit {
|
||||||
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
|
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,126 @@
|
|||||||
.sprite--monster {
|
.sprite--monster {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
block-size: calc(var(--sprite-scale, 0.6) * 100%);
|
block-size: calc(var(--sprite-scale, 0.6) * 100%);
|
||||||
|
// Every beat below scales and rotates the cut-out. Pivoting at the feet keeps
|
||||||
|
// it standing on the same spot instead of sinking behind the action bar.
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The monster is a single cut-out, so every beat below is transform-only. All
|
||||||
|
of them animate translate/rotate/scale rather than `transform`, and any step
|
||||||
|
that touches `filter` has to repeat the base drop-shadow from `.sprite`,
|
||||||
|
because a keyframe filter replaces the whole list. */
|
||||||
|
|
||||||
|
/* Getting hit: a short recoil to the right, away from the player, with a red
|
||||||
|
glow pulse. 240ms so it finishes inside the 260ms riposte pause. */
|
||||||
|
@keyframes monster-flinch {
|
||||||
|
0% {
|
||||||
|
translate: 0 0;
|
||||||
|
rotate: 0deg;
|
||||||
|
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
translate: 0.45rem -0.12rem;
|
||||||
|
rotate: 1.1deg;
|
||||||
|
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7))
|
||||||
|
drop-shadow(0 0 0.65rem rgb(199 75 66 / 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
45% {
|
||||||
|
translate: -0.28rem 0;
|
||||||
|
rotate: -1deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
translate: 0.18rem 0;
|
||||||
|
rotate: 0.5deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
translate: 0 0;
|
||||||
|
rotate: 0deg;
|
||||||
|
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Striking back: a brief wind-up away from the player, then a lunge left. The
|
||||||
|
impact sits at 28% of 540ms so it reads against the warrior hit sheet. */
|
||||||
|
@keyframes monster-lunge {
|
||||||
|
0% {
|
||||||
|
translate: 0 0;
|
||||||
|
rotate: 0deg;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
12% {
|
||||||
|
translate: 0.55rem 0;
|
||||||
|
rotate: 1.4deg;
|
||||||
|
scale: 0.985;
|
||||||
|
}
|
||||||
|
|
||||||
|
28% {
|
||||||
|
translate: -2rem -0.45rem;
|
||||||
|
rotate: -2.4deg;
|
||||||
|
scale: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
55% {
|
||||||
|
translate: -0.8rem -0.1rem;
|
||||||
|
rotate: -0.8deg;
|
||||||
|
scale: 1.015;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
translate: 0 0;
|
||||||
|
rotate: 0deg;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle: deliberately tiny, just enough that the cut-out does not read as card-
|
||||||
|
board. The spec rules out permanent *strong* animation, not a slow breath. */
|
||||||
|
@keyframes monster-breathe {
|
||||||
|
from {
|
||||||
|
translate: 0 0;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
translate: 0 -0.4%;
|
||||||
|
scale: 1.004;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage jolt when the player takes the blow. The slight scale gives the offset
|
||||||
|
somewhere to go: the stage clips its children, but its own border moves too,
|
||||||
|
so without it the shift would open a sliver at the frame. */
|
||||||
|
@keyframes stage-shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
translate: 0 0;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
15% {
|
||||||
|
translate: -0.35% 0.08rem;
|
||||||
|
scale: 1.012;
|
||||||
|
}
|
||||||
|
|
||||||
|
35% {
|
||||||
|
translate: 0.3% -0.06rem;
|
||||||
|
scale: 1.012;
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
translate: -0.18% 0.04rem;
|
||||||
|
scale: 1.009;
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
translate: 0.1% 0;
|
||||||
|
scale: 1.004;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- action bar ---------- */
|
/* ---------- action bar ---------- */
|
||||||
@@ -439,11 +559,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.sprite--attacking,
|
.sprite--player.sprite--attacking,
|
||||||
.sprite--hit {
|
.sprite--player.sprite--hit {
|
||||||
animation: warrior-frames 540ms steps(6) 1;
|
animation: warrior-frames 540ms steps(6) 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Order matters: the state classes below share translate/rotate/scale with
|
||||||
|
the breathing loop, so their `animation` shorthand has to come last. */
|
||||||
|
.sprite--monster {
|
||||||
|
animation: monster-breathe 5.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite--flinch {
|
||||||
|
animation: monster-flinch 240ms ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite--lunge {
|
||||||
|
animation: monster-lunge 540ms cubic-bezier(0.22, 0.9, 0.3, 1) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat__stage--shaken {
|
||||||
|
animation: stage-shake 200ms ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
.bar__fill {
|
.bar__fill {
|
||||||
transition: inline-size var(--ar-motion-base);
|
transition: inline-size var(--ar-motion-base);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,17 +134,23 @@ describe('CombatPageComponent', () => {
|
|||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
const sprite = element.querySelector('.sprite--player');
|
const sprite = element.querySelector('.sprite--player');
|
||||||
|
const monster = element.querySelector('.sprite--monster');
|
||||||
|
const stage = element.querySelector('.combat__stage');
|
||||||
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
|
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
|
||||||
|
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
|
||||||
expect(element.textContent).toContain('31 / 45');
|
expect(element.textContent).toContain('31 / 45');
|
||||||
expect(element.textContent).toContain('95 / 100');
|
expect(element.textContent).toContain('95 / 100');
|
||||||
|
|
||||||
// Swing lands: the monster loses HP, the player's own loss is held back.
|
// Swing lands: the monster loses HP and flinches, its own loss is held back.
|
||||||
await vi.advanceTimersByTimeAsync(540);
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
|
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
|
||||||
|
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
|
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
||||||
expect(element.textContent).toContain('17 / 45');
|
expect(element.textContent).toContain('17 / 45');
|
||||||
expect(element.textContent).toContain('95 / 100');
|
expect(element.textContent).toContain('95 / 100');
|
||||||
// Only round 1's identical line is logged so far, not round 2's.
|
// Only round 1's identical line is logged so far, not round 2's.
|
||||||
@@ -154,12 +160,17 @@ describe('CombatPageComponent', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(260);
|
await vi.advanceTimersByTimeAsync(260);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
|
||||||
|
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
|
||||||
|
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
|
||||||
expect(element.textContent).toContain('90 / 100');
|
expect(element.textContent).toContain('90 / 100');
|
||||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(540);
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
|
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
|
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips the recoil when the round ends without the monster striking back', async () => {
|
it('skips the recoil when the round ends without the monster striking back', async () => {
|
||||||
@@ -184,9 +195,17 @@ describe('CombatPageComponent', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(540);
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const monster = element.querySelector('.sprite--monster');
|
||||||
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
|
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
|
||||||
|
// The killing blow still registers on the monster, it just never lunges back.
|
||||||
|
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
||||||
expect(element.textContent).toContain('0 / 45');
|
expect(element.textContent).toContain('0 / 45');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables Angriff while an action is pending', async () => {
|
it('disables Angriff while an action is pending', async () => {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ interface CombatLogRound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CombatPhase = 'idle' | 'attacking' | 'hit';
|
type CombatPhase = 'idle' | 'attacking' | 'hit';
|
||||||
|
// The monster is a single cut-out with no sheets, so its beats are pure
|
||||||
|
// CSS transforms and run offset from the player's: it flinches when the
|
||||||
|
// player's blow lands and lunges while the player is recoiling.
|
||||||
|
type MonsterPhase = 'idle' | 'flinch' | 'lunge';
|
||||||
|
|
||||||
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
|
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ export class CombatPageComponent implements OnInit {
|
|||||||
|
|
||||||
protected readonly combat = this.displayed.asReadonly();
|
protected readonly combat = this.displayed.asReadonly();
|
||||||
protected readonly phase = signal<CombatPhase>('idle');
|
protected readonly phase = signal<CombatPhase>('idle');
|
||||||
|
protected readonly monsterPhase = signal<MonsterPhase>('idle');
|
||||||
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
|
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
|
||||||
protected readonly playerIcon = PLAYER_ICON;
|
protected readonly playerIcon = PLAYER_ICON;
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ export class CombatPageComponent implements OnInit {
|
|||||||
this.replaying.set(true);
|
this.replaying.set(true);
|
||||||
try {
|
try {
|
||||||
this.phase.set('attacking');
|
this.phase.set('attacking');
|
||||||
|
this.monsterPhase.set('idle');
|
||||||
const swing = this.wait(SWING_MS);
|
const swing = this.wait(SWING_MS);
|
||||||
await this.combatStore.attack();
|
await this.combatStore.attack();
|
||||||
await swing;
|
await swing;
|
||||||
@@ -74,6 +80,7 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.phase.set('idle');
|
this.phase.set('idle');
|
||||||
|
this.monsterPhase.set('flinch');
|
||||||
|
|
||||||
const after = this.combatStore.combat();
|
const after = this.combatStore.combat();
|
||||||
if (!after) {
|
if (!after) {
|
||||||
@@ -103,12 +110,14 @@ export class CombatPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.phase.set('hit');
|
this.phase.set('hit');
|
||||||
|
this.monsterPhase.set('lunge');
|
||||||
this.displayed.set(after);
|
this.displayed.set(after);
|
||||||
await this.wait(RECOIL_MS);
|
await this.wait(RECOIL_MS);
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.phase.set('idle');
|
this.phase.set('idle');
|
||||||
|
this.monsterPhase.set('idle');
|
||||||
} finally {
|
} finally {
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
this.replaying.set(false);
|
this.replaying.set(false);
|
||||||
@@ -192,6 +201,8 @@ export class CombatPageComponent implements OnInit {
|
|||||||
|
|
||||||
await this.combatStore.loadCombat(combatId);
|
await this.combatStore.loadCombat(combatId);
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
|
this.phase.set('idle');
|
||||||
|
this.monsterPhase.set('idle');
|
||||||
this.displayed.set(this.combatStore.combat());
|
this.displayed.set(this.combatStore.combat());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user