feat: add Pokemon-style world mode with workshop integration
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>
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Back to world button (shown in workshop mode) -->
|
||||
<button id="back-to-world-btn" style="display:none; position:fixed; top:12px; right:12px; z-index:200; padding:6px 14px; background:#00e599; border:none; border-radius:6px; color:#000; font-weight:700; cursor:pointer; font-size:12px;">◀ Back to World</button>
|
||||
|
||||
<div id="toolbar">
|
||||
<span class="logo">⚡ Logic Lab</span>
|
||||
|
||||
|
||||
40
js/app.js
40
js/app.js
@@ -1,22 +1,40 @@
|
||||
// Entry point — initializes all modules
|
||||
import { initRenderer } from './renderer.js';
|
||||
// Entry point — initializes game (world + workshop modes)
|
||||
import { initRenderer, resize } from './renderer.js';
|
||||
import { initEvents } from './events.js';
|
||||
import { initPuzzleUI } from './puzzleUI.js';
|
||||
import { loadFromStorage, startAutoSave } from './saveLoad.js';
|
||||
import { updateComponentButtons } from './components.js';
|
||||
import { evaluateAll } from './gates.js';
|
||||
import { startGame, registerCircuitEditor, enterWorldMode } from './world/gameMode.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initRenderer();
|
||||
initEvents();
|
||||
initPuzzleUI();
|
||||
// Register circuit editor init/destroy so gameMode can switch to workshop
|
||||
registerCircuitEditor(
|
||||
// init workshop
|
||||
() => {
|
||||
initRenderer();
|
||||
initEvents();
|
||||
initPuzzleUI();
|
||||
if (loadFromStorage()) {
|
||||
updateComponentButtons();
|
||||
evaluateAll();
|
||||
}
|
||||
startAutoSave(3000);
|
||||
},
|
||||
// destroy workshop (cleanup when switching back to world)
|
||||
() => {
|
||||
// Auto-save is fine to leave running
|
||||
}
|
||||
);
|
||||
|
||||
// Restore previous session from localStorage
|
||||
if (loadFromStorage()) {
|
||||
updateComponentButtons();
|
||||
evaluateAll();
|
||||
// Add back-to-world button handler
|
||||
const backBtn = document.getElementById('back-to-world-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
enterWorldMode();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-save every 3 seconds + on page unload
|
||||
startAutoSave(3000);
|
||||
// Start the game in world mode
|
||||
startGame();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
||||
import { getBusPairs } from './bus.js';
|
||||
|
||||
let canvas, ctx;
|
||||
let circuitAnimFrameId = null;
|
||||
let rendererInitialized = false;
|
||||
|
||||
/**
|
||||
* Read the value arriving at an input port by looking up the source gate/port.
|
||||
@@ -25,8 +27,23 @@ export function initRenderer() {
|
||||
canvas = document.getElementById('canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
requestAnimationFrame(draw);
|
||||
if (!rendererInitialized) {
|
||||
window.addEventListener('resize', resize);
|
||||
rendererInitialized = true;
|
||||
}
|
||||
startCircuitLoop();
|
||||
}
|
||||
|
||||
export function startCircuitLoop() {
|
||||
if (circuitAnimFrameId) return; // already running
|
||||
circuitAnimFrameId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
export function stopCircuitLoop() {
|
||||
if (circuitAnimFrameId) {
|
||||
cancelAnimationFrame(circuitAnimFrameId);
|
||||
circuitAnimFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resize() {
|
||||
@@ -646,5 +663,5 @@ function draw() {
|
||||
drawWaveforms();
|
||||
}
|
||||
|
||||
requestAnimationFrame(draw);
|
||||
circuitAnimFrameId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
173
js/world/gameMode.js
Normal file
173
js/world/gameMode.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// gameMode.js - Central coordinator: switches between World and Workshop modes
|
||||
import { worldState, setPlayerPosition, warpToMap, solvePuzzle, isPuzzleSolved } from './worldState.js';
|
||||
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
|
||||
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
|
||||
import { getMap } from './maps.js';
|
||||
|
||||
// Circuit editor stop function (to stop its render loop when switching modes)
|
||||
import { stopCircuitLoop } from '../renderer.js';
|
||||
|
||||
// Circuit editor modules (registered from app.js to avoid circular deps)
|
||||
let circuitEditorInit = null;
|
||||
let circuitEditorDestroy = null;
|
||||
|
||||
let currentMode = 'none'; // 'world' | 'workshop'
|
||||
|
||||
/**
|
||||
* Register the circuit editor's init/destroy functions.
|
||||
* Called from app.js so we don't create circular imports.
|
||||
*/
|
||||
export function registerCircuitEditor(initFn, destroyFn) {
|
||||
circuitEditorInit = initFn;
|
||||
circuitEditorDestroy = destroyFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the game — start in world mode
|
||||
*/
|
||||
export function startGame() {
|
||||
// Set spawn
|
||||
const map = getMap(worldState.currentMap);
|
||||
if (map && map.spawn) {
|
||||
setPlayerPosition(map.spawn.x, map.spawn.y);
|
||||
}
|
||||
|
||||
// Wire up interaction handler
|
||||
setInteractionHandler(handleInteraction);
|
||||
|
||||
// Enter world mode
|
||||
enterWorldMode();
|
||||
}
|
||||
|
||||
// ==================== Mode switching ====================
|
||||
|
||||
export function enterWorldMode() {
|
||||
if (currentMode === 'world') return;
|
||||
|
||||
// Tear down workshop if active
|
||||
if (currentMode === 'workshop') {
|
||||
stopCircuitLoop();
|
||||
if (circuitEditorDestroy) circuitEditorDestroy();
|
||||
hideWorkshopUI();
|
||||
}
|
||||
|
||||
currentMode = 'world';
|
||||
worldState.mode = 'world';
|
||||
|
||||
showWorldUI();
|
||||
initWorldRenderer();
|
||||
initWorldInput();
|
||||
startWorldLoop();
|
||||
|
||||
console.log('[gameMode] entered world mode');
|
||||
}
|
||||
|
||||
export function enterWorkshopMode() {
|
||||
if (currentMode === 'workshop') return;
|
||||
|
||||
// Tear down world
|
||||
if (currentMode === 'world') {
|
||||
stopWorldLoop();
|
||||
destroyWorldInput();
|
||||
hideWorldUI();
|
||||
}
|
||||
|
||||
currentMode = 'workshop';
|
||||
worldState.mode = 'workshop';
|
||||
|
||||
showWorkshopUI();
|
||||
if (circuitEditorInit) circuitEditorInit();
|
||||
|
||||
console.log('[gameMode] entered workshop mode');
|
||||
}
|
||||
|
||||
export function getCurrentMode() { return currentMode; }
|
||||
|
||||
// ==================== Interaction handler ====================
|
||||
|
||||
function handleInteraction(event) {
|
||||
switch (event.type) {
|
||||
case 'enterWorkshop':
|
||||
enterWorkshopMode();
|
||||
break;
|
||||
|
||||
case 'puzzleDoor': {
|
||||
const inter = event.data;
|
||||
if (isPuzzleSolved(inter.puzzleId)) {
|
||||
// Already solved — could open door, show message, etc.
|
||||
return;
|
||||
}
|
||||
// For now, show a hint dialog. Later: open puzzle UI
|
||||
worldState.dialog = {
|
||||
lines: [
|
||||
'This door requires a logic circuit to open.',
|
||||
`Required output pattern: [${inter.requiredOutputs.join(', ')}]`,
|
||||
'Craft a component in your Workshop (TAB)!'
|
||||
],
|
||||
currentLine: 0,
|
||||
speakerName: 'System'
|
||||
};
|
||||
worldState.mode = 'dialog';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mapExit': {
|
||||
const { targetMap, targetX, targetY } = event.data;
|
||||
warpToMap(targetMap, targetX, targetY);
|
||||
console.log(`[gameMode] warped to ${targetMap} (${targetX}, ${targetY})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'openInventory':
|
||||
// TODO: inventory UI
|
||||
console.log('[gameMode] inventory:', worldState.inventory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UI visibility ====================
|
||||
|
||||
function showWorldUI() {
|
||||
// Hide workshop-specific elements
|
||||
const toolbar = document.getElementById('toolbar');
|
||||
const wavePanel = document.getElementById('waveform-panel');
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
if (toolbar) toolbar.style.display = 'none';
|
||||
if (wavePanel) wavePanel.style.display = 'none';
|
||||
if (canvas) {
|
||||
canvas.style.top = '0';
|
||||
canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
// Show back-to-world button (hidden since we're IN world)
|
||||
const backBtn = document.getElementById('back-to-world-btn');
|
||||
if (backBtn) backBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideWorldUI() {
|
||||
// Nothing special to hide — canvas stays
|
||||
}
|
||||
|
||||
function showWorkshopUI() {
|
||||
const toolbar = document.getElementById('toolbar');
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
if (toolbar) toolbar.style.display = 'flex';
|
||||
if (canvas) {
|
||||
canvas.style.top = '56px';
|
||||
canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
// Show back-to-world button
|
||||
const backBtn = document.getElementById('back-to-world-btn');
|
||||
if (backBtn) backBtn.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideWorkshopUI() {
|
||||
const toolbar = document.getElementById('toolbar');
|
||||
if (toolbar) toolbar.style.display = 'none';
|
||||
|
||||
const backBtn = document.getElementById('back-to-world-btn');
|
||||
if (backBtn) backBtn.style.display = 'none';
|
||||
}
|
||||
305
js/world/maps.js
Normal file
305
js/world/maps.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* maps.js - Tile-based world map definitions
|
||||
*
|
||||
* Tile types:
|
||||
* 0 = floor
|
||||
* 1 = wall
|
||||
* 2 = grass
|
||||
* 3 = workshop table
|
||||
* 4 = puzzle door locked
|
||||
* 5 = puzzle door open
|
||||
* 6 = NPC spot
|
||||
* 7 = path
|
||||
* 8 = water
|
||||
* 9 = terminal
|
||||
*/
|
||||
|
||||
// Helper to fill a rectangular area with a tile type
|
||||
function fillRect(tiles, x, y, w, h, tileType) {
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
if (row >= 0 && row < tiles.length && col >= 0 && col < tiles[0].length) {
|
||||
tiles[row][col] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to place a horizontal line
|
||||
function hline(tiles, x, y, length, tileType) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (y >= 0 && y < tiles.length && x + i >= 0 && x + i < tiles[0].length) {
|
||||
tiles[y][x + i] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to place a vertical line
|
||||
function vline(tiles, x, y, length, tileType) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (x >= 0 && x < tiles[0].length && y + i >= 0 && y + i < tiles.length) {
|
||||
tiles[y + i][x] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MAP 1: CIRCUIT LAB (20×15)
|
||||
* Indoor tech lab with workstations, terminals, and a puzzle door
|
||||
*/
|
||||
function createLabMap() {
|
||||
const width = 20;
|
||||
const height = 15;
|
||||
|
||||
// Start with all floor
|
||||
const tiles = Array(height).fill(null).map(() => Array(width).fill(0));
|
||||
|
||||
// Border walls
|
||||
for (let i = 0; i < width; i++) {
|
||||
tiles[0][i] = 1; // top
|
||||
tiles[height - 1][i] = 1; // bottom
|
||||
}
|
||||
for (let i = 0; i < height; i++) {
|
||||
tiles[i][0] = 1; // left
|
||||
tiles[i][width - 1] = 1; // right
|
||||
}
|
||||
|
||||
// Internal wall structure - create lab rooms
|
||||
// Vertical divider wall down the middle area
|
||||
vline(tiles, 13, 1, 10, 1);
|
||||
|
||||
// Horizontal divider
|
||||
hline(tiles, 1, 7, 12, 1);
|
||||
|
||||
// Workshop area - left side with tables
|
||||
tiles[4][3] = 3; // workshop table 1
|
||||
tiles[4][5] = 3; // workshop table 2
|
||||
tiles[4][7] = 3; // workshop table 3
|
||||
tiles[6][3] = 3; // workshop table 4
|
||||
tiles[6][5] = 3; // workshop table 5
|
||||
|
||||
// Terminal near workshop area
|
||||
tiles[5][10] = 9; // terminal
|
||||
|
||||
// Professor NPC spot at top
|
||||
tiles[2][10] = 6; // NPC spawn location
|
||||
|
||||
// Puzzle door leading to back room (top-right area)
|
||||
tiles[2][16] = 4; // locked puzzle door
|
||||
|
||||
// Door opening in the internal wall for navigation
|
||||
tiles[7][7] = 0; // create passage
|
||||
|
||||
// Exit point at bottom (to town)
|
||||
tiles[13][10] = 0; // clear exit path
|
||||
|
||||
return {
|
||||
id: 'lab',
|
||||
name: 'Circuit Lab',
|
||||
width: width,
|
||||
height: height,
|
||||
tiles: tiles,
|
||||
spawn: { x: 10, y: 12 },
|
||||
|
||||
exits: [
|
||||
{ x: 10, y: 13, targetMap: 'town', targetX: 15, targetY: 1 }
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{
|
||||
id: 'professor',
|
||||
type: 0,
|
||||
x: 10,
|
||||
y: 2,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to the Circuit Lab!',
|
||||
'I\'m the Professor. We study logic gates here.',
|
||||
'Try using the workshop tables to design circuits.',
|
||||
'Once you\'ve created some components, you can use them to solve puzzles.'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 3, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 5, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 7, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 10, y: 5, type: 'terminal', action: 'openTerminal', label: 'Terminal' },
|
||||
{ x: 16, y: 2, type: 'puzzle_door', puzzleId: 'lab_door_1', requiredOutputs: [1, 0, 1, 1], label: 'Locked Door' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAP 2: NEON TOWN (30×20)
|
||||
* Outdoor town with buildings, NPCs, water feature, and puzzle areas
|
||||
*/
|
||||
function createTownMap() {
|
||||
const width = 30;
|
||||
const height = 20;
|
||||
|
||||
// Start with grass
|
||||
const tiles = Array(height).fill(null).map(() => Array(width).fill(2));
|
||||
|
||||
// Add some paths (lighter grass/paths)
|
||||
hline(tiles, 0, 10, width, 7); // horizontal path
|
||||
vline(tiles, 15, 0, height, 7); // vertical path
|
||||
|
||||
// Water feature on the left (pond)
|
||||
fillRect(tiles, 2, 5, 5, 6, 8);
|
||||
|
||||
// Lab entrance at top
|
||||
tiles[0][15] = 0; // entrance floor
|
||||
tiles[1][15] = 6; // NPC spawn for entrance
|
||||
|
||||
// Building 1 (top-left) - House structure
|
||||
fillRect(tiles, 5, 2, 7, 5, 1); // walls
|
||||
fillRect(tiles, 6, 3, 5, 3, 0); // interior floor
|
||||
tiles[4][8] = 0; // door to building 1
|
||||
|
||||
// Building 2 (top-right) - Shop
|
||||
fillRect(tiles, 20, 2, 7, 5, 1); // walls
|
||||
fillRect(tiles, 21, 3, 5, 3, 0); // interior floor
|
||||
tiles[4][23] = 0; // door to building 2
|
||||
|
||||
// Building 3 (bottom-left) - Guard post
|
||||
fillRect(tiles, 5, 14, 7, 5, 1); // walls
|
||||
fillRect(tiles, 6, 15, 5, 3, 0); // interior floor
|
||||
tiles[13][8] = 0; // door to guard post
|
||||
|
||||
// Building 4 (bottom-right) - Town Hall
|
||||
fillRect(tiles, 20, 14, 7, 5, 1); // walls
|
||||
fillRect(tiles, 21, 15, 5, 3, 0); // interior floor
|
||||
tiles[13][23] = 0; // door to town hall
|
||||
|
||||
// Merchant NPC in center town square
|
||||
tiles[10][15] = 6; // NPC spawn
|
||||
|
||||
// Guard NPC at guard post entrance
|
||||
tiles[13][8] = 6; // NPC spawn (overlays door, but NPC takes priority)
|
||||
|
||||
// Puzzle door to eastern area (locked)
|
||||
tiles[10][28] = 4; // locked puzzle door
|
||||
|
||||
return {
|
||||
id: 'town',
|
||||
name: 'Neon Town',
|
||||
width: width,
|
||||
height: height,
|
||||
tiles: tiles,
|
||||
spawn: { x: 15, y: 2 },
|
||||
|
||||
exits: [
|
||||
{ x: 15, y: 0, targetMap: 'lab', targetX: 10, targetY: 13 }
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{
|
||||
id: 'merchant',
|
||||
type: 0,
|
||||
x: 15,
|
||||
y: 10,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to Neon Town!',
|
||||
'I trade in rare logic components.',
|
||||
'Show me what circuits you\'ve designed, and maybe we can make a deal.',
|
||||
'Some items are only available if you\'ve solved certain puzzles.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guard',
|
||||
type: 0,
|
||||
x: 8,
|
||||
y: 13,
|
||||
facing: 'right',
|
||||
dialog: [
|
||||
'I guard the eastern territories.',
|
||||
'You need to solve the puzzle at the gate before you can pass.',
|
||||
'Bring me a component that produces the right output pattern!'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 8, y: 4, type: 'door', action: 'openBuilding', label: 'House', buildingId: 'house_1' },
|
||||
{ x: 23, y: 4, type: 'door', action: 'openBuilding', label: 'Shop', buildingId: 'shop_1' },
|
||||
{ x: 8, y: 13, type: 'door', action: 'openBuilding', label: 'Guard Post', buildingId: 'guardpost_1' },
|
||||
{ x: 23, y: 13, type: 'door', action: 'openBuilding', label: 'Town Hall', buildingId: 'townhall_1' },
|
||||
{ x: 28, y: 10, type: 'puzzle_door', puzzleId: 'town_gate_1', requiredOutputs: [0, 1, 1, 0], label: 'Locked Gate' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Map registry
|
||||
const maps = {
|
||||
lab: createLabMap(),
|
||||
town: createTownMap()
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a complete map by ID
|
||||
*/
|
||||
export function getMap(id) {
|
||||
return maps[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tile type at position
|
||||
*/
|
||||
export function getTile(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
if (x < 0 || x >= map.width || y < 0 || y >= map.height) return null;
|
||||
return map.tiles[y][x];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interaction at position (if any)
|
||||
*/
|
||||
export function getInteraction(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.interactions.find(inter => inter.x === x && inter.y === y) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NPC at position (if any)
|
||||
*/
|
||||
export function getNPC(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.npcs.find(npc => npc.x === x && npc.y === y) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exit at position (if any)
|
||||
*/
|
||||
export function getExit(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.exits.find(exit => exit.x === x && exit.y === y) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tile is walkable
|
||||
* Walls (1), water (8), workshop tables (3), terminals (9), locked puzzle doors (4), and NPCs are not walkable
|
||||
*/
|
||||
export function isWalkable(mapId, x, y) {
|
||||
const tile = getTile(mapId, x, y);
|
||||
|
||||
// Out of bounds
|
||||
if (tile === null) return false;
|
||||
|
||||
// Non-walkable tiles
|
||||
const nonWalkable = [1, 3, 4, 8, 9];
|
||||
if (nonWalkable.includes(tile)) return false;
|
||||
|
||||
// Check for NPC
|
||||
if (getNPC(mapId, x, y)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export { maps };
|
||||
694
js/world/sprites.js
Normal file
694
js/world/sprites.js
Normal file
@@ -0,0 +1,694 @@
|
||||
// Cyberpunk pixel-art sprite system
|
||||
// All sprites drawn on canvas, no image assets
|
||||
// 16x16 tile size with 3x scaling for screen rendering
|
||||
|
||||
export const TILE_SIZE = 16;
|
||||
export const SCALE = 3;
|
||||
|
||||
// Color palette
|
||||
const COLORS = {
|
||||
// Neon palette
|
||||
neonGreen: '#00e599',
|
||||
neonPink: '#ff44aa',
|
||||
neonPurple: '#9900ff',
|
||||
neonCyan: '#44ddff',
|
||||
neonOrange: '#ff8844',
|
||||
|
||||
// Dark palette
|
||||
black: '#0a0e27',
|
||||
darkGray: '#1a1f3a',
|
||||
gray: '#3a3f5a',
|
||||
lightGray: '#5a5f7a',
|
||||
|
||||
// Skin tones & details
|
||||
skinLight: '#d4a574',
|
||||
skinMid: '#c89860',
|
||||
skinDark: '#a0704c',
|
||||
|
||||
// Material colors
|
||||
metalDark: '#2a2f4a',
|
||||
metalLight: '#4a4f6a',
|
||||
copper: '#b87333',
|
||||
blue: '#4488dd',
|
||||
red: '#ee4444',
|
||||
green: '#44aa44',
|
||||
white: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to draw a single scaled pixel
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} baseX - Base X position (in pixels on screen)
|
||||
* @param {number} baseY - Base Y position (in pixels on screen)
|
||||
* @param {number} px - Pixel X offset (0-15 within tile)
|
||||
* @param {number} py - Pixel Y offset (0-15 within tile)
|
||||
* @param {string} color - Color hex code
|
||||
*/
|
||||
function pixel(ctx, baseX, baseY, px, py, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(baseX + px * SCALE, baseY + py * SCALE, SCALE, SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a filled rectangle in tile space
|
||||
*/
|
||||
function rect(ctx, baseX, baseY, x, y, w, h, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(baseX + x * SCALE, baseY + y * SCALE, w * SCALE, h * SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the player character
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
* @param {string} direction - 'up', 'down', 'left', 'right'
|
||||
* @param {number} frame - Animation frame (0 or 1)
|
||||
*/
|
||||
export function drawPlayer(ctx, x, y, direction, frame) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
// Idle position or walking offset
|
||||
const walkOffset = frame === 1 ? 1 : 0;
|
||||
|
||||
// Head position shifts slightly with walk cycle
|
||||
let headY = 2;
|
||||
let legOffset = 0;
|
||||
if (frame === 1 && direction === 'down') legOffset = 1;
|
||||
if (frame === 1 && direction === 'up') legOffset = -1;
|
||||
|
||||
if (direction === 'down') {
|
||||
// Facing down
|
||||
// Hair/head
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinLight);
|
||||
|
||||
// Hair back
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Eyes (neon glow)
|
||||
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.red);
|
||||
|
||||
// Torso - black outfit with neon trim
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonGreen);
|
||||
|
||||
// Chest neon accent
|
||||
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.neonPink);
|
||||
pixel(ctx, baseY, baseY, 8, headY + 6, COLORS.neonPink);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.skinDark);
|
||||
|
||||
// Gloves/wrists - neon
|
||||
pixel(ctx, baseX, baseY, 5, headY + 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9 + legOffset, COLORS.black);
|
||||
|
||||
// Feet - boots with neon
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'up') {
|
||||
// Facing up - back view
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Back of head
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinDark);
|
||||
|
||||
// Neck
|
||||
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.skinLight);
|
||||
|
||||
// Jacket back with neon stripe
|
||||
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.black);
|
||||
|
||||
// Torso
|
||||
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.darkGray);
|
||||
|
||||
// Waist - neon bands
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonPink);
|
||||
|
||||
// Arms back
|
||||
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.darkGray);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9 - legOffset, COLORS.black);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'left') {
|
||||
// Facing left
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 6, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Eye (neon)
|
||||
pixel(ctx, baseX, baseY, 6, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.red);
|
||||
|
||||
// Torso with side view
|
||||
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.darkGray);
|
||||
|
||||
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.black);
|
||||
|
||||
// Left arm
|
||||
pixel(ctx, baseX, baseY, 4, headY + 5 - walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 4, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Right arm (back)
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5 + walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 6, COLORS.darkGray);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 5, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9, COLORS.darkGray);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 5, headY + 11, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'right') {
|
||||
// Facing right
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.skinLight);
|
||||
|
||||
// Eye (neon)
|
||||
pixel(ctx, baseX, baseY, 9, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.red);
|
||||
|
||||
// Torso with side view
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.neonGreen);
|
||||
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.neonGreen);
|
||||
|
||||
// Left arm (back)
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5 + walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.darkGray);
|
||||
|
||||
// Right arm
|
||||
pixel(ctx, baseX, baseY, 11, headY + 5 - walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 9, COLORS.black);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 11, COLORS.neonGreen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a tile
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Tile X position
|
||||
* @param {number} y - Tile Y position
|
||||
* @param {number} tileType - Tile type (0-9)
|
||||
*/
|
||||
export function drawTile(ctx, x, y, tileType) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
switch (tileType) {
|
||||
case 0: // Floor - metal grid pattern
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
|
||||
// Grid pattern
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
rect(ctx, baseX, baseY, i, 0, 1, 16, COLORS.metalLight);
|
||||
rect(ctx, baseX, baseY, 0, i, 16, 1, COLORS.metalLight);
|
||||
}
|
||||
// Corner accents
|
||||
pixel(ctx, baseX, baseY, 0, 0, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 15, 0, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 0, 15, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 15, 15, COLORS.neonGreen);
|
||||
break;
|
||||
|
||||
case 1: // Wall - solid dark with neon trim
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.black);
|
||||
// Neon edges
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonCyan);
|
||||
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPink);
|
||||
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonOrange);
|
||||
break;
|
||||
|
||||
case 2: // Grass/outdoor ground
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.green);
|
||||
// Grass tufts
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
for (let j = 0; j < 16; j += 4) {
|
||||
if ((i + j) % 8 === 0) {
|
||||
pixel(ctx, baseX, baseY, i, j, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, i + 1, j, COLORS.neonGreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 3: // Workshop table
|
||||
// Table surface
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 10, COLORS.copper);
|
||||
rect(ctx, baseX, baseY, 2, 2, 12, 8, COLORS.lightGray);
|
||||
|
||||
// Electronic components on table
|
||||
rect(ctx, baseX, baseY, 3, 3, 3, 3, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 10, 3, 3, 3, COLORS.neonCyan);
|
||||
rect(ctx, baseX, baseY, 6, 5, 4, 2, COLORS.neonGreen);
|
||||
|
||||
// Table legs
|
||||
rect(ctx, baseX, baseY, 2, 11, 2, 5, COLORS.gray);
|
||||
rect(ctx, baseX, baseY, 12, 11, 2, 5, COLORS.gray);
|
||||
break;
|
||||
|
||||
case 4: // Puzzle door - locked
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
|
||||
// Door frame
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
|
||||
|
||||
// LED indicators (locked - red)
|
||||
pixel(ctx, baseX, baseY, 4, 4, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 12, 4, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 4, 12, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 12, 12, COLORS.red);
|
||||
|
||||
// Center lock symbol
|
||||
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 8, COLORS.black);
|
||||
break;
|
||||
|
||||
case 5: // Puzzle door - open
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalLight);
|
||||
// Door frame
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
|
||||
|
||||
// LED indicators (unlocked - green)
|
||||
pixel(ctx, baseX, baseY, 4, 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 12, 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 4, 12, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 12, 12, COLORS.neonGreen);
|
||||
|
||||
// Open door effect
|
||||
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonGreen);
|
||||
break;
|
||||
|
||||
case 6: // NPC spot - empty, marked with neon circle
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
|
||||
// Neon circle outline
|
||||
for (let i = 3; i < 13; i++) {
|
||||
pixel(ctx, baseX, baseY, i, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, i, 12, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 3, i, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 12, i, COLORS.neonCyan);
|
||||
}
|
||||
break;
|
||||
|
||||
case 7: // Path/road
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.gray);
|
||||
// Road markings
|
||||
rect(ctx, baseX, baseY, 6, 0, 4, 16, COLORS.neonOrange);
|
||||
// Dashes
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
rect(ctx, baseX, baseY, 7, i, 2, 2, COLORS.black);
|
||||
}
|
||||
break;
|
||||
|
||||
case 8: // Water/void
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
// Ripple effect (animated-looking)
|
||||
for (let i = 2; i < 14; i += 3) {
|
||||
for (let j = 2; j < 14; j += 3) {
|
||||
if ((i + j) % 6 === 0) {
|
||||
pixel(ctx, baseX, baseY, i, j, COLORS.neonCyan);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neon glow edges
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonPurple);
|
||||
break;
|
||||
|
||||
case 9: // Terminal/computer
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
// Screen border
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 12, COLORS.metalDark);
|
||||
rect(ctx, baseX, baseY, 2, 2, 12, 10, COLORS.neonGreen);
|
||||
|
||||
// Screen display with scanlines effect
|
||||
for (let i = 0; i < 10; i += 2) {
|
||||
rect(ctx, baseX, baseY, 3, 3 + i, 10, 1, COLORS.darkGray);
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
rect(ctx, baseX, baseY, 2, 13, 12, 2, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 4, 14, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 14, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 12, 14, COLORS.neonPink);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Default: empty space
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an NPC character
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
* @param {number} npcType - NPC type (0 = scientist, 1 = guard, 2 = merchant)
|
||||
* @param {number} frame - Animation frame (0 or 1)
|
||||
*/
|
||||
export function drawNPC(ctx, x, y, npcType, frame) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
const wobble = frame === 1 ? 1 : 0;
|
||||
|
||||
if (npcType === 0) {
|
||||
// Scientist - lab coat, goggles
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 7, 2, COLORS.lightGray);
|
||||
pixel(ctx, baseX, baseY, 8, 2, COLORS.lightGray);
|
||||
|
||||
// Goggles
|
||||
pixel(ctx, baseX, baseY, 6, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 6, 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, 4, COLORS.black);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.skinLight);
|
||||
|
||||
// Nose
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinMid);
|
||||
|
||||
// Lab coat - white with neon trim
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.white);
|
||||
|
||||
// Coat buttons - neon
|
||||
pixel(ctx, baseX, baseY, 7, 7, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, 7, COLORS.neonGreen);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.skinDark);
|
||||
|
||||
// Hands
|
||||
pixel(ctx, baseX, baseY, 4, 8 + wobble, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 11, 8 + wobble, COLORS.skinLight);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.gray);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.black);
|
||||
|
||||
} else if (npcType === 1) {
|
||||
// Guard - helmet, armor
|
||||
// Helmet with visor
|
||||
pixel(ctx, baseX, baseY, 7, 2, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 2, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 6, 3, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 3, COLORS.metalLight);
|
||||
|
||||
// Face hidden by visor
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.black);
|
||||
|
||||
// Armor - angular, metallic
|
||||
pixel(ctx, baseX, baseY, 5, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 6, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.metalDark);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.metalDark);
|
||||
pixel(ctx, baseX, baseY, 9, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 10, 5, COLORS.metalLight);
|
||||
|
||||
// Chest plate
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonPurple);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonPurple);
|
||||
|
||||
// Arms - armored
|
||||
pixel(ctx, baseX, baseY, 4, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 11, 6, COLORS.metalLight);
|
||||
|
||||
// Gauntlets - neon edge
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonCyan);
|
||||
|
||||
// Legs - armored
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.metalLight);
|
||||
|
||||
// Boots
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonOrange);
|
||||
|
||||
} else if (npcType === 2) {
|
||||
// Merchant - fancy outfit, hat
|
||||
// Hat
|
||||
pixel(ctx, baseX, baseY, 6, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 6, 2, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 2, COLORS.neonPink);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
|
||||
|
||||
// Mustache (fancy)
|
||||
pixel(ctx, baseX, baseY, 6, 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, 4, COLORS.darkGray);
|
||||
|
||||
// Fancy jacket - colorful
|
||||
pixel(ctx, baseX, baseY, 5, 5, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 6, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 9, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 10, 5, COLORS.neonOrange);
|
||||
|
||||
// Vest with gems
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonGreen);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 4, 6, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, 6, COLORS.skinDark);
|
||||
|
||||
// Rings on fingers - neon
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonOrange);
|
||||
|
||||
// Legs - fancy pants
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.blue);
|
||||
|
||||
// Fancy shoes
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonPink);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw interaction prompt
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
*/
|
||||
export function drawInteractionPrompt(ctx, x, y) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
ctx.font = `bold ${12 * SCALE}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('E', baseX + 8 * SCALE, baseY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a dialog box at the bottom of the screen
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} canvasWidth - Canvas width in pixels
|
||||
* @param {number} canvasHeight - Canvas height in pixels
|
||||
* @param {string} text - Dialog text
|
||||
* @param {string} speakerName - NPC name
|
||||
*/
|
||||
export function drawDialogBox(ctx, canvasWidth, canvasHeight, text, speakerName) {
|
||||
const padding = 20;
|
||||
const boxHeight = 120;
|
||||
const boxY = canvasHeight - boxHeight - padding;
|
||||
const boxX = padding;
|
||||
const boxWidth = canvasWidth - 2 * padding;
|
||||
|
||||
// Dialog box background
|
||||
ctx.fillStyle = COLORS.black;
|
||||
ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
// Neon border
|
||||
ctx.strokeStyle = COLORS.neonCyan;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
// Corner accents
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
const cornerSize = 10;
|
||||
ctx.fillRect(boxX, boxY, cornerSize, 3);
|
||||
ctx.fillRect(boxX, boxY, 3, cornerSize);
|
||||
ctx.fillRect(boxX + boxWidth - cornerSize, boxY, cornerSize, 3);
|
||||
ctx.fillRect(boxX + boxWidth - 3, boxY, 3, cornerSize);
|
||||
|
||||
// Speaker name - neon green
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
ctx.font = `bold 16px monospace`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(speakerName, boxX + 15, boxY + 10);
|
||||
|
||||
// Dialog text - cyan
|
||||
ctx.fillStyle = COLORS.neonCyan;
|
||||
ctx.font = `14px monospace`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
// Word wrap
|
||||
const maxWidth = boxWidth - 30;
|
||||
const lineHeight = 18;
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let lineNum = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + (line ? ' ' : '') + word;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line) {
|
||||
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
|
||||
line = word;
|
||||
lineNum++;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
|
||||
}
|
||||
|
||||
// Cursor prompt - blinking indicator
|
||||
ctx.fillStyle = COLORS.neonPink;
|
||||
ctx.font = `bold 16px monospace`;
|
||||
ctx.fillText('▼', boxX + boxWidth - 30, boxY + boxHeight - 20);
|
||||
}
|
||||
191
js/world/worldInput.js
Normal file
191
js/world/worldInput.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// worldInput.js - Keyboard input for world mode
|
||||
import { worldState, advanceDialog, startDialog } from './worldState.js';
|
||||
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
|
||||
|
||||
const keysDown = new Set();
|
||||
let interactionHandler = null;
|
||||
|
||||
export function setInteractionHandler(fn) { interactionHandler = fn; }
|
||||
|
||||
export function initWorldInput() {
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
}
|
||||
|
||||
export function destroyWorldInput() {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
keysDown.clear();
|
||||
}
|
||||
|
||||
// ---- Key handlers ----
|
||||
|
||||
function onKeyDown(e) {
|
||||
const key = e.key;
|
||||
keysDown.add(key);
|
||||
|
||||
// During dialog: advance on action keys
|
||||
if (worldState.dialog) {
|
||||
if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
|
||||
e.preventDefault();
|
||||
if (!advanceDialog()) {
|
||||
// Dialog ended
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Workshop shortcut (TAB)
|
||||
if (key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (interactionHandler) interactionHandler({ type: 'enterWorkshop' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Interaction (E / Enter / Space)
|
||||
if (key === 'e' || key === 'E' || key === 'Enter' || key === ' ') {
|
||||
e.preventDefault();
|
||||
performInteraction();
|
||||
return;
|
||||
}
|
||||
|
||||
// Movement (handled in updateMovement via keysDown)
|
||||
const dir = keyToDir(key);
|
||||
if (dir) e.preventDefault();
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
keysDown.delete(e.key);
|
||||
}
|
||||
|
||||
// ---- Direction mapping ----
|
||||
|
||||
function keyToDir(key) {
|
||||
if (key === 'ArrowUp' || key === 'w' || key === 'W') return 'up';
|
||||
if (key === 'ArrowDown' || key === 's' || key === 'S') return 'down';
|
||||
if (key === 'ArrowLeft' || key === 'a' || key === 'A') return 'left';
|
||||
if (key === 'ArrowRight' || key === 'd' || key === 'D') return 'right';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get the currently pressed direction (prioritizes most recent) */
|
||||
function getHeldDirection() {
|
||||
// Check in order of specificity
|
||||
for (const key of keysDown) {
|
||||
const dir = keyToDir(key);
|
||||
if (dir) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Movement ----
|
||||
|
||||
const MOVE_DURATION = 0.15; // seconds per tile
|
||||
|
||||
/**
|
||||
* Called each frame by the renderer.
|
||||
* Handles movement interpolation and starting new moves.
|
||||
*/
|
||||
export function updateMovement(dt) {
|
||||
const p = worldState.player;
|
||||
|
||||
if (p.moving) {
|
||||
// Advance interpolation
|
||||
p._moveProgress = (p._moveProgress || 0) + dt / MOVE_DURATION;
|
||||
|
||||
if (p._moveProgress >= 1) {
|
||||
// Snap to target
|
||||
p.x = p._targetX;
|
||||
p.y = p._targetY;
|
||||
p.px = 0;
|
||||
p.py = 0;
|
||||
p.moving = false;
|
||||
p._moveProgress = 0;
|
||||
|
||||
// Check map exit
|
||||
checkMapExit();
|
||||
|
||||
// Continue moving if key held
|
||||
const dir = getHeldDirection();
|
||||
if (dir) tryMove(dir);
|
||||
} else {
|
||||
// Interpolate
|
||||
p.px = (p._targetX - p._startX) * p._moveProgress;
|
||||
p.py = (p._targetY - p._startY) * p._moveProgress;
|
||||
}
|
||||
} else {
|
||||
// Not moving — check if direction key is held
|
||||
const dir = getHeldDirection();
|
||||
if (dir) tryMove(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function tryMove(direction) {
|
||||
const p = worldState.player;
|
||||
p.direction = direction;
|
||||
|
||||
let tx = p.x, ty = p.y;
|
||||
if (direction === 'up') ty--;
|
||||
else if (direction === 'down') ty++;
|
||||
else if (direction === 'left') tx--;
|
||||
else if (direction === 'right') tx++;
|
||||
|
||||
if (!isWalkable(worldState.currentMap, tx, ty)) return;
|
||||
|
||||
// Start movement
|
||||
p._startX = p.x;
|
||||
p._startY = p.y;
|
||||
p._targetX = tx;
|
||||
p._targetY = ty;
|
||||
p._moveProgress = 0;
|
||||
p.moving = true;
|
||||
}
|
||||
|
||||
// ---- Interaction ----
|
||||
|
||||
function performInteraction() {
|
||||
if (worldState.player.moving) return;
|
||||
|
||||
const p = worldState.player;
|
||||
let fx = p.x, fy = p.y;
|
||||
if (p.direction === 'up') fy--;
|
||||
else if (p.direction === 'down') fy++;
|
||||
else if (p.direction === 'left') fx--;
|
||||
else if (p.direction === 'right') fx++;
|
||||
|
||||
// NPC?
|
||||
const npc = getNPC(worldState.currentMap, fx, fy);
|
||||
if (npc && npc.dialog) {
|
||||
startDialog(npc.dialog, npc.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interaction tile?
|
||||
const inter = getInteraction(worldState.currentMap, fx, fy);
|
||||
if (!inter) return;
|
||||
|
||||
switch (inter.type) {
|
||||
case 'workshop':
|
||||
if (interactionHandler) interactionHandler({ type: 'enterWorkshop', data: inter });
|
||||
break;
|
||||
case 'puzzle_door':
|
||||
if (interactionHandler) interactionHandler({ type: 'puzzleDoor', data: inter });
|
||||
break;
|
||||
default:
|
||||
if (inter.dialog) startDialog(inter.dialog, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Map transitions ----
|
||||
|
||||
function checkMapExit() {
|
||||
const p = worldState.player;
|
||||
const exit = getExit(worldState.currentMap, p.x, p.y);
|
||||
if (exit && interactionHandler) {
|
||||
interactionHandler({
|
||||
type: 'mapExit',
|
||||
data: { targetMap: exit.targetMap, targetX: exit.targetX, targetY: exit.targetY }
|
||||
});
|
||||
}
|
||||
}
|
||||
181
js/world/worldRenderer.js
Normal file
181
js/world/worldRenderer.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
307
js/world/worldState.js
Normal file
307
js/world/worldState.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* worldState.js - World game state management
|
||||
*
|
||||
* Tracks player position, current map, dialog, inventory, puzzles, and other game state
|
||||
*/
|
||||
|
||||
// Default/initial world state
|
||||
export const worldState = {
|
||||
// Current mode
|
||||
mode: 'world', // 'world' | 'workshop' | 'dialog' | 'puzzle'
|
||||
|
||||
// Player
|
||||
player: {
|
||||
x: 10,
|
||||
y: 12, // tile position in current map
|
||||
px: 0,
|
||||
py: 0, // pixel offset for smooth movement (interpolation)
|
||||
direction: 'down', // 'up' | 'down' | 'left' | 'right'
|
||||
moving: false,
|
||||
frame: 0, // animation frame (0-3 for walking cycles)
|
||||
speed: 150 // milliseconds per tile movement
|
||||
},
|
||||
|
||||
// Map
|
||||
currentMap: 'lab',
|
||||
|
||||
// Camera
|
||||
camera: {
|
||||
x: 0,
|
||||
y: 0
|
||||
},
|
||||
|
||||
// Dialog
|
||||
dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null
|
||||
|
||||
// Inventory of crafted components
|
||||
inventory: [], // array of component IDs from customComponents (stored in circuit editor)
|
||||
|
||||
// Puzzle state
|
||||
solvedPuzzles: [], // array of puzzleIds that have been solved
|
||||
activePuzzle: null, // { puzzleId, requiredOutputs, doorX, doorY } or null when no puzzle active
|
||||
|
||||
// Game flags
|
||||
flags: {
|
||||
// Examples:
|
||||
// 'met_professor': false,
|
||||
// 'guard_talked': false,
|
||||
// 'merchant_met': false
|
||||
},
|
||||
|
||||
// Timing
|
||||
lastMoveTime: 0,
|
||||
animTimer: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset world state to initial defaults
|
||||
*/
|
||||
export function resetWorldState() {
|
||||
worldState.mode = 'world';
|
||||
worldState.player.x = 10;
|
||||
worldState.player.y = 12;
|
||||
worldState.player.px = 0;
|
||||
worldState.player.py = 0;
|
||||
worldState.player.direction = 'down';
|
||||
worldState.player.moving = false;
|
||||
worldState.player.frame = 0;
|
||||
worldState.currentMap = 'lab';
|
||||
worldState.camera.x = 0;
|
||||
worldState.camera.y = 0;
|
||||
worldState.dialog = null;
|
||||
worldState.inventory = [];
|
||||
worldState.solvedPuzzles = [];
|
||||
worldState.activePuzzle = null;
|
||||
worldState.flags = {};
|
||||
worldState.lastMoveTime = 0;
|
||||
worldState.animTimer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player is currently in movement animation
|
||||
*/
|
||||
export function isPlayerMoving() {
|
||||
return worldState.player.moving;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set player position and reset movement state
|
||||
*/
|
||||
export function setPlayerPosition(x, y) {
|
||||
worldState.player.x = x;
|
||||
worldState.player.y = y;
|
||||
worldState.player.px = 0;
|
||||
worldState.player.py = 0;
|
||||
worldState.player.moving = false;
|
||||
worldState.player.frame = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dialog sequence
|
||||
*/
|
||||
export function startDialog(lines, speakerName = '') {
|
||||
worldState.dialog = {
|
||||
lines: Array.isArray(lines) ? lines : [lines],
|
||||
currentLine: 0,
|
||||
speakerName: speakerName
|
||||
};
|
||||
worldState.mode = 'dialog';
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance dialog to next line
|
||||
* Returns false when dialog sequence ends and should be closed
|
||||
*/
|
||||
export function advanceDialog() {
|
||||
if (!worldState.dialog) return false;
|
||||
|
||||
worldState.dialog.currentLine++;
|
||||
|
||||
// Dialog finished
|
||||
if (worldState.dialog.currentLine >= worldState.dialog.lines.length) {
|
||||
worldState.dialog = null;
|
||||
worldState.mode = 'world';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current dialog line text
|
||||
*/
|
||||
export function getCurrentDialogLine() {
|
||||
if (!worldState.dialog) return '';
|
||||
return worldState.dialog.lines[worldState.dialog.currentLine] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add component to inventory
|
||||
*/
|
||||
export function addToInventory(componentId) {
|
||||
if (!worldState.inventory.includes(componentId)) {
|
||||
worldState.inventory.push(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove component from inventory
|
||||
*/
|
||||
export function removeFromInventory(componentId) {
|
||||
const idx = worldState.inventory.indexOf(componentId);
|
||||
if (idx !== -1) {
|
||||
worldState.inventory.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component is in inventory
|
||||
*/
|
||||
export function hasInInventory(componentId) {
|
||||
return worldState.inventory.includes(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a puzzle as solved
|
||||
*/
|
||||
export function solvePuzzle(puzzleId) {
|
||||
if (!worldState.solvedPuzzles.includes(puzzleId)) {
|
||||
worldState.solvedPuzzles.push(puzzleId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a puzzle has been solved
|
||||
*/
|
||||
export function isPuzzleSolved(puzzleId) {
|
||||
return worldState.solvedPuzzles.includes(puzzleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active puzzle that player is attempting
|
||||
*/
|
||||
export function setActivePuzzle(puzzleId, requiredOutputs, doorX, doorY) {
|
||||
worldState.activePuzzle = {
|
||||
puzzleId: puzzleId,
|
||||
requiredOutputs: requiredOutputs,
|
||||
doorX: doorX,
|
||||
doorY: doorY
|
||||
};
|
||||
worldState.mode = 'puzzle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active puzzle
|
||||
*/
|
||||
export function clearActivePuzzle() {
|
||||
worldState.activePuzzle = null;
|
||||
worldState.mode = 'world';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active puzzle
|
||||
*/
|
||||
export function getActivePuzzle() {
|
||||
return worldState.activePuzzle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a game flag
|
||||
*/
|
||||
export function setFlag(key, value) {
|
||||
worldState.flags[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a game flag
|
||||
*/
|
||||
export function getFlag(key, defaultValue = false) {
|
||||
return worldState.flags[key] !== undefined ? worldState.flags[key] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a flag is true
|
||||
*/
|
||||
export function isFlagSet(key) {
|
||||
return getFlag(key) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move player by tile offset (for movement updates)
|
||||
* Returns true if movement started, false if blocked
|
||||
*/
|
||||
export function movePlayer(dx, dy, isWalkable) {
|
||||
if (worldState.player.moving) return false;
|
||||
|
||||
const newX = worldState.player.x + dx;
|
||||
const newY = worldState.player.y + dy;
|
||||
|
||||
// Check if new position is walkable
|
||||
if (!isWalkable(newX, newY)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update direction
|
||||
if (dx > 0) worldState.player.direction = 'right';
|
||||
if (dx < 0) worldState.player.direction = 'left';
|
||||
if (dy > 0) worldState.player.direction = 'down';
|
||||
if (dy < 0) worldState.player.direction = 'up';
|
||||
|
||||
// Start movement animation
|
||||
worldState.player.x = newX;
|
||||
worldState.player.y = newY;
|
||||
worldState.player.moving = true;
|
||||
worldState.player.frame = 0;
|
||||
worldState.lastMoveTime = Date.now();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player movement animation
|
||||
* Call this in game loop, delta is time elapsed in ms
|
||||
*/
|
||||
export function updatePlayerAnimation(delta) {
|
||||
if (!worldState.player.moving) return;
|
||||
|
||||
const elapsed = Date.now() - worldState.lastMoveTime;
|
||||
const progress = Math.min(elapsed / worldState.player.speed, 1);
|
||||
|
||||
// Update pixel offset for smooth movement
|
||||
const tileSize = 32; // Assuming 32x32 tiles
|
||||
worldState.player.px = (worldState.player.direction === 'right' ? 1 : worldState.player.direction === 'left' ? -1 : 0) * tileSize * progress;
|
||||
worldState.player.py = (worldState.player.direction === 'down' ? 1 : worldState.player.direction === 'up' ? -1 : 0) * tileSize * progress;
|
||||
|
||||
// Update animation frame
|
||||
worldState.player.frame = Math.floor(progress * 4) % 4;
|
||||
|
||||
// Movement complete
|
||||
if (progress >= 1) {
|
||||
worldState.player.moving = false;
|
||||
worldState.player.px = 0;
|
||||
worldState.player.py = 0;
|
||||
worldState.player.frame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warp player to a new map and position
|
||||
*/
|
||||
export function warpToMap(mapId, x, y) {
|
||||
worldState.currentMap = mapId;
|
||||
setPlayerPosition(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete world state snapshot (for debugging/saving)
|
||||
*/
|
||||
export function getWorldStateSnapshot() {
|
||||
return JSON.parse(JSON.stringify(worldState));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load world state from snapshot
|
||||
*/
|
||||
export function loadWorldStateSnapshot(snapshot) {
|
||||
Object.assign(worldState, JSON.parse(JSON.stringify(snapshot)));
|
||||
}
|
||||
Reference in New Issue
Block a user