feat: tetris test
This commit is contained in:
563
test/pixelstream/tetris/public/app.js
Normal file
563
test/pixelstream/tetris/public/app.js
Normal file
@@ -0,0 +1,563 @@
|
||||
(() => {
|
||||
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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user