Optimize text display
This commit is contained in:
parent
7956d2bb51
commit
738272f2d4
|
|
@ -2,10 +2,11 @@ import '@common/assets/fonts/vga.font.css';
|
||||||
import { randInt } from "@common/utils";
|
import { randInt } from "@common/utils";
|
||||||
import { createCanvas } from './canvas';
|
import { createCanvas } from './canvas';
|
||||||
import type { Rect } from '@common/geometry';
|
import type { Rect } from '@common/geometry';
|
||||||
|
import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham';
|
||||||
|
|
||||||
export type IColorLike = string | number | Color;
|
export type ColorLike = string | number | Color;
|
||||||
export type IChar = [string, IColorLike?, IColorLike?] | string;
|
export type Char = [string, ColorLike?, ColorLike?] | string;
|
||||||
export type IDefinedChar = [string, IColorLike, IColorLike];
|
export type DefinedChar = [string, ColorLike, ColorLike];
|
||||||
|
|
||||||
export const randChar = (min = ' ', max = '~') =>
|
export const randChar = (min = ' ', max = '~') =>
|
||||||
String.fromCharCode(randInt(
|
String.fromCharCode(randInt(
|
||||||
|
|
@ -68,16 +69,16 @@ export const isVertical = (char: string) => char === '│';
|
||||||
export const isHorizontal = (char: string) => char === '─';
|
export const isHorizontal = (char: string) => char === '─';
|
||||||
export const isCorner = (char: string) => '┌┐└┘'.includes(char);
|
export const isCorner = (char: string) => '┌┐└┘'.includes(char);
|
||||||
|
|
||||||
interface IBoxOptions {
|
interface BoxOptions {
|
||||||
vertical?: string;
|
vertical?: string;
|
||||||
horizontal?: string;
|
horizontal?: string;
|
||||||
topLeft?: string;
|
topLeft?: string;
|
||||||
topRight?: string;
|
topRight?: string;
|
||||||
bottomLeft?: string;
|
bottomLeft?: string;
|
||||||
bottomRight?: string;
|
bottomRight?: string;
|
||||||
fill?: IChar;
|
fill?: Char;
|
||||||
fg?: IColorLike;
|
fg?: ColorLike;
|
||||||
bg?: IColorLike;
|
bg?: ColorLike;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,23 +88,23 @@ const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`;
|
||||||
const FALLBACK_FONT = `${CHAR_H}px monospace`;
|
const FALLBACK_FONT = `${CHAR_H}px monospace`;
|
||||||
const REGION_DATA = Symbol('TextRegion.data');
|
const REGION_DATA = Symbol('TextRegion.data');
|
||||||
|
|
||||||
const colorToCSS = (c: IColorLike): string =>
|
const colorToCSS = (c: ColorLike): string =>
|
||||||
typeof c === 'number' ? COLORS[c] : c as string;
|
typeof c === 'number' ? COLORS[c] : c as string;
|
||||||
|
|
||||||
const parseChar = (char: IChar): IDefinedChar => (
|
const parseChar = (char: Char, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG): DefinedChar => (
|
||||||
typeof char === 'string'
|
typeof char === 'string'
|
||||||
? [char, DEFAULT_FG, DEFAULT_BG]
|
? [char, fg, bg]
|
||||||
: [
|
: [
|
||||||
char[0],
|
char[0],
|
||||||
char[1] ?? DEFAULT_FG,
|
char[1] ?? fg,
|
||||||
char[2] ?? DEFAULT_BG,
|
char[2] ?? bg,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export class TextDisplay {
|
export class TextDisplay {
|
||||||
private chars: string[];
|
private chars: string[];
|
||||||
private fgs: IColorLike[];
|
private fgs: ColorLike[];
|
||||||
private bgs: IColorLike[];
|
private bgs: ColorLike[];
|
||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
private ctx: CanvasRenderingContext2D;
|
private ctx: CanvasRenderingContext2D;
|
||||||
private font = FALLBACK_FONT;
|
private font = FALLBACK_FONT;
|
||||||
|
|
@ -112,12 +113,13 @@ export class TextDisplay {
|
||||||
private clipTop: number = 0;
|
private clipTop: number = 0;
|
||||||
private clipRight: number;
|
private clipRight: number;
|
||||||
private clipBottom: number;
|
private clipBottom: number;
|
||||||
|
private dirtySet = new Set<number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public width = GAME_WIDTH,
|
public width = GAME_WIDTH,
|
||||||
public height = GAME_HEIGHT,
|
public height = GAME_HEIGHT,
|
||||||
parent?: HTMLCanvasElement,
|
parent?: HTMLCanvasElement,
|
||||||
letterboxColor: IColorLike = DEFAULT_BG,
|
letterboxColor: ColorLike = DEFAULT_BG,
|
||||||
) {
|
) {
|
||||||
this.letterboxColor = colorToCSS(letterboxColor);
|
this.letterboxColor = colorToCSS(letterboxColor);
|
||||||
const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H);
|
const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H);
|
||||||
|
|
@ -158,6 +160,14 @@ export class TextDisplay {
|
||||||
document.body.style.background = this.letterboxColor;
|
document.body.style.background = this.letterboxColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private redrawDirty() {
|
||||||
|
for (const i of this.dirtySet) {
|
||||||
|
const x = i % this.width;
|
||||||
|
const y = Math.floor(i / this.width);
|
||||||
|
this.drawCell(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private redraw() {
|
private redraw() {
|
||||||
for (let y = 0; y < this.height; y++) {
|
for (let y = 0; y < this.height; y++) {
|
||||||
for (let x = 0; x < this.width; x++) {
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
|
@ -166,6 +176,10 @@ export class TextDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dirtyCell(x: number, y: number) {
|
||||||
|
this.dirtySet.add(y * this.width + x);
|
||||||
|
}
|
||||||
|
|
||||||
private drawCell(x: number, y: number) {
|
private drawCell(x: number, y: number) {
|
||||||
const i = y * this.width + x;
|
const i = y * this.width + x;
|
||||||
const px = x * CHAR_W;
|
const px = x * CHAR_W;
|
||||||
|
|
@ -185,9 +199,10 @@ export class TextDisplay {
|
||||||
this.ctx.fillText(char, px, py);
|
this.ctx.fillText(char, px, py);
|
||||||
this.ctx.restore();
|
this.ctx.restore();
|
||||||
}
|
}
|
||||||
|
this.dirtySet.delete(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) {
|
private setCharRaw(x: number, y: number, char: string, fg: ColorLike, bg: ColorLike) {
|
||||||
if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) return;
|
if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) return;
|
||||||
if (!char || char === '\0') return;
|
if (!char || char === '\0') return;
|
||||||
|
|
||||||
|
|
@ -206,15 +221,15 @@ export class TextDisplay {
|
||||||
dirty = true;
|
dirty = true;
|
||||||
}
|
}
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
this.drawCell(x | 0, y | 0);
|
this.dirtyCell(x | 0, y | 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setChar(x: number, y: number, char: IChar = '█') {
|
setChar(x: number, y: number, char: Char = '█') {
|
||||||
this.setCharRaw(x | 0, y | 0, ...parseChar(char));
|
this.setCharRaw(x | 0, y | 0, ...parseChar(char));
|
||||||
}
|
}
|
||||||
|
|
||||||
getChar(x: number, y: number): IDefinedChar {
|
getChar(x: number, y: number): DefinedChar {
|
||||||
x = x | 0; y = y | 0;
|
x = x | 0; y = y | 0;
|
||||||
if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) {
|
if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) {
|
||||||
return [' ', DEFAULT_FG, DEFAULT_BG];
|
return [' ', DEFAULT_FG, DEFAULT_BG];
|
||||||
|
|
@ -239,53 +254,54 @@ export class TextDisplay {
|
||||||
const regRow = row - y;
|
const regRow = row - y;
|
||||||
const regBase = regRow * rw + regColOffset;
|
const regBase = regRow * rw + regColOffset;
|
||||||
const dispBase = row * this.width + x0;
|
const dispBase = row * this.width + x0;
|
||||||
const regRowStr = chars[regRow];
|
|
||||||
for (let i = 0; i < copyW; i++) {
|
for (let i = 0; i < copyW; i++) {
|
||||||
const ch = regRowStr[regColOffset + i];
|
const ch = chars[regBase + i];
|
||||||
if (ch === '\0') continue;
|
if (ch === '\0') continue;
|
||||||
this.chars[dispBase + i] = ch;
|
let dirty = false;
|
||||||
this.fgs[dispBase + i] = fgs[regBase + i];
|
if (ch !== this.chars[dispBase + i]) {
|
||||||
this.bgs[dispBase + i] = bgs[regBase + i];
|
dirty = true;
|
||||||
this.drawCell(x0 + i, row);
|
this.chars[dispBase + i] = ch;
|
||||||
|
}
|
||||||
|
if (this.fgs[dispBase + i] !== fgs[regBase + i]) {
|
||||||
|
this.fgs[dispBase + i] = fgs[regBase + i];
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (this.bgs[dispBase + i] !== bgs[regBase + i]) {
|
||||||
|
this.bgs[dispBase + i] = bgs[regBase + i];
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
this.dirtyCell(x0 + i, row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getRegion(x: number, y: number, w: number, h: number): TextRegion {
|
getRegion(x: number, y: number, w: number, h: number): TextRegion {
|
||||||
x = x | 0; y = y | 0; w = w | 0; h = h | 0;
|
x = x | 0; y = y | 0; w = w | 0; h = h | 0;
|
||||||
const rows: string[] = [];
|
const data: DefinedChar[][] = Array(h);
|
||||||
const fgs: IColorLike[][] = [];
|
|
||||||
const bgs: IColorLike[][] = [];
|
|
||||||
|
|
||||||
for (let row = 0; row < h; row++) {
|
for (let row = 0; row < h; row++) {
|
||||||
const dispRow = y + row;
|
const dispRow = y + row;
|
||||||
let rowStr = '';
|
const charsRow: DefinedChar[] = Array(w);
|
||||||
const fgRow: IColorLike[] = [];
|
|
||||||
const bgRow: IColorLike[] = [];
|
|
||||||
for (let col = 0; col < w; col++) {
|
for (let col = 0; col < w; col++) {
|
||||||
const dispCol = x + col;
|
const dispCol = x + col;
|
||||||
if (dispCol < this.clipLeft || dispRow < this.clipTop || dispCol >= this.clipRight || dispRow >= this.clipBottom) {
|
if (dispCol < this.clipLeft || dispRow < this.clipTop || dispCol >= this.clipRight || dispRow >= this.clipBottom) {
|
||||||
rowStr += ' ';
|
charsRow[col] = ['\0', DEFAULT_FG, DEFAULT_BG];
|
||||||
fgRow.push(DEFAULT_FG);
|
|
||||||
bgRow.push(DEFAULT_BG);
|
|
||||||
} else {
|
} else {
|
||||||
const i = dispRow * this.width + dispCol;
|
const i = dispRow * this.width + dispCol;
|
||||||
rowStr += this.chars[i];
|
charsRow[col] = [this.chars[i], this.fgs[i], this.bgs[i]]
|
||||||
fgRow.push(this.fgs[i]);
|
|
||||||
bgRow.push(this.bgs[i]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.push(rowStr);
|
data[row] = charsRow;
|
||||||
fgs.push(fgRow);
|
|
||||||
bgs.push(bgRow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TextRegion(rows, fgs, bgs);
|
return new TextRegion(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawText(x: number, y: number, text: string, fg: IColorLike = DEFAULT_FG, bg: IColorLike = DEFAULT_BG) {
|
drawString(text: unknown, x: number, y: number, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG) {
|
||||||
x = x | 0; y = y | 0;
|
x = x | 0; y = y | 0;
|
||||||
const lines = text.split('\n');
|
const lines = String(text).split('\n');
|
||||||
for (let row = 0; row < lines.length; row++) {
|
for (let row = 0; row < lines.length; row++) {
|
||||||
const line = lines[row];
|
const line = lines[row];
|
||||||
const ry = y + row;
|
const ry = y + row;
|
||||||
|
|
@ -296,7 +312,7 @@ export class TextDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawVLine(x: number, y1: number, y2: number, char: IChar = '│') {
|
drawVLine(x: number, y1: number, y2: number, char: Char = '│') {
|
||||||
x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
|
x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
|
||||||
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
|
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
|
||||||
|
|
||||||
|
|
@ -310,7 +326,7 @@ export class TextDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawHLine(x1: number, x2: number, y: number, char: IChar = '─') {
|
drawHLine(x1: number, x2: number, y: number, char: Char = '─') {
|
||||||
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
|
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
|
||||||
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
|
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
|
||||||
x1 = Math.max(this.clipLeft, x1);
|
x1 = Math.max(this.clipLeft, x1);
|
||||||
|
|
@ -323,7 +339,7 @@ export class TextDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) {
|
drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) {
|
||||||
x = x | 0; y = y | 0;
|
x = x | 0; y = y | 0;
|
||||||
const {
|
const {
|
||||||
vertical = '│',
|
vertical = '│',
|
||||||
|
|
@ -354,35 +370,67 @@ export class TextDisplay {
|
||||||
this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);
|
this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
this.drawText(x + 1, y, title, fg, bg);
|
this.drawString(title, x + 1, y, fg, bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) {
|
drawStringInBox(text: unknown, x: number, y: number, options: BoxOptions = {}) {
|
||||||
x = x | 0; y = y | 0;
|
x = x | 0; y = y | 0;
|
||||||
const {
|
const {
|
||||||
fg = DEFAULT_FG,
|
fg = DEFAULT_FG,
|
||||||
bg = DEFAULT_BG,
|
bg = DEFAULT_BG,
|
||||||
} = options;
|
} = options;
|
||||||
let width = 0;
|
const lines = String(text).split('\n');
|
||||||
const lines = text.split('\n');
|
const width = lines.reduce((m, line) => Math.max(m, line.length), 0);
|
||||||
const height = lines.length;
|
const height = lines.length;
|
||||||
|
|
||||||
for (const line of lines) {
|
this.drawBox(x, y, width, height, { ...options, fill: ' ' });
|
||||||
if (line.length > width) width = line.length;
|
this.drawString(text, x + 1, y + 1, fg, bg);
|
||||||
}
|
|
||||||
|
|
||||||
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 = '█') {
|
fillBox(x: number, y: number, width: number, height: number, char: Char = '█') {
|
||||||
x = x | 0; y = y | 0;
|
x = x | 0; y = y | 0;
|
||||||
for (let i = y; i < y + height; i++) {
|
for (let i = y; i < y + height; i++) {
|
||||||
this.drawHLine(x, x + width - 1, i, char);
|
this.drawHLine(x, x + width - 1, i, char);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = '█') {
|
||||||
|
const [ch, fg, bg] = parseChar(char);
|
||||||
|
const options: BresenhamLineOptions = {
|
||||||
|
minX: this.clipLeft,
|
||||||
|
minY: this.clipTop,
|
||||||
|
maxX: this.clipRight - 1,
|
||||||
|
maxY: this.clipBottom - 1,
|
||||||
|
directions: 8,
|
||||||
|
}
|
||||||
|
for (const { x, y } of bresenhamLineGen(fromX, fromY, toX, toY, options)) {
|
||||||
|
this.setCharRaw(x, y, ch, fg, bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#circle(cx: number, cy: number, radius: number, char: Char, fill: boolean) {
|
||||||
|
const [ch, fg, bg] = parseChar(char);
|
||||||
|
const options: BresenhamCircleOptions = {
|
||||||
|
minX: this.clipLeft,
|
||||||
|
minY: this.clipTop,
|
||||||
|
maxX: this.clipRight - 1,
|
||||||
|
maxY: this.clipBottom - 1,
|
||||||
|
fill,
|
||||||
|
}
|
||||||
|
for (const { x, y } of bresenhamCircleGen(cx, cy, radius, options)) {
|
||||||
|
this.setCharRaw(x, y, ch, fg, bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCircle(cx: number, cy: number, radius: number, char: Char = '█') {
|
||||||
|
this.#circle(cx, cy, radius, char, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fillCircle(cx: number, cy: number, radius: number, char: Char = '█') {
|
||||||
|
this.#circle(cx, cy, radius, char, true);
|
||||||
|
}
|
||||||
|
|
||||||
getClipRect(): Rect {
|
getClipRect(): Rect {
|
||||||
return {
|
return {
|
||||||
x: this.clipLeft,
|
x: this.clipLeft,
|
||||||
|
|
@ -398,72 +446,86 @@ export class TextDisplay {
|
||||||
this.clipRight = Math.min(this.width, rect.x + rect.width);
|
this.clipRight = Math.min(this.width, rect.x + rect.width);
|
||||||
this.clipBottom = Math.min(this.height, rect.y + rect.height);
|
this.clipBottom = Math.min(this.height, rect.y + rect.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.redrawDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandColors(chars: string[] | Char[][], colors: ColorLike | ColorLike[] | ColorLike[][]): ColorLike[][] {
|
||||||
|
if (!Array.isArray(colors)) {
|
||||||
|
return chars.map(row => Array.from(row, () => colors));
|
||||||
|
}
|
||||||
|
if (colors.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(colors[0])) {
|
||||||
|
return colors as ColorLike[][];
|
||||||
|
}
|
||||||
|
const oldColors = colors as ColorLike[];
|
||||||
|
const newColors: ColorLike[][] = [];
|
||||||
|
let i = 0;
|
||||||
|
for (const row of chars) {
|
||||||
|
newColors.push(Array.from(row, () => oldColors[i++]));
|
||||||
|
}
|
||||||
|
return newColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TextRegion {
|
export class TextRegion {
|
||||||
readonly #chars: string[]; // one string per row, all same length
|
readonly #chars: string[];
|
||||||
readonly #fgs: IColorLike[];
|
readonly #fgs: ColorLike[];
|
||||||
readonly #bgs: IColorLike[];
|
readonly #bgs: ColorLike[];
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
|
||||||
get width(): number { return this.#chars[0]?.length ?? 0; }
|
constructor(chars: Char[][], fg?: ColorLike, bg?: ColorLike);
|
||||||
get height(): number { return this.#chars.length; }
|
constructor(chars: string, fg?: ColorLike | ColorLike[], bg?: ColorLike | ColorLike[]);
|
||||||
|
constructor(chars: string[], fg?: ColorLike | ColorLike[][], bg?: ColorLike | ColorLike[][]);
|
||||||
constructor(chars: IChar[][]);
|
|
||||||
constructor(chars: string, fg?: IColorLike | IColorLike[], bg?: IColorLike | IColorLike[]);
|
|
||||||
constructor(chars: string[], fg?: IColorLike | IColorLike[][], bg?: IColorLike | IColorLike[][]);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
chars: IChar[][] | string | string[],
|
chars: Char[][] | string | string[],
|
||||||
fg?: IColorLike | IColorLike[] | IColorLike[][],
|
fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG,
|
||||||
bg?: IColorLike | IColorLike[] | IColorLike[][],
|
bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG,
|
||||||
) {
|
) {
|
||||||
if (typeof chars === 'string') {
|
if (typeof chars === 'string') {
|
||||||
chars = chars.split('\n');
|
chars = chars.split('\n');
|
||||||
}
|
}
|
||||||
if (chars.length === 0 || typeof chars[0] === 'string') {
|
this.width = chars.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
const rows = chars as string[];
|
this.height = chars.length;
|
||||||
const w = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
const defaultFg = Array.isArray(fg) ? DEFAULT_FG : fg;
|
||||||
const h = rows.length;
|
const defaultBg = Array.isArray(bg) ? DEFAULT_BG : bg;
|
||||||
this.#chars = rows.map(r => r.padEnd(w, ' '));
|
|
||||||
this.#fgs = Array(w * h).fill(DEFAULT_FG);
|
fg = expandColors(chars, fg ?? defaultFg);
|
||||||
this.#bgs = Array(w * h).fill(DEFAULT_BG);
|
bg = expandColors(chars, bg ?? defaultBg);
|
||||||
if (fg != null && !Array.isArray(fg)) this.#fgs.fill(fg);
|
|
||||||
if (bg != null && !Array.isArray(bg)) this.#bgs.fill(bg);
|
const s = this.width * this.height;
|
||||||
if (Array.isArray(fg) || Array.isArray(bg)) {
|
this.#chars = Array(s);
|
||||||
for (let y = 0; y < h; y++) {
|
this.#fgs = Array(s);
|
||||||
for (let x = 0; x < w; x++) {
|
this.#bgs = Array(s);
|
||||||
const i = y * w + x;
|
|
||||||
if (Array.isArray(fg)) this.#fgs[i] = (fg as IColorLike[][])[y]?.[x] ?? DEFAULT_FG;
|
for (let y = 0; y < this.height; y++) {
|
||||||
if (Array.isArray(bg)) this.#bgs[i] = (bg as IColorLike[][])[y]?.[x] ?? DEFAULT_BG;
|
for (let x = 0; x < this.width; x++) {
|
||||||
}
|
const i = y * this.width + x;
|
||||||
}
|
const char = chars[y]?.[x] ?? '\0';
|
||||||
}
|
const charFG = fg[y]?.[x] ?? defaultFg;
|
||||||
} else {
|
const charBG = bg[y]?.[x] ?? defaultBg;
|
||||||
const ichars = chars as IChar[][];
|
|
||||||
const h = ichars.length;
|
const ch = parseChar(char, charFG, charBG);
|
||||||
const w = ichars.reduce((m, r) => Math.max(m, r.length), 0);
|
this.#chars[i] = ch[0];
|
||||||
this.#chars = ichars.map(row => row.map(ch => ch[0]).join('').padEnd(w, ' '));
|
this.#fgs[i] = ch[1];
|
||||||
this.#fgs = Array(w * h).fill(DEFAULT_FG);
|
this.#bgs[i] = ch[2];
|
||||||
this.#bgs = Array(w * h).fill(DEFAULT_BG);
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
for (let x = 0; x < ichars[y].length; x++) {
|
|
||||||
const ch = ichars[y][x];
|
|
||||||
const i = y * w + x;
|
|
||||||
if (ch[1] != null) this.#fgs[i] = ch[1];
|
|
||||||
if (ch[2] != null) this.#bgs[i] = ch[2];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get(x: number, y: number): IDefinedChar {
|
get(x: number, y: number): DefinedChar {
|
||||||
const i = y * this.width + x;
|
const i = y * this.width + x;
|
||||||
return [this.#chars[y][x], this.#fgs[i], this.#bgs[i]];
|
return [this.#chars[i], this.#fgs[i], this.#bgs[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
set(x: number, y: number, char: IChar) {
|
set(x: number, y: number, char: Char) {
|
||||||
const ch = parseChar(char);
|
const ch = parseChar(char);
|
||||||
this.#chars[y] = this.#chars[y].slice(0, x) + ch[0] + this.#chars[y].slice(x + 1);
|
this.#chars[y * this.width + x] = ch[0];
|
||||||
this.#fgs[y * this.width + x] = ch[1];
|
this.#fgs[y * this.width + x] = ch[1];
|
||||||
this.#bgs[y * this.width + x] = ch[2];
|
this.#bgs[y * this.width + x] = ch[2];
|
||||||
}
|
}
|
||||||
|
|
@ -475,4 +537,4 @@ export class TextRegion {
|
||||||
bgs: this.#bgs,
|
bgs: this.#bgs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@ import { formatError, formatErrorMessage } from "./errors";
|
||||||
import Input from "./input";
|
import Input from "./input";
|
||||||
import { nextFrame } from "./utils";
|
import { nextFrame } from "./utils";
|
||||||
|
|
||||||
type Setup<T> = () => Promise<T> | T;
|
interface FrameMeta {
|
||||||
type Frame<T> = (dt: number, state: T) => Promise<T | void> | T | void;
|
fps: number;
|
||||||
type GameMain = () => Promise<void>;
|
}
|
||||||
|
type Awaitable<T> = PromiseLike<T> | T;
|
||||||
|
|
||||||
export function gameLoop<T>(frame: Frame<T>): GameMain;
|
type Setup<T> = () => Awaitable<T>;
|
||||||
export function gameLoop<T>(setup: Setup<T>, frame: Frame<T>): GameMain;
|
type Frame<T> = (dt: number, state: T, meta: FrameMeta) => Awaitable<T | void>;
|
||||||
export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>): GameMain {
|
|
||||||
|
export function gameLoop<T>(frame: Frame<T>): RunGame;
|
||||||
|
export function gameLoop<T>(setup: Setup<T>, frame: Frame<T>): RunGame;
|
||||||
|
export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>): RunGame {
|
||||||
return async () => {
|
return async () => {
|
||||||
let state: T;
|
let state: T;
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,6 +30,9 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let prevFrame = performance.now();
|
let prevFrame = performance.now();
|
||||||
|
let fpsCounter = 0;
|
||||||
|
let fpsTimer = 0;
|
||||||
|
const meta: FrameMeta = { fps: 0 };
|
||||||
while (true) {
|
while (true) {
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
Input.updateKeys();
|
Input.updateKeys();
|
||||||
|
|
@ -33,13 +40,20 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const dt = (now - prevFrame) / 1000;
|
const dt = (now - prevFrame) / 1000;
|
||||||
|
|
||||||
if (dt < 1) {
|
if (dt < 1) { // skip long pause to avoid blowing up values
|
||||||
const newState = await frame(dt, state);
|
const newState = await frame(dt, state, meta);
|
||||||
if (newState) {
|
if (newState) {
|
||||||
state = newState;
|
state = newState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fpsCounter += 1;
|
||||||
|
if (now - fpsTimer > 500) {
|
||||||
|
meta.fps = fpsCounter * 2;
|
||||||
|
fpsCounter = 0;
|
||||||
|
fpsTimer = now;
|
||||||
|
}
|
||||||
|
|
||||||
prevFrame = performance.now();
|
prevFrame = performance.now();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov' | '
|
||||||
breaker?: (x: number, y: number) => boolean;
|
breaker?: (x: number, y: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions;
|
export type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cohen-Sutherland outcodes
|
// Cohen-Sutherland outcodes
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,7 @@ export class TextDisplaySystem extends System {
|
||||||
this.display.setClipRect(clipRect);
|
this.display.setClipRect(clipRect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.display.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -21,8 +21,8 @@ function createMap(world: World, random: SeededRandom) {
|
||||||
|
|
||||||
BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, {
|
BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, {
|
||||||
minWidth: 8,
|
minWidth: 8,
|
||||||
minHeight: 8,
|
minHeight: 4,
|
||||||
depth: 8,
|
depth: 10,
|
||||||
random,
|
random,
|
||||||
});
|
});
|
||||||
const mapDataString: string[] = [];
|
const mapDataString: string[] = [];
|
||||||
|
|
@ -49,17 +49,9 @@ function createPlayer(world: World, x = 0, y = 0) {
|
||||||
const player = world.createEntity('player');
|
const player = world.createEntity('player');
|
||||||
player.add(new Position(x, y, 10));
|
player.add(new Position(x, y, 10));
|
||||||
player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW))));
|
player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW))));
|
||||||
;
|
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFPS(world: World, x = 0, y = 0) {
|
|
||||||
const fps = world.createEntity('fps');
|
|
||||||
fps.add(new Position(x, y, 100, true));
|
|
||||||
fps.add(new Sprite(Resources.add('fps', '60')));
|
|
||||||
return fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default gameLoop(() => {
|
export default gameLoop(() => {
|
||||||
const world = new World();
|
const world = new World();
|
||||||
const display = world.addSystem(new TextDisplaySystem(100, 25)).display;
|
const display = world.addSystem(new TextDisplaySystem(100, 25)).display;
|
||||||
|
|
@ -89,14 +81,12 @@ export default gameLoop(() => {
|
||||||
screenY: 0,
|
screenY: 0,
|
||||||
});
|
});
|
||||||
const player = createPlayer(world, startCell.x, startCell.y);
|
const player = createPlayer(world, startCell.x, startCell.y);
|
||||||
createFPS(world, 0, 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
display,
|
||||||
world,
|
world,
|
||||||
map, mapData,
|
map, mapData,
|
||||||
mask, maskData, maskDirty: true,
|
mask, maskData, maskDirty: true,
|
||||||
fps: 0,
|
|
||||||
fpsTimer: 0,
|
|
||||||
random,
|
random,
|
||||||
viewport,
|
viewport,
|
||||||
player,
|
player,
|
||||||
|
|
@ -107,13 +97,20 @@ export default gameLoop(() => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, (dt, state) => {
|
}, (dt, state) => {
|
||||||
const { world, viewport, player, lastMove, now, mapData, maskData, isWall } = state;
|
const {
|
||||||
|
world,
|
||||||
|
viewport,
|
||||||
|
player,
|
||||||
|
lastMove, now,
|
||||||
|
mapData, maskData,
|
||||||
|
isWall,
|
||||||
|
} = state;
|
||||||
|
|
||||||
let dx = -Math.sign(Input.getHorizontal());
|
let dx = -Math.sign(Input.getHorizontal());
|
||||||
let dy = -Math.sign(Input.getVertical());
|
let dy = -Math.sign(Input.getVertical());
|
||||||
const playerPos = getPosition(player)!;
|
const playerPos = getPosition(player)!;
|
||||||
|
|
||||||
if (now - lastMove > 0.03) {
|
if (now - lastMove > 0.05) {
|
||||||
if (isWall(playerPos.x + dx, playerPos.y)) {
|
if (isWall(playerPos.x + dx, playerPos.y)) {
|
||||||
dx = 0;
|
dx = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -157,14 +154,5 @@ export default gameLoop(() => {
|
||||||
|
|
||||||
world.update(dt);
|
world.update(dt);
|
||||||
|
|
||||||
state.fpsTimer += dt;
|
|
||||||
if (state.fpsTimer >= 0.5) {
|
|
||||||
Resources.update('fps', (state.fps * 2).toString());
|
|
||||||
state.fps = 0;
|
|
||||||
state.fpsTimer = 0;
|
|
||||||
} else {
|
|
||||||
state.fps++;
|
|
||||||
};
|
|
||||||
|
|
||||||
state.now += dt;
|
state.now += dt;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default async function main() {
|
||||||
const display = new TextDisplay();
|
const display = new TextDisplay();
|
||||||
world.addSystem(new TextDisplaySystem(display));
|
world.addSystem(new TextDisplaySystem(display));
|
||||||
|
|
||||||
display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN });
|
display.drawStringInBox('Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', 8, 11, { fg: Color.CYAN });
|
||||||
|
|
||||||
let roomEntity = getRoom(world, 0, 0, 0);
|
let roomEntity = getRoom(world, 0, 0, 0);
|
||||||
let room = roomEntity?.get(Room);
|
let room = roomEntity?.get(Room);
|
||||||
|
|
@ -33,14 +33,14 @@ export default async function main() {
|
||||||
if (!player) {
|
if (!player) {
|
||||||
throw new Error('No player found');
|
throw new Error('No player found');
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRoom() {
|
function drawRoom() {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: ' ' });
|
display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: ' ' });
|
||||||
display.drawBox(room.x, room.y, room.width, room.height, { fill: ['.', Color.GRAY] });
|
display.drawBox(room.x, room.y, room.width, room.height, { fill: ['.', Color.GRAY] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawMap() {
|
function drawMap() {
|
||||||
display.drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' });
|
display.drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' });
|
||||||
const worldPos = roomEntity.get(Position, 'world');
|
const worldPos = roomEntity.get(Position, 'world');
|
||||||
|
|
@ -72,10 +72,10 @@ export default async function main() {
|
||||||
const foundItems = inv ? Object.values(inv.state.slots).length : 0;
|
const foundItems = inv ? Object.values(inv.state.slots).length : 0;
|
||||||
const totalItems = Array.from(world.query(Item)).length;
|
const totalItems = Array.from(world.query(Item)).length;
|
||||||
const items = `${foundItems}/${totalItems}`.padStart(coords.length - 2);
|
const items = `${foundItems}/${totalItems}`.padStart(coords.length - 2);
|
||||||
display.drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' });
|
display.drawStringInBox(`Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, 0, 0, { fg: Color.YELLOW, title: 'Info' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastMove = Date.now();
|
let lastMove = Date.now();
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
if (!room || !player) return;
|
if (!room || !player) return;
|
||||||
Input.updateKeys();
|
Input.updateKeys();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue