feat: add Turing Complete-style puzzle system

Add progressive puzzle mode alongside the existing sandbox:
- 8 levels from basic gates to 2-bit adder
- Truth table verification with pass/fail feedback
- Gate restrictions per level
- Custom components system (save circuits as reusable chips)
- Save/load circuits as JSON
- Level selection sidebar with difficulty ratings
- Mode toggle: Sandbox (free play) vs Puzzle (guided levels)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 02:06:57 +01:00
parent 0f4fe27396
commit b2e367817c
10 changed files with 1733 additions and 249 deletions

View File

@@ -206,3 +206,399 @@ body {
cursor: ns-resize;
z-index: 91;
}
/* ==================== Mode Toggle ==================== */
.mode-toggle {
display: flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
}
.mode-btn {
padding: 5px 12px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
color: #888;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
border-radius: 0;
margin: 0 -1px;
}
.mode-btn:first-child { border-radius: 6px 0 0 6px; }
.mode-btn:last-child { border-radius: 0 6px 6px 0; }
.mode-btn:hover {
border-color: #00e599;
color: #fff;
}
.mode-btn.active {
background: #00e59922;
border-color: #00e599;
color: #00e599;
}
/* ==================== Puzzle Panels ==================== */
.puzzle-panel {
display: none;
position: fixed;
top: 48px;
left: 0;
width: 380px;
height: calc(100vh - 48px);
background: #12121a;
border-right: 1px solid #2a2a3a;
z-index: 95;
flex-direction: column;
overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.5);
}
.puzzle-panel.visible {
display: flex;
}
.puzzle-panel.puzzle-info {
top: 48px;
width: 340px;
}
.puzzle-header {
padding: 16px;
background: #0a0a0f;
border-bottom: 1px solid #2a2a3a;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-shrink: 0;
}
.puzzle-header h2 {
color: #00e599;
font-size: 18px;
margin: 0 0 8px 0;
}
.puzzle-header .close-btn {
background: none;
border: none;
color: #888;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.puzzle-header .close-btn:hover {
color: #ff4444;
}
.close-btn {
background: none;
border: none;
color: #888;
font-size: 20px;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.close-btn:hover {
color: #ff4444;
}
/* Level Panel */
.levels-container {
padding: 16px;
overflow-y: auto;
}
.level-category h3 {
color: #ff44aa;
font-size: 14px;
margin: 16px 0 8px 0;
padding: 8px 0;
border-bottom: 1px solid #2a2a3a;
}
.level-card {
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.level-card:hover:not(.locked) {
border-color: #00e599;
background: #1a1a3a;
}
.level-card.locked {
opacity: 0.5;
cursor: not-allowed;
}
.level-card.completed {
border-color: #00e599;
background: #00e59911;
}
.level-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.level-title {
color: #fff;
font-weight: 600;
font-size: 13px;
}
.level-difficulty {
color: #ff8833;
font-size: 11px;
letter-spacing: 2px;
}
.level-description {
color: #888;
font-size: 12px;
margin-bottom: 8px;
line-height: 1.4;
}
.level-status {
color: #00e599;
font-size: 11px;
font-weight: 600;
text-align: right;
}
.level-card.locked .level-status {
color: #ff8833;
}
/* Puzzle Content */
#puzzle-content {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.puzzle-description {
color: #aaa;
font-size: 13px;
margin-bottom: 16px;
line-height: 1.5;
}
.truth-table-section h3,
.available-gates-section h3 {
color: #ff44aa;
font-size: 13px;
margin: 12px 0 8px 0;
font-weight: 600;
}
/* Truth Table */
.truth-table {
display: grid;
gap: 1px;
margin-bottom: 16px;
background: #0a0a0f;
padding: 8px;
border-radius: 6px;
}
.truth-table-row {
display: grid;
grid-auto-flow: column;
gap: 1px;
}
.truth-table-row.header {
background: #1a1a2e;
margin-bottom: 4px;
}
.truth-cell {
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
background: #0e0e18;
text-align: center;
border-radius: 4px;
min-width: 30px;
}
.truth-table-row.header .truth-cell {
background: #1a1a2e;
color: #00e599;
}
.truth-cell.input-cell {
color: #3388ff;
}
.truth-cell.output-cell {
color: #ff8833;
}
/* Available Gates */
.available-gates-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.available-gate-btn {
padding: 6px 10px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 6px;
color: #aaa;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s;
white-space: nowrap;
}
.available-gate-btn:hover {
border-color: #00e599;
color: #fff;
background: #1a1a3a;
}
.available-gate-btn:active {
background: #00e59922;
}
/* Puzzle Actions */
.puzzle-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #2a2a3a;
}
.action-btn.verify-btn {
background: #00e599;
border: none;
color: #000;
font-weight: 600;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.action-btn.verify-btn:hover {
background: #00ff99;
box-shadow: 0 0 10px #00e59944;
}
.action-btn.hint-btn,
.action-btn.reset-btn {
background: transparent;
border: 1px solid #2a2a3a;
color: #888;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.action-btn.hint-btn:hover {
border-color: #ff44aa;
color: #ff44aa;
}
.action-btn.reset-btn:hover {
border-color: #ff8833;
color: #ff8833;
}
/* Verification Result */
.verification-result {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
font-size: 12px;
}
.verification-result.success {
background: #00e59922;
border: 1px solid #00e599;
color: #00e599;
}
.verification-result.failure {
background: #ff444422;
border: 1px solid #ff4444;
color: #ff8888;
}
.result-message {
font-weight: 600;
margin-bottom: 8px;
}
.verification-result.success .result-message {
color: #00ff99;
}
.verification-result.failure .result-message {
color: #ff6666;
}
.failed-tests {
margin-top: 8px;
font-size: 11px;
max-height: 120px;
overflow-y: auto;
}
.failed-test {
margin-bottom: 6px;
padding: 6px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
font-family: monospace;
color: #ccc;
}
#next-level-btn {
background: #00e599;
border: none;
color: #000;
font-weight: 600;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
margin-top: 8px;
transition: all 0.15s;
}
#next-level-btn:hover {
background: #00ff99;
box-shadow: 0 0 10px #00e59944;
}

View File

@@ -22,11 +22,18 @@
<div class="separator"></div>
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
<div class="toolbar-right">
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
<button class="action-btn import-btn" id="import-btn" title="Import circuit">↑ Import</button>
<button class="action-btn help-btn" id="help-btn">? Help</button>
<button class="action-btn" id="clear-btn">Clear All</button>
</div>
</div>
<!-- Hidden file input for import -->
<input type="file" id="import-file" accept=".json" style="display:none">
<!-- Mode toggle will be inserted here by puzzleUI.js -->
<canvas id="canvas"></canvas>
<div id="waveform-panel">

View File

@@ -1,8 +1,10 @@
// Entry point — initializes all modules
import { initRenderer } from './renderer.js';
import { initEvents } from './events.js';
import { initPuzzleUI } from './puzzleUI.js';
document.addEventListener('DOMContentLoaded', () => {
initRenderer();
initEvents();
initPuzzleUI();
});

206
js/components.js Normal file
View File

@@ -0,0 +1,206 @@
// Custom components system — save and reuse circuits as components
import { state } from './state.js';
import { GATE_W, GATE_H } from './constants.js';
/**
* Save current circuit as a reusable component
* Returns the component ID if successful
*/
export function saveComponentFromCircuit(name) {
// Validate inputs exist
const inputGates = state.gates.filter(g => g.type === 'INPUT');
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) {
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
}
// Create component definition
const component = {
id: sanitizeComponentName(name),
name,
inputCount: inputGates.length,
outputCount: outputGates.length,
gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections))
};
// Store in state
if (!state.customComponents) {
state.customComponents = {};
}
state.customComponents[component.id] = component;
return { success: true, component };
}
/**
* Instantiate a component on the canvas
*/
export function instantiateComponent(componentId, x, y) {
if (!state.customComponents || !state.customComponents[componentId]) {
return { success: false, error: 'Component not found' };
}
const component = state.customComponents[componentId];
const instanceId = state.nextId++;
// Create a component instance gate
const gate = {
id: instanceId,
type: `COMPONENT:${componentId}`,
x,
y,
value: 0,
component
};
state.gates.push(gate);
return { success: true, gate };
}
/**
* Evaluate a component instance
* Simulates the internal circuit and returns output
*/
export function evaluateComponent(gate, inputs) {
if (!gate.component) return 0;
const comp = gate.component;
const internalState = {
gates: JSON.parse(JSON.stringify(comp.gates)),
connections: JSON.parse(JSON.stringify(comp.connections)),
nextId: Math.max(...comp.gates.map(g => g.id), 0) + 1
};
// Set inputs
const inputGates = internalState.gates.filter(g => g.type === 'INPUT');
inputs.forEach((val, i) => {
if (inputGates[i]) inputGates[i].value = val;
});
// Evaluate internal circuit
evaluateInternalCircuit(internalState);
// Get outputs
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT');
const outputs = outputGates.map(g => g.value || 0);
return outputs;
}
/**
* Helper to evaluate internal circuit
*/
function evaluateInternalCircuit(internalState) {
const { gates, connections } = internalState;
// Simple evaluation - may need optimization for complex circuits
for (let i = 0; i < 10; i++) {
for (const gate of gates) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const inputCount = getGateInputCount(gate.type);
const inputs = [];
for (let j = 0; j < inputCount; j++) {
const conn = connections.find(c => c.to === gate.id && c.toPort === j);
if (conn) {
const srcGate = gates.find(g => g.id === conn.from);
inputs.push(srcGate ? srcGate.value || 0 : 0);
} else {
inputs.push(0);
}
}
// Evaluate based on gate type
let result = 0;
if (gate.type === 'AND') result = (inputs[0] && inputs[1]) ? 1 : 0;
else if (gate.type === 'OR') result = (inputs[0] || inputs[1]) ? 1 : 0;
else if (gate.type === 'NOT') result = inputs[0] ? 0 : 1;
else if (gate.type === 'NAND') result = (inputs[0] && inputs[1]) ? 0 : 1;
else if (gate.type === 'NOR') result = (inputs[0] || inputs[1]) ? 0 : 1;
else if (gate.type === 'XOR') result = (inputs[0] !== inputs[1]) ? 1 : 0;
else if (gate.type === 'OUTPUT') result = inputs[0] || 0;
gate.value = result;
}
}
}
/**
* Get input count for a gate type (includes component types)
*/
function getGateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1;
if (type.startsWith('COMPONENT:')) {
// Return the component's input count
return 2; // Default for now, should lookup
}
return 2;
}
/**
* Get output count for a gate type
*/
function getGateOutputCount(type) {
if (type === 'OUTPUT') return 0;
return 1;
}
/**
* Sanitize component name for use as ID
*/
function sanitizeComponentName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
}
/**
* Get all custom components
*/
export function getAllComponents() {
return state.customComponents || {};
}
/**
* Delete a component
*/
export function deleteComponent(componentId) {
if (state.customComponents) {
delete state.customComponents[componentId];
return { success: true };
}
return { success: false, error: 'Component not found' };
}
/**
* Export component data as JSON
*/
export function exportComponent(componentId) {
if (!state.customComponents || !state.customComponents[componentId]) {
return { success: false, error: 'Component not found' };
}
return { success: true, data: state.customComponents[componentId] };
}
/**
* Import component from JSON
*/
export function importComponent(data) {
if (!data.id || !data.gates || !data.connections) {
return { success: false, error: 'Invalid component data' };
}
if (!state.customComponents) {
state.customComponents = {};
}
state.customComponents[data.id] = data;
return { success: true, component: data };
}

View File

@@ -5,6 +5,9 @@ import { evaluateAll, findGateAt, findPortAt } from './gates.js';
import { manualStep, clearWaveData } from './waveform.js';
import { startSim, stopSim, adjustSpeed } from './simulation.js';
import { resize, screenToWorld } from './renderer.js';
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
import { getLevel } from './levels.js';
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
const PAN_SPEED = 40;
@@ -176,7 +179,18 @@ export function initEvents() {
// ==================== TOOLBAR ====================
document.querySelectorAll('.gate-btn').forEach(btn => {
btn.addEventListener('click', () => { state.placingGate = btn.dataset.gate; });
btn.addEventListener('click', () => {
const gateName = btn.dataset.gate;
// In puzzle mode, check if gate is allowed
if (puzzleMode && currentLevel) {
const level = getLevel(currentLevel.id);
if (!level.availableGates.includes(gateName)) {
alert(`${gateName} is not available in this level.`);
return;
}
}
state.placingGate = gateName;
});
});
document.getElementById('clear-btn').addEventListener('click', () => {
@@ -189,6 +203,33 @@ export function initEvents() {
}
});
// Export/Import
document.getElementById('export-btn').addEventListener('click', () => {
exportAsFile(`circuit-${new Date().toISOString().slice(0, 10)}.json`);
});
document.getElementById('import-btn').addEventListener('click', () => {
document.getElementById('import-file').click();
});
document.getElementById('import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await importFromFile(file);
if (result.success) {
alert('Circuit imported successfully!');
evaluateAll();
} else {
alert(`Import failed: ${result.error}`);
}
} catch (err) {
alert(`Import error: ${err.message}`);
}
// Reset file input
e.target.value = '';
});
// Help modal
document.getElementById('help-btn').addEventListener('click', () => {
document.getElementById('help-modal').classList.add('visible');

297
js/levels.js Normal file
View File

@@ -0,0 +1,297 @@
// Level system — puzzle definitions, verification, and progression
import { state } from './state.js';
import { evaluate } from './gates.js';
// Level definitions
export const LEVELS = [
{
id: 'buffer',
category: 'Logic Basics',
title: 'Buffer',
description: 'Connect input to output directly. Pass the value through.',
availableGates: ['INPUT', 'OUTPUT'],
testCases: [
{ inputs: { 0: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1 }, outputs: { 0: 1 } }
],
hints: ['Use only one INPUT and one OUTPUT gate.'],
difficulty: 1
},
{
id: 'not_gate',
category: 'Logic Basics',
title: 'NOT Gate',
description: 'Build a NOT gate using only NAND gates. Invert the input signal.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0 }, outputs: { 0: 1 } },
{ inputs: { 0: 1 }, outputs: { 0: 0 } }
],
hints: ['Connect both inputs of a NAND gate to the same signal.'],
difficulty: 1
},
{
id: 'and_gate',
category: 'Logic Basics',
title: 'AND Gate',
description: 'Build an AND gate using only NAND gates. AND = NAND with inverted output.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } }
],
hints: ['NOT(NAND) = AND. You need 2 NAND gates.', 'Use a NAND gate as a NOT on the output.'],
difficulty: 2
},
{
id: 'or_gate',
category: 'Logic Basics',
title: 'OR Gate',
description: 'Build an OR gate using NAND gates. OR = NAND(NOT A, NOT B).',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } }
],
hints: ['Invert both inputs first, then NAND them.', 'You need 3 NAND gates total.'],
difficulty: 2
},
{
id: 'xor_gate',
category: 'Logic Basics',
title: 'XOR Gate',
description: 'Build an XOR gate using NAND gates. True when inputs differ.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0 } }
],
hints: ['XOR = (A NAND B) NAND (NOT(A NAND NOT B) NAND NOT(NOT A NAND B))', 'This requires 4-5 NAND gates.'],
difficulty: 3
},
{
id: 'half_adder',
category: 'Arithmetic',
title: 'Half Adder',
description: 'Build a half adder. Two outputs: Sum (A XOR B) and Carry (A AND B).',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0: sum=0, carry=0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+1: sum=1, carry=0
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0: sum=1, carry=0
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } } // 1+1: sum=0, carry=1
],
hints: ['Two independent functions: Sum=XOR, Carry=AND.', 'You need 2 OUTPUT gates.'],
difficulty: 3
},
{
id: 'full_adder',
category: 'Arithmetic',
title: 'Full Adder',
description: 'Build a full adder with carry-in. Outputs: Sum (3-input XOR) and Carry.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0+0
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+0+1
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 0+1+0
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 0+1+1
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0+0
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 1+0+1
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0, 1: 1 } }, // 1+1+0
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1, 1: 1 } } // 1+1+1
],
hints: ['Reuse your half adder logic. A full adder = half adder + half adder + OR.'],
difficulty: 4
},
{
id: 'two_bit_adder',
category: 'Components',
title: '2-bit Adder',
description: 'Chain two full adders to add two 2-bit numbers.',
availableGates: ['INPUT', 'OUTPUT', 'FULL_ADDER'], // FULL_ADDER is saved component
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0, 2: 0 } }, // 00+00=000
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } }, // 00+01=001
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } }, // 01+01=010
{ inputs: { 0: 1, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1, 2: 1 } }, // 11+01=100
{ inputs: { 0: 1, 1: 1, 2: 1, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } } // 11+11=110
],
hints: ['Use two FULL_ADDER components chained together.', 'First adder has no carry-in (set to 0).'],
difficulty: 3
}
];
// Progress tracking
export const progress = {
unlockedLevels: ['buffer'],
completedLevels: [],
currentLevel: null,
customComponents: {} // { name -> component definition }
};
/**
* Get all available levels for display
*/
export function getAllLevels() {
return LEVELS;
}
/**
* Get a level by ID
*/
export function getLevel(id) {
return LEVELS.find(l => l.id === id);
}
/**
* Check if a level is unlocked
*/
export function isLevelUnlocked(levelId) {
return progress.unlockedLevels.includes(levelId);
}
/**
* Check if a level is completed
*/
export function isLevelCompleted(levelId) {
return progress.completedLevels.includes(levelId);
}
/**
* Verify if the current circuit passes all test cases for a level
*/
export function verifyLevel(levelId) {
const level = getLevel(levelId);
if (!level) return { passed: false, results: [], message: 'Level not found' };
const results = [];
let allPassed = true;
for (const testCase of level.testCases) {
const result = runTestCase(level, testCase);
results.push(result);
if (!result.passed) allPassed = false;
}
return {
passed: allPassed,
results,
message: allPassed ? 'All tests passed! ✓' : `${results.filter(r => r.passed).length}/${results.length} tests passed`
};
}
/**
* Run a single test case
*/
function runTestCase(level, testCase) {
// Find INPUT gates
const inputGates = state.gates.filter(g => g.type === 'INPUT');
// Find OUTPUT gates
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
// Check if we have the right number of inputs and outputs
const inputIds = Object.keys(testCase.inputs).map(Number).sort((a, b) => a - b);
const outputIds = Object.keys(testCase.outputs).map(Number).sort((a, b) => a - b);
if (inputGates.length !== inputIds.length || outputGates.length !== outputIds.length) {
return {
passed: false,
inputs: testCase.inputs,
expectedOutputs: testCase.outputs,
actualOutputs: {},
error: `Expected ${inputIds.length} inputs and ${outputIds.length} outputs, got ${inputGates.length} and ${outputGates.length}`
};
}
// Set input values
for (let i = 0; i < inputIds.length; i++) {
if (inputGates[i]) {
inputGates[i].value = testCase.inputs[i];
}
}
// Evaluate circuit
state.gates.forEach(g => {
if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0;
});
state.gates.forEach(g => evaluate(g));
// Check outputs
const expected = testCase.outputs;
let passed = true;
const actualOutputs = {};
for (let i = 0; i < outputIds.length; i++) {
const actualValue = outputGates[i]?.value || 0;
actualOutputs[i] = actualValue;
if (actualValue !== expected[i]) {
passed = false;
}
}
return {
passed,
inputs: testCase.inputs,
expectedOutputs: expected,
actualOutputs
};
}
/**
* Complete a level and unlock the next one
*/
export function completeLevel(levelId) {
const levelIndex = LEVELS.findIndex(l => l.id === levelId);
if (levelIndex >= 0 && !progress.completedLevels.includes(levelId)) {
progress.completedLevels.push(levelId);
}
// Unlock next level
if (levelIndex < LEVELS.length - 1) {
const nextId = LEVELS[levelIndex + 1].id;
if (!progress.unlockedLevels.includes(nextId)) {
progress.unlockedLevels.push(nextId);
}
}
}
/**
* Register a custom component (saved circuit)
*/
export function registerComponent(name, gateTypes, connections) {
progress.customComponents[name] = {
name,
gateTypes,
connections
};
}
/**
* Get a custom component
*/
export function getComponent(name) {
return progress.customComponents[name];
}
/**
* Get all custom components
*/
export function getAllComponents() {
return progress.customComponents;
}
/**
* Reset progress (dev/testing)
*/
export function resetProgress() {
progress.unlockedLevels = ['buffer'];
progress.completedLevels = [];
progress.currentLevel = null;
progress.customComponents = {};
}

383
js/puzzleUI.js Normal file
View File

@@ -0,0 +1,383 @@
// Puzzle UI system — level selection, puzzle mode, verification
import { state } from './state.js';
import { LEVELS, getLevel, isLevelUnlocked, isLevelCompleted, verifyLevel, completeLevel } from './levels.js';
import { evaluateAll } from './gates.js';
export let puzzleMode = false;
export let currentLevel = null;
/**
* Initialize puzzle UI
*/
export function initPuzzleUI() {
createLevelSelectionPanel();
createPuzzlePanel();
initModeToggle();
}
/**
* Create level selection panel
*/
function createLevelSelectionPanel() {
const panel = document.createElement('div');
panel.id = 'level-panel';
panel.className = 'puzzle-panel';
panel.innerHTML = `
<div class="level-header">
<h2>Levels</h2>
<button id="close-levels" class="close-btn">✕</button>
</div>
<div id="levels-container" class="levels-container"></div>
`;
document.body.appendChild(panel);
// Create level cards
const container = document.getElementById('levels-container');
const categories = new Map();
LEVELS.forEach(level => {
if (!categories.has(level.category)) {
categories.set(level.category, []);
}
categories.get(level.category).push(level);
});
categories.forEach((levels, category) => {
const categoryEl = document.createElement('div');
categoryEl.className = 'level-category';
categoryEl.innerHTML = `<h3>${category}</h3>`;
levels.forEach(level => {
const unlocked = isLevelUnlocked(level.id);
const completed = isLevelCompleted(level.id);
const card = document.createElement('div');
card.className = `level-card ${completed ? 'completed' : ''} ${!unlocked ? 'locked' : ''}`;
card.innerHTML = `
<div class="level-card-header">
<div class="level-title">${level.title}</div>
<div class="level-difficulty">${'●'.repeat(level.difficulty)}${'○'.repeat(5 - level.difficulty)}</div>
</div>
<div class="level-description">${level.description}</div>
<div class="level-status">
${completed ? '✓ COMPLETED' : unlocked ? 'READY' : '🔒 LOCKED'}
</div>
`;
if (unlocked) {
card.addEventListener('click', () => selectLevel(level.id));
}
categoryEl.appendChild(card);
});
container.appendChild(categoryEl);
});
document.getElementById('close-levels').addEventListener('click', () => {
panel.classList.remove('visible');
});
}
/**
* Create puzzle info panel (shown when level is selected)
*/
function createPuzzlePanel() {
const panel = document.createElement('div');
panel.id = 'puzzle-panel';
panel.className = 'puzzle-panel puzzle-info';
panel.innerHTML = `
<div class="puzzle-header">
<div>
<h2 id="puzzle-title">Level Title</h2>
<p id="puzzle-description" class="puzzle-description">Level description</p>
</div>
<button id="close-puzzle" class="close-btn">✕</button>
</div>
<div id="puzzle-content">
<div class="truth-table-section">
<h3>Truth Table</h3>
<div id="truth-table" class="truth-table"></div>
</div>
<div class="available-gates-section">
<h3>Available Gates</h3>
<div id="available-gates" class="available-gates-list"></div>
</div>
<div class="puzzle-actions">
<button id="verify-btn" class="action-btn verify-btn">Verify Solution</button>
<button id="hint-btn" class="action-btn hint-btn">Hint</button>
<button id="reset-puzzle-btn" class="action-btn reset-btn">Reset</button>
</div>
<div id="verification-result" class="verification-result"></div>
</div>
`;
document.body.appendChild(panel);
document.getElementById('close-puzzle').addEventListener('click', () => {
exitPuzzleMode();
});
document.getElementById('verify-btn').addEventListener('click', () => {
verifySolution();
});
document.getElementById('hint-btn').addEventListener('click', () => {
showHint();
});
document.getElementById('reset-puzzle-btn').addEventListener('click', () => {
resetPuzzle();
});
}
/**
* Initialize mode toggle in toolbar
*/
function initModeToggle() {
const toolbar = document.getElementById('toolbar');
const separator = document.createElement('div');
separator.className = 'separator';
const modeToggle = document.createElement('div');
modeToggle.id = 'mode-toggle';
modeToggle.className = 'mode-toggle';
modeToggle.innerHTML = `
<button id="mode-sandbox" class="mode-btn active" data-mode="sandbox">Sandbox</button>
<button id="mode-puzzle" class="mode-btn" data-mode="puzzle">Puzzle</button>
`;
toolbar.appendChild(separator);
toolbar.appendChild(modeToggle);
document.getElementById('mode-sandbox').addEventListener('click', () => {
setSandboxMode();
});
document.getElementById('mode-puzzle').addEventListener('click', () => {
setPuzzleMode();
});
}
/**
* Set sandbox mode
*/
export function setSandboxMode() {
puzzleMode = false;
currentLevel = null;
document.getElementById('mode-sandbox').classList.add('active');
document.getElementById('mode-puzzle').classList.remove('active');
document.getElementById('level-panel').classList.remove('visible');
document.getElementById('puzzle-panel').classList.remove('visible');
document.getElementById('toolbar').classList.remove('puzzle-mode');
}
/**
* Set puzzle mode
*/
export function setPuzzleMode() {
puzzleMode = true;
document.getElementById('mode-puzzle').classList.add('active');
document.getElementById('mode-sandbox').classList.remove('active');
document.getElementById('level-panel').classList.add('visible');
}
/**
* Select a level
*/
function selectLevel(levelId) {
const level = getLevel(levelId);
if (!level || !isLevelUnlocked(levelId)) return;
currentLevel = level;
// Update puzzle panel
document.getElementById('puzzle-title').textContent = level.title;
document.getElementById('puzzle-description').textContent = level.description;
// Render truth table
renderTruthTable(level);
// Show available gates
renderAvailableGates(level);
// Clear verification result
document.getElementById('verification-result').innerHTML = '';
// Hide level panel, show puzzle panel
document.getElementById('level-panel').classList.remove('visible');
document.getElementById('puzzle-panel').classList.add('visible');
// Clear circuit and setup for puzzle
clearCircuitForPuzzle(level);
}
/**
* Render truth table
*/
function renderTruthTable(level) {
const table = document.getElementById('truth-table');
table.innerHTML = '';
// Get input/output counts
const testCase = level.testCases[0];
const inputCount = Object.keys(testCase.inputs).length;
const outputCount = Object.keys(testCase.outputs).length;
// Create header
const header = document.createElement('div');
header.className = 'truth-table-row header';
for (let i = 0; i < inputCount; i++) {
const cell = document.createElement('div');
cell.className = 'truth-cell input-cell';
cell.textContent = `A${i}`;
header.appendChild(cell);
}
for (let i = 0; i < outputCount; i++) {
const cell = document.createElement('div');
cell.className = 'truth-cell output-cell';
cell.textContent = `Q${i}`;
header.appendChild(cell);
}
table.appendChild(header);
// Create rows
level.testCases.forEach((tc, idx) => {
const row = document.createElement('div');
row.className = 'truth-table-row';
for (let i = 0; i < inputCount; i++) {
const cell = document.createElement('div');
cell.className = 'truth-cell input-cell';
cell.textContent = tc.inputs[i];
row.appendChild(cell);
}
for (let i = 0; i < outputCount; i++) {
const cell = document.createElement('div');
cell.className = 'truth-cell output-cell';
cell.textContent = tc.outputs[i];
row.appendChild(cell);
}
table.appendChild(row);
});
}
/**
* Render available gates
*/
function renderAvailableGates(level) {
const container = document.getElementById('available-gates');
container.innerHTML = '';
level.availableGates.forEach(gateName => {
const btn = document.createElement('button');
btn.className = 'available-gate-btn';
btn.textContent = gateName;
btn.addEventListener('click', () => {
state.placingGate = gateName;
});
container.appendChild(btn);
});
}
/**
* Clear circuit for puzzle mode
*/
function clearCircuitForPuzzle(level) {
state.gates = [];
state.connections = [];
state.placingGate = null;
state.connecting = null;
evaluateAll();
}
/**
* Verify solution
*/
function verifySolution() {
if (!currentLevel) return;
const result = verifyLevel(currentLevel.id);
const resultEl = document.getElementById('verification-result');
if (result.passed) {
resultEl.className = 'verification-result success';
resultEl.innerHTML = `
<div class="result-message">✓ ${result.message}</div>
<button id="next-level-btn" class="action-btn">Next Level →</button>
`;
completeLevel(currentLevel.id);
document.getElementById('next-level-btn').addEventListener('click', () => {
const currentIdx = LEVELS.findIndex(l => l.id === currentLevel.id);
if (currentIdx < LEVELS.length - 1) {
const nextLevel = LEVELS[currentIdx + 1];
selectLevel(nextLevel.id);
} else {
resultEl.innerHTML = '<div class="result-message">🎉 All levels completed!</div>';
}
});
} else {
resultEl.className = 'verification-result failure';
resultEl.innerHTML = `<div class="result-message">✗ ${result.message}</div>`;
// Show which tests failed
const failedTests = result.results.filter(r => !r.passed);
if (failedTests.length > 0 && failedTests.length <= 3) {
resultEl.innerHTML += '<div class="failed-tests">';
failedTests.forEach(test => {
resultEl.innerHTML += `
<div class="failed-test">
Input: ${JSON.stringify(test.inputs)}
Expected: ${JSON.stringify(test.expectedOutputs)},
Got: ${JSON.stringify(test.actualOutputs)}
</div>
`;
});
resultEl.innerHTML += '</div>';
}
}
}
/**
* Show hint
*/
function showHint() {
if (!currentLevel || !currentLevel.hints) return;
const hints = currentLevel.hints;
let hintIndex = parseInt(sessionStorage.getItem(`hint_${currentLevel.id}`) || '0');
if (hintIndex >= hints.length) {
alert('No more hints available!');
return;
}
const hint = hints[hintIndex];
alert(`Hint: ${hint}`);
sessionStorage.setItem(`hint_${currentLevel.id}`, String(hintIndex + 1));
}
/**
* Reset puzzle
*/
function resetPuzzle() {
if (!currentLevel) return;
clearCircuitForPuzzle(currentLevel);
document.getElementById('verification-result').innerHTML = '';
}
/**
* Exit puzzle mode
*/
export function exitPuzzleMode() {
setSandboxMode();
}
/**
* Show level panel
*/
export function showLevelPanel() {
document.getElementById('level-panel').classList.add('visible');
}

149
js/saveLoad.js Normal file
View File

@@ -0,0 +1,149 @@
// Save/Load system — export and import circuits and progress
import { state } from './state.js';
import { progress } from './levels.js';
/**
* Save complete application state to JSON
*/
export function saveState() {
return {
timestamp: new Date().toISOString(),
circuit: {
gates: state.gates,
connections: state.connections,
nextId: state.nextId
},
camera: {
camX: state.camX,
camY: state.camY,
zoom: state.zoom
},
components: state.customComponents || {},
progress: {
unlockedLevels: progress.unlockedLevels,
completedLevels: progress.completedLevels,
currentLevel: progress.currentLevel,
customComponents: progress.customComponents
}
};
}
/**
* Load application state from JSON
*/
export function loadState(data) {
if (!data || !data.circuit) {
return { success: false, error: 'Invalid save data' };
}
try {
// Load circuit
state.gates = JSON.parse(JSON.stringify(data.circuit.gates));
state.connections = JSON.parse(JSON.stringify(data.circuit.connections));
state.nextId = data.circuit.nextId;
// Load camera
if (data.camera) {
state.camX = data.camera.camX;
state.camY = data.camera.camY;
state.zoom = data.camera.zoom;
}
// Load components
if (data.components) {
state.customComponents = JSON.parse(JSON.stringify(data.components));
}
// Load progress
if (data.progress) {
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
progress.completedLevels = data.progress.completedLevels || [];
progress.currentLevel = data.progress.currentLevel || null;
progress.customComponents = data.progress.customComponents || {};
}
return { success: true };
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* Export as JSON file (for download)
*/
export function exportAsFile(filename = 'logic-circuit.json') {
const data = saveState();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Import from file (returns Promise)
*/
export function importFromFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const result = loadState(data);
resolve(result);
} catch (err) {
reject(new Error('Failed to parse JSON'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
/**
* Export circuit as a simple netlist (text format)
*/
export function exportAsNetlist() {
let netlist = '# Logic Circuit Netlist\n\n';
netlist += '## Gates\n';
state.gates.forEach(gate => {
netlist += `${gate.type} ${gate.id} at (${gate.x.toFixed(0)}, ${gate.y.toFixed(0)})\n`;
});
netlist += '\n## Connections\n';
state.connections.forEach(conn => {
netlist += `${conn.from}:${conn.fromPort} -> ${conn.to}:${conn.toPort}\n`;
});
return netlist;
}
/**
* Copy state to clipboard as JSON (useful for sharing)
*/
export function copyToClipboard() {
const data = saveState();
const json = JSON.stringify(data);
navigator.clipboard.writeText(json).then(() => {
alert('State copied to clipboard');
}).catch(() => {
alert('Failed to copy to clipboard');
});
}
/**
* Paste state from clipboard
*/
export async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
const data = JSON.parse(text);
return loadState(data);
} catch (e) {
return { success: false, error: e.message };
}
}

View File

@@ -33,5 +33,8 @@ export const state = {
// Simulation
simRunning: false,
simInterval: null,
simSpeed: 500 // ms per tick
simSpeed: 500, // ms per tick
// Puzzle/Components
customComponents: {} // { id -> component definition }
};