diff --git a/build/build.ts b/build/build.ts index 20ae4df..85a51fc 100644 --- a/build/build.ts +++ b/build/build.ts @@ -13,13 +13,13 @@ let game = process.argv[2]; const publish = process.env.PUBLISH_LOCATION; while (!await isGame(game)) { - const game = await select({ + game = await select({ message: 'Game to build:', choices: (await getGames()).map(value => ({ value })), }); } -const html = await buildHTML(game, true); +const html = await buildHTML(game, { production: true }); if (!html) { process.exit(1); diff --git a/build/html.ts b/build/html.ts index 6194399..71fa9d3 100644 --- a/build/html.ts +++ b/build/html.ts @@ -10,7 +10,12 @@ import lightningcss from 'bun-lightningcss'; import { getGames } from './isGame'; import audioPlugin from './audioPlugin'; -export async function buildHTML(game: string, production = false, portable = false) { +interface Args { + production?: boolean; + portable?: boolean; + mobile?: boolean +} +export async function buildHTML(game: string, { production = false, portable = false, mobile = false }: Args = {}) { const html = await Bun.file(path.resolve(import.meta.dir, 'assets', 'index.html')).text(); const bundle = await Bun.build({ outdir: '/tmp', @@ -62,7 +67,7 @@ export async function buildHTML(game: string, production = false, portable = fal } else { script = minifyResult.code; } - } else { + } else if (mobile) { const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text(); scriptPrefix = ``; } diff --git a/build/server.ts b/build/server.ts index f56c67f..c637e02 100644 --- a/build/server.ts +++ b/build/server.ts @@ -1,5 +1,6 @@ import Bun from 'bun'; import path from 'path'; +import browser from 'browser-detect'; import { buildHTML } from './html'; import { isGame } from './isGame'; @@ -15,10 +16,14 @@ Bun.serve({ case '/': case 'index.html': try { + const detectedBrowser = browser(req.headers.get('user-agent') ?? ''); const html = await buildHTML( - game, - url.searchParams.get('production') === 'true', // to debug production builds - url.searchParams.get('portable') === 'true', // to skip AssemblyScript compilation + game, + { + production: url.searchParams.get('production') === 'true', // to debug production builds + portable: url.searchParams.get('portable') === 'true', // to skip AssemblyScript compilation, + mobile: detectedBrowser.mobile, + } ); if (html) { return new Response(html, { @@ -33,7 +38,7 @@ Bun.serve({ } return new Response(`Error building HTML`, { status: 500 }); default: - console.log(`Pathname: ${pathname}`); + console.log(`[Dev Server] requested unknown pathname: ${pathname}`); return new Response(null, { status: 404 }); } } diff --git a/bun.lockb b/bun.lockb index 45cc0df..a12c8c0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8d567e5..85a57b0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/html-minifier": "4.0.5", "@types/inquirer": "9.0.7", "assemblyscript": "0.27.29", + "browser-detect": "0.2.28", "bun-lightningcss": "0.2.0", "eruda": "3.2.3", "html-minifier": "4.0.0", diff --git a/src/common/display/assets/brick.module.css b/src/common/display/assets/brick.module.css index 04952c3..2cedfde 100644 --- a/src/common/display/assets/brick.module.css +++ b/src/common/display/assets/brick.module.css @@ -128,6 +128,14 @@ body { transition: color 100ms ease-out; } +.helpText { + width: 100%; + text-align: center; + padding: calc(var(--pixel-size) * 2) calc(var(--pixel-size) * 0.3); + font-size: calc(var(--pixel-size) * 0.4); + box-sizing: border-box; +} + .footer { position: absolute; bottom: 0; diff --git a/src/common/display/brick.tsx b/src/common/display/brick.tsx index c352a0c..4d2c689 100644 --- a/src/common/display/brick.tsx +++ b/src/common/display/brick.tsx @@ -1,4 +1,5 @@ import { render } from "preact"; +import type { ReactElement } from "preact/compat"; import { clamp, range } from "@common/utils"; import classNames from "classnames"; @@ -25,6 +26,7 @@ export class BrickDisplay { #level: number = 1; public pause: boolean = false; public gameOver: boolean = false; + public helpText: string | ReactElement = ''; init() { this.update(); @@ -178,12 +180,16 @@ export class BrickDisplay { } } - drawImage(image: BrickDisplayImage, x: number, y: number, miniDisplay = false) { + drawImage(image: BrickDisplayImage, x: number, y: number, miniDisplay = false, xor = false) { for (let j = 0; j < image.height; j++) { for (let i = 0; i < image.width; i++) { const px = image.image[j * image.width + i]; if (px) { - this.setPixel(x + i, y + j, px, miniDisplay); + if (xor) { + this.togglePixel(x + i, y + j, miniDisplay); + } else { + this.setPixel(x + i, y + j, px, miniDisplay); + } } } } @@ -197,7 +203,7 @@ export class BrickDisplay {
))} @@ -209,7 +215,7 @@ export class BrickDisplay {
))} @@ -224,6 +230,9 @@ export class BrickDisplay {
Level
+
+ {this.helpText} +
Pause
Game over
@@ -282,4 +291,64 @@ export class BrickDisplay { return result; } + + static autoCrop(image: BrickDisplayImage): BrickDisplayImage { + let minX = image.width; + let minY = image.height; + let maxX = 0; + let maxY = 0; + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const i = y * image.width + x; + if (image.image[i]) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + } + + return this.extractSprite(image, minX, minY, maxX - minX + 1, maxY - minY + 1); + } + + static copySprite(image: BrickDisplayImage): BrickDisplayImage { + return this.extractSprite(image, 0, 0, image.width, image.height); + } + + static rotateSprite(image: BrickDisplayImage, angle: 0 | 90 | 180 | 270): BrickDisplayImage { + if (angle === 0) return this.copySprite(image); + + const newImage: BrickDisplayImage = { + image: new Array(image.width * image.height), + width: angle === 180 ? image.width : image.height, + height: angle === 180 ? image.height : image.width, + } + + for (let j = 0; j < image.height; j++) { + for (let i = 0; i < image.width; i++) { + const originalPixel = image.image[j * image.width + i]; + let x = i; + let y = j; + + if (angle === 90) { + const tmp = y; + y = x; + x = image.height - tmp - 1; + } else if (angle === 180) { + x = image.width - x - 1; + y = image.height - y - 1; + } else if (angle === 270) { + const tmp = x; + x = y; + y = image.width - tmp - 1; + } + + newImage.image[y * newImage.width + x] = originalPixel + } + } + + return newImage; + } } \ No newline at end of file diff --git a/src/common/input.ts b/src/common/input.ts index d8c6303..a0a9740 100644 --- a/src/common/input.ts +++ b/src/common/input.ts @@ -6,10 +6,12 @@ interface IKeyState { pressed?: boolean; held?: boolean; } -const KEYS: Record = {}; +type KeyCode = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Space'; + +const KEYS: Partial> = {}; document.body.addEventListener('keydown', (e) => { - const keyId = e.code; + const keyId = e.code as KeyCode; console.debug(`[Input] Pressed ${keyId}`); if (KEYS[keyId]) { KEYS[keyId].state = true; @@ -19,16 +21,16 @@ document.body.addEventListener('keydown', (e) => { }); document.body.addEventListener('keyup', (e) => { - const keyId = e.code; + const keyId = e.code as KeyCode; console.debug(`[Input] Released ${keyId}`); if (KEYS[keyId]) { KEYS[keyId].state = false; } }); -export const isPressed = (key: string): boolean => KEYS[key]?.pressed ?? false; -export const isReleased = (key: string): boolean => KEYS[key]?.released ?? false; -export const isHeld = (key: string): boolean => KEYS[key]?.held ?? false; +export const isPressed = (key: KeyCode): boolean => KEYS[key]?.pressed ?? false; +export const isReleased = (key: KeyCode): boolean => KEYS[key]?.released ?? false; +export const isHeld = (key: KeyCode): boolean => KEYS[key]?.held ?? false; export function updateKeys() { for (const key of Object.values(KEYS)) { diff --git a/src/games/brick-dungeon/data.ts b/src/games/brick-dungeon/data.ts index 1332a4d..ae3c9d7 100644 --- a/src/games/brick-dungeon/data.ts +++ b/src/games/brick-dungeon/data.ts @@ -1,4 +1,4 @@ -import { BrickDisplay, type BrickDisplayImage } from "@common/display"; +import { BrickDisplay, type BrickDisplayImage } from "@common/display/brick"; import { choice, shuffle } from "@common/utils"; const emptySprite: BrickDisplayImage = { image: [], width: 0, height: 0 }; diff --git a/src/games/olc-run-2024/assets/figures.png b/src/games/olc-run-2024/assets/figures.png new file mode 100644 index 0000000..6b75dae Binary files /dev/null and b/src/games/olc-run-2024/assets/figures.png differ diff --git a/src/games/olc-run-2024/assets/fill.ogg b/src/games/olc-run-2024/assets/fill.ogg new file mode 100644 index 0000000..bd54c66 Binary files /dev/null and b/src/games/olc-run-2024/assets/fill.ogg differ diff --git a/src/games/olc-run-2024/assets/place.ogg b/src/games/olc-run-2024/assets/place.ogg new file mode 100644 index 0000000..eb57aea Binary files /dev/null and b/src/games/olc-run-2024/assets/place.ogg differ diff --git a/src/games/olc-run-2024/index.tsx b/src/games/olc-run-2024/index.tsx new file mode 100644 index 0000000..bb53342 --- /dev/null +++ b/src/games/olc-run-2024/index.tsx @@ -0,0 +1,229 @@ +import { BrickDisplay, type BrickDisplayImage } from "@common/display/brick"; +import { choice, delay, randInt, range } from "@common/utils"; + +import figuresImage from './assets/figures.png'; +import placeSound from './assets/place.ogg'; +import fillSound from './assets/fill.ogg'; + +let display: BrickDisplay; +const field: BrickDisplayImage = { + image: [], + width: 8, + height: 8, +}; + +const figuresSpritesheet = BrickDisplay.convertImage(figuresImage); +const figures = extractFigures(figuresSpritesheet); +let currentFigure: BrickDisplayImage = generateFigure(); +let nextFigure: BrickDisplayImage = generateFigure(); +let currentFigureX = 4; +let currentFigureY = 14; +let currentFigureBlink: boolean; +const rowsToClear = new Set(); +const colsToClear = new Set(); + +function extractFigures(spritesheet: BrickDisplayImage): BrickDisplayImage[] { + const figures: BrickDisplayImage[] = []; + + for (let j = 0; j < spritesheet.height; j += 3) { + for (let i = 0; i < spritesheet.width; i += 3) { + const figure = BrickDisplay.extractSprite(spritesheet, i, j, 3, 3); + figures.push(BrickDisplay.autoCrop(figure)); + } + } + + return figures; +} + +function generateFigure() { + return BrickDisplay.rotateSprite(choice(figures), randInt(0, 4) * 90 as any); +} + +function tryToPlace() { + if (canPlaceFigure(currentFigure, currentFigureX - 1, currentFigureY - 1)) { + let pixelsCount = 0; + for (let y = currentFigureY; y < currentFigureY + currentFigure.height; y++) { + for (let x = currentFigureX; x < currentFigureX + currentFigure.width; x++) { + const fieldX = x - 1; + const fieldY = y - 1; + const figureX = x - currentFigureX; + const figureY = y - currentFigureY; + const px = currentFigure.image[figureY * currentFigure.width + figureX]; + if (px) { + pixelsCount++; + field.image[fieldY * field.width + fieldX] = true; + } + } + } + for (let y = currentFigureY; y < currentFigureY + currentFigure.height; y++) { + let lineFilled = true; + for (let fieldX = 0; fieldX < field.width; fieldX++) { + const fieldY = y - 1; + const px = field.image[fieldY * field.width + fieldX]; + if (!px) { + lineFilled = false; + break; + } + } + + if (lineFilled) { + rowsToClear.add(y - 1); + } + } + + for (let x = currentFigureX; x < currentFigureX + currentFigure.width; x++) { + let lineFilled = true; + for (let fieldY = 0; fieldY < field.height; fieldY++) { + const fieldX = x - 1; + const px = field.image[fieldY * field.width + fieldX]; + if (!px) { + lineFilled = false; + break; + } + } + + if (lineFilled) { + colsToClear.add(x - 1); + } + } + + if (rowsToClear.size > 0 || colsToClear.size > 0) { + fillSound.currentTime = 0; + fillSound.play(); + } else { + placeSound.currentTime = 0; + placeSound.play(); + } + + currentFigure = nextFigure; + nextFigure = generateFigure() + currentFigureX = 4; + currentFigureY = 14; + display.score += pixelsCount; + + if (!canPlaceAnywhere(currentFigure)) { + display.gameOver = true; + } + } +} + +function canPlaceFigure(figure: BrickDisplayImage, checkX: number, checkY: number): boolean { + for (let y = checkY; y < checkY + figure.height; y++) { + for (let x = checkX; x < checkX + figure.width; x++) { + if (colsToClear.has(x) || rowsToClear.has(y)) continue; + const figureX = x - checkX; + const figureY = y - checkY; + const figurePx = figure.image[figureY * figure.width + figureX]; + const fieldPx = field.image[y * field.width + x]; + if (figurePx && fieldPx) { + return false; + } + } + } + + return true; +} + +function canPlaceAnywhere(figure: BrickDisplayImage): boolean { + for (let y = 0; y <= field.height - figure.height; y++) { + for (let x = 0; x <= field.width - figure.width; x++) { + if (range(4).some(i => canPlaceFigure(BrickDisplay.rotateSprite(figure, (i * 90) as any), x, y))) { + return true; + } + } + } + + return false; +} + +function reset() { + currentFigure = generateFigure(); + nextFigure = generateFigure(); + currentFigureX = 4; + currentFigureY = 14; + field.image = []; + display.score = 0; +} + +function onKeyDown(e: KeyboardEvent) { + if (rowsToClear.size > 0 || colsToClear.size > 0) { + return; + } + const isUp = e.code === 'ArrowUp' || e.code === 'KeyW'; + const isDown = e.code === 'ArrowDown' || e.code === 'KeyS'; + const isLeft = e.code === 'ArrowLeft' || e.code === 'KeyA'; + const isRight = e.code === 'ArrowRight' || e.code === 'KeyD'; + if (display.gameOver) { + if (e.code === 'Space') { + reset(); + } + } else if (isUp && currentFigureY > 1) { + currentFigureY--; + } else if (isDown && currentFigureY < (20 - currentFigure.height)) { + currentFigureY++; + } else if (isLeft && currentFigureX > 1) { + currentFigureX--; + } else if (isRight && currentFigureX < (9 - currentFigure.width)) { + currentFigureX++; + } else if (e.code === 'Space') { + if (currentFigureY <= (9 - currentFigure.height)) { + tryToPlace(); + } else { + currentFigure = BrickDisplay.rotateSprite(currentFigure, 90); + } + } +} + +async function loop() { + currentFigureBlink = !currentFigureBlink; + + if (rowsToClear.size > 0 || colsToClear.size > 0) { + display.score += Math.pow(rowsToClear.size + colsToClear.size, 2) * 100; + for (let i = 0; i < field.width; i++) { + for (const y of rowsToClear) { + field.image[y * field.width + i] = false; + } + for (const x of colsToClear) { + field.image[i * field.width + x] = false; + } + display.fillRect(1, 1, field.width, field.height, false); + display.drawImage(field, 1, 1); + display.update(); + await delay(20); + } + rowsToClear.clear(); + colsToClear.clear(); + + } + + display.clear(); + display.drawRect(0, 0, 9, 9); + + display.drawImage(field, 1, 1); + + if (currentFigureBlink) { + display.drawImage(currentFigure, currentFigureX, currentFigureY, false, true); + } + + display.clear(true); + display.drawImage(nextFigure, 0, 0, true); + + display.update(); + requestAnimationFrame(loop); +} + +export default async function main() { + display = new BrickDisplay(); + display.init(); + + display.helpText = <> +
Try not to
+
Run Out Of Space
+
Arrows - move
+
Space - rotate on bottom
+
Space - place on top
+ ; + + document.addEventListener('keydown', onKeyDown); + requestAnimationFrame(loop); +} \ No newline at end of file