feat: add snek game pixel stream example
This commit is contained in:
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
test/pixelstream/snek/package.json
Normal file
17
test/pixelstream/snek/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "snek",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
274
test/pixelstream/snek/public/app.js
Normal file
274
test/pixelstream/snek/public/app.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(() => {
|
||||
const GRID_SIZE = 16;
|
||||
const TICK_MS = 120; // game speed
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
// NeoPixel matrix transmission config
|
||||
// Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine.
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR
|
||||
const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar
|
||||
const SERPENTINE = true; // true if every other row is reversed
|
||||
const FLIP_X = true; // set true if physical matrix is mirrored horizontally
|
||||
const FLIP_Y = false; // set true if physical matrix is flipped vertically
|
||||
|
||||
/** Game state */
|
||||
let snakeSegments = [];
|
||||
let currentDirection = { x: 1, y: 0 };
|
||||
let pendingDirection = { x: 1, y: 0 };
|
||||
let appleCell = null;
|
||||
let score = 0;
|
||||
let isGameOver = false;
|
||||
let sendingFrame = false;
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => setWsStatus('open'));
|
||||
ws.addEventListener('close', () => setWsStatus('closed'));
|
||||
ws.addEventListener('error', () => setWsStatus('error'));
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
// value is 0..255, BRIGHTNESS is 0..1
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
// Apply optional flips to align with physical orientation
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
|
||||
// Serpentine wiring reverses every other row (commonly odd rows)
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
// target is a Uint8Array, baseIndex is pixelIndex * 3
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
// Fallback to GRB if misconfigured
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
// Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data; // RGBA
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
// try to reconnect after a delay
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ];
|
||||
currentDirection = { x: 1, y: 0 };
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
score = 0;
|
||||
scoreEl.textContent = String(score);
|
||||
isGameOver = false;
|
||||
placeApple();
|
||||
}
|
||||
|
||||
function placeApple() {
|
||||
const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`));
|
||||
let x, y;
|
||||
do {
|
||||
x = Math.floor(Math.random() * GRID_SIZE);
|
||||
y = Math.floor(Math.random() * GRID_SIZE);
|
||||
} while (occupied.has(`${x},${y}`));
|
||||
appleCell = { x, y };
|
||||
}
|
||||
|
||||
function stepGame() {
|
||||
if (isGameOver) return;
|
||||
|
||||
// Commit pending direction (prevents double-turning in one tick)
|
||||
if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) {
|
||||
currentDirection = pendingDirection;
|
||||
}
|
||||
|
||||
const head = snakeSegments[0];
|
||||
const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y };
|
||||
|
||||
// Wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Self collision
|
||||
for (let i = 0; i < snakeSegments.length; i++) {
|
||||
const seg = snakeSegments[i];
|
||||
if (seg.x === newHead.x && seg.y === newHead.y) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move snake
|
||||
snakeSegments.unshift(newHead);
|
||||
const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y);
|
||||
if (ateApple) {
|
||||
score += 1;
|
||||
scoreEl.textContent = String(score);
|
||||
placeApple();
|
||||
} else {
|
||||
snakeSegments.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function renderGame() {
|
||||
// Background
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apple
|
||||
ctx.fillStyle = '#d23';
|
||||
ctx.fillRect(appleCell.x, appleCell.y, 1, 1);
|
||||
|
||||
// Snake
|
||||
ctx.fillStyle = '#3bd16f';
|
||||
for (const cell of snakeSegments) {
|
||||
ctx.fillRect(cell.x, cell.y, 1, 1);
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
// Simple overlay pixel art for game over (draw a cross)
|
||||
ctx.fillStyle = '#f33';
|
||||
for (let i = 0; i < GRID_SIZE; i++) {
|
||||
ctx.fillRect(i, i, 1, 1);
|
||||
ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (sendingFrame) return;
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
// Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
stepGame();
|
||||
renderGame();
|
||||
sendFrame();
|
||||
}
|
||||
|
||||
// Controls
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' && currentDirection.y !== 1) {
|
||||
pendingDirection = { x: 0, y: -1 };
|
||||
} else if (e.key === 'ArrowDown' && currentDirection.y !== -1) {
|
||||
pendingDirection = { x: 0, y: 1 };
|
||||
} else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) {
|
||||
pendingDirection = { x: -1, y: 0 };
|
||||
} else if (e.key === 'ArrowRight' && currentDirection.x !== -1) {
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
setInterval(gameLoop, TICK_MS);
|
||||
})();
|
||||
59
test/pixelstream/snek/public/index.html
Normal file
59
test/pixelstream/snek/public/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Snek 16x16</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px; /* make it big */
|
||||
height: 640px; /* make it big */
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Snek</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move. Space to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
test/pixelstream/snek/server.js
Normal file
83
test/pixelstream/snek/server.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
// UDP settings
|
||||
// Default broadcast; override with unicast address via UDP_ADDR if you have a single receiver
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '10.0.1.144';
|
||||
|
||||
// NeoPixel frame properties for basic validation/logging (no transformation here)
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3; // GRB without alpha
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
Reference in New Issue
Block a user