Compare commits
10 Commits
aea0c5558e
...
e3dac7edcc
| Author | SHA1 | Date | |
|---|---|---|---|
| e3dac7edcc | |||
| e33fb44c16 | |||
| bba8e32fb7 | |||
| 3f6bacfcd3 | |||
| ef3909ef3d | |||
| a8610f7e27 | |||
| 010730127b | |||
| 0fe73b6f3e | |||
| 2c200d6d11 | |||
| e4464f5e46 |
|
|
@ -96,23 +96,23 @@ export function update(_delta: number, input: InputState) {
|
|||
if (ball.y > ROWS * CELL + 100) dead = true
|
||||
}
|
||||
|
||||
export function draw(ctx: GameContext) {
|
||||
ctx.clear("#6C6FF6")
|
||||
export function draw(game: GameContext) {
|
||||
game.clear("#6C6FF6")
|
||||
|
||||
const boardW = COLS * CELL
|
||||
const boardH = ROWS * CELL + 100
|
||||
const offsetX = (ctx.width - boardW) / 2
|
||||
const offsetY = (ctx.height - boardH) / 2
|
||||
const offsetX = (game.width - boardW) / 2
|
||||
const offsetY = (game.height - boardH) / 2
|
||||
|
||||
const c = ctx.ctx
|
||||
const c = game.ctx
|
||||
c.save()
|
||||
c.translate(offsetX, offsetY)
|
||||
|
||||
// background
|
||||
ctx.rectfill(0, 0, boardW, boardH, "black")
|
||||
game.rectfill(0, 0, boardW, boardH, "black")
|
||||
|
||||
// paddle
|
||||
ctx.rectfill(
|
||||
game.rectfill(
|
||||
paddleX,
|
||||
ROWS * CELL + 60,
|
||||
paddleX + PADDLE_W * CELL,
|
||||
|
|
@ -121,23 +121,23 @@ export function draw(ctx: GameContext) {
|
|||
)
|
||||
|
||||
// ball
|
||||
ctx.circfill(ball.x, ball.y, 6, "red")
|
||||
game.circfill(ball.x, ball.y, 6, "red")
|
||||
|
||||
// bricks
|
||||
for (const b of bricks) {
|
||||
if (b.alive) {
|
||||
const color = pickColor(b.x, b.y)
|
||||
ctx.rectfill(b.x, b.y, (b.x + (CELL * 2)) - .5, (b.y + CELL) - .5, color)
|
||||
game.rectfill(b.x, b.y, (b.x + (CELL * 2)) - .5, (b.y + CELL) - .5, color)
|
||||
}
|
||||
}
|
||||
// score!
|
||||
ctx.text(`Score: ${score}`, 5, boardH - 18, "cyan", 12)
|
||||
game.text(`Score: ${score}`, 5, boardH - 18, "cyan", 12)
|
||||
|
||||
c.restore()
|
||||
|
||||
// ya dead
|
||||
if (dead) {
|
||||
ctx.centerTextX("GAME OVER", boardH + 30, "red", 24)
|
||||
game.centerTextX("GAME OVER", boardH + 30, "red", 24)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,29 +54,29 @@ export function update(_delta: number, input: InputState) {
|
|||
}
|
||||
}
|
||||
|
||||
export function draw(ctx: GameContext) {
|
||||
ctx.clear()
|
||||
ctx.rectfill(0, 0, ctx.width, ctx.height, "black")
|
||||
export function draw(game: GameContext) {
|
||||
game.clear()
|
||||
game.rectfill(0, 0, game.width, game.height, "black")
|
||||
|
||||
const boardW = WIDTH * CELL
|
||||
const boardH = HEIGHT * CELL
|
||||
|
||||
// move board center → canvas center
|
||||
const offsetX = (ctx.width - boardW) / 2
|
||||
const offsetY = (ctx.height - boardH) / 2
|
||||
const offsetX = (game.width - boardW) / 2
|
||||
const offsetY = (game.height - boardH) / 2
|
||||
|
||||
console.log("X", offsetX)
|
||||
console.log("Y", offsetY)
|
||||
|
||||
const c = ctx.ctx
|
||||
const c = game.ctx
|
||||
c.save()
|
||||
c.translate(offsetX, offsetY)
|
||||
|
||||
// board background (now local 0,0 is board top-left)
|
||||
ctx.rectfill(0, 0, boardW, boardH, "green")
|
||||
game.rectfill(0, 0, boardW, boardH, "green")
|
||||
|
||||
// food
|
||||
ctx.rectfill(
|
||||
game.rectfill(
|
||||
food.x * CELL, food.y * CELL,
|
||||
(food.x + 1) * CELL, (food.y + 1) * CELL,
|
||||
"lime"
|
||||
|
|
@ -84,7 +84,7 @@ export function draw(ctx: GameContext) {
|
|||
|
||||
// snake
|
||||
for (const s of snake) {
|
||||
ctx.rectfill(
|
||||
game.rectfill(
|
||||
s.x * CELL, s.y * CELL,
|
||||
((s.x + 1) * CELL) + .5, ((s.y + 1) * CELL) + .5,
|
||||
"magenta"
|
||||
|
|
@ -92,11 +92,11 @@ export function draw(ctx: GameContext) {
|
|||
}
|
||||
|
||||
// score!
|
||||
ctx.text(`Score: ${snake.length - 1}`, 5, boardH - 18, "cyan", 12)
|
||||
game.text(`Score: ${snake.length - 1}`, 5, boardH - 18, "cyan", 12)
|
||||
|
||||
c.restore()
|
||||
|
||||
if (dead) {
|
||||
ctx.centerText("GAME OVER", "red", 50)
|
||||
game.centerText("GAME OVER", "red", 50)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
380
app/nose/bin/tetris.ts
Normal file
380
app/nose/bin/tetris.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/// <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
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Message, InputState } from "../shared/types.js"
|
||||
import { GameContext } from "../shared/game.js"
|
||||
import type { Message } from "../shared/types.js"
|
||||
import { GameContext, type InputState } from "../shared/game.js"
|
||||
import { focusInput } from "./focus.js"
|
||||
import { $$, scrollback } from "./dom.js"
|
||||
import { randomId } from "../shared/utils.js"
|
||||
|
|
@ -22,7 +22,10 @@ let pressed: InputState = {
|
|||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
pressed: pressedStack
|
||||
pressed: pressedStack,
|
||||
prevPressed: new Set(),
|
||||
justPressed: new Set(),
|
||||
justReleased: new Set(),
|
||||
}
|
||||
|
||||
export async function handleGameStart(msg: Message) {
|
||||
|
|
@ -90,6 +93,12 @@ function handleKeyup(e: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateInputState() {
|
||||
pressed.justPressed = new Set([...pressed.pressed].filter(k => !pressed.prevPressed.has(k)))
|
||||
pressed.justReleased = new Set([...pressed.prevPressed].filter(k => !pressed.pressed.has(k)))
|
||||
pressed.prevPressed = new Set(pressed.pressed)
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const scale = Math.min(
|
||||
window.innerWidth / 960,
|
||||
|
|
@ -114,6 +123,7 @@ function gameLoop(ctx: GameContext, game: Game) {
|
|||
|
||||
const delta = ts - last
|
||||
if (delta >= 1000 / FPS) {
|
||||
updateInputState()
|
||||
if (game.update) game.update(delta, pressed)
|
||||
if (game.draw) game.draw(ctx)
|
||||
last = ts
|
||||
|
|
|
|||
|
|
@ -48,5 +48,5 @@ function retryConnection() {
|
|||
}
|
||||
retries++
|
||||
addErrorMessage(`!! Connection lost. Retrying...`)
|
||||
setTimeout(startConnection, 1000)
|
||||
setTimeout(startConnection, 2000)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
export type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }
|
||||
export type InputState = {
|
||||
key: string,
|
||||
shift: boolean,
|
||||
ctrl: boolean,
|
||||
meta: boolean,
|
||||
pressed: Set<string>,
|
||||
prevPressed: Set<string>,
|
||||
justPressed: Set<string>,
|
||||
justReleased: Set<string>
|
||||
}
|
||||
|
||||
export class GameContext {
|
||||
constructor(public ctx: CanvasRenderingContext2D) { }
|
||||
|
|
@ -8,7 +17,7 @@ export class GameContext {
|
|||
|
||||
clear(color?: string) {
|
||||
if (color)
|
||||
this.rectfill(0, 0, this.ctx.canvas.width, this.ctx.canvas.height, color)
|
||||
this.rectfill(0, 0, this.width, this.height, color)
|
||||
else
|
||||
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export function randomId(): string {
|
|||
// rng(1, 5) #=> result can be 1 2 3 4 or 5
|
||||
// rng(2) #=> result can be 1 or 2
|
||||
export function rng(min: number, max = 0) {
|
||||
if (min === 0 && max === 0) return 0
|
||||
|
||||
if (max === 0) {
|
||||
max = min
|
||||
min = 1
|
||||
|
|
@ -20,3 +22,15 @@ export function rng(min: number, max = 0) {
|
|||
max = Math.floor(max)
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
// randomElement([5, 7, 9]) #=> 7
|
||||
export function randomElement<T>(list: T[]): T | undefined {
|
||||
if (!list.length) return
|
||||
return list[rng(0, list.length - 1)]
|
||||
}
|
||||
|
||||
// randomIndex([5, 7, 9]) #=> 1
|
||||
export function randomIndex<T>(list: T[]): number | undefined {
|
||||
if (!list.length) return
|
||||
return rng(0, list.length - 1)
|
||||
}
|
||||
20
app/test/rng.test.ts
Normal file
20
app/test/rng.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { test } from "bun:test"
|
||||
import { equal as assertEqual, ok as assert } from "assert"
|
||||
import { rng, randomIndex } from "../src/shared/utils"
|
||||
|
||||
test("randomIndex", () => {
|
||||
let idx = randomIndex([5, 7, 9])!
|
||||
assert(idx >= 0 && idx < 3)
|
||||
|
||||
idx = randomIndex([5])!
|
||||
assertEqual(idx, 0)
|
||||
})
|
||||
|
||||
test("rng", () => {
|
||||
assertEqual(rng(0, 0), 0)
|
||||
|
||||
const samples = Array.from({ length: 1000 }, () => rng(0, 1))
|
||||
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
|
||||
assert(avg > 0.45 && avg < 0.55, "Average not close to 0.5")
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user