Compare commits
13 Commits
main
...
f8aa4e2eab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aa4e2eab | ||
|
|
f740d96fc0 | ||
|
|
1d494d8ef3 | ||
|
|
b60edc49af | ||
|
|
eee405d5d9 | ||
|
|
943ba0b51c | ||
|
|
71321e8e88 | ||
|
|
bf34879390 | ||
|
|
9b2a25856e | ||
|
|
75001e10e7 | ||
|
|
bc9786ce49 | ||
|
|
c836ccbb21 | ||
|
|
e4cf35701e |
14
Dockerfile
@@ -1,5 +1,11 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY css/ /usr/share/nginx/html/css/
|
||||
COPY js/ /usr/share/nginx/html/js/
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY server.js .
|
||||
RUN mkdir -p public
|
||||
COPY index.html public/
|
||||
COPY editor.html public/
|
||||
COPY css/ public/css/
|
||||
COPY js/ public/js/
|
||||
COPY assets/ public/assets/
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
BIN
assets/character/back-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/back-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/character/back-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/map/house-a-1f.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/lab.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/pallet-town.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/map/route-1.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/npcs/a-down.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/npcs/a-left.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-right.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-up.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
1309
editor.html
Normal file
@@ -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>
|
||||
|
||||
|
||||
30
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', () => {
|
||||
// Register circuit editor init/destroy so gameMode can switch to workshop
|
||||
registerCircuitEditor(
|
||||
// init workshop
|
||||
() => {
|
||||
initRenderer();
|
||||
initEvents();
|
||||
initPuzzleUI();
|
||||
|
||||
// Restore previous session from localStorage
|
||||
if (loadFromStorage()) {
|
||||
updateComponentButtons();
|
||||
evaluateAll();
|
||||
}
|
||||
|
||||
// Auto-save every 3 seconds + on page unload
|
||||
startAutoSave(3000);
|
||||
},
|
||||
// destroy workshop (cleanup when switching back to world)
|
||||
() => {
|
||||
// Auto-save is fine to leave running
|
||||
}
|
||||
);
|
||||
|
||||
// Add back-to-world button handler
|
||||
const backBtn = document.getElementById('back-to-world-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
enterWorldMode();
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (!rendererInitialized) {
|
||||
window.addEventListener('resize', resize);
|
||||
requestAnimationFrame(draw);
|
||||
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);
|
||||
}
|
||||
|
||||
175
js/world/gameMode.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// gameMode.js - Central coordinator: switches between World and Workshop modes
|
||||
import { worldState, setPlayerPosition, warpToMap, 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': {
|
||||
// Every exit MUST have targetX/targetY — bidirectional door links.
|
||||
// No spawn fallback. Spawn is only for the initial game start.
|
||||
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';
|
||||
}
|
||||
192
js/world/maps.js
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* maps.js - PNG-based world maps (auto-generated by Level Editor)
|
||||
*/
|
||||
|
||||
function buildWallSet(wallData) {
|
||||
const set = new Set();
|
||||
for (const [row, cols] of Object.entries(wallData)) {
|
||||
for (const col of cols) set.add(col + ',' + row);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function r(a, b) { const arr = []; for (let i = a; i <= b; i++) arr.push(i); return arr; }
|
||||
|
||||
// ==================== Circuit Lab ====================
|
||||
|
||||
const labWalls = {
|
||||
0: [...r(0,9)],
|
||||
1: [0,1,2,3,6,7,8,9],
|
||||
3: [6,7,8],
|
||||
6: [0,1,2,3,6,7,8,9],
|
||||
7: [0,1,2,3,6,7,8,9],
|
||||
};
|
||||
|
||||
const labMap = {
|
||||
id: 'lab',
|
||||
name: 'Circuit Lab',
|
||||
image: 'map:lab',
|
||||
widthTiles: 10,
|
||||
heightTiles: 12,
|
||||
// No spawn — player enters via door from town
|
||||
wallSet: buildWallSet(labWalls),
|
||||
|
||||
exits: [
|
||||
// Bidirectional: these doors return to the specific town door (12,12 = tile in front of lab entrance)
|
||||
{ x: 4, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
|
||||
{ x: 5, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{ id: 'professor', x: 5, y: 1, facing: 'down', dialog: ["Welcome to the Circuit Lab!","I\"m the Professor. We study logic gates here.","Use the workshop tables to design circuits.","Press TAB to open the Workshop anytime!"] },
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 7, y: 3, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 8, y: 3, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 6, y: 3, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 1, y: 7, type: 'sign', label: 'Bookshelf', dialog: ["A collection of logic circuit manuals."] },
|
||||
{ x: 7, y: 7, type: 'sign', label: 'Bookshelf', dialog: ["Advanced boolean algebra textbooks."] },
|
||||
{ x: 0, y: 1, type: 'terminal', label: 'Terminal', dialog: ["Circuit analysis terminal.","Connect components to solve puzzles."] },
|
||||
]
|
||||
};
|
||||
|
||||
// ==================== Neon Town ====================
|
||||
|
||||
const palletTownWalls = {
|
||||
0: [...r(0,19)],
|
||||
1: [0,1,2,3,4,5,6,7,8,9,10,11,18,19],
|
||||
2: [0,19],
|
||||
3: [0,4,5,6,7,12,13,14,15,19],
|
||||
4: [0,4,5,6,7,12,13,14,15,19],
|
||||
5: [0,3,4,5,6,7,11,12,13,14,15,19],
|
||||
6: [0,19],
|
||||
7: [0,19],
|
||||
8: [0,10,11,12,13,14,15,19],
|
||||
9: [0,4,5,6,7,10,11,12,13,14,15,19],
|
||||
10: [0,10,11,12,13,14,15,19],
|
||||
11: [0,10,11,13,14,15,19],
|
||||
12: [0,19],
|
||||
13: [0,10,11,12,13,14,15,19],
|
||||
14: [0,19],
|
||||
15: [0,19],
|
||||
16: [0,19],
|
||||
17: [0,1,8,9,10,11,12,13,14,15,16,17,18,19],
|
||||
};
|
||||
|
||||
const palletTownMap = {
|
||||
id: 'town',
|
||||
name: 'Neon Town',
|
||||
image: 'map:pallet-town',
|
||||
widthTiles: 20,
|
||||
heightTiles: 18,
|
||||
spawn: { x: 12, y: 12 },
|
||||
wallSet: buildWallSet(palletTownWalls),
|
||||
|
||||
exits: [
|
||||
{ x: 12, y: 11, targetMap: 'lab', targetX: 4, targetY: 10 },
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{ id: 'merchant', x: 8, y: 10, facing: 'right', dialog: ["Welcome to Neon Town!","I trade in rare logic components."] },
|
||||
{ id: 'guide', x: 11, y: 12, facing: 'down', dialog: ["The Circuit Lab is in the big building up north.","Press TAB anytime to open your Workshop."] },
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 3, y: 5, type: 'door', label: 'House', dialog: ["The door is locked."] },
|
||||
{ x: 7, y: 9, type: 'sign', label: 'Sign', dialog: ["Welcome to Neon Town!","Circuit Lab ↑"] },
|
||||
{ x: 11, y: 5, type: 'sign', label: 'Sign', dialog: ["CIRCUIT LAB","Open for research!"] },
|
||||
]
|
||||
};
|
||||
|
||||
// ==================== House Interior ====================
|
||||
|
||||
const houseA1fWalls = {
|
||||
};
|
||||
|
||||
const houseA1fMap = {
|
||||
id: 'house-a-1f',
|
||||
name: 'House Interior',
|
||||
image: 'map:house-a-1f',
|
||||
widthTiles: 8,
|
||||
heightTiles: 8,
|
||||
wallSet: buildWallSet(houseA1fWalls),
|
||||
|
||||
exits: [
|
||||
],
|
||||
|
||||
npcs: [
|
||||
],
|
||||
|
||||
interactions: [
|
||||
]
|
||||
};
|
||||
|
||||
// ==================== Route 1 ====================
|
||||
|
||||
const route1Walls = {
|
||||
};
|
||||
|
||||
const route1Map = {
|
||||
id: 'route-1',
|
||||
name: 'Route 1',
|
||||
image: 'map:route-1',
|
||||
widthTiles: 20,
|
||||
heightTiles: 36,
|
||||
wallSet: buildWallSet(route1Walls),
|
||||
|
||||
exits: [
|
||||
],
|
||||
|
||||
npcs: [
|
||||
],
|
||||
|
||||
interactions: [
|
||||
]
|
||||
};
|
||||
|
||||
// ==================== Registry ====================
|
||||
|
||||
const maps = {
|
||||
'lab': labMap,
|
||||
'town': palletTownMap,
|
||||
'house-a-1f': houseA1fMap,
|
||||
'route-1': route1Map,
|
||||
};
|
||||
|
||||
export function getMap(id) { return maps[id] || null; }
|
||||
|
||||
export function isWall(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return true;
|
||||
if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;
|
||||
return map.wallSet.has(x + ',' + y);
|
||||
}
|
||||
|
||||
export function isWalkable(mapId, x, y) {
|
||||
if (isWall(mapId, x, y)) return false;
|
||||
if (getNPC(mapId, x, y)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getInteraction(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.interactions.find(i => i.x === x && i.y === y) || null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function getExit(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.exits.find(e => e.x === x && e.y === y) || null;
|
||||
}
|
||||
|
||||
export function getTile(mapId, x, y) { return isWall(mapId, x, y) ? 1 : 0; }
|
||||
|
||||
export { maps };
|
||||
242
js/world/sprites.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// sprites.js - PNG image-based sprite system
|
||||
// Uses pre-rendered assets from assets/ directory
|
||||
// 16px native tile size, 3x scale for screen rendering
|
||||
|
||||
export const TILE = 16;
|
||||
export const SCALE = 3;
|
||||
export const TILE_PX = TILE * SCALE; // 48px on screen
|
||||
|
||||
// Also export as TILE_SIZE for backward compat
|
||||
export const TILE_SIZE = TILE;
|
||||
|
||||
// ==================== Image cache ====================
|
||||
|
||||
const imageCache = {};
|
||||
let assetsLoaded = false;
|
||||
let onAssetsReady = null;
|
||||
|
||||
function loadImage(key, src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (imageCache[key]) { resolve(imageCache[key]); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => { imageCache[key] = img; resolve(img); };
|
||||
img.onerror = () => { console.warn(`[sprites] failed to load: ${src}`); resolve(null); };
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export function getImage(key) {
|
||||
return imageCache[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all game assets. Returns a promise that resolves when done.
|
||||
*/
|
||||
export async function preloadAssets() {
|
||||
if (assetsLoaded) return;
|
||||
|
||||
const loads = [];
|
||||
|
||||
// Resolve asset base path relative to the HTML document
|
||||
const base = new URL('.', document.baseURI).href;
|
||||
|
||||
// Map backgrounds
|
||||
loads.push(loadImage('map:lab', `${base}assets/map/lab.png`));
|
||||
loads.push(loadImage('map:pallet-town', `${base}assets/map/pallet-town.png`));
|
||||
loads.push(loadImage('map:house-a-1f', `${base}assets/map/house-a-1f.png`));
|
||||
loads.push(loadImage('map:route-1', `${base}assets/map/route-1.png`));
|
||||
|
||||
// Character sprites (32x32 each)
|
||||
const dirs = ['front', 'back', 'left', 'right'];
|
||||
const frames = ['still', 'walk-1', 'walk-2'];
|
||||
for (const dir of dirs) {
|
||||
for (const frame of frames) {
|
||||
const key = `char:${dir}-${frame}`;
|
||||
loads.push(loadImage(key, `${base}assets/character/${dir}-${frame}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
// NPC sprites (16x16 each)
|
||||
const npcDirs = ['down', 'up', 'left', 'right'];
|
||||
for (const d of npcDirs) {
|
||||
loads.push(loadImage(`npc:a-${d}`, `${base}assets/npcs/a-${d}.png`));
|
||||
}
|
||||
|
||||
await Promise.all(loads);
|
||||
assetsLoaded = true;
|
||||
console.log('[sprites] all assets loaded');
|
||||
}
|
||||
|
||||
// ==================== Direction mapping ====================
|
||||
|
||||
// Map game direction to character sprite prefix
|
||||
const DIR_TO_SPRITE = {
|
||||
down: 'front',
|
||||
up: 'back',
|
||||
left: 'left',
|
||||
right: 'right'
|
||||
};
|
||||
|
||||
// Map game direction to NPC sprite suffix
|
||||
const DIR_TO_NPC = {
|
||||
down: 'down',
|
||||
up: 'up',
|
||||
left: 'left',
|
||||
right: 'right'
|
||||
};
|
||||
|
||||
// ==================== Drawing functions ====================
|
||||
|
||||
/**
|
||||
* Draw a map background image
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} mapImageKey - key in imageCache (e.g. 'map:lab')
|
||||
* @param {number} offsetX - pixel offset for camera
|
||||
* @param {number} offsetY - pixel offset for camera
|
||||
*/
|
||||
export function drawMapImage(ctx, mapImageKey, offsetX, offsetY) {
|
||||
const img = imageCache[mapImageKey];
|
||||
if (!img) return;
|
||||
// Draw scaled: native pixels * SCALE
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, offsetX, offsetY, img.width * SCALE, img.height * SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the player character
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} screenX - top-left X on screen
|
||||
* @param {number} screenY - top-left Y on screen
|
||||
* @param {string} direction - 'up'|'down'|'left'|'right'
|
||||
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
|
||||
*/
|
||||
export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) {
|
||||
const spriteDir = DIR_TO_SPRITE[direction] || 'front';
|
||||
const frameName = walkFrame === 0 ? 'still' : walkFrame === 1 ? 'walk-1' : 'walk-2';
|
||||
const key = `char:${spriteDir}-${frameName}`;
|
||||
const img = imageCache[key];
|
||||
if (!img) {
|
||||
// Fallback: colored rectangle
|
||||
ctx.fillStyle = '#00e599';
|
||||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX * 2);
|
||||
return;
|
||||
}
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// Character is 32x32 native but represents a 1-tile-wide, 2-tile-tall entity
|
||||
// Draw at TILE_PX wide x TILE_PX tall (square, matching NPC size on grid)
|
||||
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an NPC
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} screenX - top-left X on screen
|
||||
* @param {number} screenY - top-left Y on screen
|
||||
* @param {string} facing - 'up'|'down'|'left'|'right'
|
||||
*/
|
||||
export function drawNPC(ctx, screenX, screenY, facing) {
|
||||
const dir = DIR_TO_NPC[facing] || 'down';
|
||||
const key = `npc:a-${dir}`;
|
||||
const img = imageCache[key];
|
||||
if (!img) {
|
||||
// Fallback
|
||||
ctx.fillStyle = '#ff44aa';
|
||||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
|
||||
return;
|
||||
}
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// NPC is 16x16 native = 1 tile
|
||||
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the interaction prompt (E button hint) above a tile
|
||||
*/
|
||||
export function drawInteractionPrompt(ctx, screenX, screenY) {
|
||||
const cx = screenX + TILE_PX / 2;
|
||||
const cy = screenY - 12;
|
||||
|
||||
// Bubble background
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#00e599';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||||
ctx.stroke();
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('[E]', cx, cy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the dialog box at the bottom of the screen
|
||||
*/
|
||||
export function drawDialogBox(ctx, canvasW, canvasH, text, speakerName) {
|
||||
const boxH = 100;
|
||||
const boxY = canvasH - boxH - 16;
|
||||
const boxX = 32;
|
||||
const boxW = canvasW - 64;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(10, 14, 39, 0.92)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#00e599';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||||
ctx.stroke();
|
||||
|
||||
// Speaker name
|
||||
if (speakerName) {
|
||||
ctx.fillStyle = '#00e599';
|
||||
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(speakerName, boxX + 16, boxY + 12);
|
||||
}
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
const textY = speakerName ? boxY + 34 : boxY + 16;
|
||||
// Simple word wrap
|
||||
wrapText(ctx, text, boxX + 16, textY, boxW - 32, 20);
|
||||
|
||||
// Continue prompt
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('Press E to continue ▶', boxX + boxW - 16, boxY + boxH - 16);
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
for (const word of words) {
|
||||
const test = line + (line ? ' ' : '') + word;
|
||||
if (ctx.measureText(test).width > maxWidth && line) {
|
||||
ctx.fillText(line, x, currentY);
|
||||
line = word;
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) ctx.fillText(line, x, currentY);
|
||||
}
|
||||
199
js/world/worldInput.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// worldInput.js - Keyboard input for world mode
|
||||
import { worldState, advanceDialog, startDialog } from './worldState.js';
|
||||
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
|
||||
import { toggleDebug } from './worldRenderer.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;
|
||||
}
|
||||
|
||||
// Debug overlay toggle (F3)
|
||||
if (key === 'F3') {
|
||||
e.preventDefault();
|
||||
toggleDebug();
|
||||
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
289
js/world/worldRenderer.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 | 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;
|
||||
}
|
||||
}
|
||||
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: 4,
|
||||
y: 10, // 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 = 4;
|
||||
worldState.player.y = 10;
|
||||
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)));
|
||||
}
|
||||
131
server.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Lightweight static file server + editor API for saving maps.js
|
||||
// Used in production Docker container
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const STATIC_DIR = path.join(__dirname, 'public');
|
||||
const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers for editor
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// === API: GET /api/maps — read maps.js source ===
|
||||
if (req.method === 'GET' && req.url === '/api/maps') {
|
||||
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ content: data }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// === API: PUT /api/maps — write maps.js source ===
|
||||
if (req.method === 'PUT' && req.url === '/api/maps') {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { content } = JSON.parse(body);
|
||||
if (!content || typeof content !== 'string') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing content field' }));
|
||||
return;
|
||||
}
|
||||
// Backup before overwrite
|
||||
const backup = MAPS_FILE + '.bak';
|
||||
if (fs.existsSync(MAPS_FILE)) {
|
||||
fs.copyFileSync(MAPS_FILE, backup);
|
||||
}
|
||||
fs.writeFileSync(MAPS_FILE, content, 'utf-8');
|
||||
console.log(`[server] maps.js saved (${content.length} bytes)`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, bytes: content.length }));
|
||||
} catch (e) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// === API: GET /api/maps/json — parse current map data as JSON ===
|
||||
if (req.method === 'GET' && req.url === '/api/maps/json') {
|
||||
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
|
||||
return;
|
||||
}
|
||||
// Extract JSON-serializable data from the JS source
|
||||
// This is a best-effort parser for the generated maps.js format
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ source: data }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// === Static file serving ===
|
||||
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
|
||||
// Prevent directory traversal
|
||||
if (!filePath.startsWith(STATIC_DIR)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME[ext] || 'application/octet-stream';
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end('Server error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cache static assets
|
||||
if (ext === '.png' || ext === '.jpg' || ext === '.woff2') {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[server] Logic Gates running on port ${PORT}`);
|
||||
console.log(`[server] Static: ${STATIC_DIR}`);
|
||||
console.log(`[server] Maps file: ${MAPS_FILE}`);
|
||||
});
|
||||