Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display/Backend refactor #217

Closed
wants to merge 7 commits into from
Closed

Display/Backend refactor #217

wants to merge 7 commits into from

Conversation

dmchurch
Copy link
Contributor

This is not actually a pull request; I don't plan to take this out of draft state, as this isn't a particularly feature-rich change. This is, however, the end result of the first major bit of work I have planned, so I wanted to give you a chance to look over it and have opinions. This non-PR encompasses two changes:

Display genericization

The TL;DR of this is that the Display class is now generic; it will present different capabilities depending on the options object (and most specifically its layout property) used to construct it. The type signature is Display<TLayout, TChars, TFGColor, TBGColor>, and all of these are set from the options parameter. For example, creating a hex-layout Display with

const dpy = new Display({layout: "hex"});
// dpy is of type Display<'hex', string[], string, string>

will result in a Display object whose draw() method is typed as:

draw(x: number, y: number, ch: string | string[] | null, fg?: string | null, bg?: string | null);

since the Hex backend can support multiple characters (the string[] TChars type argument), but only a single foreground and background color (the string type arguments). On the other hand, the TileGL backend supports multiple values for all three parameters, the construct signature and resulting draw() method are:

const dpy = new Display({layout: "tile-gl", /* ... */};
// dpy is of type Display<'tile-gl', string[], string[], string[]>
draw(x: number, y: number, ch: string | string[] | null, fg?: string | string[] | null, bg?: string | string[] | null);

Note that, when explicitly typing variables and properties, only the first type parameter, TLayout, needs to be declared, and the other three will take their default values for the given backend:

let dpy: Display<'hex'>;
// dpy is of type Display<'hex', string[], string, string>, as above
dpy = new Display(); // error, value of type Display<'rect', string[], string, string> cannot be assigned to variable
dpy = new Display({layout: "hex"}); // success

Tile backend improvements

The above is most of the code change involved here, and all of that was in service of being able to pull some small tweaks to the Tile backend from the Deiphage codebase into rot.js proper. The major one is that you can now pass a number (or array of numbers) to the fg parameter of Display.draw(), and they will be used as the opacity with which to draw the tile or tiles. So, the expanded signatures for the tile layout are:

let dpy: Display<'tile'>; // expanded type: Display<'tile', string[], (string | number)[], string[]>
draw(x: number,
     y: number,
     ch: string | string[] | null,
     fg?: string | number | (string | number)[] | null,
     bg?: string | string[] | null);

The other change is that the Tile and TileGL backends will now accept tile mappings with symbol or number keys, as well as those with string keys, or any combination or restriction thereof. For example, you can pass an array of [x, y] tile coordinates as the tileMap parameter, in which case it acts as a number-keyed map:

const dpy = new Display({
  layout: "tile",
  tileMap: [
    [0, 0], // tile 0
    [16, 0], // tile 1
    [32, 0], // tile 2
  ],
  /* ... */
});
// dpy is of type Display<'tile', number[], (string | number)[], string[]>,
// which can be abbreviated as Display<'tile', number[]>

// to draw tile 1 at 50% opacity at coordinate 3,5:
dpy.draw(3, 5, 1, 0.5);

// to draw tile 1 opaque at coordinate 5,10, with tile 2 overlaid at 25% opacity, on a red background:
dpy.draw(5, 10, [1, 2], [1, 0.25], "red");

// explicit maps will type TChars by the properties in question:
const dpy2 = new Display({
  layout:  "tile",
  tileMap: {
    "@": [0, 0],
    "f": [16, 0],
    "d": [32, 0],
  }
});
// dpy2 is of type Display<'tile', ('@' | 'f' | 'd')[], (string | number)[], string[]>

This is a fairly hefty code change, so you may want to go commit-by-commit on this one if and when you look through it; I've done my best to keep the history at least fairly coherent. And, as always, you're under no obligation, but if you do look it over, I'd certainly appreciate any feedback or thoughts!

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.
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.
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.
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.
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.
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.
@ondras
Copy link
Owner

ondras commented Mar 28, 2024

Yeah, this is quite a lot to chew on. Especially since my ts-fu is not really up-to-date; I have no idea what satisfies does, how exactly Unwrapped/infer works or what is means.

Also, some CI checks are failing - probably because your IDEs TS implementation is a lot newer than the one used in the check/build process?

I understand that most of the work is done purely to satisfy TS with the Display multi-morphic nature. I agree with the concept, but I also note that this makes the codebase a lot more complex to navigate and/or comprehend.

A wild idea: is it really useful/necessary to try providing a unified ROT.Display API for all compatible backends? We are somewhat caught in the OOP inheritance trap, trying to shape all backend implementations into a common abstract interface. This introduces quite a lot of complex TS annotations and gives just one bonus: the ability to easily switch different backends/layouts. But: is someone actually doing that? Is there a point in having a setOptions(layout: somethingCompletelyDifferent) call? Do we support drawText with the tile backend? Perhaps it would be more useful to provide a set of independent ROT.Display classes, switching the inheritance for composition (or simply independent implementations).

This is something I am thinking about quite a lot recently, as I am planning an independent project with a DOM-based TTY-like renderer. And the differences between text, individual characters, tiles, gl, hexes etc. are simply too large for a unified interface.

@dmchurch
Copy link
Contributor Author

Hmm, those are good points - and for sure, being able to switch layouts on the fly is probably not a particularly useful bit of functionality. I'd been making an effort to keep the JS API unchanged, but since you're fine with larger breaking changes here, I'll do another pass on this with an eye towards simplifying and streamlining the codebase 😄

(It's also interesting that you mention a DOM-based renderer - I'd been thinking while I was working on this refactor that it wouldn't be too hard to make a DOM-based Display backend!)

@dmchurch
Copy link
Contributor Author

Hmm, one thing I'm thinking is, I think I'm going to make a distinction between backdrop and foreground items, on account of the fact that in roguelikes, it's extremely common to use a static or mostly-static background with just a few actors moving around. Being able to describe that a character has moved from one coordinate to another is a more intuitive than repainting the old position with a floor glyph and repainting the new one with the character glyph. (It also allows for, say, movement effects, especially with a DOM-based renderer: all it takes is a CSS transition rule on the top and left properties and then actors will glide smoothly from one cell to another whenever they're repositioned.)

Given that, and just thinking aloud, the display subsystem can be broken up into a few different components:

  1. Layout/grid: responsible for translating "game coordinates" into "display coordinates" and vice versa. Needs to have a basic understanding of the display environment (pixels vs characters, total available space), should have the capability to translate (and potentially scale) the display, to allow for a shifting or zooming viewport. A Display will always have exactly one associated grid.
  2. Graphic layer: comes in one of a few varieties based on function, but all are responsible for storing a subset of displayable content. Each layer can be addressed, depending on implementation, either in game coordinates or display coordinates, but this cannot be changed after creation. Layers can live independent of a Display and can be added to or removed from a Display at any time; a single layer could even be shared by multiple displays (main screen + minimap, for example).
    • A paint layer is the equivalent of the current Display class; you can draw glyphs to any position and they will stay drawn until you erase them or clear the layer. This is what I'd expect to use for drawing the dungeon map, but also for UI overlays.
    • A sprite layer has a collection of sprites, objects that each have a glyph and a position. Sprites can be added, moved, or removed at any time.
    • An effect layer describes an effect that applies across many tiles, and it modulates the display of anything in a layer below it. This is what you can use for fog-of-war, lighting, etc; typically the only things you can do with an effects layer besides adding or removing it are to change global effect parameters (lighting color, etc) or change the intensity at any given position.
      Note that the layers here aren't related to the z-layer concept I'll eventually be using to render 3D environments; separate z-layers will almost certainly be implemented as separate Display elements.
  3. Compositor/renderer: responsible for taking the data from one or more graphic layers and displaying it onscreen. A terminal renderer, for example, needs only to walk the layer stack from the top down until it finds an opaque glyph to draw at a given screen position. A canvas renderer would draw each layer from the bottom up, like the existing backends, while a DOM renderer might create separate elements on each layer and allow the browser to perform the compositing. A Display will have at least one renderer but can have multiple; for example, you could use a canvas renderer to draw the static backdrop, while a DOM renderer displays the sprites.

Any thoughts? This architecture should work well for all the things I've done or plan to do with Deiphage, but you've got a lot more experience with the roguelike genre than I do - what use cases have I not accounted for here?

@dmchurch dmchurch closed this Mar 28, 2024
@dmchurch dmchurch deleted the rot3d branch March 28, 2024 18:40
@dmchurch dmchurch restored the rot3d branch March 28, 2024 18:40
@ondras
Copy link
Owner

ondras commented Mar 28, 2024

(It's also interesting that you mention a DOM-based renderer - I'd been thinking while I was working on this refactor that it wouldn't be too hard to make a DOM-based Display backend!)

Yeah. DOM-based rendering is something I actually experimented with way baaack in my first JS roguelike (https://ondras.github.io/js-like/), but it turned out to be bad for performance (many DOM elements back in ~2008 or so). But the situation is different today; browsers are more performant, we have the Web Animations API and I am particularly interested in beautiful vector fonts with large glyphs. Will let you know once I find some time to actually implement something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants