feat: add Turing Complete-style puzzle system
Add progressive puzzle mode alongside the existing sandbox: - 8 levels from basic gates to 2-bit adder - Truth table verification with pass/fail feedback - Gate restrictions per level - Custom components system (save circuits as reusable chips) - Save/load circuits as JSON - Level selection sidebar with difficulty ratings - Mode toggle: Sandbox (free play) vs Puzzle (guided levels) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
396
css/style.css
396
css/style.css
@@ -206,3 +206,399 @@ body {
|
|||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
z-index: 91;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,18 @@
|
|||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
|
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
|
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
|
||||||
|
<button class="action-btn import-btn" id="import-btn" title="Import circuit">↑ Import</button>
|
||||||
<button class="action-btn help-btn" id="help-btn">? Help</button>
|
<button class="action-btn help-btn" id="help-btn">? Help</button>
|
||||||
<button class="action-btn" id="clear-btn">Clear All</button>
|
<button class="action-btn" id="clear-btn">Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input for import -->
|
||||||
|
<input type="file" id="import-file" accept=".json" style="display:none">
|
||||||
|
|
||||||
|
<!-- Mode toggle will be inserted here by puzzleUI.js -->
|
||||||
|
|
||||||
<canvas id="canvas"></canvas>
|
<canvas id="canvas"></canvas>
|
||||||
|
|
||||||
<div id="waveform-panel">
|
<div id="waveform-panel">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Entry point — initializes all modules
|
// Entry point — initializes all modules
|
||||||
import { initRenderer } from './renderer.js';
|
import { initRenderer } from './renderer.js';
|
||||||
import { initEvents } from './events.js';
|
import { initEvents } from './events.js';
|
||||||
|
import { initPuzzleUI } from './puzzleUI.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initRenderer();
|
initRenderer();
|
||||||
initEvents();
|
initEvents();
|
||||||
|
initPuzzleUI();
|
||||||
});
|
});
|
||||||
|
|||||||
206
js/components.js
Normal file
206
js/components.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
// Custom components system — save and reuse circuits as components
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { GATE_W, GATE_H } from './constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current circuit as a reusable component
|
||||||
|
* Returns the component ID if successful
|
||||||
|
*/
|
||||||
|
export function saveComponentFromCircuit(name) {
|
||||||
|
// Validate inputs exist
|
||||||
|
const inputGates = state.gates.filter(g => g.type === 'INPUT');
|
||||||
|
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
||||||
|
|
||||||
|
if (inputGates.length === 0 || outputGates.length === 0) {
|
||||||
|
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create component definition
|
||||||
|
const component = {
|
||||||
|
id: sanitizeComponentName(name),
|
||||||
|
name,
|
||||||
|
inputCount: inputGates.length,
|
||||||
|
outputCount: outputGates.length,
|
||||||
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||||
|
connections: JSON.parse(JSON.stringify(state.connections))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in state
|
||||||
|
if (!state.customComponents) {
|
||||||
|
state.customComponents = {};
|
||||||
|
}
|
||||||
|
state.customComponents[component.id] = component;
|
||||||
|
|
||||||
|
return { success: true, component };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a component on the canvas
|
||||||
|
*/
|
||||||
|
export function instantiateComponent(componentId, x, y) {
|
||||||
|
if (!state.customComponents || !state.customComponents[componentId]) {
|
||||||
|
return { success: false, error: 'Component not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = state.customComponents[componentId];
|
||||||
|
const instanceId = state.nextId++;
|
||||||
|
|
||||||
|
// Create a component instance gate
|
||||||
|
const gate = {
|
||||||
|
id: instanceId,
|
||||||
|
type: `COMPONENT:${componentId}`,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
value: 0,
|
||||||
|
component
|
||||||
|
};
|
||||||
|
|
||||||
|
state.gates.push(gate);
|
||||||
|
|
||||||
|
return { success: true, gate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a component instance
|
||||||
|
* Simulates the internal circuit and returns output
|
||||||
|
*/
|
||||||
|
export function evaluateComponent(gate, inputs) {
|
||||||
|
if (!gate.component) return 0;
|
||||||
|
|
||||||
|
const comp = gate.component;
|
||||||
|
const internalState = {
|
||||||
|
gates: JSON.parse(JSON.stringify(comp.gates)),
|
||||||
|
connections: JSON.parse(JSON.stringify(comp.connections)),
|
||||||
|
nextId: Math.max(...comp.gates.map(g => g.id), 0) + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set inputs
|
||||||
|
const inputGates = internalState.gates.filter(g => g.type === 'INPUT');
|
||||||
|
inputs.forEach((val, i) => {
|
||||||
|
if (inputGates[i]) inputGates[i].value = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evaluate internal circuit
|
||||||
|
evaluateInternalCircuit(internalState);
|
||||||
|
|
||||||
|
// Get outputs
|
||||||
|
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT');
|
||||||
|
const outputs = outputGates.map(g => g.value || 0);
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to evaluate internal circuit
|
||||||
|
*/
|
||||||
|
function evaluateInternalCircuit(internalState) {
|
||||||
|
const { gates, connections } = internalState;
|
||||||
|
|
||||||
|
// Simple evaluation - may need optimization for complex circuits
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
for (const gate of gates) {
|
||||||
|
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
|
||||||
|
|
||||||
|
const inputCount = getGateInputCount(gate.type);
|
||||||
|
const inputs = [];
|
||||||
|
|
||||||
|
for (let j = 0; j < inputCount; j++) {
|
||||||
|
const conn = connections.find(c => c.to === gate.id && c.toPort === j);
|
||||||
|
if (conn) {
|
||||||
|
const srcGate = gates.find(g => g.id === conn.from);
|
||||||
|
inputs.push(srcGate ? srcGate.value || 0 : 0);
|
||||||
|
} else {
|
||||||
|
inputs.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate based on gate type
|
||||||
|
let result = 0;
|
||||||
|
if (gate.type === 'AND') result = (inputs[0] && inputs[1]) ? 1 : 0;
|
||||||
|
else if (gate.type === 'OR') result = (inputs[0] || inputs[1]) ? 1 : 0;
|
||||||
|
else if (gate.type === 'NOT') result = inputs[0] ? 0 : 1;
|
||||||
|
else if (gate.type === 'NAND') result = (inputs[0] && inputs[1]) ? 0 : 1;
|
||||||
|
else if (gate.type === 'NOR') result = (inputs[0] || inputs[1]) ? 0 : 1;
|
||||||
|
else if (gate.type === 'XOR') result = (inputs[0] !== inputs[1]) ? 1 : 0;
|
||||||
|
else if (gate.type === 'OUTPUT') result = inputs[0] || 0;
|
||||||
|
|
||||||
|
gate.value = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input count for a gate type (includes component types)
|
||||||
|
*/
|
||||||
|
function getGateInputCount(type) {
|
||||||
|
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
||||||
|
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
||||||
|
if (type.startsWith('COMPONENT:')) {
|
||||||
|
// Return the component's input count
|
||||||
|
return 2; // Default for now, should lookup
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get output count for a gate type
|
||||||
|
*/
|
||||||
|
function getGateOutputCount(type) {
|
||||||
|
if (type === 'OUTPUT') return 0;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize component name for use as ID
|
||||||
|
*/
|
||||||
|
function sanitizeComponentName(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_|_$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom components
|
||||||
|
*/
|
||||||
|
export function getAllComponents() {
|
||||||
|
return state.customComponents || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a component
|
||||||
|
*/
|
||||||
|
export function deleteComponent(componentId) {
|
||||||
|
if (state.customComponents) {
|
||||||
|
delete state.customComponents[componentId];
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Component not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export component data as JSON
|
||||||
|
*/
|
||||||
|
export function exportComponent(componentId) {
|
||||||
|
if (!state.customComponents || !state.customComponents[componentId]) {
|
||||||
|
return { success: false, error: 'Component not found' };
|
||||||
|
}
|
||||||
|
return { success: true, data: state.customComponents[componentId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import component from JSON
|
||||||
|
*/
|
||||||
|
export function importComponent(data) {
|
||||||
|
if (!data.id || !data.gates || !data.connections) {
|
||||||
|
return { success: false, error: 'Invalid component data' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.customComponents) {
|
||||||
|
state.customComponents = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.customComponents[data.id] = data;
|
||||||
|
return { success: true, component: data };
|
||||||
|
}
|
||||||
43
js/events.js
43
js/events.js
@@ -5,6 +5,9 @@ import { evaluateAll, findGateAt, findPortAt } from './gates.js';
|
|||||||
import { manualStep, clearWaveData } from './waveform.js';
|
import { manualStep, clearWaveData } from './waveform.js';
|
||||||
import { startSim, stopSim, adjustSpeed } from './simulation.js';
|
import { startSim, stopSim, adjustSpeed } from './simulation.js';
|
||||||
import { resize, screenToWorld } from './renderer.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;
|
const PAN_SPEED = 40;
|
||||||
|
|
||||||
@@ -176,7 +179,18 @@ export function initEvents() {
|
|||||||
|
|
||||||
// ==================== TOOLBAR ====================
|
// ==================== TOOLBAR ====================
|
||||||
document.querySelectorAll('.gate-btn').forEach(btn => {
|
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', () => {
|
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
|
// Help modal
|
||||||
document.getElementById('help-btn').addEventListener('click', () => {
|
document.getElementById('help-btn').addEventListener('click', () => {
|
||||||
document.getElementById('help-modal').classList.add('visible');
|
document.getElementById('help-modal').classList.add('visible');
|
||||||
|
|||||||
297
js/levels.js
Normal file
297
js/levels.js
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// Level system — puzzle definitions, verification, and progression
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { evaluate } from './gates.js';
|
||||||
|
|
||||||
|
// Level definitions
|
||||||
|
export const LEVELS = [
|
||||||
|
{
|
||||||
|
id: 'buffer',
|
||||||
|
category: 'Logic Basics',
|
||||||
|
title: 'Buffer',
|
||||||
|
description: 'Connect input to output directly. Pass the value through.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 1 }, outputs: { 0: 1 } }
|
||||||
|
],
|
||||||
|
hints: ['Use only one INPUT and one OUTPUT gate.'],
|
||||||
|
difficulty: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not_gate',
|
||||||
|
category: 'Logic Basics',
|
||||||
|
title: 'NOT Gate',
|
||||||
|
description: 'Build a NOT gate using only NAND gates. Invert the input signal.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0 }, outputs: { 0: 1 } },
|
||||||
|
{ inputs: { 0: 1 }, outputs: { 0: 0 } }
|
||||||
|
],
|
||||||
|
hints: ['Connect both inputs of a NAND gate to the same signal.'],
|
||||||
|
difficulty: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'and_gate',
|
||||||
|
category: 'Logic Basics',
|
||||||
|
title: 'AND Gate',
|
||||||
|
description: 'Build an AND gate using only NAND gates. AND = NAND with inverted output.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } }
|
||||||
|
],
|
||||||
|
hints: ['NOT(NAND) = AND. You need 2 NAND gates.', 'Use a NAND gate as a NOT on the output.'],
|
||||||
|
difficulty: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'or_gate',
|
||||||
|
category: 'Logic Basics',
|
||||||
|
title: 'OR Gate',
|
||||||
|
description: 'Build an OR gate using NAND gates. OR = NAND(NOT A, NOT B).',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1 } },
|
||||||
|
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } },
|
||||||
|
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } }
|
||||||
|
],
|
||||||
|
hints: ['Invert both inputs first, then NAND them.', 'You need 3 NAND gates total.'],
|
||||||
|
difficulty: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'xor_gate',
|
||||||
|
category: 'Logic Basics',
|
||||||
|
title: 'XOR Gate',
|
||||||
|
description: 'Build an XOR gate using NAND gates. True when inputs differ.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0 } },
|
||||||
|
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1 } },
|
||||||
|
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } },
|
||||||
|
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0 } }
|
||||||
|
],
|
||||||
|
hints: ['XOR = (A NAND B) NAND (NOT(A NAND NOT B) NAND NOT(NOT A NAND B))', 'This requires 4-5 NAND gates.'],
|
||||||
|
difficulty: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'half_adder',
|
||||||
|
category: 'Arithmetic',
|
||||||
|
title: 'Half Adder',
|
||||||
|
description: 'Build a half adder. Two outputs: Sum (A XOR B) and Carry (A AND B).',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0: sum=0, carry=0
|
||||||
|
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+1: sum=1, carry=0
|
||||||
|
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0: sum=1, carry=0
|
||||||
|
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } } // 1+1: sum=0, carry=1
|
||||||
|
],
|
||||||
|
hints: ['Two independent functions: Sum=XOR, Carry=AND.', 'You need 2 OUTPUT gates.'],
|
||||||
|
difficulty: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'full_adder',
|
||||||
|
category: 'Arithmetic',
|
||||||
|
title: 'Full Adder',
|
||||||
|
description: 'Build a full adder with carry-in. Outputs: Sum (3-input XOR) and Carry.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0+0
|
||||||
|
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+0+1
|
||||||
|
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 0+1+0
|
||||||
|
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 0+1+1
|
||||||
|
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0+0
|
||||||
|
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 1+0+1
|
||||||
|
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0, 1: 1 } }, // 1+1+0
|
||||||
|
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1, 1: 1 } } // 1+1+1
|
||||||
|
],
|
||||||
|
hints: ['Reuse your half adder logic. A full adder = half adder + half adder + OR.'],
|
||||||
|
difficulty: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'two_bit_adder',
|
||||||
|
category: 'Components',
|
||||||
|
title: '2-bit Adder',
|
||||||
|
description: 'Chain two full adders to add two 2-bit numbers.',
|
||||||
|
availableGates: ['INPUT', 'OUTPUT', 'FULL_ADDER'], // FULL_ADDER is saved component
|
||||||
|
testCases: [
|
||||||
|
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0, 2: 0 } }, // 00+00=000
|
||||||
|
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } }, // 00+01=001
|
||||||
|
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } }, // 01+01=010
|
||||||
|
{ inputs: { 0: 1, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1, 2: 1 } }, // 11+01=100
|
||||||
|
{ inputs: { 0: 1, 1: 1, 2: 1, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } } // 11+11=110
|
||||||
|
],
|
||||||
|
hints: ['Use two FULL_ADDER components chained together.', 'First adder has no carry-in (set to 0).'],
|
||||||
|
difficulty: 3
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Progress tracking
|
||||||
|
export const progress = {
|
||||||
|
unlockedLevels: ['buffer'],
|
||||||
|
completedLevels: [],
|
||||||
|
currentLevel: null,
|
||||||
|
customComponents: {} // { name -> component definition }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available levels for display
|
||||||
|
*/
|
||||||
|
export function getAllLevels() {
|
||||||
|
return LEVELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a level by ID
|
||||||
|
*/
|
||||||
|
export function getLevel(id) {
|
||||||
|
return LEVELS.find(l => l.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a level is unlocked
|
||||||
|
*/
|
||||||
|
export function isLevelUnlocked(levelId) {
|
||||||
|
return progress.unlockedLevels.includes(levelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a level is completed
|
||||||
|
*/
|
||||||
|
export function isLevelCompleted(levelId) {
|
||||||
|
return progress.completedLevels.includes(levelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if the current circuit passes all test cases for a level
|
||||||
|
*/
|
||||||
|
export function verifyLevel(levelId) {
|
||||||
|
const level = getLevel(levelId);
|
||||||
|
if (!level) return { passed: false, results: [], message: 'Level not found' };
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
for (const testCase of level.testCases) {
|
||||||
|
const result = runTestCase(level, testCase);
|
||||||
|
results.push(result);
|
||||||
|
if (!result.passed) allPassed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: allPassed,
|
||||||
|
results,
|
||||||
|
message: allPassed ? 'All tests passed! ✓' : `${results.filter(r => r.passed).length}/${results.length} tests passed`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single test case
|
||||||
|
*/
|
||||||
|
function runTestCase(level, testCase) {
|
||||||
|
// Find INPUT gates
|
||||||
|
const inputGates = state.gates.filter(g => g.type === 'INPUT');
|
||||||
|
// Find OUTPUT gates
|
||||||
|
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
||||||
|
|
||||||
|
// Check if we have the right number of inputs and outputs
|
||||||
|
const inputIds = Object.keys(testCase.inputs).map(Number).sort((a, b) => a - b);
|
||||||
|
const outputIds = Object.keys(testCase.outputs).map(Number).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (inputGates.length !== inputIds.length || outputGates.length !== outputIds.length) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
inputs: testCase.inputs,
|
||||||
|
expectedOutputs: testCase.outputs,
|
||||||
|
actualOutputs: {},
|
||||||
|
error: `Expected ${inputIds.length} inputs and ${outputIds.length} outputs, got ${inputGates.length} and ${outputGates.length}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set input values
|
||||||
|
for (let i = 0; i < inputIds.length; i++) {
|
||||||
|
if (inputGates[i]) {
|
||||||
|
inputGates[i].value = testCase.inputs[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate circuit
|
||||||
|
state.gates.forEach(g => {
|
||||||
|
if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0;
|
||||||
|
});
|
||||||
|
state.gates.forEach(g => evaluate(g));
|
||||||
|
|
||||||
|
// Check outputs
|
||||||
|
const expected = testCase.outputs;
|
||||||
|
let passed = true;
|
||||||
|
const actualOutputs = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < outputIds.length; i++) {
|
||||||
|
const actualValue = outputGates[i]?.value || 0;
|
||||||
|
actualOutputs[i] = actualValue;
|
||||||
|
if (actualValue !== expected[i]) {
|
||||||
|
passed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
inputs: testCase.inputs,
|
||||||
|
expectedOutputs: expected,
|
||||||
|
actualOutputs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a level and unlock the next one
|
||||||
|
*/
|
||||||
|
export function completeLevel(levelId) {
|
||||||
|
const levelIndex = LEVELS.findIndex(l => l.id === levelId);
|
||||||
|
if (levelIndex >= 0 && !progress.completedLevels.includes(levelId)) {
|
||||||
|
progress.completedLevels.push(levelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock next level
|
||||||
|
if (levelIndex < LEVELS.length - 1) {
|
||||||
|
const nextId = LEVELS[levelIndex + 1].id;
|
||||||
|
if (!progress.unlockedLevels.includes(nextId)) {
|
||||||
|
progress.unlockedLevels.push(nextId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom component (saved circuit)
|
||||||
|
*/
|
||||||
|
export function registerComponent(name, gateTypes, connections) {
|
||||||
|
progress.customComponents[name] = {
|
||||||
|
name,
|
||||||
|
gateTypes,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a custom component
|
||||||
|
*/
|
||||||
|
export function getComponent(name) {
|
||||||
|
return progress.customComponents[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom components
|
||||||
|
*/
|
||||||
|
export function getAllComponents() {
|
||||||
|
return progress.customComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset progress (dev/testing)
|
||||||
|
*/
|
||||||
|
export function resetProgress() {
|
||||||
|
progress.unlockedLevels = ['buffer'];
|
||||||
|
progress.completedLevels = [];
|
||||||
|
progress.currentLevel = null;
|
||||||
|
progress.customComponents = {};
|
||||||
|
}
|
||||||
383
js/puzzleUI.js
Normal file
383
js/puzzleUI.js
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
// Puzzle UI system — level selection, puzzle mode, verification
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { LEVELS, getLevel, isLevelUnlocked, isLevelCompleted, verifyLevel, completeLevel } from './levels.js';
|
||||||
|
import { evaluateAll } from './gates.js';
|
||||||
|
|
||||||
|
export let puzzleMode = false;
|
||||||
|
export let currentLevel = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize puzzle UI
|
||||||
|
*/
|
||||||
|
export function initPuzzleUI() {
|
||||||
|
createLevelSelectionPanel();
|
||||||
|
createPuzzlePanel();
|
||||||
|
initModeToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create level selection panel
|
||||||
|
*/
|
||||||
|
function createLevelSelectionPanel() {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'level-panel';
|
||||||
|
panel.className = 'puzzle-panel';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="level-header">
|
||||||
|
<h2>Levels</h2>
|
||||||
|
<button id="close-levels" class="close-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="levels-container" class="levels-container"></div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
// Create level cards
|
||||||
|
const container = document.getElementById('levels-container');
|
||||||
|
const categories = new Map();
|
||||||
|
|
||||||
|
LEVELS.forEach(level => {
|
||||||
|
if (!categories.has(level.category)) {
|
||||||
|
categories.set(level.category, []);
|
||||||
|
}
|
||||||
|
categories.get(level.category).push(level);
|
||||||
|
});
|
||||||
|
|
||||||
|
categories.forEach((levels, category) => {
|
||||||
|
const categoryEl = document.createElement('div');
|
||||||
|
categoryEl.className = 'level-category';
|
||||||
|
categoryEl.innerHTML = `<h3>${category}</h3>`;
|
||||||
|
|
||||||
|
levels.forEach(level => {
|
||||||
|
const unlocked = isLevelUnlocked(level.id);
|
||||||
|
const completed = isLevelCompleted(level.id);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `level-card ${completed ? 'completed' : ''} ${!unlocked ? 'locked' : ''}`;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="level-card-header">
|
||||||
|
<div class="level-title">${level.title}</div>
|
||||||
|
<div class="level-difficulty">${'●'.repeat(level.difficulty)}${'○'.repeat(5 - level.difficulty)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-description">${level.description}</div>
|
||||||
|
<div class="level-status">
|
||||||
|
${completed ? '✓ COMPLETED' : unlocked ? 'READY' : '🔒 LOCKED'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
card.addEventListener('click', () => selectLevel(level.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(categoryEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('close-levels').addEventListener('click', () => {
|
||||||
|
panel.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create puzzle info panel (shown when level is selected)
|
||||||
|
*/
|
||||||
|
function createPuzzlePanel() {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'puzzle-panel';
|
||||||
|
panel.className = 'puzzle-panel puzzle-info';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="puzzle-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="puzzle-title">Level Title</h2>
|
||||||
|
<p id="puzzle-description" class="puzzle-description">Level description</p>
|
||||||
|
</div>
|
||||||
|
<button id="close-puzzle" class="close-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="puzzle-content">
|
||||||
|
<div class="truth-table-section">
|
||||||
|
<h3>Truth Table</h3>
|
||||||
|
<div id="truth-table" class="truth-table"></div>
|
||||||
|
</div>
|
||||||
|
<div class="available-gates-section">
|
||||||
|
<h3>Available Gates</h3>
|
||||||
|
<div id="available-gates" class="available-gates-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="puzzle-actions">
|
||||||
|
<button id="verify-btn" class="action-btn verify-btn">Verify Solution</button>
|
||||||
|
<button id="hint-btn" class="action-btn hint-btn">Hint</button>
|
||||||
|
<button id="reset-puzzle-btn" class="action-btn reset-btn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div id="verification-result" class="verification-result"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
document.getElementById('close-puzzle').addEventListener('click', () => {
|
||||||
|
exitPuzzleMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('verify-btn').addEventListener('click', () => {
|
||||||
|
verifySolution();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('hint-btn').addEventListener('click', () => {
|
||||||
|
showHint();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reset-puzzle-btn').addEventListener('click', () => {
|
||||||
|
resetPuzzle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize mode toggle in toolbar
|
||||||
|
*/
|
||||||
|
function initModeToggle() {
|
||||||
|
const toolbar = document.getElementById('toolbar');
|
||||||
|
const separator = document.createElement('div');
|
||||||
|
separator.className = 'separator';
|
||||||
|
|
||||||
|
const modeToggle = document.createElement('div');
|
||||||
|
modeToggle.id = 'mode-toggle';
|
||||||
|
modeToggle.className = 'mode-toggle';
|
||||||
|
modeToggle.innerHTML = `
|
||||||
|
<button id="mode-sandbox" class="mode-btn active" data-mode="sandbox">Sandbox</button>
|
||||||
|
<button id="mode-puzzle" class="mode-btn" data-mode="puzzle">Puzzle</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
toolbar.appendChild(separator);
|
||||||
|
toolbar.appendChild(modeToggle);
|
||||||
|
|
||||||
|
document.getElementById('mode-sandbox').addEventListener('click', () => {
|
||||||
|
setSandboxMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mode-puzzle').addEventListener('click', () => {
|
||||||
|
setPuzzleMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sandbox mode
|
||||||
|
*/
|
||||||
|
export function setSandboxMode() {
|
||||||
|
puzzleMode = false;
|
||||||
|
currentLevel = null;
|
||||||
|
document.getElementById('mode-sandbox').classList.add('active');
|
||||||
|
document.getElementById('mode-puzzle').classList.remove('active');
|
||||||
|
document.getElementById('level-panel').classList.remove('visible');
|
||||||
|
document.getElementById('puzzle-panel').classList.remove('visible');
|
||||||
|
document.getElementById('toolbar').classList.remove('puzzle-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set puzzle mode
|
||||||
|
*/
|
||||||
|
export function setPuzzleMode() {
|
||||||
|
puzzleMode = true;
|
||||||
|
document.getElementById('mode-puzzle').classList.add('active');
|
||||||
|
document.getElementById('mode-sandbox').classList.remove('active');
|
||||||
|
document.getElementById('level-panel').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a level
|
||||||
|
*/
|
||||||
|
function selectLevel(levelId) {
|
||||||
|
const level = getLevel(levelId);
|
||||||
|
if (!level || !isLevelUnlocked(levelId)) return;
|
||||||
|
|
||||||
|
currentLevel = level;
|
||||||
|
|
||||||
|
// Update puzzle panel
|
||||||
|
document.getElementById('puzzle-title').textContent = level.title;
|
||||||
|
document.getElementById('puzzle-description').textContent = level.description;
|
||||||
|
|
||||||
|
// Render truth table
|
||||||
|
renderTruthTable(level);
|
||||||
|
|
||||||
|
// Show available gates
|
||||||
|
renderAvailableGates(level);
|
||||||
|
|
||||||
|
// Clear verification result
|
||||||
|
document.getElementById('verification-result').innerHTML = '';
|
||||||
|
|
||||||
|
// Hide level panel, show puzzle panel
|
||||||
|
document.getElementById('level-panel').classList.remove('visible');
|
||||||
|
document.getElementById('puzzle-panel').classList.add('visible');
|
||||||
|
|
||||||
|
// Clear circuit and setup for puzzle
|
||||||
|
clearCircuitForPuzzle(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render truth table
|
||||||
|
*/
|
||||||
|
function renderTruthTable(level) {
|
||||||
|
const table = document.getElementById('truth-table');
|
||||||
|
table.innerHTML = '';
|
||||||
|
|
||||||
|
// Get input/output counts
|
||||||
|
const testCase = level.testCases[0];
|
||||||
|
const inputCount = Object.keys(testCase.inputs).length;
|
||||||
|
const outputCount = Object.keys(testCase.outputs).length;
|
||||||
|
|
||||||
|
// Create header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'truth-table-row header';
|
||||||
|
for (let i = 0; i < inputCount; i++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'truth-cell input-cell';
|
||||||
|
cell.textContent = `A${i}`;
|
||||||
|
header.appendChild(cell);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < outputCount; i++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'truth-cell output-cell';
|
||||||
|
cell.textContent = `Q${i}`;
|
||||||
|
header.appendChild(cell);
|
||||||
|
}
|
||||||
|
table.appendChild(header);
|
||||||
|
|
||||||
|
// Create rows
|
||||||
|
level.testCases.forEach((tc, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'truth-table-row';
|
||||||
|
|
||||||
|
for (let i = 0; i < inputCount; i++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'truth-cell input-cell';
|
||||||
|
cell.textContent = tc.inputs[i];
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < outputCount; i++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'truth-cell output-cell';
|
||||||
|
cell.textContent = tc.outputs[i];
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render available gates
|
||||||
|
*/
|
||||||
|
function renderAvailableGates(level) {
|
||||||
|
const container = document.getElementById('available-gates');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
level.availableGates.forEach(gateName => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'available-gate-btn';
|
||||||
|
btn.textContent = gateName;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.placingGate = gateName;
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear circuit for puzzle mode
|
||||||
|
*/
|
||||||
|
function clearCircuitForPuzzle(level) {
|
||||||
|
state.gates = [];
|
||||||
|
state.connections = [];
|
||||||
|
state.placingGate = null;
|
||||||
|
state.connecting = null;
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify solution
|
||||||
|
*/
|
||||||
|
function verifySolution() {
|
||||||
|
if (!currentLevel) return;
|
||||||
|
|
||||||
|
const result = verifyLevel(currentLevel.id);
|
||||||
|
const resultEl = document.getElementById('verification-result');
|
||||||
|
|
||||||
|
if (result.passed) {
|
||||||
|
resultEl.className = 'verification-result success';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="result-message">✓ ${result.message}</div>
|
||||||
|
<button id="next-level-btn" class="action-btn">Next Level →</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
completeLevel(currentLevel.id);
|
||||||
|
|
||||||
|
document.getElementById('next-level-btn').addEventListener('click', () => {
|
||||||
|
const currentIdx = LEVELS.findIndex(l => l.id === currentLevel.id);
|
||||||
|
if (currentIdx < LEVELS.length - 1) {
|
||||||
|
const nextLevel = LEVELS[currentIdx + 1];
|
||||||
|
selectLevel(nextLevel.id);
|
||||||
|
} else {
|
||||||
|
resultEl.innerHTML = '<div class="result-message">🎉 All levels completed!</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'verification-result failure';
|
||||||
|
resultEl.innerHTML = `<div class="result-message">✗ ${result.message}</div>`;
|
||||||
|
|
||||||
|
// Show which tests failed
|
||||||
|
const failedTests = result.results.filter(r => !r.passed);
|
||||||
|
if (failedTests.length > 0 && failedTests.length <= 3) {
|
||||||
|
resultEl.innerHTML += '<div class="failed-tests">';
|
||||||
|
failedTests.forEach(test => {
|
||||||
|
resultEl.innerHTML += `
|
||||||
|
<div class="failed-test">
|
||||||
|
Input: ${JSON.stringify(test.inputs)} →
|
||||||
|
Expected: ${JSON.stringify(test.expectedOutputs)},
|
||||||
|
Got: ${JSON.stringify(test.actualOutputs)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
resultEl.innerHTML += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show hint
|
||||||
|
*/
|
||||||
|
function showHint() {
|
||||||
|
if (!currentLevel || !currentLevel.hints) return;
|
||||||
|
|
||||||
|
const hints = currentLevel.hints;
|
||||||
|
let hintIndex = parseInt(sessionStorage.getItem(`hint_${currentLevel.id}`) || '0');
|
||||||
|
|
||||||
|
if (hintIndex >= hints.length) {
|
||||||
|
alert('No more hints available!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = hints[hintIndex];
|
||||||
|
alert(`Hint: ${hint}`);
|
||||||
|
sessionStorage.setItem(`hint_${currentLevel.id}`, String(hintIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset puzzle
|
||||||
|
*/
|
||||||
|
function resetPuzzle() {
|
||||||
|
if (!currentLevel) return;
|
||||||
|
clearCircuitForPuzzle(currentLevel);
|
||||||
|
document.getElementById('verification-result').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit puzzle mode
|
||||||
|
*/
|
||||||
|
export function exitPuzzleMode() {
|
||||||
|
setSandboxMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show level panel
|
||||||
|
*/
|
||||||
|
export function showLevelPanel() {
|
||||||
|
document.getElementById('level-panel').classList.add('visible');
|
||||||
|
}
|
||||||
149
js/saveLoad.js
Normal file
149
js/saveLoad.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// Save/Load system — export and import circuits and progress
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { progress } from './levels.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save complete application state to JSON
|
||||||
|
*/
|
||||||
|
export function saveState() {
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
circuit: {
|
||||||
|
gates: state.gates,
|
||||||
|
connections: state.connections,
|
||||||
|
nextId: state.nextId
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
camX: state.camX,
|
||||||
|
camY: state.camY,
|
||||||
|
zoom: state.zoom
|
||||||
|
},
|
||||||
|
components: state.customComponents || {},
|
||||||
|
progress: {
|
||||||
|
unlockedLevels: progress.unlockedLevels,
|
||||||
|
completedLevels: progress.completedLevels,
|
||||||
|
currentLevel: progress.currentLevel,
|
||||||
|
customComponents: progress.customComponents
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load application state from JSON
|
||||||
|
*/
|
||||||
|
export function loadState(data) {
|
||||||
|
if (!data || !data.circuit) {
|
||||||
|
return { success: false, error: 'Invalid save data' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load circuit
|
||||||
|
state.gates = JSON.parse(JSON.stringify(data.circuit.gates));
|
||||||
|
state.connections = JSON.parse(JSON.stringify(data.circuit.connections));
|
||||||
|
state.nextId = data.circuit.nextId;
|
||||||
|
|
||||||
|
// Load camera
|
||||||
|
if (data.camera) {
|
||||||
|
state.camX = data.camera.camX;
|
||||||
|
state.camY = data.camera.camY;
|
||||||
|
state.zoom = data.camera.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load components
|
||||||
|
if (data.components) {
|
||||||
|
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load progress
|
||||||
|
if (data.progress) {
|
||||||
|
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
||||||
|
progress.completedLevels = data.progress.completedLevels || [];
|
||||||
|
progress.currentLevel = data.progress.currentLevel || null;
|
||||||
|
progress.customComponents = data.progress.customComponents || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export as JSON file (for download)
|
||||||
|
*/
|
||||||
|
export function exportAsFile(filename = 'logic-circuit.json') {
|
||||||
|
const data = saveState();
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import from file (returns Promise)
|
||||||
|
*/
|
||||||
|
export function importFromFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.target.result);
|
||||||
|
const result = loadState(data);
|
||||||
|
resolve(result);
|
||||||
|
} catch (err) {
|
||||||
|
reject(new Error('Failed to parse JSON'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export circuit as a simple netlist (text format)
|
||||||
|
*/
|
||||||
|
export function exportAsNetlist() {
|
||||||
|
let netlist = '# Logic Circuit Netlist\n\n';
|
||||||
|
netlist += '## Gates\n';
|
||||||
|
|
||||||
|
state.gates.forEach(gate => {
|
||||||
|
netlist += `${gate.type} ${gate.id} at (${gate.x.toFixed(0)}, ${gate.y.toFixed(0)})\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
netlist += '\n## Connections\n';
|
||||||
|
state.connections.forEach(conn => {
|
||||||
|
netlist += `${conn.from}:${conn.fromPort} -> ${conn.to}:${conn.toPort}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return netlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy state to clipboard as JSON (useful for sharing)
|
||||||
|
*/
|
||||||
|
export function copyToClipboard() {
|
||||||
|
const data = saveState();
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
navigator.clipboard.writeText(json).then(() => {
|
||||||
|
alert('State copied to clipboard');
|
||||||
|
}).catch(() => {
|
||||||
|
alert('Failed to copy to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste state from clipboard
|
||||||
|
*/
|
||||||
|
export async function pasteFromClipboard() {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return loadState(data);
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,5 +33,8 @@ export const state = {
|
|||||||
// Simulation
|
// Simulation
|
||||||
simRunning: false,
|
simRunning: false,
|
||||||
simInterval: null,
|
simInterval: null,
|
||||||
simSpeed: 500 // ms per tick
|
simSpeed: 500, // ms per tick
|
||||||
|
|
||||||
|
// Puzzle/Components
|
||||||
|
customComponents: {} // { id -> component definition }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user