Files
logic-gates/js/world/worldRenderer.js
Jose Luis c836ccbb21 refactor: migrate world rendering from programmatic sprites to PNG assets
Replace pixel-art drawing with pre-rendered PNG map backgrounds and
character/NPC sprite images from pokemon-js reference. Maps now use
coordinate-based wall arrays instead of tile grids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:02:44 +01:00

181 lines
5.3 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 } from './maps.js';
import { updateMovement } from './worldInput.js';
let canvas = null;
let ctx = null;
let animFrameId = null;
let lastTime = 0;
export function initWorldRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
if (!canvas) return;
canvas.width = canvas.offsetWidth || window.innerWidth;
canvas.height = canvas.offsetHeight || 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 !== canvas.offsetWidth || canvas.height !== canvas.offsetHeight) {
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);
// === 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 ===
// Character sprite is 32x32 native (2 tiles tall)
// Position so bottom half aligns with player tile, top half overlaps above
const playerScreen = tileToScreen(
worldState.player.x + worldState.player.px,
worldState.player.y + worldState.player.py
);
// Offset upward by 1 tile since character is 2 tiles tall
const playerDrawX = playerScreen.x;
const playerDrawY = playerScreen.y - TILE_PX;
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);
}
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);
// Inventory
ctx.fillStyle = '#ff44aa';
ctx.textAlign = 'right';
ctx.fillText(`🔧 Components: ${worldState.inventory.length}`, 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 | TAB: Workshop', canvas.width / 2, 16);
}
// ==================== 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;
}
}