secret-student / frontend /static /src /character.js
asanwari's picture
fix: invalidate stale game assets after deploy
453d293
Raw
History Blame Contribute Delete
3.87 kB
export const CHARACTER_FRAME = { width: 32, height: 48 };
export const CHARACTER_LAYERS = ["gear-back", "body", "pants", "shirt", "hair", "gear-front"];
export const APPEARANCE_COLORS = {
shirt: { red: 0xd94b48, blue: 0x3f71b5, green: 0x4d8d59, yellow: 0xe1b840, purple: 0x765495, teal: 0x398b87 },
pants: { navy: 0x263b60, charcoal: 0x3d4148, brown: 0x6f4d3b, olive: 0x5d673f, blue: 0x365f86, plum: 0x61405d },
hair: { black: 0x1d2025, dark_brown: 0x3b271f, brown: 0x70462c, blond: 0xc99a45, auburn: 0x8a3f2c },
};
const DIRECTIONS = { down: 0, left: 1, right: 2, up: 3 };
const TINTED_LAYERS = { pants: "pants", shirt: "shirt", hair: "hair" };
const DEFAULT_APPEARANCE = { shirt: "red", pants: "navy", hair: "dark_brown" };
const colorName = (palette, appearance) => {
const selected = appearance[`${palette}_color`];
return Object.hasOwn(APPEARANCE_COLORS[palette], selected) ? selected : DEFAULT_APPEARANCE[palette];
};
export const characterAssetUrl = (layer, appearance = {}) => {
const palette = TINTED_LAYERS[layer];
const suffix = palette ? `-${colorName(palette, appearance)}` : "";
return `/game-static/assets/player/student-${layer}${suffix}.png?v=20260615-2`;
};
export function preloadCharacter(scene, appearance = {}) {
CHARACTER_LAYERS.forEach((layer) => {
scene.load.spritesheet(`student-${layer}`, characterAssetUrl(layer, appearance), { frameWidth: CHARACTER_FRAME.width, frameHeight: CHARACTER_FRAME.height });
});
}
export function createCharacter(scene, x, y, appearance = {}) {
const container = scene.add.container(x, y);
const shadow = scene.add.ellipse(0, 1, 24, 8, 0x17202a, .28);
const sprites = CHARACTER_LAYERS.map((layer) => {
const sprite = scene.add.sprite(0, -24, `student-${layer}`, 1).setOrigin(.5, .5);
return sprite;
});
container.add([shadow, ...sprites]);
return { container, sprites, facing: "down", moving: false };
}
export function resolveCharacterFacing(dx, dy, current = "down") {
if (!dx && !dy) return current;
const horizontal = dx < 0 ? "left" : "right";
const vertical = dy < 0 ? "up" : "down";
if (Math.abs(dx) > Math.abs(dy)) return horizontal;
if (Math.abs(dy) > Math.abs(dx)) return vertical;
return current === "left" || current === "right" ? horizontal : vertical;
}
export function updateCharacter(rig, dx, dy, time) {
const moving = Boolean(dx || dy);
rig.facing = resolveCharacterFacing(dx, dy, rig.facing);
rig.moving = moving;
const column = moving ? [0, 1, 2, 1][Math.floor(time / 125) % 4] : 1;
const frame = DIRECTIONS[rig.facing] * 3 + column;
rig.sprites.forEach((sprite) => sprite.setFrame(frame));
}
const previewImages = new Map();
const previewDrawIds = new WeakMap();
function loadPreviewImages(appearance) {
const urls = CHARACTER_LAYERS.map((layer) => [layer, characterAssetUrl(layer, appearance)]);
const key = urls.map(([, url]) => url).join("|");
if (!previewImages.has(key)) {
previewImages.set(key, Promise.all(urls.map(([layer, url]) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve([layer, image]);
image.onerror = reject;
image.src = url;
}))).then(Object.fromEntries));
}
return previewImages.get(key);
}
export async function drawCharacterPreview(canvas, appearance = {}) {
const drawId = (previewDrawIds.get(canvas) || 0) + 1;
previewDrawIds.set(canvas, drawId);
const images = await loadPreviewImages(appearance);
if (previewDrawIds.get(canvas) !== drawId) return;
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, canvas.width, canvas.height);
for (const layer of CHARACTER_LAYERS) {
context.drawImage(images[layer], CHARACTER_FRAME.width, 0, CHARACTER_FRAME.width, CHARACTER_FRAME.height, 0, 0, CHARACTER_FRAME.width, CHARACTER_FRAME.height);
}
}