diff --git a/css/style.css b/css/style.css index b625c22..636d36f 100644 --- a/css/style.css +++ b/css/style.css @@ -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; +} diff --git a/index.html b/index.html index 3e77cd1..a28a016 100644 --- a/index.html +++ b/index.html @@ -22,11 +22,18 @@
+ +
+ + + + +
diff --git a/js/app.js b/js/app.js index aaa50ed..203dfe7 100644 --- a/js/app.js +++ b/js/app.js @@ -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(); }); diff --git a/js/components.js b/js/components.js new file mode 100644 index 0000000..7e32221 --- /dev/null +++ b/js/components.js @@ -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 }; +} diff --git a/js/events.js b/js/events.js index e67b56b..832c31a 100644 --- a/js/events.js +++ b/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'); diff --git a/js/levels.js b/js/levels.js new file mode 100644 index 0000000..3c66b1b --- /dev/null +++ b/js/levels.js @@ -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 = {}; +} diff --git a/js/puzzleUI.js b/js/puzzleUI.js new file mode 100644 index 0000000..ba65179 --- /dev/null +++ b/js/puzzleUI.js @@ -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 = ` +
+

Levels

+ +
+
+ `; + 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 = `

${category}

`; + + 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 = ` +
+
${level.title}
+
${'●'.repeat(level.difficulty)}${'○'.repeat(5 - level.difficulty)}
+
+
${level.description}
+
+ ${completed ? '✓ COMPLETED' : unlocked ? 'READY' : '🔒 LOCKED'} +
+ `; + + 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 = ` +
+
+

Level Title

+

Level description

+
+ +
+
+
+

Truth Table

+
+
+
+

Available Gates

+
+
+
+ + + +
+
+
+ `; + 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 = ` + + + `; + + 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 = ` +
✓ ${result.message}
+ + `; + + 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 = '
🎉 All levels completed!
'; + } + }); + } else { + resultEl.className = 'verification-result failure'; + resultEl.innerHTML = `
✗ ${result.message}
`; + + // Show which tests failed + const failedTests = result.results.filter(r => !r.passed); + if (failedTests.length > 0 && failedTests.length <= 3) { + resultEl.innerHTML += '
'; + failedTests.forEach(test => { + resultEl.innerHTML += ` +
+ Input: ${JSON.stringify(test.inputs)} → + Expected: ${JSON.stringify(test.expectedOutputs)}, + Got: ${JSON.stringify(test.actualOutputs)} +
+ `; + }); + resultEl.innerHTML += '
'; + } + } +} + +/** + * 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'); +} diff --git a/js/saveLoad.js b/js/saveLoad.js new file mode 100644 index 0000000..20240f8 --- /dev/null +++ b/js/saveLoad.js @@ -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 }; + } +} diff --git a/js/state.js b/js/state.js index ba5ff71..5553e3e 100644 --- a/js/state.js +++ b/js/state.js @@ -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 } }; diff --git a/js/waveform.js b/js/waveform.js index 0f6514d..6280194 100644 --- a/js/waveform.js +++ b/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([]); + } +}