Compare commits
23 Commits
920a30ffa8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbde11dfc7 | ||
|
|
a1cc631406 | ||
|
|
c116b6cf84 | ||
|
|
9ec3367253 | ||
|
|
12d7331d2c | ||
|
|
99f0fefe5c | ||
|
|
89d118f738 | ||
|
|
2fd22cc79d | ||
|
|
5bd157c059 | ||
|
|
eb22a5ff62 | ||
|
|
817dab43df | ||
|
|
1c45dc6104 | ||
|
|
bc8823bcd4 | ||
|
|
6cb3f091d4 | ||
|
|
a4292b42cf | ||
|
|
2384c489b9 | ||
|
|
d78b45841c | ||
|
|
f0f3516efa | ||
|
|
2a58ad372e | ||
|
|
53d600fcb0 | ||
|
|
268013d053 | ||
|
|
3bff1fd4b4 | ||
|
|
3f1daa77bd |
267
css/style.css
267
css/style.css
@@ -1,5 +1,34 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* ==================== Custom Scrollbar ==================== */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a3a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #00e59966;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #2a2a3a transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0a0a0f;
|
||||
@@ -12,7 +41,7 @@ body {
|
||||
#toolbar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 48px;
|
||||
height: 56px;
|
||||
background: #12121a;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
display: flex;
|
||||
@@ -49,7 +78,21 @@ body {
|
||||
.gate-btn.output-btn { border-color: #ff8833; }
|
||||
.gate-btn.output-btn:hover { border-color: #ffaa55; }
|
||||
|
||||
.separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 6px; }
|
||||
.separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 4px; }
|
||||
|
||||
.create-component-btn {
|
||||
padding: 4px 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #9900ff;
|
||||
border-radius: 6px;
|
||||
color: #9900ff;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.create-component-btn:hover { background: #252540; border-color: #cc66ff; color: #cc66ff; }
|
||||
.toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
.action-btn {
|
||||
@@ -68,10 +111,157 @@ body {
|
||||
.action-btn.sim-btn:hover { background: #ff44aa22; }
|
||||
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
|
||||
|
||||
/* ==================== Toolbar Dropdowns ==================== */
|
||||
.toolbar-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
padding: 6px 14px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dropdown-toggle:hover {
|
||||
background: #252540;
|
||||
border-color: #00e599;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toolbar-dropdown.open .dropdown-toggle {
|
||||
background: #252540;
|
||||
border-color: #00e599;
|
||||
color: #00e599;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-dropdown.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: #14141e;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 150;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-dropdown.open .dropdown-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dropdown-menu .gate-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dropdown-menu .create-component-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dropdown-menu #saved-components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dropdown-menu .component-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 12px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 5px;
|
||||
color: #9900ff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-menu .component-btn:hover {
|
||||
border-color: #9900ff;
|
||||
color: #cc66ff;
|
||||
background: #252540;
|
||||
}
|
||||
|
||||
/* Example buttons */
|
||||
.example-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.example-btn:hover {
|
||||
background: #1a1a3a;
|
||||
border-color: #00e599;
|
||||
}
|
||||
|
||||
.example-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #00e599;
|
||||
}
|
||||
|
||||
.example-btn:hover .example-name {
|
||||
color: #00ff99;
|
||||
}
|
||||
|
||||
.example-desc {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
#examples-menu {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
/* ==================== Canvas ==================== */
|
||||
#canvas {
|
||||
position: fixed;
|
||||
top: 48px; left: 0; right: 0; bottom: 0;
|
||||
top: 56px; left: 0; right: 0; bottom: 0;
|
||||
cursor: default;
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
@@ -244,14 +434,79 @@ body {
|
||||
color: #00e599;
|
||||
}
|
||||
|
||||
/* ==================== Component Editor Overlay ==================== */
|
||||
#component-editor-overlay {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
background: #1a1a2e;
|
||||
border-bottom: 2px solid #9900ff;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#component-editor-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#component-editor-title {
|
||||
color: #9900ff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#component-editor-save, #component-editor-cancel {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
#component-editor-save {
|
||||
background: #00e599;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#component-editor-save:hover {
|
||||
background: #00ff99;
|
||||
box-shadow: 0 0 10px #00e59944;
|
||||
}
|
||||
|
||||
#component-editor-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid #ff4444;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
#component-editor-cancel:hover {
|
||||
background: #ff444422;
|
||||
}
|
||||
|
||||
/* Shift canvas when editor is active */
|
||||
#component-editor-overlay:not([style*="display: none"]) ~ #canvas {
|
||||
top: calc(56px + 44px);
|
||||
}
|
||||
|
||||
/* ==================== Puzzle Panels ==================== */
|
||||
.puzzle-panel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
width: 340px;
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100vh - 56px);
|
||||
background: #12121a;
|
||||
border-right: 1px solid #2a2a3a;
|
||||
z-index: 95;
|
||||
@@ -264,7 +519,7 @@ body {
|
||||
}
|
||||
|
||||
.puzzle-panel.puzzle-info {
|
||||
top: 48px;
|
||||
top: 56px;
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
|
||||
41
index.html
41
index.html
@@ -9,17 +9,45 @@
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span class="logo">⚡ Logic Lab</span>
|
||||
|
||||
<!-- I/O Dropdown -->
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="dropdown-toggle">I/O <span class="dropdown-arrow">▾</span></button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="gate-btn input-btn" data-gate="INPUT">INPUT</button>
|
||||
<button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button>
|
||||
<button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button>
|
||||
<div class="separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gates Dropdown -->
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="dropdown-toggle">Gates <span class="dropdown-arrow">▾</span></button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="gate-btn" data-gate="AND">AND</button>
|
||||
<button class="gate-btn" data-gate="OR">OR</button>
|
||||
<button class="gate-btn" data-gate="NOT">NOT</button>
|
||||
<button class="gate-btn" data-gate="NAND">NAND</button>
|
||||
<button class="gate-btn" data-gate="NOR">NOR</button>
|
||||
<button class="gate-btn" data-gate="XOR">XOR</button>
|
||||
<div class="separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components Dropdown -->
|
||||
<div class="toolbar-dropdown" id="components-section">
|
||||
<button class="dropdown-toggle">Components <span class="dropdown-arrow">▾</span></button>
|
||||
<div class="dropdown-menu" id="components-menu">
|
||||
<button class="create-component-btn" id="create-component-btn" title="Create custom component">✚ Create</button>
|
||||
<div id="saved-components"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Examples Dropdown -->
|
||||
<div class="toolbar-dropdown" id="examples-section">
|
||||
<button class="dropdown-toggle">Examples <span class="dropdown-arrow">▾</span></button>
|
||||
<div class="dropdown-menu" id="examples-menu"></div>
|
||||
</div>
|
||||
|
||||
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
|
||||
<div class="toolbar-right">
|
||||
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
|
||||
@@ -32,6 +60,15 @@
|
||||
<!-- Hidden file input for import -->
|
||||
<input type="file" id="import-file" accept=".json" style="display:none">
|
||||
|
||||
<!-- Component Editor Overlay -->
|
||||
<div id="component-editor-overlay" style="display:none;">
|
||||
<div id="component-editor-bar">
|
||||
<span id="component-editor-title">Editing Component: </span>
|
||||
<button id="component-editor-save">Save</button>
|
||||
<button id="component-editor-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode toggle will be inserted here by puzzleUI.js -->
|
||||
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
12
js/app.js
12
js/app.js
@@ -2,9 +2,21 @@
|
||||
import { initRenderer } from './renderer.js';
|
||||
import { initEvents } from './events.js';
|
||||
import { initPuzzleUI } from './puzzleUI.js';
|
||||
import { loadFromStorage, startAutoSave } from './saveLoad.js';
|
||||
import { updateComponentButtons } from './components.js';
|
||||
import { evaluateAll } from './gates.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initRenderer();
|
||||
initEvents();
|
||||
initPuzzleUI();
|
||||
|
||||
// Restore previous session from localStorage
|
||||
if (loadFromStorage()) {
|
||||
updateComponentButtons();
|
||||
evaluateAll();
|
||||
}
|
||||
|
||||
// Auto-save every 3 seconds + on page unload
|
||||
startAutoSave(3000);
|
||||
});
|
||||
|
||||
197
js/bus.js
Normal file
197
js/bus.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// Bus system — shift+drag to cut wires and create paired bus terminals + cable
|
||||
import { state } from './state.js';
|
||||
import { getOutputPorts, getInputPorts, evaluateAll } from './gates.js';
|
||||
|
||||
/**
|
||||
* Sample a cubic bezier curve into discrete line segments.
|
||||
*/
|
||||
function sampleBezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, steps = 24) {
|
||||
const points = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const mt = 1 - t;
|
||||
const mt2 = mt * mt, mt3 = mt2 * mt;
|
||||
const t2 = t * t, t3 = t2 * t;
|
||||
points.push({
|
||||
x: mt3 * p0x + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t3 * p3x,
|
||||
y: mt3 * p0y + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t3 * p3y
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test intersection between two line segments.
|
||||
*/
|
||||
function segmentIntersect(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||
const dx1 = ax2 - ax1, dy1 = ay2 - ay1;
|
||||
const dx2 = bx2 - bx1, dy2 = by2 - by1;
|
||||
const d = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(d) < 1e-10) return null;
|
||||
|
||||
const t = ((bx1 - ax1) * dy2 - (by1 - ay1) * dx2) / d;
|
||||
const u = ((bx1 - ax1) * dy1 - (by1 - ay1) * dx1) / d;
|
||||
|
||||
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
|
||||
return { x: ax1 + t * dx1, y: ay1 + t * dy1, t };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bezier control points for a connection (matching renderer.js drawConnection).
|
||||
*/
|
||||
function getConnectionBezier(conn) {
|
||||
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||
const toGate = state.gates.find(g => g.id === conn.to);
|
||||
if (!fromGate || !toGate) return null;
|
||||
|
||||
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
|
||||
const toPort = getInputPorts(toGate)[conn.toPort];
|
||||
if (!fromPort || !toPort) return null;
|
||||
|
||||
const midX = (fromPort.x + toPort.x) / 2;
|
||||
return {
|
||||
p0x: fromPort.x, p0y: fromPort.y,
|
||||
p1x: midX, p1y: fromPort.y,
|
||||
p2x: midX, p2y: toPort.y,
|
||||
p3x: toPort.x, p3y: toPort.y
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all connections that intersect a cut line.
|
||||
* Returns array of { conn, hitPoint } sorted by position along the cut line.
|
||||
*/
|
||||
export function findIntersectingConnections(startX, startY, endX, endY) {
|
||||
const results = [];
|
||||
|
||||
for (const conn of state.connections) {
|
||||
const bez = getConnectionBezier(conn);
|
||||
if (!bez) continue;
|
||||
|
||||
const points = sampleBezier(
|
||||
bez.p0x, bez.p0y, bez.p1x, bez.p1y,
|
||||
bez.p2x, bez.p2y, bez.p3x, bez.p3y
|
||||
);
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const hit = segmentIntersect(
|
||||
startX, startY, endX, endY,
|
||||
points[i].x, points[i].y, points[i + 1].x, points[i + 1].y
|
||||
);
|
||||
if (hit) {
|
||||
results.push({ conn, hitPoint: hit });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.hitPoint.t - b.hitPoint.t);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create two paired BUS terminals from a cut line and rewire connections through them.
|
||||
*
|
||||
* Layout:
|
||||
* Wire1 ──┐ ┌── Wire1
|
||||
* Wire2 ──┤ ═══════ ├── Wire2
|
||||
* Wire3 ──┘ └── Wire3
|
||||
* Terminal IN Terminal OUT
|
||||
*
|
||||
* The thick cable between them is rendered by the renderer using busPairId.
|
||||
*/
|
||||
export function createBusFromCut() {
|
||||
const cut = state.busCutting;
|
||||
if (!cut) return;
|
||||
|
||||
const hits = findIntersectingConnections(cut.startX, cut.startY, cut.endX, cut.endY);
|
||||
if (hits.length === 0) {
|
||||
console.log('[bus] no connections intersected');
|
||||
return;
|
||||
}
|
||||
|
||||
const n = hits.length;
|
||||
console.log(`[bus] cut intersected ${n} connection(s)`);
|
||||
|
||||
// Calculate position from hit points
|
||||
const avgX = hits.reduce((s, h) => s + h.hitPoint.x, 0) / n;
|
||||
const avgY = hits.reduce((s, h) => s + h.hitPoint.y, 0) / n;
|
||||
const busH = Math.max(40, (n + 1) * 22);
|
||||
const gap = 120; // horizontal distance between the two terminals
|
||||
|
||||
// Reserve IDs
|
||||
const busInId = state.nextId++;
|
||||
const busOutId = state.nextId++;
|
||||
|
||||
// Create BUS_IN terminal (left — collects wires into bus, only input pins)
|
||||
const busIn = {
|
||||
id: busInId,
|
||||
type: `BUS_IN:${n}`,
|
||||
x: avgX - gap / 2 - 15,
|
||||
y: avgY - busH / 2,
|
||||
value: 0,
|
||||
busValues: new Array(n).fill(0),
|
||||
busPairId: busOutId
|
||||
};
|
||||
|
||||
// Create BUS_OUT terminal (right — distributes bus back to wires, only output pins)
|
||||
const busOut = {
|
||||
id: busOutId,
|
||||
type: `BUS_OUT:${n}`,
|
||||
x: avgX + gap / 2 - 15,
|
||||
y: avgY - busH / 2,
|
||||
value: 0,
|
||||
outputValues: new Array(n).fill(0),
|
||||
busPairId: busInId
|
||||
};
|
||||
|
||||
state.gates.push(busIn, busOut);
|
||||
|
||||
// Rewire: source → BUS_IN input, BUS_OUT output → destination
|
||||
// No internal connections — BUS_OUT reads from BUS_IN directly via busPairId
|
||||
hits.forEach((hit, i) => {
|
||||
const orig = hit.conn;
|
||||
|
||||
// Remove original connection
|
||||
state.connections = state.connections.filter(c => c !== orig);
|
||||
|
||||
// Source → BUS_IN input[i]
|
||||
state.connections.push({
|
||||
from: orig.from,
|
||||
fromPort: orig.fromPort,
|
||||
to: busIn.id,
|
||||
toPort: i
|
||||
});
|
||||
|
||||
// BUS_OUT output[i] → original destination
|
||||
state.connections.push({
|
||||
from: busOut.id,
|
||||
fromPort: i,
|
||||
to: orig.to,
|
||||
toPort: orig.toPort
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[bus] created BUS_IN#${busIn.id} ↔ BUS_OUT#${busOut.id} with ${n} channels`);
|
||||
evaluateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bus pairs for rendering the bus cables.
|
||||
* Returns array of { inGate, outGate } for each pair.
|
||||
*/
|
||||
export function getBusPairs() {
|
||||
const pairs = [];
|
||||
const seen = new Set();
|
||||
for (const gate of state.gates) {
|
||||
if (!gate.type.startsWith('BUS_IN:') || !gate.busPairId || seen.has(gate.id)) continue;
|
||||
const outGate = state.gates.find(g => g.id === gate.busPairId);
|
||||
if (!outGate || !outGate.type.startsWith('BUS_OUT:')) continue;
|
||||
seen.add(gate.id);
|
||||
seen.add(outGate.id);
|
||||
pairs.push({ inGate: gate, outGate });
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
334
js/components.js
334
js/components.js
@@ -2,6 +2,12 @@
|
||||
import { state } from './state.js';
|
||||
import { GATE_W, GATE_H } from './constants.js';
|
||||
|
||||
// Avoid circular imports - resize will be called from events.js
|
||||
let resizeCallback = null;
|
||||
export function setResizeCallback(fn) {
|
||||
resizeCallback = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current circuit as a reusable component
|
||||
* Returns the component ID if successful
|
||||
@@ -12,15 +18,22 @@ export function saveComponentFromCircuit(name) {
|
||||
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
||||
|
||||
if (inputGates.length === 0 || outputGates.length === 0) {
|
||||
alert('Component must have at least one INPUT and one OUTPUT');
|
||||
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
|
||||
}
|
||||
|
||||
// Store the input/output gate IDs in order so we can map ports consistently
|
||||
const inputIds = inputGates.map(g => g.id);
|
||||
const outputIds = outputGates.map(g => g.id);
|
||||
|
||||
// Create component definition
|
||||
const component = {
|
||||
id: sanitizeComponentName(name),
|
||||
name,
|
||||
inputCount: inputGates.length,
|
||||
outputCount: outputGates.length,
|
||||
inputIds,
|
||||
outputIds,
|
||||
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||
connections: JSON.parse(JSON.stringify(state.connections))
|
||||
};
|
||||
@@ -31,125 +44,114 @@ export function saveComponentFromCircuit(name) {
|
||||
}
|
||||
state.customComponents[component.id] = component;
|
||||
|
||||
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
|
||||
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
|
||||
|
||||
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
|
||||
* Evaluate a component instance.
|
||||
* Simulates the internal circuit and returns an array of output values.
|
||||
* IMPORTANT: Uses persistent internal state so latches/flip-flops retain
|
||||
* their values between evaluations (just like the main circuit).
|
||||
*/
|
||||
export function evaluateComponent(gate, inputs) {
|
||||
if (!gate.component) return 0;
|
||||
if (!gate.component) {
|
||||
console.warn('[component] evaluateComponent called without component data', gate);
|
||||
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;
|
||||
});
|
||||
// Persist internal gate state on the gate instance so latches hold their value
|
||||
if (!gate._internalGates) {
|
||||
gate._internalGates = JSON.parse(JSON.stringify(comp.gates));
|
||||
}
|
||||
const internalGates = gate._internalGates;
|
||||
const internalConns = comp.connections; // read-only, no need to clone
|
||||
|
||||
// Evaluate internal circuit
|
||||
evaluateInternalCircuit(internalState);
|
||||
// Map external inputs to internal INPUT gates using stored inputIds
|
||||
const inputIds = comp.inputIds || [];
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const targetId = inputIds[i];
|
||||
const inputGate = targetId != null
|
||||
? internalGates.find(g => g.id === targetId)
|
||||
: internalGates.filter(g => g.type === 'INPUT')[i]; // fallback for old components
|
||||
if (inputGate) {
|
||||
inputGate.value = inputs[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Get outputs
|
||||
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT');
|
||||
const outputs = outputGates.map(g => g.value || 0);
|
||||
// Iterative fixed-point evaluation (same approach as main evaluateAll)
|
||||
const MAX_ITER = 20;
|
||||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||
let changed = false;
|
||||
for (const g of internalGates) {
|
||||
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
|
||||
|
||||
const inCount = getGateInputCount(g.type);
|
||||
const gInputs = [];
|
||||
for (let j = 0; j < inCount; j++) {
|
||||
const conn = internalConns.find(c => c.to === g.id && c.toPort === j);
|
||||
if (conn) {
|
||||
const src = internalGates.find(s => s.id === conn.from);
|
||||
gInputs.push(src ? (src.value || 0) : 0);
|
||||
} else {
|
||||
gInputs.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
switch (g.type) {
|
||||
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
|
||||
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
|
||||
case 'NOT': result = gInputs[0] ? 0 : 1; break;
|
||||
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
|
||||
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
|
||||
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
|
||||
case 'OUTPUT': result = gInputs[0] || 0; break;
|
||||
default: result = 0;
|
||||
}
|
||||
|
||||
if (result !== g.value) {
|
||||
g.value = result;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
// Read outputs using stored outputIds
|
||||
const outputIds = comp.outputIds || [];
|
||||
const outputs = [];
|
||||
if (outputIds.length > 0) {
|
||||
for (const outId of outputIds) {
|
||||
const outGate = internalGates.find(g => g.id === outId);
|
||||
outputs.push(outGate ? (outGate.value || 0) : 0);
|
||||
}
|
||||
} else {
|
||||
// Fallback for old components without outputIds
|
||||
const outputGates = internalGates.filter(g => g.type === 'OUTPUT');
|
||||
for (const g of outputGates) {
|
||||
outputs.push(g.value || 0);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[component] eval "${comp.name}" inputs=[${inputs}] → outputs=[${outputs}]`,
|
||||
`(internal state preserved: ${gate._internalGates ? 'yes' : 'no'})`);
|
||||
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)
|
||||
* Get input count for a gate type
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -204,3 +206,143 @@ export function importComponent(data) {
|
||||
state.customComponents[data.id] = data;
|
||||
return { success: true, component: data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter component editor mode (new component)
|
||||
*/
|
||||
export function enterComponentEditor() {
|
||||
// Save current main circuit
|
||||
state.savedMainCircuit = {
|
||||
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||
connections: JSON.parse(JSON.stringify(state.connections)),
|
||||
nextId: state.nextId
|
||||
};
|
||||
|
||||
// Clear canvas for sub-circuit design
|
||||
state.gates = [];
|
||||
state.connections = [];
|
||||
state.nextId = 1;
|
||||
state.componentEditorActive = true;
|
||||
state.editingComponentId = null; // new component, not editing existing
|
||||
state.placingGate = null;
|
||||
state.connecting = null;
|
||||
|
||||
// Show editor overlay
|
||||
const overlay = document.getElementById('component-editor-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
document.getElementById('component-editor-title').textContent = 'Editing Component: (New)';
|
||||
|
||||
// Resize canvas to account for editor bar
|
||||
if (resizeCallback) resizeCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter component editor to edit an existing component's blueprint.
|
||||
* Loads the component's internal circuit for modification.
|
||||
*/
|
||||
export function editComponentBlueprint(gate) {
|
||||
if (!gate.component) return;
|
||||
const comp = gate.component;
|
||||
|
||||
// Save current main circuit
|
||||
state.savedMainCircuit = {
|
||||
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||
connections: JSON.parse(JSON.stringify(state.connections)),
|
||||
nextId: state.nextId
|
||||
};
|
||||
|
||||
// Load the component's internal circuit into the canvas
|
||||
state.gates = JSON.parse(JSON.stringify(comp.gates));
|
||||
state.connections = JSON.parse(JSON.stringify(comp.connections));
|
||||
// Set nextId to max existing id + 1 so new gates don't collide
|
||||
state.nextId = state.gates.reduce((max, g) => Math.max(max, g.id), 0) + 1;
|
||||
state.componentEditorActive = true;
|
||||
state.editingComponentId = comp.id; // track which component we're editing
|
||||
state.placingGate = null;
|
||||
state.connecting = null;
|
||||
|
||||
// Show editor overlay
|
||||
const overlay = document.getElementById('component-editor-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
document.getElementById('component-editor-title').textContent = `Editing Component: ${comp.name}`;
|
||||
|
||||
console.log(`[component] editing blueprint of "${comp.name}" (${comp.inputCount} in, ${comp.outputCount} out)`);
|
||||
|
||||
// Resize canvas to account for editor bar
|
||||
if (resizeCallback) resizeCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit component editor mode
|
||||
*/
|
||||
export function exitComponentEditor(name, shouldSave) {
|
||||
const overlay = document.getElementById('component-editor-overlay');
|
||||
overlay.style.display = 'none';
|
||||
|
||||
const editingId = state.editingComponentId;
|
||||
|
||||
if (shouldSave && name) {
|
||||
// Save the component (works for both new and edited)
|
||||
const result = saveComponentFromCircuit(name);
|
||||
|
||||
// Update all placed instances of this component in the main circuit.
|
||||
// Handles both: editing existing component (editingId matches) AND
|
||||
// creating a "new" component that overwrites an existing one (same sanitized name).
|
||||
if (result.success && state.savedMainCircuit) {
|
||||
const updatedComp = state.customComponents[result.component.id];
|
||||
if (updatedComp) {
|
||||
const matchId = editingId || result.component.id;
|
||||
for (const gate of state.savedMainCircuit.gates) {
|
||||
if (gate.component && gate.component.id === matchId) {
|
||||
gate.component = updatedComp;
|
||||
// Clear persisted internal state so it re-initializes from updated blueprint
|
||||
delete gate._internalGates;
|
||||
console.log(`[component] updated instance #${gate.id} with new blueprint`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore main circuit
|
||||
if (state.savedMainCircuit) {
|
||||
state.gates = state.savedMainCircuit.gates;
|
||||
state.connections = state.savedMainCircuit.connections;
|
||||
state.nextId = state.savedMainCircuit.nextId;
|
||||
state.savedMainCircuit = null;
|
||||
}
|
||||
|
||||
state.componentEditorActive = false;
|
||||
state.editingComponentId = null;
|
||||
state.placingGate = null;
|
||||
|
||||
// Update component buttons to show newly saved component
|
||||
updateComponentButtons();
|
||||
|
||||
// Resize canvas via callback
|
||||
if (resizeCallback) resizeCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update component buttons in toolbar
|
||||
*/
|
||||
export function updateComponentButtons() {
|
||||
const container = document.getElementById('saved-components');
|
||||
container.innerHTML = '';
|
||||
|
||||
const components = getAllComponents();
|
||||
Object.values(components).forEach(comp => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'component-btn';
|
||||
btn.dataset.componentId = comp.id;
|
||||
btn.textContent = comp.name;
|
||||
btn.title = `${comp.inputCount} input(s), ${comp.outputCount} output(s)`;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.placingGate = `COMPONENT:${comp.id}`;
|
||||
// Close dropdown
|
||||
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||
});
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Gate dimensions and rendering constants
|
||||
export const GATE_W = 100;
|
||||
export const GATE_H = 60;
|
||||
export const COMP_W = 120;
|
||||
export const PORT_R = 7;
|
||||
|
||||
export const GATE_COLORS = {
|
||||
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
|
||||
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
|
||||
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833'
|
||||
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833',
|
||||
BUS: '#44ddff'
|
||||
};
|
||||
|
||||
export const SIGNAL_COLORS = [
|
||||
@@ -18,9 +20,18 @@ export const SIGNAL_COLORS = [
|
||||
export function gateInputCount(type) {
|
||||
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
||||
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
||||
if (type.startsWith('COMPONENT:')) {
|
||||
// Component types look up their input count from state
|
||||
return 0; // Will be overridden by lookup in gates.js
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
export function gateOutputCount(type) {
|
||||
return type === 'OUTPUT' ? 0 : 1;
|
||||
if (type === 'OUTPUT') return 0;
|
||||
if (type.startsWith('COMPONENT:')) {
|
||||
// Component types look up their output count from state
|
||||
return 0; // Will be overridden by lookup in gates.js
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
332
js/events.js
332
js/events.js
@@ -1,13 +1,16 @@
|
||||
// Event handlers — mouse, keyboard, toolbar, waveform controls
|
||||
import { GATE_W, GATE_H } from './constants.js';
|
||||
import { GATE_W, GATE_H, COMP_W } from './constants.js';
|
||||
import { state } from './state.js';
|
||||
import { evaluateAll, findGateAt, findPortAt } from './gates.js';
|
||||
import { evaluateAll, findGateAt, findPortAt, getComponentWidth, getComponentHeight } 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';
|
||||
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
||||
import { getExampleList, loadExample } from './examples.js';
|
||||
import { createBusFromCut } from './bus.js';
|
||||
|
||||
const PAN_SPEED = 40;
|
||||
|
||||
@@ -19,6 +22,9 @@ function updateWaveZoomLabel() {
|
||||
export function initEvents() {
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
// Set up resize callback for component editor
|
||||
setResizeCallback(resize);
|
||||
|
||||
// ==================== CANVAS MOUSE ====================
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
state.mouseX = e.offsetX;
|
||||
@@ -26,36 +32,117 @@ export function initEvents() {
|
||||
|
||||
// Convert to world coords for gate/port detection
|
||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||
|
||||
// Update bus cut line endpoint
|
||||
if (state.busCutting) {
|
||||
state.busCutting.endX = world.x;
|
||||
state.busCutting.endY = world.y;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update selection box
|
||||
if (state.selectionBox) {
|
||||
state.selectionBox.endX = world.x;
|
||||
state.selectionBox.endY = world.y;
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-drag selected gates
|
||||
if (state.multiDrag) {
|
||||
if (dragStartPos && !dragMoved) {
|
||||
const dx = e.offsetX - dragStartPos.x;
|
||||
const dy = e.offsetY - dragStartPos.y;
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragMoved = true;
|
||||
}
|
||||
if (dragMoved) {
|
||||
const dx = world.x - state.multiDrag.startX;
|
||||
const dy = world.y - state.multiDrag.startY;
|
||||
for (const orig of state.multiDrag.origins) {
|
||||
const gate = state.gates.find(g => g.id === orig.id);
|
||||
if (gate) {
|
||||
gate.x = orig.x + dx;
|
||||
gate.y = orig.y + dy;
|
||||
}
|
||||
}
|
||||
evaluateAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.hoveredPort = findPortAt(world.x, world.y);
|
||||
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
|
||||
|
||||
if (state.dragging) {
|
||||
// Detect if mouse actually moved (threshold of 4px to distinguish click vs drag)
|
||||
if (dragStartPos && !dragMoved) {
|
||||
const dx = e.offsetX - dragStartPos.x;
|
||||
const dy = e.offsetY - dragStartPos.y;
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||
dragMoved = true;
|
||||
}
|
||||
}
|
||||
if (dragMoved) {
|
||||
state.dragging.x = world.x - state.dragOffset.x;
|
||||
state.dragging.y = world.y - state.dragOffset.y;
|
||||
evaluateAll();
|
||||
}
|
||||
}
|
||||
|
||||
canvas.style.cursor = state.placingGate ? 'crosshair'
|
||||
: state.selectionBox ? 'crosshair'
|
||||
: state.hoveredPort ? 'pointer'
|
||||
: state.hoveredGate ? 'grab'
|
||||
: 'default';
|
||||
});
|
||||
|
||||
let dragStartPos = null;
|
||||
let dragMoved = false;
|
||||
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
if (e.button !== 0) return;
|
||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||
dragStartPos = { x: e.offsetX, y: e.offsetY };
|
||||
dragMoved = false;
|
||||
|
||||
// Shift+click on empty space → start bus cut
|
||||
if (e.shiftKey && !state.placingGate) {
|
||||
const port = findPortAt(world.x, world.y);
|
||||
const gate = findGateAt(world.x, world.y);
|
||||
if (!port && !gate) {
|
||||
state.busCutting = {
|
||||
startX: world.x, startY: world.y,
|
||||
endX: world.x, endY: world.y
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Placing a new gate
|
||||
if (state.placingGate) {
|
||||
state.gates.push({
|
||||
let w = GATE_W, h = GATE_H;
|
||||
if (state.placingGate.startsWith('COMPONENT:')) {
|
||||
const componentId = state.placingGate.substring(10);
|
||||
const component = state.customComponents?.[componentId];
|
||||
if (component) {
|
||||
const fakeGate = { type: state.placingGate, component };
|
||||
w = getComponentWidth(fakeGate);
|
||||
h = getComponentHeight(fakeGate);
|
||||
}
|
||||
}
|
||||
|
||||
const newGate = {
|
||||
id: state.nextId++,
|
||||
type: state.placingGate,
|
||||
x: world.x - GATE_W / 2,
|
||||
y: world.y - GATE_H / 2,
|
||||
x: world.x - w / 2,
|
||||
y: world.y - h / 2,
|
||||
value: 0
|
||||
});
|
||||
};
|
||||
if (state.placingGate.startsWith('COMPONENT:')) {
|
||||
newGate.component = state.customComponents[state.placingGate.substring(10)];
|
||||
}
|
||||
state.gates.push(newGate);
|
||||
evaluateAll();
|
||||
state.placingGate = null;
|
||||
// Keep placingGate active so user can place multiple — right-click to cancel
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,12 +152,18 @@ export function initEvents() {
|
||||
if (state.connecting) {
|
||||
if (state.connecting.portType === 'output' && port.type === 'input') {
|
||||
state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index));
|
||||
state.connections.push({ from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index });
|
||||
const conn = { from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index };
|
||||
state.connections.push(conn);
|
||||
console.log(`[wire] ${conn.from}:${conn.fromPort} → ${conn.to}:${conn.toPort}`);
|
||||
evaluateAll();
|
||||
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
||||
} else if (state.connecting.portType === 'input' && port.type === 'output') {
|
||||
state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex));
|
||||
state.connections.push({ from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex });
|
||||
const conn = { from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex };
|
||||
state.connections.push(conn);
|
||||
console.log(`[wire] ${conn.from}:${conn.fromPort} → ${conn.to}:${conn.toPort}`);
|
||||
evaluateAll();
|
||||
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
||||
}
|
||||
state.connecting = null;
|
||||
} else {
|
||||
@@ -81,26 +174,127 @@ export function initEvents() {
|
||||
|
||||
if (state.connecting) { state.connecting = null; return; }
|
||||
|
||||
// Toggle INPUT/CLOCK
|
||||
// Drag any gate (including INPUT/CLOCK)
|
||||
const gate = findGateAt(world.x, world.y);
|
||||
if (gate && (gate.type === 'INPUT' || gate.type === 'CLOCK')) {
|
||||
gate.value = gate.value ? 0 : 1;
|
||||
evaluateAll();
|
||||
if (gate) {
|
||||
// If clicking a selected gate → multi-drag all selected
|
||||
if (state.selectedGates.includes(gate.id)) {
|
||||
state.multiDrag = {
|
||||
startX: world.x,
|
||||
startY: world.y,
|
||||
origins: state.selectedGates.map(id => {
|
||||
const g = state.gates.find(g => g.id === id);
|
||||
return g ? { id: g.id, x: g.x, y: g.y } : null;
|
||||
}).filter(Boolean)
|
||||
};
|
||||
canvas.style.cursor = 'grabbing';
|
||||
return;
|
||||
}
|
||||
|
||||
// Drag gate
|
||||
if (gate) {
|
||||
// Clicking an unselected gate → clear selection, drag just this one
|
||||
state.selectedGates = [];
|
||||
state.dragging = gate;
|
||||
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
||||
canvas.style.cursor = 'grabbing';
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on empty space → clear selection and start selection box
|
||||
state.selectedGates = [];
|
||||
state.selectionBox = {
|
||||
startX: world.x, startY: world.y,
|
||||
endX: world.x, endY: world.y
|
||||
};
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', e => {
|
||||
// Finish bus cut
|
||||
if (state.busCutting) {
|
||||
createBusFromCut();
|
||||
state.busCutting = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Finish selection box → select gates inside
|
||||
if (state.selectionBox) {
|
||||
const box = state.selectionBox;
|
||||
const x1 = Math.min(box.startX, box.endX);
|
||||
const y1 = Math.min(box.startY, box.endY);
|
||||
const x2 = Math.max(box.startX, box.endX);
|
||||
const y2 = Math.max(box.startY, box.endY);
|
||||
|
||||
// Only select if box is big enough (not just a click)
|
||||
if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
|
||||
state.selectedGates = state.gates
|
||||
.filter(g => {
|
||||
const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS_IN:') || g.type.startsWith('BUS_OUT:');
|
||||
const gw = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||
const gh = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||
// Gate overlaps selection box
|
||||
return g.x + gw > x1 && g.x < x2 && g.y + gh > y1 && g.y < y2;
|
||||
})
|
||||
.map(g => g.id);
|
||||
if (state.selectedGates.length > 0) {
|
||||
console.log(`[select] ${state.selectedGates.length} gate(s) selected`);
|
||||
}
|
||||
}
|
||||
state.selectionBox = null;
|
||||
dragStartPos = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Finish multi-drag
|
||||
if (state.multiDrag) {
|
||||
state.multiDrag = null;
|
||||
dragStartPos = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle INPUT/CLOCK only on click (no drag movement)
|
||||
if (state.dragging && !dragMoved) {
|
||||
const gate = state.dragging;
|
||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') {
|
||||
gate.value = gate.value ? 0 : 1;
|
||||
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
||||
evaluateAll(true); // record waveform on intentional toggle
|
||||
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
||||
}
|
||||
}
|
||||
state.dragging = null;
|
||||
dragStartPos = null;
|
||||
});
|
||||
|
||||
// Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint
|
||||
canvas.addEventListener('dblclick', e => {
|
||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||
const gate = findGateAt(world.x, world.y);
|
||||
if (!gate) return;
|
||||
|
||||
// Double-click on component gate → edit its blueprint
|
||||
if (gate.type.startsWith('COMPONENT:') && gate.component) {
|
||||
editComponentBlueprint(gate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-click on I/O gates → rename (only inside component editor)
|
||||
if (state.componentEditorActive && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
|
||||
const current = gate.label || '';
|
||||
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
|
||||
if (label !== null) {
|
||||
gate.label = label.trim() || undefined;
|
||||
console.log(`[label] ${gate.type}#${gate.id} → "${gate.label || ''}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', () => { state.dragging = null; });
|
||||
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
|
||||
// Right-click cancels placing mode
|
||||
if (state.placingGate) {
|
||||
state.placingGate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||
const port = findPortAt(world.x, world.y);
|
||||
if (port && port.type === 'input') {
|
||||
@@ -134,8 +328,22 @@ export function initEvents() {
|
||||
keysDown.add(e.key);
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (state.hoveredGate && document.activeElement === document.body) {
|
||||
if (document.activeElement !== document.body) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Delete all selected gates
|
||||
if (state.selectedGates.length > 0) {
|
||||
for (const gateId of state.selectedGates) {
|
||||
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||
delete state.waveData[gateId];
|
||||
}
|
||||
console.log(`[delete] removed ${state.selectedGates.length} gate(s)`);
|
||||
state.selectedGates = [];
|
||||
state.hoveredGate = null;
|
||||
evaluateAll();
|
||||
} else if (state.hoveredGate) {
|
||||
// Delete single hovered gate
|
||||
const gateId = state.hoveredGate.id;
|
||||
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||
@@ -147,6 +355,7 @@ export function initEvents() {
|
||||
if (e.key === 'Escape') {
|
||||
state.placingGate = null;
|
||||
state.connecting = null;
|
||||
state.selectedGates = [];
|
||||
}
|
||||
|
||||
// Pan with arrow keys
|
||||
@@ -177,9 +386,32 @@ export function initEvents() {
|
||||
keysDown.delete(e.key);
|
||||
});
|
||||
|
||||
// ==================== TOOLBAR ====================
|
||||
document.querySelectorAll('.gate-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// ==================== TOOLBAR DROPDOWNS ====================
|
||||
function closeAllDropdowns() {
|
||||
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||
}
|
||||
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const dropdown = toggle.parentElement;
|
||||
const wasOpen = dropdown.classList.contains('open');
|
||||
closeAllDropdowns();
|
||||
if (!wasOpen) dropdown.classList.add('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.toolbar-dropdown')) {
|
||||
closeAllDropdowns();
|
||||
}
|
||||
});
|
||||
|
||||
// Gate buttons inside dropdowns
|
||||
document.querySelectorAll('.dropdown-menu .gate-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const gateName = btn.dataset.gate;
|
||||
// In puzzle mode, check if gate is allowed
|
||||
if (puzzleMode && currentLevel) {
|
||||
@@ -190,9 +422,38 @@ export function initEvents() {
|
||||
}
|
||||
}
|
||||
state.placingGate = gateName;
|
||||
closeAllDropdowns();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== EXAMPLES ====================
|
||||
const examplesMenu = document.getElementById('examples-menu');
|
||||
getExampleList().forEach((ex, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'example-btn';
|
||||
btn.innerHTML = `<span class="example-name">${ex.name}</span><span class="example-desc">${ex.description}</span>`;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const hasGates = state.gates.length > 0;
|
||||
if (hasGates && !confirm('Load example? This will replace your current circuit.')) return;
|
||||
const data = loadExample(i);
|
||||
if (data) {
|
||||
state.gates = data.circuit.gates;
|
||||
state.connections = data.circuit.connections;
|
||||
state.nextId = data.circuit.nextId;
|
||||
if (data.camera) {
|
||||
state.camX = data.camera.camX;
|
||||
state.camY = data.camera.camY;
|
||||
state.zoom = data.camera.zoom;
|
||||
}
|
||||
clearWaveData();
|
||||
evaluateAll();
|
||||
}
|
||||
closeAllDropdowns();
|
||||
});
|
||||
examplesMenu.appendChild(btn);
|
||||
});
|
||||
|
||||
document.getElementById('clear-btn').addEventListener('click', () => {
|
||||
if (state.gates.length === 0 || confirm('Clear all gates and connections?')) {
|
||||
state.gates = [];
|
||||
@@ -292,4 +553,31 @@ export function initEvents() {
|
||||
}
|
||||
});
|
||||
document.addEventListener('mouseup', () => { state.resizingWave = false; });
|
||||
|
||||
// ==================== COMPONENT EDITOR ====================
|
||||
document.getElementById('create-component-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeAllDropdowns();
|
||||
enterComponentEditor();
|
||||
});
|
||||
|
||||
document.getElementById('component-editor-save').addEventListener('click', () => {
|
||||
// If editing existing, pre-fill with current name
|
||||
const existingName = state.editingComponentId
|
||||
? (state.customComponents[state.editingComponentId]?.name || 'MyComponent')
|
||||
: 'MyComponent';
|
||||
const name = prompt('Component name:', existingName);
|
||||
if (name && name.trim()) {
|
||||
exitComponentEditor(name.trim(), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('component-editor-cancel').addEventListener('click', () => {
|
||||
if (confirm('Discard component without saving?')) {
|
||||
exitComponentEditor('', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Update component buttons initially
|
||||
updateComponentButtons();
|
||||
}
|
||||
|
||||
300
js/examples.js
Normal file
300
js/examples.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// Pre-built example circuits
|
||||
// Each example is a { gates, connections, nextId, camera } object
|
||||
|
||||
const W = 100; // GATE_W
|
||||
const H = 60; // GATE_H
|
||||
const GAP_X = 160;
|
||||
const GAP_Y = 100;
|
||||
|
||||
/**
|
||||
* SR Flip-Flop using cross-coupled NOR gates
|
||||
*
|
||||
* S ──┐
|
||||
* NOR─── Q
|
||||
* ┌──┘ │
|
||||
* │ │
|
||||
* └──┐ │
|
||||
* NOR─── Q̅
|
||||
* R ──┘
|
||||
*/
|
||||
function srFlipFlop() {
|
||||
const gates = [
|
||||
// Inputs
|
||||
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 0, label: 'S' },
|
||||
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'R' },
|
||||
|
||||
// Cross-coupled NOR gates
|
||||
{ id: 3, type: 'NOR', x: 300, y: 80, value: 0 },
|
||||
{ id: 4, type: 'NOR', x: 300, y: 280, value: 0 },
|
||||
|
||||
// Outputs
|
||||
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
|
||||
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
|
||||
];
|
||||
|
||||
const connections = [
|
||||
// S → Top NOR input 0
|
||||
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
|
||||
// R → Bottom NOR input 1
|
||||
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
|
||||
// Top NOR (Q) → Bottom NOR input 0 (cross-couple)
|
||||
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
|
||||
// Bottom NOR (Q̅) → Top NOR input 1 (cross-couple)
|
||||
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
|
||||
// NOR outputs → visible outputs
|
||||
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
|
||||
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'SR Flip-Flop (NOR)',
|
||||
description: 'Set-Reset latch using cross-coupled NOR gates. Toggle S to set Q=1, toggle R to reset Q=0.',
|
||||
gates,
|
||||
connections,
|
||||
nextId: 7,
|
||||
camera: { camX: 0, camY: 0, zoom: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SR Flip-Flop using cross-coupled NAND gates
|
||||
*
|
||||
* S̅ ──┐
|
||||
* NAND── Q
|
||||
* ┌───┘ │
|
||||
* │ │
|
||||
* └───┐ │
|
||||
* NAND── Q̅
|
||||
* R̅ ──┘
|
||||
*/
|
||||
function srFlipFlopNand() {
|
||||
const gates = [
|
||||
// Inputs (active low for NAND SR)
|
||||
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 1, label: 'S̅' },
|
||||
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 1, label: 'R̅' },
|
||||
|
||||
// Cross-coupled NAND gates
|
||||
{ id: 3, type: 'NAND', x: 300, y: 80, value: 0 },
|
||||
{ id: 4, type: 'NAND', x: 300, y: 280, value: 0 },
|
||||
|
||||
// Outputs
|
||||
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
|
||||
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
|
||||
];
|
||||
|
||||
const connections = [
|
||||
// S̅ → Top NAND input 0
|
||||
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
|
||||
// R̅ → Bottom NAND input 1
|
||||
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
|
||||
// Top NAND (Q) → Bottom NAND input 0 (cross-couple)
|
||||
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
|
||||
// Bottom NAND (Q̅) → Top NAND input 1 (cross-couple)
|
||||
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
|
||||
// Outputs
|
||||
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
|
||||
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'SR Flip-Flop (NAND)',
|
||||
description: 'Set-Reset latch using cross-coupled NAND gates. Inputs are active-low: set S̅=0 to set, R̅=0 to reset.',
|
||||
gates,
|
||||
connections,
|
||||
nextId: 7,
|
||||
camera: { camX: 0, camY: 0, zoom: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gated D Latch (1-bit memory)
|
||||
*
|
||||
* Uses an SR flip-flop with gating logic:
|
||||
* D ──AND──┐
|
||||
* E ──┤ NOR── Q
|
||||
* │ │ ┌──┘│
|
||||
* │ │ │ │
|
||||
* │ │ └──┐│
|
||||
* E ──┤ NOR── Q̅
|
||||
* D─NOT─AND┘
|
||||
*
|
||||
* When Enable=1, Q follows D.
|
||||
* When Enable=0, Q holds its value.
|
||||
*/
|
||||
function dLatch() {
|
||||
const gates = [
|
||||
// Inputs
|
||||
{ id: 1, type: 'INPUT', x: 50, y: 100, value: 0, label: 'D' },
|
||||
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'EN' },
|
||||
|
||||
// NOT gate to invert D
|
||||
{ id: 3, type: 'NOT', x: 200, y: 340, value: 0 },
|
||||
|
||||
// AND gates for gating
|
||||
{ id: 4, type: 'AND', x: 350, y: 60, value: 0 }, // D AND E → S
|
||||
{ id: 5, type: 'AND', x: 350, y: 340, value: 0 }, // NOT(D) AND E → R
|
||||
|
||||
// Cross-coupled NOR gates (SR latch core)
|
||||
{ id: 6, type: 'NOR', x: 550, y: 60, value: 0 }, // → Q
|
||||
{ id: 7, type: 'NOR', x: 550, y: 340, value: 0 }, // → Q̅
|
||||
|
||||
// Outputs
|
||||
{ id: 8, type: 'OUTPUT', x: 750, y: 60, value: 0, label: 'Q' },
|
||||
{ id: 9, type: 'OUTPUT', x: 750, y: 340, value: 0, label: 'Q̅' }
|
||||
];
|
||||
|
||||
const connections = [
|
||||
// D → AND top input, and D → NOT
|
||||
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
|
||||
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
|
||||
|
||||
// E → both AND gates
|
||||
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
|
||||
{ from: 2, fromPort: 0, to: 5, toPort: 1 },
|
||||
|
||||
// NOT(D) → bottom AND
|
||||
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
|
||||
|
||||
// AND outputs → NOR inputs (S and R)
|
||||
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
|
||||
{ from: 5, fromPort: 0, to: 7, toPort: 1 },
|
||||
|
||||
// Cross-coupling
|
||||
{ from: 6, fromPort: 0, to: 7, toPort: 0 },
|
||||
{ from: 7, fromPort: 0, to: 6, toPort: 1 },
|
||||
|
||||
// Outputs
|
||||
{ from: 6, fromPort: 0, to: 8, toPort: 0 },
|
||||
{ from: 7, fromPort: 0, to: 9, toPort: 0 }
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'D Latch (1-bit Memory)',
|
||||
description: 'Gated D latch — a 1-bit memory cell. When Enable=1, output Q follows input D. When Enable=0, Q holds its last value.',
|
||||
gates,
|
||||
connections,
|
||||
nextId: 10,
|
||||
camera: { camX: 0, camY: 0, zoom: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D Flip-Flop (edge-triggered, master-slave)
|
||||
*
|
||||
* Two D latches in series with inverted enable:
|
||||
* - Master latch captures D when CLK=0
|
||||
* - Slave latch outputs when CLK=1
|
||||
* This creates rising-edge triggered behavior.
|
||||
*/
|
||||
function dFlipFlop() {
|
||||
const gates = [
|
||||
// Inputs
|
||||
{ id: 1, type: 'INPUT', x: 30, y: 100, value: 0, label: 'D' },
|
||||
{ id: 2, type: 'CLOCK', x: 30, y: 350, value: 0, label: 'CLK' },
|
||||
|
||||
// CLK inverter (for master latch)
|
||||
{ id: 3, type: 'NOT', x: 170, y: 350, value: 0 },
|
||||
|
||||
// === MASTER LATCH (enabled when CLK=0, i.e. NOT CLK=1) ===
|
||||
{ id: 4, type: 'NOT', x: 170, y: 200, value: 0 }, // NOT D for master
|
||||
{ id: 5, type: 'AND', x: 300, y: 60, value: 0 }, // D AND !CLK
|
||||
{ id: 6, type: 'AND', x: 300, y: 240, value: 0 }, // !D AND !CLK
|
||||
{ id: 7, type: 'NOR', x: 450, y: 60, value: 0 }, // Master Q
|
||||
{ id: 8, type: 'NOR', x: 450, y: 240, value: 0 }, // Master Q̅
|
||||
|
||||
// === SLAVE LATCH (enabled when CLK=1) ===
|
||||
{ id: 9, type: 'NOT', x: 570, y: 200, value: 0 }, // NOT master Q for slave
|
||||
{ id: 10, type: 'AND', x: 680, y: 60, value: 0 }, // masterQ AND CLK
|
||||
{ id: 11, type: 'AND', x: 680, y: 240, value: 0 }, // !masterQ AND CLK
|
||||
{ id: 12, type: 'NOR', x: 830, y: 60, value: 0 }, // Slave Q
|
||||
{ id: 13, type: 'NOR', x: 830, y: 240, value: 0 }, // Slave Q̅
|
||||
|
||||
// Outputs
|
||||
{ id: 14, type: 'OUTPUT', x: 1010, y: 60, value: 0, label: 'Q' },
|
||||
{ id: 15, type: 'OUTPUT', x: 1010, y: 240, value: 0, label: 'Q̅' }
|
||||
];
|
||||
|
||||
const connections = [
|
||||
// D → master AND, D → master NOT
|
||||
{ from: 1, fromPort: 0, to: 5, toPort: 0 },
|
||||
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
|
||||
|
||||
// CLK → NOT (invert for master)
|
||||
{ from: 2, fromPort: 0, to: 3, toPort: 0 },
|
||||
|
||||
// !CLK → master AND gates (enable)
|
||||
{ from: 3, fromPort: 0, to: 5, toPort: 1 },
|
||||
{ from: 3, fromPort: 0, to: 6, toPort: 1 },
|
||||
|
||||
// !D → master bottom AND
|
||||
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
|
||||
|
||||
// Master AND outputs → Master NOR (SR latch)
|
||||
{ from: 5, fromPort: 0, to: 7, toPort: 0 },
|
||||
{ from: 6, fromPort: 0, to: 8, toPort: 1 },
|
||||
|
||||
// Master cross-coupling
|
||||
{ from: 7, fromPort: 0, to: 8, toPort: 0 },
|
||||
{ from: 8, fromPort: 0, to: 7, toPort: 1 },
|
||||
|
||||
// Master Q → slave AND, Master Q → slave NOT
|
||||
{ from: 7, fromPort: 0, to: 10, toPort: 0 },
|
||||
{ from: 7, fromPort: 0, to: 9, toPort: 0 },
|
||||
|
||||
// CLK → slave AND gates (enable, direct CLK)
|
||||
{ from: 2, fromPort: 0, to: 10, toPort: 1 },
|
||||
{ from: 2, fromPort: 0, to: 11, toPort: 1 },
|
||||
|
||||
// !masterQ → slave bottom AND
|
||||
{ from: 9, fromPort: 0, to: 11, toPort: 0 },
|
||||
|
||||
// Slave AND outputs → Slave NOR (SR latch)
|
||||
{ from: 10, fromPort: 0, to: 12, toPort: 0 },
|
||||
{ from: 11, fromPort: 0, to: 13, toPort: 1 },
|
||||
|
||||
// Slave cross-coupling
|
||||
{ from: 12, fromPort: 0, to: 13, toPort: 0 },
|
||||
{ from: 13, fromPort: 0, to: 12, toPort: 1 },
|
||||
|
||||
// Outputs
|
||||
{ from: 12, fromPort: 0, to: 14, toPort: 0 },
|
||||
{ from: 13, fromPort: 0, to: 15, toPort: 0 }
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'D Flip-Flop (Master-Slave)',
|
||||
description: 'Edge-triggered D flip-flop built from two D latches. Captures D on the rising edge of CLK. Use with the clock simulation to see it in action.',
|
||||
gates,
|
||||
connections,
|
||||
nextId: 16,
|
||||
camera: { camX: 0, camY: -50, zoom: 0.9 }
|
||||
};
|
||||
}
|
||||
|
||||
// Export all examples as a list
|
||||
export const examples = [
|
||||
srFlipFlop,
|
||||
srFlipFlopNand,
|
||||
dLatch,
|
||||
dFlipFlop
|
||||
];
|
||||
|
||||
export function getExampleList() {
|
||||
return examples.map((fn, i) => {
|
||||
const ex = fn();
|
||||
return { id: i, name: ex.name, description: ex.description };
|
||||
});
|
||||
}
|
||||
|
||||
export function loadExample(index) {
|
||||
if (index < 0 || index >= examples.length) return null;
|
||||
const ex = examples[index]();
|
||||
return {
|
||||
circuit: {
|
||||
gates: JSON.parse(JSON.stringify(ex.gates)),
|
||||
connections: JSON.parse(JSON.stringify(ex.connections)),
|
||||
nextId: ex.nextId
|
||||
},
|
||||
camera: ex.camera
|
||||
};
|
||||
}
|
||||
193
js/gates.js
193
js/gates.js
@@ -1,13 +1,71 @@
|
||||
// Gate evaluation and port geometry
|
||||
import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js';
|
||||
import { GATE_W, GATE_H, COMP_W, PORT_R, gateInputCount as baseGateInputCount, gateOutputCount as baseGateOutputCount } from './constants.js';
|
||||
import { state } from './state.js';
|
||||
import { recordSample, setEvaluateAll } from './waveform.js';
|
||||
import { evaluateComponent } from './components.js';
|
||||
|
||||
// Wrappers that handle component and BUS types
|
||||
export function gateInputCount(type) {
|
||||
if (type.startsWith('COMPONENT:')) {
|
||||
const componentId = type.substring(10);
|
||||
const component = state.customComponents?.[componentId];
|
||||
return component ? component.inputCount : 0;
|
||||
}
|
||||
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 0;
|
||||
if (type.startsWith('BUS_OUT:')) return 0;
|
||||
return baseGateInputCount(type);
|
||||
}
|
||||
|
||||
export function gateOutputCount(type) {
|
||||
if (type.startsWith('COMPONENT:')) {
|
||||
const componentId = type.substring(10);
|
||||
const component = state.customComponents?.[componentId];
|
||||
return component ? component.outputCount : 0;
|
||||
}
|
||||
if (type.startsWith('BUS_IN:')) return 0;
|
||||
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 0;
|
||||
return baseGateOutputCount(type);
|
||||
}
|
||||
|
||||
function isBusType(type) {
|
||||
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||
}
|
||||
|
||||
function getBusSize(type) {
|
||||
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 1;
|
||||
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getComponentWidth(gate) {
|
||||
if (isBusType(gate.type)) return 30;
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||
return Math.max(120, (count + 1) * 25);
|
||||
}
|
||||
return GATE_W;
|
||||
}
|
||||
|
||||
export function getComponentHeight(gate) {
|
||||
if (isBusType(gate.type)) {
|
||||
const n = getBusSize(gate.type);
|
||||
return Math.max(40, (n + 1) * 22);
|
||||
}
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||
return Math.max(60, (count + 1) * 25);
|
||||
}
|
||||
return GATE_H;
|
||||
}
|
||||
|
||||
export function getInputPorts(gate) {
|
||||
const count = gateInputCount(gate.type);
|
||||
const ports = [];
|
||||
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const spacing = GATE_H / (count + 1);
|
||||
const spacing = gateHeight / (count + 1);
|
||||
ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' });
|
||||
}
|
||||
return ports;
|
||||
@@ -16,15 +74,35 @@ export function getInputPorts(gate) {
|
||||
export function getOutputPorts(gate) {
|
||||
const count = gateOutputCount(gate.type);
|
||||
const ports = [];
|
||||
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||
const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, index: i, type: 'output' });
|
||||
const spacing = gateHeight / (count + 1);
|
||||
ports.push({ x: gate.x + gateWidth, y: gate.y + spacing * (i + 1), index: i, type: 'output' });
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
export function evaluate(gate, visited = new Set()) {
|
||||
if (visited.has(gate.id)) return gate.value || 0;
|
||||
visited.add(gate.id);
|
||||
/**
|
||||
* Read the value from a source gate at a specific output port.
|
||||
* For component gates with multiple outputs, reads from outputValues[].
|
||||
* For normal gates (single output), reads gate.value.
|
||||
*/
|
||||
function readSourcePort(srcGate, fromPort) {
|
||||
if (srcGate.outputValues && fromPort < srcGate.outputValues.length) {
|
||||
return srcGate.outputValues[fromPort];
|
||||
}
|
||||
return srcGate.value || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the output of a single gate given its current input values.
|
||||
* Does NOT recurse — just reads source gate .value directly.
|
||||
* For COMPONENT gates, evaluates internal circuit and stores all outputs.
|
||||
*/
|
||||
function computeGate(gate) {
|
||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
|
||||
|
||||
const inputCount = gateInputCount(gate.type);
|
||||
@@ -33,39 +111,104 @@ export function evaluate(gate, visited = new Set()) {
|
||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === i);
|
||||
if (conn) {
|
||||
const srcGate = state.gates.find(g => g.id === conn.from);
|
||||
inputs.push(srcGate ? evaluate(srcGate, visited) : 0);
|
||||
inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
|
||||
} else {
|
||||
inputs.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
switch (gate.type) {
|
||||
case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break;
|
||||
case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break;
|
||||
case 'NOT': result = inputs[0] ? 0 : 1; break;
|
||||
case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break;
|
||||
case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break;
|
||||
case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break;
|
||||
case 'OUTPUT': result = inputs[0] || 0; break;
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
const outputs = evaluateComponent(gate, inputs);
|
||||
// Store all output values for multi-output components
|
||||
gate.outputValues = outputs;
|
||||
return outputs[0] || 0;
|
||||
}
|
||||
|
||||
// BUS_IN: collect input values and store them for the paired BUS_OUT
|
||||
if (gate.type.startsWith('BUS_IN:')) {
|
||||
gate.busValues = [...inputs];
|
||||
gate.value = inputs[0] || 0;
|
||||
return gate.value;
|
||||
}
|
||||
|
||||
// BUS_OUT: read values from paired BUS_IN terminal
|
||||
if (gate.type.startsWith('BUS_OUT:')) {
|
||||
const pair = state.gates.find(g => g.id === gate.busPairId);
|
||||
if (pair && pair.busValues) {
|
||||
gate.outputValues = [...pair.busValues];
|
||||
gate.value = gate.outputValues[0] || 0;
|
||||
}
|
||||
return gate.value || 0;
|
||||
}
|
||||
|
||||
switch (gate.type) {
|
||||
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
|
||||
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
|
||||
case 'NOT': return inputs[0] ? 0 : 1;
|
||||
case 'NAND': return (inputs[0] && inputs[1]) ? 0 : 1;
|
||||
case 'NOR': return (inputs[0] || inputs[1]) ? 0 : 1;
|
||||
case 'XOR': return (inputs[0] !== inputs[1]) ? 1 : 0;
|
||||
case 'OUTPUT': return inputs[0] || 0;
|
||||
default: return 0;
|
||||
}
|
||||
gate.value = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function evaluateAll() {
|
||||
state.gates.forEach(g => {
|
||||
if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0;
|
||||
});
|
||||
state.gates.forEach(g => evaluate(g));
|
||||
if (state.recording && state.waveformVisible) recordSample();
|
||||
/**
|
||||
* Iterative fixed-point evaluation.
|
||||
* Runs multiple passes over all gates until no values change (stable)
|
||||
* or a max iteration limit is reached. Does NOT reset gate values,
|
||||
* preserving latch/flip-flop state across evaluations.
|
||||
*/
|
||||
const MAX_ITERATIONS = 20;
|
||||
|
||||
export function evaluateAll(recordWave = false) {
|
||||
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
for (const gate of state.gates) {
|
||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
|
||||
const oldVal = gate.value;
|
||||
const oldOutputs = gate.outputValues ? [...gate.outputValues] : null;
|
||||
const newVal = computeGate(gate);
|
||||
if (newVal !== oldVal) {
|
||||
gate.value = newVal;
|
||||
changed = true;
|
||||
}
|
||||
// Also check if outputValues changed (for multi-output components)
|
||||
if (gate.outputValues && oldOutputs) {
|
||||
for (let i = 0; i < gate.outputValues.length; i++) {
|
||||
if (gate.outputValues[i] !== oldOutputs[i]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
if (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
|
||||
break;
|
||||
}
|
||||
if (iter === MAX_ITERATIONS - 1) {
|
||||
console.warn(`[eval] did not stabilize after ${MAX_ITERATIONS} iterations (oscillation?)`);
|
||||
}
|
||||
}
|
||||
if (recordWave && state.recording && state.waveformVisible) recordSample();
|
||||
}
|
||||
|
||||
// Keep legacy export name for components.js internal use
|
||||
export function evaluate(gate) {
|
||||
return computeGate(gate);
|
||||
}
|
||||
|
||||
// Register evaluateAll in waveform to break circular dependency
|
||||
setEvaluateAll(evaluateAll);
|
||||
|
||||
export function findGateAt(x, y) {
|
||||
return state.gates.find(g => x >= g.x && x <= g.x + GATE_W && y >= g.y && y <= g.y + GATE_H);
|
||||
return state.gates.find(g => {
|
||||
const isDynamic = g.type.startsWith('COMPONENT:') || isBusType(g.type);
|
||||
const w = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||
const h = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPortAt(x, y) {
|
||||
|
||||
240
js/levels.js
240
js/levels.js
@@ -75,6 +75,111 @@ export const LEVELS = [
|
||||
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: 'xnor_gate',
|
||||
category: 'Logic Basics',
|
||||
title: 'XNOR Gate',
|
||||
description: 'Build an XNOR gate. True when both inputs are equal.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1 } },
|
||||
{ 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: ['XNOR is the inverse of XOR.', 'Build XOR first, then invert the output with another NAND-as-NOT.'],
|
||||
difficulty: 3
|
||||
},
|
||||
// ============ Combinational Logic ============
|
||||
{
|
||||
id: 'mux_2to1',
|
||||
category: 'Combinational Logic',
|
||||
title: '2:1 Multiplexer',
|
||||
description: 'Build a MUX. When SEL=0, output A. When SEL=1, output B. Three inputs: A (0), B (1), SEL (2).',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // SEL=0 → A=0
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1 } }, // SEL=0 → A=1
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } }, // SEL=0 → A=0
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // SEL=0 → A=1
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // SEL=1 → B=0
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } }, // SEL=1 → B=1
|
||||
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // SEL=1 → B=0
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } } // SEL=1 → B=1
|
||||
],
|
||||
hints: ['MUX = (A AND NOT SEL) OR (B AND SEL).', 'You need 1 NOT, 2 AND, and 1 OR gate.'],
|
||||
difficulty: 2
|
||||
},
|
||||
{
|
||||
id: 'demux_1to2',
|
||||
category: 'Combinational Logic',
|
||||
title: '1:2 Demultiplexer',
|
||||
description: 'Route input to one of two outputs based on SEL. Inputs: DATA (0), SEL (1). Outputs: Q0, Q1.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // DATA=0, SEL=0 → Q0=0, Q1=0
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // DATA=1, SEL=0 → Q0=1, Q1=0
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 0 } }, // DATA=0, SEL=1 → Q0=0, Q1=0
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } } // DATA=1, SEL=1 → Q0=0, Q1=1
|
||||
],
|
||||
hints: ['Q0 = DATA AND NOT SEL. Q1 = DATA AND SEL.', 'You need 1 NOT and 2 AND gates.'],
|
||||
difficulty: 2
|
||||
},
|
||||
{
|
||||
id: 'and3',
|
||||
category: 'Combinational Logic',
|
||||
title: '3-input AND',
|
||||
description: 'Build a 3-input AND gate. Output is 1 only when all three inputs are 1.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } }
|
||||
],
|
||||
hints: ['Chain two AND gates: (A AND B) AND C.'],
|
||||
difficulty: 1
|
||||
},
|
||||
{
|
||||
id: 'majority',
|
||||
category: 'Combinational Logic',
|
||||
title: 'Majority Gate',
|
||||
description: 'Output 1 if at least two of the three inputs are 1.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } },
|
||||
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } }
|
||||
],
|
||||
hints: ['Majority = (A AND B) OR (A AND C) OR (B AND C).', 'You need 3 AND gates and 2 OR gates.'],
|
||||
difficulty: 2
|
||||
},
|
||||
{
|
||||
id: 'parity',
|
||||
category: 'Combinational Logic',
|
||||
title: 'Even Parity',
|
||||
description: 'Output 1 if an even number of inputs are 1 (0 counts as even). Three inputs.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'XOR', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 1 } }, // 0 ones = even
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 1 one = odd
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // 2 ones = even
|
||||
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 0 } } // 3 ones = odd
|
||||
],
|
||||
hints: ['XOR gives odd parity. Invert it for even parity.', 'Chain: NOT(A XOR B XOR C).'],
|
||||
difficulty: 2
|
||||
},
|
||||
// ============ Arithmetic ============
|
||||
{
|
||||
id: 'half_adder',
|
||||
category: 'Arithmetic',
|
||||
@@ -82,10 +187,10 @@ export const LEVELS = [
|
||||
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
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } },
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 0 } },
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } },
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } }
|
||||
],
|
||||
hints: ['Two independent functions: Sum=XOR, Carry=AND.', 'You need 2 OUTPUT gates.'],
|
||||
difficulty: 3
|
||||
@@ -94,36 +199,131 @@ export const LEVELS = [
|
||||
id: 'full_adder',
|
||||
category: 'Arithmetic',
|
||||
title: 'Full Adder',
|
||||
description: 'Build a full adder with carry-in. Outputs: Sum (3-input XOR) and Carry.',
|
||||
description: 'Build a full adder with carry-in. Outputs: Sum and Carry-out.',
|
||||
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
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0, 1: 0 } },
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 1, 1: 0 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 1, 1: 0 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0, 1: 1 } },
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1, 1: 0 } },
|
||||
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0, 1: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0, 1: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1, 1: 1 } }
|
||||
],
|
||||
hints: ['Reuse your half adder logic. A full adder = half adder + half adder + OR.'],
|
||||
hints: ['A full adder = two half adders + OR for the carry.', 'Sum = A XOR B XOR Cin. Cout = (A AND B) OR (Cin AND (A XOR B)).'],
|
||||
difficulty: 4
|
||||
},
|
||||
{
|
||||
id: 'half_subtractor',
|
||||
category: 'Arithmetic',
|
||||
title: 'Half Subtractor',
|
||||
description: 'Build A minus B. Outputs: Difference (A XOR B) and Borrow (NOT A AND B).',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'XOR', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // 0-0: diff=0, borrow=0
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 1 } }, // 0-1: diff=1, borrow=1
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // 1-0: diff=1, borrow=0
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 0 } } // 1-1: diff=0, borrow=0
|
||||
],
|
||||
hints: ['Difference = A XOR B (same as addition!).', 'Borrow = (NOT A) AND B.'],
|
||||
difficulty: 2
|
||||
},
|
||||
{
|
||||
id: 'comparator',
|
||||
category: 'Arithmetic',
|
||||
title: '1-bit Comparator',
|
||||
description: 'Compare A and B. Three outputs: A>B, A=B, A<B.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT', 'XOR'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 1, 2: 0 } }, // 0=0: GT=0, EQ=1, LT=0
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } }, // 0<1: GT=0, EQ=0, LT=1
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0, 2: 0 } }, // 1>0: GT=1, EQ=0, LT=0
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1, 2: 0 } } // 1=1: GT=0, EQ=1, LT=0
|
||||
],
|
||||
hints: ['A>B = A AND NOT B.', 'A=B = NOT(A XOR B) = XNOR.', 'A<B = NOT A AND B.'],
|
||||
difficulty: 3
|
||||
},
|
||||
// ============ Decoders & Encoders ============
|
||||
{
|
||||
id: 'decoder_2to4',
|
||||
category: 'Decoders & Encoders',
|
||||
title: '2-to-4 Decoder',
|
||||
description: 'Decode 2 input bits into 4 output lines. Only one output is 1 at a time.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1, 1: 0, 2: 0, 3: 0 } }, // 00 → line 0
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 1, 2: 0, 3: 0 } }, // 01 → line 1
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 0, 1: 0, 2: 1, 3: 0 } }, // 10 → line 2
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 0, 2: 0, 3: 1 } } // 11 → line 3
|
||||
],
|
||||
hints: ['Q0 = NOT A AND NOT B.', 'Q1 = NOT A AND B. Q2 = A AND NOT B. Q3 = A AND B.', 'You need 2 NOT gates and 4 AND gates.'],
|
||||
difficulty: 3
|
||||
},
|
||||
{
|
||||
id: 'encoder_4to2',
|
||||
category: 'Decoders & Encoders',
|
||||
title: '4-to-2 Encoder',
|
||||
description: 'Encode 4 input lines into 2-bit binary. Assume only one input is active at a time.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'OR'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0 } }, // line 0 → 00
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 1 } }, // line 1 → 01
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1, 3: 0 }, outputs: { 0: 1, 1: 0 } }, // line 2 → 10
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1 } } // line 3 → 11
|
||||
],
|
||||
hints: ['Bit 1 (MSB) = Input2 OR Input3.', 'Bit 0 (LSB) = Input1 OR Input3.', 'Just 2 OR gates!'],
|
||||
difficulty: 2
|
||||
},
|
||||
{
|
||||
id: 'seven_seg_a',
|
||||
category: 'Decoders & Encoders',
|
||||
title: '7-Segment: Segment A',
|
||||
description: 'Drive segment A of a 7-segment display for digits 0-3. Segment A is ON for 0, 2, 3.',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1 } }, // 0 → A on
|
||||
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0 } }, // 1 → A off
|
||||
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } }, // 2 → A on
|
||||
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } } // 3 → A on
|
||||
],
|
||||
hints: ['A = NOT(NOT A1 AND A0). Or equivalently: A = A1 OR NOT A0.'],
|
||||
difficulty: 2
|
||||
},
|
||||
// ============ Components ============
|
||||
{
|
||||
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
|
||||
availableGates: ['INPUT', 'OUTPUT', 'FULL_ADDER'],
|
||||
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
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0, 2: 0 } },
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } },
|
||||
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1, 2: 1 } },
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } }
|
||||
],
|
||||
hints: ['Use two FULL_ADDER components chained together.', 'First adder has no carry-in (set to 0).'],
|
||||
difficulty: 3
|
||||
},
|
||||
{
|
||||
id: 'alu_1bit',
|
||||
category: 'Components',
|
||||
title: '1-bit ALU',
|
||||
description: 'Build a 1-bit ALU. OP=0 → AND, OP=1 → OR. Inputs: A (0), B (1), OP (2).',
|
||||
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
|
||||
testCases: [
|
||||
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 0 AND 0 = 0
|
||||
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 1 AND 0 = 0
|
||||
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // 1 AND 1 = 1
|
||||
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // 0 OR 0 = 0
|
||||
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } }, // 1 OR 0 = 1
|
||||
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } }, // 0 OR 1 = 1
|
||||
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } } // 1 OR 1 = 1
|
||||
],
|
||||
hints: ['Compute both A AND B and A OR B, then use a MUX to select based on OP.', 'MUX = (result0 AND NOT OP) OR (result1 AND OP).'],
|
||||
difficulty: 3
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
439
js/renderer.js
439
js/renderer.js
@@ -1,11 +1,26 @@
|
||||
// Canvas rendering — gates, connections, grid
|
||||
import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js';
|
||||
import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
|
||||
import { state } from './state.js';
|
||||
import { getInputPorts, getOutputPorts } from './gates.js';
|
||||
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
||||
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
||||
import { getBusPairs } from './bus.js';
|
||||
|
||||
let canvas, ctx;
|
||||
|
||||
/**
|
||||
* Read the value arriving at an input port by looking up the source gate/port.
|
||||
* Handles multi-output sources (BUS_OUT, COMPONENT) via outputValues[].
|
||||
*/
|
||||
function getSourcePortValue(conn) {
|
||||
if (!conn) return 0;
|
||||
const srcGate = state.gates.find(g => g.id === conn.from);
|
||||
if (!srcGate) return 0;
|
||||
if (srcGate.outputValues && conn.fromPort < srcGate.outputValues.length) {
|
||||
return srcGate.outputValues[conn.fromPort] || 0;
|
||||
}
|
||||
return srcGate.value || 0;
|
||||
}
|
||||
|
||||
export function initRenderer() {
|
||||
canvas = document.getElementById('canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
@@ -19,7 +34,8 @@ export function resize() {
|
||||
const sidebarW = sidebarOpen ? 340 : 0;
|
||||
canvas.width = window.innerWidth - sidebarW;
|
||||
const waveH = state.waveformVisible ? state.waveformHeight : 0;
|
||||
canvas.height = window.innerHeight - 48 - waveH;
|
||||
const editorH = state.componentEditorActive ? 44 : 0;
|
||||
canvas.height = window.innerHeight - 56 - editorH - waveH;
|
||||
}
|
||||
|
||||
// Convert screen coords to world coords (accounting for pan/zoom)
|
||||
@@ -30,7 +46,30 @@ export function screenToWorld(sx, sy) {
|
||||
};
|
||||
}
|
||||
|
||||
function isBusType(type) {
|
||||
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||
}
|
||||
|
||||
function drawSelectionHighlight(gate) {
|
||||
if (!state.selectedGates.includes(gate.id)) return;
|
||||
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||
const w = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||
const h = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||
const pad = 4;
|
||||
ctx.strokeStyle = '#44ddff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawGate(gate) {
|
||||
// Special gate types have different rendering
|
||||
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
|
||||
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
|
||||
|
||||
const color = GATE_COLORS[gate.type];
|
||||
const isHovered = state.hoveredGate === gate;
|
||||
const isActive = gate.value === 1;
|
||||
@@ -57,8 +96,18 @@ function drawGate(gate) {
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK';
|
||||
|
||||
// Show custom label above the gate if it has one
|
||||
if (gate.label && isIOType) {
|
||||
ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.fillText(gate.label, gate.x + GATE_W / 2, gate.y - 8);
|
||||
}
|
||||
|
||||
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
|
||||
ctx.fillStyle = isActive ? '#fff' : color;
|
||||
ctx.fillText(
|
||||
gate.type === 'CLOCK' ? '⏱ CLK' : gate.type,
|
||||
gate.label && isIOType ? gate.label : (gate.type === 'CLOCK' ? '⏱ CLK' : gate.type),
|
||||
gate.x + GATE_W / 2,
|
||||
gate.y + GATE_H / 2 - (isIOType ? 8 : 0)
|
||||
);
|
||||
@@ -82,7 +131,7 @@ function drawGate(gate) {
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'input';
|
||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
||||
const portActive = getSourcePortValue(conn);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||
@@ -99,15 +148,269 @@ function drawGate(gate) {
|
||||
state.hoveredPort.gate === gate &&
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'output';
|
||||
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
drawSelectionHighlight(gate);
|
||||
}
|
||||
|
||||
function drawBusGate(gate) {
|
||||
const isHovered = state.hoveredGate === gate;
|
||||
const w = getComponentWidth(gate); // 30
|
||||
const h = getComponentHeight(gate);
|
||||
const color = '#44ddff';
|
||||
const isIn = gate.type.startsWith('BUS_IN:');
|
||||
const n = isIn
|
||||
? parseInt(gate.type.substring(7)) || 1
|
||||
: parseInt(gate.type.substring(8)) || 1;
|
||||
|
||||
// Check if any channel is active
|
||||
const values = isIn ? gate.busValues : gate.outputValues;
|
||||
const hasActive = values?.some(v => v === 1);
|
||||
|
||||
if (hasActive) {
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 12 * state.zoom;
|
||||
}
|
||||
|
||||
// Main bus bar
|
||||
ctx.fillStyle = hasActive ? color + '22' : '#14141e';
|
||||
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||
ctx.lineWidth = isHovered ? 2.5 : 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(gate.x, gate.y, w, h, 4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Thick center line (bus bar visual)
|
||||
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gate.x + w / 2, gate.y + 6);
|
||||
ctx.lineTo(gate.x + w / 2, gate.y + h - 6);
|
||||
ctx.stroke();
|
||||
|
||||
// Bus size label + role indicator
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const roleIcon = isIn ? '▶' : '◀';
|
||||
ctx.fillText(`${roleIcon} ${n}`, gate.x + w / 2, gate.y + h + 10);
|
||||
|
||||
// BUS_IN: only input ports (left side)
|
||||
if (isIn) {
|
||||
getInputPorts(gate).forEach(p => {
|
||||
const isPortHovered = state.hoveredPort &&
|
||||
state.hoveredPort.gate === gate &&
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'input';
|
||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||
const portActive = getSourcePortValue(conn);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// BUS_OUT: only output ports (right side)
|
||||
if (!isIn) {
|
||||
getOutputPorts(gate).forEach(p => {
|
||||
const isPortHovered = state.hoveredPort &&
|
||||
state.hoveredPort.gate === gate &&
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'output';
|
||||
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawBusCables() {
|
||||
const pairs = getBusPairs();
|
||||
for (const { inGate, outGate } of pairs) {
|
||||
const inW = getComponentWidth(inGate);
|
||||
const inH = getComponentHeight(inGate);
|
||||
const outH = getComponentHeight(outGate);
|
||||
|
||||
// Cable runs from right edge of inGate to left edge of outGate
|
||||
const x1 = inGate.x + inW;
|
||||
const y1 = inGate.y + inH / 2;
|
||||
const x2 = outGate.x;
|
||||
const y2 = outGate.y + outH / 2;
|
||||
|
||||
// Check if any channel is active
|
||||
const hasActive = inGate.busValues?.some(v => v === 1);
|
||||
const n = parseInt(inGate.type.substring(7)) || 1;
|
||||
|
||||
// Outer thick cable (bus background)
|
||||
const cableWidth = Math.max(6, n * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
const midX = (x1 + x2) / 2;
|
||||
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||
ctx.strokeStyle = hasActive ? '#44ddff33' : '#44ddff11';
|
||||
ctx.lineWidth = cableWidth + 4;
|
||||
ctx.stroke();
|
||||
|
||||
// Inner cable
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||
ctx.strokeStyle = hasActive ? '#44ddff' : '#44ddff66';
|
||||
ctx.lineWidth = cableWidth;
|
||||
ctx.stroke();
|
||||
|
||||
// Channel count label at midpoint
|
||||
const labelX = (x1 + x2) / 2;
|
||||
const labelY = (y1 + y2) / 2 - cableWidth / 2 - 6;
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.fillStyle = '#44ddff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`/${n}`, labelX, labelY);
|
||||
|
||||
// Draw small diagonal slash across cable (bus notation)
|
||||
const slashX = labelX;
|
||||
const slashY = (y1 + y2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(slashX - 6, slashY + 6);
|
||||
ctx.lineTo(slashX + 6, slashY - 6);
|
||||
ctx.strokeStyle = '#44ddff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawComponentGate(gate) {
|
||||
const isHovered = state.hoveredGate === gate;
|
||||
const isActive = gate.value === 1;
|
||||
const w = getComponentWidth(gate);
|
||||
const h = getComponentHeight(gate);
|
||||
const color = '#9900ff';
|
||||
|
||||
if (isActive) {
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 20 * state.zoom;
|
||||
}
|
||||
|
||||
ctx.fillStyle = isActive ? color + '22' : '#14141e';
|
||||
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||
ctx.lineWidth = (isHovered ? 2.5 : 1.5);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(gate.x, gate.y, w, h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Component name label
|
||||
ctx.fillStyle = isActive ? '#fff' : color;
|
||||
ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const componentName = gate.component?.name || 'Component';
|
||||
ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2);
|
||||
|
||||
// Small ID label
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillStyle = '#444';
|
||||
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
|
||||
|
||||
// Get port labels from the authoritative component definition (customComponents)
|
||||
// This must match the source used by gateOutputCount/gateInputCount for port counts
|
||||
const compId = gate.type.substring(10);
|
||||
const comp = state.customComponents?.[compId] || gate.component;
|
||||
const inputLabels = [];
|
||||
const outputLabels = [];
|
||||
if (comp) {
|
||||
const inputIds = comp.inputIds || [];
|
||||
const outputIds = comp.outputIds || [];
|
||||
for (const id of inputIds) {
|
||||
const g = comp.gates.find(g => g.id === id);
|
||||
inputLabels.push(g?.label || '');
|
||||
}
|
||||
for (const id of outputIds) {
|
||||
const g = comp.gates.find(g => g.id === id);
|
||||
outputLabels.push(g?.label || '');
|
||||
}
|
||||
}
|
||||
|
||||
// Input ports
|
||||
getInputPorts(gate).forEach(p => {
|
||||
const isPortHovered = state.hoveredPort &&
|
||||
state.hoveredPort.gate === gate &&
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'input';
|
||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||
const portActive = getSourcePortValue(conn);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Port label (inside the gate, to the right of the port)
|
||||
const label = inputLabels[p.index];
|
||||
if (label) {
|
||||
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, p.x + PORT_R + 4, p.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Output ports
|
||||
getOutputPorts(gate).forEach(p => {
|
||||
const isPortHovered = state.hoveredPort &&
|
||||
state.hoveredPort.gate === gate &&
|
||||
state.hoveredPort.index === p.index &&
|
||||
state.hoveredPort.type === 'output';
|
||||
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Port label (inside the gate, to the left of the port)
|
||||
const label = outputLabels[p.index];
|
||||
if (label) {
|
||||
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, p.x - PORT_R - 4, p.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawConnection(conn) {
|
||||
@@ -119,7 +422,10 @@ function drawConnection(conn) {
|
||||
const toPort = getInputPorts(toGate)[conn.toPort];
|
||||
if (!fromPort || !toPort) return;
|
||||
|
||||
const active = fromGate.value === 1;
|
||||
// Read correct output port value for multi-output gates (components)
|
||||
const active = fromGate.outputValues
|
||||
? (fromGate.outputValues[conn.fromPort] || 0) === 1
|
||||
: fromGate.value === 1;
|
||||
const midX = (fromPort.x + toPort.x) / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
@@ -177,16 +483,126 @@ function drawConnectingWire() {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawBusCutLine() {
|
||||
if (!state.busCutting) return;
|
||||
const cut = state.busCutting;
|
||||
|
||||
// Dashed cyan line showing the cut
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cut.startX, cut.startY);
|
||||
ctx.lineTo(cut.endX, cut.endY);
|
||||
ctx.strokeStyle = '#44ddff';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([8, 5]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Small circles at endpoints
|
||||
ctx.beginPath();
|
||||
ctx.arc(cut.startX, cut.startY, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#44ddff';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cut.endX, cut.endY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight intersecting wires
|
||||
// Import is circular so we compute inline: sample bezier + test intersection
|
||||
const hits = countCutIntersections(cut);
|
||||
if (hits > 0) {
|
||||
ctx.save();
|
||||
ctx.resetTransform();
|
||||
ctx.fillStyle = '#44ddff';
|
||||
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`✂ ${hits} wire${hits > 1 ? 's' : ''}`, 10, canvas.height - 10);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick inline intersection count for preview (avoids circular import from bus.js)
|
||||
*/
|
||||
function countCutIntersections(cut) {
|
||||
let count = 0;
|
||||
for (const conn of state.connections) {
|
||||
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||
const toGate = state.gates.find(g => g.id === conn.to);
|
||||
if (!fromGate || !toGate) continue;
|
||||
const fp = getOutputPorts(fromGate)[conn.fromPort];
|
||||
const tp = getInputPorts(toGate)[conn.toPort];
|
||||
if (!fp || !tp) continue;
|
||||
|
||||
const midX = (fp.x + tp.x) / 2;
|
||||
// Sample bezier at 16 points
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const t1 = i / 16, t2 = (i + 1) / 16;
|
||||
const bx1 = bezAt(fp.x, midX, midX, tp.x, t1);
|
||||
const by1 = bezAt(fp.y, fp.y, tp.y, tp.y, t1);
|
||||
const bx2 = bezAt(fp.x, midX, midX, tp.x, t2);
|
||||
const by2 = bezAt(fp.y, fp.y, tp.y, tp.y, t2);
|
||||
if (segsHit(cut.startX, cut.startY, cut.endX, cut.endY, bx1, by1, bx2, by2)) {
|
||||
count++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function bezAt(p0, p1, p2, p3, t) {
|
||||
const mt = 1 - t;
|
||||
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||
const d = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1);
|
||||
if (Math.abs(d) < 1e-10) return false;
|
||||
const t = ((bx1 - ax1) * (by2 - by1) - (by1 - ay1) * (bx2 - bx1)) / d;
|
||||
const u = ((bx1 - ax1) * (ay2 - ay1) - (by1 - ay1) * (ax2 - ax1)) / d;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
}
|
||||
|
||||
function drawSelectionBox() {
|
||||
if (!state.selectionBox) return;
|
||||
const box = state.selectionBox;
|
||||
const x = Math.min(box.startX, box.endX);
|
||||
const y = Math.min(box.startY, box.endY);
|
||||
const w = Math.abs(box.endX - box.startX);
|
||||
const h = Math.abs(box.endY - box.startY);
|
||||
|
||||
ctx.fillStyle = '#44ddff0a';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = '#44ddff88';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawPlacingGhost() {
|
||||
if (!state.placingGate) return;
|
||||
ctx.globalAlpha = 0.5;
|
||||
const world = screenToWorld(state.mouseX, state.mouseY);
|
||||
|
||||
let w = GATE_W, h = GATE_H;
|
||||
if (state.placingGate.startsWith('COMPONENT:')) {
|
||||
const componentId = state.placingGate.substring(10);
|
||||
const component = state.customComponents?.[componentId];
|
||||
if (component) {
|
||||
const count = Math.max(component.inputCount, component.outputCount);
|
||||
w = 120;
|
||||
h = Math.max(60, (count + 1) * 25);
|
||||
}
|
||||
}
|
||||
|
||||
const ghost = {
|
||||
x: world.x - GATE_W / 2,
|
||||
y: world.y - GATE_H / 2,
|
||||
x: world.x - w / 2,
|
||||
y: world.y - h / 2,
|
||||
type: state.placingGate,
|
||||
value: 0,
|
||||
id: -1
|
||||
id: -1,
|
||||
component: state.customComponents?.[state.placingGate.substring(10)]
|
||||
};
|
||||
drawGate(ghost);
|
||||
ctx.globalAlpha = 1;
|
||||
@@ -213,8 +629,11 @@ function draw() {
|
||||
|
||||
drawGrid();
|
||||
state.connections.forEach(drawConnection);
|
||||
drawBusCables();
|
||||
state.gates.forEach(drawGate);
|
||||
drawConnectingWire();
|
||||
drawBusCutLine();
|
||||
drawSelectionBox();
|
||||
drawPlacingGhost();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { state } from './state.js';
|
||||
import { progress } from './levels.js';
|
||||
|
||||
const STORAGE_KEY = 'logiclab_state';
|
||||
|
||||
/**
|
||||
* Save complete application state to JSON
|
||||
*/
|
||||
@@ -54,6 +56,16 @@ export function loadState(data) {
|
||||
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
||||
}
|
||||
|
||||
// Re-link gate.component references to customComponents (authoritative source)
|
||||
for (const gate of state.gates) {
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
const compId = gate.type.substring(10);
|
||||
if (state.customComponents[compId]) {
|
||||
gate.component = state.customComponents[compId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load progress
|
||||
if (data.progress) {
|
||||
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
||||
@@ -147,3 +159,46 @@ export async function pasteFromClipboard() {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to localStorage
|
||||
*/
|
||||
export function saveToStorage() {
|
||||
try {
|
||||
const data = saveState();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.warn('[storage] failed to save:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from localStorage (returns true if state was restored)
|
||||
*/
|
||||
export function loadFromStorage() {
|
||||
try {
|
||||
const json = localStorage.getItem(STORAGE_KEY);
|
||||
if (!json) return false;
|
||||
const data = JSON.parse(json);
|
||||
const result = loadState(data);
|
||||
if (result.success) {
|
||||
console.log('[storage] restored state from localStorage');
|
||||
}
|
||||
return result.success;
|
||||
} catch (e) {
|
||||
console.warn('[storage] failed to load:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-saving to localStorage on an interval
|
||||
*/
|
||||
let autoSaveInterval = null;
|
||||
export function startAutoSave(intervalMs = 3000) {
|
||||
if (autoSaveInterval) clearInterval(autoSaveInterval);
|
||||
autoSaveInterval = setInterval(saveToStorage, intervalMs);
|
||||
// Also save on page unload
|
||||
window.addEventListener('beforeunload', saveToStorage);
|
||||
console.log(`[storage] auto-save enabled (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// Clock simulation engine
|
||||
// Clock simulation engine — uses real timestamps for consistent timing
|
||||
import { state } from './state.js';
|
||||
import { evaluateAll } from './gates.js';
|
||||
import { forceRecordSample } from './waveform.js';
|
||||
|
||||
export function simTick() {
|
||||
let lastTickTime = 0;
|
||||
let animFrameId = null;
|
||||
|
||||
function simLoop(now) {
|
||||
if (!state.simRunning) return;
|
||||
|
||||
// Fire ticks that are due based on real elapsed time
|
||||
while (now - lastTickTime >= state.simSpeed) {
|
||||
lastTickTime += state.simSpeed;
|
||||
|
||||
// Toggle all CLOCK gates
|
||||
state.gates.forEach(g => {
|
||||
if (g.type === 'CLOCK') {
|
||||
@@ -11,10 +20,12 @@ export function simTick() {
|
||||
}
|
||||
});
|
||||
evaluateAll();
|
||||
// Force record even if evaluateAll didn't detect change
|
||||
if (state.recording && state.waveformVisible) {
|
||||
forceRecordSample();
|
||||
}
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(simLoop);
|
||||
}
|
||||
|
||||
export function startSim() {
|
||||
@@ -29,18 +40,18 @@ export function startSim() {
|
||||
state.waveformVisible = true;
|
||||
document.getElementById('waveform-panel').classList.add('visible');
|
||||
document.getElementById('sim-btn').classList.add('active');
|
||||
// Trigger resize via event so renderer picks it up
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
state.simInterval = setInterval(simTick, state.simSpeed);
|
||||
lastTickTime = performance.now();
|
||||
animFrameId = requestAnimationFrame(simLoop);
|
||||
updateSimUI();
|
||||
}
|
||||
|
||||
export function stopSim() {
|
||||
state.simRunning = false;
|
||||
if (state.simInterval) clearInterval(state.simInterval);
|
||||
state.simInterval = null;
|
||||
if (animFrameId) cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
updateSimUI();
|
||||
}
|
||||
|
||||
@@ -57,10 +68,8 @@ export function updateSimUI() {
|
||||
}
|
||||
|
||||
export function adjustSpeed(delta) {
|
||||
const wasRunning = state.simRunning;
|
||||
state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta));
|
||||
if (state.simRunning) {
|
||||
clearInterval(state.simInterval);
|
||||
state.simInterval = setInterval(simTick, state.simSpeed);
|
||||
}
|
||||
// No need to restart — the loop reads simSpeed dynamically
|
||||
updateSimUI();
|
||||
}
|
||||
|
||||
16
js/state.js
16
js/state.js
@@ -36,5 +36,19 @@ export const state = {
|
||||
simSpeed: 500, // ms per tick
|
||||
|
||||
// Puzzle/Components
|
||||
customComponents: {} // { id -> component definition }
|
||||
customComponents: {}, // { id -> component definition }
|
||||
|
||||
// Component Editor
|
||||
componentEditorActive: false,
|
||||
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
|
||||
componentEditorName: '',
|
||||
editingComponentId: null, // ID of component being edited (null = new component)
|
||||
|
||||
// Bus cutting (shift+drag)
|
||||
busCutting: null, // { startX, startY, endX, endY } in world coords, or null
|
||||
|
||||
// Multi-selection
|
||||
selectedGates: [], // array of gate IDs currently selected
|
||||
selectionBox: null, // { startX, startY, endX, endY } in world coords while dragging
|
||||
multiDrag: null // { startX, startY, origins: [{id, x, y}] } while dragging selected gates
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export function getTrackedGates() {
|
||||
}
|
||||
|
||||
export function getGateLabel(gate) {
|
||||
if (gate.label) return gate.label;
|
||||
const sameType = state.gates.filter(g => g.type === gate.type);
|
||||
const idx = sameType.indexOf(gate);
|
||||
if (gate.type === 'CLOCK') return `CLK_${idx}`;
|
||||
@@ -20,6 +21,11 @@ export function getGateLabel(gate) {
|
||||
return `${gate.type}_${idx}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a sample triggered by user interaction (INPUT toggle).
|
||||
* When sim is running, records at current timeStep WITHOUT advancing it.
|
||||
* When sim is stopped, advances timeStep first.
|
||||
*/
|
||||
export function recordSample() {
|
||||
const { gates, waveData } = state;
|
||||
|
||||
@@ -31,8 +37,11 @@ export function recordSample() {
|
||||
|
||||
if (!changed && state.timeStep > 0) return;
|
||||
|
||||
// Manual toggles advance by simSpeed too for consistency
|
||||
// Only advance time if sim is NOT running (manual mode)
|
||||
if (!state.simRunning) {
|
||||
state.timeStep += state.simSpeed;
|
||||
}
|
||||
|
||||
gates.forEach(g => {
|
||||
if (!waveData[g.id]) waveData[g.id] = [];
|
||||
const arr = waveData[g.id];
|
||||
@@ -43,8 +52,11 @@ export function recordSample() {
|
||||
updateWaveInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a sample from the simulation tick.
|
||||
* Always advances timeStep — this is the ONLY source of time when sim is running.
|
||||
*/
|
||||
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] = [];
|
||||
|
||||
Reference in New Issue
Block a user