@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 +21 -0
- package/README.md +357 -2
- package/dist/lib/index.d.ts +270 -0
- package/dist/lib/index.js +816 -0
- package/dist/lib/index.js.map +1 -0
- package/package.json +79 -4
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { P, match } from "ts-pattern";
|
|
2
|
+
import { pipe } from "remeda";
|
|
3
|
+
import { clamp, filter, find, flatMap, floor, fromPairs, inRange, isInteger, isUndefined, map, mapValues, parseInt, range, round, split, words } from "lodash-es";
|
|
4
|
+
// libs/common/src/effects/run-effect.ts
|
|
5
|
+
/**
|
|
6
|
+
* Calls `effect` with `value`, then returns `value`. Use it to run a side
|
|
7
|
+
* effect as a step in a `pipe`: `(scene) => runEffect(scene, draw)`.
|
|
8
|
+
*/
|
|
9
|
+
const runEffect = (value, effect) => match(effect(value)).otherwise(() => value);
|
|
10
|
+
// libs/engine/src/crt/consts.ts
|
|
11
|
+
const GLOW_STRENGTH = .6;
|
|
12
|
+
const GLOW_THRESHOLD = .45;
|
|
13
|
+
const SCANLINE_DEPTH = .09;
|
|
14
|
+
const GRAIN_AMOUNT = .015;
|
|
15
|
+
const SURFACE_AMOUNT = .0125;
|
|
16
|
+
const POSITION_ATTRIBUTE = "a_position";
|
|
17
|
+
const FULL_SCREEN_TRIANGLE = Object.freeze([
|
|
18
|
+
-1,
|
|
19
|
+
-1,
|
|
20
|
+
3,
|
|
21
|
+
-1,
|
|
22
|
+
-1,
|
|
23
|
+
3
|
|
24
|
+
]);
|
|
25
|
+
const CRT_UNIFORM_NAMES = Object.freeze({
|
|
26
|
+
frame: "u_frame",
|
|
27
|
+
frameSize: "u_frameSize",
|
|
28
|
+
canvasSize: "u_canvasSize",
|
|
29
|
+
pixelSize: "u_pixelSize",
|
|
30
|
+
time: "u_time"
|
|
31
|
+
});
|
|
32
|
+
const CRT_CONTEXT_ATTRIBUTES = Object.freeze({
|
|
33
|
+
alpha: true,
|
|
34
|
+
antialias: false,
|
|
35
|
+
depth: false,
|
|
36
|
+
stencil: false,
|
|
37
|
+
premultipliedAlpha: true,
|
|
38
|
+
preserveDrawingBuffer: false
|
|
39
|
+
});
|
|
40
|
+
// libs/engine/src/crt/internal/draw-crt-triangle.ts
|
|
41
|
+
const VALUES_PER_CORNER = 2;
|
|
42
|
+
const CORNER_COUNT = 3;
|
|
43
|
+
const drawCrtTriangle = (context, { vertices }) => pipe(context, (gl) => runEffect(gl, () => gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)), (gl) => runEffect(gl, () => gl.bindBuffer(gl.ARRAY_BUFFER, vertices)), (gl) => runEffect(gl, () => gl.enableVertexAttribArray(0)), (gl) => runEffect(gl, () => gl.vertexAttribPointer(0, VALUES_PER_CORNER, gl.FLOAT, false, 0, 0)), (gl) => gl.drawArrays(gl.TRIANGLES, 0, CORNER_COUNT));
|
|
44
|
+
// libs/engine/src/crt/internal/patch-crt-texture.ts
|
|
45
|
+
const patchCrtTexture = (context, { texture }, { bytes, width, height }) => pipe(context, (gl) => runEffect(gl, () => gl.bindTexture(gl.TEXTURE_2D, texture)), (gl) => runEffect(gl, () => gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength))));
|
|
46
|
+
// libs/engine/src/crt/internal/patch-crt-uniforms.ts
|
|
47
|
+
const FRAME_TEXTURE_UNIT = 0;
|
|
48
|
+
const patchCrtUniforms = (context, { program, uniforms }, { width, height, pixelSize }, time) => pipe(context, (gl) => runEffect(gl, () => gl.useProgram(program)), (gl) => runEffect(gl, () => gl.uniform1i(uniforms.frame, FRAME_TEXTURE_UNIT)), (gl) => runEffect(gl, () => gl.uniform2f(uniforms.frameSize, width, height)), (gl) => runEffect(gl, () => gl.uniform2f(uniforms.canvasSize, gl.drawingBufferWidth, gl.drawingBufferHeight)), (gl) => runEffect(gl, () => gl.uniform1f(uniforms.pixelSize, pixelSize)), (gl) => runEffect(gl, () => gl.uniform1f(uniforms.time, time)));
|
|
49
|
+
// libs/engine/src/crt/draw-crt.ts
|
|
50
|
+
/**
|
|
51
|
+
* Draws a frame on the canvas with the monitor effect:
|
|
52
|
+
*
|
|
53
|
+
* - each logical pixel becomes a sharp `pixelSize` square;
|
|
54
|
+
* - bright pixels glow up to 2 canvas pixels outward;
|
|
55
|
+
* - each pixel row has a darker scanline at its bottom;
|
|
56
|
+
* - film grain (animated by `time`, in milliseconds) and a fixed surface
|
|
57
|
+
* texture are added on top.
|
|
58
|
+
*
|
|
59
|
+
* The effect uses full RGB color, not the palette.
|
|
60
|
+
*/
|
|
61
|
+
const drawCrt = (context, crt, picture, time) => pipe(context, (gl) => runEffect(gl, () => patchCrtTexture(gl, crt, picture)), (gl) => runEffect(gl, () => patchCrtUniforms(gl, crt, picture, time)), (gl) => drawCrtTriangle(gl, crt));
|
|
62
|
+
// libs/engine/src/palette/consts.ts
|
|
63
|
+
/** The number of base colors in a palette. */
|
|
64
|
+
const COLOR_COUNT = 16;
|
|
65
|
+
/** The number of shades of each color that sprites and backgrounds use. */
|
|
66
|
+
const SHADE_COUNT = 8;
|
|
67
|
+
/**
|
|
68
|
+
* The shade that shows a base color exactly as given. Lower shades are
|
|
69
|
+
* darker, higher shades are lighter.
|
|
70
|
+
*/
|
|
71
|
+
const BASE_SHADE = 4;
|
|
72
|
+
/**
|
|
73
|
+
* The number of shades of each color that lighting uses. The finer scale
|
|
74
|
+
* lets light fade in smaller steps than sprite shades.
|
|
75
|
+
*/
|
|
76
|
+
const EXTENDED_SHADE_COUNT = 32;
|
|
77
|
+
/** The number of extended shades in one sprite shade. */
|
|
78
|
+
const EXTENDED_SHADES_PER_SHADE = 4;
|
|
79
|
+
/** The pixel value that draws nothing. Whatever is below shows through. */
|
|
80
|
+
const TRANSPARENT = -1;
|
|
81
|
+
const BLACK = Object.freeze({
|
|
82
|
+
red: 0,
|
|
83
|
+
green: 0,
|
|
84
|
+
blue: 0
|
|
85
|
+
});
|
|
86
|
+
const WHITE = Object.freeze({
|
|
87
|
+
red: 255,
|
|
88
|
+
green: 255,
|
|
89
|
+
blue: 255
|
|
90
|
+
});
|
|
91
|
+
// libs/engine/src/lighting/consts.ts
|
|
92
|
+
/**
|
|
93
|
+
* How many extended shades one doubling of light moves a pixel: one sprite
|
|
94
|
+
* shade per doubling. Lights add up as light and are read off this scale
|
|
95
|
+
* afterwards, so every doubling costs the same number of shades, the way the
|
|
96
|
+
* eye sees brightness.
|
|
97
|
+
*/
|
|
98
|
+
const EXTENDED_SHADES_PER_STOP = 4;
|
|
99
|
+
/**
|
|
100
|
+
* The most light can move a pixel, in extended shades. A whole ramp in
|
|
101
|
+
* either direction is more than any pixel needs: every pixel stops at the
|
|
102
|
+
* darkest or the lightest extended shade well before this.
|
|
103
|
+
*/
|
|
104
|
+
const MAX_SHADE_OFFSET = 32;
|
|
105
|
+
const MIN_EXPOSURE = 2 ** (-32 / EXTENDED_SHADES_PER_STOP);
|
|
106
|
+
const MAX_EXPOSURE = 2 ** (MAX_SHADE_OFFSET / EXTENDED_SHADES_PER_STOP);
|
|
107
|
+
/**
|
|
108
|
+
* How much of the light at a pixel comes from each side of the pixel ahead
|
|
109
|
+
* of it, toward the light, rather than all of it from that one pixel. It
|
|
110
|
+
* gives every shadow a penumbra: its edge ramps from full light to full
|
|
111
|
+
* shadow over two pixels instead of stepping down in one, the way an edge
|
|
112
|
+
* looks under a light that is not a single point.
|
|
113
|
+
*
|
|
114
|
+
* Only the edge softens. What blocks a light still blocks it, so the heart
|
|
115
|
+
* of a shadow stays as dark as ever, and a shadow never fades out with
|
|
116
|
+
* distance. 0 gives the hard edges of a point source, and 0.25 spreads the
|
|
117
|
+
* ramp evenly, which is as soft as three pixels go.
|
|
118
|
+
*/
|
|
119
|
+
const SHADOW_SOFTNESS = .25;
|
|
120
|
+
// libs/engine/src/lighting/internal/compute-falloff.ts
|
|
121
|
+
const computeWindow = (squaredReach) => 1 - squaredReach * squaredReach;
|
|
122
|
+
/**
|
|
123
|
+
* Returns the share of a light that reaches a point: 1 at its center, 0 at
|
|
124
|
+
* its radius. `squaredReach` is the distance to the light as a share of the
|
|
125
|
+
* radius, squared, from 0 at the center to 1 at the radius. Squared, because
|
|
126
|
+
* then the caller needs no square root, and this runs once per lit pixel.
|
|
127
|
+
*
|
|
128
|
+
* The share thins out with distance the way light spreads out in the world,
|
|
129
|
+
* and a window closes it off smoothly at the radius, so a light leaves a
|
|
130
|
+
* bright core and a long dim halo instead of a flat disc with a visible rim.
|
|
131
|
+
* Callers keep to the center and the radius; a share past 1 means nothing.
|
|
132
|
+
*/
|
|
133
|
+
const computeFalloff = (squaredReach) => computeWindow(squaredReach) ** 2 / (1 + 8 * squaredReach);
|
|
134
|
+
// libs/engine/src/lighting/internal/to-exposure.ts
|
|
135
|
+
/**
|
|
136
|
+
* Returns the amount of light that shows a pixel `shades` extended shades
|
|
137
|
+
* from its authored color. `toExposure(0)` is `REFERENCE_EXPOSURE`, and every
|
|
138
|
+
* `EXTENDED_SHADES_PER_STOP` shades up double it.
|
|
139
|
+
*
|
|
140
|
+
* Ambient light and light intensities are written in extended shades because
|
|
141
|
+
* that is what you see on screen. This turns one of them back into the amount
|
|
142
|
+
* of light behind it, which is what adds up.
|
|
143
|
+
*/
|
|
144
|
+
const toExposure = (shades) => 2 ** (shades / EXTENDED_SHADES_PER_STOP);
|
|
145
|
+
// libs/engine/src/lighting/internal/compute-light-exposure.ts
|
|
146
|
+
const SHARE_AHEAD = 1 - 2 * SHADOW_SOFTNESS;
|
|
147
|
+
/**
|
|
148
|
+
* Returns how much light one light adds to every pixel. The values are
|
|
149
|
+
* amounts of light, not extended shades, because that is what adds up:
|
|
150
|
+
* `computeShadeOffsets` sums the amounts and reads the shades off them.
|
|
151
|
+
*
|
|
152
|
+
* At its center the light adds as much light as its intensity stands for,
|
|
153
|
+
* which thins out to nothing at its radius. Sprites that cast shadows block
|
|
154
|
+
* it, and the edge of a shadow ramps down over two pixels rather than
|
|
155
|
+
* stepping down in one. A negative intensity takes light away instead.
|
|
156
|
+
*/
|
|
157
|
+
const computeLightExposure = ({ width, height, occluders }, light) => {
|
|
158
|
+
const exposures = new Float32Array(width * height);
|
|
159
|
+
const visible = new Uint8Array(width * height);
|
|
160
|
+
const lightRow = Math.floor(light.row);
|
|
161
|
+
const lightColumn = Math.floor(light.column);
|
|
162
|
+
const reach = Math.ceil(light.radius);
|
|
163
|
+
const squaredRadius = light.radius * light.radius;
|
|
164
|
+
const emission = toExposure(light.intensity) - 1;
|
|
165
|
+
const firstRing = Math.max(0, -lightRow, lightRow - (height - 1), -lightColumn, lightColumn - (width - 1));
|
|
166
|
+
for (let ring = firstRing; ring <= reach; ring += 1) {
|
|
167
|
+
const top = Math.max(-ring, -lightRow);
|
|
168
|
+
const bottom = Math.min(ring, height - 1 - lightRow);
|
|
169
|
+
const sideReach = ring - 1;
|
|
170
|
+
for (let rowOffset = top; rowOffset <= bottom; rowOffset += 1) {
|
|
171
|
+
const isEdgeRow = rowOffset === -ring || rowOffset === ring;
|
|
172
|
+
const left = isEdgeRow ? Math.max(-ring, -lightColumn) : -ring;
|
|
173
|
+
const right = isEdgeRow ? Math.min(ring, width - 1 - lightColumn) : ring;
|
|
174
|
+
const step = isEdgeRow ? 1 : 2 * ring;
|
|
175
|
+
for (let columnOffset = left; columnOffset <= right; columnOffset += step) {
|
|
176
|
+
const row = lightRow + rowOffset;
|
|
177
|
+
const column = lightColumn + columnOffset;
|
|
178
|
+
if (column < 0 || column >= width) continue;
|
|
179
|
+
const parentRowOffset = Math.round(rowOffset * sideReach / ring);
|
|
180
|
+
const parentColumnOffset = Math.round(columnOffset * sideReach / ring);
|
|
181
|
+
const parentRow = lightRow + parentRowOffset;
|
|
182
|
+
const parentColumn = lightColumn + parentColumnOffset;
|
|
183
|
+
const parentIndex = parentRow * width + parentColumn;
|
|
184
|
+
const hasParent = ring >= 2 && parentRow >= 0 && parentRow < height && parentColumn >= 0 && parentColumn < width;
|
|
185
|
+
const litAhead = !hasParent || visible[parentIndex] === 1 && occluders[parentIndex] !== 1 ? 1 : 0;
|
|
186
|
+
const runsAlongRow = Math.abs(parentRowOffset) >= Math.abs(parentColumnOffset);
|
|
187
|
+
const sideStep = runsAlongRow ? 1 : width;
|
|
188
|
+
const sideOffset = runsAlongRow ? parentColumnOffset : parentRowOffset;
|
|
189
|
+
const sidePlace = runsAlongRow ? parentColumn : parentRow;
|
|
190
|
+
const sideLimit = runsAlongRow ? width : height;
|
|
191
|
+
const lowIndex = parentIndex - sideStep;
|
|
192
|
+
const highIndex = parentIndex + sideStep;
|
|
193
|
+
const litLow = hasParent && sideOffset > -sideReach && sidePlace > 0 ? visible[lowIndex] === 1 && occluders[lowIndex] !== 1 ? 1 : 0 : litAhead;
|
|
194
|
+
const litHigh = hasParent && sideOffset < sideReach && sidePlace < sideLimit - 1 ? visible[highIndex] === 1 && occluders[highIndex] !== 1 ? 1 : 0 : litAhead;
|
|
195
|
+
const share = SHARE_AHEAD * litAhead + SHADOW_SOFTNESS * (litLow + litHigh);
|
|
196
|
+
const index = row * width + column;
|
|
197
|
+
const squaredDistance = rowOffset * rowOffset + columnOffset * columnOffset;
|
|
198
|
+
visible[index] = litAhead;
|
|
199
|
+
if (share > 0 && squaredDistance < squaredRadius) exposures[index] = emission * share * computeFalloff(squaredDistance / squaredRadius);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return exposures;
|
|
204
|
+
};
|
|
205
|
+
// libs/engine/src/lighting/internal/to-shade-offset.ts
|
|
206
|
+
/**
|
|
207
|
+
* Returns how many extended shades an amount of light moves a pixel from its
|
|
208
|
+
* authored color. This undoes `toExposure`: `REFERENCE_EXPOSURE` gives 0,
|
|
209
|
+
* twice as much light gives `EXTENDED_SHADES_PER_STOP` more shades, and half
|
|
210
|
+
* as much gives that many fewer. Light beyond what a pixel can show stops at
|
|
211
|
+
* the ends of the scale.
|
|
212
|
+
*
|
|
213
|
+
* Because every doubling costs the same number of shades, a scene that is
|
|
214
|
+
* already bright barely shows one more lamp, while the same lamp in the dark
|
|
215
|
+
* shows in full. That is how the eye reads brightness, and it is why lights
|
|
216
|
+
* no longer stack up into white.
|
|
217
|
+
*
|
|
218
|
+
* It clamps with `Math.min` and `Math.max` rather than `clamp`, because it
|
|
219
|
+
* runs once per lit pixel.
|
|
220
|
+
*/
|
|
221
|
+
const toShadeOffset = (exposure) => EXTENDED_SHADES_PER_STOP * Math.log2(Math.min(MAX_EXPOSURE, Math.max(MIN_EXPOSURE, exposure)));
|
|
222
|
+
// libs/engine/src/lighting/internal/compute-shade-offsets.ts
|
|
223
|
+
/**
|
|
224
|
+
* Adds up the ambient light and every light that reaches a pixel, then reads
|
|
225
|
+
* off how many extended shades that much light moves the pixel.
|
|
226
|
+
*
|
|
227
|
+
* The amounts of light are added, not the shades. Two lamps on one spot are
|
|
228
|
+
* twice the light, which is one shade step more than one lamp, not twice as
|
|
229
|
+
* many shade steps, and a lamp in daylight hardly tells at all. That is why
|
|
230
|
+
* a lit scene keeps its shading instead of washing out to white.
|
|
231
|
+
*/
|
|
232
|
+
const computeShadeOffsets = ({ ambient, width, height }, exposures) => {
|
|
233
|
+
const offsets = new Float64Array(Math.max(0, width * height));
|
|
234
|
+
const ambientExposure = toExposure(ambient);
|
|
235
|
+
for (const exposure of exposures) for (let index = 0; index < offsets.length; index += 1) offsets[index] = (offsets[index] ?? 0) + (exposure[index] ?? 0);
|
|
236
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
237
|
+
const light = offsets[index] ?? 0;
|
|
238
|
+
offsets[index] = light === 0 ? ambient : toShadeOffset(ambientExposure + light);
|
|
239
|
+
}
|
|
240
|
+
return offsets;
|
|
241
|
+
};
|
|
242
|
+
// libs/engine/src/lighting/compute-light-levels.ts
|
|
243
|
+
/**
|
|
244
|
+
* Returns how many extended shades the light moves every pixel, row by row
|
|
245
|
+
* from the top left.
|
|
246
|
+
*
|
|
247
|
+
* Lights are combined the way light behaves, not the way numbers add. Each
|
|
248
|
+
* light and the ambient light are turned into amounts of light, those amounts
|
|
249
|
+
* are added together, and the shade is read off the total on a scale where
|
|
250
|
+
* every doubling of light is worth `EXTENDED_SHADES_PER_STOP` shades. So one
|
|
251
|
+
* light on an unlit scene moves a pixel by exactly its intensity, while a
|
|
252
|
+
* second light of the same intensity adds only one more step, and a lamp in
|
|
253
|
+
* bright daylight barely shows. Nothing stacks up into flat white.
|
|
254
|
+
*/
|
|
255
|
+
const computeLightLevels = (lighting) => computeShadeOffsets(lighting, map(lighting.lights, (light) => computeLightExposure(lighting, light)));
|
|
256
|
+
// libs/engine/src/frame/internal/compute-frame-light.ts
|
|
257
|
+
const computeFrameLight = ({ pixels, lighting }) => ({
|
|
258
|
+
pixels,
|
|
259
|
+
levels: computeLightLevels(lighting)
|
|
260
|
+
});
|
|
261
|
+
// libs/engine/src/palette/internal/is-index.ts
|
|
262
|
+
const isIndex = (index, size) => isInteger(index) && inRange(index, size);
|
|
263
|
+
// libs/engine/src/palette/to-extended-pixel.ts
|
|
264
|
+
/**
|
|
265
|
+
* Returns the extended pixel for a color (0 to 15) and an extended shade
|
|
266
|
+
* (0 to 31): `color * 32 + extendedShade`. Lit frames hold extended pixels,
|
|
267
|
+
* and the palette is keyed by them.
|
|
268
|
+
*
|
|
269
|
+
* Sprite shade `s` is extended shade `4 * s + 1`. Extended shades 0, 30 and
|
|
270
|
+
* 31 are outside the sprite shades; only light reaches them. Returns
|
|
271
|
+
* `TRANSPARENT` for out-of-range input.
|
|
272
|
+
*/
|
|
273
|
+
const toExtendedPixel = (color, extendedShade) => match(isIndex(color, 16) && isIndex(extendedShade, 32)).with(true, () => color * 32 + extendedShade).otherwise(() => -1);
|
|
274
|
+
// libs/engine/src/palette/to-pixel.ts
|
|
275
|
+
/**
|
|
276
|
+
* Returns the pixel value for a color (0 to 15) and a shade (0 to 7):
|
|
277
|
+
* `toPixel(3, 5)` is `0x35`. Returns `TRANSPARENT` for out-of-range input.
|
|
278
|
+
*/
|
|
279
|
+
const toPixel = (color, shade) => match(isIndex(color, 16) && isIndex(shade, 8)).with(true, () => color * 16 + shade).otherwise(() => -1);
|
|
280
|
+
// libs/engine/src/palette/internal/to-extended-shade.ts
|
|
281
|
+
const toExtendedShade = (shade) => 1 + shade * 4;
|
|
282
|
+
// libs/engine/src/palette/internal/create-shade-ramps.ts
|
|
283
|
+
const createColorEntries = (color, pixels) => map(range(8), (shade) => [toPixel(color, shade), {
|
|
284
|
+
pixels,
|
|
285
|
+
extendedShade: toExtendedShade(shade)
|
|
286
|
+
}]);
|
|
287
|
+
const createShadeRamps = () => fromPairs(flatMap(range(16), (color) => createColorEntries(color, map(range(32), (extendedShade) => toExtendedPixel(color, extendedShade)))));
|
|
288
|
+
// libs/engine/src/palette/internal/shade-ramps.ts
|
|
289
|
+
const SHADE_RAMPS = Object.freeze(createShadeRamps());
|
|
290
|
+
// libs/engine/src/palette/is-pixel.ts
|
|
291
|
+
/**
|
|
292
|
+
* Returns `true` if the number is a pixel the palette can draw: a color from
|
|
293
|
+
* 0 to 15 in the high hex digit and a shade from 0 to 7 in the low hex digit.
|
|
294
|
+
* Returns `false` for `TRANSPARENT` and all other values.
|
|
295
|
+
*/
|
|
296
|
+
const isPixel = (pixel) => !isUndefined(SHADE_RAMPS[pixel]);
|
|
297
|
+
// libs/engine/src/frame/internal/compute-frame-pixels.ts
|
|
298
|
+
const SHADOW_CASTER = 1;
|
|
299
|
+
const computeFramePixels = ({ screen, spriteSize, width, height }) => {
|
|
300
|
+
const size = Math.max(0, width * height);
|
|
301
|
+
const pixels = new Int16Array(size).fill(isPixel(screen.background) ? screen.background : -1);
|
|
302
|
+
const occluders = new Uint8Array(size);
|
|
303
|
+
const spritePixelCount = spriteSize * spriteSize;
|
|
304
|
+
for (const { sprite, row, column, castsShadow } of screen.sprites) {
|
|
305
|
+
const top = Math.floor(row);
|
|
306
|
+
const left = Math.floor(column);
|
|
307
|
+
const count = Math.min(sprite.length, spritePixelCount);
|
|
308
|
+
for (let index = 0; index < count; index += 1) {
|
|
309
|
+
const pixel = sprite[index] ?? -1;
|
|
310
|
+
const pixelRow = top + Math.floor(index / spriteSize);
|
|
311
|
+
const pixelColumn = left + index % spriteSize;
|
|
312
|
+
if (pixelRow >= 0 && pixelRow < height && pixelColumn >= 0 && pixelColumn < width && isPixel(pixel)) {
|
|
313
|
+
const place = pixelRow * width + pixelColumn;
|
|
314
|
+
pixels[place] = pixel;
|
|
315
|
+
if (castsShadow) occluders[place] = SHADOW_CASTER;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
pixels,
|
|
321
|
+
lighting: {
|
|
322
|
+
ambient: screen.ambient,
|
|
323
|
+
lights: screen.lights,
|
|
324
|
+
width,
|
|
325
|
+
height,
|
|
326
|
+
occluders
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
// libs/engine/src/palette/to-lit-pixels.ts
|
|
331
|
+
const toLitPixels = (pixels, shadeOffsets) => {
|
|
332
|
+
const litPixels = new Array(pixels.length);
|
|
333
|
+
for (let index = 0; index < pixels.length; index += 1) {
|
|
334
|
+
const ramp = SHADE_RAMPS[pixels[index] ?? -1];
|
|
335
|
+
const extendedShade = Math.round((ramp?.extendedShade ?? 0) + (shadeOffsets[index] ?? 0));
|
|
336
|
+
litPixels[index] = ramp?.pixels[Math.min(31, Math.max(0, extendedShade))] ?? -1;
|
|
337
|
+
}
|
|
338
|
+
return litPixels;
|
|
339
|
+
};
|
|
340
|
+
// libs/engine/src/frame/internal/patch-frame-shades.ts
|
|
341
|
+
const patchFrameShades = ({ pixels, levels }) => toLitPixels(pixels, levels);
|
|
342
|
+
// libs/engine/src/frame/compute-frame.ts
|
|
343
|
+
/**
|
|
344
|
+
* Turns a screen into a frame of `width * height` extended pixels:
|
|
345
|
+
*
|
|
346
|
+
* 1. Fills the frame with the background.
|
|
347
|
+
* 2. Draws the sprites in order. Transparent pixels are skipped, and parts
|
|
348
|
+
* outside the frame are cut off.
|
|
349
|
+
* 3. Lights every pixel: moves it along its 32-shade scale by the ambient
|
|
350
|
+
* light plus each light that reaches it. Sprites that cast shadows block
|
|
351
|
+
* light.
|
|
352
|
+
*
|
|
353
|
+
* With ambient 0 and no lights, every pixel keeps its authored color.
|
|
354
|
+
*/
|
|
355
|
+
const computeFrame = (screen, spriteSize, width, height) => pipe({
|
|
356
|
+
screen,
|
|
357
|
+
spriteSize,
|
|
358
|
+
width,
|
|
359
|
+
height
|
|
360
|
+
}, computeFramePixels, computeFrameLight, patchFrameShades);
|
|
361
|
+
// libs/engine/src/display/internal/to-frame-bytes.ts
|
|
362
|
+
const BYTES_PER_PIXEL = 4;
|
|
363
|
+
const RED_OFFSET = 0;
|
|
364
|
+
const GREEN_OFFSET = 1;
|
|
365
|
+
const BLUE_OFFSET = 2;
|
|
366
|
+
const ALPHA_OFFSET = 3;
|
|
367
|
+
const toFrameBytes = (frame, palette) => {
|
|
368
|
+
const bytes = new Uint8ClampedArray(frame.length * BYTES_PER_PIXEL);
|
|
369
|
+
for (let index = 0; index < frame.length; index += 1) {
|
|
370
|
+
const rgb = palette[frame[index] ?? -1];
|
|
371
|
+
if (rgb !== void 0) {
|
|
372
|
+
const offset = index * BYTES_PER_PIXEL;
|
|
373
|
+
bytes[offset + RED_OFFSET] = rgb.red;
|
|
374
|
+
bytes[offset + GREEN_OFFSET] = rgb.green;
|
|
375
|
+
bytes[offset + BLUE_OFFSET] = rgb.blue;
|
|
376
|
+
bytes[offset + ALPHA_OFFSET] = 255;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return bytes;
|
|
380
|
+
};
|
|
381
|
+
// libs/engine/src/display/draw-screen.ts
|
|
382
|
+
/**
|
|
383
|
+
* Draws the screen on the canvas. It composes and lights the frame, then
|
|
384
|
+
* draws it with the monitor effect. `time` is the current time in
|
|
385
|
+
* milliseconds, for example the `requestAnimationFrame` timestamp. It
|
|
386
|
+
* animates the film grain. Returns the same display.
|
|
387
|
+
*/
|
|
388
|
+
const drawScreen = (display, screen, time) => pipe(computeFrame(screen, display.spriteSize, display.width, display.height), (frame) => toFrameBytes(frame, display.palette), (bytes) => runEffect(display, ({ context, crt, width, height, pixelSize }) => drawCrt(context, crt, {
|
|
389
|
+
bytes,
|
|
390
|
+
width,
|
|
391
|
+
height,
|
|
392
|
+
pixelSize
|
|
393
|
+
}, time)));
|
|
394
|
+
// libs/engine/src/crt/internal/find-crt-uniforms.ts
|
|
395
|
+
const { nonNullable: nonNullable$9 } = P;
|
|
396
|
+
const findCrtUniforms = (context, program) => match(mapValues(CRT_UNIFORM_NAMES, (name) => context.getUniformLocation(program, name) ?? void 0)).with({
|
|
397
|
+
frame: nonNullable$9,
|
|
398
|
+
frameSize: nonNullable$9,
|
|
399
|
+
canvasSize: nonNullable$9,
|
|
400
|
+
pixelSize: nonNullable$9,
|
|
401
|
+
time: nonNullable$9
|
|
402
|
+
}, (uniforms) => uniforms).otherwise(() => void 0);
|
|
403
|
+
// libs/engine/src/crt/internal/format-glsl-float.ts
|
|
404
|
+
const GLSL_FLOAT_DIGITS = 4;
|
|
405
|
+
const formatGlslFloat = (value) => value.toFixed(GLSL_FLOAT_DIGITS);
|
|
406
|
+
// libs/engine/src/crt/internal/fragment-shader-source.ts
|
|
407
|
+
const FRAGMENT_SHADER_SOURCE = `
|
|
408
|
+
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
|
409
|
+
precision highp float;
|
|
410
|
+
#else
|
|
411
|
+
precision mediump float;
|
|
412
|
+
#endif
|
|
413
|
+
|
|
414
|
+
uniform sampler2D ${CRT_UNIFORM_NAMES.frame};
|
|
415
|
+
uniform vec2 ${CRT_UNIFORM_NAMES.frameSize};
|
|
416
|
+
uniform vec2 ${CRT_UNIFORM_NAMES.canvasSize};
|
|
417
|
+
uniform float ${CRT_UNIFORM_NAMES.pixelSize};
|
|
418
|
+
uniform float ${CRT_UNIFORM_NAMES.time};
|
|
419
|
+
|
|
420
|
+
const float GLOW_STRENGTH = ${formatGlslFloat(GLOW_STRENGTH)};
|
|
421
|
+
const float GLOW_THRESHOLD = ${formatGlslFloat(GLOW_THRESHOLD)};
|
|
422
|
+
const int GLOW_REACH = 2;
|
|
423
|
+
const float SCANLINE_DEPTH = ${formatGlslFloat(SCANLINE_DEPTH)};
|
|
424
|
+
const float GRAIN_AMOUNT = ${formatGlslFloat(GRAIN_AMOUNT)};
|
|
425
|
+
const float GRAIN_RATE = ${formatGlslFloat(24)};
|
|
426
|
+
const float SURFACE_AMOUNT = ${formatGlslFloat(SURFACE_AMOUNT)};
|
|
427
|
+
const float SURFACE_SCALE = ${formatGlslFloat(3)};
|
|
428
|
+
const vec3 LUMINANCE = vec3(0.299, 0.587, 0.114);
|
|
429
|
+
|
|
430
|
+
vec4 sampleFrame(vec2 position) {
|
|
431
|
+
vec2 logical = floor(position / ${CRT_UNIFORM_NAMES.pixelSize});
|
|
432
|
+
|
|
433
|
+
if (logical.x < 0.0 || logical.y < 0.0
|
|
434
|
+
|| logical.x >= ${CRT_UNIFORM_NAMES.frameSize}.x
|
|
435
|
+
|| logical.y >= ${CRT_UNIFORM_NAMES.frameSize}.y) {
|
|
436
|
+
return vec4(0.0);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return texture2D(
|
|
440
|
+
${CRT_UNIFORM_NAMES.frame},
|
|
441
|
+
(logical + 0.5) / ${CRT_UNIFORM_NAMES.frameSize}
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
float hash(vec2 point) {
|
|
446
|
+
vec3 mixed = fract(vec3(point.xyx) * 0.1031);
|
|
447
|
+
mixed += dot(mixed, mixed.yzx + 33.33);
|
|
448
|
+
return fract((mixed.x + mixed.y) * mixed.z);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
float valueNoise(vec2 point) {
|
|
452
|
+
vec2 cell = floor(point);
|
|
453
|
+
vec2 blend = fract(point);
|
|
454
|
+
blend = blend * blend * (3.0 - 2.0 * blend);
|
|
455
|
+
|
|
456
|
+
return mix(
|
|
457
|
+
mix(hash(cell), hash(cell + vec2(1.0, 0.0)), blend.x),
|
|
458
|
+
mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), blend.x),
|
|
459
|
+
blend.y
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
vec3 computeBright(vec3 color) {
|
|
464
|
+
return color * smoothstep(GLOW_THRESHOLD, 1.0, dot(color, LUMINANCE));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
vec3 computeGlow(vec2 position, vec3 base) {
|
|
468
|
+
vec3 glow = vec3(0.0);
|
|
469
|
+
float total = 0.0;
|
|
470
|
+
|
|
471
|
+
for (int row = -GLOW_REACH; row <= GLOW_REACH; row++) {
|
|
472
|
+
for (int column = -GLOW_REACH; column <= GLOW_REACH; column++) {
|
|
473
|
+
vec2 offset = vec2(float(column), float(row));
|
|
474
|
+
float reach = length(offset);
|
|
475
|
+
|
|
476
|
+
if (reach > float(GLOW_REACH)) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
float weight = 1.0 - reach / (float(GLOW_REACH) + 1.0);
|
|
481
|
+
|
|
482
|
+
glow += computeBright(sampleFrame(position + offset).rgb) * weight;
|
|
483
|
+
total += weight;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return max(glow / total - computeBright(base), 0.0);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
float computeScanline(vec2 position) {
|
|
491
|
+
float period = max(${CRT_UNIFORM_NAMES.pixelSize}, 2.0);
|
|
492
|
+
float phase = (mod(floor(position.y), period) + 0.5) / period;
|
|
493
|
+
|
|
494
|
+
return 1.0 - SCANLINE_DEPTH * smoothstep(0.4, 1.0, phase);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
float computeGrain(vec2 position) {
|
|
498
|
+
float grainFrame = floor(${CRT_UNIFORM_NAMES.time} * GRAIN_RATE / 1000.0);
|
|
499
|
+
vec2 shift = fract(vec2(grainFrame * 0.6180339, grainFrame * 0.3819660))
|
|
500
|
+
* 1024.0;
|
|
501
|
+
|
|
502
|
+
return (hash(position + shift) - 0.5) * GRAIN_AMOUNT;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
float computeSurface(vec2 position) {
|
|
506
|
+
return (valueNoise(position / SURFACE_SCALE) - 0.5) * SURFACE_AMOUNT;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
void main() {
|
|
510
|
+
vec2 position = vec2(
|
|
511
|
+
gl_FragCoord.x,
|
|
512
|
+
${CRT_UNIFORM_NAMES.canvasSize}.y - gl_FragCoord.y
|
|
513
|
+
);
|
|
514
|
+
vec4 base = sampleFrame(position);
|
|
515
|
+
vec3 color = (base.rgb + computeGlow(position, base.rgb) * GLOW_STRENGTH)
|
|
516
|
+
* computeScanline(position)
|
|
517
|
+
* (1.0 + computeSurface(position))
|
|
518
|
+
+ computeGrain(position) * base.a;
|
|
519
|
+
|
|
520
|
+
color = clamp(color, 0.0, 1.0);
|
|
521
|
+
|
|
522
|
+
gl_FragColor = vec4(color, max(base.a, max(color.r, max(color.g, color.b))));
|
|
523
|
+
}
|
|
524
|
+
`;
|
|
525
|
+
// libs/engine/src/crt/internal/try-create-shader.ts
|
|
526
|
+
const { nonNullable: nonNullable$8 } = P;
|
|
527
|
+
const tryCompileShader = (context, shader, source) => pipe(shader, (created) => runEffect(created, (target) => context.shaderSource(target, source)), (sourced) => runEffect(sourced, (target) => context.compileShader(target)), (compiled) => match(context.getShaderParameter(compiled, context.COMPILE_STATUS)).with(true, () => compiled).otherwise(() => void 0));
|
|
528
|
+
const tryCreateShader = (context, type, source) => match(context.createShader(type)).with(nonNullable$8, (shader) => tryCompileShader(context, shader, source)).otherwise(() => void 0);
|
|
529
|
+
// libs/engine/src/crt/internal/try-link-program.ts
|
|
530
|
+
const tryLinkProgram = (context, program, vertexShader, fragmentShader) => pipe(program, (created) => runEffect(created, (target) => context.attachShader(target, vertexShader)), (attached) => runEffect(attached, (target) => context.attachShader(target, fragmentShader)), (attached) => runEffect(attached, (target) => context.bindAttribLocation(target, 0, POSITION_ATTRIBUTE)), (bound) => runEffect(bound, (target) => context.linkProgram(target)), (linked) => match(context.getProgramParameter(linked, context.LINK_STATUS)).with(true, () => linked).otherwise(() => void 0));
|
|
531
|
+
// libs/engine/src/crt/internal/vertex-shader-source.ts
|
|
532
|
+
const VERTEX_SHADER_SOURCE = `
|
|
533
|
+
attribute vec2 ${POSITION_ATTRIBUTE};
|
|
534
|
+
|
|
535
|
+
void main() {
|
|
536
|
+
gl_Position = vec4(${POSITION_ATTRIBUTE}, 0.0, 1.0);
|
|
537
|
+
}
|
|
538
|
+
`;
|
|
539
|
+
// libs/engine/src/crt/internal/try-create-program.ts
|
|
540
|
+
const { nonNullable: nonNullable$7 } = P;
|
|
541
|
+
const tryCreateProgram = (context) => match({
|
|
542
|
+
program: context.createProgram(),
|
|
543
|
+
vertexShader: tryCreateShader(context, context.VERTEX_SHADER, VERTEX_SHADER_SOURCE),
|
|
544
|
+
fragmentShader: tryCreateShader(context, context.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE)
|
|
545
|
+
}).with({
|
|
546
|
+
program: nonNullable$7,
|
|
547
|
+
vertexShader: nonNullable$7,
|
|
548
|
+
fragmentShader: nonNullable$7
|
|
549
|
+
}, ({ program, vertexShader, fragmentShader }) => tryLinkProgram(context, program, vertexShader, fragmentShader)).otherwise(() => void 0);
|
|
550
|
+
// libs/engine/src/crt/internal/try-create-texture.ts
|
|
551
|
+
const { nonNullable: nonNullable$6 } = P;
|
|
552
|
+
const patchTextureSampling = (context, texture) => pipe(texture, (created) => runEffect(created, (target) => context.bindTexture(context.TEXTURE_2D, target)), (bound) => runEffect(bound, () => context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST)), (bound) => runEffect(bound, () => context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST)), (bound) => runEffect(bound, () => context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE)), (bound) => runEffect(bound, () => context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE)));
|
|
553
|
+
const tryCreateTexture = (context) => match(context.createTexture()).with(nonNullable$6, (texture) => patchTextureSampling(context, texture)).otherwise(() => void 0);
|
|
554
|
+
// libs/engine/src/crt/internal/try-create-vertices.ts
|
|
555
|
+
const { nonNullable: nonNullable$5 } = P;
|
|
556
|
+
const patchVertices = (context, vertices) => pipe(vertices, (created) => runEffect(created, (target) => context.bindBuffer(context.ARRAY_BUFFER, target)), (bound) => runEffect(bound, () => context.bufferData(context.ARRAY_BUFFER, new Float32Array(FULL_SCREEN_TRIANGLE), context.STATIC_DRAW)));
|
|
557
|
+
const tryCreateVertices = (context) => match(context.createBuffer()).with(nonNullable$5, (vertices) => patchVertices(context, vertices)).otherwise(() => void 0);
|
|
558
|
+
// libs/engine/src/crt/try-create-crt.ts
|
|
559
|
+
const { nonNullable: nonNullable$4 } = P;
|
|
560
|
+
const createCrtDraft = (context, program) => ({
|
|
561
|
+
program,
|
|
562
|
+
texture: tryCreateTexture(context),
|
|
563
|
+
vertices: tryCreateVertices(context),
|
|
564
|
+
uniforms: match(program).with(nonNullable$4, (linked) => findCrtUniforms(context, linked)).otherwise(() => void 0)
|
|
565
|
+
});
|
|
566
|
+
const tryToCrt = (draft) => match(draft).with({
|
|
567
|
+
program: nonNullable$4,
|
|
568
|
+
texture: nonNullable$4,
|
|
569
|
+
vertices: nonNullable$4,
|
|
570
|
+
uniforms: nonNullable$4
|
|
571
|
+
}, (crt) => crt).otherwise(() => void 0);
|
|
572
|
+
/**
|
|
573
|
+
* Sets up the monitor effect on a WebGL context: the shader program, the
|
|
574
|
+
* texture that holds the frame, and a triangle that covers the canvas.
|
|
575
|
+
* Returns `undefined` if there is no context or any step fails.
|
|
576
|
+
*/
|
|
577
|
+
const tryCreateCrt = (context) => match(context).with(nonNullable$4, (ready) => tryToCrt(createCrtDraft(ready, tryCreateProgram(ready)))).otherwise(() => void 0);
|
|
578
|
+
// libs/engine/src/palette/internal/compute-rgb-mix.ts
|
|
579
|
+
const computeChannelMix = (channel, target, amount) => round(channel + (target - channel) * amount);
|
|
580
|
+
const computeRgbMix = (color, target, amount) => ({
|
|
581
|
+
red: computeChannelMix(color.red, target.red, amount),
|
|
582
|
+
green: computeChannelMix(color.green, target.green, amount),
|
|
583
|
+
blue: computeChannelMix(color.blue, target.blue, amount)
|
|
584
|
+
});
|
|
585
|
+
// libs/engine/src/palette/internal/compute-extended-shade.ts
|
|
586
|
+
const { number: number$1 } = P;
|
|
587
|
+
const EXTENDED_BASE_SHADE = toExtendedShade(4);
|
|
588
|
+
const DARKEN_STEP = 1 / 20;
|
|
589
|
+
const LIGHTEN_STEP = 1 / 16;
|
|
590
|
+
const computeExtendedShade = (base, extendedShade) => match(extendedShade).with(number$1.lt(EXTENDED_BASE_SHADE), (darker) => computeRgbMix(base, BLACK, (EXTENDED_BASE_SHADE - darker) * DARKEN_STEP)).with(number$1.gt(EXTENDED_BASE_SHADE), (lighter) => computeRgbMix(base, WHITE, (lighter - EXTENDED_BASE_SHADE) * LIGHTEN_STEP)).otherwise(() => base);
|
|
591
|
+
// libs/engine/src/palette/internal/create-palette-entries.ts
|
|
592
|
+
const createPaletteEntries = (base, color) => map(range(32), (extendedShade) => [toExtendedPixel(color, extendedShade), computeExtendedShade(base, extendedShade)]);
|
|
593
|
+
// libs/engine/src/palette/internal/split-hex-rgb.ts
|
|
594
|
+
const HEX_CHANNEL_PATTERN = /[\da-f]{2}/gi;
|
|
595
|
+
const HEX_RADIX = 16;
|
|
596
|
+
const splitHexRgb = (color) => pipe(words(color, HEX_CHANNEL_PATTERN), (channels) => map(channels, (channel) => parseInt(channel, HEX_RADIX)), ([red = 0, green = 0, blue = 0]) => ({
|
|
597
|
+
red,
|
|
598
|
+
green,
|
|
599
|
+
blue
|
|
600
|
+
}));
|
|
601
|
+
// libs/engine/src/palette/internal/to-rgb.ts
|
|
602
|
+
const { string } = P;
|
|
603
|
+
const HEX_COLOR_PATTERN = /^#[\da-f]{6}$/i;
|
|
604
|
+
const toRgb = (color) => match(color).with(string.regex(HEX_COLOR_PATTERN), splitHexRgb).otherwise(() => BLACK);
|
|
605
|
+
// libs/engine/src/palette/create-palette.ts
|
|
606
|
+
/**
|
|
607
|
+
* Expands the 16 base colors into 512 colors: 32 extended shades per color,
|
|
608
|
+
* keyed by extended pixel.
|
|
609
|
+
*
|
|
610
|
+
* Sprite shade `s` is extended shade `4 * s + 1`. Shade `BASE_SHADE` is the
|
|
611
|
+
* base color itself. Darker shades are mixed with black, lighter shades with
|
|
612
|
+
* white. Only light reaches the extended shades between and beyond the
|
|
613
|
+
* sprite shades. A color that is not in `#rrggbb` form is read as black.
|
|
614
|
+
*/
|
|
615
|
+
const createPalette = (colors) => pipe(map(colors, toRgb), (bases) => flatMap(bases, createPaletteEntries), (entries) => fromPairs(entries));
|
|
616
|
+
// libs/engine/src/display/internal/compute-display-size.ts
|
|
617
|
+
const computeDisplaySize = (canvas, pixelSize) => ({
|
|
618
|
+
width: floor(canvas.width / pixelSize),
|
|
619
|
+
height: floor(canvas.height / pixelSize)
|
|
620
|
+
});
|
|
621
|
+
// libs/engine/src/display/internal/try-to-display.ts
|
|
622
|
+
const { nonNullable: nonNullable$3, number } = P;
|
|
623
|
+
const POSITIVE_INTEGER = number.int().positive();
|
|
624
|
+
const tryToDisplay = (draft) => match(draft).with({
|
|
625
|
+
context: nonNullable$3,
|
|
626
|
+
crt: nonNullable$3,
|
|
627
|
+
pixelSize: POSITIVE_INTEGER,
|
|
628
|
+
spriteSize: POSITIVE_INTEGER,
|
|
629
|
+
width: POSITIVE_INTEGER,
|
|
630
|
+
height: POSITIVE_INTEGER
|
|
631
|
+
}, (display) => display).otherwise(() => void 0);
|
|
632
|
+
// libs/engine/src/display/try-create-display.ts
|
|
633
|
+
/**
|
|
634
|
+
* Creates a display on the canvas.
|
|
635
|
+
*
|
|
636
|
+
* - `colors`: the 16 base colors.
|
|
637
|
+
* - `pixelSize`: the width and height of one logical pixel, in canvas pixels.
|
|
638
|
+
* - `spriteSize`: the width and height of every sprite, in logical pixels.
|
|
639
|
+
*
|
|
640
|
+
* The screen size in logical pixels is the canvas size divided by
|
|
641
|
+
* `pixelSize`. The display draws with WebGL, and the monitor effect is
|
|
642
|
+
* always on.
|
|
643
|
+
*
|
|
644
|
+
* Returns `undefined` if the canvas has no WebGL context, the monitor effect
|
|
645
|
+
* cannot be set up, a size is not a positive whole number, or the canvas is
|
|
646
|
+
* smaller than one logical pixel.
|
|
647
|
+
*/
|
|
648
|
+
const tryCreateDisplay = (canvas, { colors, pixelSize, spriteSize }) => pipe({
|
|
649
|
+
context: canvas.getContext("webgl", CRT_CONTEXT_ATTRIBUTES) ?? void 0,
|
|
650
|
+
size: computeDisplaySize(canvas, pixelSize)
|
|
651
|
+
}, ({ context, size }) => ({
|
|
652
|
+
context,
|
|
653
|
+
crt: tryCreateCrt(context),
|
|
654
|
+
palette: createPalette(colors),
|
|
655
|
+
pixelSize,
|
|
656
|
+
spriteSize,
|
|
657
|
+
width: size.width,
|
|
658
|
+
height: size.height
|
|
659
|
+
}), tryToDisplay);
|
|
660
|
+
// libs/engine/src/palette/to-lit-pixel.ts
|
|
661
|
+
const { nonNullable: nonNullable$2 } = P;
|
|
662
|
+
/**
|
|
663
|
+
* Returns the extended pixel that a pixel shows under light. The color stays
|
|
664
|
+
* the same. The shade moves `shadeOffset` extended shades up or down
|
|
665
|
+
* (4 extended shades = 1 sprite shade). The result is rounded and stops at
|
|
666
|
+
* the darkest and lightest extended shade. `toLitPixel(pixel, 0)` returns
|
|
667
|
+
* the authored color. Returns `TRANSPARENT` for `TRANSPARENT` and for any
|
|
668
|
+
* value that is not a pixel.
|
|
669
|
+
*/
|
|
670
|
+
const toLitPixel = (pixel, shadeOffset) => match(SHADE_RAMPS[pixel]).with(nonNullable$2, ({ pixels, extendedShade }) => pixels[clamp(round(extendedShade + shadeOffset), 0, 31)] ?? -1).otherwise(() => -1);
|
|
671
|
+
// libs/engine/src/screen/internal/filter-other-lights.ts
|
|
672
|
+
const filterOtherLights = (lights, id) => filter(lights, (light) => light.id !== id);
|
|
673
|
+
// libs/engine/src/screen/add-light.ts
|
|
674
|
+
/**
|
|
675
|
+
* Adds a light to the screen. If a light with the same id exists, it is
|
|
676
|
+
* replaced. Returns a new screen.
|
|
677
|
+
*/
|
|
678
|
+
const addLight = (screen, id, { row, column, radius, intensity }) => ({
|
|
679
|
+
...screen,
|
|
680
|
+
lights: [...filterOtherLights(screen.lights, id), {
|
|
681
|
+
id,
|
|
682
|
+
row,
|
|
683
|
+
column,
|
|
684
|
+
radius,
|
|
685
|
+
intensity
|
|
686
|
+
}]
|
|
687
|
+
});
|
|
688
|
+
// libs/engine/src/screen/internal/filter-other-sprites.ts
|
|
689
|
+
const filterOtherSprites = (sprites, id) => filter(sprites, (placement) => placement.id !== id);
|
|
690
|
+
// libs/engine/src/screen/add-sprite.ts
|
|
691
|
+
/**
|
|
692
|
+
* Adds a sprite on top of the other sprites. `row` and `column` set its
|
|
693
|
+
* top-left pixel. If a sprite with the same id exists, it is removed first.
|
|
694
|
+
* The new sprite does not cast a shadow; to change that, call
|
|
695
|
+
* `patchSprite(screen, id, { castsShadow: true })`. Returns a new screen.
|
|
696
|
+
*/
|
|
697
|
+
const addSprite = (screen, id, sprite, row, column) => ({
|
|
698
|
+
...screen,
|
|
699
|
+
sprites: [...filterOtherSprites(screen.sprites, id), {
|
|
700
|
+
id,
|
|
701
|
+
sprite,
|
|
702
|
+
row,
|
|
703
|
+
column,
|
|
704
|
+
castsShadow: false
|
|
705
|
+
}]
|
|
706
|
+
});
|
|
707
|
+
// libs/engine/src/screen/create-screen.ts
|
|
708
|
+
/**
|
|
709
|
+
* Creates an empty screen, with no sprites and no lights, filled with
|
|
710
|
+
* `background`. `ambient` shifts every pixel by that many extended shades
|
|
711
|
+
* (4 extended shades = 1 sprite shade). For example, a night scene could
|
|
712
|
+
* use -16.
|
|
713
|
+
*/
|
|
714
|
+
const createScreen = (background = -1, ambient = 0) => ({
|
|
715
|
+
background,
|
|
716
|
+
ambient,
|
|
717
|
+
sprites: [],
|
|
718
|
+
lights: []
|
|
719
|
+
});
|
|
720
|
+
// libs/engine/src/screen/find-light.ts
|
|
721
|
+
/** Returns the light with the id, or `undefined` if there is none. */
|
|
722
|
+
const findLight = (screen, id) => find(screen.lights, (light) => light.id === id);
|
|
723
|
+
// libs/engine/src/screen/find-sprite.ts
|
|
724
|
+
/** Returns the sprite with the id, or `undefined` if there is none. */
|
|
725
|
+
const findSprite = (screen, id) => find(screen.sprites, (placement) => placement.id === id);
|
|
726
|
+
// libs/engine/src/screen/patch-ambient.ts
|
|
727
|
+
/**
|
|
728
|
+
* Sets the ambient light: how many extended shades every pixel moves up
|
|
729
|
+
* (positive) or down (negative) before lights are added. Change it over time
|
|
730
|
+
* to fade between day and night. Returns a new screen.
|
|
731
|
+
*/
|
|
732
|
+
const patchAmbient = (screen, ambient) => ({
|
|
733
|
+
...screen,
|
|
734
|
+
ambient
|
|
735
|
+
});
|
|
736
|
+
// libs/engine/src/screen/internal/patch-matching-light.ts
|
|
737
|
+
const patchMatchingLight = (light, id, changes) => match(light.id === id).with(true, () => ({
|
|
738
|
+
id,
|
|
739
|
+
row: changes.row ?? light.row,
|
|
740
|
+
column: changes.column ?? light.column,
|
|
741
|
+
radius: changes.radius ?? light.radius,
|
|
742
|
+
intensity: changes.intensity ?? light.intensity
|
|
743
|
+
})).otherwise(() => light);
|
|
744
|
+
// libs/engine/src/screen/patch-light.ts
|
|
745
|
+
/**
|
|
746
|
+
* Changes a light's `row`, `column`, `radius` or `intensity`. Fields you
|
|
747
|
+
* leave out keep their values. Returns a new screen. If no light has the id,
|
|
748
|
+
* nothing changes.
|
|
749
|
+
*/
|
|
750
|
+
const patchLight = (screen, id, changes) => ({
|
|
751
|
+
...screen,
|
|
752
|
+
lights: map(screen.lights, (light) => patchMatchingLight(light, id, changes))
|
|
753
|
+
});
|
|
754
|
+
// libs/engine/src/screen/patch-light-by-offset.ts
|
|
755
|
+
const { nonNullable: nonNullable$1 } = P;
|
|
756
|
+
/**
|
|
757
|
+
* Moves a light `rowOffset` pixels down and `columnOffset` pixels right.
|
|
758
|
+
* Negative values move it up and left. Returns a new screen. If no light has
|
|
759
|
+
* the id, nothing changes.
|
|
760
|
+
*/
|
|
761
|
+
const patchLightByOffset = (screen, id, rowOffset, columnOffset) => match(findLight(screen, id)).with(nonNullable$1, ({ row, column }) => patchLight(screen, id, {
|
|
762
|
+
row: row + rowOffset,
|
|
763
|
+
column: column + columnOffset
|
|
764
|
+
})).otherwise(() => screen);
|
|
765
|
+
// libs/engine/src/screen/internal/patch-sprite-placement.ts
|
|
766
|
+
const patchSpritePlacement = (placement, id, changes) => match(placement.id === id).with(true, () => ({
|
|
767
|
+
id,
|
|
768
|
+
sprite: changes.sprite ?? placement.sprite,
|
|
769
|
+
row: changes.row ?? placement.row,
|
|
770
|
+
column: changes.column ?? placement.column,
|
|
771
|
+
castsShadow: changes.castsShadow ?? placement.castsShadow
|
|
772
|
+
})).otherwise(() => placement);
|
|
773
|
+
// libs/engine/src/screen/patch-sprite.ts
|
|
774
|
+
/**
|
|
775
|
+
* Changes a sprite's `sprite` (for animation), `row`, `column` or
|
|
776
|
+
* `castsShadow`. Fields you leave out keep their values. The sprite keeps its
|
|
777
|
+
* place in the drawing order. Returns a new screen. If no sprite has the id,
|
|
778
|
+
* nothing changes.
|
|
779
|
+
*/
|
|
780
|
+
const patchSprite = (screen, id, changes) => ({
|
|
781
|
+
...screen,
|
|
782
|
+
sprites: map(screen.sprites, (placement) => patchSpritePlacement(placement, id, changes))
|
|
783
|
+
});
|
|
784
|
+
// libs/engine/src/screen/patch-sprite-by-offset.ts
|
|
785
|
+
const { nonNullable } = P;
|
|
786
|
+
/**
|
|
787
|
+
* Moves a sprite `rowOffset` pixels down and `columnOffset` pixels right.
|
|
788
|
+
* Negative values move it up and left. Returns a new screen. If no sprite
|
|
789
|
+
* has the id, nothing changes.
|
|
790
|
+
*/
|
|
791
|
+
const patchSpriteByOffset = (screen, id, rowOffset, columnOffset) => match(findSprite(screen, id)).with(nonNullable, ({ row, column }) => patchSprite(screen, id, {
|
|
792
|
+
row: row + rowOffset,
|
|
793
|
+
column: column + columnOffset
|
|
794
|
+
})).otherwise(() => screen);
|
|
795
|
+
// libs/engine/src/screen/remove-light.ts
|
|
796
|
+
/** Removes the light with the id. Returns a new screen. */
|
|
797
|
+
const removeLight = (screen, id) => ({
|
|
798
|
+
...screen,
|
|
799
|
+
lights: filterOtherLights(screen.lights, id)
|
|
800
|
+
});
|
|
801
|
+
// libs/engine/src/screen/remove-sprite.ts
|
|
802
|
+
/** Removes the sprite with the id. Returns a new screen. */
|
|
803
|
+
const removeSprite = (screen, id) => ({
|
|
804
|
+
...screen,
|
|
805
|
+
sprites: filterOtherSprites(screen.sprites, id)
|
|
806
|
+
});
|
|
807
|
+
// libs/engine/src/screen/to-sprite.ts
|
|
808
|
+
/**
|
|
809
|
+
* Builds a sprite from text art: one string per row, one character per
|
|
810
|
+
* pixel. Each character is looked up in `legend`. Characters that are not in
|
|
811
|
+
* the legend become transparent.
|
|
812
|
+
*/
|
|
813
|
+
const toSprite = (rows, legend) => flatMap(rows, (row) => map(split(row, ""), (symbol) => legend[symbol] ?? -1));
|
|
814
|
+
export { BASE_SHADE, COLOR_COUNT, EXTENDED_SHADES_PER_SHADE, EXTENDED_SHADE_COUNT, SHADE_COUNT, TRANSPARENT, addLight, addSprite, computeFrame, createPalette, createScreen, drawScreen, findLight, findSprite, isPixel, patchAmbient, patchLight, patchLightByOffset, patchSprite, patchSpriteByOffset, removeLight, removeSprite, toExtendedPixel, toLitPixel, toPixel, toSprite, tryCreateDisplay };
|
|
815
|
+
|
|
816
|
+
//# sourceMappingURL=index.js.map
|