Add drag & drop spritesheet upload in editor, character registry in sprites.js, character selector for NPCs, sprite rendering on editor canvas, server API for character persistence, and game-side character loading via characterLoader.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// gameMode.js - Central coordinator: switches between World and Workshop modes
|
|
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle, startDialog } from './worldState.js';
|
|
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
|
|
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
|
|
import { getMap } from './maps.js';
|
|
import { saveGadget, openBackpack, getGadgets, openNamingScreen, showNotification } from './inventory.js';
|
|
import { openWiringPanel } from './wiringPanel.js';
|
|
import { loadCharacters } from './characterLoader.js';
|
|
|
|
// Circuit editor stop function (to stop its render loop when switching modes)
|
|
import { stopCircuitLoop } from '../renderer.js';
|
|
import { state as circuitState } from '../state.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 async function startGame() {
|
|
// Load character spritesheets before entering world
|
|
await loadCharacters();
|
|
|
|
// Set spawn
|
|
const map = getMap(worldState.currentMap);
|
|
if (map && map.spawn) {
|
|
setPlayerPosition(map.spawn.x, map.spawn.y);
|
|
}
|
|
|
|
// Wire up interaction handler
|
|
setInteractionHandler(handleInteraction);
|
|
|
|
// Wire save-gadget button
|
|
const saveGadgetBtn = document.getElementById('save-gadget-btn');
|
|
if (saveGadgetBtn) {
|
|
saveGadgetBtn.addEventListener('click', handleSaveGadget);
|
|
}
|
|
|
|
// 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)) {
|
|
startDialog(['This door is already unlocked.'], 'System');
|
|
return;
|
|
}
|
|
// Open backpack to let player choose a gadget
|
|
const gadgets = getGadgets();
|
|
if (gadgets.length === 0) {
|
|
startDialog([
|
|
'This door requires a logic circuit to open.',
|
|
`Required output: [${inter.requiredOutputs.join(', ')}]`,
|
|
'Craft a circuit in your Workshop (TAB) and save it as a gadget!'
|
|
], 'System');
|
|
return;
|
|
}
|
|
// Open backpack with a "use" callback that tests the gadget
|
|
openBackpack((gadget) => {
|
|
const result = testGadgetOnPuzzle(gadget, inter);
|
|
if (result) {
|
|
solvePuzzle(inter.puzzleId);
|
|
startDialog([
|
|
`🎉 "${gadget.name}" solved the puzzle!`,
|
|
'The door unlocks with a satisfying click.'
|
|
], 'System');
|
|
} else {
|
|
startDialog([
|
|
`"${gadget.name}" didn't produce the right output.`,
|
|
`Required: [${inter.requiredOutputs.join(', ')}]`,
|
|
'Try a different gadget or tweak your circuit!'
|
|
], 'System');
|
|
}
|
|
});
|
|
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 'module': {
|
|
const inter = event.data;
|
|
// Already solved?
|
|
if (inter.moduleId && isPuzzleSolved(inter.moduleId)) {
|
|
startDialog(['This module is already unlocked.'], 'System');
|
|
return;
|
|
}
|
|
// Need gadgets
|
|
const mGadgets = getGadgets();
|
|
if (mGadgets.length === 0) {
|
|
const portDesc = (inter.ports || []).map(p => `${p.name} (${p.dir})`).join(', ');
|
|
startDialog([
|
|
`This module requires a gadget to operate.`,
|
|
`Ports: ${portDesc}`,
|
|
'Craft a circuit in your Workshop (TAB) and save it as a gadget!'
|
|
], 'System');
|
|
return;
|
|
}
|
|
// Open backpack → on "Use", open wiring panel
|
|
openBackpack((gadget) => {
|
|
openWiringPanel(inter, gadget);
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'openInventory':
|
|
// TODO: inventory UI
|
|
console.log('[gameMode] inventory:', worldState.inventory);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ==================== Save Gadget ====================
|
|
|
|
function handleSaveGadget() {
|
|
// Gather current circuit from the editor state
|
|
const gates = circuitState.gates;
|
|
const connections = circuitState.connections;
|
|
|
|
const inputGates = gates.filter(g => g.type === 'INPUT');
|
|
const outputGates = gates.filter(g => g.type === 'OUTPUT');
|
|
|
|
if (inputGates.length === 0 || outputGates.length === 0) {
|
|
showNotification('Need at least 1 INPUT and 1 OUTPUT!', '⚠️', '#ff5555');
|
|
return;
|
|
}
|
|
|
|
// Switch to world render temporarily to show the naming screen on canvas
|
|
// (workshop mode uses its own render loop, so we overlay on the canvas)
|
|
openNamingScreen(
|
|
'🎒 Name your gadget',
|
|
`Gadget ${getGadgets().length + 1}`,
|
|
(name) => {
|
|
if (!name) return; // cancelled
|
|
|
|
const component = {
|
|
id: name.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_'),
|
|
name,
|
|
inputCount: inputGates.length,
|
|
outputCount: outputGates.length,
|
|
inputIds: inputGates.map(g => g.id),
|
|
outputIds: outputGates.map(g => g.id),
|
|
gates: JSON.parse(JSON.stringify(gates)),
|
|
connections: JSON.parse(JSON.stringify(connections))
|
|
};
|
|
|
|
const result = saveGadget(component);
|
|
if (result.success) {
|
|
showNotification(`"${name}" saved to backpack!`, '🎒', '#ff44aa');
|
|
} else {
|
|
showNotification(result.error, '⚠️', '#ff5555');
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// ==================== Puzzle testing ====================
|
|
|
|
/**
|
|
* Test a gadget against a puzzle door's required outputs.
|
|
* Runs the gadget's internal circuit with all possible input combos
|
|
* and checks if outputs match.
|
|
*/
|
|
function testGadgetOnPuzzle(gadget, puzzleInter) {
|
|
const required = puzzleInter.requiredOutputs;
|
|
if (!required || !gadget.gates || !gadget.connections) return false;
|
|
|
|
// Clone internal circuit for evaluation
|
|
const gates = JSON.parse(JSON.stringify(gadget.gates));
|
|
const conns = gadget.connections;
|
|
const inputIds = gadget.inputIds || [];
|
|
const outputIds = gadget.outputIds || [];
|
|
|
|
// Test with all inputs = 0, then all = 1, etc.
|
|
// For simplicity: test all 2^n input combos and collect outputs for each
|
|
const n = inputIds.length;
|
|
const allOutputs = [];
|
|
|
|
for (let combo = 0; combo < (1 << n); combo++) {
|
|
// Reset gates
|
|
const evalGates = JSON.parse(JSON.stringify(gates));
|
|
|
|
// Set inputs
|
|
for (let i = 0; i < n; i++) {
|
|
const inputGate = evalGates.find(g => g.id === inputIds[i]);
|
|
if (inputGate) inputGate.value = (combo >> i) & 1;
|
|
}
|
|
|
|
// Evaluate (fixed-point iteration)
|
|
for (let iter = 0; iter < 20; iter++) {
|
|
let changed = false;
|
|
for (const g of evalGates) {
|
|
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
|
|
const inCount = g.type === 'NOT' || g.type === 'OUTPUT' ? 1 : 2;
|
|
const gInputs = [];
|
|
for (let j = 0; j < inCount; j++) {
|
|
const conn = conns.find(c => c.to === g.id && c.toPort === j);
|
|
if (conn) {
|
|
const src = evalGates.find(s => s.id === conn.from);
|
|
gInputs.push(src ? (src.value || 0) : 0);
|
|
} else {
|
|
gInputs.push(0);
|
|
}
|
|
}
|
|
let result = 0;
|
|
switch (g.type) {
|
|
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
|
|
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
|
|
case 'NOT': result = gInputs[0] ? 0 : 1; break;
|
|
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
|
|
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
|
|
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
|
|
case 'OUTPUT': result = gInputs[0] || 0; break;
|
|
default: result = 0;
|
|
}
|
|
if (result !== g.value) { g.value = result; changed = true; }
|
|
}
|
|
if (!changed) break;
|
|
}
|
|
|
|
// Collect outputs
|
|
for (const outId of outputIds) {
|
|
const outGate = evalGates.find(g => g.id === outId);
|
|
allOutputs.push(outGate ? (outGate.value || 0) : 0);
|
|
}
|
|
}
|
|
|
|
// Compare: the required outputs should match the output pattern
|
|
// Simple comparison: check if required matches any combo's outputs
|
|
// Or: required is the full truth table (outputs for combo 0, then combo 1, etc.)
|
|
if (allOutputs.length === required.length) {
|
|
return allOutputs.every((v, i) => v === required[i]);
|
|
}
|
|
|
|
// Fallback: check if any single combo's outputs match
|
|
const outPerCombo = outputIds.length;
|
|
for (let combo = 0; combo < (1 << n); combo++) {
|
|
const comboOutputs = allOutputs.slice(combo * outPerCombo, (combo + 1) * outPerCombo);
|
|
if (comboOutputs.length === required.length && comboOutputs.every((v, i) => v === required[i])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ==================== 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';
|
|
}
|
|
|
|
// Hide game buttons (we're IN world, not workshop)
|
|
const backBtn = document.getElementById('back-to-world-btn');
|
|
if (backBtn) backBtn.style.display = 'none';
|
|
const saveBtn = document.getElementById('save-gadget-btn');
|
|
if (saveBtn) saveBtn.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';
|
|
}
|
|
|
|
// Move game buttons into toolbar-right so they sit inline with Export/Import
|
|
const toolbarRight = document.querySelector('.toolbar-right');
|
|
const saveBtn = document.getElementById('save-gadget-btn');
|
|
const backBtn = document.getElementById('back-to-world-btn');
|
|
if (toolbarRight && saveBtn) {
|
|
toolbarRight.insertBefore(saveBtn, toolbarRight.firstChild);
|
|
saveBtn.style.display = 'inline-block';
|
|
}
|
|
if (toolbarRight && backBtn) {
|
|
toolbarRight.insertBefore(backBtn, toolbarRight.firstChild);
|
|
backBtn.style.display = 'inline-block';
|
|
}
|
|
}
|
|
|
|
function hideWorkshopUI() {
|
|
const toolbar = document.getElementById('toolbar');
|
|
if (toolbar) toolbar.style.display = 'none';
|
|
|
|
// Hide game buttons and move them back out of toolbar
|
|
const backBtn = document.getElementById('back-to-world-btn');
|
|
if (backBtn) { backBtn.style.display = 'none'; document.body.insertBefore(backBtn, document.body.firstChild); }
|
|
const saveBtn = document.getElementById('save-gadget-btn');
|
|
if (saveBtn) { saveBtn.style.display = 'none'; document.body.insertBefore(saveBtn, document.body.firstChild); }
|
|
}
|