275 lines
8.0 KiB
JavaScript
275 lines
8.0 KiB
JavaScript
(() => {
|
|
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);
|
|
})();
|