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
This commit is contained in:
parent
b0440e2e47
commit
9cc395d2a7
6 changed files with 1418 additions and 0 deletions
1
engine/demo/.gitignore
vendored
Normal file
1
engine/demo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
760
engine/demo/index.html
Normal file
760
engine/demo/index.html
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
<!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>
|
||||
37
engine/demo/package-lock.json
generated
Normal file
37
engine/demo/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
engine/demo/package.json
Normal file
15
engine/demo/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"main": "relay.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
536
engine/demo/receiver.html
Normal file
536
engine/demo/receiver.html
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
<!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 Receiver</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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem 1rem;
|
||||
position: relative;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 10%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--purple), var(--blue), transparent)
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--purple), var(--blue));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent
|
||||
}
|
||||
|
||||
.header .sub {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .7rem;
|
||||
color: var(--purple);
|
||||
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)
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
font-size: .65rem;
|
||||
color: var(--dim);
|
||||
padding: .3rem;
|
||||
font-style: italic
|
||||
}
|
||||
|
||||
.hint em {
|
||||
color: var(--orange);
|
||||
font-style: normal
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
align-items: flex-start
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
box-shadow: 0 0 20px rgba(139, 92, 246, 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(--purple)
|
||||
}
|
||||
|
||||
.panel-label .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--purple);
|
||||
animation: pulse 2s infinite
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: .3
|
||||
}
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
image-rendering: auto;
|
||||
background: #08080f;
|
||||
cursor: crosshair
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .55rem;
|
||||
color: var(--dim);
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border)
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
gap: .3rem
|
||||
}
|
||||
|
||||
.stat-v {
|
||||
color: var(--green)
|
||||
}
|
||||
|
||||
.stat-v.rtt {
|
||||
color: var(--orange)
|
||||
}
|
||||
|
||||
.stat-v.compress {
|
||||
color: var(--cyan)
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
min-width: 200px;
|
||||
max-width: 220px;
|
||||
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
|
||||
}
|
||||
|
||||
.info-card.rtt-card {
|
||||
border-color: var(--orange)
|
||||
}
|
||||
|
||||
.info-card.rtt-card .title {
|
||||
color: var(--orange)
|
||||
}
|
||||
|
||||
.info-card.compress-card {
|
||||
border-color: var(--cyan)
|
||||
}
|
||||
|
||||
.info-card.compress-card .title {
|
||||
color: var(--cyan)
|
||||
}
|
||||
|
||||
.rtt-big {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--orange);
|
||||
text-align: center;
|
||||
padding: .3rem 0
|
||||
}
|
||||
|
||||
.compress-big {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--cyan);
|
||||
text-align: center;
|
||||
padding: .3rem 0
|
||||
}
|
||||
|
||||
.rtt-label {
|
||||
font-size: .5rem;
|
||||
color: var(--dim);
|
||||
text-align: center
|
||||
}
|
||||
|
||||
.waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 360px;
|
||||
gap: 1rem;
|
||||
color: var(--dim)
|
||||
}
|
||||
|
||||
.waiting .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--purple);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>DreamStack — Receiver</h1>
|
||||
<div class="sub">ds-stream delta receiver · interactive</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
|
||||
style="color:var(--orange)">ws://localhost:9800/stream</span></div>
|
||||
</div>
|
||||
<div class="hint">👆 <em>Click on the stream</em> to interact — forces sent via relay, round-trip latency measured
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="panel">
|
||||
<div class="panel-label"><span class="dot"></span> Pixel Stream · Click to Interact</div>
|
||||
<div id="waitingMsg" class="waiting">
|
||||
<div class="spinner"></div>
|
||||
<div>Waiting for source stream...</div>
|
||||
</div>
|
||||
<canvas id="receiver" width="480" height="360" style="display:none"></canvas>
|
||||
<div class="stats">
|
||||
<div class="stat">Frames <span class="stat-v" id="frames">0</span></div>
|
||||
<div class="stat">Bytes <span class="stat-v" id="bytes">0B</span></div>
|
||||
<div class="stat">FPS <span class="stat-v" id="fps">0</span></div>
|
||||
<div class="stat">Decode <span class="stat-v" id="latency">—</span></div>
|
||||
<div class="stat">RTT <span class="stat-v rtt" id="rttStat">—</span></div>
|
||||
<div class="stat">Saved <span class="stat-v compress" id="savedStat">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-panel">
|
||||
<div class="info-card rtt-card">
|
||||
<div class="title">⏱ Round-Trip Latency</div>
|
||||
<div class="rtt-big" id="rttBig">—</div>
|
||||
<div class="rtt-label">click → source → render → back</div>
|
||||
<div class="val" style="margin-top:.3rem;font-size:.5rem" id="rttHistory">Click to measure</div>
|
||||
</div>
|
||||
<div class="info-card compress-card">
|
||||
<div class="title">📦 Compression</div>
|
||||
<div class="compress-big" id="compressBig">—</div>
|
||||
<div class="rtt-label">delta + RLE vs raw RGBA</div>
|
||||
<div class="val" style="margin-top:.3rem;font-size:.5rem" id="compressDetail">—</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="title">Frame Type</div>
|
||||
<div class="val" id="frameType">—</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="title">Interactions</div>
|
||||
<div class="val" id="clickCount">0 clicks sent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const HEADER_SIZE = 16;
|
||||
const FRAME_KEYFRAME = 0x11;
|
||||
const FRAME_DELTA = 0x12;
|
||||
const SIGNAL_CLICK = 0x30;
|
||||
const SIGNAL_PONG = 0x31;
|
||||
|
||||
const canvas = document.getElementById('receiver');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let ws = null;
|
||||
let totalFrames = 0, totalBytes = 0, clicksSent = 0;
|
||||
let fpsCnt = 0, displayFps = 0, lastFpsTime = performance.now();
|
||||
const rttHistory = [];
|
||||
|
||||
// Frame buffer for delta decoding
|
||||
let frameBuffer = null;
|
||||
const RAW_SIZE = 480 * 360 * 4; // 691200 bytes
|
||||
|
||||
function decodeHeader(buffer) {
|
||||
const frame = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
return {
|
||||
type: frame[0],
|
||||
seq: view.getUint16(2, true),
|
||||
width: view.getUint16(8, true),
|
||||
height: view.getUint16(10, true),
|
||||
payloadLength: view.getUint32(12, true),
|
||||
payload: new Uint8Array(buffer, HEADER_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
// Decode RLE delta and apply XOR to frame buffer
|
||||
function applyDeltaRLE(payload, buffer, totalBytes) {
|
||||
let pi = 0; // payload index
|
||||
let bi = 0; // buffer index
|
||||
while (pi < payload.length && bi < totalBytes) {
|
||||
const runLen = (payload[pi] << 8) | payload[pi + 1];
|
||||
const dr = payload[pi + 2];
|
||||
const dg = payload[pi + 3];
|
||||
const db = payload[pi + 4];
|
||||
const da = payload[pi + 5];
|
||||
pi += 6;
|
||||
|
||||
for (let r = 0; r < runLen && bi < totalBytes; r++) {
|
||||
buffer[bi] ^= dr;
|
||||
buffer[bi + 1] ^= dg;
|
||||
buffer[bi + 2] ^= db;
|
||||
buffer[bi + 3] ^= da;
|
||||
bi += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmtB(b) { if (b < 1024) return b + 'B'; if (b < 1048576) return (b / 1024).toFixed(1) + 'KB'; return (b / 1048576).toFixed(1) + 'MB'; }
|
||||
|
||||
function renderBuffer() {
|
||||
const imgData = ctx.createImageData(480, 360);
|
||||
imgData.data.set(frameBuffer);
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
// ─── Click → Signal ───
|
||||
canvas.addEventListener('click', (e) => {
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickX = (e.clientX - rect.left) * (480 / rect.width);
|
||||
const clickY = (e.clientY - rect.top) * (360 / rect.height);
|
||||
|
||||
const signal = new ArrayBuffer(20);
|
||||
const sv = new DataView(signal);
|
||||
new Uint8Array(signal)[0] = SIGNAL_CLICK;
|
||||
sv.setFloat32(4, clickX, true);
|
||||
sv.setFloat32(8, clickY, true);
|
||||
sv.setFloat64(12, performance.now(), true);
|
||||
ws.send(signal);
|
||||
clicksSent++;
|
||||
document.getElementById('clickCount').textContent = `${clicksSent} clicks sent`;
|
||||
});
|
||||
|
||||
// ─── WebSocket ───
|
||||
function connectWS() {
|
||||
ws = new WebSocket('ws://localhost:9800/stream');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('wsDot').className = 'status-dot on';
|
||||
document.getElementById('wsStatus').textContent = 'Connected';
|
||||
document.getElementById('wsStatus').style.color = 'var(--green)';
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
const data = new Uint8Array(evt.data);
|
||||
|
||||
// Handle pong (RTT response)
|
||||
if (data[0] === SIGNAL_PONG && evt.data.byteLength >= 20) {
|
||||
const view = new DataView(evt.data);
|
||||
const originalTs = view.getFloat64(12, true);
|
||||
const rtt = performance.now() - originalTs;
|
||||
rttHistory.push(rtt);
|
||||
if (rttHistory.length > 20) rttHistory.shift();
|
||||
const avg = rttHistory.reduce((a, b) => a + b, 0) / rttHistory.length;
|
||||
document.getElementById('rttBig').textContent = rtt.toFixed(1) + 'ms';
|
||||
document.getElementById('rttStat').textContent = rtt.toFixed(1) + 'ms';
|
||||
document.getElementById('rttHistory').textContent =
|
||||
`avg: ${avg.toFixed(1)}ms · min: ${Math.min(...rttHistory).toFixed(1)}ms · max: ${Math.max(...rttHistory).toFixed(1)}ms (n=${rttHistory.length})`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pixel frames
|
||||
if (data[0] !== FRAME_KEYFRAME && data[0] !== FRAME_DELTA) return;
|
||||
|
||||
const t0 = performance.now();
|
||||
const hdr = decodeHeader(evt.data);
|
||||
const isKey = hdr.type === FRAME_KEYFRAME;
|
||||
|
||||
if (isKey) {
|
||||
// Keyframe: replace entire buffer
|
||||
frameBuffer = new Uint8Array(RAW_SIZE);
|
||||
frameBuffer.set(hdr.payload.subarray(0, RAW_SIZE));
|
||||
} else {
|
||||
// Delta: apply RLE-decoded XOR to existing buffer
|
||||
if (!frameBuffer) return; // can't apply delta without a keyframe first
|
||||
applyDeltaRLE(hdr.payload, frameBuffer, RAW_SIZE);
|
||||
}
|
||||
|
||||
document.getElementById('waitingMsg').style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
renderBuffer();
|
||||
|
||||
totalFrames++;
|
||||
totalBytes += evt.data.byteLength;
|
||||
fpsCnt++;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
displayFps = fpsCnt;
|
||||
fpsCnt = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
|
||||
const decodeMs = (now - t0).toFixed(1);
|
||||
const wireSize = evt.data.byteLength;
|
||||
const saved = ((1 - wireSize / (RAW_SIZE + HEADER_SIZE)) * 100).toFixed(0);
|
||||
|
||||
document.getElementById('frames').textContent = totalFrames;
|
||||
document.getElementById('bytes').textContent = fmtB(totalBytes);
|
||||
document.getElementById('fps').textContent = displayFps;
|
||||
document.getElementById('latency').textContent = decodeMs + 'ms';
|
||||
document.getElementById('savedStat').textContent = saved + '%';
|
||||
|
||||
document.getElementById('frameType').textContent = isKey
|
||||
? `KEY (0x11) · ${fmtB(wireSize)} raw`
|
||||
: `Δ (0x12) · ${fmtB(wireSize)} compressed`;
|
||||
document.getElementById('compressBig').textContent = isKey ? '0%' : saved + '%';
|
||||
document.getElementById('compressDetail').textContent =
|
||||
`${fmtB(wireSize)} wire vs ${fmtB(RAW_SIZE)} raw`;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('wsDot').className = 'status-dot off';
|
||||
document.getElementById('wsStatus').textContent = 'Disconnected';
|
||||
document.getElementById('wsStatus').style.color = 'var(--red)';
|
||||
setTimeout(connectWS, 2000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWS();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
69
engine/demo/relay.js
Normal file
69
engine/demo/relay.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// DreamStack ds-stream Pixel Relay Server (bidirectional)
|
||||
// Source → Receiver: pixel frames
|
||||
// Receiver → Source: interaction signals (clicks, drags)
|
||||
const { WebSocketServer } = require('ws');
|
||||
const http = require('http');
|
||||
|
||||
const PORT = 9800;
|
||||
const sources = new Set();
|
||||
const receivers = new Set();
|
||||
let frameCount = 0;
|
||||
let byteCount = 0;
|
||||
let signalCount = 0;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(`ds-stream relay | sources: ${sources.size} | receivers: ${receivers.size} | frames: ${frameCount} | signals: ${signalCount} | bytes: ${byteCount}`);
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const path = req.url || '/';
|
||||
|
||||
if (path.startsWith('/source')) {
|
||||
sources.add(ws);
|
||||
console.log(`[relay] source connected (${sources.size} total)`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
frameCount++;
|
||||
byteCount += data.length;
|
||||
for (const recv of receivers) {
|
||||
if (recv.readyState === 1) recv.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
sources.delete(ws);
|
||||
console.log(`[relay] source disconnected (${sources.size} remaining)`);
|
||||
});
|
||||
} else {
|
||||
receivers.add(ws);
|
||||
console.log(`[relay] receiver connected (${receivers.size} total)`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
// Forward interaction signals from receiver → source
|
||||
signalCount++;
|
||||
for (const src of sources) {
|
||||
if (src.readyState === 1) src.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
receivers.delete(ws);
|
||||
console.log(`[relay] receiver disconnected (${receivers.size} remaining)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n ╔═══════════════════════════════════════╗`);
|
||||
console.log(` ║ ds-stream relay on :${PORT} ║`);
|
||||
console.log(` ║ Source: ws://localhost:${PORT}/source ║`);
|
||||
console.log(` ║ Receiver: ws://localhost:${PORT}/stream ║`);
|
||||
console.log(` ║ Mode: bidirectional (pixels + signals) ║`);
|
||||
console.log(` ╚═══════════════════════════════════════╝\n`);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue