diff --git a/bun.lock b/bun.lock index 471eca4..f62c77e 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,14 @@ "@huggingface/jinja": "0.5.5", "@huggingface/tokenizers": "0.1.1", "@inquirer/select": "2.3.10", + "@types/node": "^25.3.3", "ace-builds": "1.36.3", "clsx": "2.1.1", "delay": "6.0.0", "preact": "10.22.0", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.10", "@types/html-minifier": "4.0.5", "@types/inquirer": "9.0.7", "@types/web-bluetooth": "0.0.21", @@ -47,7 +48,7 @@ "@inquirer/type": ["@inquirer/type@1.4.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw=="], - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/clean-css": ["@types/clean-css@4.2.11", "", { "dependencies": { "@types/node": "*", "source-map": "^0.6.0" } }, "sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw=="], @@ -57,9 +58,7 @@ "@types/mute-stream": ["@types/mute-stream@0.0.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow=="], - "@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], "@types/relateurl": ["@types/relateurl@0.2.33", "", {}, "sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw=="], @@ -81,7 +80,7 @@ "browser-detect": ["browser-detect@0.2.28", "", { "dependencies": { "core-js": "^2.5.7" } }, "sha512-KeWGHqYQmHDkCFG2dIiX/2wFUgqevbw/rd6wNi9N6rZbaSJFtG5kel0HtprRwCGp8sqpQP79LzDJXf/WCx4WAw=="], - "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "camel-case": ["camel-case@3.0.0", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.1.1" } }, "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w=="], @@ -103,8 +102,6 @@ "core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "delay": ["delay@6.0.0", "", {}, "sha512-2NJozoOHQ4NuZuVIr5CWd0iiLVIRSDepakaovIN+9eIDHEhdCAEvSy2cuf1DCrPPQLvHmbqTHODlhHg8UCy4zw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -147,7 +144,7 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "upper-case": ["upper-case@1.1.3", "", {}, "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA=="], @@ -155,10 +152,26 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], + "@inquirer/core/@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], + "@types/clean-css/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + "@types/mute-stream/@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], + "@types/through/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + "bun-types/@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], + "html-minifier/uglify-js": ["uglify-js@3.18.0", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A=="], + + "@inquirer/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@types/clean-css/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@types/mute-stream/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@types/through/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], } } diff --git a/package.json b/package.json index b8c4c02..cf2ff3c 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,14 @@ "@huggingface/jinja": "0.5.5", "@huggingface/tokenizers": "0.1.1", "@inquirer/select": "2.3.10", + "@types/node": "^25.3.3", "ace-builds": "1.36.3", "clsx": "2.1.1", "delay": "6.0.0", "preact": "10.22.0" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.10", "@types/html-minifier": "4.0.5", "@types/inquirer": "9.0.7", "@types/web-bluetooth": "0.0.21", diff --git a/src/common/display/canvas.ts b/src/common/display/canvas.ts index 61197e1..90963f3 100644 --- a/src/common/display/canvas.ts +++ b/src/common/display/canvas.ts @@ -3,7 +3,7 @@ export function loadImageData(dataView: DataView, pointer: number) { const height = dataView.getUint16(pointer + 2, true); const dataPtr = dataView.getUint32(pointer + 4, true); - const imageBuffer = new Uint8ClampedArray(dataView.buffer, dataPtr, width * height * 4); + const imageBuffer = new Uint8Array(dataView.buffer, dataPtr, width * height * 4); const imageData = new ImageData(imageBuffer, width, height); return imageData; @@ -66,3 +66,40 @@ export function getImageData(image: HTMLImageElement): ImageData { return imageData; } + + +export const getPixel = (imageData: ImageData, x: number, y: number): [number, number, number, number] => { + const index = (y * imageData.width + x) * 4; + return [ + imageData.data[index + 0], + imageData.data[index + 1], + imageData.data[index + 2], + imageData.data[index + 3], + ]; +} + +export const getPixelInt = (imageData: ImageData, x: number, y: number): number => { + const index = (y * imageData.width + x) * 4; + return ( + imageData.data[index + 0] << 24 | + imageData.data[index + 1] << 16 | + imageData.data[index + 2] << 8 | + imageData.data[index + 3] << 0 + ); +} + +export const setPixel = (imageData: ImageData, x: number, y: number, color: [number, number, number, number] | number) => { + const index = (y * imageData.width + x) * 4; + if (Array.isArray(color)) { + imageData.data[index + 0] = color[0]; + imageData.data[index + 1] = color[1]; + imageData.data[index + 2] = color[2]; + imageData.data[index + 3] = color[3]; + } else { + const index = (y * imageData.width + x) * 4; + imageData.data[index + 0] = (color >> 24) & 0xFF; + imageData.data[index + 1] = (color >> 16) & 0xFF; + imageData.data[index + 2] = (color >> 8) & 0xFF; + imageData.data[index + 3] = color & 0xFF; + } +} diff --git a/src/common/game.ts b/src/common/game.ts index e5cce9c..db8faf8 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -2,13 +2,13 @@ import { formatError, formatErrorMessage } from "./errors"; import Input from "./input"; import { nextFrame } from "./utils"; -type Setup | void> = () => Promise | T; -type Frame | void> = (dt: number, state: T) => Promise | T | void; +type Setup = () => Promise | T; +type Frame = (dt: number, state: T) => Promise | T | void; type GameMain = () => void; -export function gameLoop | void>(frame: Frame): GameMain; -export function gameLoop | void>(setup: Setup, frame: Frame): GameMain; -export function gameLoop | void>(setupOrFrame: Setup | Frame, frame?: Frame): GameMain { +export function gameLoop(frame: Frame): GameMain; +export function gameLoop(setup: Setup, frame: Frame): GameMain; +export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame): GameMain { return async () => { let state: T; try { diff --git a/src/games/sandstorm/index.tsx b/src/games/sandstorm/index.tsx new file mode 100644 index 0000000..2653cc4 --- /dev/null +++ b/src/games/sandstorm/index.tsx @@ -0,0 +1,110 @@ + +import { createCanvas, getPixelInt, setPixel } from '@common/display/canvas'; +import { gameLoop } from '@common/game'; +import Input from '@common/input'; + +const sandWidth = 128; + +const setup = () => { + const canvas = createCanvas(window.innerWidth >> 2, window.innerHeight >> 2); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + document.body.style.backgroundColor = '#eee'; + + const imageData = initData(); + + return { + canvas, + ctx, + imageData, + }; +}; + +const initData = () => { + const imageData = new ImageData(sandWidth, sandWidth); + + for (let x = 0; x < imageData.width; x++) { + for (let y = 0; y < imageData.height; y++) { + const index = (y * imageData.width + x) * 4; + imageData.data[index + 0] = Math.random() < 0.3 ? 255 : 0; + imageData.data[index + 1] = Math.random() < 0.3 ? 255 : 0; + imageData.data[index + 2] = Math.random() < 0.3 ? 255 : 0; + imageData.data[index + 3] = 255; + } + } + + return imageData; +} + +const emptyColor = 0x000000FF; +const isEmpty = (pixel: number) => (pixel >> 8) === 0; + +type State = ReturnType; + +const frame = async (dt: number, state: State): Promise => { + const { canvas, ctx } = state; + + if (Input.isPressed(Input.KeyCode.SPACE)) { + state.imageData = initData(); + } + + const imageData = state.imageData; + + for (let y = imageData.height - 2; y >= 0; y--) { + const direction = Math.random() < 0.5 ? 1 : -1; + let startX, endX; + if (direction > 0) { + startX = 0; + endX = imageData.width; + } else { + startX = imageData.width - 1; + endX = -1; + } + for (let x = startX; x != endX; x += direction) { + const pixel = getPixelInt(imageData, x, y); + if (isEmpty(pixel)) continue; + + const pixelBelow = getPixelInt(imageData, x, y + 1); + if (isEmpty(pixelBelow)) { + setPixel(imageData, x, y, emptyColor); + setPixel(imageData, x, y + 1, pixel); + continue; + } + + let leftEmpty = false; + let rightEmpty = false; + + if (x > 0) { + const pixelLeft = getPixelInt(imageData, x - 1, y + 1); + leftEmpty = isEmpty(pixelLeft); + } + if (x < imageData.width - 1) { + const pixelRight = getPixelInt(imageData, x + 1, y + 1); + rightEmpty = isEmpty(pixelRight); + } + + const selector = Math.random() < 0.5; + const selectedLeft = (leftEmpty && rightEmpty && selector) || (leftEmpty && !rightEmpty); + const selectedRight = (leftEmpty && rightEmpty && !selector) || (!leftEmpty && rightEmpty); + + if (selectedLeft) { + setPixel(imageData, x, y, emptyColor); + setPixel(imageData, x - 1, y + 1, pixel); + } else if (selectedRight) { + setPixel(imageData, x, y, emptyColor); + setPixel(imageData, x + 1, y + 1, pixel); + } + } + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.putImageData(imageData, (canvas.width - sandWidth) / 2, (canvas.height - sandWidth) / 2); + + return { + ...state, + }; +}; + +export default gameLoop(setup, frame); \ No newline at end of file