// 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'); } } // === 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; } }