Compare commits

..

9 Commits

Author SHA1 Message Date
Jose Luis
0c0ab2fc9b fix: rebuild props panel when interaction type changes
Without this, switching type to 'module' didn't show the moduleId,
ports, and Open IDE button until manually reselecting the entity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:44:09 +01:00
Jose Luis
9ffd9c113e feat: character/NPC management system with spritesheet support
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>
2026-03-20 21:15:28 +01:00
Jose Luis
9d218c8728 fix: replace verify textarea with Open IDE button + fix gutter line numbers
- Hide the inline verify textarea, show a clean "Open IDE" button instead
- Add white-space:pre to gutter div so line numbers render one per line
- Match gutter font-family with textarea for consistent alignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:08:21 +01:00
Jose Luis
06807801d0 feat: animated execution log with truth table + waveform viewer
When the player executes wiring, the panel transitions to a full
execution log showing:
- Animated truth table with rows revealing one by one (flash effect)
- Digital waveform viewer with square wave signals for each port
- Scanning cursor animation during evaluation
- Pulsing glow verdict (pass/fail) after all test cases complete
- Color-coded columns: orange for inputs, blue for outputs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:07:14 +01:00
Jose Luis
bb72c58a15 feat: fullscreen VSCode-style code editor for module verify logic
Add a dark-themed fullscreen code editor overlay in the level editor
for editing module verify JS. Features line numbers, cursor position
tracking, tab-to-spaces, Ctrl+Enter to apply, and syncs back to the
property panel on save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:04:23 +01:00
Jose Luis
f9492bff4c feat: module interaction system with wiring panel
Add a new "module" interaction type where doors/devices define ports
(in/out) and a JS verify function. Players wire their gadget's I/O
to the module's ports via a canvas-rendered wiring panel, then execute
to verify the circuit logic.

- New wiringPanel.js: full wiring UI with keyboard nav, bezier wires,
  mini circuit evaluator, and verify execution
- AND-gate example door in Circuit Lab (tile 9,1)
- Editor support: module type with ports editor and JS verify textarea
- Integrated into gameMode, worldInput, worldRenderer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:59:25 +01:00
Jose Luis
6ba3fa457a hide: disable puzzle mode from editor without removing code
Comment out initPuzzleUI() call and remove puzzle_door from
interaction type dropdown — all puzzle code remains intact for
future re-enablement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:45:19 +01:00
Jose Luis
e7b18afd1a fix: move game buttons into toolbar-right to prevent overlap
Save Gadget and Back to World buttons were overlapping Export/Import.
Now they are dynamically inserted into .toolbar-right when entering
workshop mode, sitting inline with the other toolbar buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:40:09 +01:00
Jose Luis
816a02aeb9 feat: replace browser dialogs with in-game naming screen + notifications
Remove prompt() and alert() calls that broke game immersion. Add:
- Pokemon-style naming screen with character grid + direct typing
- Canvas-rendered notification toasts (with fade-out animation)
- Both render on top of workshop AND world mode canvases
- Workshop keyboard handler yields to naming screen when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:36:31 +01:00
15 changed files with 1769 additions and 70 deletions

View File

@@ -7,5 +7,6 @@ COPY editor.html public/
COPY css/ public/css/ COPY css/ public/css/
COPY js/ public/js/ COPY js/ public/js/
COPY assets/ public/assets/ COPY assets/ public/assets/
RUN mkdir -p public/data
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -63,6 +63,23 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
#status-bar span { font-family: monospace; } #status-bar span { font-family: monospace; }
#zoom-info { margin-left: auto; } #zoom-info { margin-left: auto; }
/* Characters section */
#char-section { border-bottom: 1px solid var(--border); max-height: 200px; overflow-y: auto; }
#char-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 6px; }
#char-section h3 button { background: var(--bg); border: 1px solid var(--border); color: var(--accent); border-radius: 3px; padding: 1px 6px; cursor: pointer; font-size: 10px; }
#char-section h3 button:hover { background: var(--accent); color: #000; }
.char-drop-zone { margin: 4px 12px 8px; padding: 12px; border: 2px dashed var(--border); border-radius: 6px; text-align: center; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.2s; }
.char-drop-zone.dragover { border-color: var(--accent); background: rgba(0,229,153,0.08); color: var(--accent); }
.char-card { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; border-left: 3px solid transparent; font-size: 12px; }
.char-card:hover { background: rgba(255,255,255,0.04); }
.char-card.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
.char-card canvas { border: 1px solid var(--border); border-radius: 2px; image-rendering: pixelated; flex-shrink: 0; }
.char-card .char-info { flex: 1; overflow: hidden; }
.char-card .char-name { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.char-card .char-meta { font-size: 10px; color: var(--text2); }
.char-card .char-delete { background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; opacity: 0.5; padding: 2px 4px; }
.char-card .char-delete:hover { opacity: 1; }
/* Toast */ /* Toast */
#toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; } #toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; }
#toast.show { opacity: 1; } #toast.show { opacity: 1; }
@@ -76,6 +93,104 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
.modal-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; } .modal-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-actions button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 12px; } .modal-actions button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 12px; }
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } .modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
/* ===== Fullscreen Code Editor ===== */
#code-editor-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.85);
display: none; flex-direction: column;
align-items: center; justify-content: center;
padding: 20px;
}
#code-editor-overlay.show { display: flex; }
#code-editor-panel {
width: min(900px, 95vw); height: min(680px, 90vh);
background: #1e1e2e; border: 1px solid #333;
border-radius: 8px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.6);
}
/* Title bar (VSCode-like) */
#code-editor-titlebar {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; background: #181825;
border-bottom: 1px solid #333; flex-shrink: 0;
}
#code-editor-titlebar .tab {
padding: 4px 12px; background: #1e1e2e;
border-radius: 4px 4px 0 0; font-size: 12px;
color: var(--accent); font-family: 'Cascadia Code', 'Fira Code', monospace;
border: 1px solid #333; border-bottom: 1px solid #1e1e2e;
margin-bottom: -1px;
}
#code-editor-titlebar .title-hint {
color: #666; font-size: 11px; margin-left: auto;
}
#code-editor-titlebar .close-x {
width: 28px; height: 28px; border: none; background: transparent;
color: #888; cursor: pointer; font-size: 18px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
#code-editor-titlebar .close-x:hover { background: #ff555530; color: var(--red); }
/* Editor body */
#code-editor-body {
flex: 1; display: flex; overflow: hidden;
}
/* Line numbers gutter */
#code-editor-gutter {
width: 48px; padding: 12px 8px 12px 0;
background: #181825; text-align: right;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px; line-height: 1.6;
color: #555; user-select: none; overflow: hidden;
flex-shrink: 0; white-space: pre;
}
/* Main textarea */
#code-editor-textarea {
flex: 1; resize: none; border: none; outline: none;
background: #1e1e2e; color: #cdd6f4;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px; line-height: 1.6;
padding: 12px 16px; tab-size: 2;
white-space: pre; overflow: auto;
}
#code-editor-textarea::selection { background: rgba(0,229,153,0.25); }
/* Status bar */
#code-editor-statusbar {
display: flex; align-items: center; gap: 16px;
padding: 4px 16px; background: #181825;
border-top: 1px solid #333; flex-shrink: 0;
font-size: 11px; color: #666;
}
#code-editor-statusbar .status-accent { color: var(--accent); }
/* Bottom actions */
#code-editor-actions {
display: flex; justify-content: flex-end; gap: 8px;
padding: 10px 16px; background: #181825;
border-top: 1px solid #333; flex-shrink: 0;
}
#code-editor-actions button {
padding: 6px 16px; border-radius: 4px; cursor: pointer;
font-size: 12px; font-weight: 600; border: 1px solid #333;
}
#code-editor-actions .btn-cancel { background: #2a2a3e; color: #aaa; }
#code-editor-actions .btn-cancel:hover { background: #333; }
#code-editor-actions .btn-save { background: var(--accent); color: #000; border-color: var(--accent); }
#code-editor-actions .btn-save:hover { filter: brightness(1.15); }
/* Expand button for property panel */
.btn-expand-code {
padding: 3px 8px; border: 1px solid var(--border); border-radius: 3px;
background: var(--bg); color: var(--text2); cursor: pointer;
font-size: 10px; transition: all 0.15s; flex-shrink: 0;
}
.btn-expand-code:hover { border-color: var(--accent); color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
@@ -97,6 +212,14 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
<button class="tool-btn" data-tool="select" title="Click entity to select/move">🔍 Select</button> <button class="tool-btn" data-tool="select" title="Click entity to select/move">🔍 Select</button>
<button class="tool-btn danger" data-tool="delete" title="Click entity to delete">✕ Delete</button> <button class="tool-btn danger" data-tool="delete" title="Click entity to delete">✕ Delete</button>
</div> </div>
<div id="char-section">
<h3>Characters <button id="btn-add-char" title="Add character from file">+ Add</button></h3>
<div id="char-list"></div>
<div class="char-drop-zone" id="char-drop-zone">
Drop spritesheet PNG here<br><span style="font-size:10px;color:#555;">3 cols (still, walk1, walk2) × 4 rows (↓↑←→)</span>
</div>
<input type="file" id="char-file-input" accept="image/png" style="display:none;">
</div>
<div id="entity-section"> <div id="entity-section">
<h3>Spawn</h3> <h3>Spawn</h3>
<div id="spawn-list"></div> <div id="spawn-list"></div>
@@ -182,6 +305,31 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
</div> </div>
</div> </div>
<!-- Fullscreen Code Editor -->
<div id="code-editor-overlay">
<div id="code-editor-panel">
<div id="code-editor-titlebar">
<div class="tab" id="code-editor-tab">verify.js</div>
<span class="title-hint">Module Verify Logic</span>
<button class="close-x" id="code-editor-close" title="Close (Esc)"></button>
</div>
<div id="code-editor-body">
<div id="code-editor-gutter"></div>
<textarea id="code-editor-textarea" spellcheck="false"></textarea>
</div>
<div id="code-editor-statusbar">
<span class="status-accent">JavaScript</span>
<span id="code-editor-cursor">Ln 1, Col 1</span>
<span>UTF-8</span>
<span style="margin-left:auto;">Tab Size: 2</span>
</div>
<div id="code-editor-actions">
<button class="btn-cancel" id="code-editor-discard">Discard</button>
<button class="btn-save" id="code-editor-apply">✓ Apply Changes</button>
</div>
</div>
</div>
<script> <script>
// ==================== State ==================== // ==================== State ====================
const TILE = 16; const TILE = 16;
@@ -197,6 +345,8 @@ const mapConfigs = {
let currentMapId = 'lab'; let currentMapId = 'lab';
let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } } let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } }
let characters = {}; // { [charId]: { id, name, spritesheet (base64), frameW, frameH, img (HTMLImageElement) } }
let selectedCharId = null;
let selectedTool = 'wall'; let selectedTool = 'wall';
let selectedEntity = null; // { type, index } let selectedEntity = null; // { type, index }
let isPainting = false; let isPainting = false;
@@ -268,10 +418,122 @@ function init() {
document.getElementById('btn-load-server').addEventListener('click', loadFromServer); document.getElementById('btn-load-server').addEventListener('click', loadFromServer);
document.getElementById('btn-do-bilink').addEventListener('click', createBiLink); document.getElementById('btn-do-bilink').addEventListener('click', createBiLink);
initCharacterUI();
resizeCanvas(); resizeCanvas();
updateEntityList(); updateEntityList();
} }
// ==================== Character management ====================
function addCharacterFromFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result;
const img = new Image();
img.onload = () => {
// Auto-detect frame size: assume 3 cols × 4 rows
const frameW = Math.floor(img.width / 3);
const frameH = Math.floor(img.height / 4);
const id = file.name.replace(/\.png$/i, '').replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
const name = id.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
characters[id] = { id, name, spritesheet: base64, frameW, frameH, img };
updateCharList();
selectCharacter(id);
toast(`Character "${name}" added (${frameW}×${frameH}px frames)`);
};
img.src = base64;
};
reader.readAsDataURL(file);
}
function removeCharacter(charId) {
delete characters[charId];
if (selectedCharId === charId) selectedCharId = null;
// Remove charId from NPCs that reference it
for (const md of Object.values(mapData)) {
for (const npc of (md.npcs || [])) {
if (npc.charId === charId) delete npc.charId;
}
}
updateCharList();
updateEntityList();
render();
}
function selectCharacter(charId) {
selectedCharId = charId;
updateCharList();
}
function updateCharList() {
const container = document.getElementById('char-list');
const ids = Object.keys(characters);
if (ids.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = ids.map(id => {
const c = characters[id];
const sel = id === selectedCharId ? ' selected' : '';
return `<div class="char-card${sel}" data-char-id="${id}">
<canvas class="char-preview" data-char-id="${id}" width="32" height="32"></canvas>
<div class="char-info">
<div class="char-name">${c.name}</div>
<div class="char-meta">${c.frameW}×${c.frameH}px · ${id}</div>
</div>
<button class="char-delete" data-char-id="${id}" title="Delete">✕</button>
</div>`;
}).join('');
// Draw sprite previews (down-still frame)
container.querySelectorAll('.char-preview').forEach(cvs => {
const c = characters[cvs.dataset.charId];
if (!c || !c.img) return;
const pctx = cvs.getContext('2d');
pctx.imageSmoothingEnabled = false;
pctx.clearRect(0, 0, 32, 32);
// Draw the down-still frame (row 0, col 0)
pctx.drawImage(c.img, 0, 0, c.frameW, c.frameH, 0, 0, 32, 32);
});
// Wire click events
container.querySelectorAll('.char-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.classList.contains('char-delete')) return;
selectCharacter(card.dataset.charId);
});
});
container.querySelectorAll('.char-delete').forEach(btn => {
btn.addEventListener('click', () => removeCharacter(btn.dataset.charId));
});
}
// Wire drag & drop + file input
function initCharacterUI() {
const dropZone = document.getElementById('char-drop-zone');
const fileInput = document.getElementById('char-file-input');
const addBtn = document.getElementById('btn-add-char');
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
for (const file of e.dataTransfer.files) {
if (file.type === 'image/png') addCharacterFromFile(file);
}
});
dropZone.addEventListener('click', () => fileInput.click());
addBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) {
if (file.type === 'image/png') addCharacterFromFile(file);
}
fileInput.value = '';
});
}
// ==================== Load game data ==================== // ==================== Load game data ====================
function loadCurrentGameData() { function loadCurrentGameData() {
@@ -467,18 +729,28 @@ function render() {
ctx.strokeStyle = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? '#fff' : '#ffdd44'; ctx.strokeStyle = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? '#fff' : '#ffdd44';
ctx.lineWidth = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? 2.5 : 1.5; ctx.lineWidth = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeRect(inter.x * TILE_PX, inter.y * TILE_PX, TILE_PX, TILE_PX); ctx.strokeRect(inter.x * TILE_PX, inter.y * TILE_PX, TILE_PX, TILE_PX);
const icon = inter.type === 'workshop' ? '🔧' : inter.type === 'puzzle_door' ? '🔒' : inter.type === 'terminal' ? '💻' : '📋'; const icon = inter.type === 'workshop' ? '🔧' : inter.type === 'puzzle_door' ? '🔒' : inter.type === 'module' ? '⚡' : inter.type === 'terminal' ? '💻' : '📋';
drawLabel(ctx, icon, inter.x, inter.y); drawLabel(ctx, icon, inter.x, inter.y);
}); });
// NPCs // NPCs
md.npcs.forEach((npc, i) => { md.npcs.forEach((npc, i) => {
const sx = npc.x * TILE_PX, sy = npc.y * TILE_PX;
const char = npc.charId ? characters[npc.charId] : null;
if (char && char.img) {
// Draw spritesheet frame: down-still (row 0, col 0)
const dirRow = { down: 0, up: 1, left: 2, right: 3 };
const row = dirRow[npc.facing] ?? 0;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(char.img, 0, row * char.frameH, char.frameW, char.frameH, sx, sy, TILE_PX, TILE_PX);
} else {
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)'; ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
ctx.fillRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX); ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
drawLabel(ctx, '👤', npc.x, npc.y);
}
ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff'; ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff';
ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5; ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX); ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
drawLabel(ctx, '👤', npc.x, npc.y);
}); });
// Tile coordinates (when zoomed in enough) // Tile coordinates (when zoomed in enough)
@@ -563,11 +835,14 @@ function onMouseDown(e) {
} }
updateEntityList(); updateProps(); render(); updateEntityList(); updateProps(); render();
break; break;
case 'npc': case 'npc': {
md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] }); const npcData = { id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] };
if (selectedCharId && characters[selectedCharId]) npcData.charId = selectedCharId;
md.npcs.push(npcData);
selectedEntity = { type: 'npc', index: md.npcs.length - 1 }; selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
updateEntityList(); updateProps(); render(); updateEntityList(); updateProps(); render();
break; break;
}
case 'exit': { case 'exit': {
// Default to a different map than the current one // Default to a different map than the current one
const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId); const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId);
@@ -770,9 +1045,10 @@ function updateEntityList() {
? makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y) ? makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y)
: '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None (use 🏠 tool to place)</div>'; : '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None (use 🏠 tool to place)</div>';
document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => {
makeEntityItem('npc', i, '👤', n.id, n.x, n.y, '#cc55ff') const charLabel = n.charId && characters[n.charId] ? ` [${characters[n.charId].name}]` : '';
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>'; return makeEntityItem('npc', i, '👤', n.id + charLabel, n.x, n.y, '#cc55ff');
}).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
document.getElementById('exit-list').innerHTML = md.exits.map((e, i) => { document.getElementById('exit-list').innerHTML = md.exits.map((e, i) => {
const hasCoords = e.targetX != null && e.targetY != null; const hasCoords = e.targetX != null && e.targetY != null;
@@ -842,6 +1118,9 @@ function updateProps() {
if (t === 'npc') { if (t === 'npc') {
html += propText('ID', 'id', ent.id); html += propText('ID', 'id', ent.id);
html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']); html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']);
// Character selector
const charOpts = ['(none)', ...Object.keys(characters)];
html += propSelect('Character', 'charId', ent.charId || '(none)', charOpts);
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n')); html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
} else if (t === 'exit') { } else if (t === 'exit') {
// Show map options with both editor ID and game ID // Show map options with both editor ID and game ID
@@ -856,13 +1135,24 @@ function updateProps() {
html += '<div style="color:var(--red);font-size:10px;padding:2px 0;">⚠️ Set target X/Y! Every exit needs explicit coordinates.</div>'; html += '<div style="color:var(--red);font-size:10px;padding:2px 0;">⚠️ Set target X/Y! Every exit needs explicit coordinates.</div>';
} }
} else if (t === 'interaction') { } else if (t === 'interaction') {
html += propSelect('Type', 'type', ent.type, ['sign','workshop','puzzle_door','terminal','door']); html += propSelect('Type', 'type', ent.type, ['sign','workshop','terminal','door','module']); // puzzle_door hidden for now
html += propText('Label', 'label', ent.label || ''); html += propText('Label', 'label', ent.label || '');
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n')); html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
if (ent.type === 'puzzle_door') { if (ent.type === 'puzzle_door') {
html += propText('Puzzle ID', 'puzzleId', ent.puzzleId || ''); html += propText('Puzzle ID', 'puzzleId', ent.puzzleId || '');
html += propText('Req. Outputs', 'requiredOutputs', (ent.requiredOutputs || []).join(',')); html += propText('Req. Outputs', 'requiredOutputs', (ent.requiredOutputs || []).join(','));
} }
if (ent.type === 'module') {
html += propText('Module ID', 'moduleId', ent.moduleId || '');
// Ports editor — compact format: "A:out, B:out, C:in"
const portsStr = (ent.ports || []).map(p => `${p.name}:${p.dir}`).join(', ');
html += propText('Ports', 'ports', portsStr);
html += `<div class="prop-row" style="font-size:10px;color:#888;">Format: A:out, B:out, C:in</div>`;
// Verify code — hidden textarea + "Open IDE" button
const verifyCode = ent.verify || `(test) => {\n return test({A:0, B:0}).C === 0\n && test({A:1, B:1}).C === 1;\n}`;
html += `<textarea id="verify-textarea" data-prop="verify" style="display:none;">${esc(verifyCode)}</textarea>`;
html += `<div class="prop-row"><label>Verify (JS)</label><button class="btn-expand-code" id="btn-expand-verify" style="flex:1;padding:6px 12px;font-size:12px;">⚡ Open IDE</button></div>`;
}
} }
container.innerHTML = html; container.innerHTML = html;
@@ -874,6 +1164,13 @@ function updateProps() {
if (el.type === 'number') applyPropChange(el.dataset.prop, el.value, el.type); if (el.type === 'number') applyPropChange(el.dataset.prop, el.value, el.type);
}); });
}); });
// Wire expand button for verify code editor
const btnExpand = document.getElementById('btn-expand-verify');
const verifyTA = document.getElementById('verify-textarea');
if (btnExpand && verifyTA) {
btnExpand.addEventListener('click', () => openCodeEditor(verifyTA, 'verify.js'));
}
} }
function propNum(label, prop, val) { function propNum(label, prop, val) {
@@ -895,10 +1192,21 @@ function applyPropChange(prop, value, inputType) {
const ent = getSelectedEntityData(); const ent = getSelectedEntityData();
if (!ent) return; if (!ent) return;
if (prop === 'dialog') { if (prop === 'charId') {
if (value === '(none)') delete ent.charId;
else ent.charId = value;
} else if (prop === 'dialog') {
ent.dialog = value.split('\n').filter(l => l.trim()); ent.dialog = value.split('\n').filter(l => l.trim());
} else if (prop === 'requiredOutputs') { } else if (prop === 'requiredOutputs') {
ent.requiredOutputs = value.split(',').map(Number); ent.requiredOutputs = value.split(',').map(Number);
} else if (prop === 'ports') {
// Parse "A:out, B:out, C:in" format
ent.ports = value.split(',').map(s => s.trim()).filter(Boolean).map(s => {
const [name, dir] = s.split(':').map(p => p.trim());
return { name: name || '?', dir: dir || 'out', bits: 1 };
});
} else if (prop === 'verify') {
ent.verify = value;
} else if (prop === 'targetMap') { } else if (prop === 'targetMap') {
// Store as game ID (pallet-town → town) // Store as game ID (pallet-town → town)
ent.targetMap = value === 'pallet-town' ? 'town' : value; ent.targetMap = value === 'pallet-town' ? 'town' : value;
@@ -910,6 +1218,8 @@ function applyPropChange(prop, value, inputType) {
updateEntityList(); updateEntityList();
render(); render();
// Rebuild props panel when type changes (shows/hides type-specific fields)
if (prop === 'type') updateProps();
} }
// ==================== Export ==================== // ==================== Export ====================
@@ -987,7 +1297,10 @@ function generateMapsJS() {
// NPCs // NPCs
out += ` npcs: [\n`; out += ` npcs: [\n`;
for (const n of md.npcs) { for (const n of md.npcs) {
out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`; let npcLine = ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}'`;
if (n.charId) npcLine += `, charId: '${n.charId}'`;
npcLine += `, dialog: ${JSON.stringify(n.dialog)} },\n`;
out += npcLine;
} }
out += ` ],\n\n`; out += ` ],\n\n`;
@@ -998,7 +1311,19 @@ function generateMapsJS() {
if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`; if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`;
if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`; if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`;
if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`; if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`;
line += ` },\n`; if (inter.moduleId) line += `,\n moduleId: '${inter.moduleId}'`;
if (inter.ports && inter.ports.length > 0) {
line += `,\n ports: [\n`;
for (const p of inter.ports) {
line += ` { name: '${p.name}', dir: '${p.dir}', bits: ${p.bits || 1} },\n`;
}
line += ` ]`;
}
if (inter.verify) {
// Output verify as raw JS (not a string) so it's executable
line += `,\n verify: \`${inter.verify.replace(/`/g, '\\`')}\``;
}
line += `\n },\n`;
out += line; out += line;
} }
out += ` ]\n};\n\n`; out += ` ]\n};\n\n`;
@@ -1095,14 +1420,28 @@ function toast(msg) {
async function saveToServer() { async function saveToServer() {
const code = generateMapsJS(); const code = generateMapsJS();
try { try {
// Save maps.js
const res = await fetch('/api/maps', { const res = await fetch('/api/maps', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: code }) body: JSON.stringify({ content: code })
}); });
const data = await res.json(); const data = await res.json();
// Save characters
const charData = {};
for (const [id, c] of Object.entries(characters)) {
charData[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH };
}
await fetch('/api/characters', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ characters: charData })
});
if (data.ok) { if (data.ok) {
toast(`Saved maps.js (${data.bytes} bytes)`); const charCount = Object.keys(charData).length;
toast(`Saved maps.js (${data.bytes}b) + ${charCount} character(s)`);
} else { } else {
toast('Error: ' + (data.error || 'Unknown')); toast('Error: ' + (data.error || 'Unknown'));
} }
@@ -1118,11 +1457,26 @@ async function loadFromServer() {
if (!data.content) { toast('No maps.js found on server'); return; } if (!data.content) { toast('No maps.js found on server'); return; }
// Parse the maps.js to extract wall data and entities // Parse the maps.js to extract wall data and entities
// We parse the JS source to extract the wall objects and map definitions
const src = data.content; const src = data.content;
parseAndLoadMapsJS(src); parseAndLoadMapsJS(src);
// Load characters
try {
const charRes = await fetch('/api/characters');
const charData = await charRes.json();
if (charData.characters) {
characters = {};
for (const [id, c] of Object.entries(charData.characters)) {
const img = new Image();
img.src = c.spritesheet;
characters[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH, img };
}
updateCharList();
}
} catch (ce) { console.warn('[editor] Character load failed:', ce); }
updateEntityList(); updateProps(); render(); updateEntityList(); updateProps(); render();
toast('Loaded maps.js from server'); toast('Loaded maps.js + characters from server');
} catch (e) { } catch (e) {
toast('Load failed: ' + e.message); toast('Load failed: ' + e.message);
} }
@@ -1300,6 +1654,98 @@ function createBiLink() {
init(); init();
// ==================== Fullscreen Code Editor ====================
const codeEditorOverlay = document.getElementById('code-editor-overlay');
const codeEditorTextarea = document.getElementById('code-editor-textarea');
const codeEditorGutter = document.getElementById('code-editor-gutter');
const codeEditorCursor = document.getElementById('code-editor-cursor');
const codeEditorTab = document.getElementById('code-editor-tab');
let codeEditorTarget = null; // the prop textarea we're editing for
function openCodeEditor(propTextarea, tabName) {
codeEditorTarget = propTextarea;
codeEditorTab.textContent = tabName || 'verify.js';
codeEditorTextarea.value = propTextarea.value;
codeEditorOverlay.classList.add('show');
updateGutter();
updateCursorPos();
// Focus after animation frame
requestAnimationFrame(() => codeEditorTextarea.focus());
}
function closeCodeEditor(apply) {
if (apply && codeEditorTarget) {
codeEditorTarget.value = codeEditorTextarea.value;
// Trigger change event so applyPropChange fires
codeEditorTarget.dispatchEvent(new Event('change', { bubbles: true }));
}
codeEditorOverlay.classList.remove('show');
codeEditorTarget = null;
}
function updateGutter() {
const lines = codeEditorTextarea.value.split('\n').length;
let html = '';
for (let i = 1; i <= Math.max(lines, 20); i++) {
html += i + '\n';
}
codeEditorGutter.textContent = html;
}
function updateCursorPos() {
const val = codeEditorTextarea.value;
const pos = codeEditorTextarea.selectionStart;
const before = val.substring(0, pos);
const line = before.split('\n').length;
const col = pos - before.lastIndexOf('\n');
codeEditorCursor.textContent = `Ln ${line}, Col ${col}`;
}
// Sync gutter with scroll
codeEditorTextarea.addEventListener('scroll', () => {
codeEditorGutter.scrollTop = codeEditorTextarea.scrollTop;
});
// Update gutter + cursor on input
codeEditorTextarea.addEventListener('input', () => { updateGutter(); updateCursorPos(); });
codeEditorTextarea.addEventListener('click', updateCursorPos);
codeEditorTextarea.addEventListener('keyup', updateCursorPos);
// Tab key inserts spaces
codeEditorTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = codeEditorTextarea.selectionStart;
const end = codeEditorTextarea.selectionEnd;
const val = codeEditorTextarea.value;
codeEditorTextarea.value = val.substring(0, start) + ' ' + val.substring(end);
codeEditorTextarea.selectionStart = codeEditorTextarea.selectionEnd = start + 2;
updateGutter();
}
// Escape closes
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeCodeEditor(false);
}
// Ctrl+Enter applies
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
closeCodeEditor(true);
}
});
// Buttons
document.getElementById('code-editor-close').addEventListener('click', () => closeCodeEditor(false));
document.getElementById('code-editor-discard').addEventListener('click', () => closeCodeEditor(false));
document.getElementById('code-editor-apply').addEventListener('click', () => closeCodeEditor(true));
// Click outside panel to close
codeEditorOverlay.addEventListener('click', (e) => {
if (e.target === codeEditorOverlay) closeCodeEditor(false);
});
// Auto-load from server on start // Auto-load from server on start
loadFromServer().catch(() => { loadFromServer().catch(() => {
console.log('[editor] Server load failed, using embedded defaults'); console.log('[editor] Server load failed, using embedded defaults');

View File

@@ -7,9 +7,9 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body> <body>
<!-- Back to world button (shown in workshop mode) --> <!-- Game buttons (hidden, placed outside toolbar — injected into toolbar-right by gameMode.js) -->
<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> <button id="save-gadget-btn" class="action-btn" style="display:none; background:#ff44aa; color:#fff; border:none; border-radius:4px; font-weight:700; cursor:pointer; font-size:11px; padding:6px 12px;">🎒 Save Gadget</button>
<button id="save-gadget-btn" style="display:none; position:fixed; top:12px; right:170px; z-index:200; padding:6px 14px; background:#ff44aa; border:none; border-radius:6px; color:#fff; font-weight:700; cursor:pointer; font-size:12px;">🎒 Save as Gadget</button> <button id="back-to-world-btn" class="action-btn" style="display:none; background:#00e599; color:#000; border:none; border-radius:4px; font-weight:700; cursor:pointer; font-size:11px; padding:6px 12px;">◀ World</button>
<div id="toolbar"> <div id="toolbar">
<span class="logo">⚡ Logic Lab</span> <span class="logo">⚡ Logic Lab</span>

View File

@@ -14,7 +14,7 @@ document.addEventListener('DOMContentLoaded', () => {
() => { () => {
initRenderer(); initRenderer();
initEvents(); initEvents();
initPuzzleUI(); // initPuzzleUI(); // HIDDEN: puzzle mode disabled for now
if (loadFromStorage()) { if (loadFromStorage()) {
updateComponentButtons(); updateComponentButtons();
evaluateAll(); evaluateAll();

View File

@@ -11,6 +11,7 @@ import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.j
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js'; import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js'; import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js'; import { createBusFromCut } from './bus.js';
import { isNamingActive, handleNamingInput } from './world/inventory.js';
const PAN_SPEED = 40; const PAN_SPEED = 40;
@@ -325,6 +326,13 @@ export function initEvents() {
const keysDown = new Set(); const keysDown = new Set();
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
// In-game naming screen takes priority over circuit editor
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(e.key);
return;
}
keysDown.add(e.key); keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {

View File

@@ -4,6 +4,7 @@ import { state } from './state.js';
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js'; import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js'; import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
import { getBusPairs } from './bus.js'; import { getBusPairs } from './bus.js';
import { drawNamingScreen, drawNotification } from './world/inventory.js';
let canvas, ctx; let canvas, ctx;
let circuitAnimFrameId = null; let circuitAnimFrameId = null;
@@ -663,5 +664,9 @@ function draw() {
drawWaveforms(); drawWaveforms();
} }
// In-game overlays (naming screen, notifications) — render on top
drawNamingScreen(ctx, canvas.width, canvas.height);
drawNotification(ctx, canvas.width);
circuitAnimFrameId = requestAnimationFrame(draw); circuitAnimFrameId = requestAnimationFrame(draw);
} }

View File

@@ -0,0 +1,26 @@
// characterLoader.js - Loads character spritesheets from server and registers them
import { registerCharacter } from './sprites.js';
/**
* Fetch character data from the server and register all characters
* with the sprite system so NPCs can use them.
* @returns {Promise<number>} number of characters loaded
*/
export async function loadCharacters() {
try {
const res = await fetch('/api/characters');
const data = await res.json();
if (!data.characters) return 0;
const entries = Object.entries(data.characters);
const promises = entries.map(([id, c]) =>
registerCharacter(id, c.name, c.spritesheet, c.frameW, c.frameH)
);
await Promise.all(promises);
console.log(`[characterLoader] loaded ${entries.length} character(s)`);
return entries.length;
} catch (e) {
console.warn('[characterLoader] failed to load characters:', e);
return 0;
}
}

View File

@@ -3,7 +3,9 @@ import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle,
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js'; import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js'; import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
import { getMap } from './maps.js'; import { getMap } from './maps.js';
import { saveGadget, openBackpack, getGadgets } from './inventory.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) // Circuit editor stop function (to stop its render loop when switching modes)
import { stopCircuitLoop } from '../renderer.js'; import { stopCircuitLoop } from '../renderer.js';
@@ -27,7 +29,10 @@ export function registerCircuitEditor(initFn, destroyFn) {
/** /**
* Boot the game — start in world mode * Boot the game — start in world mode
*/ */
export function startGame() { export async function startGame() {
// Load character spritesheets before entering world
await loadCharacters();
// Set spawn // Set spawn
const map = getMap(worldState.currentMap); const map = getMap(worldState.currentMap);
if (map && map.spawn) { if (map && map.spawn) {
@@ -144,6 +149,31 @@ function handleInteraction(event) {
break; 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': case 'openInventory':
// TODO: inventory UI // TODO: inventory UI
console.log('[gameMode] inventory:', worldState.inventory); console.log('[gameMode] inventory:', worldState.inventory);
@@ -162,11 +192,16 @@ function handleSaveGadget() {
const outputGates = gates.filter(g => g.type === 'OUTPUT'); const outputGates = gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) { if (inputGates.length === 0 || outputGates.length === 0) {
alert('Your circuit needs at least 1 INPUT and 1 OUTPUT to save as a gadget.'); showNotification('Need at least 1 INPUT and 1 OUTPUT!', '⚠️', '#ff5555');
return; return;
} }
const name = prompt('Name your gadget:', `Gadget ${getGadgets().length + 1}`); // 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 if (!name) return; // cancelled
const component = { const component = {
@@ -182,24 +217,12 @@ function handleSaveGadget() {
const result = saveGadget(component); const result = saveGadget(component);
if (result.success) { if (result.success) {
showToast(`🎒 "${name}" saved to backpack!`); showNotification(`"${name}" saved to backpack!`, '🎒', '#ff44aa');
} else { } else {
alert('Failed to save: ' + result.error); showNotification(result.error, '⚠️', '#ff5555');
} }
}
function showToast(msg) {
// Simple floating toast
let toast = document.getElementById('game-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'game-toast';
toast.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);padding:10px 20px;background:#ff44aa;color:#fff;border-radius:8px;font-weight:700;font-size:13px;z-index:300;opacity:0;transition:opacity 0.3s;pointer-events:none;font-family:system-ui,sans-serif;';
document.body.appendChild(toast);
} }
toast.textContent = msg; );
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; }, 2500);
} }
// ==================== Puzzle testing ==================== // ==================== Puzzle testing ====================
@@ -307,9 +330,11 @@ function showWorldUI() {
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
} }
// Show back-to-world button (hidden since we're IN world) // Hide game buttons (we're IN world, not workshop)
const backBtn = document.getElementById('back-to-world-btn'); const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) backBtn.style.display = 'none'; if (backBtn) backBtn.style.display = 'none';
const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) saveBtn.style.display = 'none';
} }
function hideWorldUI() { function hideWorldUI() {
@@ -326,19 +351,27 @@ function showWorkshopUI() {
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
} }
// Show back-to-world button and save-gadget button // Move game buttons into toolbar-right so they sit inline with Export/Import
const backBtn = document.getElementById('back-to-world-btn'); const toolbarRight = document.querySelector('.toolbar-right');
if (backBtn) backBtn.style.display = 'flex';
const saveBtn = document.getElementById('save-gadget-btn'); const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) saveBtn.style.display = 'flex'; 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() { function hideWorkshopUI() {
const toolbar = document.getElementById('toolbar'); const toolbar = document.getElementById('toolbar');
if (toolbar) toolbar.style.display = 'none'; if (toolbar) toolbar.style.display = 'none';
// Hide game buttons and move them back out of toolbar
const backBtn = document.getElementById('back-to-world-btn'); const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) backBtn.style.display = 'none'; if (backBtn) { backBtn.style.display = 'none'; document.body.insertBefore(backBtn, document.body.firstChild); }
const saveBtn = document.getElementById('save-gadget-btn'); const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) saveBtn.style.display = 'none'; if (saveBtn) { saveBtn.style.display = 'none'; document.body.insertBefore(saveBtn, document.body.firstChild); }
} }

View File

@@ -100,6 +100,218 @@ export function getGadget(gadgetId) {
} }
// ==================== In-game naming screen ====================
let namingActive = false;
let namingText = '';
let namingCursorBlink = 0;
let namingCallback = null; // called with the final name string
let namingTitle = '';
let namingMaxLen = 16;
const CHAR_ROWS = [
'ABCDEFGHIJ',
'KLMNOPQRST',
'UVWXYZ ',
'abcdefghij',
'klmnopqrst',
'uvwxyz ',
'0123456789',
'-_.!? ⌫ ✓',
];
let charRow = 0, charCol = 0;
export function isNamingActive() { return namingActive; }
export function openNamingScreen(title, defaultText, callback) {
namingActive = true;
namingTitle = title || 'Enter name:';
namingText = defaultText || '';
namingCallback = callback;
namingCursorBlink = 0;
charRow = 0;
charCol = 0;
namingMaxLen = 16;
}
export function handleNamingInput(key) {
if (!namingActive) return false;
if (key === 'Escape') {
// Cancel
namingActive = false;
if (namingCallback) namingCallback(null);
return true;
}
if (key === 'Backspace') {
namingText = namingText.slice(0, -1);
return true;
}
// Navigate character grid
if (key === 'ArrowUp') { charRow = Math.max(0, charRow - 1); return true; }
if (key === 'ArrowDown') { charRow = Math.min(CHAR_ROWS.length - 1, charRow + 1); return true; }
if (key === 'ArrowLeft') { charCol = Math.max(0, charCol - 1); return true; }
if (key === 'ArrowRight') { charCol = Math.min(CHAR_ROWS[charRow].length - 1, charCol + 1); return true; }
// Select from grid
if (key === 'Enter' || key === ' ') {
const ch = CHAR_ROWS[charRow]?.[charCol];
if (ch === '✓' || (key === 'Enter' && (charRow === CHAR_ROWS.length - 1 && charCol >= 9))) {
// Confirm
if (namingText.trim()) {
namingActive = false;
if (namingCallback) namingCallback(namingText.trim());
}
} else if (ch === '⌫') {
namingText = namingText.slice(0, -1);
} else if (ch && ch !== ' ' && namingText.length < namingMaxLen) {
namingText += ch;
} else if (ch === ' ' && namingText.length < namingMaxLen && namingText.length > 0) {
namingText += ' ';
}
return true;
}
// Direct typing — any printable character
if (key.length === 1 && namingText.length < namingMaxLen) {
namingText += key;
return true;
}
return true;
}
export function drawNamingScreen(ctx, canvasW, canvasH) {
if (!namingActive) return;
namingCursorBlink = (namingCursorBlink + 1) % 60;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvasW, canvasH);
const pw = Math.min(380, canvasW - 40);
const ph = 340;
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Panel
ctx.fillStyle = '#181c2a';
ctx.strokeStyle = '#ff44aa';
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Title
ctx.fillStyle = '#ff44aa';
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(namingTitle, canvasW / 2, py + 14);
// Text field
const tfx = px + 20, tfy = py + 42, tfw = pw - 40, tfh = 28;
ctx.fillStyle = '#0f1119';
ctx.strokeStyle = '#2a2f45';
ctx.lineWidth = 1;
roundRect(ctx, tfx, tfy, tfw, tfh, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = namingText + (namingCursorBlink < 30 ? '▌' : '');
ctx.fillText(displayText, tfx + 8, tfy + tfh / 2);
// Character count
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${namingText.length}/${namingMaxLen}`, tfx + tfw - 4, tfy + tfh / 2);
// Character grid
const gridY = tfy + tfh + 16;
const cellW = 28, cellH = 24;
const gridW = 10 * cellW;
const gridX = (canvasW - gridW) / 2;
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let r = 0; r < CHAR_ROWS.length; r++) {
const row = CHAR_ROWS[r];
for (let c = 0; c < row.length; c++) {
const cx = gridX + c * cellW;
const cy = gridY + r * cellH;
const ch = row[c];
const isSel = r === charRow && c === charCol;
if (isSel) {
ctx.fillStyle = 'rgba(255, 68, 170, 0.25)';
roundRect(ctx, cx, cy, cellW - 2, cellH - 2, 3);
ctx.fill();
ctx.strokeStyle = '#ff44aa';
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (ch === ' ') continue;
ctx.fillStyle = isSel ? '#ff44aa' : (ch === '✓' ? '#00e599' : ch === '⌫' ? '#ff5555' : '#c8cad0');
ctx.font = (ch === '✓' || ch === '⌫') ? 'bold 14px sans-serif' : '12px monospace';
ctx.fillText(ch, cx + cellW / 2 - 1, cy + cellH / 2);
}
}
// Hint
ctx.fillStyle = '#444';
ctx.font = '10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Type directly or use ↑↓←→ + Enter | ESC: Cancel', canvasW / 2, gridY + CHAR_ROWS.length * cellH + 10);
}
// ==================== In-game notification ====================
let notification = null; // { text, icon, timer, color }
export function showNotification(text, icon = '🎒', color = '#ff44aa') {
notification = { text, icon, timer: 150, color }; // ~2.5s at 60fps
}
export function drawNotification(ctx, canvasW) {
if (!notification) return;
notification.timer--;
if (notification.timer <= 0) { notification = null; return; }
const alpha = notification.timer < 20 ? notification.timer / 20 : 1;
const n = notification;
const text = `${n.icon} ${n.text}`;
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
const tw = ctx.measureText(text).width + 32;
const bx = (canvasW - tw) / 2;
const by = 50;
ctx.fillStyle = n.color;
roundRect(ctx, bx, by, tw, 32, 6);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvasW / 2, by + 16);
ctx.restore();
}
// ==================== Backpack UI state ==================== // ==================== Backpack UI state ====================
let backpackOpen = false; let backpackOpen = false;

View File

@@ -48,6 +48,23 @@ const labMap = {
{ x: 1, y: 7, type: 'sign', label: 'Bookshelf', dialog: ["A collection of logic circuit manuals."] }, { 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: 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."] }, { x: 0, y: 1, type: 'terminal', label: 'Terminal', dialog: ["Circuit analysis terminal.","Connect components to solve puzzles."] },
// Module door example: requires AND(A, B) → C
{
x: 9, y: 1, type: 'module',
moduleId: 'lab_and_door',
label: 'AND Gate Door',
ports: [
{ name: 'A', dir: 'out', bits: 1 },
{ name: 'B', dir: 'out', bits: 1 },
{ name: 'C', dir: 'in', bits: 1 }
],
verify: `(test) => {
return test({A:0, B:0}).C === 0
&& test({A:0, B:1}).C === 0
&& test({A:1, B:0}).C === 0
&& test({A:1, B:1}).C === 1;
}`
},
] ]
}; };

View File

@@ -67,6 +67,73 @@ export async function preloadAssets() {
console.log('[sprites] all assets loaded'); console.log('[sprites] all assets loaded');
} }
// ==================== Character Registry ====================
// Characters are stored as spritesheets: 3 cols (still, walk1, walk2) × 4 rows (down, up, left, right)
const characterRegistry = {};
/**
* Register a character from a spritesheet image (or base64 data URL).
* @param {string} charId - unique character ID
* @param {string} name - display name
* @param {string|HTMLImageElement} source - image URL, base64 data URL, or Image element
* @param {number} frameW - frame width in px (default 16)
* @param {number} frameH - frame height in px (default 16)
* @returns {Promise} resolves when character is loaded
*/
export function registerCharacter(charId, name, source, frameW = 16, frameH = 16) {
return new Promise((resolve) => {
const char = { id: charId, name, frameW, frameH, img: null };
if (source instanceof HTMLImageElement) {
char.img = source;
characterRegistry[charId] = char;
resolve(char);
} else {
const img = new Image();
img.onload = () => { char.img = img; characterRegistry[charId] = char; resolve(char); };
img.onerror = () => { console.warn(`[sprites] failed to load char: ${charId}`); resolve(null); };
img.src = source;
}
});
}
/** Get a registered character definition */
export function getCharacter(charId) { return characterRegistry[charId] || null; }
/** Get all registered characters */
export function getAllCharacters() { return { ...characterRegistry }; }
/** Remove a character from the registry */
export function removeCharacter(charId) { delete characterRegistry[charId]; }
// Direction → row index in spritesheet
const DIR_ROW = { down: 0, up: 1, left: 2, right: 3 };
/**
* Draw a character from the registry using its spritesheet.
* @param {CanvasRenderingContext2D} ctx
* @param {string} charId - character ID from registry
* @param {number} screenX - top-left X on screen
* @param {number} screenY - top-left Y on screen
* @param {string} facing - 'up'|'down'|'left'|'right'
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
*/
export function drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame = 0) {
const char = characterRegistry[charId];
if (!char || !char.img) {
// Fallback: magenta box
ctx.fillStyle = '#ff44aa';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
return;
}
const row = DIR_ROW[facing] ?? 0;
const col = Math.min(walkFrame, 2);
const sx = col * char.frameW;
const sy = row * char.frameH;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(char.img, sx, sy, char.frameW, char.frameH, screenX, screenY, TILE_PX, TILE_PX);
}
// ==================== Direction mapping ==================== // ==================== Direction mapping ====================
// Map game direction to character sprite prefix // Map game direction to character sprite prefix
@@ -128,24 +195,31 @@ export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) {
} }
/** /**
* Draw an NPC * Draw an NPC — if it has a charId, uses the character registry spritesheet.
* Otherwise falls back to the default hardcoded NPC sprites.
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
* @param {number} screenX - top-left X on screen * @param {number} screenX - top-left X on screen
* @param {number} screenY - top-left Y on screen * @param {number} screenY - top-left Y on screen
* @param {string} facing - 'up'|'down'|'left'|'right' * @param {string} facing - 'up'|'down'|'left'|'right'
* @param {string} [charId] - optional character ID from registry
* @param {number} [walkFrame=0] - animation frame (0=still, 1=walk-1, 2=walk-2)
*/ */
export function drawNPC(ctx, screenX, screenY, facing) { export function drawNPC(ctx, screenX, screenY, facing, charId, walkFrame = 0) {
// If a character is registered, use the spritesheet renderer
if (charId && characterRegistry[charId]) {
drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame);
return;
}
// Fallback: legacy hardcoded sprites
const dir = DIR_TO_NPC[facing] || 'down'; const dir = DIR_TO_NPC[facing] || 'down';
const key = `npc:a-${dir}`; const key = `npc:a-${dir}`;
const img = imageCache[key]; const img = imageCache[key];
if (!img) { if (!img) {
// Fallback
ctx.fillStyle = '#ff44aa'; ctx.fillStyle = '#ff44aa';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX); ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
return; return;
} }
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
// NPC is 16x16 native = 1 tile
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX); ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
} }

809
js/world/wiringPanel.js Normal file
View File

@@ -0,0 +1,809 @@
// wiringPanel.js — Wiring panel for connecting gadget ports to module ports
// The player wires a gadget's I/O to a module door's ports, then executes
// to verify the circuit satisfies the module's logic.
// After execution, shows an animated truth table + digital waveform viewer.
import { worldState, solvePuzzle, isPuzzleSolved, startDialog } from './worldState.js';
import { showNotification } from './inventory.js';
// ==================== State ====================
let panelOpen = false;
let moduleInter = null; // the full interaction object from the map
let gadget = null; // the selected gadget from backpack
let wires = []; // [{ moduleIdx, gadgetIdx }]
let cursor = { side: 'module', index: 0 };
let selectedModule = null; // index or null (first port of a pending wire)
let selectedGadget = null;
let result = null; // { message, color } or null
let resultTimer = 0;
// Execution log state
let execLog = null; // { rows, portNames, passed, startTime, revealedRows }
const ROW_REVEAL_MS = 300; // ms between each row appearing
// ==================== Public API ====================
export function isWiringOpen() { return panelOpen; }
export function openWiringPanel(inter, gad) {
panelOpen = true;
moduleInter = inter;
gadget = gad;
wires = [];
cursor = { side: 'module', index: 0 };
selectedModule = null;
selectedGadget = null;
result = null;
execLog = null;
worldState.mode = 'wiring';
console.log(`[wiring] opened for module "${inter.label}" with gadget "${gad.name}"`);
}
export function closeWiringPanel() {
panelOpen = false;
moduleInter = null;
gadget = null;
wires = [];
execLog = null;
worldState.mode = 'world';
}
// ==================== Gadget port helpers ====================
function getGadgetPorts() {
if (!gadget) return [];
const ports = [];
const inputIds = gadget.inputIds || [];
const outputIds = gadget.outputIds || [];
for (let i = 0; i < inputIds.length; i++) {
ports.push({ name: `In ${i + 1}`, dir: 'in', gateId: inputIds[i] });
}
for (let i = 0; i < outputIds.length; i++) {
ports.push({ name: `Out ${i + 1}`, dir: 'out', gateId: outputIds[i] });
}
return ports;
}
// ==================== Input handling ====================
export function handleWiringInput(key) {
if (!panelOpen) return false;
// If execution log is showing, ESC or Enter dismisses it
if (execLog) {
if (key === 'Escape' || key === 'Enter') {
if (execLog.passed) {
closeWiringPanel();
showNotification('Module unlocked!', '⚡', '#00ff88');
startDialog([
`⚡ "${gadget.name}" passed the verification!`,
'The module hums to life and the door unlocks.'
], 'System');
} else {
execLog = null; // dismiss log, back to wiring
}
}
return true;
}
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
switch (key) {
case 'ArrowUp': case 'w': case 'W':
cursor.index = Math.max(0, cursor.index - 1);
break;
case 'ArrowDown': case 's': case 'S': {
const max = cursor.side === 'module' ? mPorts.length - 1 : gPorts.length - 1;
cursor.index = Math.min(Math.max(max, 0), cursor.index + 1);
break;
}
case 'ArrowLeft': case 'ArrowRight': case 'a': case 'A': case 'd': case 'D':
cursor.side = cursor.side === 'module' ? 'gadget' : 'module';
{ const max = cursor.side === 'module' ? mPorts.length - 1 : gPorts.length - 1;
cursor.index = Math.min(cursor.index, Math.max(max, 0)); }
break;
case 'e': case 'E': case ' ':
selectPort();
break;
case 'Enter':
executeWiring();
break;
case 'Backspace': case 'Delete': case 'x': case 'X':
removeWireAtCursor();
break;
case 'Escape':
closeWiringPanel();
break;
default:
return false;
}
return true;
}
// ==================== Wire management ====================
function selectPort() {
if (cursor.side === 'module') {
selectedModule = cursor.index;
if (selectedGadget !== null) tryCreateWire();
} else {
selectedGadget = cursor.index;
if (selectedModule !== null) tryCreateWire();
}
}
function tryCreateWire() {
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
const mPort = mPorts[selectedModule];
const gPort = gPorts[selectedGadget];
if (!mPort || !gPort) {
selectedModule = null;
selectedGadget = null;
return;
}
const valid =
(mPort.dir === 'out' && gPort.dir === 'in') ||
(mPort.dir === 'in' && gPort.dir === 'out');
if (!valid) {
result = { message: '✗ Invalid! Wire out→in only', color: '#ff4444' };
resultTimer = Date.now();
selectedModule = null;
selectedGadget = null;
return;
}
wires = wires.filter(w => w.moduleIdx !== selectedModule && w.gadgetIdx !== selectedGadget);
wires.push({ moduleIdx: selectedModule, gadgetIdx: selectedGadget });
result = { message: `✓ Wired ${mPort.name}${gPort.name}`, color: '#00e599' };
resultTimer = Date.now();
selectedModule = null;
selectedGadget = null;
}
function removeWireAtCursor() {
const key = cursor.side === 'module' ? 'moduleIdx' : 'gadgetIdx';
const before = wires.length;
wires = wires.filter(w => w[key] !== cursor.index);
if (wires.length < before) {
result = { message: 'Wire removed', color: '#ffaa00' };
resultTimer = Date.now();
}
}
// ==================== Circuit evaluation ====================
function evaluateGadgetCircuit(gates, connections, inputValues) {
const evalGates = JSON.parse(JSON.stringify(gates));
for (const g of evalGates) {
if (g.type === 'INPUT' && inputValues[g.id] !== undefined) {
g.value = inputValues[g.id];
}
}
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 ins = [];
for (let p = 0; p < inCount; p++) {
const conn = connections.find(c => c.to === g.id && c.toPort === p);
if (conn) {
const src = evalGates.find(s => s.id === conn.from);
ins.push(src ? (src.value || 0) : 0);
} else { ins.push(0); }
}
let val = 0;
switch (g.type) {
case 'AND': val = (ins[0] && ins[1]) ? 1 : 0; break;
case 'OR': val = (ins[0] || ins[1]) ? 1 : 0; break;
case 'NOT': val = ins[0] ? 0 : 1; break;
case 'NAND': val = (ins[0] && ins[1]) ? 0 : 1; break;
case 'NOR': val = (ins[0] || ins[1]) ? 0 : 1; break;
case 'XOR': val = (ins[0] !== ins[1]) ? 1 : 0; break;
case 'OUTPUT': val = ins[0] || 0; break;
default: val = g.value || 0;
}
if (val !== g.value) { g.value = val; changed = true; }
}
if (!changed) break;
}
return evalGates;
}
/**
* Build the test function + run all combos to produce the truth table,
* then pass it to the verify function. Store results for the execution log.
*/
function executeWiring() {
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
// Identify module out ports (inputs to the gadget) and in ports (outputs from gadget)
const outPorts = mPorts.filter(p => p.dir === 'out');
const inPorts = mPorts.filter(p => p.dir === 'in');
const n = outPorts.length;
// Collect truth table rows: for each input combo, run the circuit
const rows = [];
function testOnce(moduleOutputs) {
const inputValues = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'out' && gp.dir === 'in') {
inputValues[gp.gateId] = moduleOutputs[mp.name] || 0;
}
}
const evaluated = evaluateGadgetCircuit(gadget.gates, gadget.connections, inputValues);
const moduleInputs = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'in' && gp.dir === 'out') {
const outGate = evaluated.find(g => g.id === gp.gateId);
moduleInputs[mp.name] = outGate ? (outGate.value || 0) : 0;
}
}
return moduleInputs;
}
// Generate all input combos and record results
for (let combo = 0; combo < (1 << n); combo++) {
const inputs = {};
for (let i = 0; i < n; i++) {
inputs[outPorts[i].name] = (combo >> i) & 1;
}
const outputs = testOnce(inputs);
rows.push({ inputs: { ...inputs }, outputs: { ...outputs } });
}
// Build test function that the verify code calls (using our precomputed results)
function test(moduleOutputs) {
const inputValues = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'out' && gp.dir === 'in') {
inputValues[gp.gateId] = moduleOutputs[mp.name] || 0;
}
}
const evaluated = evaluateGadgetCircuit(gadget.gates, gadget.connections, inputValues);
const moduleInputs = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'in' && gp.dir === 'out') {
const outGate = evaluated.find(g => g.id === gp.gateId);
moduleInputs[mp.name] = outGate ? (outGate.value || 0) : 0;
}
}
return moduleInputs;
}
// Run verify
try {
const verifyFn = new Function('return ' + moduleInter.verify)();
const passed = verifyFn(test);
// Build port name lists for display
const portNames = {
inputs: outPorts.map(p => p.name),
outputs: inPorts.map(p => p.name)
};
// Set up execution log with animated reveal
execLog = {
rows,
portNames,
passed,
startTime: Date.now(),
totalRows: rows.length
};
if (passed && moduleInter.moduleId) {
solvePuzzle(moduleInter.moduleId);
}
} catch (e) {
result = { message: `Error: ${e.message}`, color: '#ff4444' };
resultTimer = Date.now();
console.error('[wiring] verify error:', e);
}
}
// ==================== Rendering ====================
const PANEL_BG = 'rgba(10, 12, 20, 0.95)';
const PANEL_BORDER = '#00e599';
const PORT_OUT_COLOR = '#ff6644';
const PORT_IN_COLOR = '#44aaff';
const WIRE_COLOR = '#ffdd44';
const SELECTED_COLOR = '#ffffff';
const CURSOR_COLOR = '#00ffcc';
// Waveform/log colors
const WAVE_HIGH = '#00e599';
const WAVE_LOW = '#334';
const WAVE_GRID = '#1a1d2e';
const TABLE_HEADER_BG = '#141828';
const TABLE_ROW_BG = '#0d1018';
const TABLE_ROW_ALT = '#111520';
const PASS_COLOR = '#00ff88';
const FAIL_COLOR = '#ff4444';
export function drawWiringPanel(ctx, canvasW, canvasH) {
if (!panelOpen || !moduleInter || !gadget) return;
// If execution log is active, draw that instead
if (execLog) {
drawExecutionLog(ctx, canvasW, canvasH);
return;
}
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
// Panel dimensions
const pw = Math.min(640, canvasW - 40);
const ph = Math.min(480, canvasH - 40);
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Panel background
ctx.fillStyle = PANEL_BG;
ctx.strokeStyle = PANEL_BORDER;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Title
ctx.fillStyle = '#00e599';
ctx.font = 'bold 16px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`⚡ WIRING PANEL — ${moduleInter.label || 'Module'}`, px + pw / 2, py + 12);
// Gadget name
ctx.fillStyle = '#ff44aa';
ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText(`Gadget: ${gadget.icon || '🔧'} ${gadget.name}`, px + pw / 2, py + 34);
// Column headers
const colY = py + 60;
const leftX = px + 30;
const rightX = px + pw - 30;
const portStartY = colY + 30;
const portSpacing = 40;
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#aaa';
ctx.fillText('MODULE PORTS', leftX + 80, colY);
ctx.fillText('GADGET PORTS', rightX - 80, colY);
// Column separator
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + pw / 2, colY + 15);
ctx.lineTo(px + pw / 2, py + ph - 50);
ctx.stroke();
const modulePortPositions = [];
const gadgetPortPositions = [];
// Draw module ports
for (let i = 0; i < mPorts.length; i++) {
const port = mPorts[i];
const yPos = portStartY + i * portSpacing;
const isOut = port.dir === 'out';
const dotX = leftX;
const wireX = leftX + 170;
modulePortPositions.push({ x: wireX, y: yPos + 6 });
const isCursor = cursor.side === 'module' && cursor.index === i;
const isSelected = selectedModule === i;
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(leftX - 10, yPos - 6, 190, portSpacing - 8);
}
ctx.beginPath();
ctx.arc(dotX, yPos + 6, 8, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? SELECTED_COLOR : (isOut ? PORT_OUT_COLOR : PORT_IN_COLOR);
ctx.fill();
if (isCursor) { ctx.strokeStyle = CURSOR_COLOR; ctx.lineWidth = 2; ctx.stroke(); }
ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = isCursor ? CURSOR_COLOR : '#ddd';
ctx.fillText(port.name, dotX + 14, yPos);
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX + 14, yPos + 16);
if (wires.find(w => w.moduleIdx === i)) {
ctx.fillStyle = WIRE_COLOR; ctx.fillText('⚡', dotX + 50, yPos + 16);
}
}
// Draw gadget ports
for (let i = 0; i < gPorts.length; i++) {
const port = gPorts[i];
const yPos = portStartY + i * portSpacing;
const isOut = port.dir === 'out';
const dotX = rightX;
const wireX = rightX - 170;
gadgetPortPositions.push({ x: wireX, y: yPos + 6 });
const isCursor = cursor.side === 'gadget' && cursor.index === i;
const isSelected = selectedGadget === i;
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(rightX - 180, yPos - 6, 190, portSpacing - 8);
}
ctx.beginPath();
ctx.arc(dotX, yPos + 6, 8, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? SELECTED_COLOR : (isOut ? PORT_OUT_COLOR : PORT_IN_COLOR);
ctx.fill();
if (isCursor) { ctx.strokeStyle = CURSOR_COLOR; ctx.lineWidth = 2; ctx.stroke(); }
ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = isCursor ? CURSOR_COLOR : '#ddd';
ctx.fillText(port.name, dotX - 14, yPos);
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX - 14, yPos + 16);
if (wires.find(w => w.gadgetIdx === i)) {
ctx.fillStyle = WIRE_COLOR; ctx.fillText('⚡', dotX - 60, yPos + 16);
}
}
// Draw wires (bezier)
ctx.strokeStyle = WIRE_COLOR; ctx.lineWidth = 2; ctx.setLineDash([6, 4]);
for (const wire of wires) {
const mp = modulePortPositions[wire.moduleIdx];
const gp = gadgetPortPositions[wire.gadgetIdx];
if (mp && gp) {
ctx.beginPath(); ctx.moveTo(mp.x, mp.y);
const midX = (mp.x + gp.x) / 2;
ctx.bezierCurveTo(midX, mp.y, midX, gp.y, gp.x, gp.y);
ctx.stroke();
}
}
ctx.setLineDash([]);
// Pending wire indicator
if (selectedModule !== null && selectedGadget === null) {
const mp = modulePortPositions[selectedModule];
if (mp) { ctx.beginPath(); ctx.arc(mp.x, mp.y, 5, 0, Math.PI * 2); ctx.fillStyle = SELECTED_COLOR; ctx.fill(); }
}
if (selectedGadget !== null && selectedModule === null) {
const gp = gadgetPortPositions[selectedGadget];
if (gp) { ctx.beginPath(); ctx.arc(gp.x, gp.y, 5, 0, Math.PI * 2); ctx.fillStyle = SELECTED_COLOR; ctx.fill(); }
}
// Result message (transient)
if (result && (Date.now() - resultTimer < 3000)) {
ctx.fillStyle = result.color;
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(result.message, px + pw / 2, py + ph - 35);
}
// Controls hint
ctx.fillStyle = '#555'; ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('↑↓←→: Navigate | E: Wire | X: Remove | Enter: Execute | ESC: Close', px + pw / 2, py + ph - 10);
}
// ==================== Execution Log ====================
function drawExecutionLog(ctx, canvasW, canvasH) {
const { rows, portNames, passed, startTime, totalRows } = execLog;
const elapsed = Date.now() - startTime;
const revealedRows = Math.min(totalRows, Math.floor(elapsed / ROW_REVEAL_MS));
const allRevealed = revealedRows >= totalRows;
// Show verdict after all rows + a small delay
const showVerdict = allRevealed && (elapsed > totalRows * ROW_REVEAL_MS + 400);
const allPortNames = [...portNames.inputs, ...portNames.outputs];
const numInputs = portNames.inputs.length;
const numOutputs = portNames.outputs.length;
const numCols = allPortNames.length;
// Panel sizing — full-width execution log
const pw = Math.min(720, canvasW - 40);
const tableH = 28 * (totalRows + 1) + 8; // header + rows
const waveH = 40 * numCols + 20; // waveform area
const verdictH = showVerdict ? 60 : 0;
const ph = Math.min(canvasH - 40, 70 + tableH + 20 + waveH + verdictH + 50);
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Panel
ctx.fillStyle = PANEL_BG;
ctx.strokeStyle = passed && showVerdict ? PASS_COLOR : PANEL_BORDER;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Glow effect if passed
if (passed && showVerdict) {
const glowIntensity = 0.15 + 0.1 * Math.sin(Date.now() / 300);
ctx.shadowColor = PASS_COLOR;
ctx.shadowBlur = 20;
ctx.strokeStyle = PASS_COLOR;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.stroke();
ctx.shadowBlur = 0;
}
// Title
ctx.fillStyle = '#00e599';
ctx.font = 'bold 15px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('⚡ EXECUTION LOG', px + pw / 2, py + 12);
// Subtitle — scanning animation
const dots = '.'.repeat((Math.floor(elapsed / 400) % 4));
const subtitle = allRevealed ? `All ${totalRows} test cases evaluated` : `Running test cases${dots}`;
ctx.fillStyle = '#888'; ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.fillText(subtitle, px + pw / 2, py + 32);
let curY = py + 52;
// ==================== Truth Table ====================
const colW = Math.min(70, (pw - 60) / numCols);
const tableX = px + (pw - colW * numCols) / 2;
const rowH = 28;
// Header background
ctx.fillStyle = TABLE_HEADER_BG;
roundRect(ctx, tableX - 8, curY, colW * numCols + 16, rowH, 4);
ctx.fill();
// Header labels
ctx.font = 'bold 12px "Cascadia Code", "Fira Code", monospace';
ctx.textBaseline = 'middle';
for (let c = 0; c < numCols; c++) {
const cx = tableX + c * colW + colW / 2;
const isInput = c < numInputs;
ctx.fillStyle = isInput ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.textAlign = 'center';
ctx.fillText(allPortNames[c], cx, curY + rowH / 2);
}
// Separator line under header
curY += rowH;
ctx.strokeStyle = '#333'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tableX - 4, curY);
ctx.lineTo(tableX + colW * numCols + 4, curY);
ctx.stroke();
// Vertical separator between inputs and outputs
if (numInputs > 0 && numOutputs > 0) {
const sepX = tableX + numInputs * colW;
ctx.strokeStyle = '#444'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(sepX, py + 52);
ctx.lineTo(sepX, curY + rowH * Math.min(revealedRows, totalRows) + 4);
ctx.stroke();
ctx.setLineDash([]);
}
// Data rows (animated reveal)
for (let r = 0; r < revealedRows; r++) {
const row = rows[r];
const rowY = curY + r * rowH;
const rowAge = elapsed - r * ROW_REVEAL_MS;
const alpha = Math.min(1, rowAge / 200); // fade in
// Row background
ctx.fillStyle = r % 2 === 0 ? TABLE_ROW_BG : TABLE_ROW_ALT;
ctx.globalAlpha = alpha;
ctx.fillRect(tableX - 8, rowY, colW * numCols + 16, rowH);
// Flash effect on new row
if (rowAge < 250) {
const flash = 1 - rowAge / 250;
ctx.fillStyle = `rgba(0, 229, 153, ${flash * 0.15})`;
ctx.fillRect(tableX - 8, rowY, colW * numCols + 16, rowH);
}
// Values
ctx.font = '13px "Cascadia Code", "Fira Code", monospace';
ctx.textBaseline = 'middle';
for (let c = 0; c < numCols; c++) {
const cx = tableX + c * colW + colW / 2;
const isInput = c < numInputs;
const portName = allPortNames[c];
const val = isInput ? row.inputs[portName] : row.outputs[portName];
// Color: high = bright, low = dim
ctx.fillStyle = val ? WAVE_HIGH : '#555';
ctx.textAlign = 'center';
ctx.fillText(val !== undefined ? String(val) : '?', cx, rowY + rowH / 2);
}
ctx.globalAlpha = 1;
}
curY += totalRows * rowH + 12;
// ==================== Waveform Viewer ====================
if (revealedRows > 0) {
const waveX = tableX;
const waveW = colW * numCols + 16;
const sigH = 28;
const sigSpacing = 38;
const labelW = 36;
const sigAreaW = waveW - labelW - 8;
// Section header
ctx.fillStyle = '#666'; ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('SIGNAL VIEW', waveX, curY);
curY += 16;
// Background
ctx.fillStyle = '#0a0c14';
roundRect(ctx, waveX - 8, curY - 4, waveW, sigSpacing * numCols + 12, 4);
ctx.fill();
// Grid lines (vertical, one per test case)
ctx.strokeStyle = WAVE_GRID; ctx.lineWidth = 1;
const stepW = sigAreaW / Math.max(totalRows, 1);
for (let i = 0; i <= totalRows; i++) {
const gx = waveX + labelW + i * stepW;
ctx.beginPath();
ctx.moveTo(gx, curY);
ctx.lineTo(gx, curY + sigSpacing * numCols);
ctx.stroke();
}
// Draw each signal
for (let s = 0; s < numCols; s++) {
const sigY = curY + s * sigSpacing;
const isInput = s < numInputs;
const portName = allPortNames[s];
const color = isInput ? PORT_OUT_COLOR : PORT_IN_COLOR;
// Label
ctx.fillStyle = color;
ctx.font = 'bold 11px "Cascadia Code", "Fira Code", monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(portName, waveX, sigY + sigH / 2);
// Draw square wave
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
let prevVal = null;
for (let r = 0; r < revealedRows; r++) {
const row = rows[r];
const val = isInput ? (row.inputs[portName] || 0) : (row.outputs[portName] || 0);
const x1 = waveX + labelW + r * stepW;
const x2 = x1 + stepW;
const yHigh = sigY + 3;
const yLow = sigY + sigH - 3;
const yVal = val ? yHigh : yLow;
if (r === 0) {
ctx.moveTo(x1, yVal);
} else if (prevVal !== val) {
// Transition — vertical edge
ctx.lineTo(x1, yVal);
}
ctx.lineTo(x2, yVal);
// Fill area under high signals
if (val) {
ctx.save();
ctx.fillStyle = color.replace(')', ', 0.08)').replace('rgb', 'rgba');
if (color.startsWith('#')) {
const r2 = parseInt(color.slice(1,3), 16);
const g = parseInt(color.slice(3,5), 16);
const b = parseInt(color.slice(5,7), 16);
ctx.fillStyle = `rgba(${r2},${g},${b},0.1)`;
}
ctx.fillRect(x1, yHigh, stepW, yLow - yHigh);
ctx.restore();
}
prevVal = val;
}
ctx.stroke();
// Scanline animation (moving cursor)
if (!allRevealed) {
const scanX = waveX + labelW + revealedRows * stepW;
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(scanX, sigY);
ctx.lineTo(scanX, sigY + sigH);
ctx.stroke();
ctx.setLineDash([]);
}
}
curY += sigSpacing * numCols + 16;
}
// ==================== Verdict ====================
if (showVerdict) {
const verdictY = curY;
const pulse = 0.8 + 0.2 * Math.sin(Date.now() / 200);
if (passed) {
ctx.fillStyle = PASS_COLOR;
ctx.font = `bold ${18 * pulse}px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('⚡ VERIFICATION PASSED ⚡', px + pw / 2, verdictY);
ctx.fillStyle = '#aaa'; ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText('Press Enter to unlock', px + pw / 2, verdictY + 26);
} else {
ctx.fillStyle = FAIL_COLOR;
ctx.font = 'bold 18px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('✗ VERIFICATION FAILED', px + pw / 2, verdictY);
ctx.fillStyle = '#888'; ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText('Press Esc to go back and adjust your wiring', px + pw / 2, verdictY + 26);
}
}
// Footer hint
ctx.fillStyle = '#444'; ctx.font = '10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
const hint = showVerdict
? (passed ? 'Enter: Continue | Esc: Close' : 'Esc: Back to wiring')
: 'Evaluating...';
ctx.fillText(hint, px + pw / 2, py + ph - 8);
}
// ==================== Helpers ====================
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}

View File

@@ -2,7 +2,8 @@
import { worldState, advanceDialog, startDialog } from './worldState.js'; import { worldState, advanceDialog, startDialog } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js'; import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
import { toggleDebug } from './worldRenderer.js'; import { toggleDebug } from './worldRenderer.js';
import { isBackpackOpen, openBackpack, handleBackpackInput } from './inventory.js'; import { isBackpackOpen, openBackpack, handleBackpackInput, isNamingActive, handleNamingInput } from './inventory.js';
import { isWiringOpen, handleWiringInput } from './wiringPanel.js';
const keysDown = new Set(); const keysDown = new Set();
let interactionHandler = null; let interactionHandler = null;
@@ -26,6 +27,20 @@ function onKeyDown(e) {
const key = e.key; const key = e.key;
keysDown.add(key); keysDown.add(key);
// Naming screen — route all input there
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(key);
return;
}
// Wiring panel — route all input there
if (isWiringOpen()) {
e.preventDefault();
handleWiringInput(key);
return;
}
// Backpack open — route all input there // Backpack open — route all input there
if (isBackpackOpen()) { if (isBackpackOpen()) {
e.preventDefault(); e.preventDefault();
@@ -194,6 +209,9 @@ function performInteraction() {
case 'puzzle_door': case 'puzzle_door':
if (interactionHandler) interactionHandler({ type: 'puzzleDoor', data: inter }); if (interactionHandler) interactionHandler({ type: 'puzzleDoor', data: inter });
break; break;
case 'module':
if (interactionHandler) interactionHandler({ type: 'module', data: inter });
break;
default: default:
if (inter.dialog) startDialog(inter.dialog, ''); if (inter.dialog) startDialog(inter.dialog, '');
break; break;

View File

@@ -6,7 +6,8 @@ import {
import { worldState } from './worldState.js'; import { worldState } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js'; import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
import { updateMovement } from './worldInput.js'; import { updateMovement } from './worldInput.js';
import { drawBackpack, getGadgets } from './inventory.js'; import { drawBackpack, getGadgets, drawNamingScreen, drawNotification } from './inventory.js';
import { isWiringOpen, drawWiringPanel } from './wiringPanel.js';
let canvas = null; let canvas = null;
let ctx = null; let ctx = null;
@@ -102,7 +103,7 @@ export function renderWorld(timestamp) {
if (map.npcs) { if (map.npcs) {
for (const npc of map.npcs) { for (const npc of map.npcs) {
const pos = tileToScreen(npc.x, npc.y); const pos = tileToScreen(npc.x, npc.y);
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down'); drawNPC(ctx, pos.x, pos.y, npc.facing || 'down', npc.charId || null);
} }
} }
@@ -144,6 +145,17 @@ export function renderWorld(timestamp) {
if (worldState.mode === 'inventory') { if (worldState.mode === 'inventory') {
drawBackpack(ctx, canvas.width, canvas.height); drawBackpack(ctx, canvas.width, canvas.height);
} }
// === Layer 7: Wiring panel overlay ===
if (isWiringOpen()) {
drawWiringPanel(ctx, canvas.width, canvas.height);
}
// === Layer 8: Naming screen (on top of everything) ===
drawNamingScreen(ctx, canvas.width, canvas.height);
// === Layer 8: Notification toast ===
drawNotification(ctx, canvas.width);
} }
function drawHUD(map) { function drawHUD(map) {

View File

@@ -8,6 +8,7 @@ const path = require('path');
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'public'); const STATIC_DIR = path.join(__dirname, 'public');
const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js'); const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js');
const CHARS_FILE = path.join(STATIC_DIR, 'data', 'characters.json');
const MIME = { const MIME = {
'.html': 'text/html', '.html': 'text/html',
@@ -92,6 +93,43 @@ const server = http.createServer((req, res) => {
return; return;
} }
// === API: GET /api/characters — read character data ===
if (req.method === 'GET' && req.url === '/api/characters') {
fs.readFile(CHARS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ characters: {} }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
});
return;
}
// === API: PUT /api/characters — write character data ===
if (req.method === 'PUT' && req.url === '/api/characters') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const parsed = JSON.parse(body);
// Ensure data dir exists
const dataDir = path.dirname(CHARS_FILE);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(CHARS_FILE, JSON.stringify(parsed, null, 2), 'utf-8');
const count = Object.keys(parsed.characters || {}).length;
console.log(`[server] characters.json saved (${count} characters)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, count }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
// === Static file serving === // === Static file serving ===
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url); let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
// Prevent directory traversal // Prevent directory traversal