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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ docs/GO_TO_MARKET.md
docs/PLAYER_ACQUISITION_RESEARCH.md
docs/GOLD_SUPPLY_PLAN.md
docs/RETENTION_ANALYSIS_*.md
tools/creature-lab/.env
1 change: 1 addition & 0 deletions packages/client/public/models/items/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
21 changes: 21 additions & 0 deletions packages/client/src/components/TileDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { BattleDeathScreen } from './pretext/game/BattleDeathScreen';
import { TextDissolve } from './pretext/game/TextDissolve';
import { ThreatWeightedName } from './pretext/game/ThreatWeightedName';
import { classifyWeapon } from './pretext/game/weaponAnimations';
import { loadItemManifest, loadItemModel, itemSlug } from './pretext/game/glbItemLoader';
import { useBattleSceneSignals, type BattleSceneHandle } from '../hooks/useBattleSceneSignals';
import {
ADVANCED_CLASS_COLORS,
Expand Down Expand Up @@ -293,14 +294,34 @@ export const TileDetailsPanel = (): JSX.Element => {
return 'melee' as const;
}, [equippedSpells, equippedWeapons]);

const weaponNameForItem = useCallback((itemId: string) => {
const spell = equippedSpells.find(s => s.tokenId === itemId || s.itemId === itemId);
if (spell) return spell.name;
const weapon = equippedWeapons.find(w => w.tokenId === itemId || w.itemId === itemId);
if (weapon) return weapon.name;
return undefined;
}, [equippedSpells, equippedWeapons]);

useBattleSceneSignals({
visibleOutcomes,
characterId: character?.id,
sceneRef: battleSceneRef,
weaponTypeForItem,
weaponNameForItem,
opponentName: opponent?.name ?? 'the enemy',
});

// Preload 3D item models for equipped weapons so they're ready before first attack
useEffect(() => {
const slugs = [...equippedWeapons, ...equippedSpells]
.map((w) => itemSlug(w.name))
.filter(Boolean);
if (slugs.length === 0) return;
loadItemManifest().then(() => {
slugs.forEach((s) => loadItemModel(s).catch(() => {}));
}).catch(() => {});
}, [equippedWeapons, equippedSpells]);

const encounterTx = useTransaction({
actionName: 'initiate battle',
});
Expand Down
162 changes: 34 additions & 128 deletions packages/client/src/components/pretext/game/BattleSceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,15 @@ export type BattleSceneProps = {

type ActiveAttack = {
weaponType: WeaponAnimType;
weaponName?: string;
startTime: number;
duration: number;
damage: number;
isCrit: boolean;
isCombo: boolean;
isPlayerAttack: boolean;
blocked: boolean;
dodged: boolean;
didHit: boolean;
targetDied: boolean;
impacted: boolean;
Expand Down Expand Up @@ -389,12 +392,15 @@ export const BattleSceneCanvas = forwardRef<

state.attacks.push({
weaponType: signal.weaponType,
weaponName: signal.weaponName,
startTime: performance.now(),
duration,
damage: signal.damage,
isCrit: signal.isCrit,
isCombo: signal.isCombo,
isPlayerAttack: signal.isPlayerAttack,
blocked: signal.blocked,
dodged: signal.dodged,
didHit: signal.didHit,
targetDied: signal.targetDied,
impacted: false,
Expand Down Expand Up @@ -436,6 +442,7 @@ export const BattleSceneCanvas = forwardRef<

let activeProjectile: {
weaponType: WeaponAnimType;
weaponName?: string;
progress: number;
isPlayerAttack: boolean;
} | null = null;
Expand All @@ -450,6 +457,7 @@ export const BattleSceneCanvas = forwardRef<
if (!activeProjectile) {
activeProjectile = {
weaponType: atk.weaponType,
weaponName: atk.weaponName,
progress,
isPlayerAttack: atk.isPlayerAttack,
};
Expand All @@ -458,35 +466,53 @@ export const BattleSceneCanvas = forwardRef<
// Impact moment
atk.impacted = true;

// Pick defender clip: dodge > block > hit/death
const defenderClip = atk.dodged
? 'dodge'
: atk.blocked
? 'block'
: atk.targetDied
? 'death'
: 'hit';

if (atk.isPlayerAttack) {
state.playerAttackStart = now;
if (atk.didHit) {

// Spawn impact effect (skip on dodge — no contact)
if (atk.didHit && !atk.dodged) {
state.impacts.push({
startTime: now,
x: w * 0.55,
y: h * 0.45,
});
}

if (atk.targetDied) {
state.monsterAnim = { action: 'death', startTime: now };
state.hitReactionStart = -1;
state.hitReaction = HIT_REACTION_IDLE;
} else {
state.hitReactionStart = now;
state.monsterAnim = { action: 'hit', startTime: now };
state.monsterAnim = { action: defenderClip, startTime: now };
}
}
} else {
// Counterattack hits player
if (atk.didHit) {
state.playerHitStart = now;
}

const playerUrl = p.userRace ? RACE_GLB_URL[p.userRace] : null;
if (playerUrl) {
const ps = getCreatureState(playerUrl);
ps?.playClip?.(atk.targetDied ? 'death' : 'hit');
}
// Trigger defender animation on player GLB
const playerUrl = p.userRace ? RACE_GLB_URL[p.userRace] : null;
if (playerUrl) {
const ps = getCreatureState(playerUrl);
ps?.playClip?.(atk.targetDied ? 'death' : defenderClip);
}
}

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

// Clean up finished attacks (allow 200ms after impact for overlap)
Expand All @@ -509,126 +535,6 @@ export const BattleSceneCanvas = forwardRef<
}
}

// ── Clean old impacts ───────────────────────────────────────────

for (let i = state.impacts.length - 1; i >= 0; i--) {
if (now - state.impacts[i].startTime > IMPACT_DURATION) {
state.impacts.splice(i, 1);
}
}

// ── Render monster (right 60%) ──────────────────────────────────

const { offsetX, flash, shake } = state.hitReaction;
const shakeX = shake * (Math.random() - 0.5) * w * 0.01;
const shakeY = shake * (Math.random() - 0.5) * h * 0.01;

ctx.save();
ctx.translate(offsetX + shakeX, shakeY);

const monsterX = w * 0.3;
const monsterW = w * 0.7;
const monsterY = 0;
const monsterH = h;

if (template) {
renderMonster(ctx, template, monsterX, monsterY, monsterW, monsterH, {
elapsed: p.monsterDefeated ? 0 : elapsed,
cellSize: 5,
enable3D: true,
enableGlow: !p.monsterDefeated,
enableBgFill: true,
animation: state.monsterAnim,
});
}

// White flash overlay on hit
if (flash > 0) {
ctx.save();
ctx.globalAlpha = flash * 0.3;
ctx.fillStyle = '#fff';
ctx.fillRect(monsterX, monsterY, monsterW, monsterH);
ctx.restore();
}

// Defeated overlay
if (p.monsterDefeated) {
ctx.save();
ctx.globalAlpha = 0.5;
ctx.fillStyle = '#000';
ctx.fillRect(monsterX, monsterY, monsterW, monsterH);
ctx.restore();
}

ctx.restore();

// ── Render weapon projectile ────────────────────────────────────

if (activeProjectile) {
const { weaponType, progress, isPlayerAttack } = activeProjectile;
const t = easeInQuad(progress);

let startX: number, endX: number, startY: number, endY: number;
if (isPlayerAttack) {
// Player attack: fly left → right toward monster
startX = w * 0.05;
endX = w * 0.55;
startY = h * 0.45;
endY = h * 0.5;
} else {
// Counterattack: fly right → left toward player
startX = w * 0.6;
endX = w * 0.15;
startY = h * 0.45;
endY = h * 0.5;
}

const projX = startX + (endX - startX) * t;
const projY = startY + (endY - startY) * t;
drawWeapon(ctx, weaponType, projX, projY, w, h, progress);
}

// ── Render impact effects ───────────────────────────────────────

for (const impact of state.impacts) {
const impProgress = (now - impact.startTime) / IMPACT_DURATION;
drawImpact(
ctx,
impact.x + state.hitReaction.offsetX,
impact.y,
w,
impProgress,
);
}

// ── Render player character (left 35%) ──────────────────────────

const playerTpl = playerTemplate;
if (playerTpl) {
const playerX = 0;
const playerW = w * 0.35;
const playerY = 0;
const playerH = h;

// Player hit flash
let playerFlash = 0;
if (state.playerHitStart > 0) {
const hitElapsed = now - state.playerHitStart;
if (hitElapsed < 400) {
playerFlash = Math.max(0, 1 - hitElapsed / 400);
} else {
state.playerHitStart = -1;
}
}

// Reset player attack timestamp
if (
state.playerAttackStart > 0 &&
now - state.playerAttackStart > 800
) {
state.playerAttackStart = -1;
}

ctx.save();

// Subtle shake when player is hit
Expand Down
Loading
Loading