1
0
Fork 0

Add awoodungeon as subproject, add favicons and font styles

This commit is contained in:
Pabloader 2024-07-06 19:40:55 +00:00
parent ed9ddd5e89
commit 9ae3c40368
30 changed files with 1177 additions and 31 deletions

View File

@ -27,6 +27,7 @@
margin: auto;
}
</style>
$ICON$
</head>
<body>
<canvas id="canvas"></canvas>

View File

@ -8,7 +8,7 @@ const dataUrlPlugin: BunPlugin = {
const buffer = Buffer.from(arrayBuffer);
return {
contents: `data:image/png;base64,${buffer.toString('base64')}`,
contents: `data:;base64,${buffer.toString('base64')}`,
loader: 'text',
};
});

41
src/build/fontPlugin.ts Normal file
View File

@ -0,0 +1,41 @@
import { plugin, type BunPlugin } from "bun";
import path from 'path';
const fontPlugin: BunPlugin = {
name: "Font loader",
async setup(build) {
build.onLoad({ filter: /\.font\.css$/ }, async (args) => {
const css = await Bun.file(args.path).text();
const match = css.match(/url\(['"]?([^'")]*)['"]?\)/);
if (match?.[1]) {
const fontName = match[1];
const fontPath = path.resolve(path.dirname(args.path), fontName);
const fontFile = Bun.file(fontPath);
if (await fontFile.exists()) {
const buffer = Buffer.from(await fontFile.arrayBuffer());
const url = `data:;base64,${buffer.toString('base64')}`;
const updatedCSS = css.replace(fontName, url)
.replace(/(\n\s*)*/g, '')
.replace(/;\s*\}/g, '}')
.replace(/\s*([{:])\s*/g, '$1');
return {
contents: `import { injectStyle } from '__style_helper__';
injectStyle(${JSON.stringify(updatedCSS)})`,
loader: 'js',
}
}
}
return {
contents: '',
loader: 'js',
};
});
}
};
plugin(fontPlugin);
export default fontPlugin;

View File

@ -2,13 +2,17 @@ import path from 'path';
import { minify } from 'html-minifier';
import dataUrlPlugin from './dataUrlPlugin';
import fontPlugin from './fontPlugin';
import lightningcss from 'bun-lightningcss';
import { getGames } from './isGame';
const transpiler = new Bun.Transpiler();
export async function buildHTML(game: string, production = false) {
const html = await Bun.file(path.resolve(import.meta.dir, '..', 'assets', 'index.html')).text();
const bundle = await Bun.build({
outdir: '/tmp',
entrypoints: [path.resolve(import.meta.dir, '..', 'index.ts')],
sourcemap: production ? 'none' : 'inline',
minify: production,
@ -19,15 +23,27 @@ export async function buildHTML(game: string, production = false) {
},
plugins: [
dataUrlPlugin,
fontPlugin,
lightningcss(),
]
});
if (bundle.success && bundle.outputs.length === 1) {
if (bundle.success) {
if (bundle.outputs.length > 1) {
console.log(bundle.outputs);
return;
}
const iconFile = Bun.file(path.resolve(import.meta.dir, '..', 'games', game, 'assets', 'favicon.ico'));
let icon = '';
if (await iconFile.exists()) {
icon = `<link rel="icon" href="data:;base64,${Buffer.from(await iconFile.arrayBuffer()).toString('base64')}" />`;
}
const script = await bundle.outputs[0].text();
const resultHTML = html
.replace('$SCRIPT$', `<script>${script}</script>`)
.replace('$TITLE$', game[0].toUpperCase() + game.slice(1).toLowerCase());
.replace('$TITLE$', game[0].toUpperCase() + game.slice(1).toLowerCase())
.replace('$ICON$', icon);
return minify(resultHTML, {
collapseWhitespace: true,
@ -35,6 +51,6 @@ export async function buildHTML(game: string, production = false) {
minifyCSS: true,
});
} else {
console.error('Multiple assets: ', bundle.outputs, 'or fail: ', !bundle.success, bundle);
console.error('Failed: ', !bundle.success, bundle);
}
}

46
src/common/input.ts Normal file
View File

@ -0,0 +1,46 @@
interface IKeyState {
state: boolean;
prevState?: boolean;
released?: boolean;
pressed?: boolean;
held?: boolean;
}
const KEYS: Record<string, IKeyState> = {};
document.body.addEventListener('keydown', (e) => {
const keyId = e.key.toLowerCase();
if (KEYS[keyId]) {
KEYS[keyId].state = true;
} else {
KEYS[keyId] = { state: true };
}
});
document.body.addEventListener('keyup', (e) => {
const keyId = e.key.toLowerCase();
if (KEYS[keyId]) {
KEYS[keyId].state = false;
}
});
export const isPressed = (key: string): boolean => KEYS[key.toLowerCase()]?.pressed ?? false;
export const isReleased = (key: string): boolean => KEYS[key.toLowerCase()]?.released ?? false;
export const isHeld = (key: string): boolean => KEYS[key.toLowerCase()]?.held ?? false;
export function updateKeys() {
for (const key of Object.values(KEYS)) {
key.released = false;
key.pressed = false;
if (key.state) {
key.pressed = !key.held;
key.held = true;
} else {
key.released = true;
key.held = false;
}
key.prevState = key.state;
}
}

3
src/common/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export const delay = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const nextFrame = async (): Promise<number> => new Promise((resolve) => requestAnimationFrame(resolve));
export const randInt = (min: number, max: number) => Math.round(min + (max - min - 1) * Math.random());

View File

@ -0,0 +1,6 @@
import Binario from "./game";
export default function runGame(canvas: HTMLCanvasElement) {
const game = new Binario(canvas);
game.run();
}

View File

@ -1,17 +0,0 @@
import { render } from "preact";
import styles from './game.module.css';
declare const GAMES: string[];
export default class GameIndex implements IGame {
constructor(canvas: HTMLCanvasElement) {
canvas.remove();
}
run() {
const root = <div class={styles.games}>
{GAMES.map(g => <a key={g} href={`?game=${g}`}>{g}</a>)}
</div>;
render(root, document.body);
}
}

13
src/games/index/index.tsx Normal file
View File

@ -0,0 +1,13 @@
import { render } from "preact";
import styles from './game.module.css';
declare const GAMES: string[];
export default function run(canvas: HTMLCanvasElement) {
canvas.remove();
const root = <div class={styles.games}>
{GAMES.map(g => <a key={g} href={`?game=${g}`}>{g}</a>)}
</div>;
render(root, document.body);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1,21 @@
html, body {
background: black;
color: white;
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Verite 8x16', 'IBM VGA 8x16', monospace;
font-size: 16px;
line-height: 16px;
}
#fps {
position: absolute;
top: 5px;
left: 5px;
}

View File

@ -0,0 +1,6 @@
@font-face {
font-family: 'IBM VGA 8x16';
src: url("./WebPlus_IBM_VGA_8x16.woff") format('woff');
font-weight: normal;
font-style: normal;
}

View File

@ -0,0 +1,83 @@
export const GAME_WIDTH = 80;
export const GAME_HEIGHT = 25;
export const ROOM_AREA_WIDTH = 40;
export const ROOM_AREA_HEIGHT = GAME_HEIGHT;
export const ROOM_AREA_X = GAME_WIDTH - ROOM_AREA_WIDTH;
export const ROOM_AREA_Y = Math.round((GAME_HEIGHT - ROOM_AREA_HEIGHT) / 2);
export const INVENTORY_HEIGHT = 3;
export const INVENTORY_X = 0;
export const INVENTORY_Y = GAME_HEIGHT - INVENTORY_HEIGHT;
export const MAP_X = 16;
export const MAP_Y = 0;
export const MAP_WIDTH = GAME_WIDTH - ROOM_AREA_WIDTH - MAP_X;
export const MAP_HEIGHT = 10;
export enum Color {
BLACK = 0b0000,
DARK_BLUE = 0b0001,
DARK_CYAN = 0b0011,
DARK_GREEN = 0b0010,
DARK_YELLOW = 0b0110,
DARK_RED = 0b0100,
DARK_MAGENTA = 0b0101,
GRAY = 0b0111,
BLUE = 0b1001,
CYAN = 0b1011,
GREEN = 0b1010,
YELLOW = 0b1110,
RED = 0b1100,
MAGENTA = 0b1101,
WHITE = 0b1111,
}
export enum Direction {
NORTH = 0,
EAST = 1,
SOUTH = 2,
WEST = 3,
UP = 4,
DOWN = 5,
}
export const DIRECTION_OFFSETS: Record<number, [number, number, number]> = {
[Direction.NORTH]: [0, -1, 0],
[Direction.EAST]: [1, 0, 0],
[Direction.SOUTH]: [0, 1, 0],
[Direction.WEST]: [-1, 0, 0],
[Direction.UP]: [0, 0, -1],
[Direction.DOWN]: [0, 0, 1],
};
export const getOppositeDirection = (d: Direction): Direction => {
switch (d) {
case Direction.NORTH: return Direction.SOUTH;
case Direction.WEST: return Direction.EAST;
case Direction.SOUTH: return Direction.NORTH;
case Direction.EAST: return Direction.WEST;
case Direction.UP: return Direction.DOWN;
case Direction.DOWN: return Direction.UP;
}
}
export const MAP_ROOM_CHARS: Record<number, string> = {
[0b0000]: ' ',
[0b0001]: '█',
[0b0010]: '█',
[0b0100]: '█',
[0b1000]: '█',
[0b0011]: '╚',
[0b0110]: '╔',
[0b1100]: '╗',
[0b1001]: '╝',
[0b1010]: '═',
[0b0101]: '║',
[0b0111]: '╠',
[0b1110]: '╦',
[0b1101]: '╣',
[0b1011]: '╩',
[0b1111]: '╬',
};

View File

@ -0,0 +1,230 @@
import { delay, nextFrame } from "@common/utils";
import { Color, GAME_HEIGHT, GAME_WIDTH } from "./const";
import type { IChar, IColorLike, IRegion } from "./types";
import { generateColors, randChar } from "./utils";
const ROOT_NODE = document.getElementById('root');
const FPS_NODE = document.getElementById('fps');
const COLORS = generateColors();
const GAME_FIELD = generateField();
function handleResize() {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const charWidth = windowWidth / GAME_WIDTH;
const charHeight = windowHeight / GAME_HEIGHT;
// TODO
}
function generateField(width = GAME_WIDTH, height = GAME_HEIGHT, parent = ROOT_NODE): HTMLSpanElement[][] {
if (!parent) return [];
let field: HTMLSpanElement[][] = [];
parent.textContent = '';
for (let row = 0; row < height; row++) {
const line = document.createElement('div');
field[row] = [];
for (let column = 0; column < width; column++) {
const span = document.createElement('span');
span.innerHTML = randChar('!');
span.style.color = COLORS[7];
line.append(span);
field[row][column] = span;
}
parent.append(line);
}
return field;
}
export function setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) {
if (x < 0 || y < 0 || y >= GAME_HEIGHT || x >= GAME_WIDTH || !char) {
return;
}
const span = GAME_FIELD[y | 0][x | 0];
if (typeof fg === 'number') fg = COLORS[fg];
if (typeof bg === 'number') bg = COLORS[bg];
if (char === ' ') {
span.innerHTML = '&nbsp;';
} else {
span.textContent = char.toString();
span.style.color = fg;
span.style.backgroundColor = bg;
}
}
export function getChar(x: number, y: number): IChar {
if (x < 0 || y < 0 || y >= GAME_HEIGHT || x >= GAME_WIDTH) {
return [' ', COLORS[0], COLORS[1]];
}
const span = GAME_FIELD[y | 0][x | 0];
return [
span.textContent?.[0] ?? ' ',
span.style.color,
span.style.backgroundColor,
];
}
export function setRegion(x: number, y: number, w: number, h: number, region: IRegion) {
for (let screenY = y; screenY < y + h; screenY++) {
for (let screenX = x; screenX < x + w; screenX++) {
const char = region[screenY - y][screenX - x]
setChar(screenX, screenY, char);
}
}
}
export function getRegion(x: number, y: number, w: number, h: number) {
const region: IRegion = [];
for (let screenY = y; screenY < y + h; screenY++) {
const line: IChar[] = []
for (let screenX = x; screenX < x + w; screenX++) {
line.push(getChar(screenX, screenY));
}
region.push(line);
}
return region;
}
export function drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) {
if (text.includes('\n')) {
const lines = text.split('\n');
for (let line = 0; line < lines.length; line++) {
drawText(x, y + line, lines[line], fg, bg);
}
} else {
for (let i = 0; i < text.length; i++) {
setChar(x + i, y, [text[i], fg, bg]);
}
}
}
export function drawVLine(x: number, y1: number, y2: number, char: IChar = ['│']) {
if (y2 < y1) {
const t = y2;
y2 = y1;
y1 = t;
}
for (let y = y1; y <= y2; y++) {
setChar(x, y, char);
}
}
export function drawHLine(x1: number, x2: number, y: number, char: IChar = ['─']) {
if (x2 < x1) {
const t = x2;
x2 = x1;
x1 = t;
}
for (let x = x1; x <= x2; x++) {
setChar(x, y, char);
}
}
export const isVertical = (char: string) => char === '│';
export const isHorizontal = (char: string) => char === '─';
export const isCorner = (char: string) => '┌┐└┘'.includes(char);
interface IBoxOptions {
vertical?: string;
horizontal?: string;
topLeft?: string;
topRight?: string;
bottomLeft?: string;
bottomRight?: string;
fill?: IChar;
fg?: IColorLike;
bg?: IColorLike;
title?: string;
}
export function drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) {
const {
vertical = '│',
horizontal = '─',
topLeft = '┌',
topRight = '┐',
bottomLeft = '└',
bottomRight = '┘',
fg = Color.WHITE,
bg = Color.BLACK,
fill,
title,
} = options;
setChar(x, y, [topLeft, fg, bg]);
setChar(x + width + 1, y, [topRight, fg, bg]);
setChar(x, y + height + 1, [bottomLeft, fg, bg]);
setChar(x + width + 1, y + height + 1, [bottomRight, fg, bg]);
if (fill) {
fillBox(x + 1, y + 1, width, height, fill);
}
drawHLine(x + 1, x + width, y, [horizontal, fg, bg]);
drawHLine(x + 1, x + width, y + height + 1, [horizontal, fg, bg]);
drawVLine(x, y + 1, y + height, [vertical, fg, bg]);
drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);
if (title) {
drawText(x + 1, y, title, fg, bg);
}
}
export function drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) {
const {
fg = Color.WHITE,
bg = Color.BLACK,
} = options;
let width = 0;
const lines = text.split('\n');
const height = lines.length;
for (const line of lines) {
if (line.length > width) {
width = line.length;
}
}
drawBox(x, y, width, height, { ...options, fill: [' '] });
drawText(x + 1, y + 1, text, fg, bg);
}
export function fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) {
for (let i = y; i < y + height; i++) {
drawHLine(x, x + width - 1, i, char);
}
}
let prevTime = 0;
let dt = 0;
export async function tick(desiredFPS = 60) {
const time = await nextFrame();
dt = time - prevTime;
prevTime = time;
if (FPS_NODE) {
FPS_NODE.textContent = `${Math.round(1000 / dt)}`;
}
const totalDelay = 1000 / desiredFPS;
const remainingDelay = totalDelay - dt;
if (remainingDelay > 4) {
await delay(remainingDelay);
}
}
window.addEventListener('resize', handleResize);
handleResize();

View File

@ -0,0 +1,15 @@
export abstract class Drawable {
protected dirty: boolean = true;
abstract doDraw(): void;
draw() {
if (this.dirty) {
this.doDraw();
this.dirty = false;
}
}
invalidate() {
this.dirty = true;
}
}

View File

@ -0,0 +1,52 @@
import { setRegion } from "./display";
import { Drawable } from "./drawable";
import type { IRegion, ISpriteDefinition } from "./types";
export class Figure extends Drawable {
private frames: IRegion[];
private animationCounter = 0;
private animationPeriod;
constructor(
public x: number,
public y: number,
definition: ISpriteDefinition,
public frame: number = 0,
) {
super();
this.x = x | 0;
this.y = y | 0;
this.frames = definition.frames;
this.animationCounter = 0;
this.animationPeriod = definition.animationPeriod ?? Number.POSITIVE_INFINITY;
}
override doDraw() {
this.animationCounter++;
if (this.animationCounter >= this.animationPeriod) {
this.animationCounter = 0;
this.frame = (this.frame + 1) % this.numFrames;
}
setRegion(this.x, this.y, this.width, this.height, this.image);
}
get width() {
const frame = this.frames[(this.frame % this.numFrames)];
const line = frame[0];
return line?.length ?? 0;
}
get height() {
const frame = this.frames[(this.frame % this.numFrames)];
return frame.length;
}
get numFrames() {
return this.frames.length;
}
get image() {
return this.frames[(this.frame % this.numFrames)];
}
}

View File

@ -0,0 +1,141 @@
import { Color, Direction, getOppositeDirection } from './const';
import { drawTextInBox, tick } from './display';
import { isHeld, isPressed, updateKeys } from '@common/input';
import { createItems, getItemsCount } from './item';
import { GameMap } from './map';
import { Player } from './player';
import { getPossibleRoomsCount, getRoom, getRoomsCount } from './room';
let currentRoom = getRoom(0, 0, 0);
const map = new GameMap(currentRoom);
currentRoom.draw();
const player = new Player(currentRoom.x + currentRoom.width / 2, currentRoom.y + currentRoom.height / 2);
let lastMove = Date.now();
function handleInput() {
const isSpacePressed = isPressed(' ');
if (Date.now() - lastMove < 75 && !isHeld('shift') && !isSpacePressed) return;
lastMove = Date.now();
let newX = player.x;
let newY = player.y;
let moved = isSpacePressed;
if (isHeld('arrowup')) {
newY--;
moved = true;
}
if (isHeld('arrowdown')) {
newY++;
moved = true;
}
if (isHeld('arrowleft')) {
newX--;
moved = true;
}
if (isHeld('arrowright')) {
newX++;
moved = true;
}
if (moved) {
const activatedDoor = currentRoom.getActivatedDoor(newX, newY);
if (activatedDoor) {
const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed;
if (shouldTravel) {
currentRoom = getRoom(activatedDoor.worldX, activatedDoor.worldY, activatedDoor.worldZ);
currentRoom.invalidate();
map.currentRoom = currentRoom;
map.invalidate();
player.skipNextBackgroundRestore = true;
const oppositeDoor = currentRoom.doors[getOppositeDirection(activatedDoor.direction)];
if (oppositeDoor) {
switch (activatedDoor.direction) {
case Direction.NORTH:
newX = oppositeDoor.x + 1;
newY = oppositeDoor.y - 1;
break;
case Direction.SOUTH:
newX = oppositeDoor.x + 1;
newY = oppositeDoor.y + 1;
break;
case Direction.EAST:
newX = oppositeDoor.x + 1;
newY = oppositeDoor.y + 1;
break;
case Direction.WEST:
newX = oppositeDoor.x - 1;
newY = oppositeDoor.y + 1;
break;
case Direction.UP:
case Direction.DOWN:
newX = oppositeDoor.x;
newY = oppositeDoor.y;
break;
}
} else {
newX = currentRoom.x + currentRoom.width / 2;
newY = currentRoom.y + currentRoom.height / 2;
}
}
} else {
if (newX < currentRoom.x + 1 || newX >= currentRoom.x + currentRoom.width + 1) {
newX = player.x;
}
if (newY < currentRoom.y + 1 || newY >= currentRoom.y + currentRoom.height + 1) {
newY = player.y;
}
}
const pickedItem = currentRoom.pickItem(newX, newY);
if (pickedItem) {
player.addItem(pickedItem);
}
player.x = newX;
player.y = newY;
player.invalidate();
}
}
function handleLogic() {
}
function drawInfo() {
const coords = `${currentRoom.worldX},${currentRoom.worldY},${currentRoom.worldZ}`.padStart(9);
const foundRooms = getRoomsCount();
const totalRooms = getPossibleRoomsCount();
const rooms = `${foundRooms}/${totalRooms}${foundRooms === totalRooms ? '' : '+'}`.padStart(coords.length - 2);
const items = `${player.foundItems}/${getItemsCount()}`.padStart(coords.length - 2);
drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' });
}
function draw() {
currentRoom.draw();
map.draw();
player.draw();
drawInfo();
}
export async function main() {
createItems();
currentRoom.invalidate();
player.invalidate();
drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN });
while (true) {
updateKeys();
handleInput();
handleLogic();
draw();
await tick(60);
}
}

View File

@ -0,0 +1,57 @@
import { Color } from "./const";
import type { IChar, ISpriteDefinition } from "./types";
const l = (line: string, fg = Color.WHITE, bg = Color.BLACK): IChar[] =>
Array.from(line).map((c) => [c, fg, bg]);
export const PLAYER_SPRITE: ISpriteDefinition = {
frames: [
[
l('@', Color.YELLOW),
],
],
};
export const DOOR_SPRITE: ISpriteDefinition = {
frames: [
[
l('┘ └'),
],
[
l('└'),
l(' '),
l('┌'),
],
[
l('┐ ┌'),
],
[
l('┘'),
l(' '),
l('┐'),
],
[
l('^', Color.CYAN),
],
[
l('_', Color.GREEN),
],
],
}
export const ITEM_KEY_SPRITE: ISpriteDefinition = {
frames: [
[
l('ъ', Color.MAGENTA),
],
[
l('ъ', Color.YELLOW),
],
[
l('ъ', Color.CYAN),
],
[
l('ъ', Color.GREEN),
],
],
}

View File

@ -0,0 +1,18 @@
import './assets/style.css';
import './assets/vga.font.css';
export default async function run(canvas: HTMLCanvasElement) {
canvas.remove();
const root = document.createElement('div');
root.id = 'root';
document.body.append(root);
const fps = document.createElement('div');
fps.id = 'fps';
document.body.append(fps);
const { main } = await import('./game');
await main();
}

View File

@ -0,0 +1,37 @@
import { Drawable } from "./drawable";
import { Figure } from "./figure";
import { ITEM_KEY_SPRITE } from "./images";
let globalItemId = 0;
const idMap = new Map<number, Figure>();
export const getItemsCount = () => idMap.size;
export function createItems() {
for (let frame = 0; frame < ITEM_KEY_SPRITE.frames.length; frame++) {
idMap.set(
globalItemId++,
new Figure(0, 0, ITEM_KEY_SPRITE, frame),
);
}
}
export class Item extends Drawable {
constructor(
public id: number,
public count: number = 1,
public x: number = -1,
public y: number = -1,
) {
super();
}
override doDraw() {
const figure = idMap.get(this.id);
if (figure) {
figure.x = this.x;
figure.y = this.y;
figure.doDraw();
}
}
}

View File

@ -0,0 +1,38 @@
import { Color, MAP_HEIGHT, MAP_ROOM_CHARS, MAP_WIDTH, MAP_X, MAP_Y } from "./const";
import { drawBox, setChar } from "./display";
import { Drawable } from "./drawable";
import { getRoomsForLayer, Room } from "./room";
export class GameMap extends Drawable {
constructor(public currentRoom: Room) {
super();
}
override doDraw() {
drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' });
const centerX = this.currentRoom.worldX - MAP_X - MAP_WIDTH / 2;
const centerY = this.currentRoom.worldY - MAP_Y - MAP_HEIGHT / 2;
for (const room of getRoomsForLayer(this.currentRoom.worldZ)) {
const x = Math.round(room.worldX - centerX);
const y = Math.round(room.worldY - centerY);
if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) {
const char = getMapRoomChar(room);
setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]);
}
}
}
}
function getMapRoomChar(room: Room): string {
let doorsMask = 0;
for (let i = 0; i < 4; i++) {
const mask = 1 << i;
if (room.doors[i]) {
doorsMask |= mask;
}
}
return MAP_ROOM_CHARS[doorsMask];
}

View File

@ -0,0 +1,47 @@
import { INVENTORY_X, INVENTORY_Y } from "./const";
import { drawBox } from "./display";
import { getItemsCount, Item } from "./item";
import { Sprite } from "./sprite";
export class Player extends Sprite {
private inventory: Item[] = [];
addItem(item: Item) {
for (const i of this.inventory) {
if (i.id === item.id) {
i.count += item.count;
return;
}
}
this.inventory.push(new Item(
item.id,
item.count,
INVENTORY_X + 1 + this.inventory.length,
INVENTORY_Y + 1,
));
}
hasItem(item: Item) {
for (const i of this.inventory) {
if (i.id === item.id) {
return true;
}
}
return false;
}
get foundItems() {
return this.inventory.length;
}
override doDraw() {
super.doDraw();
drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' });
this.inventory.forEach((item) => {
if (item.count > 0) {
item.doDraw();
}
});
}
}

View File

@ -0,0 +1,220 @@
import { randInt } from "@common/utils";
import { Color, Direction, DIRECTION_OFFSETS, getOppositeDirection, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from "./const";
import { drawBox } from "./display";
import { Drawable } from "./drawable";
import { Figure } from "./figure";
import { DOOR_SPRITE } from "./images";
import { getItemsCount, Item } from "./item";
type IDoors = [Door | null, Door | null, Door | null, Door | null, Door | null, Door | null];
export class Door extends Figure {
constructor(
x: number,
y: number,
direction: Direction,
public worldX: number,
public worldY: number,
public worldZ: number,
) {
super(x, y, DOOR_SPRITE);
this.frame = direction;
}
isActivated(x: number, y: number): boolean {
const cx = Math.floor(this.x + this.width / 2);
const cy = Math.floor(this.y + this.height / 2);
return (cx === x && cy === y);
}
get direction(): Direction {
return this.frame as Direction;
}
}
export class Room extends Drawable {
constructor(
public readonly x: number,
public readonly y: number,
public readonly width: number,
public readonly height: number,
public readonly worldX: number,
public readonly worldY: number,
public readonly worldZ: number,
public readonly doors: IDoors = [null, null, null, null, null, null],
public readonly items: Item[] = [],
) {
super();
}
override doDraw() {
drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] });
drawBox(this.x, this.y, this.width, this.height, { fill: ['.', Color.GRAY] });
this.doors.forEach((door) => {
if (door) {
door.doDraw();
}
});
this.items.forEach((item) => item.doDraw());
}
getActivatedDoor(x: number, y: number): Door | null {
return this.doors.find((door) => door?.isActivated(x, y)) ?? null;
}
pickItem(x: number, y: number): Item | null {
const itemIndex = this.items.findIndex((item) => item.x === x && item.y === y);
if (itemIndex >= 0) {
const [item] = this.items.splice(itemIndex, 1);
this.invalidate();
return item;
}
return null;
}
}
type IRoomBlank = {
-readonly [P in keyof Room]?: Room[P]
};
const rooms = new Map<string, Room>();
const generateRoomId = (worldX: number, worldY: number, worldZ: number) => `${worldX},${worldY},${worldZ}`;
const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => {
const doors: IDoors = [null, null, null, null, null, null];
if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') {
console.error('World coordinates not defined for doors generation');
return doors;
}
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') {
console.error('Screen coordinates not defined for doors generation');
return doors;
}
for (let i = 0; i < 4; i++) {
const [xoff, yoff] = DIRECTION_OFFSETS[i];
const doorWorldX = worldX + xoff;
const doorWorldY = worldY + yoff;
const doorId = generateRoomId(doorWorldX, doorWorldY, worldZ);
const doorRoom = rooms.get(doorId);
if (doorRoom && !doorRoom.doors[getOppositeDirection(i)]) {
continue;
}
if ((worldX === 0 && worldY === 0) || doorRoom || Math.random() < 0.3) {
const doorX = randInt(x + 1, x + width - 2);
const doorY = randInt(y + 1, y + height - 2);
let dx = 0;
let dy = 0;
switch (i) {
case Direction.NORTH: dx = doorX; dy = y; break;
case Direction.SOUTH: dx = doorX; dy = y + height + 1; break;
case Direction.EAST: dx = x + width + 1; dy = doorY; break;
case Direction.WEST: dx = x; dy = doorY; break;
}
doors[i] = new Door(dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ);
}
}
if (worldX === 0 && worldY === 0) {
const doorX = randInt(x + 1, x + width - 2);
const doorY = randInt(y + 1, y + height - 2);
const [, , zoff] = DIRECTION_OFFSETS[Direction.DOWN];
doors[Direction.DOWN] = new Door(doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff);
if (worldZ !== 0) {
let upDoorX: number;
let upDoorY: number;
do {
upDoorX = randInt(x + 1, x + width - 2);
upDoorY = randInt(y + 1, y + height - 2);
} while (doorX === upDoorX && doorY === upDoorY);
const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP];
doors[Direction.UP] = new Door(upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff);
}
}
return doors;
};
const generatedItems = new Set<number>();
const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => {
const items: Item[] = [];
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') {
console.error('Screen coordinates not defined for items generation');
return items;
}
if (typeof doors === 'undefined') {
console.error('Doors not defined for items generation');
return items;
}
const hasObjectAt = (x: number, y: number) =>
doors.some((d) => d?.x === x && d?.y === y) ||
items.some((i) => i.x === x && i.y === y);
const oneDoor = doors.filter((d) => Boolean(d)).length === 1;
const id = randInt(0, getItemsCount());
if (Math.random() < 0.4 && oneDoor && !generatedItems.has(id)) {
let newItemX: number;
let newItemY: number;
do {
newItemX = randInt(x + 1, x + width - 2);
newItemY = randInt(y + 1, y + height - 2);
} while (hasObjectAt(newItemX, newItemY));
const item = new Item(id, 1, newItemX, newItemY);
items.push(item);
generatedItems.add(id);
}
return items;
};
export function getRoom(worldX: number, worldY: number, worldZ: number) {
const id = generateRoomId(worldX, worldY, worldZ);
const existingRoom = rooms.get(id);
if (existingRoom) {
return existingRoom;
}
const width = randInt(5, ROOM_AREA_WIDTH - 5);
const height = randInt(5, ROOM_AREA_HEIGHT - 5);
const x = Math.max(ROOM_AREA_X + randInt(2, ROOM_AREA_WIDTH - width - 2), ROOM_AREA_X + 2);
const y = Math.max(ROOM_AREA_Y + randInt(2, ROOM_AREA_HEIGHT - height - 2), ROOM_AREA_Y + 2);
const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ };
roomBlank.doors = generateDoors(roomBlank);
roomBlank.items = generateItems(roomBlank);
const room = new Room(x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items);
rooms.set(id, room);
return room;
}
export const getRoomsCount = () => rooms.size;
export const getPossibleRoomsCount = () => {
const roomsSet = new Set(rooms.keys());
for (const room of rooms.values()) {
room.doors.forEach((d) => {
if (d) {
const id = generateRoomId(d.worldX, d.worldY, d.worldZ);
roomsSet.add(id);
}
});
}
return roomsSet.size;
};
export const getRoomsForLayer = (z: number): Room[] => Array.from(rooms.values()).filter((r) => r.worldZ === z);

View File

@ -0,0 +1,37 @@
import { getRegion, setRegion } from "./display";
import { Figure } from "./figure";
import { PLAYER_SPRITE } from "./images";
import type { IRegion } from "./types";
export class Sprite extends Figure {
private prevX: number;
private prevY: number;
private prevImage: IRegion;
public skipNextBackgroundRestore = false;
constructor(
x: number,
y: number,
) {
super(x, y, PLAYER_SPRITE);
this.prevX = x | 0;
this.prevY = y | 0;
this.prevImage = getRegion(x, y, this.width, this.height);
}
override doDraw() {
if (this.skipNextBackgroundRestore) {
this.skipNextBackgroundRestore = false;
} else {
setRegion(this.prevX, this.prevY, this.width, this.height, this.prevImage);
}
this.prevImage = getRegion(this.x, this.y, this.width, this.height);
super.doDraw();
this.prevX = this.x;
this.prevY = this.y;
}
}

10
src/games/text-dungeon/types.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import { Color } from "const";
export type IColorLike = string | number | Color;
export type IChar = [string, IColorLike?, IColorLike?];
export type IRegion = IChar[][];
export interface ISpriteDefinition {
frames: IRegion[];
animationPeriod?: number;
}

View File

@ -0,0 +1,25 @@
import { randInt } from "@common/utils";
export const randChar = (min = ' ', max = '~') =>
String.fromCharCode(randInt(
min.charCodeAt(0),
max.charCodeAt(0) + 1,
));
export const choice = (array: any[]) => array[randInt(0, array.length)];
export const generateColors = () => {
const colors: string[] = [];
for (let i = 0; i < 16; i++) {
const high = ((i & 0b1000) > 0) ? 'ff' : '7f';
const b = ((i & 0b0001) > 0) ? high : '00';
const g = ((i & 0b0010) > 0) ? high : '00';
const r = ((i & 0b0100) > 0) ? high : '00';
const color = `#${r}${g}${b}`;
colors.push(color);
}
return colors;
};

View File

@ -1,11 +1,10 @@
declare const GAME: string;
async function main() {
const { default: Game }: { default: IGameConstructor } = await import(`./games/${GAME}/game`);
const { default: runGame }: { default: RunGame } = await import(`./games/${GAME}`);
const canvas = document.getElementById('canvas');
if (canvas instanceof HTMLCanvasElement) {
const game = new Game(canvas);
game.run();
await runGame(canvas);
} else {
alert('Something wrong with your canvas!');
}

12
src/types.d.ts vendored
View File

@ -1,13 +1,7 @@
type Point = [number, number];
type Rect = [number, number, number, number];
interface IGame {
run();
}
interface IGameConstructor {
new(canvas: HTMLCanvasElement): IGame;
}
type RunGame = (canvas: HTMLCanvasElement) => Promise<void>;
declare module "*.png" {
const content: string;
@ -16,4 +10,8 @@ declare module "*.png" {
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.module.scss' {
const classes: { [key: string]: string };
export default classes;
}

View File

@ -24,5 +24,8 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"paths": {
"@common/*": ["./src/common/*"]
}
}
}