- AST: StreamDecl, StreamMode, Declaration::Stream, StreamFrom struct variant
- Lexer: Pixel, Delta, Signals keywords
- Parser: parse_stream_decl with mode block, fixed TokenKind::On match
- Signal graph: streamable flag, SignalManifest, Declaration::Stream detection
- Checker: StreamFrom { source, .. } pattern
- Codegen: DS._initStream(), DS._connectStream(), DS._streamDiff() hooks
- Runtime JS: full streaming layer with binary protocol encoding
- Layout: to_bytes/from_bytes on LayoutRect
82 tests pass (5 new: 3 parser stream + 2 analyzer streamable)
530 lines
No EOL
22 KiB
HTML
530 lines
No EOL
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>DreamStack — Spring Physics + 2D Scene</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, system-ui, sans-serif;
|
|
background: #0a0a0f;
|
|
color: #e2e8f0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.8rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6, #c084fc);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.subtitle {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
font-size: 0.8rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.controls button {
|
|
padding: 0.5rem 1.2rem;
|
|
border: none;
|
|
border-radius: 12px;
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
|
|
color: #c4b5fd;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
border: 1px solid rgba(139, 92, 246, 0.15);
|
|
}
|
|
|
|
.controls button:hover {
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.4), rgba(139, 92, 246, 0.4));
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.info {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.7rem;
|
|
color: rgba(255, 255, 255, 0.2);
|
|
margin-top: 0.5rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.sliders {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
margin-top: 1rem;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.slider-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.slider-group label {
|
|
font-size: 0.7rem;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
min-width: 55px;
|
|
}
|
|
|
|
.slider-group input[type="range"] {
|
|
width: 100px;
|
|
accent-color: #8b5cf6;
|
|
}
|
|
|
|
.slider-group .val {
|
|
font-size: 0.7rem;
|
|
color: #8b5cf6;
|
|
min-width: 25px;
|
|
text-align: right;
|
|
}
|
|
|
|
.powered {
|
|
margin-top: 1.5rem;
|
|
font-size: 0.65rem;
|
|
color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.powered span {
|
|
color: rgba(139, 92, 246, 0.3);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>Spring Physics + 2D Scene</h1>
|
|
<p class="subtitle">Springs drive positions. Canvas renders frames. No CSS animations.</p>
|
|
|
|
<div class="controls" id="controls"></div>
|
|
<div id="scene"></div>
|
|
<div class="info" id="info"></div>
|
|
<div class="sliders" id="sliders"></div>
|
|
<div class="powered">Built with <span>DreamStack</span> — springs + signals + 2D scene</div>
|
|
|
|
<script>
|
|
// ═══════════════════════════════════════════════════════════
|
|
// DreamStack Runtime v0.5.0 — Signals + Springs + 2D Scene
|
|
// ═══════════════════════════════════════════════════════════
|
|
const DS = (() => {
|
|
let currentEffect = null;
|
|
let batchDepth = 0;
|
|
let pendingEffects = new Set();
|
|
|
|
class Signal {
|
|
constructor(val) { this._value = val; this._subs = new Set(); }
|
|
get value() { if (currentEffect) this._subs.add(currentEffect); return this._value; }
|
|
set value(v) {
|
|
if (this._value === v) return;
|
|
this._value = v;
|
|
if (batchDepth > 0) for (const s of this._subs) pendingEffects.add(s);
|
|
else for (const s of [...this._subs]) s._run();
|
|
}
|
|
}
|
|
|
|
class Effect {
|
|
constructor(fn) { this._fn = fn; this._disposed = false; }
|
|
_run() {
|
|
if (this._disposed) return;
|
|
const prev = currentEffect; currentEffect = this;
|
|
try { this._fn(); } finally { currentEffect = prev; }
|
|
}
|
|
dispose() { this._disposed = true; }
|
|
}
|
|
|
|
const signal = v => new Signal(v);
|
|
const effect = fn => { const e = new Effect(fn); e._run(); return e; };
|
|
const batch = fn => {
|
|
batchDepth++;
|
|
try { fn(); } finally { batchDepth--; if (batchDepth === 0) flush(); }
|
|
};
|
|
const flush = () => {
|
|
const effs = [...pendingEffects]; pendingEffects.clear();
|
|
for (const e of effs) e._run();
|
|
};
|
|
|
|
// ── Spring Physics ──
|
|
const _activeSprings = new Set();
|
|
let _rafId = null, _lastTime = 0;
|
|
|
|
class Spring {
|
|
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
|
|
this._signal = new Signal(value);
|
|
this._velocity = 0;
|
|
this._target = target !== undefined ? target : value;
|
|
this.stiffness = stiffness;
|
|
this.damping = damping;
|
|
this.mass = mass;
|
|
this._settled = true;
|
|
}
|
|
get value() { return this._signal.value; }
|
|
set value(v) { this.target = v; }
|
|
get target() { return this._target; }
|
|
set target(t) {
|
|
this._target = t;
|
|
this._settled = false;
|
|
_activeSprings.add(this);
|
|
_startLoop();
|
|
}
|
|
set(v) {
|
|
this._signal.value = v; this._target = v;
|
|
this._velocity = 0; this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
_step(dt) {
|
|
const pos = this._signal._value, vel = this._velocity;
|
|
const k = this.stiffness, d = this.damping, m = this.mass;
|
|
const a = (p, v) => (-k * (p - this._target) - d * v) / m;
|
|
const k1v = a(pos, vel), k1p = vel;
|
|
const k2v = a(pos + k1p * dt / 2, vel + k1v * dt / 2), k2p = vel + k1v * dt / 2;
|
|
const k3v = a(pos + k2p * dt / 2, vel + k2v * dt / 2), k3p = vel + k2v * dt / 2;
|
|
const k4v = a(pos + k3p * dt, vel + k3v * dt), k4p = vel + k3v * dt;
|
|
this._velocity = vel + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
|
|
this._signal.value = pos + (dt / 6) * (k1p + 2 * k2p + 2 * k3p + k4p);
|
|
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
|
|
this._signal.value = this._target;
|
|
this._velocity = 0; this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _startLoop() {
|
|
if (_rafId !== null) return;
|
|
_lastTime = performance.now();
|
|
_rafId = requestAnimationFrame(_loop);
|
|
}
|
|
function _loop(now) {
|
|
const dt = Math.min((now - _lastTime) / 1000, 0.064);
|
|
_lastTime = now;
|
|
batch(() => {
|
|
for (const s of _activeSprings) {
|
|
const steps = Math.ceil(dt / (1 / 120));
|
|
const subDt = dt / steps;
|
|
for (let i = 0; i < steps; i++) s._step(subDt);
|
|
}
|
|
});
|
|
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_loop);
|
|
else _rafId = null;
|
|
}
|
|
|
|
function spring(opts) {
|
|
return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts });
|
|
}
|
|
|
|
// ── 2D Scene Engine ──
|
|
function scene(width, height) {
|
|
const canvas = document.createElement('canvas');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = (width || 600) * dpr;
|
|
canvas.height = (height || 400) * dpr;
|
|
canvas.style.width = (width || 600) + 'px';
|
|
canvas.style.height = (height || 400) + 'px';
|
|
canvas.style.borderRadius = '16px';
|
|
canvas.style.background = 'rgba(255,255,255,0.02)';
|
|
canvas.style.border = '1px solid rgba(255,255,255,0.06)';
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(dpr, dpr);
|
|
const shapes = [];
|
|
let _dirty = false;
|
|
const w = width || 600, h = height || 400;
|
|
|
|
function _render() {
|
|
ctx.clearRect(0, 0, w, h);
|
|
// Draw grid
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x < w; x += 40) {
|
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
}
|
|
for (let y = 0; y < h; y += 40) {
|
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
}
|
|
for (const s of shapes) s._draw(ctx);
|
|
}
|
|
|
|
function _scheduleRender() {
|
|
if (!_dirty) {
|
|
_dirty = true;
|
|
queueMicrotask(() => { _dirty = false; _render(); });
|
|
}
|
|
}
|
|
|
|
return { canvas, ctx, shapes, w, h, _render, _scheduleRender };
|
|
}
|
|
|
|
function _readVal(v) {
|
|
return v && typeof v === 'object' && 'value' in v ? v.value : (typeof v === 'function' ? v() : v);
|
|
}
|
|
|
|
function circle(scn, opts) {
|
|
const shape = {
|
|
type: 'circle',
|
|
_draw(ctx) {
|
|
const x = _readVal(opts.x), y = _readVal(opts.y);
|
|
const r = _readVal(opts.r) || 20;
|
|
const fill = opts.fill || '#8b5cf6';
|
|
|
|
// Trail effect
|
|
if (opts.trail) {
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r + 8, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(139,92,246,0.08)';
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r + 16, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(139,92,246,0.03)';
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
|
|
grad.addColorStop(0, opts.highlight || '#c4b5fd');
|
|
grad.addColorStop(1, fill);
|
|
ctx.fillStyle = grad;
|
|
ctx.shadowColor = fill;
|
|
ctx.shadowBlur = 25;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
|
|
if (opts.r && typeof opts.r === 'object' && 'value' in opts.r) opts.r.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function rect(scn, opts) {
|
|
const shape = {
|
|
type: 'rect',
|
|
_draw(ctx) {
|
|
const x = _readVal(opts.x), y = _readVal(opts.y);
|
|
const w = _readVal(opts.w) || 40, h = _readVal(opts.h) || 40;
|
|
const fill = opts.fill || '#6366f1';
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, w, h, opts.radius || 8);
|
|
ctx.fillStyle = fill;
|
|
ctx.shadowColor = fill;
|
|
ctx.shadowBlur = 15;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x && typeof opts.x === 'object') opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object') opts.y.value;
|
|
if (opts.w && typeof opts.w === 'object') opts.w.value;
|
|
if (opts.h && typeof opts.h === 'object') opts.h.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function line(scn, opts) {
|
|
const shape = {
|
|
type: 'line',
|
|
_draw(ctx) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(_readVal(opts.x1), _readVal(opts.y1));
|
|
ctx.lineTo(_readVal(opts.x2), _readVal(opts.y2));
|
|
ctx.strokeStyle = opts.stroke || 'rgba(139,92,246,0.2)';
|
|
ctx.lineWidth = opts.width || 1;
|
|
ctx.setLineDash(opts.dash || []);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x1 && typeof opts.x1 === 'object') opts.x1.value;
|
|
if (opts.y1 && typeof opts.y1 === 'object') opts.y1.value;
|
|
if (opts.x2 && typeof opts.x2 === 'object') opts.x2.value;
|
|
if (opts.y2 && typeof opts.y2 === 'object') opts.y2.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
return { signal, effect, batch, flush, spring, scene, circle, rect, line, Signal, Spring };
|
|
})();
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Spring Ball Demo
|
|
// ═══════════════════════════════════════════════════════════
|
|
(() => {
|
|
const scn = DS.scene(700, 400);
|
|
document.getElementById('scene').appendChild(scn.canvas);
|
|
|
|
// Springs for ball position
|
|
const ballX = DS.spring({ value: 350, stiffness: 170, damping: 26 });
|
|
const ballY = DS.spring({ value: 200, stiffness: 170, damping: 26 });
|
|
const ballR = DS.spring({ value: 25, stiffness: 300, damping: 20 });
|
|
|
|
// Target marker (non-spring, just a signal)
|
|
const targetX = DS.signal(350);
|
|
const targetY = DS.signal(200);
|
|
|
|
// Draw target crosshair
|
|
DS.line(scn, { x1: targetX, y1: 0, x2: targetX, y2: 400, stroke: 'rgba(99,102,241,0.08)', dash: [4, 4] });
|
|
DS.line(scn, { x1: 0, y1: targetY, x2: 700, y2: targetY, stroke: 'rgba(99,102,241,0.08)', dash: [4, 4] });
|
|
|
|
// Draw connection line from ball to target
|
|
DS.line(scn, { x1: ballX, y1: ballY, x2: targetX, y2: targetY, stroke: 'rgba(139,92,246,0.15)', width: 1, dash: [3, 3] });
|
|
|
|
// Target dot
|
|
DS.circle(scn, { x: targetX, y: targetY, r: 6, fill: 'rgba(99,102,241,0.3)' });
|
|
|
|
// The ball!
|
|
DS.circle(scn, { x: ballX, y: ballY, r: ballR, fill: '#8b5cf6', trail: true });
|
|
|
|
// Click to move
|
|
scn.canvas.addEventListener('click', (e) => {
|
|
const rect = scn.canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left);
|
|
const y = (e.clientY - rect.top);
|
|
targetX.value = x;
|
|
targetY.value = y;
|
|
ballX.value = x; // triggers spring animation
|
|
ballY.value = y;
|
|
});
|
|
|
|
// Drag support
|
|
let dragging = false;
|
|
scn.canvas.addEventListener('mousedown', (e) => {
|
|
const rect = scn.canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const bx = ballX._signal._value, by = ballY._signal._value;
|
|
if (Math.hypot(x - bx, y - by) < 40) {
|
|
dragging = true;
|
|
scn.canvas.style.cursor = 'grabbing';
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!dragging) return;
|
|
const rect = scn.canvas.getBoundingClientRect();
|
|
const x = Math.max(20, Math.min(680, e.clientX - rect.left));
|
|
const y = Math.max(20, Math.min(380, e.clientY - rect.top));
|
|
ballX.set(x); ballY.set(y); // instant set while dragging
|
|
targetX.value = x; targetY.value = y;
|
|
});
|
|
window.addEventListener('mouseup', () => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
scn.canvas.style.cursor = 'pointer';
|
|
// Spring back to center
|
|
ballX.value = 350;
|
|
ballY.value = 200;
|
|
targetX.value = 350;
|
|
targetY.value = 200;
|
|
ballR.value = 25;
|
|
});
|
|
|
|
scn.canvas.style.cursor = 'pointer';
|
|
|
|
// Info display
|
|
const info = document.getElementById('info');
|
|
DS.effect(() => {
|
|
info.textContent = `ball: (${ballX.value.toFixed(0)}, ${ballY.value.toFixed(0)}) → target: (${targetX.value.toFixed(0)}, ${targetY.value.toFixed(0)}) | r: ${ballR.value.toFixed(0)}`;
|
|
});
|
|
|
|
// Buttons
|
|
const controls = document.getElementById('controls');
|
|
const presets = [
|
|
{ label: '↖ Top-Left', x: 60, y: 60 },
|
|
{ label: '↗ Top-Right', x: 640, y: 60 },
|
|
{ label: '⊙ Center', x: 350, y: 200 },
|
|
{ label: '↙ Bot-Left', x: 60, y: 340 },
|
|
{ label: '↘ Bot-Right', x: 640, y: 340 },
|
|
{ label: '🎾 Bounce', action: 'bounce' },
|
|
{ label: '💥 Explode', action: 'explode' },
|
|
];
|
|
|
|
presets.forEach(p => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = p.label;
|
|
btn.addEventListener('click', () => {
|
|
if (p.action === 'bounce') {
|
|
const positions = [
|
|
[100, 100], [600, 100], [600, 300], [100, 300]
|
|
];
|
|
let i = 0;
|
|
const interval = setInterval(() => {
|
|
const [x, y] = positions[i % positions.length];
|
|
targetX.value = x; targetY.value = y;
|
|
ballX.value = x; ballY.value = y;
|
|
i++;
|
|
if (i >= 8) clearInterval(interval);
|
|
}, 400);
|
|
} else if (p.action === 'explode') {
|
|
ballR.value = 80;
|
|
setTimeout(() => { ballR.value = 25; }, 300);
|
|
} else {
|
|
targetX.value = p.x; targetY.value = p.y;
|
|
ballX.value = p.x; ballY.value = p.y;
|
|
}
|
|
});
|
|
controls.appendChild(btn);
|
|
});
|
|
|
|
// Spring parameter sliders
|
|
const sliders = document.getElementById('sliders');
|
|
function makeSlider(label, min, max, initial, onChange) {
|
|
const group = document.createElement('div');
|
|
group.className = 'slider-group';
|
|
const lbl = document.createElement('label');
|
|
lbl.textContent = label;
|
|
const input = document.createElement('input');
|
|
input.type = 'range'; input.min = min; input.max = max; input.value = initial;
|
|
const val = document.createElement('span');
|
|
val.className = 'val'; val.textContent = initial;
|
|
input.addEventListener('input', () => {
|
|
val.textContent = input.value;
|
|
onChange(Number(input.value));
|
|
});
|
|
group.appendChild(lbl);
|
|
group.appendChild(input);
|
|
group.appendChild(val);
|
|
sliders.appendChild(group);
|
|
}
|
|
|
|
makeSlider('Stiffness', 10, 500, 170, v => { ballX.stiffness = v; ballY.stiffness = v; });
|
|
makeSlider('Damping', 1, 60, 26, v => { ballX.damping = v; ballY.damping = v; });
|
|
makeSlider('Mass', 1, 20, 1, v => { ballX.mass = v; ballY.mass = v; });
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |