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 = `
+
+
+ `;
+ 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.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 = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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([]);
+ }
+}