///
// A classic.
//
// Arrow keys move your tetrinome.
// Space bar rotates.
// Enter pauses the game.
export const game = true
import type { GameContext, InputState } from "@/shared/game"
import { randomElement, randomIndex } from "@/shared/utils.ts"
const COLS = 10
const ROWS = 20
const CELL = 25
const DOWN_TICK = 10
const MOVE_TICK = 3
const TETRIS_FRAMES = 5
const LOCK_DELAY = 5
type Shape = { x: number, y: number, shape: string, rotation: number }
let score = 0
let player: Shape
let nextShape: Shape
let grid: string[][] = []
let dead = false
let paused = false
let downTick = 0
let moveTick = 0
let clearedLines: number[] = []
let clearedLinesTimer = 0
let lockTimer = 0
const BORDER_COLOR = "#b388ff"
const BG_COLOR = "#222"
const LINE_COLOR = "black"
const UI_BG_COLOR = "#4c3a87"
const LINE_SCORES = [
100,
300,
500,
800
]
const COLORS: Record = {
I: "cyan",
O: "yellow",
T: "magenta",
S: "lime",
Z: "red",
J: "blue",
L: "orange",
}
// I, O, T, L, J, S, Z
const SHAPES: Record = {
I: [
[[1, 1, 1, 1]],
[[1], [1], [1], [1]]
],
O: [[
[1, 1],
[1, 1]
]],
T: [
[
[1, 0],
[1, 1],
[1, 0]
],
[
[1, 1, 1],
[0, 1, 0],
],
[
[0, 1],
[1, 1],
[0, 1]
],
[
[0, 1, 0],
[1, 1, 1],
]
],
L: [
[
[1, 0],
[1, 0],
[1, 1]
],
[
[1, 1, 1],
[1, 0, 0]
],
[
[1, 1],
[0, 1],
[0, 1]
],
[
[0, 0, 1],
[1, 1, 1]
]
],
J: [
[
[0, 1],
[0, 1],
[1, 1]
],
[
[1, 0, 0],
[1, 1, 1]
],
[
[1, 1],
[1, 0],
[1, 0]
],
[
[1, 1, 1],
[0, 0, 1]
]
],
S: [
[
[1, 0],
[1, 1],
[0, 1]
],
[
[0, 1, 1],
[1, 1, 0],
],
[
[1, 0],
[1, 1],
[0, 1]
],
[
[0, 1, 1],
[1, 1, 0]
]
],
Z: [
[
[0, 1],
[1, 1],
[1, 0]
],
[
[1, 1, 0],
[0, 1, 1]
],
[
[0, 1],
[1, 1],
[1, 0]
],
[
[1, 1, 0],
[0, 1, 1]
]
],
}
export function init() {
paused = false
score = 0
dead = false
downTick = 0
moveTick = 0
lockTimer = 0
clearedLinesTimer = TETRIS_FRAMES
clearedLines.length = 0
grid.length = 0
player = newShape()
nextShape = newShape()
}
export function update(_delta: number, input: InputState) {
if (dead) return
if (input.justPressed.has("Enter"))
paused = !paused
if (paused) return
if (input.justPressed.has(" ") || input.justPressed.has("z")) {
rotateShape()
}
// tetris animation
if (clearedLines.length > 0) {
clearedLinesTimer--
if (clearedLinesTimer <= 0) {
removeClearedLines()
clearedLines = []
}
} else if (anyFullRows()) {
clearedLines = findFullRows()
clearedLinesTimer = TETRIS_FRAMES
}
const hit = isBlocked()
if (!hit) lockTimer = LOCK_DELAY
if (hit) {
if (lockTimer > 0) {
lockTimer--
} else {
lockShape()
return
}
}
if (++moveTick % MOVE_TICK === 0)
detectMovement(input)
if (++downTick % downTickForScore() === 0)
if (!collision(player.x, player.y + 1, player.rotation)) {
player.y++
}
}
export function draw(game: GameContext) {
game.clear(UI_BG_COLOR)
const boardW = COLS * CELL
const boardH = ROWS * CELL
const offsetX = (game.width - boardW) / 2
const offsetY = (game.height - boardH) / 2
const c = game.ctx
c.save()
c.translate(offsetX, offsetY)
// lines (shows through cracks between blocks)
game.rectfill(0, 0, boardW, boardH, LINE_COLOR)
// draw border
drawBorder(game)
// draw board
drawBoard(game)
// player shape
drawPlayer(game)
// "next shape" UI
drawPreview(game)
// high score
game.text(`Score: ${score}`, (COLS + 2) * CELL, (8 * CELL) + 10, "yellow", 15)
c.restore()
if (paused)
game.centerTextX("PAUSED", 200, "lime", 60)
// ya dead
if (dead)
game.centerTextX("GAME OVER", 200, "red", 60)
}
function spawn() {
player = nextShape
player.x = 3
player.y = 0
nextShape = newShape()
if (collision(player.x, player.y, player.rotation)) {
dead = true
}
}
function newShape(): Shape {
const shape = randomElement(Object.keys(SHAPES))!
return { x: 3, y: 0, shape, rotation: randomIndex(SHAPES[shape]!)! }
}
function anyFullRows(): boolean {
return findFullRows().length > 0
}
function findFullRows(): number[] {
const rows: number[] = []
for (let y = 0; y < ROWS; y++) {
let full = true
for (let x = 0; x < COLS; x++) {
if (!grid[y]?.[x]) {
full = false
break
}
}
if (full) rows.push(y)
}
return rows
}
function downTickForScore(): number {
return Math.max(1, DOWN_TICK - Math.floor(score / 1000))
}
function removeClearedLines() {
const newGrid: string[][] = []
score += LINE_SCORES[clearedLines.length - 1] || 0
for (let y = 0; y < ROWS; y++)
if (!clearedLines.includes(y))
newGrid.push(grid[y] ?? [])
// add empty rows on top to restore height
while (newGrid.length < ROWS)
newGrid.unshift([])
grid = newGrid
clearedLines = []
}
function collision(x: number, y: number, rotation: number): boolean {
const shape = SHAPES[player.shape]![rotation]!
for (let row = 0; row < shape.length; row++) {
for (let col = 0; col < shape[row]!.length; col++) {
if (!shape[row]![col]) continue
const nx = x + col
const ny = y + row
if (nx < 0 || nx >= COLS) return true
if (ny >= ROWS) return true
if (grid[ny]?.[nx]) return true
}
}
return false
}
function lockShape() {
const shape = SHAPES[player.shape]![player.rotation]!
for (const row in shape) {
for (const col in shape[row]!) {
const filled = shape[row][col]
if (!filled) continue
const x = player.x + Number(col)
const y = player.y + Number(row)
grid[y] ??= []
grid[y][x] = COLORS[player.shape]!
}
}
spawn()
}
function isBlocked(): boolean {
const shape = SHAPES[player.shape]![player.rotation]!
let hit = false
outer: for (const row in shape) {
for (const col in shape[row]!) {
const filled = shape[row][col]
if (!filled) continue
const x = player.x + Number(col)
const y = player.y + Number(row)
grid[y + 1] ??= []
if ((y + 1) >= ROWS || grid[y + 1]![x]) {
hit = true
break outer
}
}
}
return hit
}
function detectMovement(input: InputState) {
const keys = input.pressed
if (
(keys.has("ArrowLeft") || keys.has("a")) &&
!collision(player.x - 1, player.y, player.rotation)
)
player.x = Math.max(0, player.x - 1)
if (
(keys.has("ArrowRight") || keys.has("d")) &&
!collision(player.x + 1, player.y, player.rotation)
)
player.x = Math.min(COLS, player.x + 1)
if (
(keys.has("ArrowDown") || keys.has("s")) &&
!collision(player.x, player.y + 1, player.rotation)
)
player.y += 1
}
function drawBlock(game: GameContext, x: number, y: number, color: string) {
game.rectfill(
x * CELL,
y * CELL,
((x + 1) * CELL) - .5,
((y + 1) * CELL) - .5,
color
)
}
// I, O, T, L, J, S, Z
const PREVIEW_OFFSETS: Record = {
I: [2, 3],
O: [3, 2],
T: [3, 2],
L: [3, 2],
J: [3, 2],
S: [3, 2],
Z: [3, 2],
}
// draw next shape (top-right corner)
function drawPreview(game: GameContext) {
const previewX = COLS + 2
const previewY = 0
const cols = 7
const rows = 7
// lines (shows through cracks between blocks)
game.rectfill(
previewX * CELL,
previewY * CELL,
(previewX + cols) * CELL,
(previewY + rows) * CELL,
LINE_COLOR
)
// draw board
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
drawBlock(game, previewX + col, previewY + row, BG_COLOR)
}
}
// draw border
for (let col = -1; col < cols; col++) {
drawBlock(game, previewX + col + 1, previewY + -1, BORDER_COLOR)
drawBlock(game, previewX + col + 1, previewY + rows, BORDER_COLOR)
}
for (let row = -1; row < rows; row++) {
drawBlock(game, previewX, previewY + row, BORDER_COLOR)
drawBlock(game, previewX + cols, previewY + row, BORDER_COLOR)
}
const next = SHAPES[nextShape.shape]![0]!
for (let row = 0; row < next.length; row++) {
for (let col = 0; col < next[row]!.length; col++) {
if (!next[row]![col]) continue
const [offsetX, offsetY] = PREVIEW_OFFSETS[nextShape.shape]!
drawBlock(game, previewX + col + offsetX, previewY + row + offsetY, COLORS[nextShape.shape]!)
}
}
}
function rotateShape() {
const oldRot = player.rotation
const newRot = (oldRot + 1) % SHAPES[player.shape]!.length
if (!collision(player.x, player.y, newRot)) {
player.rotation = newRot
return
}
if (!collision(player.x + 1, player.y, newRot)) {
player.x += 1
player.rotation = newRot
return
}
if (!collision(player.x - 1, player.y, newRot)) {
player.x -= 1
player.rotation = newRot
return
}
}
function drawBorder(game: GameContext) {
for (let col = -1; col <= COLS; col++) {
drawBlock(game, col, -1, BORDER_COLOR)
drawBlock(game, col, ROWS, BORDER_COLOR)
}
for (let row = 0; row < ROWS; row++) {
drawBlock(game, -1, row, BORDER_COLOR)
drawBlock(game, COLS, row, BORDER_COLOR)
}
}
function drawBoard(game: GameContext) {
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
let color = grid[row]?.[col] || BG_COLOR
if (clearedLines.includes(row)) color = "white"
drawBlock(game, col, row, color)
}
}
}
function drawPlayer(game: GameContext) {
const shape = SHAPES[player.shape]![player.rotation]!
for (const row in shape) {
for (const col in shape[row]!) {
const filled = shape[row][col]
if (!filled) continue
const x = player.x + Number(col)
const y = player.y + Number(row)
drawBlock(game, x, y, COLORS[player.shape]!)
}
}
}