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
81 changes: 34 additions & 47 deletions packages/client/src/components/pretext/game/glbItemLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Flow:
* 1. loadItemManifest() — fetch manifest.json once
* 2. loadItemModel(slug) — load GLB, cache scene + camera
* 3. drawItemProjectile() — render item at position with rotation
* 3. getItemDrawFn() — MonsterTemplate-compatible draw fn for ASCII rendering
*/

import { getSharedRenderer } from './glbCreatureLoader';
Expand Down Expand Up @@ -194,7 +194,7 @@ export async function loadItemModel(slug: string): Promise<void> {
const THREE = await import('three');
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');

// Cache shared renderer reference for sync drawItemProjectile calls
// Cache shared renderer reference for sync draw calls
if (!_renderer) {
_renderer = await getSharedRenderer();
_rendererTHREE = THREE;
Expand Down Expand Up @@ -242,63 +242,50 @@ export async function loadItemModel(slug: string): Promise<void> {
itemCache.set(slug, { loaded: true, scene, camera, model });
}

// ---- Render function for projectile drawing -------------------------------
// ---- ASCII-compatible draw function for MonsterTemplate integration --------

/**
* Draw an item model as a projectile at (x, y) on the battle canvas.
* Create a MonsterTemplate-compatible draw function for an item GLB.
* Returns a (ctx, w, h) => void function that renders the item's 3D model
* onto a black canvas at (w x h) — the ASCII renderer then converts it.
*
* @param ctx Battle scene canvas context
* @param slug Item slug (e.g. 'iron-axe')
* @param x Center X position on battle canvas
* @param y Center Y position on battle canvas
* @param size Display size in pixels (square)
* @param rotation Z rotation in radians (for spin effect)
* @returns true if 3D model was drawn, false if not loaded (caller should use 2D fallback)
* The rotation is updated externally via setItemRotation() before each frame.
*/
export function drawItemProjectile(
ctx: CanvasRenderingContext2D,
export function getItemDrawFn(
slug: string,
x: number,
y: number,
size: number,
rotation: number,
): boolean {
): ((ctx: CanvasRenderingContext2D, w: number, h: number) => void) | null {
const state = itemCache.get(slug);
if (!state?.loaded || !state.scene || !state.camera || !state.model) {
// Kick off load if not started
if (!itemCache.has(slug)) loadItemModel(slug).catch(() => {});
return false;
if (!state?.loaded || !state.scene || !state.camera || !state.model || !_renderer) {
return null;
}

// Rotate the model for spin effect
state.model.rotation.z = rotation;
state.model.rotation.y = rotation * 0.5; // slight Y rotation for depth

// Render synchronously if renderer is ready (it will be if creatures loaded first)
if (!_renderer) return false;
return (ctx: CanvasRenderingContext2D, w: number, h: number) => {
const renderer = _renderer!;
const THREE = _rendererTHREE!;
const prevSize = renderer.getSize(new THREE.Vector2());

const THREE = _rendererTHREE!;
const prevSize = _renderer.getSize(new THREE.Vector2());
// Render at requested dimensions with black background (ASCII pipeline expects black bg)
renderer.setSize(w, h);
renderer.setClearColor(0x000000, 1);
renderer.render(state.scene!, state.camera!);

// Render at small size with transparent background
_renderer.setSize(ITEM_RENDER_SIZE, ITEM_RENDER_SIZE);
_renderer.setClearColor(0x000000, 0);
_renderer.render(state.scene, state.camera);
// Stamp 3D render onto the template canvas
ctx.drawImage(renderer.domElement, 0, 0, w, h);

// Stamp onto battle canvas
const half = size / 2;
ctx.drawImage(
_renderer.domElement,
x - half,
y - half,
size,
size,
);

// Restore renderer size
_renderer.setSize(prevSize.x, prevSize.y);
// Restore renderer size
renderer.setSize(prevSize.x, prevSize.y);
};
}

return true;
/**
* Set the rotation of a cached item model. Call before rendering each frame
* so the draw function picks up the current rotation.
*/
export function setItemRotation(slug: string, z: number, y: number): void {
const state = itemCache.get(slug);
if (!state?.model) return;
state.model.rotation.z = z;
state.model.rotation.y = y;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock glbItemLoader — we test its integration with drawWeapon
vi.mock('./glbItemLoader', () => ({
isItemModelReady: vi.fn().mockReturnValue(false),
drawItemProjectile: vi.fn().mockReturnValue(false),
getItemDrawFn: vi.fn().mockReturnValue(null),
setItemRotation: vi.fn(),
itemSlug: vi.fn((name: string) =>
name.toLowerCase().replace(/['']/g, '').replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
),
loadItemModel: vi.fn().mockResolvedValue(undefined),
}));

// Mock MonsterAsciiRenderer
vi.mock('./MonsterAsciiRenderer', () => ({
renderMonster: vi.fn(),
}));

import {
classifyWeapon,
drawWeapon,
WEAPON_SPEED,
type WeaponAnimType,
} from './weaponAnimations';
import { isItemModelReady, drawItemProjectile, loadItemModel } from './glbItemLoader';
import { isItemModelReady, getItemDrawFn, setItemRotation, loadItemModel } from './glbItemLoader';
import { renderMonster } from './MonsterAsciiRenderer';

// Minimal canvas mock for draw functions
function makeCtx(): CanvasRenderingContext2D {
Expand Down Expand Up @@ -126,50 +133,55 @@ describe('weaponAnimations', () => {
expect(ctx.save).toHaveBeenCalled();
});

it('uses 3D model when item is ready and drawItemProjectile succeeds', () => {
it('renders ASCII projectile when item model is ready', () => {
const mockDrawFn = vi.fn();
vi.mocked(isItemModelReady).mockReturnValue(true);
vi.mocked(drawItemProjectile).mockReturnValue(true);
vi.mocked(getItemDrawFn).mockReturnValue(mockDrawFn);
const ctx = makeCtx();
drawWeapon(ctx, 'melee', 100, 100, 400, 300, 0.5, 'Iron Axe');
expect(drawItemProjectile).toHaveBeenCalledWith(
ctx, 'iron-axe', 100, 100, 400 * 0.06, expect.any(Number),
// Should set rotation and call renderMonster (ASCII pipeline)
expect(setItemRotation).toHaveBeenCalledWith('iron-axe', expect.any(Number), expect.any(Number));
expect(renderMonster).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ id: 'item-iron-axe', dynamic: true }),
expect.any(Number), expect.any(Number),
expect.any(Number), expect.any(Number),
expect.objectContaining({ cellSize: 6, enable3D: true, enableGlow: true }),
);
// 2D fallback should NOT have been called (no save from 2D draw fns)
// The ctx.save would not be called since drawItemProjectile returned true
});

it('falls back to 2D if drawItemProjectile returns false', () => {
it('falls back to 2D if getItemDrawFn returns null', () => {
vi.mocked(isItemModelReady).mockReturnValue(true);
vi.mocked(drawItemProjectile).mockReturnValue(false);
vi.mocked(getItemDrawFn).mockReturnValue(null);
const ctx = makeCtx();
drawWeapon(ctx, 'ranged', 100, 100, 400, 300, 0.5, 'Hunting Bow');
// drawItemProjectile was attempted
expect(drawItemProjectile).toHaveBeenCalled();
// 2D fallback draws (ranged uses save/restore)
expect(ctx.save).toHaveBeenCalled();
// renderMonster should NOT be called
expect(renderMonster).not.toHaveBeenCalled();
// 2D fallback draws
expect(ctx.fillRect).toHaveBeenCalled();
});

it('passes spin rotation for melee weapons', () => {
it('applies spin rotation for melee weapons', () => {
const mockDrawFn = vi.fn();
vi.mocked(isItemModelReady).mockReturnValue(true);
vi.mocked(drawItemProjectile).mockReturnValue(true);
vi.mocked(getItemDrawFn).mockReturnValue(mockDrawFn);
const ctx = makeCtx();
const progress = 0.5;
drawWeapon(ctx, 'melee', 100, 100, 400, 300, progress, 'Iron Axe');
// Melee rotation = progress * PI * 2.5
const expectedRotation = progress * Math.PI * 2.5;
expect(drawItemProjectile).toHaveBeenCalledWith(
ctx, 'iron-axe', 100, 100, expect.any(Number), expectedRotation,
);
// Melee Z rotation = progress * PI * 2.5
const expectedZ = progress * Math.PI * 2.5;
const expectedY = progress * Math.PI * 1.2;
expect(setItemRotation).toHaveBeenCalledWith('iron-axe', expectedZ, expectedY);
});

it('passes zero rotation for non-melee weapons', () => {
it('applies minimal rotation for non-melee weapons', () => {
const mockDrawFn = vi.fn();
vi.mocked(isItemModelReady).mockReturnValue(true);
vi.mocked(drawItemProjectile).mockReturnValue(true);
vi.mocked(getItemDrawFn).mockReturnValue(mockDrawFn);
const ctx = makeCtx();
drawWeapon(ctx, 'ranged', 100, 100, 400, 300, 0.5, 'Hunting Bow');
expect(drawItemProjectile).toHaveBeenCalledWith(
ctx, 'hunting-bow', 100, 100, expect.any(Number), 0,
);
// Ranged: z=0, y=progress*0.3
expect(setItemRotation).toHaveBeenCalledWith('hunting-bow', 0, 0.5 * 0.3);
});
});
});
Loading
Loading