dreamstack/engine/demo/index.html
enzotar 9cc395d2a7 feat(demo): pixel streaming demo with delta+RLE encoding
- Source (index.html): physics sim → pixel capture → delta XOR + RLE encode → WebSocket
- Receiver (receiver.html): WebSocket → decode delta/keyframe → render + click interaction + RTT
- Relay (relay.js): bidirectional WebSocket relay (pixels ↓ signals ↑)
- 97% compression (18KB/frame vs 675KB raw RGBA)
- Interactive: click on receiver → impulse force on source → round-trip latency measured
- Frame types: 0x11 keyframe (every 60 frames), 0x12 delta+RLE
- No buffer bloat: drops frames when pipe is busy
2026-03-10 20:49:35 -07:00

760 lines
No EOL
36 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack — Pixel Stream Source</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0a0a12;
--card: #12121e;
--surface: #1a1a2e;
--blue: #4f8fff;
--purple: #8b5cf6;
--green: #10b981;
--orange: #f59e0b;
--red: #ef4444;
--cyan: #06b6d4;
--text: #e2e8f0;
--dim: #64748b;
--border: #1e293b;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.header {
text-align: center;
padding: 1.5rem 1rem 1rem;
position: relative;
}
.header::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
width: 80%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--blue), var(--purple), transparent);
}
.header h1 {
font-size: 1.6rem;
font-weight: 700;
background: linear-gradient(135deg, var(--blue), var(--purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .sub {
font-family: 'JetBrains Mono', monospace;
font-size: .7rem;
color: var(--green);
margin-top: .2rem;
letter-spacing: .05em;
}
.status-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: .8rem;
font-family: 'JetBrains Mono', monospace;
font-size: .7rem;
}
.status-item {
display: flex;
align-items: center;
gap: .4rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
.status-dot.off {
background: var(--red);
}
.layout {
display: flex;
gap: 1.5rem;
padding: 1rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
flex: 1;
}
.panel.source {
box-shadow: 0 0 20px rgba(79, 143, 255, 0.2);
}
.panel-label {
display: flex;
align-items: center;
gap: .5rem;
padding: .6rem 1rem;
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
border-bottom: 1px solid var(--border);
color: var(--blue);
}
.panel-label .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--blue);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1
}
50% {
opacity: .3
}
}
canvas {
display: block;
width: 100%;
background: #08080f;
}
.stats {
display: flex;
gap: 1rem;
padding: .5rem 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: .6rem;
color: var(--dim);
flex-wrap: wrap;
border-top: 1px solid var(--border);
}
.stat {
display: flex;
gap: .3rem;
}
.stat-v {
color: var(--green);
}
.controls {
display: flex;
gap: .5rem;
padding: .8rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
flex-wrap: wrap;
justify-content: center;
}
.btn {
font-family: 'Inter', sans-serif;
font-size: .65rem;
font-weight: 600;
padding: .45rem .9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--dim);
cursor: pointer;
transition: all .2s;
text-transform: uppercase;
letter-spacing: .06em;
}
.btn:hover {
border-color: var(--blue);
color: var(--text);
transform: translateY(-1px);
}
.btn.active {
background: linear-gradient(135deg, var(--blue), var(--purple));
border-color: transparent;
color: white;
box-shadow: 0 0 12px rgba(79, 143, 255, .3);
}
.legend {
max-width: 1200px;
margin: .3rem auto .5rem;
padding: 0 1.5rem;
display: flex;
gap: .8rem;
flex-wrap: wrap;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: .3rem;
font-size: .6rem;
color: var(--dim);
}
.legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
/* Stream info panel */
.stream-panel {
min-width: 200px;
max-width: 220px;
display: flex;
flex-direction: column;
gap: .75rem;
padding-top: 1rem;
}
.info-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: .5rem .75rem;
font-family: 'JetBrains Mono', monospace;
font-size: .55rem;
}
.info-card .title {
color: var(--cyan);
font-weight: 600;
text-transform: uppercase;
letter-spacing: .1em;
margin-bottom: .3rem;
font-size: .6rem;
}
.info-card .val {
color: var(--orange);
word-break: break-all;
line-height: 1.4;
}
.arrow {
text-align: center;
color: var(--green);
font-size: 1.2rem;
animation: flow 1.5s infinite;
}
@keyframes flow {
0% {
transform: translateY(-3px);
opacity: .3
}
50% {
transform: translateY(3px);
opacity: 1
}
100% {
transform: translateY(-3px);
opacity: .3
}
}
.arrow-label {
text-align: center;
font-size: .6rem;
color: var(--dim);
}
</style>
</head>
<body>
<div class="header">
<h1>DreamStack Engine — Source</h1>
<div class="sub">ds-physics v0.9.0 · pixel streaming via ds-stream</div>
</div>
<div class="status-bar">
<div class="status-item"><span class="status-dot off" id="wsDot"></span><span id="wsStatus"
style="color:var(--red)">Disconnected</span></div>
<div class="status-item"><span style="color:var(--dim)">Relay:</span> <span id="relayAddr"
style="color:var(--orange)">ws://localhost:9800/source</span></div>
</div>
<div class="controls">
<button class="btn active" onclick="setScene(0)">🔗 Joint Family</button>
<button class="btn" onclick="setScene(1)">🎯 Sensors</button>
<button class="btn" onclick="setScene(2)">💤 Sleeping</button>
<button class="btn" onclick="setScene(3)">⚙️ Motors</button>
<button class="btn" onclick="setScene(4)">💥 Collision Events</button>
</div>
<div class="legend" id="legend"></div>
<div class="layout">
<div class="panel source">
<div class="panel-label"><span class="dot"></span> Physics Simulation</div>
<canvas id="source" width="480" height="360"></canvas>
<div class="stats">
<div class="stat">FPS <span class="stat-v" id="fps">0</span></div>
<div class="stat">Bodies <span class="stat-v" id="bodyCount">0</span></div>
<div class="stat">Joints <span class="stat-v" id="jointCount">0</span></div>
<div class="stat">Events <span class="stat-v" id="eventCount">0</span></div>
</div>
</div>
<div class="stream-panel">
<div class="arrow-label">CAPTURE</div>
<div class="arrow"></div>
<div class="info-card">
<div class="title">ds-stream frame</div>
<div class="val" id="frameInfo"></div>
</div>
<div class="info-card">
<div class="title">Header (16B)</div>
<div class="val" id="frameHex"></div>
</div>
<div class="arrow"></div>
<div class="arrow-label">WebSocket</div>
<div class="arrow"></div>
<div class="info-card">
<div class="title">Streamed</div>
<div class="val" id="streamStats"></div>
</div>
</div>
</div>
<script>
// ─── ds-stream Protocol ───
const FRAME_KEYFRAME = 0x11;
const FRAME_DELTA = 0x12;
const SIGNAL_CLICK = 0x30;
const SIGNAL_PONG = 0x31;
const HEADER_SIZE = 16;
const KEYFRAME_INTERVAL = 60; // full frame every N frames
// Previous frame buffer for delta encoding
let prevFrameBuffer = null;
// Remote click flashes
const clickFlashes = [];
function encodeFrame(seq, timestamp, w, h, pixels) {
const totalPixels = w * h * 4;
const isKeyframe = (seq % KEYFRAME_INTERVAL === 0) || !prevFrameBuffer;
let payload, frameType;
if (isKeyframe) {
// Keyframe: send raw pixels (receiver resets its buffer)
frameType = FRAME_KEYFRAME;
payload = pixels;
// Save as previous
prevFrameBuffer = new Uint8Array(totalPixels);
prevFrameBuffer.set(pixels.subarray(0, totalPixels));
} else {
// Delta: XOR with previous frame, then RLE encode
frameType = FRAME_DELTA;
payload = encodeDeltaRLE(pixels, prevFrameBuffer, totalPixels);
// Update previous buffer
prevFrameBuffer.set(pixels.subarray(0, totalPixels));
}
// Build frame: [header 16B] [payload]
const frame = new Uint8Array(HEADER_SIZE + payload.length);
const v = new DataView(frame.buffer);
frame[0] = frameType;
frame[1] = 0x00;
v.setUint16(2, seq & 0xFFFF, true);
v.setUint32(4, timestamp, true);
v.setUint16(8, w, true);
v.setUint16(10, h, true);
v.setUint32(12, payload.length, true);
frame.set(payload, HEADER_SIZE);
return frame;
}
// RLE-encode XOR delta: [count_hi, count_lo, r, g, b, a] per run
// Runs of identical RGBA quads are collapsed
function encodeDeltaRLE(current, previous, totalBytes) {
const chunks = [];
let i = 0;
while (i < totalBytes) {
// XOR this pixel
const dr = current[i] ^ previous[i];
const dg = current[i + 1] ^ previous[i + 1];
const db = current[i + 2] ^ previous[i + 2];
const da = current[i + 3] ^ previous[i + 3];
// Count consecutive identical XOR values
let runLen = 1;
let j = i + 4;
while (j < totalBytes && runLen < 65535) {
if ((current[j] ^ previous[j]) !== dr ||
(current[j + 1] ^ previous[j + 1]) !== dg ||
(current[j + 2] ^ previous[j + 2]) !== db ||
(current[j + 3] ^ previous[j + 3]) !== da) break;
runLen++;
j += 4;
}
// Emit: [count_hi, count_lo, r, g, b, a]
chunks.push(runLen >> 8, runLen & 0xFF, dr, dg, db, da);
i = j;
}
return new Uint8Array(chunks);
}
// ─── Physics (same engine as before) ───
class Vec2 {
constructor(x = 0, y = 0) { this.x = x; this.y = y }
add(v) { return new Vec2(this.x + v.x, this.y + v.y) }
sub(v) { return new Vec2(this.x - v.x, this.y - v.y) }
scale(s) { return new Vec2(this.x * s, this.y * s) }
len() { return Math.sqrt(this.x * this.x + this.y * this.y) }
norm() { const l = this.len(); return l > 0 ? this.scale(1 / l) : new Vec2() }
dot(v) { return this.x * v.x + this.y * v.y }
}
class Body {
constructor(x, y, r, type = 'dynamic') {
this.pos = new Vec2(x, y); this.vel = new Vec2(); this.acc = new Vec2();
this.radius = r; this.width = r * 2; this.height = r * 2; this.shape = 'circle';
this.type = type; this.color = '#4f8fff'; this.sleeping = false; this.removed = false;
this.angularVel = 0; this.angle = 0; this.mass = r * 0.1; this.glowColor = null; this.label = '';
}
}
class Joint {
constructor(a, b, type, p = {}) { this.bodyA = a; this.bodyB = b; this.type = type; this.params = p; this.removed = false; this.color = '#64748b'; this.motorAngle = 0; }
}
class PhysicsWorld {
constructor(w, h) { this.width = w; this.height = h; this.bodies = []; this.joints = []; this.gravity = new Vec2(0, 120); this.events = []; this.prevContacts = new Set(); }
createCircle(x, y, r, t = 'dynamic') { const b = new Body(x, y, r, t); this.bodies.push(b); return this.bodies.length - 1; }
createSensor(x, y, r) { const i = this.createCircle(x, y, r, 'sensor'); this.bodies[i].color = 'rgba(16,185,129,0.25)'; this.bodies[i].glowColor = '#10b981'; return i; }
createJoint(a, b, t, p = {}) { this.joints.push(new Joint(a, b, t, p)); return this.joints.length - 1; }
step(dt) {
this.events = []; const contacts = new Set();
for (const b of this.bodies) {
if (b.removed || b.sleeping || b.type === 'kinematic' || b.type === 'sensor') continue;
b.vel = b.vel.add(this.gravity.scale(dt)).add(b.acc.scale(dt)); b.acc = new Vec2();
b.vel = b.vel.scale(0.998); b.pos = b.pos.add(b.vel.scale(dt)); b.angle += b.angularVel * dt;
if (b.pos.x - b.radius < 0) { b.pos.x = b.radius; b.vel.x *= -0.5 }
if (b.pos.x + b.radius > this.width) { b.pos.x = this.width - b.radius; b.vel.x *= -0.5 }
if (b.pos.y - b.radius < 0) { b.pos.y = b.radius; b.vel.y *= -0.5 }
if (b.pos.y + b.radius > this.height) { b.pos.y = this.height - b.radius; b.vel.y *= -0.5 }
}
for (let i = 0; i < this.bodies.length; i++) {
for (let j = i + 1; j < this.bodies.length; j++) {
const a = this.bodies[i], b = this.bodies[j];
if (a.removed || b.removed) continue;
const d = a.pos.sub(b.pos), dist = d.len(), minD = a.radius + b.radius;
if (dist < minD && dist > 0) {
contacts.add(`${i}:${j}`);
if (a.type !== 'sensor' && b.type !== 'sensor') {
const n = d.scale(1 / dist), ov = minD - dist;
if (a.type === 'dynamic' && b.type === 'dynamic') {
a.pos = a.pos.add(n.scale(ov * 0.5)); b.pos = b.pos.sub(n.scale(ov * 0.5));
const rv = a.vel.sub(b.vel).dot(n);
if (rv < 0) { a.vel = a.vel.sub(n.scale(rv * 0.8)); b.vel = b.vel.add(n.scale(rv * 0.8)) }
} else if (a.type === 'dynamic') { a.pos = a.pos.add(n.scale(ov)); const rv = a.vel.dot(n); if (rv < 0) a.vel = a.vel.sub(n.scale(rv * 1.5)) }
else if (b.type === 'dynamic') { b.pos = b.pos.sub(n.scale(ov)); const rv = b.vel.dot(n); if (rv > 0) b.vel = b.vel.sub(n.scale(rv * 1.5)) }
}
}
}
}
for (const k of contacts) if (!this.prevContacts.has(k)) { const [a, b] = k.split(':').map(Number); this.events.push({ bodyA: a, bodyB: b, started: true }) }
for (const k of this.prevContacts) if (!contacts.has(k)) { const [a, b] = k.split(':').map(Number); this.events.push({ bodyA: a, bodyB: b, started: false }) }
this.prevContacts = contacts;
for (const j of this.joints) {
if (j.removed) continue; const a = this.bodies[j.bodyA], b = this.bodies[j.bodyB];
const diff = b.pos.sub(a.pos), dist = diff.len(), norm = dist > 0 ? diff.scale(1 / dist) : new Vec2(1, 0);
switch (j.type) {
case 'spring': { const rest = j.params.restLength || 60, k = j.params.stiffness || 0.02, f = (dist - rest) * k; if (a.type === 'dynamic') a.vel = a.vel.add(norm.scale(f)); if (b.type === 'dynamic') b.vel = b.vel.sub(norm.scale(f)); break }
case 'fixed': { if (dist > 0.1) { const c = diff.sub(norm.scale(j.params.restLength || 0)); if (a.type === 'dynamic') a.pos = a.pos.add(c.scale(0.5)); if (b.type === 'dynamic') b.pos = b.pos.sub(c.scale(0.5)) } break }
case 'revolute': { const anchor = j.params.anchor || a.pos, arm = j.params.armLength || 50; const toB = b.pos.sub(anchor), d2 = toB.len(); if (d2 > 0) b.pos = anchor.add(toB.scale(arm / d2)); if (j.params.motorVel) { j.motorAngle += j.params.motorVel * dt; b.pos = new Vec2(anchor.x + Math.cos(j.motorAngle) * arm, anchor.y + Math.sin(j.motorAngle) * arm) } break }
case 'prismatic': { const ax = j.params.axis || new Vec2(1, 0), o = a.pos, toB2 = b.pos.sub(o), proj = toB2.dot(ax); b.pos = o.add(ax.scale(proj)); if (j.params.min !== undefined && proj < j.params.min) b.pos = o.add(ax.scale(j.params.min)); if (j.params.max !== undefined && proj > j.params.max) b.pos = o.add(ax.scale(j.params.max)); if (j.params.motorVel && b.type === 'dynamic') b.vel = b.vel.add(ax.scale(j.params.motorVel * 0.5)); break }
case 'rope': { const mxD = j.params.maxDistance || 100; if (dist > mxD) { const c = norm.scale(dist - mxD); if (b.type === 'dynamic') { b.pos = b.pos.sub(c); b.vel = b.vel.sub(norm.scale(b.vel.dot(norm) * 0.8)) } } break }
}
}
}
}
// ─── Scenes ───
let currentScene = 0, world, frameSeq = 0, totalBytes = 0, totalFrames = 0;
const scenes = [
{
name: 'Joint Family', legend: [{ color: '#f59e0b', label: 'Spring' }, { color: '#ef4444', label: 'Fixed' }, { color: '#8b5cf6', label: 'Revolute' }, { color: '#06b6d4', label: 'Prismatic' }, { color: '#10b981', label: 'Rope' }],
setup(w) {
w.gravity = new Vec2(0, 80);
const sa = w.createCircle(60, 80, 8, 'kinematic'); w.bodies[sa].color = '#334155';
const sb = w.createCircle(60, 160, 12); w.bodies[sb].color = '#f59e0b';
w.createJoint(sa, sb, 'spring', { restLength: 60, stiffness: 0.015 }); w.joints[w.joints.length - 1].color = '#f59e0b';
const fa = w.createCircle(160, 100, 8, 'kinematic'); w.bodies[fa].color = '#334155';
const fb = w.createCircle(190, 130, 12); w.bodies[fb].color = '#ef4444';
w.createJoint(fa, fb, 'fixed', { restLength: 42 }); w.joints[w.joints.length - 1].color = '#ef4444';
const ra = w.createCircle(280, 120, 8, 'kinematic'); w.bodies[ra].color = '#334155';
const rb = w.createCircle(330, 120, 12); w.bodies[rb].color = '#8b5cf6';
w.createJoint(ra, rb, 'revolute', { anchor: w.bodies[ra].pos, armLength: 50, motorVel: 2.5 }); w.joints[w.joints.length - 1].color = '#8b5cf6';
const pa = w.createCircle(80, 280, 8, 'kinematic'); w.bodies[pa].color = '#334155';
const pb = w.createCircle(160, 280, 14); w.bodies[pb].color = '#06b6d4';
w.createJoint(pa, pb, 'prismatic', { axis: new Vec2(1, 0), min: 20, max: 140, motorVel: 80 }); w.joints[w.joints.length - 1].color = '#06b6d4';
const rpa = w.createCircle(350, 60, 8, 'kinematic'); w.bodies[rpa].color = '#334155';
const rpb = w.createCircle(380, 180, 14); w.bodies[rpb].color = '#10b981';
w.createJoint(rpa, rpb, 'rope', { maxDistance: 130 }); w.joints[w.joints.length - 1].color = '#10b981';
}
},
{
name: 'Sensors', legend: [{ color: '#10b981', label: 'Sensor' }, { color: '#4f8fff', label: 'Dynamic' }, { color: '#f59e0b', label: 'Detected' }],
setup(w) {
w.gravity = new Vec2(0, 60);
w.createSensor(120, 200, 60); w.bodies[w.bodies.length - 1].label = 'SENSOR A';
w.createSensor(360, 200, 60); w.bodies[w.bodies.length - 1].label = 'SENSOR B';
for (let i = 0; i < 8; i++) { const x = 60 + Math.random() * 360; const idx = w.createCircle(x, -20 - i * 40, 8 + Math.random() * 8); w.bodies[idx].color = '#4f8fff'; w.bodies[idx].vel = new Vec2((Math.random() - 0.5) * 60, Math.random() * 30) }
}
},
{
name: 'Sleeping', legend: [{ color: '#4f8fff', label: 'Awake' }, { color: '#334155', label: 'Sleeping' }, { color: '#f59e0b', label: 'Waking' }],
setup(w) {
w.gravity = new Vec2(0, 100);
for (let r = 0; r < 4; r++)for (let c = 0; c < 5; c++) { const idx = w.createCircle(140 + c * 50, 300 - r * 35, 14); w.bodies[idx].sleeping = true; w.bodies[idx].color = '#334155'; w.bodies[idx].label = '💤' }
const wk = w.createCircle(240, 50, 16); w.bodies[wk].color = '#f59e0b'; w.bodies[wk].label = '⚡'; w.bodies[wk].vel = new Vec2(0, 80);
}
},
{
name: 'Motors', legend: [{ color: '#8b5cf6', label: 'Revolute' }, { color: '#06b6d4', label: 'Prismatic' }],
setup(w) {
w.gravity = new Vec2(0, 0);
const speeds = [1.5, -2.5, 3.5, -1.0], colors = ['#8b5cf6', '#a855f7', '#c084fc', '#7c3aed'];
for (let i = 0; i < 4; i++) {
const cx = 80 + i * 100; const a = w.createCircle(cx, 120, 6, 'kinematic'); w.bodies[a].color = '#1e293b';
for (let j = 1; j <= 3; j++) { const arm = w.createCircle(cx + j * 20, 120, 5 + j * 2); w.bodies[arm].color = colors[i]; w.createJoint(a, arm, 'revolute', { anchor: w.bodies[a].pos, armLength: j * 20, motorVel: speeds[i] * (1 + j * 0.3) }); w.joints[w.joints.length - 1].color = colors[i] + '60' }
}
for (let i = 0; i < 3; i++) { const cy = 250 + i * 40; const a = w.createCircle(60, cy, 5, 'kinematic'); w.bodies[a].color = '#1e293b'; const b = w.createCircle(100, cy, 10); w.bodies[b].color = '#06b6d4'; w.createJoint(a, b, 'prismatic', { axis: new Vec2(1, 0), min: 20, max: 350, motorVel: 60 + i * 40 }); w.joints[w.joints.length - 1].color = '#06b6d480' }
}
},
{
name: 'Collisions', legend: [{ color: '#4f8fff', label: 'Body' }, { color: '#ef4444', label: 'Start' }, { color: '#10b981', label: 'End' }],
setup(w) {
w.gravity = new Vec2(0, 100);
for (let i = 0; i < 12; i++) { const idx = w.createCircle(60 + Math.random() * 360, 30 + Math.random() * 100, 10 + Math.random() * 12); w.bodies[idx].color = '#4f8fff'; w.bodies[idx].vel = new Vec2((Math.random() - 0.5) * 100, 0) }
}
}
];
function setScene(idx) {
currentScene = idx; world = new PhysicsWorld(480, 360); scenes[idx].setup(world); frameSeq = 0;
document.querySelectorAll('.btn').forEach((b, i) => b.classList.toggle('active', i === idx));
document.getElementById('legend').innerHTML = scenes[idx].legend.map(l => `<div class="legend-item"><span class="legend-dot" style="background:${l.color}"></span>${l.label}</div>`).join('');
}
// ─── WebSocket ───
let ws = null;
let wsConnected = false;
function connectWS() {
ws = new WebSocket('ws://localhost:9800/source');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
wsConnected = true;
document.getElementById('wsDot').className = 'status-dot on';
document.getElementById('wsStatus').textContent = 'Connected';
document.getElementById('wsStatus').style.color = 'var(--green)';
};
ws.onclose = () => {
wsConnected = false;
document.getElementById('wsDot').className = 'status-dot off';
document.getElementById('wsStatus').textContent = 'Disconnected';
document.getElementById('wsStatus').style.color = 'var(--red)';
setTimeout(connectWS, 2000);
};
ws.onmessage = (evt) => {
// Handle interaction signals from receiver
const data = new Uint8Array(evt.data);
if (data[0] === SIGNAL_CLICK && data.length >= 20) {
const view = new DataView(evt.data);
const clickX = view.getFloat32(4, true);
const clickY = view.getFloat32(8, true);
const clickTs = view.getFloat64(12, true);
// Find nearest dynamic body and apply impulse
let nearest = -1, nearDist = Infinity;
for (let i = 0; i < world.bodies.length; i++) {
const b = world.bodies[i];
if (b.removed || b.type !== 'dynamic') continue;
const d = new Vec2(clickX - b.pos.x, clickY - b.pos.y).len();
if (d < nearDist) { nearDist = d; nearest = i; }
}
if (nearest >= 0 && nearDist < 100) {
const b = world.bodies[nearest];
const dir = new Vec2(b.pos.x - clickX, b.pos.y - clickY).norm();
b.vel = b.vel.add(dir.scale(200));
// Wake sleeping bodies
if (b.sleeping) {
b.sleeping = false; b.color = '#f59e0b'; b.label = '⚡';
setTimeout(() => { b.color = '#4f8fff'; b.label = ''; }, 500);
}
}
// Flash marker at click position
clickFlashes.push({ x: clickX, y: clickY, t: performance.now(), radius: 20 });
// Send pong with original timestamp for RTT
const pong = new ArrayBuffer(20);
const pv = new DataView(pong);
new Uint8Array(pong)[0] = SIGNAL_PONG;
pv.setFloat32(4, clickX, true);
pv.setFloat32(8, clickY, true);
pv.setFloat64(12, clickTs, true);
if (wsConnected && ws.readyState === 1) ws.send(pong);
}
};
ws.onerror = () => ws.close();
}
connectWS();
// ─── Render ───
const can = document.getElementById('source');
const ctx = can.getContext('2d');
function render() {
ctx.fillStyle = '#08080f'; ctx.fillRect(0, 0, 480, 360);
ctx.strokeStyle = '#ffffff08'; ctx.lineWidth = 1;
for (let x = 0; x < 480; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 360); ctx.stroke() }
for (let y = 0; y < 360; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(480, y); ctx.stroke() }
for (const j of world.joints) {
if (j.removed) continue; const a = world.bodies[j.bodyA], b = world.bodies[j.bodyB];
ctx.strokeStyle = j.color; ctx.lineWidth = j.type === 'rope' ? 1 : 2;
if (j.type === 'spring') {
const dx = b.pos.x - a.pos.x, dy = b.pos.y - a.pos.y, len = Math.sqrt(dx * dx + dy * dy), nx = -dy / len, ny = dx / len;
ctx.beginPath(); ctx.moveTo(a.pos.x, a.pos.y);
for (let i = 1; i < 8; i++) { const t = i / 8, s = (i % 2 === 0 ? 1 : -1) * 6; ctx.lineTo(a.pos.x + dx * t + nx * s, a.pos.y + dy * t + ny * s) }
ctx.lineTo(b.pos.x, b.pos.y); ctx.stroke();
} else if (j.type === 'rope') { ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(a.pos.x, a.pos.y); ctx.lineTo(b.pos.x, b.pos.y); ctx.stroke(); ctx.setLineDash([]) }
else { ctx.beginPath(); ctx.moveTo(a.pos.x, a.pos.y); ctx.lineTo(b.pos.x, b.pos.y); ctx.stroke() }
}
for (let i = 0; i < world.bodies.length; i++) {
const b = world.bodies[i]; if (b.removed) continue;
if (b.type === 'sensor') {
ctx.save(); ctx.globalAlpha = 0.15 + Math.sin(Date.now() * 0.003) * 0.05; ctx.fillStyle = b.glowColor || '#10b981';
ctx.beginPath(); ctx.arc(b.pos.x, b.pos.y, b.radius, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 0.6; ctx.strokeStyle = b.glowColor || '#10b981'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]); ctx.restore();
let hasOv = false;
for (let j = 0; j < world.bodies.length; j++) { if (i === j || world.bodies[j].type === 'sensor') continue; if (b.pos.sub(world.bodies[j].pos).len() < b.radius + world.bodies[j].radius) { hasOv = true; world.bodies[j].glowColor = '#f59e0b' } }
if (hasOv) { ctx.save(); ctx.globalAlpha = 0.4; ctx.fillStyle = '#f59e0b'; ctx.beginPath(); ctx.arc(b.pos.x, b.pos.y, b.radius, 0, Math.PI * 2); ctx.fill(); ctx.restore() }
if (b.label) { ctx.fillStyle = '#10b981'; ctx.font = '500 9px Inter'; ctx.textAlign = 'center'; ctx.fillText(b.label, b.pos.x, b.pos.y + b.radius + 14) }
continue;
}
ctx.save(); ctx.shadowColor = b.glowColor || b.color; ctx.shadowBlur = b.sleeping ? 0 : 8; ctx.fillStyle = b.color; ctx.globalAlpha = b.sleeping ? 0.4 : 1;
ctx.beginPath(); ctx.arc(b.pos.x, b.pos.y, b.radius, 0, Math.PI * 2); ctx.fill(); ctx.restore();
if (b.label) { ctx.fillStyle = '#fff'; ctx.font = '10px Inter'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, b.pos.x, b.pos.y) }
for (const evt of world.events) { if (evt.bodyA === i || evt.bodyB === i) { ctx.save(); ctx.globalAlpha = 0.6; ctx.strokeStyle = evt.started ? '#ef4444' : '#10b981'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(b.pos.x, b.pos.y, b.radius + 6, 0, Math.PI * 2); ctx.stroke(); ctx.restore() } }
}
ctx.fillStyle = '#ffffff30'; ctx.font = '500 10px Inter'; ctx.textAlign = 'left'; ctx.fillText(scenes[currentScene].name.toUpperCase(), 10, 350);
// Draw click flash markers
const now = performance.now();
for (let i = clickFlashes.length - 1; i >= 0; i--) {
const f = clickFlashes[i];
const age = (now - f.t) / 1000;
if (age > 0.5) { clickFlashes.splice(i, 1); continue; }
const alpha = 1 - age * 2;
const r = f.radius + age * 40;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#ff6b6b';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(f.x, f.y, r, 0, Math.PI * 2); ctx.stroke();
// Crosshair
ctx.beginPath(); ctx.moveTo(f.x - 8, f.y); ctx.lineTo(f.x + 8, f.y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(f.x, f.y - 8); ctx.lineTo(f.x, f.y + 8); ctx.stroke();
ctx.restore();
}
}
function streamFrame() {
// Drop frame if previous one hasn't flushed yet (no buffering)
if (wsConnected && ws.bufferedAmount > 0) return;
const sw = 480, sh = 360;
const imgData = ctx.getImageData(0, 0, sw, sh);
const pixels = new Uint8Array(imgData.data.buffer);
const ts = Math.floor(performance.now()) & 0xFFFFFFFF;
const frame = encodeFrame(frameSeq++, ts, sw, sh, pixels);
totalBytes += frame.length; totalFrames++;
if (wsConnected && ws.readyState === 1) {
ws.send(frame);
}
// Stats
const rawSize = sw * sh * 4;
const ratio = ((1 - frame.length / (rawSize + HEADER_SIZE)) * 100).toFixed(0);
const typeStr = frame[0] === FRAME_KEYFRAME ? 'KEY' : 'Δ';
const hdr = Array.from(frame.slice(0, HEADER_SIZE)).map(b => b.toString(16).padStart(2, '0')).join(' ');
document.getElementById('frameInfo').textContent = `${typeStr} seq=${(frameSeq - 1) & 0xFFFF} ${sw}×${sh}`;
document.getElementById('frameHex').textContent = hdr;
document.getElementById('streamStats').textContent = `${totalFrames} frames · ${fmtB(totalBytes)} · ${fmtB(frame.length)}/f (-${ratio}%)`;
}
function fmtB(b) { if (b < 1024) return b + 'B'; if (b < 1048576) return (b / 1024).toFixed(1) + 'KB'; return (b / 1048576).toFixed(1) + 'MB' }
// Sleeping wake logic
function tickSleeping() { if (currentScene !== 2) return; for (let i = 0; i < world.bodies.length; i++) { const b = world.bodies[i]; if (!b.sleeping) continue; for (let j = 0; j < world.bodies.length; j++) { if (i === j) continue; const o = world.bodies[j]; if (!o.sleeping && o.type === 'dynamic' && b.pos.sub(o.pos).len() < b.radius + o.radius + 10) { b.sleeping = false; b.color = '#f59e0b'; b.label = '⚡'; b.vel = new Vec2((Math.random() - 0.5) * 80, -40); setTimeout(() => { b.color = '#4f8fff'; b.label = '' }, 500) } } } }
let lastT = 0, fpsCnt = 0, fpsT = 0, dFps = 0;
function loop(t) {
const dt = Math.min((t - lastT) / 1000, 1 / 30); lastT = t;
fpsCnt++; fpsT += dt; if (fpsT >= 1) { dFps = fpsCnt; fpsCnt = 0; fpsT = 0 }
tickSleeping(); world.step(dt); render(); streamFrame();
document.getElementById('fps').textContent = dFps;
document.getElementById('bodyCount').textContent = world.bodies.filter(b => !b.removed).length;
document.getElementById('jointCount').textContent = world.joints.filter(j => !j.removed).length;
document.getElementById('eventCount').textContent = world.events.length;
requestAnimationFrame(loop);
}
setScene(0); requestAnimationFrame(loop);
</script>
</body>
</html>