dreamstack/engine/demo/index.html

760 lines
36 KiB
HTML
Raw Normal View History

<!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>