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:
@@ -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
206
js/components.js
Normal 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 };
|
||||
}
|
||||
43
js/events.js
43
js/events.js
@@ -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
297
js/levels.js
Normal 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
383
js/puzzleUI.js
Normal 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
149
js/saveLoad.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
494
js/waveform.js
494
js/waveform.js
@@ -1,247 +1,247 @@
|
||||
// Waveform recording and rendering (GTKWave-style)
|
||||
import { SIGNAL_COLORS } from './constants.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
export function getTrackedGates() {
|
||||
const { gates } = state;
|
||||
const clocks = gates.filter(g => g.type === 'CLOCK');
|
||||
const inputs = gates.filter(g => g.type === 'INPUT');
|
||||
const outputs = gates.filter(g => g.type === 'OUTPUT');
|
||||
const logic = gates.filter(g => g.type !== 'INPUT' && g.type !== 'OUTPUT' && g.type !== 'CLOCK');
|
||||
return [...clocks, ...inputs, ...logic, ...outputs];
|
||||
}
|
||||
|
||||
export function getGateLabel(gate) {
|
||||
const sameType = state.gates.filter(g => g.type === gate.type);
|
||||
const idx = sameType.indexOf(gate);
|
||||
if (gate.type === 'CLOCK') return `CLK_${idx}`;
|
||||
if (gate.type === 'INPUT') return `IN_${idx}`;
|
||||
if (gate.type === 'OUTPUT') return `OUT_${idx}`;
|
||||
return `${gate.type}_${idx}`;
|
||||
}
|
||||
|
||||
export function recordSample() {
|
||||
const { gates, waveData } = state;
|
||||
|
||||
const changed = gates.some(g => {
|
||||
const data = waveData[g.id];
|
||||
if (!data || data.length === 0) return true;
|
||||
return data[data.length - 1].value !== g.value;
|
||||
});
|
||||
|
||||
if (!changed && state.timeStep > 0) return;
|
||||
|
||||
// Manual toggles advance by simSpeed too for consistency
|
||||
state.timeStep += state.simSpeed;
|
||||
gates.forEach(g => {
|
||||
if (!waveData[g.id]) waveData[g.id] = [];
|
||||
const arr = waveData[g.id];
|
||||
if (arr.length === 0 || arr[arr.length - 1].value !== g.value) {
|
||||
arr.push({ t: state.timeStep, value: g.value });
|
||||
}
|
||||
});
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function forceRecordSample() {
|
||||
// Advance time by the current simSpeed (in ms) to reflect real time
|
||||
state.timeStep += state.simSpeed;
|
||||
state.gates.forEach(g => {
|
||||
if (!state.waveData[g.id]) state.waveData[g.id] = [];
|
||||
state.waveData[g.id].push({ t: state.timeStep, value: g.value });
|
||||
});
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function manualStep() {
|
||||
// Toggle all CLOCK gates on each step (same as simTick)
|
||||
state.gates.forEach(g => {
|
||||
if (g.type === 'CLOCK') {
|
||||
g.value = g.value ? 0 : 1;
|
||||
}
|
||||
});
|
||||
// Use the lazy-loaded evaluateAll to avoid circular imports
|
||||
if (_evaluateAll) _evaluateAll();
|
||||
forceRecordSample();
|
||||
}
|
||||
|
||||
// Lazy reference to evaluateAll (set by gates.js to break circular dep)
|
||||
let _evaluateAll = null;
|
||||
export function setEvaluateAll(fn) {
|
||||
_evaluateAll = fn;
|
||||
}
|
||||
|
||||
export function updateWaveInfo() {
|
||||
const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const timeLabel = state.timeStep >= 1000
|
||||
? `${(state.timeStep/1000).toFixed(1)}s`
|
||||
: `${state.timeStep}ms`;
|
||||
document.getElementById('wave-info').textContent = `T=${timeLabel} | ${totalSamples} samples`;
|
||||
}
|
||||
|
||||
export function clearWaveData() {
|
||||
state.waveData = {};
|
||||
state.timeStep = 0;
|
||||
state.waveScroll = 0;
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function drawWaveLabels() {
|
||||
const labelsEl = document.getElementById('wave-labels');
|
||||
labelsEl.innerHTML = '';
|
||||
const tracked = getTrackedGates();
|
||||
tracked.forEach((gate, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'wave-label';
|
||||
if (gate.type === 'INPUT') div.classList.add('input-label');
|
||||
else if (gate.type === 'OUTPUT') div.classList.add('output-label');
|
||||
else div.classList.add('gate-label');
|
||||
div.textContent = getGateLabel(gate);
|
||||
div.style.color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
||||
labelsEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
export function drawWaveforms() {
|
||||
const wc = document.getElementById('wave-canvas');
|
||||
const wctx = wc.getContext('2d');
|
||||
const container = document.getElementById('wave-container');
|
||||
|
||||
wc.width = container.clientWidth - 100;
|
||||
wc.height = container.clientHeight;
|
||||
|
||||
wctx.fillStyle = '#0c0c14';
|
||||
wctx.fillRect(0, 0, wc.width, wc.height);
|
||||
|
||||
const tracked = getTrackedGates();
|
||||
const rowH = 30;
|
||||
const sigH = 20;
|
||||
const margin = (rowH - sigH) / 2;
|
||||
|
||||
if (state.timeStep === 0) {
|
||||
wctx.fillStyle = '#333';
|
||||
wctx.font = '12px "Segoe UI", system-ui';
|
||||
wctx.textAlign = 'center';
|
||||
wctx.fillText('Toggle inputs to record signals...', wc.width / 2, wc.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// pxPerMs: how many pixels per millisecond of simulation time
|
||||
const pxPerMs = state.waveZoom / 100; // waveZoom=20 → 0.2 px/ms
|
||||
|
||||
// Total width in pixels for all recorded time
|
||||
const totalPx = state.timeStep * pxPerMs;
|
||||
|
||||
// Visible width in ms
|
||||
const visibleMs = wc.width / pxPerMs;
|
||||
|
||||
// Auto-scroll: always follow the latest data, keep cursor at right edge
|
||||
state.waveScroll = Math.max(0, state.timeStep - visibleMs);
|
||||
|
||||
// Helper: convert simulation time (ms) to pixel X
|
||||
const tToX = (t) => (t - state.waveScroll) * pxPerMs;
|
||||
|
||||
// Draw time grid (every gridMs milliseconds)
|
||||
let gridMs = 500;
|
||||
if (pxPerMs * gridMs < 30) gridMs = 1000;
|
||||
if (pxPerMs * gridMs < 30) gridMs = 2000;
|
||||
if (pxPerMs * gridMs > 200) gridMs = 200;
|
||||
if (pxPerMs * gridMs > 200) gridMs = 100;
|
||||
|
||||
wctx.strokeStyle = '#151520';
|
||||
wctx.lineWidth = 1;
|
||||
const startT = Math.floor(state.waveScroll / gridMs) * gridMs;
|
||||
for (let t = startT; t <= state.timeStep; t += gridMs) {
|
||||
const x = tToX(t);
|
||||
if (x < 0 || x > wc.width) continue;
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(x, 0);
|
||||
wctx.lineTo(x, wc.height);
|
||||
wctx.stroke();
|
||||
|
||||
wctx.fillStyle = '#333';
|
||||
wctx.font = '9px monospace';
|
||||
wctx.textAlign = 'center';
|
||||
const label = t >= 1000 ? `${(t/1000).toFixed(1)}s` : `${t}ms`;
|
||||
wctx.fillText(label, x, 10);
|
||||
}
|
||||
|
||||
// Row dividers
|
||||
tracked.forEach((_, i) => {
|
||||
const y = i * rowH + rowH;
|
||||
wctx.strokeStyle = '#111118';
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(0, y);
|
||||
wctx.lineTo(wc.width, y);
|
||||
wctx.stroke();
|
||||
});
|
||||
|
||||
// Draw signals using actual timestamps
|
||||
tracked.forEach((gate, i) => {
|
||||
const data = state.waveData[gate.id] || [];
|
||||
if (data.length === 0) return;
|
||||
|
||||
const color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
||||
const y0 = i * rowH + margin;
|
||||
const yHigh = y0 + 2;
|
||||
const yLow = y0 + sigH;
|
||||
|
||||
wctx.strokeStyle = color;
|
||||
wctx.lineWidth = 1.5;
|
||||
wctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
|
||||
for (let s = 0; s < data.length; s++) {
|
||||
const sample = data[s];
|
||||
const nextT = s < data.length - 1 ? data[s + 1].t : state.timeStep;
|
||||
const x1 = tToX(sample.t);
|
||||
const x2 = tToX(nextT);
|
||||
const y = sample.value ? yHigh : yLow;
|
||||
|
||||
if (!started) {
|
||||
// Draw from time 0 to first sample
|
||||
const x0 = tToX(0);
|
||||
const initY = yLow; // default low before first sample
|
||||
if (x0 < wc.width) {
|
||||
wctx.moveTo(Math.max(0, x0), initY);
|
||||
if (sample.t > 0) wctx.lineTo(x1, initY);
|
||||
wctx.lineTo(x1, y);
|
||||
}
|
||||
started = true;
|
||||
} else {
|
||||
// Vertical transition from previous value
|
||||
const prevVal = data[s - 1].value;
|
||||
if (sample.value !== prevVal) {
|
||||
wctx.lineTo(x1, prevVal ? yHigh : yLow);
|
||||
wctx.lineTo(x1, y);
|
||||
}
|
||||
}
|
||||
// Horizontal line to next transition
|
||||
wctx.lineTo(x2, y);
|
||||
|
||||
// Fill high regions
|
||||
if (sample.value) {
|
||||
wctx.save();
|
||||
wctx.globalAlpha = 0.08;
|
||||
wctx.fillStyle = color;
|
||||
wctx.fillRect(x1, yHigh, x2 - x1, sigH);
|
||||
wctx.restore();
|
||||
}
|
||||
}
|
||||
wctx.stroke();
|
||||
});
|
||||
|
||||
// Cursor line at current time
|
||||
const cursorX = tToX(state.timeStep);
|
||||
if (cursorX >= 0 && cursorX <= wc.width) {
|
||||
wctx.strokeStyle = '#00e59966';
|
||||
wctx.lineWidth = 1;
|
||||
wctx.setLineDash([4, 3]);
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(cursorX, 0);
|
||||
wctx.lineTo(cursorX, wc.height);
|
||||
wctx.stroke();
|
||||
wctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
// Waveform recording and rendering (GTKWave-style)
|
||||
import { SIGNAL_COLORS } from './constants.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
export function getTrackedGates() {
|
||||
const { gates } = state;
|
||||
const clocks = gates.filter(g => g.type === 'CLOCK');
|
||||
const inputs = gates.filter(g => g.type === 'INPUT');
|
||||
const outputs = gates.filter(g => g.type === 'OUTPUT');
|
||||
const logic = gates.filter(g => g.type !== 'INPUT' && g.type !== 'OUTPUT' && g.type !== 'CLOCK');
|
||||
return [...clocks, ...inputs, ...logic, ...outputs];
|
||||
}
|
||||
|
||||
export function getGateLabel(gate) {
|
||||
const sameType = state.gates.filter(g => g.type === gate.type);
|
||||
const idx = sameType.indexOf(gate);
|
||||
if (gate.type === 'CLOCK') return `CLK_${idx}`;
|
||||
if (gate.type === 'INPUT') return `IN_${idx}`;
|
||||
if (gate.type === 'OUTPUT') return `OUT_${idx}`;
|
||||
return `${gate.type}_${idx}`;
|
||||
}
|
||||
|
||||
export function recordSample() {
|
||||
const { gates, waveData } = state;
|
||||
|
||||
const changed = gates.some(g => {
|
||||
const data = waveData[g.id];
|
||||
if (!data || data.length === 0) return true;
|
||||
return data[data.length - 1].value !== g.value;
|
||||
});
|
||||
|
||||
if (!changed && state.timeStep > 0) return;
|
||||
|
||||
// Manual toggles advance by simSpeed too for consistency
|
||||
state.timeStep += state.simSpeed;
|
||||
gates.forEach(g => {
|
||||
if (!waveData[g.id]) waveData[g.id] = [];
|
||||
const arr = waveData[g.id];
|
||||
if (arr.length === 0 || arr[arr.length - 1].value !== g.value) {
|
||||
arr.push({ t: state.timeStep, value: g.value });
|
||||
}
|
||||
});
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function forceRecordSample() {
|
||||
// Advance time by the current simSpeed (in ms) to reflect real time
|
||||
state.timeStep += state.simSpeed;
|
||||
state.gates.forEach(g => {
|
||||
if (!state.waveData[g.id]) state.waveData[g.id] = [];
|
||||
state.waveData[g.id].push({ t: state.timeStep, value: g.value });
|
||||
});
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function manualStep() {
|
||||
// Toggle all CLOCK gates on each step (same as simTick)
|
||||
state.gates.forEach(g => {
|
||||
if (g.type === 'CLOCK') {
|
||||
g.value = g.value ? 0 : 1;
|
||||
}
|
||||
});
|
||||
// Use the lazy-loaded evaluateAll to avoid circular imports
|
||||
if (_evaluateAll) _evaluateAll();
|
||||
forceRecordSample();
|
||||
}
|
||||
|
||||
// Lazy reference to evaluateAll (set by gates.js to break circular dep)
|
||||
let _evaluateAll = null;
|
||||
export function setEvaluateAll(fn) {
|
||||
_evaluateAll = fn;
|
||||
}
|
||||
|
||||
export function updateWaveInfo() {
|
||||
const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const timeLabel = state.timeStep >= 1000
|
||||
? `${(state.timeStep/1000).toFixed(1)}s`
|
||||
: `${state.timeStep}ms`;
|
||||
document.getElementById('wave-info').textContent = `T=${timeLabel} | ${totalSamples} samples`;
|
||||
}
|
||||
|
||||
export function clearWaveData() {
|
||||
state.waveData = {};
|
||||
state.timeStep = 0;
|
||||
state.waveScroll = 0;
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
export function drawWaveLabels() {
|
||||
const labelsEl = document.getElementById('wave-labels');
|
||||
labelsEl.innerHTML = '';
|
||||
const tracked = getTrackedGates();
|
||||
tracked.forEach((gate, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'wave-label';
|
||||
if (gate.type === 'INPUT') div.classList.add('input-label');
|
||||
else if (gate.type === 'OUTPUT') div.classList.add('output-label');
|
||||
else div.classList.add('gate-label');
|
||||
div.textContent = getGateLabel(gate);
|
||||
div.style.color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
||||
labelsEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
export function drawWaveforms() {
|
||||
const wc = document.getElementById('wave-canvas');
|
||||
const wctx = wc.getContext('2d');
|
||||
const container = document.getElementById('wave-container');
|
||||
|
||||
wc.width = container.clientWidth - 100;
|
||||
wc.height = container.clientHeight;
|
||||
|
||||
wctx.fillStyle = '#0c0c14';
|
||||
wctx.fillRect(0, 0, wc.width, wc.height);
|
||||
|
||||
const tracked = getTrackedGates();
|
||||
const rowH = 30;
|
||||
const sigH = 20;
|
||||
const margin = (rowH - sigH) / 2;
|
||||
|
||||
if (state.timeStep === 0) {
|
||||
wctx.fillStyle = '#333';
|
||||
wctx.font = '12px "Segoe UI", system-ui';
|
||||
wctx.textAlign = 'center';
|
||||
wctx.fillText('Toggle inputs to record signals...', wc.width / 2, wc.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// pxPerMs: how many pixels per millisecond of simulation time
|
||||
const pxPerMs = state.waveZoom / 100; // waveZoom=20 → 0.2 px/ms
|
||||
|
||||
// Total width in pixels for all recorded time
|
||||
const totalPx = state.timeStep * pxPerMs;
|
||||
|
||||
// Visible width in ms
|
||||
const visibleMs = wc.width / pxPerMs;
|
||||
|
||||
// Auto-scroll: always follow the latest data, keep cursor at right edge
|
||||
state.waveScroll = Math.max(0, state.timeStep - visibleMs);
|
||||
|
||||
// Helper: convert simulation time (ms) to pixel X
|
||||
const tToX = (t) => (t - state.waveScroll) * pxPerMs;
|
||||
|
||||
// Draw time grid (every gridMs milliseconds)
|
||||
let gridMs = 500;
|
||||
if (pxPerMs * gridMs < 30) gridMs = 1000;
|
||||
if (pxPerMs * gridMs < 30) gridMs = 2000;
|
||||
if (pxPerMs * gridMs > 200) gridMs = 200;
|
||||
if (pxPerMs * gridMs > 200) gridMs = 100;
|
||||
|
||||
wctx.strokeStyle = '#151520';
|
||||
wctx.lineWidth = 1;
|
||||
const startT = Math.floor(state.waveScroll / gridMs) * gridMs;
|
||||
for (let t = startT; t <= state.timeStep; t += gridMs) {
|
||||
const x = tToX(t);
|
||||
if (x < 0 || x > wc.width) continue;
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(x, 0);
|
||||
wctx.lineTo(x, wc.height);
|
||||
wctx.stroke();
|
||||
|
||||
wctx.fillStyle = '#333';
|
||||
wctx.font = '9px monospace';
|
||||
wctx.textAlign = 'center';
|
||||
const label = t >= 1000 ? `${(t/1000).toFixed(1)}s` : `${t}ms`;
|
||||
wctx.fillText(label, x, 10);
|
||||
}
|
||||
|
||||
// Row dividers
|
||||
tracked.forEach((_, i) => {
|
||||
const y = i * rowH + rowH;
|
||||
wctx.strokeStyle = '#111118';
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(0, y);
|
||||
wctx.lineTo(wc.width, y);
|
||||
wctx.stroke();
|
||||
});
|
||||
|
||||
// Draw signals using actual timestamps
|
||||
tracked.forEach((gate, i) => {
|
||||
const data = state.waveData[gate.id] || [];
|
||||
if (data.length === 0) return;
|
||||
|
||||
const color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
||||
const y0 = i * rowH + margin;
|
||||
const yHigh = y0 + 2;
|
||||
const yLow = y0 + sigH;
|
||||
|
||||
wctx.strokeStyle = color;
|
||||
wctx.lineWidth = 1.5;
|
||||
wctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
|
||||
for (let s = 0; s < data.length; s++) {
|
||||
const sample = data[s];
|
||||
const nextT = s < data.length - 1 ? data[s + 1].t : state.timeStep;
|
||||
const x1 = tToX(sample.t);
|
||||
const x2 = tToX(nextT);
|
||||
const y = sample.value ? yHigh : yLow;
|
||||
|
||||
if (!started) {
|
||||
// Draw from time 0 to first sample
|
||||
const x0 = tToX(0);
|
||||
const initY = yLow; // default low before first sample
|
||||
if (x0 < wc.width) {
|
||||
wctx.moveTo(Math.max(0, x0), initY);
|
||||
if (sample.t > 0) wctx.lineTo(x1, initY);
|
||||
wctx.lineTo(x1, y);
|
||||
}
|
||||
started = true;
|
||||
} else {
|
||||
// Vertical transition from previous value
|
||||
const prevVal = data[s - 1].value;
|
||||
if (sample.value !== prevVal) {
|
||||
wctx.lineTo(x1, prevVal ? yHigh : yLow);
|
||||
wctx.lineTo(x1, y);
|
||||
}
|
||||
}
|
||||
// Horizontal line to next transition
|
||||
wctx.lineTo(x2, y);
|
||||
|
||||
// Fill high regions
|
||||
if (sample.value) {
|
||||
wctx.save();
|
||||
wctx.globalAlpha = 0.08;
|
||||
wctx.fillStyle = color;
|
||||
wctx.fillRect(x1, yHigh, x2 - x1, sigH);
|
||||
wctx.restore();
|
||||
}
|
||||
}
|
||||
wctx.stroke();
|
||||
});
|
||||
|
||||
// Cursor line at current time
|
||||
const cursorX = tToX(state.timeStep);
|
||||
if (cursorX >= 0 && cursorX <= wc.width) {
|
||||
wctx.strokeStyle = '#00e59966';
|
||||
wctx.lineWidth = 1;
|
||||
wctx.setLineDash([4, 3]);
|
||||
wctx.beginPath();
|
||||
wctx.moveTo(cursorX, 0);
|
||||
wctx.lineTo(cursorX, wc.height);
|
||||
wctx.stroke();
|
||||
wctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user