1
0
Fork 0

Add TextDisplay with canvas

This commit is contained in:
Pabloader 2026-04-30 15:25:22 +00:00
parent 6b3c0c77a1
commit b549193a3b
17 changed files with 497 additions and 449 deletions

305
src/common/display/text.ts Normal file
View File

@ -0,0 +1,305 @@
import '@common/assets/vga.font.css';
import { randInt } from "@common/utils";
import { createCanvas } from './canvas';
export type IColorLike = string | number | Color;
export type IChar = [string, IColorLike?, IColorLike?];
export type IRegion = IChar[][];
export interface ISpriteDefinition {
frames: IRegion[];
animationPeriod?: number;
}
export const randChar = (min = ' ', max = '~') =>
String.fromCharCode(randInt(
min.charCodeAt(0),
max.charCodeAt(0) + 1,
));
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;
};
const COLORS = generateColors();
export const GAME_WIDTH = 80;
export const GAME_HEIGHT = 25;
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 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;
}
const CHAR_W = 8;
const CHAR_H = 16;
const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`;
const FALLBACK_FONT = `${CHAR_H}px monospace`;
const colorToCSS = (c: IColorLike): string =>
typeof c === 'number' ? COLORS[c] : c as string;
export class TextDisplay {
private chars: string[];
private fgs: IColorLike[];
private bgs: IColorLike[];
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private font = FALLBACK_FONT;
private letterboxColor: string;
constructor(
public width = GAME_WIDTH,
public height = GAME_HEIGHT,
parent?: HTMLCanvasElement,
letterboxColor: IColorLike = Color.BLACK,
) {
this.letterboxColor = colorToCSS(letterboxColor);
const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H);
canvas.width = width * CHAR_W;
canvas.height = height * CHAR_H;
canvas.style.imageRendering = 'pixelated';
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.ctx.textBaseline = 'top';
const size = width * height;
this.chars = Array.from({ length: size }, () => randChar('!'));
this.fgs = new Array(size).fill(COLORS[7]);
this.bgs = new Array(size).fill(COLORS[0]);
window.addEventListener('resize', () => this.updateScale());
this.updateScale();
Promise.race([
document.fonts.load(NATIVE_FONT).then(() => true).catch(() => false),
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 1000)),
]).then(loaded => {
this.font = loaded ? NATIVE_FONT : FALLBACK_FONT;
this.redraw();
});
}
private updateScale() {
const scale = Math.max(1, Math.min(
Math.floor(window.innerWidth / (this.width * CHAR_W)),
Math.floor(window.innerHeight / (this.height * CHAR_H)),
));
this.canvas.style.width = `${this.width * CHAR_W * scale}px`;
this.canvas.style.height = `${this.height * CHAR_H * scale}px`;
document.body.style.background = this.letterboxColor;
}
private redraw() {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
this.drawCell(x, y);
}
}
}
private drawCell(x: number, y: number) {
const i = y * this.width + x;
const px = x * CHAR_W;
const py = y * CHAR_H;
this.ctx.fillStyle = colorToCSS(this.bgs[i]);
this.ctx.fillRect(px, py, CHAR_W, CHAR_H);
const char = this.chars[i];
if (char !== ' ') {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(px, py, CHAR_W, CHAR_H);
this.ctx.clip();
this.ctx.font = this.font;
this.ctx.fillStyle = colorToCSS(this.fgs[i]);
this.ctx.fillText(char, px, py);
this.ctx.restore();
}
}
private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) {
if (x < 0 || y < 0 || y >= this.height || x >= this.width || !char) return;
const i = (y | 0) * this.width + (x | 0);
if (this.chars[i] === char && this.fgs[i] === fg && this.bgs[i] === bg) return;
this.chars[i] = char;
this.fgs[i] = fg;
this.bgs[i] = bg;
this.drawCell(x | 0, y | 0);
}
setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) {
this.setCharRaw(x, y, char, fg, bg);
}
getChar(x: number, y: number): IChar {
if (x < 0 || y < 0 || y >= this.height || x >= this.width) {
return [' ', COLORS[0], COLORS[1]];
}
const i = (y | 0) * this.width + (x | 0);
return [this.chars[i], this.fgs[i], this.bgs[i]];
}
setRegion(x: number, y: number, w: number, h: number, region: IRegion) {
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const [char, fg = 'white', bg = 'black'] = region[row][col];
this.setCharRaw(x + col, y + row, char, fg, bg);
}
}
}
getRegion(x: number, y: number, w: number, h: number) {
const region: IRegion = [];
for (let row = 0; row < h; row++) {
const line: IChar[] = [];
for (let col = 0; col < w; col++) {
line.push(this.getChar(x + col, y + row));
}
region.push(line);
}
return region;
}
drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) {
const lines = text.split('\n');
for (let row = 0; row < lines.length; row++) {
const line = lines[row];
const ry = y + row;
if (ry < 0 || ry >= this.height) continue;
for (let col = 0; col < line.length; col++) {
this.setCharRaw(x + col, ry, line[col], fg, bg);
}
}
}
drawVLine(x: number, y1: number, y2: number, [char, fg = 'white', bg = 'black']: IChar = ['│']) {
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
y1 = Math.max(0, y1);
y2 = Math.min(this.height - 1, y2);
if (x < 0 || x >= this.width) return;
for (let y = y1; y <= y2; y++) {
this.setCharRaw(x, y, char, fg, bg);
}
}
drawHLine(x1: number, x2: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['─']) {
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
x1 = Math.max(0, x1);
x2 = Math.min(this.width - 1, x2);
if (y < 0 || y >= this.height) return;
for (let x = x1; x <= x2; x++) {
this.setCharRaw(x, y, char, fg, bg);
}
}
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;
this.setCharRaw(x, y, topLeft, fg, bg);
this.setCharRaw(x + width + 1, y, topRight, fg, bg);
this.setCharRaw(x, y + height + 1, bottomLeft, fg, bg);
this.setCharRaw(x + width + 1, y + height + 1, bottomRight, fg, bg);
if (fill) {
this.fillBox(x + 1, y + 1, width, height, fill);
}
this.drawHLine(x + 1, x + width, y, [horizontal, fg, bg]);
this.drawHLine(x + 1, x + width, y + height + 1, [horizontal, fg, bg]);
this.drawVLine(x, y + 1, y + height, [vertical, fg, bg]);
this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);
if (title) {
this.drawText(x + 1, y, title, fg, bg);
}
}
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;
}
this.drawBox(x, y, width, height, { ...options, fill: [' '] });
this.drawText(x + 1, y + 1, text, fg, bg);
}
fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) {
for (let i = y; i < y + height; i++) {
this.drawHLine(x, x + width - 1, i, char);
}
}
}

View File

@ -49,7 +49,7 @@ namespace Input {
const KEYS: Partial<Record<KeyCode, IKeyState>> = {}; const KEYS: Partial<Record<KeyCode, IKeyState>> = {};
const onStateChange = (keyId: KeyCode, state: boolean) => { const onStateChange = (keyId: KeyCode, state: boolean) => {
console.debug(`[Input] Pressed ${keyId}`); console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`);
if (KEYS[keyId]) { if (KEYS[keyId]) {
KEYS[keyId].state = state; KEYS[keyId].state = state;
} else { } else {

View File

@ -37,10 +37,12 @@ interface EquipmentState {
slots: Record<string, SlotRecord>; slots: Record<string, SlotRecord>;
} }
export type SlotInput = type SlotDefinition =
| string // generic slot | string // generic slot
| { slotName: string; type?: string }; // typed slot | { slotName: string; type?: string }; // typed slot
export type SlotInput = SlotDefinition | SlotDefinition[];
@component @component
export class Equipment extends Component<EquipmentState> { export class Equipment extends Component<EquipmentState> {
#cachedVars: RPGVariables | null = null; #cachedVars: RPGVariables | null = null;

View File

@ -1,26 +0,0 @@
import { createCanvas } from "@common/display/canvas";
import { gameLoop } from "@common/game";
type State = ReturnType<typeof setup>;
const setup = () => {
const canvas = createCanvas(800, 600);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
return {
canvas,
ctx,
};
};
const frame = (dt: number, state: State) => {
const { ctx } = state;
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 800, 600);
};
export default gameLoop(setup, frame);

View File

@ -65,10 +65,10 @@ export const getOppositeDirection = (d: Direction): Direction => {
export const MAP_ROOM_CHARS: Record<number, string> = { export const MAP_ROOM_CHARS: Record<number, string> = {
[0b0000]: ' ', [0b0000]: ' ',
[0b0001]: '', [0b0001]: '',
[0b0010]: '', [0b0010]: '',
[0b0100]: '', [0b0100]: '',
[0b1000]: '', [0b1000]: '',
[0b0011]: '╚', [0b0011]: '╚',
[0b0110]: '╔', [0b0110]: '╔',
[0b1100]: '╗', [0b1100]: '╗',

View File

@ -1,230 +0,0 @@
import { 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 Bun.sleep(remainingDelay);
}
}
window.addEventListener('resize', handleResize);
handleResize();

View File

@ -1,6 +1,9 @@
import type { TextDisplay } from "@common/display/text";
export abstract class Drawable { export abstract class Drawable {
protected dirty: boolean = true; protected dirty: boolean = true;
abstract doDraw(): void; abstract doDraw(): void;
constructor(protected display: TextDisplay) { }
draw() { draw() {
if (this.dirty) { if (this.dirty) {

View File

@ -1,4 +1,4 @@
import { setRegion } from "./display"; import type { TextDisplay } from "@common/display/text";
import { Drawable } from "./drawable"; import { Drawable } from "./drawable";
import type { IRegion, ISpriteDefinition } from "./types"; import type { IRegion, ISpriteDefinition } from "./types";
@ -8,12 +8,13 @@ export class Figure extends Drawable {
private animationPeriod; private animationPeriod;
constructor( constructor(
display: TextDisplay,
public x: number, public x: number,
public y: number, public y: number,
definition: ISpriteDefinition, definition: ISpriteDefinition,
public frame: number = 0, public frame: number = 0,
) { ) {
super(); super(display);
this.x = x | 0; this.x = x | 0;
this.y = y | 0; this.y = y | 0;
this.frames = definition.frames; this.frames = definition.frames;
@ -28,7 +29,7 @@ export class Figure extends Drawable {
this.frame = (this.frame + 1) % this.numFrames; this.frame = (this.frame + 1) % this.numFrames;
} }
setRegion(this.x, this.y, this.width, this.height, this.image); this.display.setRegion(this.x, this.y, this.width, this.height, this.image);
} }
get width() { get width() {

View File

@ -1,141 +0,0 @@
import { Color, Direction, getOppositeDirection } from './const';
import { drawTextInBox, tick } from './display';
import Input 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 = Input.isPressed(Input.KeyCode.SPACE);
if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return;
lastMove = Date.now();
let newX = player.x;
let newY = player.y;
let moved = isSpacePressed;
if (Input.isHeld(Input.KeyCode.UP)) {
newY--;
moved = true;
}
if (Input.isHeld(Input.KeyCode.DOWN)) {
newY++;
moved = true;
}
if (Input.isHeld(Input.KeyCode.LEFT)) {
newX--;
moved = true;
}
if (Input.isHeld(Input.KeyCode.RIGHT)) {
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) {
Input.updateKeys();
handleInput();
handleLogic();
draw();
await tick(60);
}
}

View File

@ -1,15 +1,144 @@
import '@common/assets/vga.font.css'; import { TextDisplay } from '@common/display/text';
import Input from '@common/input';
import { nextFrame } from '@common/utils';
import './assets/style.css'; import './assets/style.css';
import { Color, Direction, getOppositeDirection } from './const';
import { createItems, getItemsCount } from './item';
import { GameMap } from './map';
import { Player } from './player';
import { getPossibleRoomsCount, getRoom, getRoomsCount } from './room';
export default async function run() { const display = new TextDisplay();
const root = document.createElement('div'); let currentRoom = getRoom(display, 0, 0, 0);
root.id = 'root'; const map = new GameMap(display, currentRoom);
document.body.append(root); currentRoom.draw();
const player = new Player(display, currentRoom.x + currentRoom.width / 2, currentRoom.y + currentRoom.height / 2);
const fps = document.createElement('div'); let lastMove = Date.now();
fps.id = 'fps'; function handleInput() {
document.body.append(fps); const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE);
const { main } = await import('./game'); if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return;
await main(); lastMove = Date.now();
let newX = player.x;
let newY = player.y;
let moved = isSpacePressed;
if (Input.isHeld(Input.KeyCode.UP)) {
newY--;
moved = true;
}
if (Input.isHeld(Input.KeyCode.DOWN)) {
newY++;
moved = true;
}
if (Input.isHeld(Input.KeyCode.LEFT)) {
newX--;
moved = true;
}
if (Input.isHeld(Input.KeyCode.RIGHT)) {
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(display, 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);
display.drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' });
}
function draw() {
currentRoom.draw();
map.draw();
player.draw();
drawInfo();
}
export default async function main() {
createItems(display);
currentRoom.invalidate();
player.invalidate();
display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN });
while (true) {
Input.updateKeys();
handleInput();
handleLogic();
draw();
await nextFrame();
}
} }

View File

@ -1,3 +1,4 @@
import type { TextDisplay } from "@common/display/text";
import { Drawable } from "./drawable"; import { Drawable } from "./drawable";
import { Figure } from "./figure"; import { Figure } from "./figure";
import { ITEM_KEY_SPRITE } from "./images"; import { ITEM_KEY_SPRITE } from "./images";
@ -7,23 +8,24 @@ const idMap = new Map<number, Figure>();
export const getItemsCount = () => idMap.size; export const getItemsCount = () => idMap.size;
export function createItems() { export function createItems(display: TextDisplay) {
for (let frame = 0; frame < ITEM_KEY_SPRITE.frames.length; frame++) { for (let frame = 0; frame < ITEM_KEY_SPRITE.frames.length; frame++) {
idMap.set( idMap.set(
globalItemId++, globalItemId++,
new Figure(0, 0, ITEM_KEY_SPRITE, frame), new Figure(display, 0, 0, ITEM_KEY_SPRITE, frame),
); );
} }
} }
export class Item extends Drawable { export class Item extends Drawable {
constructor( constructor(
display: TextDisplay,
public id: number, public id: number,
public count: number = 1, public count: number = 1,
public x: number = -1, public x: number = -1,
public y: number = -1, public y: number = -1,
) { ) {
super(); super(display);
} }
override doDraw() { override doDraw() {

View File

@ -1,15 +1,15 @@
import type { TextDisplay } from "@common/display/text";
import { Color, MAP_HEIGHT, MAP_ROOM_CHARS, MAP_WIDTH, MAP_X, MAP_Y } from "./const"; 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 { Drawable } from "./drawable";
import { getRoomsForLayer, Room } from "./room"; import { getRoomsForLayer, Room } from "./room";
export class GameMap extends Drawable { export class GameMap extends Drawable {
constructor(public currentRoom: Room) { constructor(display: TextDisplay, public currentRoom: Room) {
super(); super(display);
} }
override doDraw() { override doDraw() {
drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' }); this.display.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 centerX = this.currentRoom.worldX - MAP_X - MAP_WIDTH / 2;
const centerY = this.currentRoom.worldY - MAP_Y - MAP_HEIGHT / 2; const centerY = this.currentRoom.worldY - MAP_Y - MAP_HEIGHT / 2;
@ -19,7 +19,7 @@ export class GameMap extends Drawable {
if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) { if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) {
const char = getMapRoomChar(room); const char = getMapRoomChar(room);
setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]); this.display.setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]);
} }
} }
} }

View File

@ -1,5 +1,4 @@
import { INVENTORY_X, INVENTORY_Y } from "./const"; import { INVENTORY_X, INVENTORY_Y } from "./const";
import { drawBox } from "./display";
import { getItemsCount, Item } from "./item"; import { getItemsCount, Item } from "./item";
import { Sprite } from "./sprite"; import { Sprite } from "./sprite";
@ -14,6 +13,7 @@ export class Player extends Sprite {
} }
} }
this.inventory.push(new Item( this.inventory.push(new Item(
this.display,
item.id, item.id,
item.count, item.count,
INVENTORY_X + 1 + this.inventory.length, INVENTORY_X + 1 + this.inventory.length,
@ -37,7 +37,7 @@ export class Player extends Sprite {
override doDraw() { override doDraw() {
super.doDraw(); super.doDraw();
drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' }); this.display.drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' });
this.inventory.forEach((item) => { this.inventory.forEach((item) => {
if (item.count > 0) { if (item.count > 0) {
item.doDraw(); item.doDraw();

View File

@ -1,15 +1,16 @@
import { randInt } from "@common/utils"; 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 { 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 { Drawable } from "./drawable";
import { Figure } from "./figure"; import { Figure } from "./figure";
import { DOOR_SPRITE } from "./images"; import { DOOR_SPRITE } from "./images";
import { getItemsCount, Item } from "./item"; import { getItemsCount, Item } from "./item";
import type { TextDisplay } from "@common/display/text";
type IDoors = [Door | null, Door | null, Door | null, Door | null, Door | null, Door | null]; type IDoors = [Door | null, Door | null, Door | null, Door | null, Door | null, Door | null];
export class Door extends Figure { export class Door extends Figure {
constructor( constructor(
display: TextDisplay,
x: number, x: number,
y: number, y: number,
direction: Direction, direction: Direction,
@ -17,7 +18,7 @@ export class Door extends Figure {
public worldY: number, public worldY: number,
public worldZ: number, public worldZ: number,
) { ) {
super(x, y, DOOR_SPRITE); super(display, x, y, DOOR_SPRITE);
this.frame = direction; this.frame = direction;
} }
@ -35,6 +36,7 @@ export class Door extends Figure {
export class Room extends Drawable { export class Room extends Drawable {
constructor( constructor(
display: TextDisplay,
public readonly x: number, public readonly x: number,
public readonly y: number, public readonly y: number,
public readonly width: number, public readonly width: number,
@ -45,12 +47,12 @@ export class Room extends Drawable {
public readonly doors: IDoors = [null, null, null, null, null, null], public readonly doors: IDoors = [null, null, null, null, null, null],
public readonly items: Item[] = [], public readonly items: Item[] = [],
) { ) {
super(); super(display);
} }
override doDraw() { override doDraw() {
drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] }); this.display.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.display.drawBox(this.x, this.y, this.width, this.height, { fill: ['.', Color.GRAY] });
this.doors.forEach((door) => { this.doors.forEach((door) => {
if (door) { if (door) {
@ -84,7 +86,7 @@ const rooms = new Map<string, Room>();
const generateRoomId = (worldX: number, worldY: number, worldZ: number) => `${worldX},${worldY},${worldZ}`; const generateRoomId = (worldX: number, worldY: number, worldZ: number) => `${worldX},${worldY},${worldZ}`;
const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => { const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => {
const doors: IDoors = [null, null, null, null, null, null]; const doors: IDoors = [null, null, null, null, null, null];
if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') { if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') {
console.error('World coordinates not defined for doors generation'); console.error('World coordinates not defined for doors generation');
@ -116,7 +118,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla
case Direction.EAST: dx = x + width + 1; dy = doorY; break; case Direction.EAST: dx = x + width + 1; dy = doorY; break;
case Direction.WEST: dx = x; dy = doorY; break; case Direction.WEST: dx = x; dy = doorY; break;
} }
doors[i] = new Door(dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ); doors[i] = new Door(display, dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ);
} }
} }
@ -124,7 +126,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla
const doorX = randInt(x + 1, x + width - 2); const doorX = randInt(x + 1, x + width - 2);
const doorY = randInt(y + 1, y + height - 2); const doorY = randInt(y + 1, y + height - 2);
const [, , zoff] = DIRECTION_OFFSETS[Direction.DOWN]; const [, , zoff] = DIRECTION_OFFSETS[Direction.DOWN];
doors[Direction.DOWN] = new Door(doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff); doors[Direction.DOWN] = new Door(display, doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff);
if (worldZ !== 0) { if (worldZ !== 0) {
let upDoorX: number; let upDoorX: number;
@ -134,7 +136,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla
upDoorY = randInt(y + 1, y + height - 2); upDoorY = randInt(y + 1, y + height - 2);
} while (doorX === upDoorX && doorY === upDoorY); } while (doorX === upDoorX && doorY === upDoorY);
const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP]; const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP];
doors[Direction.UP] = new Door(upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff); doors[Direction.UP] = new Door(display, upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff);
} }
} }
@ -143,7 +145,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla
const generatedItems = new Set<number>(); const generatedItems = new Set<number>();
const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => { const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRoomBlank): Item[] => {
const items: Item[] = []; const items: Item[] = [];
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') { if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') {
console.error('Screen coordinates not defined for items generation'); console.error('Screen coordinates not defined for items generation');
@ -170,7 +172,7 @@ const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => {
newItemX = randInt(x + 1, x + width - 2); newItemX = randInt(x + 1, x + width - 2);
newItemY = randInt(y + 1, y + height - 2); newItemY = randInt(y + 1, y + height - 2);
} while (hasObjectAt(newItemX, newItemY)); } while (hasObjectAt(newItemX, newItemY));
const item = new Item(id, 1, newItemX, newItemY); const item = new Item(display, id, 1, newItemX, newItemY);
items.push(item); items.push(item);
generatedItems.add(id); generatedItems.add(id);
@ -179,7 +181,7 @@ const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => {
return items; return items;
}; };
export function getRoom(worldX: number, worldY: number, worldZ: number) { export function getRoom(display: TextDisplay, worldX: number, worldY: number, worldZ: number) {
const id = generateRoomId(worldX, worldY, worldZ); const id = generateRoomId(worldX, worldY, worldZ);
const existingRoom = rooms.get(id); const existingRoom = rooms.get(id);
if (existingRoom) { if (existingRoom) {
@ -193,10 +195,10 @@ export function getRoom(worldX: number, worldY: number, worldZ: number) {
const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ }; const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ };
roomBlank.doors = generateDoors(roomBlank); roomBlank.doors = generateDoors(display, roomBlank);
roomBlank.items = generateItems(roomBlank); roomBlank.items = generateItems(display, roomBlank);
const room = new Room(x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items); const room = new Room(display, x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items);
rooms.set(id, room); rooms.set(id, room);
return room; return room;

View File

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

View File

@ -1,4 +1,4 @@
import { Color } from "const"; import { Color } from "./const";
export type IColorLike = string | number | Color; export type IColorLike = string | number | Color;
export type IChar = [string, IColorLike?, IColorLike?]; export type IChar = [string, IColorLike?, IColorLike?];

View File

@ -225,7 +225,7 @@ describe('Inventory — equip', () => {
sword.add('equippable', new Equippable('weapon')); sword.add('equippable', new Equippable('weapon'));
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 })); player.add('str', new Stat({ value: 10 }));
player.add('equipment', new Equipment([{ slotName: 'weapon', type: 'weapon' }])); player.add('equipment', new Equipment({ slotName: 'weapon', type: 'weapon' }));
player.add('inv', new Inventory()); player.add('inv', new Inventory());
const inv = player.get(Inventory)!; const inv = player.get(Inventory)!;
inv.add({ itemId: 'sword', amount: 1 }); inv.add({ itemId: 'sword', amount: 1 });