Files
logic-gates/js/waveform.js
Jose Luis b2e367817c feat: add Turing Complete-style puzzle system
Add progressive puzzle mode alongside the existing sandbox:
- 8 levels from basic gates to 2-bit adder
- Truth table verification with pass/fail feedback
- Gate restrictions per level
- Custom components system (save circuits as reusable chips)
- Save/load circuits as JSON
- Level selection sidebar with difficulty ratings
- Mode toggle: Sandbox (free play) vs Puzzle (guided levels)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:06:57 +01:00

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([]);
}
}