Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type DamageType =
| 'miss';

export type BattleFloatingDamageHandle = {
spawn: (x: number, y: number, type: DamageType, value?: number) => void;
spawn: (x: number, y: number, type: DamageType, value?: number, hitCount?: number) => void;
};

type FloatingNumber = {
Expand Down Expand Up @@ -126,15 +126,17 @@ export const BattleFloatingDamage = forwardRef<BattleFloatingDamageHandle>(
const { ready } = usePretextFonts();

const spawn = useCallback(
(x: number, y: number, type: DamageType, value?: number) => {
(x: number, y: number, type: DamageType, value?: number, hitCount?: number) => {
const pool = poolRef.current;
const idx = nextIndexRef.current % POOL_SIZE;
nextIndexRef.current++;

const config = getConfig(type, value ?? 0);
// Add combo hit count suffix: "42 x7"
const text = hitCount && hitCount > 1 ? `${config.text} x${hitCount}` : config.text;
const n = pool[idx];
n.active = true;
n.text = config.text;
n.text = text;
n.x = x;
n.y = y;
n.vx = (Math.random() - 0.5) * 0.6;
Expand Down
125 changes: 29 additions & 96 deletions packages/client/src/components/pretext/game/BattleSceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
easeInQuad,
HIT_REACTION_IDLE,
IMPACT_DURATION,
REACTION_DURATION,
type HitReaction,
} from './impactEffects';
import { renderMonster, type AnimationState } from './MonsterAsciiRenderer';
Expand Down Expand Up @@ -174,6 +175,7 @@ type ActiveAttack = {
startTime: number;
duration: number;
damage: number;
hitCount: number;
isCrit: boolean;
isCombo: boolean;
isPlayerAttack: boolean;
Expand All @@ -182,7 +184,6 @@ type ActiveAttack = {
didHit: boolean;
targetDied: boolean;
impacted: boolean;
callout: AttackSignal['callout'];
};

type ActiveImpact = {
Expand All @@ -196,18 +197,14 @@ type SceneState = {
impacts: ActiveImpact[];
hitReaction: HitReaction;
hitReactionStart: number;
/** Hit reaction tier for current recoil */
hitReactionTier: 'hit' | 'stagger' | 'critical';
/** Monster animation state for the renderer */
monsterAnim: AnimationState | undefined;
/** Player hit flash timestamp (-1 = idle) */
playerHitStart: number;
/** Player attack anim timestamp (-1 = idle) */
playerAttackStart: number;
activeCallout: {
title: string;
detail: string;
tone: AttackSignal['callout']['tone'];
startTime: number;
} | null;
};

function createSceneState(): SceneState {
Expand All @@ -216,87 +213,14 @@ function createSceneState(): SceneState {
impacts: [],
hitReaction: HIT_REACTION_IDLE,
hitReactionStart: -1,
hitReactionTier: 'hit',
monsterAnim: undefined,
playerHitStart: -1,
playerAttackStart: -1,
activeCallout: null,
};
}

const CALLOUT_LIFETIME_MS = 2800;

function drawSceneCallout(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
callout: NonNullable<SceneState['activeCallout']>,
now: number,
) {
const elapsed = now - callout.startTime;
if (elapsed >= CALLOUT_LIFETIME_MS) return;

const fadeIn = Math.min(1, elapsed / 180);
const fadeOut = Math.min(1, (CALLOUT_LIFETIME_MS - elapsed) / 420);
const alpha = Math.min(fadeIn, fadeOut);
const slide = Math.max(0, 1 - fadeIn) * 12;

const tone = {
player: {
border: 'rgba(212,165,74,0.75)',
glow: 'rgba(212,165,74,0.18)',
title: COLORS.amber,
},
enemy: {
border: 'rgba(184,92,58,0.75)',
glow: 'rgba(184,92,58,0.18)',
title: COLORS.danger,
},
crit: {
border: 'rgba(232,220,200,0.85)',
glow: 'rgba(212,165,74,0.22)',
title: '#F3D27A',
},
miss: {
border: 'rgba(122,112,96,0.65)',
glow: 'rgba(122,112,96,0.18)',
title: COLORS.textMuted,
},
}[callout.tone];

const boxW = Math.min(w * 0.42, 320);
const boxH = 66;
const x = (w - boxW) / 2;
const y = h * 0.12 + slide;

ctx.save();
ctx.globalAlpha = alpha;

ctx.fillStyle = 'rgba(18,16,14,0.84)';
ctx.strokeStyle = tone.border;
ctx.lineWidth = 1;
ctx.shadowColor = tone.glow;
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.roundRect(x, y, boxW, boxH, 10);
ctx.fill();
ctx.shadowBlur = 0;
ctx.stroke();

ctx.font = fontString('mono', 10, 600);
ctx.fillStyle = '#8A7E6A';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('BATTLE FEED', x + boxW / 2, y + 8);

ctx.font = fontString('heading', 20, 700);
ctx.fillStyle = tone.title;
ctx.fillText(callout.title, x + boxW / 2, y + 24);

ctx.font = fontString('serif', 12, 500);
ctx.fillStyle = COLORS.textBody;
ctx.fillText(callout.detail, x + boxW / 2, y + 47);
ctx.restore();
}
// Callout box removed — damage displayed via BattleFloatingDamage overlay only

// ── HP bar rendering ────────────────────────────────────────────────────

Expand Down Expand Up @@ -396,6 +320,7 @@ export const BattleSceneCanvas = forwardRef<
startTime: performance.now(),
duration,
damage: signal.damage,
hitCount: signal.hitCount,
isCrit: signal.isCrit,
isCombo: signal.isCombo,
isPlayerAttack: signal.isPlayerAttack,
Expand All @@ -404,7 +329,6 @@ export const BattleSceneCanvas = forwardRef<
didHit: signal.didHit,
targetDied: signal.targetDied,
impacted: false,
callout: signal.callout,
});

// Start player attack animation immediately (swing during projectile flight)
Expand Down Expand Up @@ -493,8 +417,26 @@ export const BattleSceneCanvas = forwardRef<
state.hitReaction = HIT_REACTION_IDLE;
} else {
state.hitReactionStart = now;
// Tier: critical > stagger (combo) > hit
state.hitReactionTier = atk.isCrit
? 'critical'
: atk.hitCount > 1
? 'stagger'
: 'hit';
state.monsterAnim = { action: defenderClip, startTime: now };
}

// Spawn extra impact sparks for combos/crits
if (atk.didHit && !atk.dodged && (atk.hitCount > 1 || atk.isCrit)) {
const sparkCount = Math.min(atk.hitCount, 5);
for (let s = 0; s < sparkCount; s++) {
state.impacts.push({
startTime: now + s * 40,
x: w * 0.55 + (Math.random() - 0.5) * w * 0.06,
y: h * 0.45 + (Math.random() - 0.5) * h * 0.08,
});
}
}
} else {
// Counterattack hits player
if (atk.didHit) {
Expand All @@ -508,11 +450,6 @@ export const BattleSceneCanvas = forwardRef<
ps?.playClip?.(atk.targetDied ? 'death' : defenderClip);
}
}

state.activeCallout = {
...atk.callout,
startTime: now,
};
}

// Clean up finished attacks (allow 200ms after impact for overlap)
Expand All @@ -525,8 +462,9 @@ export const BattleSceneCanvas = forwardRef<

if (state.hitReactionStart > 0) {
const recoilElapsed = now - state.hitReactionStart;
state.hitReaction = computeHitReaction(recoilElapsed, w);
if (recoilElapsed > 500) {
state.hitReaction = computeHitReaction(recoilElapsed, w, state.hitReactionTier);
const tierDuration = REACTION_DURATION[state.hitReactionTier];
if (recoilElapsed > tierDuration) {
state.hitReactionStart = -1;
state.hitReaction = HIT_REACTION_IDLE;
if (state.monsterAnim?.action === 'hit') {
Expand Down Expand Up @@ -720,12 +658,7 @@ export const BattleSceneCanvas = forwardRef<
p.monsterDefeated,
);

if (state.activeCallout) {
drawSceneCallout(ctx, w, h, state.activeCallout, now);
if (now - state.activeCallout.startTime >= CALLOUT_LIFETIME_MS) {
state.activeCallout = null;
}
}
// Callout box removed — damage shown via BattleFloatingDamage overlay
},
[template, playerTemplate],
);
Expand Down
70 changes: 70 additions & 0 deletions packages/client/src/components/pretext/game/impactEffects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';

import {
computeHitReaction,
HIT_REACTION_IDLE,
REACTION_DURATION,
easeOutCubic,
easeInQuad,
} from './impactEffects';

describe('impactEffects', () => {
describe('computeHitReaction', () => {
it('returns IDLE when elapsed exceeds tier duration', () => {
expect(computeHitReaction(301, 400, 'hit')).toEqual(HIT_REACTION_IDLE);
expect(computeHitReaction(401, 400, 'stagger')).toEqual(HIT_REACTION_IDLE);
expect(computeHitReaction(501, 400, 'critical')).toEqual(HIT_REACTION_IDLE);
});

it('returns non-zero recoil at impact start', () => {
const hit = computeHitReaction(0, 400, 'hit');
expect(hit.offsetX).toBeGreaterThan(0);
expect(hit.flash).toBeGreaterThan(0);
});

it('critical tier has stronger recoil than hit tier', () => {
const hit = computeHitReaction(0, 400, 'hit');
const critical = computeHitReaction(0, 400, 'critical');
expect(critical.offsetX).toBeGreaterThan(hit.offsetX);
});

it('stagger tier is between hit and critical', () => {
const hit = computeHitReaction(0, 400, 'hit');
const stagger = computeHitReaction(0, 400, 'stagger');
const critical = computeHitReaction(0, 400, 'critical');
expect(stagger.offsetX).toBeGreaterThan(hit.offsetX);
expect(stagger.offsetX).toBeLessThan(critical.offsetX);
});

it('recoil decreases over time', () => {
const early = computeHitReaction(50, 400, 'hit');
const late = computeHitReaction(200, 400, 'hit');
expect(late.offsetX).toBeLessThan(early.offsetX);
});

it('defaults to hit tier when no tier specified', () => {
const defaultTier = computeHitReaction(0, 400);
const hitTier = computeHitReaction(0, 400, 'hit');
expect(defaultTier).toEqual(hitTier);
});
});

describe('REACTION_DURATION', () => {
it('hit is shortest, critical is longest', () => {
expect(REACTION_DURATION.hit).toBeLessThan(REACTION_DURATION.stagger);
expect(REACTION_DURATION.stagger).toBeLessThan(REACTION_DURATION.critical);
});
});

describe('easing functions', () => {
it('easeOutCubic starts at 0 and ends at 1', () => {
expect(easeOutCubic(0)).toBe(0);
expect(easeOutCubic(1)).toBe(1);
});

it('easeInQuad starts at 0 and ends at 1', () => {
expect(easeInQuad(0)).toBe(0);
expect(easeInQuad(1)).toBe(1);
});
});
});
36 changes: 28 additions & 8 deletions packages/client/src/components/pretext/game/impactEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,44 @@ export type HitReaction = {

export const HIT_REACTION_IDLE: HitReaction = { offsetX: 0, flash: 0, shake: 0 };

/** Duration of the recoil recovery animation (ms) */
const RECOIL_DURATION = 500;
/** Reaction tier → duration (ms) */
export const REACTION_DURATION: Record<HitReactionTier, number> = {
hit: 300,
stagger: 400,
critical: 500,
};

export type HitReactionTier = 'hit' | 'stagger' | 'critical';

/** Tier → recoil/flash/shake multipliers */
const TIER_SCALE: Record<HitReactionTier, { recoil: number; flash: number; shake: number; flashColor: string }> = {
hit: { recoil: 0.03, flash: 0.15, shake: 0.005, flashColor: '#fff' },
stagger: { recoil: 0.05, flash: 0.25, shake: 0.010, flashColor: '#fff' },
critical: { recoil: 0.06, flash: 0.30, shake: 0.015, flashColor: '#f44' },
};

/**
* Compute hit reaction state from elapsed time since impact.
* Returns IDLE when recovery is complete.
*
* @param elapsed ms since impact moment
* @param w viewport width (for proportional recoil distance)
* @param tier reaction intensity tier
*/
export function computeHitReaction(elapsed: number, w: number): HitReaction {
if (elapsed >= RECOIL_DURATION) return HIT_REACTION_IDLE;
export function computeHitReaction(
elapsed: number,
w: number,
tier: HitReactionTier = 'hit',
): HitReaction {
const duration = REACTION_DURATION[tier];
if (elapsed >= duration) return HIT_REACTION_IDLE;

const p = elapsed / RECOIL_DURATION;
const scale = TIER_SCALE[tier];
const p = elapsed / duration;
return {
offsetX: w * 0.03 * (1 - easeOutCubic(p)),
flash: Math.max(0, 1 - p * 3),
shake: (1 - p) * 0.5,
offsetX: w * scale.recoil * (1 - easeOutCubic(p)),
flash: Math.max(0, scale.flash * (1 - p * 3)),
shake: (1 - p) * scale.shake * w,
};
}

Expand Down
Loading
Loading