import Aview from "./Aview.ts"; import { isLogged } from "../main.js"; import { dragElement } from "./drag.js"; import { setOnekoState, setBallPos, setOnekoOffset } from "../oneko.ts"; export default class extends Aview { running: boolean; constructor() { super(); this.setTitle("tetris (local match)"); setOnekoState("tetris"); this.running = true; } async getHTML() { return `
tetris_game.ts
×
`; } async run() { dragElement(document.getElementById("window")); const COLS = 10; const ROWS = 20; const BLOCK = 30; // pixels per block const view = this; type Cell = number; // 0 empty, >0 occupied (color index) // Tetromino definitions: each piece is an array of rotations, each rotation is a 2D matrix const TETROMINOES: { [key: string]: number[][][] } = { I: [ [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], ], [ [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], ], [ [0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], ], [ [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], ], ], J: [ [ [2, 0, 0], [2, 2, 2], [0, 0, 0], ], [ [0, 2, 2], [0, 2, 0], [0, 2, 0], ], [ [0, 0, 0], [2, 2, 2], [0, 0, 2], ], [ [0, 2, 0], [0, 2, 0], [2, 2, 0], ], ], L: [ [ [0, 0, 3], [3, 3, 3], [0, 0, 0], ], [ [0, 3, 0], [0, 3, 0], [0, 3, 3], ], [ [0, 0, 0], [3, 3, 3], [3, 0, 0], ], [ [3, 3, 0], [0, 3, 0], [0, 3, 0], ], ], O: [ [ [4, 4], [4, 4], ], ], S: [ [ [0, 5, 5], [5, 5, 0], [0, 0, 0], ], [ [0, 5, 0], [0, 5, 5], [0, 0, 5], ], [ [0, 0, 0], [0, 5, 5], [5, 5, 0], ], [ [5, 0, 0], [5, 5, 0], [0, 5, 0], ], ], T: [ [ [0, 6, 0], [6, 6, 6], [0, 0, 0], ], [ [0, 6, 0], [0, 6, 6], [0, 6, 0], ], [ [0, 0, 0], [6, 6, 6], [0, 6, 0], ], [ [0, 6, 0], [6, 6, 0], [0, 6, 0], ], ], Z: [ [ [7, 7, 0], [0, 7, 7], [0, 0, 0], ], [ [0, 0, 7], [0, 7, 7], [0, 7, 0], ], [ [0, 0, 0], [7, 7, 0], [0, 7, 7], ], [ [0, 7, 0], [7, 7, 0], [7, 0, 0], ], ], }; const COLORS = [ [ "#000000", "#000000" ] , // placeholder for 0 [ "#00d2e1", "#0080a8" ], // I - cyan [ "#0092e9", "#001fbf" ], // J - blue [ "#e79700", "#c75700" ], // L - orange [ "#d8c800", "#8f7700" ], // O - yellow [ "#59e000", "#038b00" ], // S - green [ "#de1fdf", "#870087" ], // T - purple [ "#f06600", "#c10d07" ], // Z - red ]; class Piece { shape: number[][]; rotations: number[][][]; rotationIndex: number; x: number; y: number; colorIndex: number; constructor(public type: string) { this.rotations = TETROMINOES[type]; this.rotationIndex = 0; this.shape = this.rotations[this.rotationIndex]; this.colorIndex = this.findColorIndex(); this.x = Math.floor((COLS - this.shape[0].length) / 2); this.y = -2; //start on tiles 21 and 22 } findColorIndex() { for (const row of this.shape) for (const v of row) if (v) return v; return 1; } rotateCW() { this.rotationIndex = (this.rotationIndex + 1) % this.rotations.length; this.shape = this.rotations[this.rotationIndex]; } rotateCCW() { this.rotationIndex = (this.rotationIndex - 1 + this.rotations.length) % this.rotations.length; this.shape = this.rotations[this.rotationIndex]; } getCells(): { x: number; y: number; val: number }[] { const cells: { x: number; y: number; val: number }[] = []; for (let r = 0; r < this.shape.length; r++) { for (let c = 0; c < this.shape[r].length; c++) { const val = this.shape[r][c]; if (val) cells.push({ x: this.x + c, y: this.y + r, val }); } } return cells; } } class Game { board: Cell[][]; canvas: HTMLCanvasElement | null; holdCanvas: HTMLCanvasElement | null; queueCanvas: HTMLCanvasElement | null; ctx: CanvasRenderingContext2D | null; holdCtx: CanvasRenderingContext2D | null; queueCtx: CanvasRenderingContext2D | null; piece: Piece | null = null; holdPiece: Piece | null = null; canHold: boolean = true; nextQueue: string[] = []; score: number = 0; level: number = 1; lines: number = 0; dropInterval: number = 1000; lastDrop: number = 0; isLocking: boolean = false; lockRotationCount: number = 0; lockLastRotationCount: number = 0; isGameOver: boolean = false; isPaused: boolean = false; constructor(canvasId: string) { const el = document.getElementById( canvasId, ) as HTMLCanvasElement | null; this.canvas = el; if (!this.canvas) throw console.error("no canvas :c"); this.canvas.width = COLS * BLOCK; this.canvas.height = ROWS * BLOCK; const ctx = this.canvas.getContext("2d"); this.ctx = ctx; if (!this.ctx) throw console.error("no ctx D:"); this.holdCanvas = document.getElementById("hold") as HTMLCanvasElement; this.queueCanvas = document.getElementById("queue") as HTMLCanvasElement; if (!this.holdCanvas || !this.queueCanvas) throw console.error("no canvas :c"); this.holdCtx = this.holdCanvas.getContext("2d"); this.queueCtx = this.queueCanvas.getContext("2d"); if (!this.holdCtx || !this.queueCtx) return; this.holdCtx.clearRect(0, 0, 200, 200); this.queueCtx.clearRect(0, 0, 500, 500); this.board = this.createEmptyBoard(); this.fillBag(); this.spawnPiece(); this.registerListeners(); requestAnimationFrame(this.loop.bind(this)); } createEmptyBoard(): Cell[][] { const b: Cell[][] = []; for (let r = 0; r < ROWS; r++) { const row: Cell[] = new Array(COLS).fill(0); b.push(row); } return b; } fillBag() { // classic 7-bag randomizer const pieces = Object.keys(TETROMINOES); const bag = [...pieces]; 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]]; } this.nextQueue.push(...bag); } hold() { if (!this.canHold) return; [this.piece, this.holdPiece] = [this.holdPiece, this.piece]; if (!this.piece) this.spawnPiece(); if (!this.piece) return; this.piece.x = Math.floor((COLS - this.piece.shape[0].length) / 2); this.piece.y = -2; this.piece.rotationIndex = 0; this.piece.shape = this.piece.rotations[this.piece.rotationIndex]; this.canHold = false; this.drawHold(); } spawnPiece() { this.canHold = true; if (this.nextQueue.length < 7) this.fillBag(); const type = this.nextQueue.shift()!; this.piece = new Piece(type); if (this.collides(this.piece)) { this.isGameOver = true; } this.drawHold(); this.drawQueue(); } collides(piece: Piece): boolean { for (const cell of piece.getCells()) { if (cell.y >= ROWS) return true; if (cell.x < 0 || cell.x >= COLS) return true; if (cell.y >= 0 && this.board[cell.y][cell.x]) return true; } return false; } getGhostOffset(piece: Piece): number { let y: number = 0; while (true) { for (const cell of piece.getCells()) { if ( cell.y + y >= ROWS || (cell.y + y >= 0 && this.board[cell.y + y][cell.x]) ) return y - 1; } y++; } } lockPiece() { if (!this.piece) return; this.isLocking = false; let isValid: boolean = false; for (const cell of this.piece.getCells()) { if (cell.y >= 0 && cell.y < ROWS && cell.x >= 0 && cell.x < COLS) this.board[cell.y][cell.x] = cell.val; if (cell.y > 0) isValid = true; } if (!isValid) this.isGameOver = true; this.clearLines(); this.spawnPiece(); } clearLines() { let linesCleared = 0; outer: for (let r = ROWS - 1; r >= 0; r--) { for (let c = 0; c < COLS; c++) if (!this.board[r][c]) continue outer; this.board.splice(r, 1); this.board.unshift(new Array(COLS).fill(0)); linesCleared++; r++; } if (linesCleared > 0) { this.lines += linesCleared; const points = [0, 40, 100, 300, 1200]; this.score += (points[linesCleared] || 0) * this.level; // level up every 10 lines (Fixed Goal System) const newLevel = Math.floor(this.lines / 10) + 1; if (newLevel > this.level) { this.level = newLevel; this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 75); } } } rotatePiece(dir: "cw" | "ccw") { if (!this.piece) return; if (this.isLocking && this.lockRotationCount < 15) this.lockRotationCount++; // Try wall kicks const originalIndex = this.piece.rotationIndex; if (dir === "cw") this.piece.rotateCW(); else this.piece.rotateCCW(); const kicks = [0, -1, 1, -2, 2]; for (const k of kicks) { this.piece.x += k; if (!this.collides(this.piece)) return; this.piece.x -= k; } // no kick, revert this.piece.rotationIndex = originalIndex; this.piece.shape = this.piece.rotations[originalIndex]; } movePiece(dx: number, dy: number) { if (!this.piece) return; this.piece.x += dx; this.piece.y += dy; if (this.collides(this.piece)) { this.piece.x -= dx; this.piece.y -= dy; return false; } return true; } hardDrop() { if (!this.piece) return; let dropped = 0; while (this.movePiece(0, 1)) dropped++; this.score += dropped * 2; this.lockPiece(); } softDrop() { if (!this.piece) return; if (!this.movePiece(0, 1)) return; else this.score += 1; } keys: Record = {}; direction: number = 0; inputDelay = 200; inputTimestamp = Date.now(); move: boolean = false; inputManager() { if (this.move || Date.now() > this.inputTimestamp + this.inputDelay) { if (this.keys["ArrowLeft"] && !this.keys["ArrowRight"]) this.movePiece(-1, 0); else if (!this.keys["ArrowLeft"] && this.keys["ArrowRight"]) this.movePiece(1, 0); else if (this.keys["ArrowLeft"] && this.keys["ArrowRight"]) this.movePiece(this.direction, 0); this.move = false; } } removeListeners() { window.removeEventListener("keydown", (e) => { this.keys[e.key] = true; if (this.isGameOver) return; if (e.key === "p" || e.key === "P" || e.key === "Escape") this.isPaused = !this.isPaused; if (this.isPaused) return; if (e.key === "ArrowLeft") { this.inputTimestamp = Date.now(); this.direction = -1;//this.movePiece(-1, 0); this.move = true; } else if (e.key === "ArrowRight") { this.inputTimestamp = Date.now(); this.direction = 1;//this.movePiece(1, 0); this.move = true; } else if (e.key === "ArrowDown") this.softDrop(); else if (e.code === "Space") { e.preventDefault(); this.hardDrop(); } else if (e.key === "Shift" || e.key === "c" || e.key === "C") { e.preventDefault(); this.hold(); } else if (e.key === "x" || e.key === "X" || e.key === "ArrowUp") { e.preventDefault(); this.rotatePiece("cw"); } else if (e.key === "z" || e.key === "Z" || e.key === "Control") { e.preventDefault(); this.rotatePiece("ccw"); } }); document.removeEventListener("keyup", (e) => { this.keys[e.key] = false; }); } registerListeners() { window.addEventListener("keydown", (e) => { this.keys[e.key] = true; if (this.isGameOver) return; if (e.key === "p" || e.key === "P" || e.key === "Escape") this.isPaused = !this.isPaused; if (this.isPaused) return; if (e.key === "ArrowLeft") { this.inputTimestamp = Date.now(); this.direction = -1;//this.movePiece(-1, 0); this.move = true; } else if (e.key === "ArrowRight") { this.inputTimestamp = Date.now(); this.direction = 1;//this.movePiece(1, 0); this.move = true; } else if (e.key === "ArrowDown") this.softDrop(); else if (e.code === "Space") { //e.preventDefault(); this.hardDrop(); } else if (e.key === "Shift" || e.key === "c" || e.key === "C") { this.hold(); } else if (e.key === "x" || e.key === "X" || e.key === "ArrowUp") { //e.preventDefault(); this.rotatePiece("cw"); } else if (e.key === "z" || e.key === "Z" || e.key === "Control") { this.rotatePiece("ccw"); } }); document.addEventListener("keyup", (e) => { this.keys[e.key] = false; }); } async loop(timestamp: number) { if (!view.running) return this.removeListeners(); if (!this.lastDrop) this.lastDrop = timestamp; if (!this.isPaused) { this.inputManager(); if (this.isLocking ? timestamp - this.lastDrop > 500 : timestamp - this.lastDrop > this.dropInterval) { if (this.isLocking && this.lockRotationCount == this.lockLastRotationCount) this.lockPiece(); this.lockLastRotationCount = this.lockRotationCount; if (!this.movePiece(0, 1)) { if (!this.isLocking) { this.lockRotationCount = 0; this.lockLastRotationCount = 0; this.isLocking = true; } } else if (this.isLocking) this.lockRotationCount = 0; this.lastDrop = timestamp; } } this.draw(); if (this.isGameOver) { if (await isLogged()) { let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; fetch(`http://localhost:3002/users/${uuid}/matchHistory?game=tetris`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ "game": "tetris", "myScore": this.score, "date": Date.now(), }), }); } document.getElementById("game-buttons")?.classList.remove("hidden"); return ; } requestAnimationFrame(this.loop.bind(this)); } drawGrid() { const ctx = this.ctx; if (!ctx || !this.canvas) return; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.strokeStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(14.5% 0 0)" : "oklch(55.6% 0 0)"; for (let r = 0; r <= ROWS; r++) { // horizontal lines ctx.beginPath(); ctx.moveTo(0, r * BLOCK); ctx.lineTo(COLS * BLOCK, r * BLOCK); ctx.stroke(); } for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c * BLOCK, 0); ctx.lineTo(c * BLOCK, ROWS * BLOCK); ctx.stroke(); } } drawBoard() { this.drawGrid(); for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const val = this.board[r][c]; if (val) this.fillBlock(c, r, COLORS[val], this.ctx); else this.clearBlock(c, r); } } } drawPiece() { if (!this.piece) return; for (const cell of this.piece.getCells()) if (cell.y >= 0) this.fillBlock(cell.x, cell.y, COLORS[cell.val], this.ctx); let offset: number = this.getGhostOffset(this.piece); for (const cell of this.piece.getCells()) if (cell.y + offset >= 0 && offset > 0) this.fillGhostBlock(cell.x, cell.y + offset, COLORS[cell.val]); } drawHold() { if (!this.holdCtx || !this.holdCanvas) return; this.holdCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; this.holdCtx.fillRect(0, 0, this.holdCanvas.width, this.holdCanvas.height); if (!this.holdPiece) return; let y: number = 0; for (const row of this.holdPiece.rotations[0]) { let x: number = 0; for (const val of row) { if (val) this.fillBlock(x + (4 - this.holdPiece.rotations[0].length)/ 2 + 0.35, y + 0.65, this.canHold ? COLORS[this.holdPiece.findColorIndex()] : ["#8c8c84", "#393934"], this.holdCtx); x++; } y++; } } drawQueue() { if (!this.queueCtx || !this.queueCanvas) return ; this.queueCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; this.queueCtx.fillRect(0, 0, this.queueCanvas.width, this.queueCanvas.height); let placement: number = 0; for (const nextPiece of this.nextQueue.slice(0, 5)) { let y: number = 0; for (const row of TETROMINOES[nextPiece][0]) { let x: number = 0; for (const val of row) { if (val) this.fillBlock(x + (4 - TETROMINOES[nextPiece][0].length) / 2 + 0.25, y + 0.5 + placement * 2.69 - (nextPiece ==="I" ? 0.35 : 0), COLORS[["I", "J", "L", "O", "S", "T", "Z"].indexOf(nextPiece) + 1], this.queueCtx); x++; } y++; } placement++; } } adjustColor(hex: string, amount: number): string { let color = hex.startsWith('#') ? hex.slice(1) : hex; const num = parseInt(color, 16); let r = (num >> 16) + amount; let g = ((num >> 8) & 0x00FF) + amount; let b = (num & 0x0000FF) + amount; r = Math.max(Math.min(255, r), 0); g = Math.max(Math.min(255, g), 0); b = Math.max(Math.min(255, b), 0); return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`; } fillBlock(x: number, y: number, color: string[], ctx: CanvasRenderingContext2D | null) { if (!ctx) return; const grad = ctx.createLinearGradient(x * BLOCK, y * BLOCK, x * BLOCK, y * BLOCK + BLOCK); grad.addColorStop(0, color[0]); grad.addColorStop(1, color[1]); ctx.fillStyle = grad; ctx.fillRect(Math.round(x * BLOCK) + 4, Math.round(y * BLOCK) + 4, BLOCK - 4, BLOCK - 4); const X = Math.round(x * BLOCK); const Y = Math.round(y * BLOCK); const W = BLOCK; const H = BLOCK; const S = 4; ctx.lineWidth = S; ctx.beginPath(); ctx.strokeStyle = color[0]; ctx.moveTo(X, Y + S / 2); ctx.lineTo(X + W, Y + S / 2); ctx.moveTo(X + S / 2, Y); ctx.lineTo(X + S / 2, Y + H); ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = this.adjustColor(color[1], -20); ctx.moveTo(X, Y + H - S / 2); ctx.lineTo(X + W, Y + H - S / 2); ctx.moveTo(X + W - S / 2, Y); ctx.lineTo(X + W - S / 2, Y + H); ctx.stroke(); } fillGhostBlock(x: number, y: number, color: string[]) { if (!this.ctx) return; const ctx = this.ctx; const X = x * BLOCK; const Y = y * BLOCK; const W = BLOCK; const H = BLOCK; const S = 4; ctx.lineWidth = S; ctx.beginPath(); ctx.strokeStyle = this.adjustColor(color[0], -40); ctx.moveTo(X, Y + S / 2); ctx.lineTo(X + W, Y + S / 2); ctx.moveTo(X + S / 2, Y); ctx.lineTo(X + S / 2, Y + H); ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = this.adjustColor(color[1], -60); ctx.moveTo(X, Y + H - S / 2); ctx.lineTo(X + W, Y + H - S / 2); ctx.moveTo(X + W - S / 2, Y); ctx.lineTo(X + W - S / 2, Y + H); ctx.stroke(); //ctx.strokeRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); } clearBlock(x: number, y: number) { if (!this.ctx) return; const ctx = this.ctx; ctx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; ctx.fillRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); } drawHUD() { if (!this.ctx || !this.canvas) return; const ctx = this.ctx; ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(4, 4, 120, 60); ctx.fillStyle = "#fff"; ctx.font = "12px Kubasta"; ctx.fillText(`score: ${this.score}`, 8, 20); ctx.fillText(`lines: ${this.lines}`, 8, 36); ctx.fillText(`level: ${this.level}`, 8, 52); if (this.isPaused) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0, this.canvas.height / 2 - 24, this.canvas.width, 48); ctx.fillStyle = "#fff"; ctx.font = "24px Kubasta"; ctx.textAlign = "center"; ctx.fillText( "paused", this.canvas.width / 2, this.canvas.height / 2 + 8, ); ctx.textAlign = "start"; } if (this.isGameOver) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0, this.canvas.height / 2 - 36, this.canvas.width, 72); ctx.fillStyle = "#fff"; ctx.font = "28px Kubasta"; ctx.textAlign = "center"; ctx.fillText( "game over", this.canvas.width / 2, this.canvas.height / 2 + 8, ); ctx.textAlign = "start"; } } draw() { if (!this.ctx || !this.canvas) return; // clear everything this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.fillStyle = "#000"; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.strokeStyle = "#111"; for (let r = 0; r <= ROWS; r++) { this.ctx.beginPath(); this.ctx.moveTo(0, r * BLOCK); this.ctx.lineTo(COLS * BLOCK, r * BLOCK); this.ctx.stroke(); } for (let c = 0; c <= COLS; c++) { this.ctx.beginPath(); this.ctx.moveTo(c * BLOCK, 0); this.ctx.lineTo(c * BLOCK, ROWS * BLOCK); this.ctx.stroke(); } this.drawBoard(); this.drawPiece(); this.drawHUD(); this.drawQueue(); } } document.getElementById("game-retry")?.addEventListener("click", () => { document.getElementById("game-buttons")?.classList.add("hidden"); const game = new Game("board"); }); const game = new Game("board"); } }