Two-mode game: explore a tile-based cyberpunk world, then enter Workshop mode (the existing circuit editor) to craft components. New modules (js/world/): - sprites.js: programmatic pixel-art renderer (16x16 tiles, 3x scale) - maps.js: tile-based map definitions (lab + town) - worldState.js: player position, inventory, dialog, puzzle state - worldRenderer.js: camera-following world renderer on shared canvas - worldInput.js: WASD movement, E interaction, dialog system - gameMode.js: central mode switcher (world ↔ workshop) Changes to existing code: - app.js: boots into world mode, registers circuit editor for workshop - renderer.js: circuit draw loop now stoppable (start/stopCircuitLoop) - index.html: added "Back to World" button for workshop mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
5.6 KiB
JavaScript
182 lines
5.6 KiB
JavaScript
// worldRenderer.js - Renders the tile-based game world on canvas
|
|
import { drawPlayer, drawTile, drawNPC, drawInteractionPrompt, drawDialogBox, TILE_SIZE, SCALE } from './sprites.js';
|
|
import { worldState } from './worldState.js';
|
|
import { getMap, getTile, getInteraction, getNPC } from './maps.js';
|
|
import { updateMovement } from './worldInput.js';
|
|
|
|
let canvas = null;
|
|
let ctx = null;
|
|
let animFrameId = null;
|
|
let lastTime = 0;
|
|
|
|
const TILE_PX = TILE_SIZE * SCALE; // 48px per tile on screen
|
|
|
|
export function initWorldRenderer() {
|
|
canvas = document.getElementById('canvas');
|
|
ctx = canvas.getContext('2d');
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
return true;
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
if (!canvas) return;
|
|
canvas.width = canvas.offsetWidth || window.innerWidth;
|
|
canvas.height = canvas.offsetHeight || window.innerHeight;
|
|
}
|
|
|
|
/** Convert tile coords → screen pixel coords (camera-relative) */
|
|
export function worldToScreen(tileX, tileY) {
|
|
const p = worldState.player;
|
|
const cx = canvas.width / 2;
|
|
const cy = canvas.height / 2;
|
|
// Player world position with interpolation
|
|
const pwx = (p.x + p.px) * TILE_PX;
|
|
const pwy = (p.y + p.py) * TILE_PX;
|
|
return {
|
|
x: cx + (tileX * TILE_PX - pwx),
|
|
y: cy + (tileY * TILE_PX - pwy)
|
|
};
|
|
}
|
|
|
|
/** Visible tile range for culling */
|
|
function getVisibleBounds() {
|
|
const p = worldState.player;
|
|
const halfW = canvas.width / 2;
|
|
const halfH = canvas.height / 2;
|
|
const pwx = (p.x + p.px) * TILE_PX;
|
|
const pwy = (p.y + p.py) * TILE_PX;
|
|
return {
|
|
minX: Math.floor((pwx - halfW) / TILE_PX) - 1,
|
|
minY: Math.floor((pwy - halfH) / TILE_PX) - 1,
|
|
maxX: Math.ceil((pwx + halfW) / TILE_PX) + 1,
|
|
maxY: Math.ceil((pwy + halfH) / TILE_PX) + 1
|
|
};
|
|
}
|
|
|
|
/** Tile the player is facing */
|
|
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 frame */
|
|
export function renderWorld(timestamp) {
|
|
const dt = (timestamp - lastTime) / 1000;
|
|
lastTime = timestamp;
|
|
|
|
// Update movement interpolation
|
|
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 bounds = getVisibleBounds();
|
|
|
|
// === Layer 1: Tiles ===
|
|
for (let ty = bounds.minY; ty <= bounds.maxY; ty++) {
|
|
for (let tx = bounds.minX; tx <= bounds.maxX; tx++) {
|
|
const tileType = getTile(worldState.currentMap, tx, ty);
|
|
if (tileType === null) continue;
|
|
const pos = worldToScreen(tx, ty);
|
|
drawTile(ctx, pos.x, pos.y, tileType);
|
|
}
|
|
}
|
|
|
|
// === Layer 2: NPCs ===
|
|
if (map.npcs) {
|
|
for (const npc of map.npcs) {
|
|
const pos = worldToScreen(npc.x, npc.y);
|
|
drawNPC(ctx, pos.x, pos.y, npc.type, 0);
|
|
}
|
|
}
|
|
|
|
// === Layer 3: Player ===
|
|
const pp = worldToScreen(worldState.player.x + worldState.player.px,
|
|
worldState.player.y + worldState.player.py);
|
|
// Adjust: worldToScreen already offsets from player, so player is always at center
|
|
const pcx = canvas.width / 2 - TILE_PX / 2;
|
|
const pcy = canvas.height / 2 - TILE_PX / 2;
|
|
const frame = worldState.player.moving ? (Math.floor(Date.now() / 120) % 2) : 0;
|
|
drawPlayer(ctx, pcx, pcy, worldState.player.direction, frame);
|
|
|
|
// === 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 = worldToScreen(ft.x, ft.y);
|
|
drawInteractionPrompt(ctx, pos.x, pos.y);
|
|
}
|
|
}
|
|
|
|
// === Layer 5: Dialog box ===
|
|
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();
|
|
}
|
|
|
|
function drawHUD() {
|
|
const map = getMap(worldState.currentMap);
|
|
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);
|
|
}
|
|
|
|
export function startWorldLoop() {
|
|
lastTime = performance.now();
|
|
function loop(ts) {
|
|
renderWorld(ts);
|
|
animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
export function stopWorldLoop() {
|
|
if (animFrameId !== null) {
|
|
cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
}
|