760 lines
36 KiB
HTML
760 lines
36 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>DreamStack — 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>
|