Add drag & drop spritesheet upload in editor, character registry in sprites.js, character selector for NPCs, sprite rendering on editor canvas, server API for character persistence, and game-side character loading via characterLoader.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
9.5 KiB
JavaScript
309 lines
9.5 KiB
JavaScript
// worldRenderer.js - Renders PNG-based game world on canvas
|
|
import {
|
|
drawMapImage, drawPlayer, drawNPC, drawInteractionPrompt,
|
|
drawDialogBox, preloadAssets, TILE_PX, SCALE
|
|
} from './sprites.js';
|
|
import { worldState } from './worldState.js';
|
|
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
|
|
import { updateMovement } from './worldInput.js';
|
|
import { drawBackpack, getGadgets, drawNamingScreen, drawNotification } from './inventory.js';
|
|
import { isWiringOpen, drawWiringPanel } from './wiringPanel.js';
|
|
|
|
let canvas = null;
|
|
let ctx = null;
|
|
let animFrameId = null;
|
|
let lastTime = 0;
|
|
let debugMode = false;
|
|
|
|
export function toggleDebug() {
|
|
debugMode = !debugMode;
|
|
console.log(`[debug] collision overlay ${debugMode ? 'ON' : 'OFF'}`);
|
|
return debugMode;
|
|
}
|
|
|
|
export function initWorldRenderer() {
|
|
canvas = document.getElementById('canvas');
|
|
ctx = canvas.getContext('2d');
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
if (!canvas) return;
|
|
// Always use full window size in world mode — don't rely on offsetWidth
|
|
// because CSS layout may not have recomputed yet on initial load
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
}
|
|
|
|
// ==================== Camera ====================
|
|
|
|
/** Get the pixel offset to draw the map so the player is centered */
|
|
function getCameraOffset() {
|
|
const p = worldState.player;
|
|
const playerWorldX = (p.x + p.px) * TILE_PX;
|
|
const playerWorldY = (p.y + p.py) * TILE_PX;
|
|
return {
|
|
x: canvas.width / 2 - playerWorldX - TILE_PX / 2,
|
|
y: canvas.height / 2 - playerWorldY - TILE_PX / 2
|
|
};
|
|
}
|
|
|
|
/** Convert tile position to screen position */
|
|
function tileToScreen(tileX, tileY) {
|
|
const cam = getCameraOffset();
|
|
return {
|
|
x: tileX * TILE_PX + cam.x,
|
|
y: tileY * TILE_PX + cam.y
|
|
};
|
|
}
|
|
|
|
// ==================== Facing tile ====================
|
|
|
|
function getFacingTile() {
|
|
const p = worldState.player;
|
|
let x = p.x, y = p.y;
|
|
if (p.direction === 'up') y--;
|
|
else if (p.direction === 'down') y++;
|
|
else if (p.direction === 'left') x--;
|
|
else if (p.direction === 'right') x++;
|
|
return { x, y };
|
|
}
|
|
|
|
// ==================== Main render ====================
|
|
|
|
export function renderWorld(timestamp) {
|
|
const dt = (timestamp - lastTime) / 1000;
|
|
lastTime = timestamp;
|
|
|
|
// Update movement
|
|
updateMovement(dt);
|
|
|
|
// Resize check
|
|
if (canvas.width !== window.innerWidth || canvas.height !== window.innerHeight) {
|
|
resizeCanvas();
|
|
}
|
|
|
|
// Clear
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
const map = getMap(worldState.currentMap);
|
|
if (!map) return;
|
|
|
|
const cam = getCameraOffset();
|
|
|
|
// === Layer 1: Map background (PNG) ===
|
|
drawMapImage(ctx, map.image, cam.x, cam.y);
|
|
|
|
// === Debug overlay (between map and entities) ===
|
|
if (debugMode) drawDebugOverlay(ctx, map, cam);
|
|
|
|
// === Layer 2: NPCs ===
|
|
if (map.npcs) {
|
|
for (const npc of map.npcs) {
|
|
const pos = tileToScreen(npc.x, npc.y);
|
|
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down', npc.charId || null);
|
|
}
|
|
}
|
|
|
|
// === Layer 3: Player ===
|
|
const playerScreen = tileToScreen(
|
|
worldState.player.x + worldState.player.px,
|
|
worldState.player.y + worldState.player.py
|
|
);
|
|
const playerDrawX = playerScreen.x;
|
|
const playerDrawY = playerScreen.y;
|
|
|
|
const walkFrame = worldState.player.moving
|
|
? (Math.floor(Date.now() / 150) % 2) + 1 // alternates 1, 2
|
|
: 0;
|
|
drawPlayer(ctx, playerDrawX, playerDrawY, worldState.player.direction, walkFrame);
|
|
|
|
// === Layer 4: Interaction prompt ===
|
|
if (!worldState.dialog && !worldState.player.moving) {
|
|
const ft = getFacingTile();
|
|
const inter = getInteraction(worldState.currentMap, ft.x, ft.y);
|
|
const npc = getNPC(worldState.currentMap, ft.x, ft.y);
|
|
if (inter || npc) {
|
|
const pos = tileToScreen(ft.x, ft.y);
|
|
drawInteractionPrompt(ctx, pos.x, pos.y);
|
|
}
|
|
}
|
|
|
|
// === Layer 5: Dialog ===
|
|
if (worldState.dialog) {
|
|
const line = worldState.dialog.lines[worldState.dialog.currentLine] || '';
|
|
const speaker = worldState.dialog.speakerName || '';
|
|
drawDialogBox(ctx, canvas.width, canvas.height, line, speaker);
|
|
}
|
|
|
|
// === HUD ===
|
|
drawHUD(map);
|
|
|
|
// === Layer 6: Backpack overlay (on top of everything) ===
|
|
if (worldState.mode === 'inventory') {
|
|
drawBackpack(ctx, canvas.width, canvas.height);
|
|
}
|
|
|
|
// === Layer 7: Wiring panel overlay ===
|
|
if (isWiringOpen()) {
|
|
drawWiringPanel(ctx, canvas.width, canvas.height);
|
|
}
|
|
|
|
// === Layer 8: Naming screen (on top of everything) ===
|
|
drawNamingScreen(ctx, canvas.width, canvas.height);
|
|
|
|
// === Layer 8: Notification toast ===
|
|
drawNotification(ctx, canvas.width);
|
|
}
|
|
|
|
function drawHUD(map) {
|
|
const mapName = map ? map.name : worldState.currentMap;
|
|
|
|
// Background bar
|
|
ctx.fillStyle = 'rgba(10, 10, 15, 0.75)';
|
|
ctx.fillRect(0, 0, canvas.width, 32);
|
|
|
|
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// Map name
|
|
ctx.fillStyle = '#00e599';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`📍 ${mapName}`, 12, 16);
|
|
|
|
// Gadgets count
|
|
const gadgetCount = getGadgets().length;
|
|
ctx.fillStyle = '#ff44aa';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`🎒 Gadgets: ${gadgetCount}`, canvas.width - 12, 16);
|
|
|
|
// Controls hint
|
|
ctx.fillStyle = '#555';
|
|
ctx.textAlign = 'center';
|
|
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
|
ctx.fillText('WASD: Move | E: Interact | I: Backpack | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
|
|
|
|
// Debug legend
|
|
if (debugMode) {
|
|
const legendY = 40;
|
|
ctx.font = '11px monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
|
|
const items = [
|
|
['rgba(255, 50, 50, 0.6)', 'Wall'],
|
|
['rgba(50, 255, 50, 0.6)', 'Exit'],
|
|
['rgba(255, 255, 0, 0.6)', 'Interaction'],
|
|
['rgba(200, 50, 255, 0.6)', 'NPC'],
|
|
['#00e599', 'Player tile']
|
|
];
|
|
let lx = 12;
|
|
for (const [color, label] of items) {
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(lx, legendY, 12, 12);
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.strokeRect(lx, legendY, 12, 12);
|
|
ctx.fillStyle = '#ccc';
|
|
ctx.fillText(label, lx + 16, legendY + 1);
|
|
lx += ctx.measureText(label).width + 28;
|
|
}
|
|
|
|
// Player coords
|
|
const p = worldState.player;
|
|
ctx.fillStyle = '#00e599';
|
|
ctx.fillText(`Pos: (${p.x}, ${p.y}) Map: ${worldState.currentMap}`, 12, legendY + 18);
|
|
}
|
|
}
|
|
|
|
// ==================== Debug overlay ====================
|
|
|
|
function drawDebugOverlay(ctx, map, cam) {
|
|
const mapId = worldState.currentMap;
|
|
const w = map.widthTiles;
|
|
const h = map.heightTiles;
|
|
|
|
ctx.save();
|
|
|
|
for (let ty = 0; ty < h; ty++) {
|
|
for (let tx = 0; tx < w; tx++) {
|
|
const sx = tx * TILE_PX + cam.x;
|
|
const sy = ty * TILE_PX + cam.y;
|
|
|
|
// Skip tiles entirely off-screen
|
|
if (sx + TILE_PX < 0 || sx > canvas.width || sy + TILE_PX < 0 || sy > canvas.height) continue;
|
|
|
|
const wall = isWall(mapId, tx, ty);
|
|
const exit = getExit(mapId, tx, ty);
|
|
const inter = getInteraction(mapId, tx, ty);
|
|
const npc = getNPC(mapId, tx, ty);
|
|
|
|
// Wall = red, Exit = green, Interaction = yellow, NPC = purple, walkable = no fill
|
|
if (wall) {
|
|
ctx.fillStyle = 'rgba(255, 50, 50, 0.35)';
|
|
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
|
} else if (exit) {
|
|
ctx.fillStyle = 'rgba(50, 255, 50, 0.4)';
|
|
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
|
}
|
|
|
|
if (inter) {
|
|
ctx.fillStyle = 'rgba(255, 255, 0, 0.35)';
|
|
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
|
}
|
|
|
|
if (npc) {
|
|
ctx.fillStyle = 'rgba(200, 50, 255, 0.4)';
|
|
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
|
}
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
|
|
|
|
// Coordinate labels (only near player to avoid clutter)
|
|
const p = worldState.player;
|
|
if (Math.abs(tx - p.x) <= 6 && Math.abs(ty - p.y) <= 5) {
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
|
ctx.font = '9px monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(`${tx},${ty}`, sx + 2, sy + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Player tile highlight
|
|
const px = worldState.player.x * TILE_PX + cam.x;
|
|
const py = worldState.player.y * TILE_PX + cam.y;
|
|
ctx.strokeStyle = '#00e599';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(px, py, TILE_PX, TILE_PX);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ==================== Loop control ====================
|
|
|
|
export async function startWorldLoop() {
|
|
// Ensure assets are loaded before starting
|
|
await preloadAssets();
|
|
|
|
lastTime = performance.now();
|
|
function loop(ts) {
|
|
renderWorld(ts);
|
|
animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
export function stopWorldLoop() {
|
|
if (animFrameId !== null) {
|
|
cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
}
|