380 lines
6.7 KiB
TypeScript
380 lines
6.7 KiB
TypeScript
/// <reference lib="dom" />
|
|
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 player: Shape
|
|
let grid: string[][] = []
|
|
let dead = false
|
|
let downTick = 0
|
|
let moveTick = 0
|
|
let tetrisRows: number[] = []
|
|
let tetrisTimer = 0
|
|
let lockTimer = 0
|
|
|
|
const COLORS: Record<string, string> = {
|
|
I: "#6fc6ff", // light blue (C64 cyan)
|
|
O: "#fce94f", // yellow
|
|
T: "#d87bfb", // light purple
|
|
S: "#8ae234", // light green
|
|
Z: "#f35f5f", // red
|
|
J: "#3465a4", // blue
|
|
L: "#e9b96e", // orange
|
|
}
|
|
|
|
// I, O, T, L, J, S, Z
|
|
const SHAPES: Record<string, number[][][]> = {
|
|
I: [
|
|
[[1, 1, 1, 1]],
|
|
[[1], [1], [1], [1]]
|
|
],
|
|
|
|
O: [[
|
|
[1, 1],
|
|
[1, 1]
|
|
]],
|
|
|
|
T: [
|
|
[
|
|
[0, 1, 0],
|
|
[1, 1, 1],
|
|
],
|
|
[
|
|
[1, 0],
|
|
[1, 1],
|
|
[1, 0]
|
|
],
|
|
[
|
|
[1, 1, 1],
|
|
[0, 1, 0],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 1],
|
|
[0, 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: [
|
|
[
|
|
[0, 1, 1],
|
|
[1, 1, 0]
|
|
],
|
|
[
|
|
[1, 0],
|
|
[1, 1],
|
|
[0, 1]
|
|
],
|
|
[
|
|
[0, 1, 1],
|
|
[1, 1, 0],
|
|
],
|
|
[
|
|
[1, 0],
|
|
[1, 1],
|
|
[0, 1]
|
|
]
|
|
],
|
|
|
|
Z: [
|
|
[
|
|
[1, 1, 0],
|
|
[0, 1, 1]
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 1],
|
|
[1, 0]
|
|
],
|
|
[
|
|
[1, 1, 0],
|
|
[0, 1, 1]
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 1],
|
|
[1, 0]
|
|
]
|
|
],
|
|
}
|
|
|
|
export function init() {
|
|
dead = false
|
|
downTick = 0
|
|
moveTick = 0
|
|
tetrisTimer = TETRIS_FRAMES
|
|
lockTimer = 0
|
|
tetrisRows.length = 0
|
|
grid.length = 0
|
|
|
|
player = newShape()
|
|
}
|
|
|
|
export function update(_delta: number, input: InputState) {
|
|
const keys = input.pressed
|
|
|
|
if (input.justPressed.has(" ")) {
|
|
player.rotation += 1
|
|
if (player.rotation === SHAPES[player.shape]!.length)
|
|
player.rotation = 0
|
|
}
|
|
|
|
// tetris animation
|
|
if (tetrisRows.length > 0) {
|
|
tetrisTimer--
|
|
if (tetrisTimer <= 0) {
|
|
removeTetrisRows()
|
|
tetrisRows = []
|
|
}
|
|
} else if (anyFullRows()) {
|
|
tetrisRows = findFullRows()
|
|
tetrisTimer = TETRIS_FRAMES
|
|
}
|
|
|
|
const hit = blocked()
|
|
if (!hit) lockTimer = LOCK_DELAY
|
|
|
|
if (hit) {
|
|
if (lockTimer > 0) {
|
|
lockTimer--
|
|
} else {
|
|
lockShape()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (++moveTick % MOVE_TICK === 0) {
|
|
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
|
|
}
|
|
|
|
if (++downTick % DOWN_TICK === 0)
|
|
if (!collision(player.x, player.y + 1, player.rotation)) {
|
|
player.y++
|
|
}
|
|
}
|
|
|
|
export function draw(game: GameContext) {
|
|
// game.clear("#6C6FF6")
|
|
game.clear("#654321")
|
|
|
|
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)
|
|
|
|
// background
|
|
game.rectfill(0, 0, boardW, boardH, "black")
|
|
|
|
// draw board
|
|
for (let row = 0; row < ROWS; row++) {
|
|
for (let col = 0; col < COLS; col++) {
|
|
let color = grid[row]?.[col] || "gray"
|
|
if (tetrisRows.includes(row)) color = "white"
|
|
drawBlock(game, col, row, color)
|
|
}
|
|
}
|
|
|
|
// player shape
|
|
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]!)
|
|
}
|
|
}
|
|
|
|
c.restore()
|
|
|
|
// ya dead
|
|
if (dead) {
|
|
game.centerText("GAME OVER", "red", 24)
|
|
}
|
|
}
|
|
|
|
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 removeTetrisRows() {
|
|
const newGrid: string[][] = []
|
|
|
|
for (let y = 0; y < ROWS; y++)
|
|
if (!tetrisRows.includes(y))
|
|
newGrid.push(grid[y] ?? [])
|
|
|
|
// add empty rows on top to restore height
|
|
while (newGrid.length < ROWS)
|
|
newGrid.unshift([])
|
|
|
|
grid = newGrid
|
|
tetrisRows = []
|
|
}
|
|
|
|
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]!
|
|
}
|
|
}
|
|
|
|
player = newShape()
|
|
}
|
|
|
|
function blocked(): 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 drawBlock(game: GameContext, x: number, y: number, color: string) {
|
|
game.rectfill(
|
|
x * CELL,
|
|
y * CELL,
|
|
((x + 1) * CELL) - .5,
|
|
((y + 1) * CELL) - .5,
|
|
color
|
|
)
|
|
} |