Files
spore/test/pixelstream/tetris/public/app.js
2025-10-03 21:41:05 +02:00

564 lines
14 KiB
JavaScript

(() => {
const GRID_WIDTH = 16;
const GRID_HEIGHT = 16;
const FRAME_INTERVAL_MS = 50;
const AUTO_DROP_MS = 600;
const SOFT_DROP_MS = 60;
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d', { alpha: false });
const scoreEl = document.getElementById('score');
const linesEl = document.getElementById('lines');
const wsDot = document.getElementById('ws-dot');
const wsLabel = document.getElementById('ws-label');
const MATRIX_WIDTH = canvas.width;
const MATRIX_HEIGHT = canvas.height;
const COLOR_ORDER = 'RGB';
const BRIGHTNESS = 1.0;
const SERPENTINE = true;
const FLIP_X = true;
const FLIP_Y = false;
const TETROMINOES = [
{
name: 'I',
color: '#2dd9ff',
rotations: [
[ [0, 1], [1, 1], [2, 1], [3, 1] ],
[ [2, 0], [2, 1], [2, 2], [2, 3] ],
[ [0, 2], [1, 2], [2, 2], [3, 2] ],
[ [1, 0], [1, 1], [1, 2], [1, 3] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'J',
color: '#3b7ddd',
rotations: [
[ [0, 0], [0, 1], [1, 1], [2, 1] ],
[ [1, 0], [2, 0], [1, 1], [1, 2] ],
[ [0, 1], [1, 1], [2, 1], [2, 2] ],
[ [1, 0], [1, 1], [0, 2], [1, 2] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'L',
color: '#f7b733',
rotations: [
[ [2, 0], [0, 1], [1, 1], [2, 1] ],
[ [1, 0], [1, 1], [1, 2], [2, 2] ],
[ [0, 1], [1, 1], [2, 1], [0, 2] ],
[ [0, 0], [1, 0], [1, 1], [1, 2] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'O',
color: '#ffe66d',
rotations: [
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'S',
color: '#30db6d',
rotations: [
[ [1, 0], [2, 0], [0, 1], [1, 1] ],
[ [1, 0], [1, 1], [2, 1], [2, 2] ],
[ [1, 1], [2, 1], [0, 2], [1, 2] ],
[ [0, 0], [0, 1], [1, 1], [1, 2] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'T',
color: '#c86bff',
rotations: [
[ [1, 0], [0, 1], [1, 1], [2, 1] ],
[ [1, 0], [1, 1], [2, 1], [1, 2] ],
[ [0, 1], [1, 1], [2, 1], [1, 2] ],
[ [1, 0], [0, 1], [1, 1], [1, 2] ],
],
spawnOffset: { x: 6, y: -1 },
},
{
name: 'Z',
color: '#ff5f5f',
rotations: [
[ [0, 0], [1, 0], [1, 1], [2, 1] ],
[ [2, 0], [1, 1], [2, 1], [1, 2] ],
[ [0, 1], [1, 1], [1, 2], [2, 2] ],
[ [1, 0], [0, 1], [1, 1], [0, 2] ],
],
spawnOffset: { x: 6, y: -1 },
},
];
const LINE_SCORE_TABLE = {
1: 100,
2: 250,
3: 400,
4: 800,
};
let ws;
let wsReady = false;
let sendingFrame = false;
let board;
let currentPiece;
let holdQueue;
let score;
let clearedLines;
let isGameOver;
let dropTimer;
let softDropActive;
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', () => {
wsReady = true;
setWsStatus('open');
});
ws.addEventListener('close', () => {
wsReady = false;
setWsStatus('closed');
});
ws.addEventListener('error', () => {
wsReady = false;
setWsStatus('error');
});
}
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';
setTimeout(connectWebSocket, 1000);
} else {
wsDot.classList.remove('ok');
wsLabel.textContent = 'WS: error';
}
}
function clampToByte(value) {
if (value < 0) return 0;
if (value > 255) return 255;
return value | 0;
}
function applyBrightness(value) {
return clampToByte(Math.round(value * BRIGHTNESS));
}
function mapXYToLinearIndex(x, y, width, height) {
const mappedX = FLIP_X ? (width - 1 - x) : x;
const mappedY = FLIP_Y ? (height - 1 - y) : y;
const isOddRow = (mappedY % 2) === 1;
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
return (mappedY * width) + columnInRow;
}
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
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:
target[baseIndex + 0] = g;
target[baseIndex + 1] = r;
target[baseIndex + 2] = b;
break;
}
}
function encodeCanvasToNeoPixelFrame() {
const width = MATRIX_WIDTH;
const height = MATRIX_HEIGHT;
const out = new Uint8Array(width * height * 3);
const src = ctx.getImageData(0, 0, width, height).data;
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 createEmptyBoard() {
const rows = [];
for (let y = 0; y < GRID_HEIGHT; y++) {
rows.push(new Array(GRID_WIDTH).fill(null));
}
return rows;
}
function makePiece(template) {
return {
name: template.name,
color: template.color,
rotations: template.rotations,
rotationIndex: 0,
x: template.spawnOffset.x,
y: template.spawnOffset.y,
};
}
function getCurrentShape(piece) {
return piece.rotations[piece.rotationIndex];
}
function forEachBlock(piece, callback) {
const shape = getCurrentShape(piece);
for (let i = 0; i < shape.length; i++) {
const [dx, dy] = shape[i];
callback(piece.x + dx, piece.y + dy);
}
}
function isInside(x, y) {
return x >= 0 && x < GRID_WIDTH && y < GRID_HEIGHT;
}
function isCellFree(x, y) {
if (y < 0) {
return true;
}
if (!isInside(x, y)) {
return false;
}
return board[y][x] === null;
}
function canPlace(piece, offsetX, offsetY, rotationDelta) {
const nextRotation = (piece.rotationIndex + rotationDelta + piece.rotations.length) % piece.rotations.length;
const shape = piece.rotations[nextRotation];
for (let i = 0; i < shape.length; i++) {
const [dx, dy] = shape[i];
const x = piece.x + offsetX + dx;
const y = piece.y + offsetY + dy;
if (!isCellFree(x, y)) {
return false;
}
}
return true;
}
function commitPieceToBoard(piece) {
const shape = getCurrentShape(piece);
for (let i = 0; i < shape.length; i++) {
const [dx, dy] = shape[i];
const x = piece.x + dx;
const y = piece.y + dy;
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
board[y][x] = piece.color;
}
}
}
function clearLines() {
let linesCleared = 0;
const newRows = [];
for (let y = 0; y < GRID_HEIGHT; y++) {
if (board[y].every(cell => cell !== null)) {
linesCleared += 1;
} else {
newRows.push(board[y]);
}
}
while (newRows.length < GRID_HEIGHT) {
newRows.unshift(new Array(GRID_WIDTH).fill(null));
}
board = newRows;
if (linesCleared > 0) {
clearedLines += linesCleared;
const awarded = LINE_SCORE_TABLE[linesCleared] || (linesCleared * 200);
score += awarded;
}
}
function spawnNextPiece() {
if (holdQueue.length === 0) {
refillBag();
}
currentPiece = makePiece(holdQueue.shift());
if (!canPlace(currentPiece, 0, 0, 0)) {
isGameOver = true;
}
}
function refillBag() {
const bag = [...TETROMINOES];
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
holdQueue.push(...bag);
}
function tryMove(offsetX, offsetY) {
if (!currentPiece || !canPlace(currentPiece, offsetX, offsetY, 0)) {
return false;
}
currentPiece.x += offsetX;
currentPiece.y += offsetY;
return true;
}
function tryRotate(clockwise = true) {
if (!currentPiece) {
return;
}
const delta = clockwise ? 1 : -1;
const originalRotation = currentPiece.rotationIndex;
const targetRotation = (originalRotation + delta + currentPiece.rotations.length) % currentPiece.rotations.length;
const kicks = [
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: 1, y: 0 },
{ x: -2, y: 0 },
{ x: 2, y: 0 },
{ x: 0, y: -1 },
];
for (const kick of kicks) {
if (canPlace(currentPiece, kick.x, kick.y, delta)) {
currentPiece.rotationIndex = targetRotation;
currentPiece.x += kick.x;
currentPiece.y += kick.y;
return;
}
}
}
function hardDrop() {
if (!currentPiece) {
return;
}
while (tryMove(0, 1)) {
score += 2;
}
lockPiece();
}
function lockPiece() {
commitPieceToBoard(currentPiece);
clearLines();
scoreEl.textContent = String(score);
linesEl.textContent = String(clearedLines);
spawnNextPiece();
dropTimer = 0;
}
function updateGame(deltaMs) {
if (isGameOver) {
return;
}
dropTimer += deltaMs;
const targetDrop = softDropActive ? SOFT_DROP_MS : AUTO_DROP_MS;
if (dropTimer >= targetDrop) {
dropTimer = 0;
if (!tryMove(0, 1)) {
lockPiece();
}
}
}
function drawBoard() {
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < GRID_HEIGHT; y++) {
for (let x = 0; x < GRID_WIDTH; x++) {
const cell = board[y][x];
if (cell) {
ctx.fillStyle = cell;
ctx.fillRect(x, y, 1, 1);
}
}
}
if (currentPiece) {
ctx.fillStyle = currentPiece.color;
forEachBlock(currentPiece, (x, y) => {
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
ctx.fillRect(x, y, 1, 1);
}
});
}
if (isGameOver) {
ctx.fillStyle = '#ff3b30';
for (let x = 3; x < GRID_WIDTH - 3; x++) {
ctx.fillRect(x, 6, 1, 1);
ctx.fillRect(x, 9, 1, 1);
}
for (let y = 6; y <= 9; y++) {
ctx.fillRect(3, y, 1, 1);
ctx.fillRect(GRID_WIDTH - 4, y, 1, 1);
}
}
}
function toHex(value) {
const bounded = Math.max(0, Math.min(255, value));
const hex = bounded.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
}
function sendFrame() {
if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) {
return;
}
if (sendingFrame) {
return;
}
sendingFrame = true;
try {
const frame = encodeCanvasToNeoPixelFrame();
ws.send(frame.buffer);
} catch (_) {
// ignore
} finally {
sendingFrame = false;
}
}
let previousTimestamp = performance.now();
function gameLoop() {
const now = performance.now();
const delta = now - previousTimestamp;
previousTimestamp = now;
updateGame(delta);
drawBoard();
sendFrame();
setTimeout(gameLoop, FRAME_INTERVAL_MS);
}
function resetGame() {
board = createEmptyBoard();
holdQueue = [];
score = 0;
clearedLines = 0;
isGameOver = false;
dropTimer = 0;
softDropActive = false;
scoreEl.textContent = '0';
linesEl.textContent = '0';
refillBag();
spawnNextPiece();
}
window.addEventListener('keydown', (event) => {
if (isGameOver && event.key.toLowerCase() === 'r') {
resetGame();
return;
}
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
tryMove(-1, 0);
break;
case 'ArrowRight':
event.preventDefault();
tryMove(1, 0);
break;
case 'ArrowDown':
event.preventDefault();
softDropActive = true;
break;
case 'ArrowUp':
event.preventDefault();
tryRotate(true);
break;
case ' ': // Space hard drop
case 'Enter':
event.preventDefault();
hardDrop();
break;
case 'r':
case 'R':
event.preventDefault();
resetGame();
break;
default:
break;
}
});
window.addEventListener('keyup', (event) => {
if (event.key === 'ArrowDown') {
softDropActive = false;
}
});
resetGame();
connectWebSocket();
gameLoop();
})();