@chris.araneo/cassette-canvas 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Krzysztof Pająk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,358 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">Cassette Canvas</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/ChrisAraneo/cassette-canvas/master/cassette.png" width="512px" height="256px"/>
5
+ <br>
6
+ <a href="https://github.com/ChrisAraneo/cassette-canvas/blob/master/package.json"><img src="https://img.shields.io/badge/version-v0.2.0-blue" alt="version"></a>
7
+ <a href="https://github.com/ChrisAraneo/cassette-canvas/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="@chris.araneo/cassette-canvas is released under the MIT license."></a>
8
+ <a href="https://github.com/ChrisAraneo/cassette-canvas/actions/workflows/ci.yml"><img alt="GitHub CI Status" src="https://img.shields.io/github/actions/workflow/status/ChrisAraneo/cassette-canvas/ci.yml?label=CI&logo=GitHub"></a>
9
+ <br>
10
+ <br>
11
+ <em>Retro rendering engine for the HTML canvas.</em>
12
+ <br>
13
+ <br>
14
+ </p>
15
+
16
+ - Sprites use 16 colors with 8 shades each (128 colors).
17
+ - Pixels are large, and every sprite has the same size.
18
+ - Ambient light and point lights use a scale of 32 shades per color. Sprites can cast shadows.
19
+ - Retro monitor effect is always on: a soft glow around bright pixels, scanlines, film grain and a surface texture.
20
+
21
+ ## Installation
22
+
23
+ ```sh
24
+ npm install @chris.araneo/cassette-canvas
25
+ ```
26
+
27
+ The package is an ES module with TypeScript types included. It runs in the
28
+ browser and needs WebGL.
29
+
30
+ ## Concepts
31
+
32
+ | Concept | Meaning |
33
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | **Base colors** | The 16 colors you provide, as `#rrggbb`. |
35
+ | **Shade** | Sprites can use 8 shades of each color (0 to 7). Shade 4 (`BASE_SHADE`) is the base color. Shades 0 to 3 are darker and 5 to 7 are lighter. |
36
+ | **Extended shade** | Lighting uses 32 shades of each color (0 to 31). 4 extended shades = 1 shade. The palette holds all 16 × 32 = 512 colors. |
37
+ | **Pixel** | A number with the color in the high hex digit and the shade in the low hex digit. `0x35` is color 3, shade 5. `TRANSPARENT` (`-1`) draws nothing. |
38
+ | **Pixel size** | The size of one logical pixel in canvas pixels, for example `4`. The screen size in logical pixels is the canvas size divided by the pixel size. |
39
+ | **Sprite** | A flat array of `spriteSize × spriteSize` pixels, row by row from the top left. |
40
+ | **Screen** | Immutable data: the background, sprites, lights and ambient light. Later sprites are drawn over earlier ones. |
41
+ | **Display** | The canvas, its WebGL context, the monitor shader and the palette. You create it once. |
42
+ | **Ambient light** | Shifts every pixel by a number of extended shades. `0` keeps the authored colors. `-16` (4 shades darker) looks like night. |
43
+ | **Light** | A point at `row`, `column`. At its center it lifts a pixel that daylight already lights by `intensity` extended shades, fading to nothing at `radius`. |
44
+ | **Stop** | One doubling of light, worth `EXTENDED_SHADES_PER_STOP` (4) extended shades. Lights are added as light and then read off this scale, the way the eye does. |
45
+ | **Shadow** | A sprite with `castsShadow: true` blocks light. Pixels behind it get no light from that light, and the shadow's edge ramps rather than steps. Its side that faces the light is still lit. |
46
+ | **Monitor effect** | A shader that adds a 2-pixel glow, scanlines, film grain and a surface texture. It uses full RGB color, not the palette. |
47
+
48
+ ## Usage
49
+
50
+ ```ts
51
+ import {
52
+ addLight,
53
+ addSprite,
54
+ createScreen,
55
+ drawScreen,
56
+ patchAmbient,
57
+ patchLight,
58
+ patchLightByOffset,
59
+ patchSprite,
60
+ patchSpriteByOffset,
61
+ toPixel,
62
+ toSprite,
63
+ tryCreateDisplay,
64
+ } from '@chris.araneo/cassette-canvas';
65
+ import { constant, noop, times } from 'lodash-es';
66
+ import { pipe } from 'remeda';
67
+ import { match, P } from 'ts-pattern';
68
+
69
+ const display = tryCreateDisplay(canvas, {
70
+ colors: [
71
+ '#7f7f7f',
72
+ '#d23c3c',
73
+ '#e07a2e',
74
+ '#e6c23a',
75
+ '#98c93c',
76
+ '#3d9e4a',
77
+ '#2e9c8f',
78
+ '#3bb4d6',
79
+ '#4a8fe0',
80
+ '#3c56c8',
81
+ '#5b42c2',
82
+ '#8a45c8',
83
+ '#c045b4',
84
+ '#e0608f',
85
+ '#8c5a36',
86
+ '#d4a373',
87
+ ],
88
+ pixelSize: 4,
89
+ spriteSize: 8,
90
+ });
91
+
92
+ const HEART = toSprite(
93
+ [
94
+ '........',
95
+ '.rr..rr.',
96
+ 'rRRrrrrr',
97
+ 'rRrrrrrr',
98
+ '.rrrrrr.',
99
+ '..rrrr..',
100
+ '...rr...',
101
+ '........',
102
+ ],
103
+ { r: toPixel(1, 4), R: toPixel(1, 6) },
104
+ );
105
+
106
+ const screen = addSprite(createScreen(toPixel(10, 0)), 'heart', HEART, 20, 30);
107
+ const moved = patchSpriteByOffset(screen, 'heart', 0, 5); // 5 pixels right
108
+ const placed = patchSprite(moved, 'heart', { row: 40, column: 60 });
109
+ ```
110
+
111
+ Draw the screen once per animation frame. Pass the time in milliseconds; it
112
+ animates the film grain. `display` can be `undefined`, so check it first:
113
+
114
+ ```ts
115
+ const { nonNullable } = P;
116
+
117
+ requestAnimationFrame((time) =>
118
+ match(display)
119
+ .with(nonNullable, (ready) => drawScreen(ready, placed, time))
120
+ .otherwise(noop),
121
+ );
122
+ ```
123
+
124
+ `tryCreateDisplay` returns `undefined` if:
125
+
126
+ - the canvas has no WebGL context,
127
+ - the monitor shader cannot be set up,
128
+ - `pixelSize` or `spriteSize` is not a positive whole number,
129
+ - the canvas is smaller than one logical pixel.
130
+
131
+ See [`apps/demo/src/main.ts`](https://github.com/ChrisAraneo/cassette-canvas/blob/master/apps/demo/src/main.ts)
132
+ for a full animation loop.
133
+
134
+ ### Lights and shadows
135
+
136
+ Sprites use 8 shades per color. Lighting uses 32 extended shades per color
137
+ (`EXTENDED_SHADE_COUNT`). Shade `s` is extended shade `4 × s + 1`. Extended
138
+ shade 0 is darker than shade 0, and extended shades 30 and 31 are lighter than
139
+ shade 7. Only light can reach them.
140
+
141
+ ```
142
+ extended 0 1 2 3 4 5 … 17 … 29 30 31
143
+ shade 0 1 4 7
144
+ ↑ darkest ↑ lightest a sprite can use
145
+ ```
146
+
147
+ Light never changes a pixel's color, only its shade. Ambient light and light
148
+ intensities are written in extended shades
149
+ (`EXTENDED_SHADES_PER_SHADE` = 4 per shade), because that is what you see on
150
+ screen. With ambient 0 and no lights, every pixel shows its authored color.
151
+
152
+ **Lights add up as light, not as shade numbers.** Shade numbers are a
153
+ brightness scale, and brightness scales do not add: a room with two lamps is
154
+ not twice as bright to look at as a room with one. So the engine turns the
155
+ ambient light and each light back into the amount of light behind it, adds
156
+ those amounts, and reads the shade off the total. Every doubling of light is
157
+ worth the same step on the scale, `EXTENDED_SHADES_PER_STOP` = 4 extended
158
+ shades, which is how the eye reads brightness.
159
+
160
+ What that gives you:
161
+
162
+ - One light on an unlit scene moves a pixel by exactly its `intensity`, as it
163
+ always did.
164
+ - A second light of the same intensity on the same spot adds one step, not
165
+ another full intensity. Ten lamps add a little over three steps.
166
+ - A lamp in daylight hardly tells, and the same lamp at night carries the
167
+ whole scene. Nothing piles up into flat white, so sprites and tiles keep
168
+ their shading however many lights reach them.
169
+
170
+ An `intensity` is therefore best read as _how many extended shades this light
171
+ lifts a pixel that daylight already lights_. A lamp that is only there to
172
+ light the night wants a small number, around 4 to 6; a flash or an explosion
173
+ wants a large one. Ambient and intensity can be fractions; the total is
174
+ rounded for each pixel and kept between extended shade 0 and 31.
175
+
176
+ A light also thins out with distance the way light does in the world, so it
177
+ leaves a bright core and a long dim halo rather than a flat disc, and the rim
178
+ at `radius` fades out smoothly instead of ending in a visible circle.
179
+
180
+ Shadow edges are soft. Every shadow carries a penumbra: its edge ramps from
181
+ full light to full shadow over two pixels, the way an edge looks under a
182
+ light that is not a single point. Only the edge softens, so the heart of a
183
+ shadow stays as dark as it ever was and a shadow never fades out as it
184
+ lengthens. `SHADOW_SOFTNESS` in `libs/engine/src/lighting/consts.ts` sets how
185
+ far the ramp goes, and `0` brings back the hard edges of a point source.
186
+
187
+ ```ts
188
+ const WALL = toSprite(times(8, constant('..oooo..')), { o: toPixel(0, 3) });
189
+
190
+ const night = pipe(
191
+ createScreen(toPixel(8, 5), -16), // light-blue sky, 4 shades darker
192
+ (screen) => addSprite(screen, 'wall', WALL, 40, 60),
193
+ (screen) => patchSprite(screen, 'wall', { castsShadow: true }),
194
+ (screen) =>
195
+ addLight(screen, 'torch', {
196
+ row: 44,
197
+ column: 30,
198
+ radius: 40,
199
+ intensity: 5,
200
+ }),
201
+ );
202
+
203
+ const torchMoved = patchLightByOffset(night, 'torch', 0, 2);
204
+ const flickered = patchLight(torchMoved, 'torch', { intensity: 4 });
205
+ const dawn = patchAmbient(flickered, -3);
206
+ ```
207
+
208
+ With ambient `-16` (night), that torch of intensity `5` lifts its core about
209
+ 18 extended shades above the night around it, a little past the authored
210
+ colors, and fades back to night through many small steps. The same torch at
211
+ noon lifts a pixel by 5. Flickering it down to `4` costs about 2 shades at
212
+ the core, not 1, because near the center the light is strong and the eye
213
+ notices less of it. A big number such as `16` is a flash or an explosion: it
214
+ burns the core out to the lightest shade.
215
+
216
+ A lit frame holds extended pixels:
217
+ `toExtendedPixel(color, extendedShade)` = `color × 32 + extendedShade`. The
218
+ palette is keyed by extended pixels. `toLitPixel(pixel, shadeOffset)` returns
219
+ the extended pixel that a sprite pixel shows under that much light.
220
+ `toLitPixel(pixel, 0)` returns its authored color.
221
+
222
+ ### API
223
+
224
+ | Function | What it does |
225
+ | ------------------------------------------------------------------- | --------------------------------------------------------------------- |
226
+ | `tryCreateDisplay(canvas, { colors, pixelSize, spriteSize })` | Creates a display, or returns `undefined` |
227
+ | `drawScreen(display, screen, time)` | Draws the screen with the monitor effect and returns the display |
228
+ | `createScreen(background?, ambient?)` | Creates an empty screen (transparent, ambient `0` by default) |
229
+ | `addSprite(screen, id, sprite, row, column)` | Adds a sprite on top, replacing any sprite with the same id |
230
+ | `patchSprite(screen, id, { sprite?, row?, column?, castsShadow? })` | Changes a sprite's image (animation), position or shadow |
231
+ | `patchSpriteByOffset(screen, id, rowOffset, columnOffset)` | Moves a sprite by an offset |
232
+ | `removeSprite(screen, id)` | Removes a sprite |
233
+ | `findSprite(screen, id)` | Returns a sprite, or `undefined` |
234
+ | `addLight(screen, id, { row, column, radius, intensity })` | Adds a light, replacing any light with the same id |
235
+ | `patchLight(screen, id, { row?, column?, radius?, intensity? })` | Changes a light |
236
+ | `patchLightByOffset(screen, id, rowOffset, columnOffset)` | Moves a light by an offset |
237
+ | `removeLight(screen, id)` | Removes a light |
238
+ | `findLight(screen, id)` | Returns a light, or `undefined` |
239
+ | `patchAmbient(screen, ambient)` | Sets the ambient light |
240
+ | `toSprite(rows, legend)` | Builds a sprite from text art |
241
+ | `toPixel(color, shade)` | Returns a pixel value |
242
+ | `isPixel(value)` | Checks whether a value is a pixel |
243
+ | `toExtendedPixel(color, extendedShade)` | Returns an extended pixel |
244
+ | `toLitPixel(pixel, shadeOffset)` | Returns the extended pixel a pixel shows under light |
245
+ | `createPalette(colors)` | Returns the 512 palette colors, keyed by extended pixel |
246
+ | `computeFrame(screen, spriteSize, width, height)` | Returns the lit frame as extended pixels, without drawing (for tests) |
247
+
248
+ Screen functions never change the screen you pass in. They return a new one.
249
+
250
+ ## How drawing works
251
+
252
+ `drawScreen` runs three steps:
253
+
254
+ ```
255
+ screen
256
+ → computeFrame draw the sprites, then apply light and shadows
257
+ → toFrameBytes turn extended pixels into RGBA bytes
258
+ → drawCrt upload the bytes as a texture and draw it with the shader
259
+ ```
260
+
261
+ **`computeFrame`** fills the frame with the background and draws each sprite
262
+ in order. It also records which pixels belong to sprites that cast shadows.
263
+ Then it works out how much light reaches every pixel and moves the pixel to
264
+ the matching extended shade.
265
+
266
+ **Light.** Each light is turned into the amount of light it adds to every
267
+ pixel it reaches. Those amounts are added to the ambient light, pixel by
268
+ pixel, and the shade comes off the total at 4 extended shades per doubling.
269
+ Pixels no light reaches skip that step and keep the ambient light exactly.
270
+
271
+ **Shadows.** For each light, the engine visits pixels in rings, moving
272
+ outward from the light. A pixel gets light if the next pixel toward the light
273
+ gets light and does not cast a shadow. This costs one lookup per pixel
274
+ instead of tracing a full line back to the light. For the penumbra, a pixel
275
+ takes a quarter of its light from each side of that pixel, along the ring it
276
+ sits on, which softens the edge of a shadow without touching what the shadow
277
+ covers.
278
+
279
+ **The shader** runs once for every canvas pixel. It:
280
+
281
+ 1. reads the logical pixel under it;
282
+ 2. adds glow where a pixel within 2 canvas pixels is brighter (flat areas do
283
+ not change);
284
+ 3. darkens the bottom of each pixel row (scanline);
285
+ 4. adds film grain, which changes 24 times per second, and a fixed surface
286
+ texture.
287
+
288
+ The strength of each effect is a constant in `libs/engine/src/crt/consts.ts`.
289
+
290
+ Everything before the WebGL calls is pure and unit-tested. The WebGL calls are
291
+ the only side effects. Each one runs through `runEffect` in `drawCrt`.
292
+
293
+ ## Project layout
294
+
295
+ ```
296
+ libs/
297
+ ├── common/src/ helpers for any lib (runEffect)
298
+ └── engine/src/
299
+ ├── index.ts the public API
300
+ ├── palette/ colors, shades and pixel values
301
+ ├── screen/ sprites, lights and ambient light
302
+ ├── lighting/ light amounts, the shade scale and shadows
303
+ ├── frame/ turns a screen into lit pixels
304
+ ├── crt/ the WebGL monitor shader
305
+ └── display/ draws to the canvas
306
+ apps/
307
+ └── demo/src/
308
+ ├── main.ts the animation loop for both canvases
309
+ ├── art/ text art: sky, buildings, street, people, cars, font
310
+ ├── tiles/ cuts large art into sprites and places it
311
+ ├── day/ the time of day and how dark it is
312
+ ├── street/ the city street, composed from scratch every frame
313
+ ├── hud/ the palette swatches, the frame rate and the clock
314
+ └── fps/ measures the frame rate
315
+ ```
316
+
317
+ ## Demo
318
+
319
+ Run `npm run dev` and open the page Vite prints. The demo draws two canvases,
320
+ each with its own display:
321
+
322
+ - **The street.** A city street with houses, trees, street lamps, people,
323
+ cars, clouds and a distant skyline. A day and night pass every 48 seconds.
324
+ The ambient light darkens at dusk, and the sun and then the moon cross the
325
+ sky behind the buildings. At night the stars come out, the lamps and
326
+ headlights light up, and people and cars cast shadows. Windows light up one
327
+ by one and go dark again later in the night.
328
+ - **The HUD.** A swatch for each of the 128 sprite colors (16 colors × 8
329
+ shades), the frame rate, and the time of day on the street. It has no
330
+ ambient light, so the swatches always show their exact colors, even when it
331
+ is night on the street.
332
+
333
+ The street does not keep sprites from one frame to the next. Every frame,
334
+ `composeStreetScreen` builds a new screen for that moment from the scenery
335
+ that never moves (built once) and from everything that does.
336
+
337
+ ## Performance
338
+
339
+ Measured on the demo street at 1280×720 (320×180 logical pixels), in a
340
+ Rolldown bundle run with Node 24. By day the street has 856 sprites and 1
341
+ light; at night it has 1,013 sprites and 11 lights:
342
+
343
+ | Step | By day | At night |
344
+ | -------------- | ------- | -------- |
345
+ | `computeFrame` | ~2.1 ms | ~4.8 ms |
346
+ | `toFrameBytes` | ~0.4 ms | ~0.4 ms |
347
+
348
+ Adding up light rather than shade numbers, and softening the shadow edges,
349
+ costs nothing by day and about 0.1 ms a frame at night, where 11 lights reach
350
+ 44% of the screen.
351
+
352
+ ## License
353
+
354
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
355
+
356
+ ## Author
357
+
358
+ Krzysztof Pająk (Chris Araneo) - chris.araneo@gmail.com
@@ -0,0 +1,270 @@
1
+ interface CrtUniforms {
2
+ readonly frame: WebGLUniformLocation;
3
+ readonly frameSize: WebGLUniformLocation;
4
+ readonly canvasSize: WebGLUniformLocation;
5
+ readonly pixelSize: WebGLUniformLocation;
6
+ readonly time: WebGLUniformLocation;
7
+ }
8
+ /**
9
+ * The WebGL objects the monitor effect needs: the shader program, the frame
10
+ * texture, the vertex buffer and the uniform locations.
11
+ */
12
+ interface Crt {
13
+ readonly program: WebGLProgram;
14
+ readonly texture: WebGLTexture;
15
+ readonly vertices: WebGLBuffer;
16
+ readonly uniforms: CrtUniforms;
17
+ }
18
+ /**
19
+ * A point light centered on the logical pixel at `row` and `column`. At its
20
+ * center it brightens pixels by `intensity` extended shades (4 extended
21
+ * shades = 1 sprite shade). The effect fades evenly to zero at `radius`
22
+ * logical pixels. A negative intensity darkens. Sprites that cast shadows
23
+ * block it.
24
+ */
25
+ interface Light {
26
+ readonly id: string;
27
+ readonly row: number;
28
+ readonly column: number;
29
+ readonly radius: number;
30
+ readonly intensity: number;
31
+ }
32
+ /**
33
+ * A sprite: `spriteSize * spriteSize` pixel values, row by row from the top
34
+ * left. Each value is a pixel such as `0x35` (color 3, shade 5) or
35
+ * `TRANSPARENT`.
36
+ */
37
+ type Sprite = readonly number[];
38
+ /**
39
+ * A sprite on the screen. `row` and `column` set its top-left pixel. If
40
+ * `castsShadow` is true, its non-transparent pixels block light.
41
+ */
42
+ interface SpritePlacement {
43
+ readonly id: string;
44
+ readonly sprite: Sprite;
45
+ readonly row: number;
46
+ readonly column: number;
47
+ readonly castsShadow: boolean;
48
+ }
49
+ /**
50
+ * Everything a display draws: a background pixel, sprites and lights.
51
+ * Sprites are drawn in order, so later sprites cover earlier ones. `ambient`
52
+ * shifts every pixel by that many extended shades before lights are added:
53
+ * positive is lighter, negative is darker, and 0 keeps the authored colors.
54
+ * 4 extended shades = 1 sprite shade.
55
+ */
56
+ interface Screen {
57
+ readonly background: number;
58
+ readonly ambient: number;
59
+ readonly sprites: readonly SpritePlacement[];
60
+ readonly lights: readonly Light[];
61
+ }
62
+ /** A color as red, green and blue values from 0 to 255. */
63
+ interface Rgb {
64
+ readonly red: number;
65
+ readonly green: number;
66
+ readonly blue: number;
67
+ }
68
+ /**
69
+ * The 512 colors a display can draw (16 colors x 32 extended shades), keyed
70
+ * by extended pixel.
71
+ */
72
+ type Palette = Readonly<Record<number, Rgb>>;
73
+ /**
74
+ * A canvas set up for drawing screens. `width` and `height` are in logical
75
+ * pixels: the canvas size divided by `pixelSize`.
76
+ */
77
+ interface Display {
78
+ readonly context: WebGLRenderingContext;
79
+ readonly crt: Crt;
80
+ readonly palette: Palette;
81
+ readonly pixelSize: number;
82
+ readonly spriteSize: number;
83
+ readonly width: number;
84
+ readonly height: number;
85
+ }
86
+ /**
87
+ * Draws the screen on the canvas. It composes and lights the frame, then
88
+ * draws it with the monitor effect. `time` is the current time in
89
+ * milliseconds, for example the `requestAnimationFrame` timestamp. It
90
+ * animates the film grain. Returns the same display.
91
+ */
92
+ export declare const drawScreen: (display: Display, screen: Screen, time: number) => Display;
93
+ /** A color in `#rrggbb` form, such as `#3c56c8`. */
94
+ type HexColor = `#${string}`;
95
+ /** Exactly 16 base colors. The first is color 0, the last is color 15. */
96
+ type PaletteColors = readonly [HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor, HexColor];
97
+ /** The settings for creating a display. */
98
+ interface DisplaySettings {
99
+ /** The 16 base colors. Sprites can use 8 shades of each. */
100
+ readonly colors: PaletteColors;
101
+ /** The width and height of one logical pixel, in canvas pixels. */
102
+ readonly pixelSize: number;
103
+ /** The width and height of every sprite, in logical pixels. */
104
+ readonly spriteSize: number;
105
+ }
106
+ /**
107
+ * Creates a display on the canvas.
108
+ *
109
+ * - `colors`: the 16 base colors.
110
+ * - `pixelSize`: the width and height of one logical pixel, in canvas pixels.
111
+ * - `spriteSize`: the width and height of every sprite, in logical pixels.
112
+ *
113
+ * The screen size in logical pixels is the canvas size divided by
114
+ * `pixelSize`. The display draws with WebGL, and the monitor effect is
115
+ * always on.
116
+ *
117
+ * Returns `undefined` if the canvas has no WebGL context, the monitor effect
118
+ * cannot be set up, a size is not a positive whole number, or the canvas is
119
+ * smaller than one logical pixel.
120
+ */
121
+ export declare const tryCreateDisplay: (canvas: HTMLCanvasElement, { colors, pixelSize, spriteSize }: DisplaySettings) => Display | undefined;
122
+ /**
123
+ * A composed and lit screen: one extended pixel per logical pixel, row by row
124
+ * from the top left. `TRANSPARENT` marks places where nothing is drawn.
125
+ */
126
+ type Frame = readonly number[];
127
+ /**
128
+ * Turns a screen into a frame of `width * height` extended pixels:
129
+ *
130
+ * 1. Fills the frame with the background.
131
+ * 2. Draws the sprites in order. Transparent pixels are skipped, and parts
132
+ * outside the frame are cut off.
133
+ * 3. Lights every pixel: moves it along its 32-shade scale by the ambient
134
+ * light plus each light that reaches it. Sprites that cast shadows block
135
+ * light.
136
+ *
137
+ * With ambient 0 and no lights, every pixel keeps its authored color.
138
+ */
139
+ export declare const computeFrame: (screen: Screen, spriteSize: number, width: number, height: number) => Frame;
140
+ /** The number of base colors in a palette. */
141
+ export declare const COLOR_COUNT = 16;
142
+ /** The number of shades of each color that sprites and backgrounds use. */
143
+ export declare const SHADE_COUNT = 8;
144
+ /**
145
+ * The shade that shows a base color exactly as given. Lower shades are
146
+ * darker, higher shades are lighter.
147
+ */
148
+ export declare const BASE_SHADE = 4;
149
+ /**
150
+ * The number of shades of each color that lighting uses. The finer scale
151
+ * lets light fade in smaller steps than sprite shades.
152
+ */
153
+ export declare const EXTENDED_SHADE_COUNT = 32;
154
+ /** The number of extended shades in one sprite shade. */
155
+ export declare const EXTENDED_SHADES_PER_SHADE: number;
156
+ /** The pixel value that draws nothing. Whatever is below shows through. */
157
+ export declare const TRANSPARENT = -1;
158
+ /**
159
+ * Expands the 16 base colors into 512 colors: 32 extended shades per color,
160
+ * keyed by extended pixel.
161
+ *
162
+ * Sprite shade `s` is extended shade `4 * s + 1`. Shade `BASE_SHADE` is the
163
+ * base color itself. Darker shades are mixed with black, lighter shades with
164
+ * white. Only light reaches the extended shades between and beyond the
165
+ * sprite shades. A color that is not in `#rrggbb` form is read as black.
166
+ */
167
+ export declare const createPalette: (colors: PaletteColors) => Palette;
168
+ /**
169
+ * Returns `true` if the number is a pixel the palette can draw: a color from
170
+ * 0 to 15 in the high hex digit and a shade from 0 to 7 in the low hex digit.
171
+ * Returns `false` for `TRANSPARENT` and all other values.
172
+ */
173
+ export declare const isPixel: (pixel: number) => boolean;
174
+ /**
175
+ * Returns the extended pixel for a color (0 to 15) and an extended shade
176
+ * (0 to 31): `color * 32 + extendedShade`. Lit frames hold extended pixels,
177
+ * and the palette is keyed by them.
178
+ *
179
+ * Sprite shade `s` is extended shade `4 * s + 1`. Extended shades 0, 30 and
180
+ * 31 are outside the sprite shades; only light reaches them. Returns
181
+ * `TRANSPARENT` for out-of-range input.
182
+ */
183
+ export declare const toExtendedPixel: (color: number, extendedShade: number) => number;
184
+ /**
185
+ * Returns the extended pixel that a pixel shows under light. The color stays
186
+ * the same. The shade moves `shadeOffset` extended shades up or down
187
+ * (4 extended shades = 1 sprite shade). The result is rounded and stops at
188
+ * the darkest and lightest extended shade. `toLitPixel(pixel, 0)` returns
189
+ * the authored color. Returns `TRANSPARENT` for `TRANSPARENT` and for any
190
+ * value that is not a pixel.
191
+ */
192
+ export declare const toLitPixel: (pixel: number, shadeOffset: number) => number;
193
+ /**
194
+ * Returns the pixel value for a color (0 to 15) and a shade (0 to 7):
195
+ * `toPixel(3, 5)` is `0x35`. Returns `TRANSPARENT` for out-of-range input.
196
+ */
197
+ export declare const toPixel: (color: number, shade: number) => number;
198
+ /** All the fields of a light except its id. */
199
+ type LightSettings = Omit<Light, 'id'>;
200
+ /**
201
+ * Adds a light to the screen. If a light with the same id exists, it is
202
+ * replaced. Returns a new screen.
203
+ */
204
+ export declare const addLight: (screen: Screen, id: string, { row, column, radius, intensity }: LightSettings) => Screen;
205
+ /**
206
+ * Adds a sprite on top of the other sprites. `row` and `column` set its
207
+ * top-left pixel. If a sprite with the same id exists, it is removed first.
208
+ * The new sprite does not cast a shadow; to change that, call
209
+ * `patchSprite(screen, id, { castsShadow: true })`. Returns a new screen.
210
+ */
211
+ export declare const addSprite: (screen: Screen, id: string, sprite: Sprite, row: number, column: number) => Screen;
212
+ /**
213
+ * Creates an empty screen, with no sprites and no lights, filled with
214
+ * `background`. `ambient` shifts every pixel by that many extended shades
215
+ * (4 extended shades = 1 sprite shade). For example, a night scene could
216
+ * use -16.
217
+ */
218
+ export declare const createScreen: (background?: number, ambient?: number) => Screen;
219
+ /** Returns the light with the id, or `undefined` if there is none. */
220
+ export declare const findLight: (screen: Screen, id: string) => Light | undefined;
221
+ /** Returns the sprite with the id, or `undefined` if there is none. */
222
+ export declare const findSprite: (screen: Screen, id: string) => SpritePlacement | undefined;
223
+ /**
224
+ * Sets the ambient light: how many extended shades every pixel moves up
225
+ * (positive) or down (negative) before lights are added. Change it over time
226
+ * to fade between day and night. Returns a new screen.
227
+ */
228
+ export declare const patchAmbient: (screen: Screen, ambient: number) => Screen;
229
+ /** The light fields to change. Fields you leave out keep their values. */
230
+ type LightChanges = Partial<LightSettings>;
231
+ /**
232
+ * Changes a light's `row`, `column`, `radius` or `intensity`. Fields you
233
+ * leave out keep their values. Returns a new screen. If no light has the id,
234
+ * nothing changes.
235
+ */
236
+ export declare const patchLight: (screen: Screen, id: string, changes: LightChanges) => Screen;
237
+ /**
238
+ * Moves a light `rowOffset` pixels down and `columnOffset` pixels right.
239
+ * Negative values move it up and left. Returns a new screen. If no light has
240
+ * the id, nothing changes.
241
+ */
242
+ export declare const patchLightByOffset: (screen: Screen, id: string, rowOffset: number, columnOffset: number) => Screen;
243
+ /** The sprite fields to change. Fields you leave out keep their values. */
244
+ type SpriteChanges = Partial<Pick<SpritePlacement, 'sprite' | 'row' | 'column' | 'castsShadow'>>;
245
+ /**
246
+ * Changes a sprite's `sprite` (for animation), `row`, `column` or
247
+ * `castsShadow`. Fields you leave out keep their values. The sprite keeps its
248
+ * place in the drawing order. Returns a new screen. If no sprite has the id,
249
+ * nothing changes.
250
+ */
251
+ export declare const patchSprite: (screen: Screen, id: string, changes: SpriteChanges) => Screen;
252
+ /**
253
+ * Moves a sprite `rowOffset` pixels down and `columnOffset` pixels right.
254
+ * Negative values move it up and left. Returns a new screen. If no sprite
255
+ * has the id, nothing changes.
256
+ */
257
+ export declare const patchSpriteByOffset: (screen: Screen, id: string, rowOffset: number, columnOffset: number) => Screen;
258
+ /** Removes the light with the id. Returns a new screen. */
259
+ export declare const removeLight: (screen: Screen, id: string) => Screen;
260
+ /** Removes the sprite with the id. Returns a new screen. */
261
+ export declare const removeSprite: (screen: Screen, id: string) => Screen;
262
+ /** Maps each character of sprite text art to a pixel value. */
263
+ type SpriteLegend = Readonly<Record<string, number>>;
264
+ /**
265
+ * Builds a sprite from text art: one string per row, one character per
266
+ * pixel. Each character is looked up in `legend`. Characters that are not in
267
+ * the legend become transparent.
268
+ */
269
+ export declare const toSprite: (rows: readonly string[], legend: SpriteLegend) => Sprite;
270
+ export type { Crt, Display, DisplaySettings, Frame, HexColor, Light, LightChanges, LightSettings, Palette, PaletteColors, Rgb, Screen, Sprite, SpriteChanges, SpriteLegend, SpritePlacement };