Add awoodungeon as subproject, add favicons and font styles
This commit is contained in:
parent
ed9ddd5e89
commit
9ae3c40368
|
|
@ -27,6 +27,7 @@
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
$ICON$
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="canvas"></canvas>
|
<canvas id="canvas"></canvas>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const dataUrlPlugin: BunPlugin = {
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contents: `data:image/png;base64,${buffer.toString('base64')}`,
|
contents: `data:;base64,${buffer.toString('base64')}`,
|
||||||
loader: 'text',
|
loader: 'text',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -2,13 +2,17 @@ import path from 'path';
|
||||||
import { minify } from 'html-minifier';
|
import { minify } from 'html-minifier';
|
||||||
|
|
||||||
import dataUrlPlugin from './dataUrlPlugin';
|
import dataUrlPlugin from './dataUrlPlugin';
|
||||||
|
import fontPlugin from './fontPlugin';
|
||||||
import lightningcss from 'bun-lightningcss';
|
import lightningcss from 'bun-lightningcss';
|
||||||
|
|
||||||
import { getGames } from './isGame';
|
import { getGames } from './isGame';
|
||||||
|
|
||||||
|
const transpiler = new Bun.Transpiler();
|
||||||
|
|
||||||
export async function buildHTML(game: string, production = false) {
|
export async function buildHTML(game: string, production = false) {
|
||||||
const html = await Bun.file(path.resolve(import.meta.dir, '..', 'assets', 'index.html')).text();
|
const html = await Bun.file(path.resolve(import.meta.dir, '..', 'assets', 'index.html')).text();
|
||||||
const bundle = await Bun.build({
|
const bundle = await Bun.build({
|
||||||
|
outdir: '/tmp',
|
||||||
entrypoints: [path.resolve(import.meta.dir, '..', 'index.ts')],
|
entrypoints: [path.resolve(import.meta.dir, '..', 'index.ts')],
|
||||||
sourcemap: production ? 'none' : 'inline',
|
sourcemap: production ? 'none' : 'inline',
|
||||||
minify: production,
|
minify: production,
|
||||||
|
|
@ -19,15 +23,27 @@ export async function buildHTML(game: string, production = false) {
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
dataUrlPlugin,
|
dataUrlPlugin,
|
||||||
|
fontPlugin,
|
||||||
lightningcss(),
|
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 script = await bundle.outputs[0].text();
|
||||||
|
|
||||||
const resultHTML = html
|
const resultHTML = html
|
||||||
.replace('$SCRIPT$', `<script>${script}</script>`)
|
.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, {
|
return minify(resultHTML, {
|
||||||
collapseWhitespace: true,
|
collapseWhitespace: true,
|
||||||
|
|
@ -35,6 +51,6 @@ export async function buildHTML(game: string, production = false) {
|
||||||
minifyCSS: true,
|
minifyCSS: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('Multiple assets: ', bundle.outputs, 'or fail: ', !bundle.success, bundle);
|
console.error('Failed: ', !bundle.success, bundle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Binario from "./game";
|
||||||
|
|
||||||
|
export default function runGame(canvas: HTMLCanvasElement) {
|
||||||
|
const game = new Binario(canvas);
|
||||||
|
game.run();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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]: '╬',
|
||||||
|
};
|
||||||
|
|
@ -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 = ' ';
|
||||||
|
} 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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
declare const GAME: string;
|
declare const GAME: string;
|
||||||
|
|
||||||
async function main() {
|
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');
|
const canvas = document.getElementById('canvas');
|
||||||
if (canvas instanceof HTMLCanvasElement) {
|
if (canvas instanceof HTMLCanvasElement) {
|
||||||
const game = new Game(canvas);
|
await runGame(canvas);
|
||||||
game.run();
|
|
||||||
} else {
|
} else {
|
||||||
alert('Something wrong with your canvas!');
|
alert('Something wrong with your canvas!');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
type Point = [number, number];
|
type Point = [number, number];
|
||||||
type Rect = [number, number, number, number];
|
type Rect = [number, number, number, number];
|
||||||
|
|
||||||
interface IGame {
|
type RunGame = (canvas: HTMLCanvasElement) => Promise<void>;
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IGameConstructor {
|
|
||||||
new(canvas: HTMLCanvasElement): IGame;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "*.png" {
|
declare module "*.png" {
|
||||||
const content: string;
|
const content: string;
|
||||||
|
|
@ -17,3 +11,7 @@ declare module '*.module.css' {
|
||||||
const classes: { [key: string]: string };
|
const classes: { [key: string]: string };
|
||||||
export default classes;
|
export default classes;
|
||||||
}
|
}
|
||||||
|
declare module '*.module.scss' {
|
||||||
|
const classes: { [key: string]: string };
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
|
|
@ -24,5 +24,8 @@
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"paths": {
|
||||||
|
"@common/*": ["./src/common/*"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue