From 06bb6ae122d302827492c148cb0e63b197be35c3 Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 19 Mar 2024 23:30:10 -0400 Subject: [PATCH 1/7] Adjusting build to work as a TS project reference This changes some settings in the tsconfig.json file to make this repo work with the new TypeScript project reference mechanism. It also adds some partial type declarations in the Term display backend so that including projects don't have to pull in the @types/node package. Two notable changes to the tsconfig.json file: - exactOptionalPropertyTypes is now set to true, so optional properties will no longer automatically have undefined added to their types. - sourceMap is now set to true, so the rot.js build process will create source maps; this allows including projects to view the .ts file rather than the .js output when following links or in devtools. --- .gitignore | 1 + src/display/term.ts | 16 ++++++++++++++++ tsconfig.json | 8 +++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a2564cf6..0ae467d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .ts.flag .idea .DS_Store +*.tsbuildinfo diff --git a/src/display/term.ts b/src/display/term.ts index 8f58e578..e445a1a3 100644 --- a/src/display/term.ts +++ b/src/display/term.ts @@ -2,6 +2,22 @@ import Backend from "./backend.js"; import { DisplayData, DisplayOptions } from "./types.js"; import * as Color from "../color.js"; +// Explicitly declaring this so that including projects don't need to import node types +declare global { + namespace NodeJS { + interface WriteStream + { + rows: number; + columns: number; + write(data: string): any; + } + interface Process { + stdout: WriteStream & { fd: 1; } + } + } + var process: NodeJS.Process; +} + function clearToAnsi(bg: string) { return `\x1b[0;48;5;${termcolor(bg)}m\x1b[2J`; } diff --git a/tsconfig.json b/tsconfig.json index 6bd6e21b..122aa40d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,12 +3,18 @@ "strict": true, "noImplicitReturns": true, "noImplicitThis": true, + "exactOptionalPropertyTypes": true, "target": "es2017", "declaration": true, "noUnusedLocals": true, "noUnusedParameters": true, - "outDir": "lib" + "rootDir": "./src", + "outDir": "lib", + "composite": true, + "newLine": "lf", + "sourceMap": true, }, + "include": ["src/**/*.ts"], "files": ["src/index.ts"] } From 319c7b94c2bf684bbe2151c6bdb0f6de87f92ecf Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Wed, 20 Mar 2024 16:49:24 -0400 Subject: [PATCH 2/7] Fixing the Tile backend to work with partial-transparency tile backgrounds --- src/display/canvas.ts | 4 ++-- src/display/tile.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/display/canvas.ts b/src/display/canvas.ts index bd6a5994..ba499357 100644 --- a/src/display/canvas.ts +++ b/src/display/canvas.ts @@ -26,11 +26,11 @@ export default abstract class Canvas extends Backend { } clear() { - const oldComposite = this._ctx.globalCompositeOperation; + this._ctx.save(); this._ctx.globalCompositeOperation = "copy" this._ctx.fillStyle = this._options.bg; this._ctx.fillRect(0, 0, this._ctx.canvas.width, this._ctx.canvas.height); - this._ctx.globalCompositeOperation = oldComposite; + this._ctx.restore(); } eventToPosition(x: number, y: number): [number, number] { diff --git a/src/display/tile.ts b/src/display/tile.ts index c9088479..c8bc677a 100644 --- a/src/display/tile.ts +++ b/src/display/tile.ts @@ -23,8 +23,14 @@ export default class Tile extends Canvas { if (this._options.tileColorize) { this._ctx.clearRect(x*tileWidth, y*tileHeight, tileWidth, tileHeight); } else { + this._ctx.save(); + this._ctx.globalCompositeOperation = "copy"; this._ctx.fillStyle = bg; - this._ctx.fillRect(x*tileWidth, y*tileHeight, tileWidth, tileHeight); + this._ctx.beginPath(); + this._ctx.rect(x*tileWidth, y*tileHeight, tileWidth, tileHeight); + this._ctx.clip(); + this._ctx.fill(); + this._ctx.restore(); } } From 868938bb2e8f9a0193652c0f71d6733499049be0 Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 26 Mar 2024 20:18:24 -0400 Subject: [PATCH 3/7] Create IDisplayBackend interface This removes the direct reference from Display to Backend, along with (theoretically) allowing library consumers to define their own Display backend. This also changes the DisplayData type to be an object interface rather than a tuple definition, as those are friendlier both to TypeScript and to the JS engine. --- src/display/backend.ts | 13 ++++- src/display/canvas.ts | 48 ++++++++++++---- src/display/display.ts | 123 +++++++++++++++++++++++++++++------------ src/display/hex.ts | 28 +++++----- src/display/rect.ts | 33 ++++++----- src/display/term.ts | 18 ++++-- src/display/tile-gl.ts | 25 ++++++--- src/display/tile.ts | 32 ++++++----- src/display/types.ts | 105 ++++++++++++++++++++++++++++++++++- 9 files changed, 318 insertions(+), 107 deletions(-) diff --git a/src/display/backend.ts b/src/display/backend.ts index 3fbef613..e150799e 100644 --- a/src/display/backend.ts +++ b/src/display/backend.ts @@ -1,14 +1,21 @@ -import { DisplayOptions, DisplayData } from "./types.js"; +import { DisplayOptions, DisplayData, IDisplayBackend } from "./types.js"; /** * @class Abstract display backend module * @private */ -export default abstract class Backend { +export default abstract class Backend implements IDisplayBackend { _options!: DisplayOptions; getContainer(): HTMLElement | null { return null; } - setOptions(options: DisplayOptions) { this._options = options; } + + abstract checkOptions(options: DisplayOptions): boolean; + setOptions(options: DisplayOptions) { + // return true if this change dirties the whole display + const { width, height, layout } = this._options ?? {}; + this._options = {...options}; + return width !== options.width || height !== options.height || layout !== options.layout; + } abstract schedule(cb: ()=>void): void; abstract clear(): void; diff --git a/src/display/canvas.ts b/src/display/canvas.ts index ba499357..62eef9e9 100644 --- a/src/display/canvas.ts +++ b/src/display/canvas.ts @@ -1,28 +1,28 @@ import Backend from "./backend.js"; -import { DisplayOptions } from "./types.js"; +import { DisplayOptions, UnknownBackend } from "./types.js"; -export default abstract class Canvas extends Backend { +/** + * Base class for any backend that uses a `` element as its display surface + */ +export abstract class BaseCanvas extends Backend { _ctx: CanvasRenderingContext2D; - constructor() { + constructor(oldBackend?: UnknownBackend) { super(); - this._ctx = document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D; + this._ctx = oldBackend instanceof BaseCanvas ? oldBackend._ctx : document.createElement("canvas").getContext("2d")!; } schedule(cb: () => void) { requestAnimationFrame(cb); } getContainer() { return this._ctx.canvas; } setOptions(opts: DisplayOptions) { - super.setOptions(opts); + let needsRepaint = super.setOptions(opts); - const style = (opts.fontStyle ? `${opts.fontStyle} ` : ``); - const font = `${style} ${opts.fontSize}px ${opts.fontFamily}`; - this._ctx.font = font; - this._updateSize(); + if (needsRepaint) { + this._updateSize(); + } - this._ctx.font = font; - this._ctx.textAlign = "center"; - this._ctx.textBaseline = "middle"; + return needsRepaint; } clear() { @@ -50,3 +50,27 @@ export default abstract class Canvas extends Backend { abstract _normalizedEventToPosition(x: number, y: number): [number, number]; abstract _updateSize(): void; } + +/** + * Base class for text canvases, which can display one or more text characters with a single foreground and a background color in each cell. + */ +export default abstract class Canvas extends BaseCanvas { + setOptions(opts: DisplayOptions) { + const { fontSize, fontFamily, spacing } = this._options; + let needsRepaint = super.setOptions(opts) || fontSize !== opts.fontSize || fontFamily !== opts.fontFamily || spacing !== opts.spacing; + + if (needsRepaint) { + opts = this._options; + const style = (opts.fontStyle ? `${opts.fontStyle} ` : ``); + const font = `${style} ${opts.fontSize}px ${opts.fontFamily}`; + this._ctx.font = font; + this._updateSize(); + + this._ctx.font = font; + this._ctx.textAlign = "center"; + this._ctx.textBaseline = "middle"; + } + + return needsRepaint; + } +} diff --git a/src/display/display.ts b/src/display/display.ts index cf382fb6..c0f8d7af 100644 --- a/src/display/display.ts +++ b/src/display/display.ts @@ -1,4 +1,3 @@ -import Backend from "./backend.js"; import Hex from "./hex.js"; import Rect from "./rect.js"; import Tile from "./tile.js"; @@ -6,10 +5,10 @@ import TileGL from "./tile-gl.js"; import Term from "./term.js"; import * as Text from "../text.js"; -import { DisplayOptions, DisplayData } from "./types.js"; +import { DisplayOptions, DisplayData, IDisplayBackend, LayoutType, UnknownBackend } from "./types.js"; import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "../constants.js"; -const BACKENDS = { +export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend) => IDisplayBackend} = { "hex": Hex, "rect": Rect, "tile": Tile, @@ -44,7 +43,7 @@ export default class Display { _data: { [pos:string] : DisplayData }; _dirty: boolean | { [pos: string]: boolean }; _options!: DisplayOptions; - _backend!: Backend; + _backend!: IDisplayBackend; static Rect = Rect; static Hex = Hex; @@ -55,9 +54,8 @@ export default class Display { constructor(options: Partial = {}) { this._data = {}; this._dirty = false; // false = nothing, true = all, object = dirty cells - this._options = {} as DisplayOptions; - options = Object.assign({}, DEFAULT_OPTIONS, options); + options = {...DEFAULT_OPTIONS, ...options}; this.setOptions(options); this.DEBUG = this.DEBUG.bind(this); @@ -88,15 +86,19 @@ export default class Display { * @see ROT.Display */ setOptions(options: Partial) { - Object.assign(this._options, options); - - if (options.width || options.height || options.fontSize || options.fontFamily || options.spacing || options.layout) { - if (options.layout) { - let ctor = BACKENDS[options.layout]; - this._backend = new ctor(); + this._options = Object.assign(this._options ?? {}, options); + + if (!this._backend?.checkOptions(this._options)) { + // This is either the initial backend or a backend switch + const ctor = BACKENDS[this._options.layout]; + this._backend = new ctor(this._backend); + if (!this._backend.checkOptions(this._options)) { + console.error("checkOptions returned false on a newly-constructed backend! This is probably a bug in rot.js.", options, this._backend, this._options); + throw new Error("could not construct display backend"); } + } - this._backend.setOptions(this._options); + if (this._backend.setOptions(this._options)) { this._dirty = true; } return this; @@ -157,43 +159,49 @@ export default class Display { } /** - * @param {int} x - * @param {int} y - * @param {string || string[]} ch One or more chars (will be overlapping themselves) - * @param {string} [fg] foreground color - * @param {string} [bg] background color + * @param x + * @param y + * @param ch One or more chars (will be overlapping themselves) + * @param fg foreground color + * @param bg background color */ - draw(x: number, y: number, ch: string | string[] | null, fg: string | null, bg: string | null) { - if (!fg) { fg = this._options.fg; } - if (!bg) { bg = this._options.bg; } + draw(x: number, y: number, ch: string | string[] | null, fg: string | null = null, bg: string | null = null) { let key = `${x},${y}`; - this._data[key] = [x, y, ch, fg, bg]; + const data = this._data[key] ??= {x, y, chars: [], fgs: [], bgs: [], ch: null!, fg: null!, bg: null!}; + if (this._setData(data, ch, fg ?? this._options.fg, bg ?? this._options.bg)) { + this._setDirty(key); + } + } + _setDirty(key: string) { if (this._dirty === true) { return; } // will already redraw everything if (!this._dirty) { this._dirty = {}; } // first! this._dirty[key] = true; } /** - * @param {int} x - * @param {int} y - * @param {string || string[]} ch One or more chars (will be overlapping themselves) - * @param {string || null} [fg] foreground color - * @param {string || null} [bg] background color + * @param x + * @param y + * @param ch One or more chars (will be overlapping themselves), or null to leave unchanged + * @param fg foreground color, or null to leave unchanged + * @param bg background color, or null to leave unchanged */ drawOver( x: number, y: number, - ch: string | null, - fg: string | null, - bg: string | null + ch: string | string[] | null = null, + fg: string | null = null, + bg: string | null = null, ) { const key = `${x},${y}`; const existing = this._data[key]; if (existing) { - existing[2] = ch || existing[2]; - existing[3] = fg || existing[3]; - existing[4] = bg || existing[4]; + ch ??= existing.ch; + fg ??= existing.fg; + bg ??= existing.bg; + if (this._setData(existing, ch, fg, bg)) { + this._setDirty(key); + } } else { this.draw(x, y, ch, fg, bg); } @@ -295,8 +303,55 @@ export default class Display { */ _draw(key: string, clearBefore: boolean) { let data = this._data[key]; - if (data[4] != this._options.bg) { clearBefore = true; } + if (data.bg !== this._options.bg) { clearBefore = true; } this._backend.draw(data, clearBefore); } + + _setData(data: DisplayData, ch: string | string[] | null, fg: string, bg: string) { + let changed = false; + if (data.ch !== ch) { + changed = true; + data.ch = ch; + } + if (data.fg !== fg) { + changed = true; + data.fg = fg; + } + if (data.bg !== bg) { + changed = true; + data.bg = bg; + } + changed = setArrayValue(data.chars, ch) || changed; + changed = setArrayValue(data.fgs, fg) || changed; + changed = setArrayValue(data.bgs, bg) || changed; + + return changed; + } +} + +function setArrayValue(array: T[], value: T | T[] | null) { + let changed = false; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (array.length <= i || array[i] !== value[i]) { + array[i] = value[i]; + changed = true; + } + } + if (value.length !== array.length) { + array.length = value.length; + changed = true; + } + } else if (value == null) { + changed = array.length !== 0; + array.length = 0; + } else { + if (array.length !== 1 || array[0] !== value) { + // order is important here! setting length before value means that the JS engine might degrade this to a sparse array with worse performance + array[0] = value; + array.length = 1; + } + } + return changed; } diff --git a/src/display/hex.ts b/src/display/hex.ts index 661db968..a602ecc7 100644 --- a/src/display/hex.ts +++ b/src/display/hex.ts @@ -1,25 +1,28 @@ import Canvas from "./canvas.js"; -import { DisplayData } from "./types.js"; +import { DisplayOptions, DisplayData } from "./types.js"; import { mod } from "../util.js"; +declare module "./types.js" { + interface LayoutTypeBackendMap { + hex: Hex; + } +} + /** * @class Hexagonal backend * @private */ export default class Hex extends Canvas { - _spacingX: number; - _spacingY: number; - _hexSize: number; - - constructor() { - super(); - this._spacingX = 0; - this._spacingY = 0; - this._hexSize = 0; + _spacingX = 0; + _spacingY = 0; + _hexSize = 0; + + checkOptions(options: DisplayOptions): boolean { + return options.layout === "hex"; } draw(data: DisplayData, clearBefore: boolean) { - let [x, y, ch, fg, bg] = data; + const {x, y, chars, fg, bg} = data; let px = [ (x+1) * this._spacingX, @@ -32,11 +35,10 @@ export default class Hex extends Canvas { this._fill(px[0], px[1]); } - if (!ch) { return; } + if (!chars.length) { return; } this._ctx.fillStyle = fg; - let chars = ([] as string[]).concat(ch); for (let i=0;ivoid) { setTimeout(cb, 1000/60); } + checkOptions(options: DisplayOptions): boolean { + return options.layout === "term"; + } setOptions(options: DisplayOptions) { - super.setOptions(options); - let size = [options.width, options.height]; + let needsRepaint = super.setOptions(options); + let size = [this._options.width, this._options.height]; let avail = this.computeSize(); this._offset = avail.map((val, index) => Math.floor((val as number - size[index])/2)) as [number, number]; + return needsRepaint; } clear() { @@ -69,7 +79,7 @@ export default class Term extends Backend { draw(data: DisplayData, clearBefore: boolean) { // determine where to draw what with what colors - let [x, y, ch, fg, bg] = data; + let {x, y, ch, fg, bg} = data; // determine if we need to move the terminal cursor let dx = this._offset[0] + x; diff --git a/src/display/tile-gl.ts b/src/display/tile-gl.ts index 9e76b1ee..02434892 100644 --- a/src/display/tile-gl.ts +++ b/src/display/tile-gl.ts @@ -2,6 +2,12 @@ import Backend from "./backend.js"; import { DisplayOptions, DisplayData } from "./types.js"; import * as Color from "../color.js"; +declare module "./types.js" { + interface LayoutTypeBackendMap { + "tile-gl": TileGL; + } +} + /** * @class Tile backend * @private @@ -32,8 +38,12 @@ export default class TileGL extends Backend { schedule(cb: () => void) { requestAnimationFrame(cb); } getContainer() { return this._gl.canvas as HTMLCanvasElement; } + checkOptions(options: DisplayOptions): boolean { + return options.layout === "tile-gl"; + } + setOptions(opts: DisplayOptions) { - super.setOptions(opts); + let needsRepaint = super.setOptions(opts); this._updateSize(); @@ -43,13 +53,14 @@ export default class TileGL extends Backend { } else { this._updateTexture(tileSet as HTMLImageElement); } - } + return needsRepaint; + } draw(data: DisplayData, clearBefore: boolean) { const gl = this._gl; const opts = this._options; - let [x, y, ch, fg, bg] = data; + const {x, y, chars, fgs, bgs} = data; let scissorY = gl.canvas.height - (y+1)*opts.tileHeight; gl.scissor(x*opts.tileWidth, scissorY, opts.tileWidth, opts.tileHeight); @@ -58,16 +69,12 @@ export default class TileGL extends Backend { if (opts.tileColorize) { gl.clearColor(0, 0, 0, 0); } else { - gl.clearColor(...parseColor(bg)); + gl.clearColor(...parseColor(bgs[0] ?? this._options.bg)); } gl.clear(gl.COLOR_BUFFER_BIT); } - if (!ch) { return; } - - let chars = ([] as string[]).concat(ch); - let bgs = ([] as string[]).concat(bg); - let fgs = ([] as string[]).concat(fg); + if (!chars.length) { return; } gl.uniform2fv(this._uniforms["targetPosRel"], [x, y]); diff --git a/src/display/tile.ts b/src/display/tile.ts index c8bc677a..b9dc13af 100644 --- a/src/display/tile.ts +++ b/src/display/tile.ts @@ -1,20 +1,30 @@ -import Canvas from "./canvas.js"; -import { DisplayData } from "./types.js"; +import { BaseCanvas } from "./canvas.js"; +import { DisplayOptions, DisplayData, UnknownBackend } from "./types.js"; + +declare module "./types.js" { + interface LayoutTypeBackendMap { + "tile": Tile; + } +} /** * @class Tile backend * @private */ -export default class Tile extends Canvas { +export default class Tile extends BaseCanvas { _colorCanvas: HTMLCanvasElement; - constructor() { - super(); - this._colorCanvas = document.createElement("canvas"); + constructor(oldBackend?: UnknownBackend) { + super(oldBackend); + this._colorCanvas = oldBackend instanceof Tile ? oldBackend._colorCanvas : document.createElement("canvas"); + } + + checkOptions(options: DisplayOptions): boolean { + return options.layout === "tile"; } draw(data: DisplayData, clearBefore: boolean) { - let [x, y, ch, fg, bg] = data; + const {x, y, chars, fgs, bgs} = data; let tileWidth = this._options.tileWidth; let tileHeight = this._options.tileHeight; @@ -25,7 +35,7 @@ export default class Tile extends Canvas { } else { this._ctx.save(); this._ctx.globalCompositeOperation = "copy"; - this._ctx.fillStyle = bg; + this._ctx.fillStyle = bgs[0] ?? this._options.bg; this._ctx.beginPath(); this._ctx.rect(x*tileWidth, y*tileHeight, tileWidth, tileHeight); this._ctx.clip(); @@ -34,11 +44,7 @@ export default class Tile extends Canvas { } } - if (!ch) { return; } - - let chars = ([] as string[]).concat(ch); - let fgs = ([] as string[]).concat(fg); - let bgs = ([] as string[]).concat(bg); + if (!chars.length) { return; } for (let i=0;ivoid): void; + /** + * Clear the entire display, resetting it to the color specified by {@link DisplayOptions.bg}. + */ + clear(): void; + /** + * Draw the specified cell. + * @param data The data to draw. + * @param clearBefore Whether to clear the cell to transparent prior to drawing. This will always be set if + * `data.bg` is not equal to {@link DisplayOptions.bg}, and also if this cell + * has been drawn since the last call to {@link clear()}. + */ + draw(data: DisplayData, clearBefore: boolean): void; + /** + * Compute the maximum width/height to fit into a set of given constraints. See {@link Display.computeSize()}. + */ + computeSize(availWidth: number, availHeight: number): [number, number]; + /** + * Compute the maximum font size to fit into a set of given constraints. See {@link Display.computeFontSize()}. + * If this backend does not support the concept of font size, it should throw an Error. + */ + computeFontSize(availWidth: number, availHeight: number): number; + /** + * Convert a pair of pixel coordinates to display coordinates. See {@link Display.eventToPosition()}. + * @param x The x coordinate of the event, in pixels or display-specific equivalent. + * @param y The y coordinate. + * @returns The zero-based x and y coordinates of the display cell at position ({@link x}, {@link y}), as a two-element array. + */ + eventToPosition(x:number, y:number): [number, number]; +} \ No newline at end of file From f2476d7bcd74cf07e4976188d9ae5aff318214f6 Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 26 Mar 2024 20:56:18 -0400 Subject: [PATCH 4/7] Splitting DisplayOptions interface by backend With this change, each display backend (layout) can specify its own Options interface as well as its own option defaults. The Display itself now only stores the literal options passed to the constructor or setOptions, and the Display.getOptions method takes an optional parameter for whether to return the effective options (the default) or only the caller-specified ones. The Term backend uses the ability to specify defaults to make the Display default to the width and height of the stdout terminal. --- src/display/backend.ts | 27 ++++++++++---- src/display/canvas.ts | 20 ++++++++--- src/display/display.ts | 80 ++++++++++++++++++++++++++---------------- src/display/hex.ts | 23 ++++++++++-- src/display/rect.ts | 26 +++++++++++--- src/display/term.ts | 20 ++++++++--- src/display/tile-gl.ts | 27 +++++++++++--- src/display/tile.ts | 26 ++++++++++++-- src/display/types.ts | 64 +++++++++++++++++++++------------ 9 files changed, 231 insertions(+), 82 deletions(-) diff --git a/src/display/backend.ts b/src/display/backend.ts index e150799e..f3a7d752 100644 --- a/src/display/backend.ts +++ b/src/display/backend.ts @@ -1,21 +1,36 @@ -import { DisplayOptions, DisplayData, IDisplayBackend } from "./types.js"; +import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from "../constants.js"; +import { DisplayOptions, DisplayData, IDisplayBackend, DefaultsFor, Frozen, BaseDisplayOptions } from "./types.js"; /** * @class Abstract display backend module * @private */ -export default abstract class Backend implements IDisplayBackend { - _options!: DisplayOptions; +export default abstract class Backend + implements IDisplayBackend { + protected get DEFAULTS() { + return { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + layout: "rect", + fg: "#ccc", + bg: "#000", + } satisfies DefaultsFor; + } + _options!: Frozen; getContainer(): HTMLElement | null { return null; } - abstract checkOptions(options: DisplayOptions): boolean; - setOptions(options: DisplayOptions) { + abstract checkOptions(options: DisplayOptions): options is DisplayOptions & TOptions; + setOptions(options: TOptions) { // return true if this change dirties the whole display const { width, height, layout } = this._options ?? {}; - this._options = {...options}; + this._options = this.defaultedOptions(options); return width !== options.width || height !== options.height || layout !== options.layout; } + protected abstract defaultedOptions(options: TOptions): Frozen; + getOptions(): Frozen { + return this._options as Frozen; + } abstract schedule(cb: ()=>void): void; abstract clear(): void; diff --git a/src/display/canvas.ts b/src/display/canvas.ts index 62eef9e9..d4acc25d 100644 --- a/src/display/canvas.ts +++ b/src/display/canvas.ts @@ -1,10 +1,10 @@ import Backend from "./backend.js"; -import { DisplayOptions, UnknownBackend } from "./types.js"; +import { BaseDisplayOptions, TextDisplayOptions, UnknownBackend, DefaultsFor } from "./types.js"; /** * Base class for any backend that uses a `` element as its display surface */ -export abstract class BaseCanvas extends Backend { +export abstract class BaseCanvas extends Backend { _ctx: CanvasRenderingContext2D; constructor(oldBackend?: UnknownBackend) { @@ -15,7 +15,7 @@ export abstract class BaseCanvas extends Backend { schedule(cb: () => void) { requestAnimationFrame(cb); } getContainer() { return this._ctx.canvas; } - setOptions(opts: DisplayOptions) { + setOptions(opts: TOptions) { let needsRepaint = super.setOptions(opts); if (needsRepaint) { @@ -54,8 +54,18 @@ export abstract class BaseCanvas extends Backend { /** * Base class for text canvases, which can display one or more text characters with a single foreground and a background color in each cell. */ -export default abstract class Canvas extends BaseCanvas { - setOptions(opts: DisplayOptions) { +export default abstract class Canvas extends BaseCanvas { + protected get DEFAULTS() { + return { + ...super.DEFAULTS, + fontSize: 15, + spacing: 1, + border: 0, + fontFamily: "monospace", + fontStyle: "", + } satisfies DefaultsFor; + } + setOptions(opts: TOptions) { const { fontSize, fontFamily, spacing } = this._options; let needsRepaint = super.setOptions(opts) || fontSize !== opts.fontSize || fontFamily !== opts.fontFamily || spacing !== opts.spacing; diff --git a/src/display/display.ts b/src/display/display.ts index c0f8d7af..e25974fb 100644 --- a/src/display/display.ts +++ b/src/display/display.ts @@ -5,10 +5,9 @@ import TileGL from "./tile-gl.js"; import Term from "./term.js"; import * as Text from "../text.js"; -import { DisplayOptions, DisplayData, IDisplayBackend, LayoutType, UnknownBackend } from "./types.js"; -import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "../constants.js"; +import { DisplayOptions, DisplayData, LayoutType, IDisplayBackend, UnknownBackend, LayoutOptions, BaseDisplayOptions, Frozen } from "./types.js"; -export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend) => IDisplayBackend} = { +export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend) => IDisplayBackend>} = { "hex": Hex, "rect": Rect, "tile": Tile, @@ -16,34 +15,16 @@ export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend "term": Term } -const DEFAULT_OPTIONS: DisplayOptions = { - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - transpose: false, - layout: "rect", - fontSize: 15, - spacing: 1, - border: 0, - forceSquareRatio: false, - fontFamily: "monospace", - fontStyle: "", - fg: "#ccc", - bg: "#000", - tileWidth: 32, - tileHeight: 32, - tileMap: {}, - tileSet: null, - tileColorize: false -} - /** * @class Visual map display */ -export default class Display { +class DisplayImpl { + static readonly DEFAULT_LAYOUT = "rect" satisfies LayoutType; + _data: { [pos:string] : DisplayData }; _dirty: boolean | { [pos: string]: boolean }; - _options!: DisplayOptions; - _backend!: IDisplayBackend; + _options!: LayoutOptions & Required; + _backend!: IDisplayBackend>; static Rect = Rect; static Hex = Hex; @@ -51,11 +32,11 @@ export default class Display { static TileGL = TileGL; static Term = Term; - constructor(options: Partial = {}) { + constructor(options?: DisplayOptions) { this._data = {}; this._dirty = false; // false = nothing, true = all, object = dirty cells - options = {...DEFAULT_OPTIONS, ...options}; + options = {layout: Display.DEFAULT_LAYOUT, ...options} as DisplayOptions; this.setOptions(options); this.DEBUG = this.DEBUG.bind(this); @@ -85,13 +66,13 @@ export default class Display { /** * @see ROT.Display */ - setOptions(options: Partial) { + setOptions(options: Omit, "layout"> | DisplayOptions) { this._options = Object.assign(this._options ?? {}, options); if (!this._backend?.checkOptions(this._options)) { // This is either the initial backend or a backend switch const ctor = BACKENDS[this._options.layout]; - this._backend = new ctor(this._backend); + this._backend = new ctor(this._backend) as any; if (!this._backend.checkOptions(this._options)) { console.error("checkOptions returned false on a newly-constructed backend! This is probably a bug in rot.js.", options, this._backend, this._options); throw new Error("could not construct display backend"); @@ -106,8 +87,13 @@ export default class Display { /** * Returns currently set options + * @param getEffectiveOptions When true or omitted, returns the set of active options in use by the backend. When false, + * returns the options that were passed to the Display constructor or to {@link setOptions()}. */ - getOptions() { return this._options; } + getOptions(getEffectiveOptions?: true): Frozen>; + getOptions(getEffectiveOptions: false): DisplayOptions; + getOptions(getEffectiveOptions: boolean): Frozen> | DisplayOptions; + getOptions(getEffectiveOptions = true): Frozen> | DisplayOptions { return getEffectiveOptions ? this._backend.getOptions() : this._options; } /** * Returns the DOM node of this display @@ -355,3 +341,35 @@ function setArrayValue(array: T[], value: T | T[] | null) { } return changed; } + +// Redeclaring all public API in the Display interface so that hovers etc show the right name +export interface Display extends DisplayImpl { + DEBUG(x: number, y: number, what: number): void; + clear(): void; + setOptions(options: LayoutOptions): this; + getOptions(getEffectiveOptions?: true): Frozen>; + getOptions(getEffectiveOptions: false): DisplayOptions; + getOptions(getEffectiveOptions: boolean): Frozen> | DisplayOptions; + computeSize(availWidth: number, availHeight: number): [w: number, h: number]; + computeFontSize(availWidth: number, availHeight: number): number; + computeTileSize(availWidth: number, availHeight: number): [w: number, h: number]; + eventToPosition(e: TouchEvent | MouseEvent): [x: number, y: number]; + draw(x: number, y: number, ch: string | string[] | null, fg?: string | null, bg?: string | null): void; + drawOver(x: number, y: number, ch?: string | string[] | null, fg?: string | null, bg?: string | null): void; + drawText(x:number, y:number, text:string, maxWidth?:number): number; +} + +type LayoutFor = TOptions extends {layout: infer L} ? NonNullable : typeof DisplayImpl["DEFAULT_LAYOUT"]; + +// This is, sadly, the only way to be able to explicitly declare the type arguments of the returned Display instance +export interface DisplayConstructor { + DEFAULT_LAYOUT: LayoutType; + new = LayoutOptions> + (options?: TOptions): Display>; +} + +export const Display: DisplayConstructor = class Display extends DisplayImpl { +} as any; + +export default Display; \ No newline at end of file diff --git a/src/display/hex.ts b/src/display/hex.ts index a602ecc7..12229eea 100644 --- a/src/display/hex.ts +++ b/src/display/hex.ts @@ -1,5 +1,5 @@ import Canvas from "./canvas.js"; -import { DisplayOptions, DisplayData } from "./types.js"; +import { TextDisplayOptions, DisplayData, DefaultsFor, DisplayOptions } from "./types.js"; import { mod } from "../util.js"; declare module "./types.js" { @@ -8,19 +8,36 @@ declare module "./types.js" { } } +export interface HexOptions extends TextDisplayOptions { + layout: "hex"; + transpose?: boolean; +} + /** * @class Hexagonal backend * @private */ -export default class Hex extends Canvas { +export default class Hex extends Canvas { + protected get DEFAULTS() { + return { + ...super.DEFAULTS, + transpose: false + } satisfies DefaultsFor; + } _spacingX = 0; _spacingY = 0; _hexSize = 0; - checkOptions(options: DisplayOptions): boolean { + checkOptions(options: DisplayOptions): options is HexOptions { return options.layout === "hex"; } + protected defaultedOptions(options: HexOptions): Required { + return { + ...this.DEFAULTS, + ...options, + } + } draw(data: DisplayData, clearBefore: boolean) { const {x, y, chars, fg, bg} = data; diff --git a/src/display/rect.ts b/src/display/rect.ts index be20f161..b0989615 100644 --- a/src/display/rect.ts +++ b/src/display/rect.ts @@ -1,5 +1,5 @@ import Canvas from "./canvas.js"; -import { DisplayOptions, DisplayData } from "./types.js"; +import { DefaultsFor, DisplayData, DisplayOptions, TextDisplayOptions } from "./types.js"; declare module "./types.js" { interface LayoutTypeBackendMap { @@ -7,25 +7,43 @@ declare module "./types.js" { } } +export interface RectOptions extends TextDisplayOptions { + layout?: "rect"; + forceSquareRatio?: boolean; +} + /** * @class Rectangular backend * @private */ -export default class Rect extends Canvas { +export default class Rect extends Canvas { + protected get DEFAULTS() { + return { + ...super.DEFAULTS, + forceSquareRatio: false, + } satisfies DefaultsFor; + } _spacingX: number = 0; _spacingY: number = 0; _canvasCache: {[key:string]: HTMLCanvasElement} = {}; static cache = false; - checkOptions(options: DisplayOptions): boolean { + checkOptions(options: DisplayOptions): options is RectOptions { return options.layout === "rect" || !options.layout; } - setOptions(options: DisplayOptions) { + setOptions(options: RectOptions) { this._canvasCache = {}; return super.setOptions(options); } + protected defaultedOptions(options: RectOptions): Required { + return { + ...this.DEFAULTS, + ...options, + } + } + draw(data: DisplayData, clearBefore: boolean) { if (Rect.cache) { this._drawWithCache(data); diff --git a/src/display/term.ts b/src/display/term.ts index 02077020..fae7bb19 100644 --- a/src/display/term.ts +++ b/src/display/term.ts @@ -1,5 +1,5 @@ import Backend from "./backend.js"; -import { DisplayOptions, DisplayData } from "./types.js"; +import { DisplayOptions, DisplayData, BaseDisplayOptions } from "./types.js"; import * as Color from "../color.js"; declare module "./types.js" { @@ -47,8 +47,11 @@ function termcolor(color: string) { return r*36 + g*6 + b*1 + 16; } +export interface TermOptions extends BaseDisplayOptions { + layout: "term"; +} -export default class Term extends Backend { +export default class Term extends Backend { _offset: [number, number]; _cursor: [number, number]; _lastColor: string; @@ -62,10 +65,10 @@ export default class Term extends Backend { schedule(cb: ()=>void) { setTimeout(cb, 1000/60); } - checkOptions(options: DisplayOptions): boolean { + checkOptions(options: DisplayOptions): options is TermOptions { return options.layout === "term"; } - setOptions(options: DisplayOptions) { + setOptions(options: TermOptions) { let needsRepaint = super.setOptions(options); let size = [this._options.width, this._options.height]; let avail = this.computeSize(); @@ -73,6 +76,15 @@ export default class Term extends Backend { return needsRepaint; } + protected defaultedOptions(options: TermOptions): Required { + const [width, height] = this.computeSize(); + return { + ...this.DEFAULTS, + width, height, + ...options, + }; + } + clear() { process.stdout.write(clearToAnsi(this._options.bg)); } diff --git a/src/display/tile-gl.ts b/src/display/tile-gl.ts index 02434892..a8a234db 100644 --- a/src/display/tile-gl.ts +++ b/src/display/tile-gl.ts @@ -1,5 +1,5 @@ import Backend from "./backend.js"; -import { DisplayOptions, DisplayData } from "./types.js"; +import { TileDisplayOptions, DisplayData, DefaultsFor, DisplayOptions } from "./types.js"; import * as Color from "../color.js"; declare module "./types.js" { @@ -8,15 +8,27 @@ declare module "./types.js" { } } +export interface TileGLOptions extends TileDisplayOptions { + layout: "tile-gl"; +} + /** * @class Tile backend * @private */ -export default class TileGL extends Backend { +export default class TileGL extends Backend { _gl!: WebGLRenderingContext; _program!: WebGLProgram; _uniforms: {[key:string]: WebGLUniformLocation | null}; + protected get DEFAULTS() { + return { + ...super.DEFAULTS, + tileWidth: 32, + tileHeight: 32, + tileColorize: false, + } satisfies DefaultsFor; + } static isSupported() { return !!document.createElement("canvas").getContext("webgl2", {preserveDrawingBuffer:true}); } @@ -38,11 +50,11 @@ export default class TileGL extends Backend { schedule(cb: () => void) { requestAnimationFrame(cb); } getContainer() { return this._gl.canvas as HTMLCanvasElement; } - checkOptions(options: DisplayOptions): boolean { + checkOptions(options: DisplayOptions): options is TileGLOptions { return options.layout === "tile-gl"; } - setOptions(opts: DisplayOptions) { + setOptions(opts: TileGLOptions) { let needsRepaint = super.setOptions(opts); this._updateSize(); @@ -57,6 +69,13 @@ export default class TileGL extends Backend { return needsRepaint; } + protected defaultedOptions(options: TileGLOptions): Required { + return { + ...this.DEFAULTS, + ...options, + } + } + draw(data: DisplayData, clearBefore: boolean) { const gl = this._gl; const opts = this._options; diff --git a/src/display/tile.ts b/src/display/tile.ts index b9dc13af..1717ea4d 100644 --- a/src/display/tile.ts +++ b/src/display/tile.ts @@ -1,5 +1,5 @@ import { BaseCanvas } from "./canvas.js"; -import { DisplayOptions, DisplayData, UnknownBackend } from "./types.js"; +import { TileDisplayOptions, DisplayData, UnknownBackend, DefaultsFor, DisplayOptions } from "./types.js"; declare module "./types.js" { interface LayoutTypeBackendMap { @@ -7,22 +7,42 @@ declare module "./types.js" { } } +export interface TileOptions extends TileDisplayOptions { + layout: "tile"; +} + /** * @class Tile backend * @private */ -export default class Tile extends BaseCanvas { +export default class Tile extends BaseCanvas { _colorCanvas: HTMLCanvasElement; + protected get DEFAULTS() { + return { + ...super.DEFAULTS, + tileWidth: 32, + tileHeight: 32, + tileColorize: false, + } satisfies DefaultsFor; + } + constructor(oldBackend?: UnknownBackend) { super(oldBackend); this._colorCanvas = oldBackend instanceof Tile ? oldBackend._colorCanvas : document.createElement("canvas"); } - checkOptions(options: DisplayOptions): boolean { + checkOptions(options: DisplayOptions): options is TileOptions { return options.layout === "tile"; } + protected defaultedOptions(options: TileOptions): Required { + return { + ...this.DEFAULTS, + ...options, + } + } + draw(data: DisplayData, clearBefore: boolean) { const {x, y, chars, fgs, bgs} = data; diff --git a/src/display/types.ts b/src/display/types.ts index cbc5a4c6..c1a0e464 100644 --- a/src/display/types.ts +++ b/src/display/types.ts @@ -3,28 +3,43 @@ export interface LayoutTypeBackendMap { } export type LayoutType = {[TLayout in keyof LayoutTypeBackendMap]: TLayout}[keyof LayoutTypeBackendMap]; -export type UnknownBackend = IDisplayBackend; +export type AnyBackend = IDisplayBackend; +export type UnknownBackend = IDisplayBackend; -export interface DisplayOptions { - width: number; - height: number; - transpose: boolean; - layout: LayoutType; - fontSize: number; - spacing: number; - border: number; - forceSquareRatio: boolean; - fontFamily: string; - fontStyle: string; - fg: string; - bg: string; - tileWidth: number; - tileHeight: number; +export type BackendOptions = TBackend extends {setOptions(options: infer TOptions extends BaseDisplayOptions): any} ? TOptions : BaseDisplayOptions; + +export type LayoutBackend = LayoutTypeBackendMap[TLayout]; +export type LayoutOptions = BackendOptions> + +export interface BaseDisplayOptions { + width?: number; + height?: number; + layout?: LayoutType; + fg?: string; + bg?: string; +} + +export interface TextDisplayOptions extends BaseDisplayOptions { + fontSize?: number; + spacing?: number; + border?: number; + fontFamily?: string; + fontStyle?: string; +} + +export interface TileDisplayOptions extends BaseDisplayOptions { + tileWidth?: number; + tileHeight?: number; tileMap: { [key: string]: [number, number] }; tileSet: null | HTMLCanvasElement | HTMLImageElement | HTMLVideoElement | ImageBitmap; - tileColorize: Boolean; + tileColorize?: boolean; } +export type DisplayOptions = LayoutOptions; + +export type DefaultsFor = {[K in keyof T as undefined extends T[K] ? K : never]-?: T[K]} +export type Frozen = {readonly [K in keyof T]-?: NonNullable | (null extends T[K] ? null : never)} + import type Display from "./display.js"; // for jsdoc only /** @@ -70,8 +85,9 @@ export interface DisplayData { /** * This is the contract a Backend must satisfy for it to be used by a Display. + * @template TOptions The full set of options appropriate to this Backend. */ -export interface IDisplayBackend { +export interface IDisplayBackend { /** * Get the root-level HTMLElement containing this display, or null if this backend is unrelated to HTML */ @@ -81,27 +97,31 @@ export interface IDisplayBackend { * @param options The full (defaulted) set of options from the Display * @returns `true` if setOptions() can be called with this argument */ - checkOptions(options: DisplayOptions): boolean; + checkOptions(options: BaseDisplayOptions): options is TOptions; /** * Set the options for this backend, and report whether a full repaint is needed. * @param options The full (defaulted) set of options from the Display * @returns `true` if the Display must do a full repaint after this */ - setOptions(options: DisplayOptions): boolean; + setOptions(options: TOptions): boolean; + /** + * Returns the currently-effective options for this backend (the options set by setOptions along with applicable defaults) + */ + getOptions(): Frozen; /** * Schedule a callback at the next appropriate time to perform drawing updates. * @param cb The callback to schedule. */ schedule(cb: ()=>void): void; /** - * Clear the entire display, resetting it to the color specified by {@link DisplayOptions.bg}. + * Clear the entire display, resetting it to the color specified by {@link BaseDisplayOptions.bg}. */ clear(): void; /** * Draw the specified cell. * @param data The data to draw. * @param clearBefore Whether to clear the cell to transparent prior to drawing. This will always be set if - * `data.bg` is not equal to {@link DisplayOptions.bg}, and also if this cell + * `data.bg` is not equal to {@link BaseDisplayOptions.bg}, and also if this cell * has been drawn since the last call to {@link clear()}. */ draw(data: DisplayData, clearBefore: boolean): void; From 12c4bca8664d2eeffb30ac29b4638d1acb5c775d Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 26 Mar 2024 21:26:45 -0400 Subject: [PATCH 5/7] Allow display backends to specify data format Following up the previous commit, this allows each display backend to specify a different type for the three components of display data: characters, fg style, and bg style. This means that a Display created for a tile backend (which allows arrays for all three components) will accept either an array or a bare string as arguments to the draw() method, while a Display created for a term backend (which has no character overlay support) only allows bare strings in the signature. --- src/display/backend.ts | 9 ++++-- src/display/canvas.ts | 15 ++++++++-- src/display/display.ts | 62 ++++++++++++++++++++++++++---------------- src/display/hex.ts | 10 ++++--- src/display/rect.ts | 15 ++++++---- src/display/term.ts | 8 ++++-- src/display/tile-gl.ts | 6 ++-- src/display/tile.ts | 6 ++-- src/display/types.ts | 30 ++++++++++++-------- 9 files changed, 103 insertions(+), 58 deletions(-) diff --git a/src/display/backend.ts b/src/display/backend.ts index f3a7d752..05808530 100644 --- a/src/display/backend.ts +++ b/src/display/backend.ts @@ -5,8 +5,11 @@ import { DisplayOptions, DisplayData, IDisplayBackend, DefaultsFor, Frozen, Base * @class Abstract display backend module * @private */ -export default abstract class Backend - implements IDisplayBackend { +export default abstract class Backend = DisplayData, + > + implements IDisplayBackend { protected get DEFAULTS() { return { width: DEFAULT_WIDTH, @@ -34,7 +37,7 @@ export default abstract class Backendvoid): void; abstract clear(): void; - abstract draw(data: DisplayData, clearBefore: boolean): void; + abstract draw(data: TData, clearBefore: boolean): void; abstract computeSize(availWidth: number, availHeight: number): [number, number]; abstract computeFontSize(availWidth: number, availHeight: number): number; abstract eventToPosition(x:number, y:number): [number, number]; diff --git a/src/display/canvas.ts b/src/display/canvas.ts index d4acc25d..fc61cd0b 100644 --- a/src/display/canvas.ts +++ b/src/display/canvas.ts @@ -1,10 +1,14 @@ import Backend from "./backend.js"; -import { BaseDisplayOptions, TextDisplayOptions, UnknownBackend, DefaultsFor } from "./types.js"; +import { BaseDisplayOptions, DisplayData, TextDisplayOptions, UnknownBackend, DefaultsFor } from "./types.js"; /** * Base class for any backend that uses a `` element as its display surface */ -export abstract class BaseCanvas extends Backend { +export abstract class BaseCanvas, + TChar = string[], TFGColor = string, TBGColor = string, + > + extends Backend ? TData : never> { _ctx: CanvasRenderingContext2D; constructor(oldBackend?: UnknownBackend) { @@ -51,10 +55,15 @@ export abstract class BaseCanvas extends Ba abstract _updateSize(): void; } +export type CanvasDisplayData = DisplayData; /** * Base class for text canvases, which can display one or more text characters with a single foreground and a background color in each cell. */ -export default abstract class Canvas extends BaseCanvas { +export default abstract class Canvas + extends BaseCanvas { protected get DEFAULTS() { return { ...super.DEFAULTS, diff --git a/src/display/display.ts b/src/display/display.ts index e25974fb..39b29434 100644 --- a/src/display/display.ts +++ b/src/display/display.ts @@ -5,9 +5,14 @@ import TileGL from "./tile-gl.js"; import Term from "./term.js"; import * as Text from "../text.js"; -import { DisplayOptions, DisplayData, LayoutType, IDisplayBackend, UnknownBackend, LayoutOptions, BaseDisplayOptions, Frozen } from "./types.js"; - -export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend) => IDisplayBackend>} = { +import { + DisplayOptions, DisplayData, BaseDisplayOptions, + LayoutType, LayoutBackend, LayoutOptions, LayoutChars, LayoutFGColor, LayoutBGColor, + IDisplayBackend, UnknownBackend, BackendChars, BackendFGColor, BackendBGColor, + Unwrapped, Frozen, +} from "./types.js"; + +export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend) => IDisplayBackend, any>} = { "hex": Hex, "rect": Rect, "tile": Tile, @@ -18,13 +23,13 @@ export const BACKENDS: {[TLayout in LayoutType]: new(oldBackend?: UnknownBackend /** * @class Visual map display */ -class DisplayImpl { +class DisplayImpl { static readonly DEFAULT_LAYOUT = "rect" satisfies LayoutType; - _data: { [pos:string] : DisplayData }; + _data: { [pos:string] : DisplayData }; _dirty: boolean | { [pos: string]: boolean }; _options!: LayoutOptions & Required; - _backend!: IDisplayBackend>; + _backend!: IDisplayBackend, DisplayData>; static Rect = Rect; static Hex = Hex; @@ -52,7 +57,7 @@ class DisplayImpl { */ DEBUG(x: number, y: number, what: number) { let colors = [this._options.bg, this._options.fg]; - this.draw(x, y, null, null, colors[what % colors.length]); + this.draw(x, y, null, null, colors[what % colors.length] as TBGColor); } /** @@ -151,10 +156,10 @@ class DisplayImpl { * @param fg foreground color * @param bg background color */ - draw(x: number, y: number, ch: string | string[] | null, fg: string | null = null, bg: string | null = null) { + draw(x: number, y: number, ch: TChar | Unwrapped | null, fg: TFGColor | Unwrapped | null = null, bg: TBGColor | Unwrapped | null = null) { let key = `${x},${y}`; const data = this._data[key] ??= {x, y, chars: [], fgs: [], bgs: [], ch: null!, fg: null!, bg: null!}; - if (this._setData(data, ch, fg ?? this._options.fg, bg ?? this._options.bg)) { + if (this._setData(data, ch, fg ?? (this._options.fg as TFGColor), bg ?? (this._options.bg as TBGColor))) { this._setDirty(key); } } @@ -175,9 +180,9 @@ class DisplayImpl { drawOver( x: number, y: number, - ch: string | string[] | null = null, - fg: string | null = null, - bg: string | null = null, + ch: TChar | Unwrapped | null = null, + fg: TFGColor | Unwrapped | null = null, + bg: TBGColor | Unwrapped | null = null, ) { const key = `${x},${y}`; const existing = this._data[key]; @@ -224,7 +229,7 @@ class DisplayImpl { let isCJK = cch === 0x11 || (cch >= 0x2e && cch <= 0x9f) || (cch >= 0xac && cch <= 0xd7) || (cc >= 0xA960 && cc <= 0xA97F); if (isCJK) { this.draw(cx + 0, cy, c, fg, bg); - this.draw(cx + 1, cy, "\t", fg, bg); + this.draw(cx + 1, cy, "\t" as TChar, fg, bg); cx += 2; continue; } @@ -294,19 +299,19 @@ class DisplayImpl { this._backend.draw(data, clearBefore); } - _setData(data: DisplayData, ch: string | string[] | null, fg: string, bg: string) { + _setData(data: DisplayData, ch: TChar | Unwrapped | null, fg: TFGColor | Unwrapped, bg: TBGColor | Unwrapped) { let changed = false; if (data.ch !== ch) { changed = true; - data.ch = ch; + data.ch = ch as TChar; } if (data.fg !== fg) { changed = true; - data.fg = fg; + data.fg = fg as TFGColor; } if (data.bg !== bg) { changed = true; - data.bg = bg; + data.bg = bg as TBGColor; } changed = setArrayValue(data.chars, ch) || changed; changed = setArrayValue(data.fgs, fg) || changed; @@ -343,7 +348,7 @@ function setArrayValue(array: T[], value: T | T[] | null) { } // Redeclaring all public API in the Display interface so that hovers etc show the right name -export interface Display extends DisplayImpl { +export interface Display, TFGColor = LayoutFGColor, TBGColor = LayoutBGColor> extends DisplayImpl { DEBUG(x: number, y: number, what: number): void; clear(): void; setOptions(options: LayoutOptions): this; @@ -354,22 +359,31 @@ export interface Display extends Displa computeFontSize(availWidth: number, availHeight: number): number; computeTileSize(availWidth: number, availHeight: number): [w: number, h: number]; eventToPosition(e: TouchEvent | MouseEvent): [x: number, y: number]; - draw(x: number, y: number, ch: string | string[] | null, fg?: string | null, bg?: string | null): void; - drawOver(x: number, y: number, ch?: string | string[] | null, fg?: string | null, bg?: string | null): void; + draw(x: number, y: number, ch: TChar | Unwrapped | null, fg?: TFGColor | Unwrapped | null, bg?: TBGColor | Unwrapped | null): void; + drawOver(x: number, y: number, ch?: TChar | Unwrapped | null, fg?: TFGColor | Unwrapped | null, bg?: TBGColor | Unwrapped | null): void; drawText(x:number, y:number, text:string, maxWidth?:number): number; } type LayoutFor = TOptions extends {layout: infer L} ? NonNullable : typeof DisplayImpl["DEFAULT_LAYOUT"]; +type BackendFor = LayoutBackend>; +type TCharFromOptions = BackendChars>; +type TFGColorFromOptions = BackendFGColor>; +type TBGColorFromOptions = BackendBGColor>; -// This is, sadly, the only way to be able to explicitly declare the type arguments of the returned Display instance export interface DisplayConstructor { DEFAULT_LAYOUT: LayoutType; new = LayoutOptions> - (options?: TOptions): Display>; + TChar extends LayoutChars = LayoutChars, + TFGColor extends LayoutFGColor = LayoutFGColor, + TBGColor extends LayoutBGColor = LayoutBGColor, + TOptions extends LayoutOptions = LayoutOptions>(options?: TOptions): + Display, + TChar extends TCharFromOptions ? TChar : TCharFromOptions, + TFGColor extends TFGColorFromOptions ? TFGColor : TFGColorFromOptions, + TBGColor extends TBGColorFromOptions ? TBGColor : TBGColorFromOptions>; } -export const Display: DisplayConstructor = class Display extends DisplayImpl { +export const Display = class Display extends DisplayImpl { } as any; export default Display; \ No newline at end of file diff --git a/src/display/hex.ts b/src/display/hex.ts index 12229eea..d553ddfe 100644 --- a/src/display/hex.ts +++ b/src/display/hex.ts @@ -1,5 +1,5 @@ -import Canvas from "./canvas.js"; -import { TextDisplayOptions, DisplayData, DefaultsFor, DisplayOptions } from "./types.js"; +import Canvas, { CanvasDisplayData } from "./canvas.js"; +import { TextDisplayOptions, DefaultsFor, DisplayOptions } from "./types.js"; import { mod } from "../util.js"; declare module "./types.js" { @@ -12,12 +12,14 @@ export interface HexOptions extends TextDisplayOptions { layout: "hex"; transpose?: boolean; } +export interface HexData extends CanvasDisplayData { +} /** * @class Hexagonal backend * @private */ -export default class Hex extends Canvas { +export default class Hex extends Canvas { protected get DEFAULTS() { return { ...super.DEFAULTS, @@ -38,7 +40,7 @@ export default class Hex extends Canvas { ...options, } } - draw(data: DisplayData, clearBefore: boolean) { + draw(data: HexData, clearBefore: boolean) { const {x, y, chars, fg, bg} = data; let px = [ diff --git a/src/display/rect.ts b/src/display/rect.ts index b0989615..97cb2f36 100644 --- a/src/display/rect.ts +++ b/src/display/rect.ts @@ -1,5 +1,5 @@ -import Canvas from "./canvas.js"; -import { DefaultsFor, DisplayData, DisplayOptions, TextDisplayOptions } from "./types.js"; +import Canvas, { CanvasDisplayData } from "./canvas.js"; +import { DefaultsFor, DisplayOptions, TextDisplayOptions } from "./types.js"; declare module "./types.js" { interface LayoutTypeBackendMap { @@ -12,11 +12,14 @@ export interface RectOptions extends TextDisplayOptions { forceSquareRatio?: boolean; } +export interface RectData extends CanvasDisplayData { +} + /** * @class Rectangular backend * @private */ -export default class Rect extends Canvas { +export default class Rect extends Canvas { protected get DEFAULTS() { return { ...super.DEFAULTS, @@ -44,7 +47,7 @@ export default class Rect extends Canvas { } } - draw(data: DisplayData, clearBefore: boolean) { + draw(data: RectData, clearBefore: boolean) { if (Rect.cache) { this._drawWithCache(data); } else { @@ -52,7 +55,7 @@ export default class Rect extends Canvas { } } - _drawWithCache(data: DisplayData) { + _drawWithCache(data: RectData) { const {x, y, ch, chars, fg, bg} = data; let hash = ""+ch+fg+bg; @@ -84,7 +87,7 @@ export default class Rect extends Canvas { this._ctx.drawImage(canvas, x*this._spacingX, y*this._spacingY); } - _drawNoCache(data: DisplayData, clearBefore: boolean) { + _drawNoCache(data: RectData, clearBefore: boolean) { const {x, y, chars, fg, bg} = data; if (clearBefore) { diff --git a/src/display/term.ts b/src/display/term.ts index fae7bb19..e8fbbe84 100644 --- a/src/display/term.ts +++ b/src/display/term.ts @@ -1,5 +1,5 @@ import Backend from "./backend.js"; -import { DisplayOptions, DisplayData, BaseDisplayOptions } from "./types.js"; +import { BaseDisplayOptions, DisplayData, DisplayOptions } from "./types.js"; import * as Color from "../color.js"; declare module "./types.js" { @@ -50,8 +50,10 @@ function termcolor(color: string) { export interface TermOptions extends BaseDisplayOptions { layout: "term"; } +export interface TermData extends DisplayData { +} -export default class Term extends Backend { +export default class Term extends Backend { _offset: [number, number]; _cursor: [number, number]; _lastColor: string; @@ -89,7 +91,7 @@ export default class Term extends Backend { process.stdout.write(clearToAnsi(this._options.bg)); } - draw(data: DisplayData, clearBefore: boolean) { + draw(data: TermData, clearBefore: boolean) { // determine where to draw what with what colors let {x, y, ch, fg, bg} = data; diff --git a/src/display/tile-gl.ts b/src/display/tile-gl.ts index a8a234db..862b081b 100644 --- a/src/display/tile-gl.ts +++ b/src/display/tile-gl.ts @@ -11,12 +11,14 @@ declare module "./types.js" { export interface TileGLOptions extends TileDisplayOptions { layout: "tile-gl"; } +export interface TileGLData extends DisplayData { +} /** * @class Tile backend * @private */ -export default class TileGL extends Backend { +export default class TileGL extends Backend { _gl!: WebGLRenderingContext; _program!: WebGLProgram; _uniforms: {[key:string]: WebGLUniformLocation | null}; @@ -76,7 +78,7 @@ export default class TileGL extends Backend { } } - draw(data: DisplayData, clearBefore: boolean) { + draw(data: TileGLData, clearBefore: boolean) { const gl = this._gl; const opts = this._options; const {x, y, chars, fgs, bgs} = data; diff --git a/src/display/tile.ts b/src/display/tile.ts index 1717ea4d..e5fda15a 100644 --- a/src/display/tile.ts +++ b/src/display/tile.ts @@ -10,12 +10,14 @@ declare module "./types.js" { export interface TileOptions extends TileDisplayOptions { layout: "tile"; } +export interface TileData extends DisplayData { +} /** * @class Tile backend * @private */ -export default class Tile extends BaseCanvas { +export default class Tile extends BaseCanvas { _colorCanvas: HTMLCanvasElement; protected get DEFAULTS() { @@ -43,7 +45,7 @@ export default class Tile extends BaseCanvas { } } - draw(data: DisplayData, clearBefore: boolean) { + draw(data: TileData, clearBefore: boolean) { const {x, y, chars, fgs, bgs} = data; let tileWidth = this._options.tileWidth; diff --git a/src/display/types.ts b/src/display/types.ts index c1a0e464..35b14477 100644 --- a/src/display/types.ts +++ b/src/display/types.ts @@ -3,13 +3,19 @@ export interface LayoutTypeBackendMap { } export type LayoutType = {[TLayout in keyof LayoutTypeBackendMap]: TLayout}[keyof LayoutTypeBackendMap]; -export type AnyBackend = IDisplayBackend; -export type UnknownBackend = IDisplayBackend; +export type AnyBackend = IDisplayBackend; +export type UnknownBackend = IDisplayBackend; export type BackendOptions = TBackend extends {setOptions(options: infer TOptions extends BaseDisplayOptions): any} ? TOptions : BaseDisplayOptions; +export type BackendChars = TBackend extends IDisplayBackend ? TData["ch"] : never +export type BackendFGColor = TBackend extends IDisplayBackend ? TData["fg"] : never; +export type BackendBGColor = TBackend extends IDisplayBackend ? TData["bg"] : never; export type LayoutBackend = LayoutTypeBackendMap[TLayout]; export type LayoutOptions = BackendOptions> +export type LayoutChars = BackendChars> +export type LayoutFGColor = BackendFGColor> +export type LayoutBGColor = BackendBGColor> export interface BaseDisplayOptions { width?: number; @@ -37,6 +43,7 @@ export interface TileDisplayOptions extends BaseDisplayOptions { export type DisplayOptions = LayoutOptions; +export type Unwrapped = T extends readonly (infer I)[] ? I : T; export type DefaultsFor = {[K in keyof T as undefined extends T[K] ? K : never]-?: T[K]} export type Frozen = {readonly [K in keyof T]-?: NonNullable | (null extends T[K] ? null : never)} @@ -66,28 +73,29 @@ import type Display from "./display.js"; // for jsdoc only * draw() method, as object destructuring assignment does not require instantiating an Iterator the way that array * destructuring does. */ -export interface DisplayData { +export interface DisplayData { /** X coordinate of display cell. */ readonly x: number; /** Y coordinate of display cell. */ readonly y: number; // Normalized array forms of data - readonly chars: string[]; - readonly fgs: string[]; - readonly bgs: string[]; + readonly chars: Unwrapped[]; + readonly fgs: Unwrapped[]; + readonly bgs: Unwrapped[]; // User-passed data - ch: string | string[] | null; - fg: string; - bg: string; + ch: TChar; + fg: TFGColor; + bg: TBGColor; } /** * This is the contract a Backend must satisfy for it to be used by a Display. * @template TOptions The full set of options appropriate to this Backend. + * @template TData The type that must be passed to the `draw` method. */ -export interface IDisplayBackend { +export interface IDisplayBackend { /** * Get the root-level HTMLElement containing this display, or null if this backend is unrelated to HTML */ @@ -124,7 +132,7 @@ export interface IDisplayBackend { * `data.bg` is not equal to {@link BaseDisplayOptions.bg}, and also if this cell * has been drawn since the last call to {@link clear()}. */ - draw(data: DisplayData, clearBefore: boolean): void; + draw(data: TData, clearBefore: boolean): void; /** * Compute the maximum width/height to fit into a set of given constraints. See {@link Display.computeSize()}. */ From 4f0c770b3b27dd5813a5d4db05eca2a81f3ccda3 Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 26 Mar 2024 21:41:09 -0400 Subject: [PATCH 6/7] Allow non-string keys for tilemaps Now that each backend can specify its own types for allowable characters, the tile and tile-gl layouts can use number or symbol keys for each tile, rather than requiring that each "character" be a string. --- src/display/display.ts | 9 ++++++--- src/display/hex.ts | 2 +- src/display/rect.ts | 2 +- src/display/term.ts | 2 +- src/display/tile-gl.ts | 24 ++++++++++++------------ src/display/tile.ts | 22 +++++++++++----------- src/display/types.ts | 19 ++++++++++--------- 7 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src/display/display.ts b/src/display/display.ts index 39b29434..bd6a91a7 100644 --- a/src/display/display.ts +++ b/src/display/display.ts @@ -365,8 +365,11 @@ export interface Display = TOptions extends {layout: infer L} ? NonNullable : typeof DisplayImpl["DEFAULT_LAYOUT"]; -type BackendFor = LayoutBackend>; -type TCharFromOptions = BackendChars>; +type BackendFor = LayoutBackend, TOptions>; +type TCharFromOptions = + TOptions extends {tileMap: Record} ? TKey[] + : TOptions extends {tileMap: readonly any[]} ? number[] + : BackendChars>; type TFGColorFromOptions = BackendFGColor>; type TBGColorFromOptions = BackendBGColor>; @@ -384,6 +387,6 @@ export interface DisplayConstructor { } export const Display = class Display extends DisplayImpl { -} as any; +} as unknown as DisplayConstructor; export default Display; \ No newline at end of file diff --git a/src/display/hex.ts b/src/display/hex.ts index d553ddfe..5e773f06 100644 --- a/src/display/hex.ts +++ b/src/display/hex.ts @@ -3,7 +3,7 @@ import { TextDisplayOptions, DefaultsFor, DisplayOptions } from "./types.js"; import { mod } from "../util.js"; declare module "./types.js" { - interface LayoutTypeBackendMap { + interface LayoutTypeBackendMap { hex: Hex; } } diff --git a/src/display/rect.ts b/src/display/rect.ts index 97cb2f36..e83e6aac 100644 --- a/src/display/rect.ts +++ b/src/display/rect.ts @@ -2,7 +2,7 @@ import Canvas, { CanvasDisplayData } from "./canvas.js"; import { DefaultsFor, DisplayOptions, TextDisplayOptions } from "./types.js"; declare module "./types.js" { - interface LayoutTypeBackendMap { + interface LayoutTypeBackendMap { rect: Rect; } } diff --git a/src/display/term.ts b/src/display/term.ts index e8fbbe84..bea31704 100644 --- a/src/display/term.ts +++ b/src/display/term.ts @@ -3,7 +3,7 @@ import { BaseDisplayOptions, DisplayData, DisplayOptions } from "./types.js"; import * as Color from "../color.js"; declare module "./types.js" { - interface LayoutTypeBackendMap { + interface LayoutTypeBackendMap { term: Term; } } diff --git a/src/display/tile-gl.ts b/src/display/tile-gl.ts index 862b081b..58d73292 100644 --- a/src/display/tile-gl.ts +++ b/src/display/tile-gl.ts @@ -1,24 +1,24 @@ import Backend from "./backend.js"; -import { TileDisplayOptions, DisplayData, DefaultsFor, DisplayOptions } from "./types.js"; +import { TileDisplayOptions, DisplayData, TileMapKey, DefaultsFor, DisplayOptions } from "./types.js"; import * as Color from "../color.js"; declare module "./types.js" { - interface LayoutTypeBackendMap { - "tile-gl": TileGL; + interface LayoutTypeBackendMap { + "tile-gl": TileGL} ? TKey : string>; } } -export interface TileGLOptions extends TileDisplayOptions { +export interface TileGLOptions extends TileDisplayOptions { layout: "tile-gl"; } -export interface TileGLData extends DisplayData { +export interface TileGLData extends DisplayData { } /** * @class Tile backend * @private */ -export default class TileGL extends Backend { +export default class TileGL extends Backend, TChar[], string[], string[], TileGLData> { _gl!: WebGLRenderingContext; _program!: WebGLProgram; _uniforms: {[key:string]: WebGLUniformLocation | null}; @@ -29,7 +29,7 @@ export default class TileGL extends Backend; + } satisfies DefaultsFor>; } static isSupported() { return !!document.createElement("canvas").getContext("webgl2", {preserveDrawingBuffer:true}); @@ -52,11 +52,11 @@ export default class TileGL extends Backend void) { requestAnimationFrame(cb); } getContainer() { return this._gl.canvas as HTMLCanvasElement; } - checkOptions(options: DisplayOptions): options is TileGLOptions { + checkOptions(options: DisplayOptions): options is TileGLOptions { return options.layout === "tile-gl"; } - setOptions(opts: TileGLOptions) { + setOptions(opts: TileGLOptions) { let needsRepaint = super.setOptions(opts); this._updateSize(); @@ -71,14 +71,14 @@ export default class TileGL extends Backend { + protected defaultedOptions(options: TileGLOptions): Required> { return { ...this.DEFAULTS, ...options, } } - draw(data: TileGLData, clearBefore: boolean) { + draw(data: TileGLData, clearBefore: boolean) { const gl = this._gl; const opts = this._options; const {x, y, chars, fgs, bgs} = data; @@ -101,7 +101,7 @@ export default class TileGL extends Backend { + "tile": Tile} ? TKey : string>; } } -export interface TileOptions extends TileDisplayOptions { +export interface TileOptions extends TileDisplayOptions { layout: "tile"; } -export interface TileData extends DisplayData { +export interface TileData extends DisplayData { } /** * @class Tile backend * @private */ -export default class Tile extends BaseCanvas { +export default class Tile extends BaseCanvas, TileData, TChar[], string[], string[]> { _colorCanvas: HTMLCanvasElement; protected get DEFAULTS() { @@ -26,7 +26,7 @@ export default class Tile extends BaseCanvas; + } satisfies DefaultsFor>; } constructor(oldBackend?: UnknownBackend) { @@ -34,18 +34,18 @@ export default class Tile extends BaseCanvas { return options.layout === "tile"; } - protected defaultedOptions(options: TileOptions): Required { + protected defaultedOptions(options: TileOptions): Required> { return { ...this.DEFAULTS, ...options, } } - draw(data: TileData, clearBefore: boolean) { + draw(data: TileData, clearBefore: boolean) { const {x, y, chars, fgs, bgs} = data; let tileWidth = this._options.tileWidth; @@ -70,7 +70,7 @@ export default class Tile extends BaseCanvas { // Entries in this map are added via declaration merging, see the top of e.g. rect.ts } -export type LayoutType = {[TLayout in keyof LayoutTypeBackendMap]: TLayout}[keyof LayoutTypeBackendMap]; +export type LayoutType = {[TLayout in keyof LayoutTypeBackendMap]: TLayout}[keyof LayoutTypeBackendMap]; export type AnyBackend = IDisplayBackend; export type UnknownBackend = IDisplayBackend; @@ -11,11 +11,11 @@ export type BackendChars = TBackend extends IDispla export type BackendFGColor = TBackend extends IDisplayBackend ? TData["fg"] : never; export type BackendBGColor = TBackend extends IDisplayBackend ? TData["bg"] : never; -export type LayoutBackend = LayoutTypeBackendMap[TLayout]; -export type LayoutOptions = BackendOptions> -export type LayoutChars = BackendChars> -export type LayoutFGColor = BackendFGColor> -export type LayoutBGColor = BackendBGColor> +export type LayoutBackend = LayoutTypeBackendMap[TLayout]; +export type LayoutOptions = BackendOptions> +export type LayoutChars = BackendChars> +export type LayoutFGColor = BackendFGColor> +export type LayoutBGColor = BackendBGColor> export interface BaseDisplayOptions { width?: number; @@ -33,10 +33,11 @@ export interface TextDisplayOptions extends BaseDisplayOptions { fontStyle?: string; } -export interface TileDisplayOptions extends BaseDisplayOptions { +export type TileMapKey = string | number | symbol; +export interface TileDisplayOptions extends BaseDisplayOptions { tileWidth?: number; tileHeight?: number; - tileMap: { [key: string]: [number, number] }; + tileMap: { [K in TChar]: [number, number] }; tileSet: null | HTMLCanvasElement | HTMLImageElement | HTMLVideoElement | ImageBitmap; tileColorize?: boolean; } From 493d3451e02812630d8f9fa37e32407c9c5e60ab Mon Sep 17 00:00:00 2001 From: Danielle Church Date: Tue, 26 Mar 2024 21:47:50 -0400 Subject: [PATCH 7/7] Add opacity fg-style for tile backend This is a customization from Deiphage; it allows you to pass a number rather than a string for the fg-style on a Tile-backed display, which allows for per-tile opacity customization without the overhead of tileColorize. --- src/display/tile.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/display/tile.ts b/src/display/tile.ts index 97e98fc0..c8f5e457 100644 --- a/src/display/tile.ts +++ b/src/display/tile.ts @@ -10,14 +10,15 @@ declare module "./types.js" { export interface TileOptions extends TileDisplayOptions { layout: "tile"; } -export interface TileData extends DisplayData { +type TileFGColor = string | number; // string = color, number = opacity +export interface TileData extends DisplayData { } /** * @class Tile backend * @private */ -export default class Tile extends BaseCanvas, TileData, TChar[], string[], string[]> { +export default class Tile extends BaseCanvas, TileData, TChar[], TileFGColor[], string[]> { _colorCanvas: HTMLCanvasElement; protected get DEFAULTS() { @@ -87,7 +88,7 @@ export default class Tile extends BaseCanvas< 0, 0, tileWidth, tileHeight ); - if (fg != "transparent") { + if (typeof fg === "string" && fg != "transparent") { context.fillStyle = fg; context.globalCompositeOperation = "source-atop"; context.fillRect(0, 0, tileWidth, tileHeight); @@ -99,8 +100,15 @@ export default class Tile extends BaseCanvas< context.fillRect(0, 0, tileWidth, tileHeight); } + if (typeof fg === "number") { + this._ctx.globalAlpha = fg; + } this._ctx.drawImage(canvas, x*tileWidth, y*tileHeight, tileWidth, tileHeight); } else { // no colorizing, easy + let fg = fgs[i]; + if (typeof fg === "number") { + this._ctx.globalAlpha = fg; + } this._ctx.drawImage( this._options.tileSet!, tile[0], tile[1], tileWidth, tileHeight, @@ -108,6 +116,7 @@ export default class Tile extends BaseCanvas< ); } } + this._ctx.globalAlpha = 1; } computeSize(availWidth: number, availHeight: number): [number, number] {