Add progressive puzzle mode alongside the existing sandbox: - 8 levels from basic gates to 2-bit adder - Truth table verification with pass/fail feedback - Gate restrictions per level - Custom components system (save circuits as reusable chips) - Save/load circuits as JSON - Level selection sidebar with difficulty ratings - Mode toggle: Sandbox (free play) vs Puzzle (guided levels) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
8.3 KiB
JavaScript
248 lines
8.3 KiB
JavaScript
// Waveform recording and rendering (GTKWave-style)
|
|
import { SIGNAL_COLORS } from './constants.js';
|
|
import { state } from './state.js';
|
|
|
|
export function getTrackedGates() {
|
|
const { gates } = state;
|
|
const clocks = gates.filter(g => g.type === 'CLOCK');
|
|
const inputs = gates.filter(g => g.type === 'INPUT');
|
|
const outputs = gates.filter(g => g.type === 'OUTPUT');
|
|
const logic = gates.filter(g => g.type !== 'INPUT' && g.type !== 'OUTPUT' && g.type !== 'CLOCK');
|
|
return [...clocks, ...inputs, ...logic, ...outputs];
|
|
}
|
|
|
|
export function getGateLabel(gate) {
|
|
const sameType = state.gates.filter(g => g.type === gate.type);
|
|
const idx = sameType.indexOf(gate);
|
|
if (gate.type === 'CLOCK') return `CLK_${idx}`;
|
|
if (gate.type === 'INPUT') return `IN_${idx}`;
|
|
if (gate.type === 'OUTPUT') return `OUT_${idx}`;
|
|
return `${gate.type}_${idx}`;
|
|
}
|
|
|
|
export function recordSample() {
|
|
const { gates, waveData } = state;
|
|
|
|
const changed = gates.some(g => {
|
|
const data = waveData[g.id];
|
|
if (!data || data.length === 0) return true;
|
|
return data[data.length - 1].value !== g.value;
|
|
});
|
|
|
|
if (!changed && state.timeStep > 0) return;
|
|
|
|
// Manual toggles advance by simSpeed too for consistency
|
|
state.timeStep += state.simSpeed;
|
|
gates.forEach(g => {
|
|
if (!waveData[g.id]) waveData[g.id] = [];
|
|
const arr = waveData[g.id];
|
|
if (arr.length === 0 || arr[arr.length - 1].value !== g.value) {
|
|
arr.push({ t: state.timeStep, value: g.value });
|
|
}
|
|
});
|
|
updateWaveInfo();
|
|
}
|
|
|
|
export function forceRecordSample() {
|
|
// Advance time by the current simSpeed (in ms) to reflect real time
|
|
state.timeStep += state.simSpeed;
|
|
state.gates.forEach(g => {
|
|
if (!state.waveData[g.id]) state.waveData[g.id] = [];
|
|
state.waveData[g.id].push({ t: state.timeStep, value: g.value });
|
|
});
|
|
updateWaveInfo();
|
|
}
|
|
|
|
export function manualStep() {
|
|
// Toggle all CLOCK gates on each step (same as simTick)
|
|
state.gates.forEach(g => {
|
|
if (g.type === 'CLOCK') {
|
|
g.value = g.value ? 0 : 1;
|
|
}
|
|
});
|
|
// Use the lazy-loaded evaluateAll to avoid circular imports
|
|
if (_evaluateAll) _evaluateAll();
|
|
forceRecordSample();
|
|
}
|
|
|
|
// Lazy reference to evaluateAll (set by gates.js to break circular dep)
|
|
let _evaluateAll = null;
|
|
export function setEvaluateAll(fn) {
|
|
_evaluateAll = fn;
|
|
}
|
|
|
|
export function updateWaveInfo() {
|
|
const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0);
|
|
const timeLabel = state.timeStep >= 1000
|
|
? `${(state.timeStep/1000).toFixed(1)}s`
|
|
: `${state.timeStep}ms`;
|
|
document.getElementById('wave-info').textContent = `T=${timeLabel} | ${totalSamples} samples`;
|
|
}
|
|
|
|
export function clearWaveData() {
|
|
state.waveData = {};
|
|
state.timeStep = 0;
|
|
state.waveScroll = 0;
|
|
updateWaveInfo();
|
|
}
|
|
|
|
export function drawWaveLabels() {
|
|
const labelsEl = document.getElementById('wave-labels');
|
|
labelsEl.innerHTML = '';
|
|
const tracked = getTrackedGates();
|
|
tracked.forEach((gate, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'wave-label';
|
|
if (gate.type === 'INPUT') div.classList.add('input-label');
|
|
else if (gate.type === 'OUTPUT') div.classList.add('output-label');
|
|
else div.classList.add('gate-label');
|
|
div.textContent = getGateLabel(gate);
|
|
div.style.color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
|
labelsEl.appendChild(div);
|
|
});
|
|
}
|
|
|
|
export function drawWaveforms() {
|
|
const wc = document.getElementById('wave-canvas');
|
|
const wctx = wc.getContext('2d');
|
|
const container = document.getElementById('wave-container');
|
|
|
|
wc.width = container.clientWidth - 100;
|
|
wc.height = container.clientHeight;
|
|
|
|
wctx.fillStyle = '#0c0c14';
|
|
wctx.fillRect(0, 0, wc.width, wc.height);
|
|
|
|
const tracked = getTrackedGates();
|
|
const rowH = 30;
|
|
const sigH = 20;
|
|
const margin = (rowH - sigH) / 2;
|
|
|
|
if (state.timeStep === 0) {
|
|
wctx.fillStyle = '#333';
|
|
wctx.font = '12px "Segoe UI", system-ui';
|
|
wctx.textAlign = 'center';
|
|
wctx.fillText('Toggle inputs to record signals...', wc.width / 2, wc.height / 2);
|
|
return;
|
|
}
|
|
|
|
// pxPerMs: how many pixels per millisecond of simulation time
|
|
const pxPerMs = state.waveZoom / 100; // waveZoom=20 → 0.2 px/ms
|
|
|
|
// Total width in pixels for all recorded time
|
|
const totalPx = state.timeStep * pxPerMs;
|
|
|
|
// Visible width in ms
|
|
const visibleMs = wc.width / pxPerMs;
|
|
|
|
// Auto-scroll: always follow the latest data, keep cursor at right edge
|
|
state.waveScroll = Math.max(0, state.timeStep - visibleMs);
|
|
|
|
// Helper: convert simulation time (ms) to pixel X
|
|
const tToX = (t) => (t - state.waveScroll) * pxPerMs;
|
|
|
|
// Draw time grid (every gridMs milliseconds)
|
|
let gridMs = 500;
|
|
if (pxPerMs * gridMs < 30) gridMs = 1000;
|
|
if (pxPerMs * gridMs < 30) gridMs = 2000;
|
|
if (pxPerMs * gridMs > 200) gridMs = 200;
|
|
if (pxPerMs * gridMs > 200) gridMs = 100;
|
|
|
|
wctx.strokeStyle = '#151520';
|
|
wctx.lineWidth = 1;
|
|
const startT = Math.floor(state.waveScroll / gridMs) * gridMs;
|
|
for (let t = startT; t <= state.timeStep; t += gridMs) {
|
|
const x = tToX(t);
|
|
if (x < 0 || x > wc.width) continue;
|
|
wctx.beginPath();
|
|
wctx.moveTo(x, 0);
|
|
wctx.lineTo(x, wc.height);
|
|
wctx.stroke();
|
|
|
|
wctx.fillStyle = '#333';
|
|
wctx.font = '9px monospace';
|
|
wctx.textAlign = 'center';
|
|
const label = t >= 1000 ? `${(t/1000).toFixed(1)}s` : `${t}ms`;
|
|
wctx.fillText(label, x, 10);
|
|
}
|
|
|
|
// Row dividers
|
|
tracked.forEach((_, i) => {
|
|
const y = i * rowH + rowH;
|
|
wctx.strokeStyle = '#111118';
|
|
wctx.beginPath();
|
|
wctx.moveTo(0, y);
|
|
wctx.lineTo(wc.width, y);
|
|
wctx.stroke();
|
|
});
|
|
|
|
// Draw signals using actual timestamps
|
|
tracked.forEach((gate, i) => {
|
|
const data = state.waveData[gate.id] || [];
|
|
if (data.length === 0) return;
|
|
|
|
const color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
|
const y0 = i * rowH + margin;
|
|
const yHigh = y0 + 2;
|
|
const yLow = y0 + sigH;
|
|
|
|
wctx.strokeStyle = color;
|
|
wctx.lineWidth = 1.5;
|
|
wctx.beginPath();
|
|
|
|
let started = false;
|
|
|
|
for (let s = 0; s < data.length; s++) {
|
|
const sample = data[s];
|
|
const nextT = s < data.length - 1 ? data[s + 1].t : state.timeStep;
|
|
const x1 = tToX(sample.t);
|
|
const x2 = tToX(nextT);
|
|
const y = sample.value ? yHigh : yLow;
|
|
|
|
if (!started) {
|
|
// Draw from time 0 to first sample
|
|
const x0 = tToX(0);
|
|
const initY = yLow; // default low before first sample
|
|
if (x0 < wc.width) {
|
|
wctx.moveTo(Math.max(0, x0), initY);
|
|
if (sample.t > 0) wctx.lineTo(x1, initY);
|
|
wctx.lineTo(x1, y);
|
|
}
|
|
started = true;
|
|
} else {
|
|
// Vertical transition from previous value
|
|
const prevVal = data[s - 1].value;
|
|
if (sample.value !== prevVal) {
|
|
wctx.lineTo(x1, prevVal ? yHigh : yLow);
|
|
wctx.lineTo(x1, y);
|
|
}
|
|
}
|
|
// Horizontal line to next transition
|
|
wctx.lineTo(x2, y);
|
|
|
|
// Fill high regions
|
|
if (sample.value) {
|
|
wctx.save();
|
|
wctx.globalAlpha = 0.08;
|
|
wctx.fillStyle = color;
|
|
wctx.fillRect(x1, yHigh, x2 - x1, sigH);
|
|
wctx.restore();
|
|
}
|
|
}
|
|
wctx.stroke();
|
|
});
|
|
|
|
// Cursor line at current time
|
|
const cursorX = tToX(state.timeStep);
|
|
if (cursorX >= 0 && cursorX <= wc.width) {
|
|
wctx.strokeStyle = '#00e59966';
|
|
wctx.lineWidth = 1;
|
|
wctx.setLineDash([4, 3]);
|
|
wctx.beginPath();
|
|
wctx.moveTo(cursorX, 0);
|
|
wctx.lineTo(cursorX, wc.height);
|
|
wctx.stroke();
|
|
wctx.setLineDash([]);
|
|
}
|
|
}
|