564 lines
14 KiB
JavaScript
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();
|
|
})();
|
|
|