feat(demos): sync protocol with Rust codec, add touch/gamepad support

- RLE format: 2-byte LE count (matches Rust rle_encode/rle_decode)
- Delta frames: FLAG_COMPRESSED flag set correctly
- Signal sync: FLAG_KEYFRAME flag + periodic sync every ~5s for late-join
- Source: handle touch, gamepad axis/button, resize inputs from receiver
- Receiver: touch event capture for mobile, ping frame handling
- Protocol constants: added FRAME_PING, INPUT_TOUCH, INPUT_GAMEPAD_*
This commit is contained in:
enzotar 2026-02-25 11:06:42 -08:00
parent 69f39746af
commit 968d62d0bb
2 changed files with 87 additions and 14 deletions

View file

@ -361,9 +361,12 @@
const HEADER_SIZE = 16;
const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10;
const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40;
const FRAME_PING = 0xFE;
const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11, INPUT_SCROLL = 0x50;
const FLAG_INPUT = 0x01;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11;
const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21;
const INPUT_SCROLL = 0x50;
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
const canvas = document.getElementById('display');
const ctx = canvas.getContext('2d');
@ -434,14 +437,15 @@
}
// ── Feature 1: Delta decompression ──
// RLE decompression matching Rust codec: 0x00 + 2-byte LE count
function rleDecompress(compressed, expectedLen) {
const out = new Uint8Array(expectedLen);
let ci = 0, oi = 0;
while (ci < compressed.length && oi < expectedLen) {
if (compressed[ci] === 0 && ci + 1 < compressed.length) {
const run = compressed[ci + 1];
if (compressed[ci] === 0 && ci + 2 < compressed.length) {
const run = compressed[ci + 1] | (compressed[ci + 2] << 8);
for (let j = 0; j < run && oi < expectedLen; j++) out[oi++] = 0;
ci += 2;
ci += 3;
} else {
out[oi++] = compressed[ci++];
}
@ -561,6 +565,10 @@
busBytes.audio += e.data.byteLength;
break;
}
case FRAME_PING:
// Keepalive — ignore
return;
}
framesRecv++;
@ -628,6 +636,37 @@
busBytes.scroll += HEADER_SIZE + 4;
}, { passive: false });
// Touch — for mobile receivers
function touchPayload(id, x, y, phase) {
const b = new ArrayBuffer(6), v = new DataView(b);
v.setUint8(0, id); v.setUint16(1, x, true); v.setUint16(3, y, true); v.setUint8(5, phase);
return b;
}
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
for (const t of e.changedTouches) {
const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top);
sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0)));
busBytes.pointer += HEADER_SIZE + 6;
}
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
for (const t of e.changedTouches) {
const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top);
sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0)));
busBytes.pointer += HEADER_SIZE + 6;
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
for (const t of e.changedTouches) {
sendInput(encodeInput(INPUT_TOUCH_END, touchPayload(t.identifier & 0xFF, 0, 0, 1)));
busBytes.pointer += HEADER_SIZE + 6;
}
});
// ── Stats ──
setInterval(() => {
const now = performance.now(), elapsed = (now - lastSecTime) / 1000;

View file

@ -371,9 +371,13 @@
const HEADER_SIZE = 16;
const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10;
const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40;
const FRAME_PING = 0xFE, FRAME_END = 0xFF;
const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11, INPUT_SCROLL = 0x50;
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11;
const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21;
const INPUT_GAMEPAD_AXIS = 0x30, INPUT_GAMEPAD_BUTTON = 0x31;
const INPUT_SCROLL = 0x50, INPUT_RESIZE = 0x60;
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
function encodeHeader(type, flags, seq, ts, w, h, len) {
const b = new ArrayBuffer(HEADER_SIZE), v = new DataView(b);
@ -624,6 +628,29 @@
ballR.value = Math.max(10, Math.min(80, ballR._signal._value + dy * 0.1));
}
break;
case INPUT_TOUCH:
if (payload.length >= 6) {
const tx = view.getUint16(1, true), ty = view.getUint16(3, true);
const phase = payload[5];
if (phase === 0) handlePointer(tx, ty, 'down');
else handlePointer(tx, ty, 'move');
}
break;
case INPUT_TOUCH_END:
handlePointer(0, 0, 'up');
break;
case INPUT_GAMEPAD_AXIS:
if (payload.length >= 3) {
const axis = payload[0], val = view.getInt16(1, true) / 32768;
if (axis === 0) { targetX.value = 300 + val * 250; ballX.value = targetX._value; }
if (axis === 1) { targetY.value = 200 + val * 150; ballY.value = targetY._value; }
}
break;
case INPUT_GAMEPAD_BUTTON:
if (payload.length >= 3 && payload[1] === 1) {
ballR.value = 70; setTimeout(() => ballR.value = 25, 250);
}
break;
}
}
@ -690,7 +717,7 @@
// Delta is worthwhile — compress by RLE-encoding the zero runs
const compressed = rleCompress(delta);
prevFrame = new Uint8Array(pixels);
const header = encodeHeader(FRAME_DELTA, 0, seq & 0xFFFF, ts, W, H, compressed.length);
const header = encodeHeader(FRAME_DELTA, FLAG_COMPRESSED, seq & 0xFFFF, ts, W, H, compressed.length);
const msg = new Uint8Array(HEADER_SIZE + compressed.length);
msg.set(header, 0); msg.set(compressed, HEADER_SIZE);
return msg;
@ -705,15 +732,20 @@
return msg;
}
// Simple RLE for delta frames: encode runs of zeros compactly
// RLE compression matching Rust codec: 0x00 + 2-byte LE count for zero runs
function rleCompress(data) {
const out = [];
let i = 0;
while (i < data.length) {
if (data[i] === 0) {
let run = 0;
while (i < data.length && data[i] === 0 && run < 255) { run++; i++; }
out.push(0, run); // 0x00 followed by run length
while (i < data.length && data[i] === 0) { run++; i++; }
// Emit run in chunks of max 65535
while (run > 0) {
const chunk = Math.min(run, 65535);
out.push(0, chunk & 0xFF, (chunk >> 8) & 0xFF);
run -= chunk;
}
} else {
out.push(data[i]); i++;
}
@ -737,11 +769,13 @@
const json = JSON.stringify(state);
const payload = new TextEncoder().encode(json);
// Send full sync or diff
const frameType = prevSignalState ? FRAME_SIGNAL_DIFF : FRAME_SIGNAL_SYNC;
// Send full sync (with FLAG_KEYFRAME) or diff
const isSync = !prevSignalState || (framesSent % 150 === 0); // sync every ~5s at 30fps
const frameType = isSync ? FRAME_SIGNAL_SYNC : FRAME_SIGNAL_DIFF;
const flags = isSync ? FLAG_KEYFRAME : 0;
prevSignalState = state;
const header = encodeHeader(frameType, 0, seq & 0xFFFF, ts, W, H, payload.length);
const header = encodeHeader(frameType, flags, seq & 0xFFFF, ts, W, H, payload.length);
const msg = new Uint8Array(HEADER_SIZE + payload.length);
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
return msg;