Compare commits
7 Commits
e7b18afd1a
...
feat/pokem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0ab2fc9b | ||
|
|
9ffd9c113e | ||
|
|
9d218c8728 | ||
|
|
06807801d0 | ||
|
|
bb72c58a15 | ||
|
|
f9492bff4c | ||
|
|
6ba3fa457a |
@@ -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"]
|
||||||
|
|||||||
478
editor.html
478
editor.html
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
26
js/world/characterLoader.js
Normal file
26
js/world/characterLoader.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRendere
|
|||||||
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, openNamingScreen, showNotification } 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}`
|
||||||
|
},
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
809
js/world/wiringPanel.js
Normal 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();
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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, isNamingActive, handleNamingInput } 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;
|
||||||
@@ -33,6 +34,13 @@ function onKeyDown(e) {
|
|||||||
return;
|
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();
|
||||||
@@ -201,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;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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, drawNamingScreen, drawNotification } 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +146,12 @@ export function renderWorld(timestamp) {
|
|||||||
drawBackpack(ctx, canvas.width, canvas.height);
|
drawBackpack(ctx, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Layer 7: Naming screen (on top of everything including backpack) ===
|
// === 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);
|
drawNamingScreen(ctx, canvas.width, canvas.height);
|
||||||
|
|
||||||
// === Layer 8: Notification toast ===
|
// === Layer 8: Notification toast ===
|
||||||
|
|||||||
38
server.js
38
server.js
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user