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