yuuna-engine 0.3.0 → 0.5.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/README.md +65 -107
- package/lib/engine/types.d.ts +46 -8
- package/lib/index.cjs +227 -26
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +227 -26
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,14 +32,19 @@ Then describe your game as state + render + nextState:
|
|
|
32
32
|
```ts
|
|
33
33
|
import { runEngine } from "yuuna-engine";
|
|
34
34
|
|
|
35
|
+
// The shape of your game's data — whatever it takes to fully describe
|
|
36
|
+
// what's on screen and how it behaves
|
|
35
37
|
type GameState = { cookies: number };
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
// What that state looks like before anything has happened yet
|
|
40
|
+
const initialState: GameState = { cookies: 0 };
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
runEngine<GameState>({
|
|
43
|
+
initialState,
|
|
42
44
|
|
|
45
|
+
// Given the current state, what should be drawn this frame? Called
|
|
46
|
+
// every frame — always derive the picture from state, instead of
|
|
47
|
+
// reaching for the canvas directly.
|
|
43
48
|
render: (state) => ({
|
|
44
49
|
renderables: [
|
|
45
50
|
{
|
|
@@ -59,6 +64,9 @@ runEngine<GameState>({
|
|
|
59
64
|
],
|
|
60
65
|
}),
|
|
61
66
|
|
|
67
|
+
// Given the current state and something that just happened, what's the
|
|
68
|
+
// next state? Called once per event (a click, a frame tick, ...) — the
|
|
69
|
+
// only place game logic lives.
|
|
62
70
|
nextState: ({ state, event }) => {
|
|
63
71
|
if (event.tag === "CLICK" && event.id === "cookie") {
|
|
64
72
|
return { cookies: state.cookies + 1 };
|
|
@@ -72,110 +80,42 @@ runEngine<GameState>({
|
|
|
72
80
|
## Concepts
|
|
73
81
|
|
|
74
82
|
- **Renderables** — declarative shapes drawn each frame: `RECTANGLE`,
|
|
75
|
-
`CIRCLE`, `TEXT`, `SPRITE`, `
|
|
76
|
-
one an `id` plus `isClickable`
|
|
77
|
-
|
|
78
|
-
`
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
full `Renderable`, so it can have its own `id` / `isClickable` / even
|
|
108
|
-
its own `children`. `screenSpace: true` makes a renderable (and its
|
|
109
|
-
whole subtree) ignore the camera below and stay fixed to the screen —
|
|
110
|
-
for UI/HUD that shouldn't pan or zoom with the game world.
|
|
111
|
-
- **Camera** — pass `camera: (state) => ({ x, y, zoom })` to `runEngine` to
|
|
112
|
-
pan/zoom every world-space renderable (anything without
|
|
113
|
-
`screenSpace: true`) as a group, the same way a `GROUP` parent works for
|
|
114
|
-
its children — `{ x, y }` is the world position mapped to canvas
|
|
115
|
-
`(0, 0)`, and `zoom` scales everything around that same point. It's a
|
|
116
|
-
function of state, so the camera can follow something or react to a
|
|
117
|
-
zoom level you're tracking yourself.
|
|
118
|
-
- **Events** — your `nextState` function receives one `GameEvent` per call:
|
|
119
|
-
`TIME` (frame tick with `delta`), `CLICK`, `HOVER_IN`, `HOVER_OUT`, or
|
|
120
|
-
`MOUSE_MOVE`. The mouse-carrying ones include both `mouse` (raw canvas
|
|
121
|
-
pixels — use for `screenSpace`/UI logic) and `worldMouse` (that same
|
|
122
|
-
position run through the camera's inverse transform — use to
|
|
123
|
-
place/locate world-space things, e.g. build a turret where the player
|
|
124
|
-
clicked). With no `camera` set, `worldMouse` always equals `mouse`.
|
|
125
|
-
- **Keyboard** — `nextState` also receives a `keyboard` map keyed by
|
|
126
|
-
`KeyCode`-style keys (e.g. `"KeyW"`, `"ArrowLeft"`, `"Space"`), each with
|
|
127
|
-
`isPressed` / `isJustPressed` / `isJustReleased`.
|
|
128
|
-
- **Sprites** — pass a `resources` map of `{ src, size, slices }` to
|
|
129
|
-
`runEngine` to load spritesheets, then reference them by id with a
|
|
130
|
-
`SPRITE` renderable's `resourceId` and `frame`. Set `flipX: true` to
|
|
131
|
-
mirror a sprite horizontally — useful when the art is drawn facing one
|
|
132
|
-
direction but needs to move the other way. Add an `animations` map to a
|
|
133
|
-
resource — `{ frames: number[], frameDuration, loop }` each — to play
|
|
134
|
-
one with `ANIMATED_SPRITE` instead of managing `frame` by hand.
|
|
135
|
-
- **Sound effects** — pass a `sounds` map of `{ src }` to `runEngine`, then
|
|
136
|
-
call the `playSound(id)` function `nextState` receives to play one, e.g.
|
|
137
|
-
`playSound("collect")` when a cookie is clicked. Calling it again while
|
|
138
|
-
a sound is still playing overlaps a new copy instead of cutting the
|
|
139
|
-
first one off.
|
|
140
|
-
- **Music** — pass a `music` map of `{ src }` to `runEngine`, then use the
|
|
141
|
-
`playMusic(id)` / `pauseMusic()` functions `nextState` receives to
|
|
142
|
-
control a looping background track. Unlike `playSound`, only one track
|
|
143
|
-
plays at a time and it keeps running in the background across frames
|
|
144
|
-
instead of firing once; `pauseMusic()` leaves it where it stopped, so
|
|
145
|
-
calling `playMusic(id)` again resumes it instead of starting over.
|
|
146
|
-
- **Canvas** — pass `canvas: { width, height, backgroundColor }` to
|
|
147
|
-
`runEngine` to size and color the canvas from code. All three are
|
|
148
|
-
optional; anything you don't set falls back to the canvas element's
|
|
149
|
-
existing HTML/CSS.
|
|
150
|
-
- **Mechanics** — `nextState` can also be an array of small
|
|
151
|
-
`NextStateFunction`s instead of one big function. Each one is run in
|
|
152
|
-
order for every event, and can return:
|
|
153
|
-
- a new state, to update to
|
|
154
|
-
- `undefined` (or no `return` at all) — no change, but the rest of the
|
|
155
|
-
list still runs, so a guard can just be `if (...) return;`
|
|
156
|
-
- `STOP` (imported from `yuuna-engine`) — no change, and the rest of
|
|
157
|
-
the list is skipped for this event, so a shared rule (like "nothing
|
|
158
|
-
happens once the game is over") only needs to be written once
|
|
159
|
-
|
|
160
|
-
```ts
|
|
161
|
-
import { runEngine, STOP, type NextStateFunction } from "yuuna-engine";
|
|
162
|
-
|
|
163
|
-
const freezeOnGameOver: NextStateFunction<GameState> = ({ state }) => {
|
|
164
|
-
if (state.lives <= 0) return STOP;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const moveEnemies: NextStateFunction<GameState> = ({ state, event }) => {
|
|
168
|
-
if (event.tag === "TIME") {
|
|
169
|
-
return { ...state, enemies: move(state.enemies, event.delta) };
|
|
170
|
-
}
|
|
171
|
-
};
|
|
83
|
+
`CIRCLE`, `TEXT`, `SPRITE`, `ANIMATED_SPRITE`, `LINE`, and `GROUP`. Give
|
|
84
|
+
one an `id` plus `isClickable`/`isHoverable` to make it interactive.
|
|
85
|
+
- **Events** — `nextState` receives one `GameEvent` per call: `TIME`,
|
|
86
|
+
`CLICK`, `HOVER_IN`, `HOVER_OUT`, `MOUSE_MOVE`, `MOUSE_LEAVE`,
|
|
87
|
+
`MUSIC_END`, or a `CUSTOM` event of a type you define yourself, for
|
|
88
|
+
reporting things like an async `fetch()` resolving back into your
|
|
89
|
+
state machine.
|
|
90
|
+
- **Keyboard, camera, sprites & animation, sound effects & music,
|
|
91
|
+
canvas config, and mechanics pipelines** all follow the same idea:
|
|
92
|
+
small, focused props and functions `runEngine`/`nextState` take, that
|
|
93
|
+
compose with everything above instead of replacing it.
|
|
94
|
+
|
|
95
|
+
This README stays intentionally thin — the full concept-by-concept
|
|
96
|
+
reference, with every option and example, lives on the
|
|
97
|
+
[wiki](https://github.com/lucy-dot-exe/yuuna/wiki). The
|
|
98
|
+
[playground](https://lucy-dot-exe.github.io/yuuna/#playground) also has a
|
|
99
|
+
small, focused example for most of these you can run and edit directly.
|
|
100
|
+
|
|
101
|
+
## Templates
|
|
102
|
+
|
|
103
|
+
Prefer a working starting point over typing the quick start out by
|
|
104
|
+
hand? Grab one from [`templates/`](templates):
|
|
105
|
+
|
|
106
|
+
- **[blank](templates/blank)** — a single `index.html`, zero install —
|
|
107
|
+
open it in a browser and it runs.
|
|
108
|
+
- **[npm](templates/npm)** — TypeScript + a dev server with hot reload
|
|
109
|
+
(via Vite), for a real local project.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npx degit lucy-dot-exe/yuuna/templates/blank my-game
|
|
113
|
+
# or: npx degit lucy-dot-exe/yuuna/templates/npm my-game
|
|
114
|
+
```
|
|
172
115
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
nextState: [freezeOnGameOver, moveEnemies /* ... */],
|
|
177
|
-
});
|
|
178
|
-
```
|
|
116
|
+
[`degit`](https://github.com/Rich-Harris/degit) copies the folder without
|
|
117
|
+
its git history — no cloning or forking the whole engine repo needed.
|
|
118
|
+
Each template's own README has more on running it once copied.
|
|
179
119
|
|
|
180
120
|
## Development
|
|
181
121
|
|
|
@@ -189,6 +129,24 @@ yarn watch # rebuild on change
|
|
|
189
129
|
browser via a global `Yuuna` object and embeds a live Monaco editor so
|
|
190
130
|
visitors can edit and run a game directly on the page.
|
|
191
131
|
|
|
132
|
+
## Assets
|
|
133
|
+
|
|
134
|
+
The examples' art/sound/music lives in `dist/resources/`, gitignored
|
|
135
|
+
rather than committed — this repo being open source doesn't make every
|
|
136
|
+
asset in it free to redistribute. `runEngine()` falls back to a
|
|
137
|
+
generated placeholder for any image that isn't there (and simply plays
|
|
138
|
+
nothing for missing audio) instead of failing, so the examples still
|
|
139
|
+
run without them — just with placeholder art in place of the real
|
|
140
|
+
thing. Drop the real files in locally (or restore them from wherever
|
|
141
|
+
you got this repo from) to see them for real.
|
|
142
|
+
|
|
143
|
+
Currently used:
|
|
144
|
+
|
|
145
|
+
- **[Free Pixel Food!](https://henrysoftware.itch.io/pixel-food)** by
|
|
146
|
+
[Henry Software](https://henrysoftware.itch.io/) — the food icons in
|
|
147
|
+
the Food Clicker and Sprites examples. CC0; credited here by choice,
|
|
148
|
+
not requirement.
|
|
149
|
+
|
|
192
150
|
## License
|
|
193
151
|
|
|
194
152
|
MIT © [lucy-dot-exe](https://github.com/lucy-dot-exe)
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -57,6 +57,10 @@ export type SpriteRenderable = BaseRenderable & {
|
|
|
57
57
|
frame: number;
|
|
58
58
|
opacity?: number;
|
|
59
59
|
flipX?: boolean;
|
|
60
|
+
swapColors?: {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
}[];
|
|
60
64
|
};
|
|
61
65
|
export type LineRenderable = BaseRenderable & {
|
|
62
66
|
type: "LINE";
|
|
@@ -83,6 +87,10 @@ export type AnimatedSpriteRenderable = BaseRenderable & {
|
|
|
83
87
|
paused?: boolean;
|
|
84
88
|
opacity?: number;
|
|
85
89
|
flipX?: boolean;
|
|
90
|
+
swapColors?: {
|
|
91
|
+
from: string;
|
|
92
|
+
to: string;
|
|
93
|
+
}[];
|
|
86
94
|
id: string;
|
|
87
95
|
};
|
|
88
96
|
export type GroupRenderable = BaseRenderable & {
|
|
@@ -145,10 +153,35 @@ export type MouseMoveEvent = {
|
|
|
145
153
|
y: number;
|
|
146
154
|
};
|
|
147
155
|
};
|
|
148
|
-
export type
|
|
149
|
-
|
|
156
|
+
export type MouseLeaveEvent = {
|
|
157
|
+
tag: "MOUSE_LEAVE";
|
|
158
|
+
mouse: {
|
|
159
|
+
x: number;
|
|
160
|
+
y: number;
|
|
161
|
+
};
|
|
162
|
+
worldMouse: {
|
|
163
|
+
x: number;
|
|
164
|
+
y: number;
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
export type TabBlurEvent = {
|
|
168
|
+
tag: "TAB_BLUR";
|
|
169
|
+
};
|
|
170
|
+
export type TabFocusEvent = {
|
|
171
|
+
tag: "TAB_FOCUS";
|
|
172
|
+
};
|
|
173
|
+
export type MusicEndEvent = {
|
|
174
|
+
tag: "MUSIC_END";
|
|
175
|
+
id: string;
|
|
176
|
+
};
|
|
177
|
+
export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent | MouseLeaveEvent | TabBlurEvent | TabFocusEvent | MusicEndEvent;
|
|
178
|
+
export type CustomGameEvent<Custom> = {
|
|
179
|
+
tag: "CUSTOM";
|
|
180
|
+
event: Custom;
|
|
181
|
+
};
|
|
182
|
+
export type NextStateProps<State, Custom = never> = {
|
|
150
183
|
state: State;
|
|
151
|
-
event: GameEvent
|
|
184
|
+
event: GameEvent | CustomGameEvent<Custom>;
|
|
152
185
|
keyboard: Record<KeyboardKeys, {
|
|
153
186
|
isPressed: boolean;
|
|
154
187
|
isJustPressed: boolean;
|
|
@@ -157,16 +190,18 @@ export type NextStateProps<State> = {
|
|
|
157
190
|
playSound: (id: string) => void;
|
|
158
191
|
playMusic: (id: string) => void;
|
|
159
192
|
pauseMusic: () => void;
|
|
193
|
+
resumeMusic: () => void;
|
|
194
|
+
setMusicVolume: (volume: number) => void;
|
|
160
195
|
};
|
|
161
196
|
export declare const STOP: "Yuuna.STOP";
|
|
162
|
-
export type NextStateFunction<State> = (props: NextStateProps<State>) => State | typeof STOP | undefined;
|
|
163
|
-
export type RunEngineProps<State> = {
|
|
197
|
+
export type NextStateFunction<State, Custom = never> = (props: NextStateProps<State, Custom>) => State | typeof STOP | undefined;
|
|
198
|
+
export type RunEngineProps<State, Custom = never> = {
|
|
164
199
|
initialState: State;
|
|
165
200
|
render: (state: State) => {
|
|
166
|
-
cursor?: "default" | "pointer";
|
|
201
|
+
cursor?: "default" | "pointer" | "none";
|
|
167
202
|
renderables: Renderable[];
|
|
168
203
|
};
|
|
169
|
-
nextState: NextStateFunction<State> | NextStateFunction<State>[];
|
|
204
|
+
nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
|
|
170
205
|
resources?: Record<string, {
|
|
171
206
|
src: string;
|
|
172
207
|
size: {
|
|
@@ -188,6 +223,7 @@ export type RunEngineProps<State> = {
|
|
|
188
223
|
}>;
|
|
189
224
|
music?: Record<string, {
|
|
190
225
|
src: string;
|
|
226
|
+
loop?: boolean;
|
|
191
227
|
}>;
|
|
192
228
|
canvas?: {
|
|
193
229
|
width?: number;
|
|
@@ -200,7 +236,9 @@ export type RunEngineProps<State> = {
|
|
|
200
236
|
zoom: number;
|
|
201
237
|
};
|
|
202
238
|
};
|
|
203
|
-
export type RunEngineFunction = <State>(props: RunEngineProps<State>) => Promise<
|
|
239
|
+
export type RunEngineFunction = <State, Custom = never>(props: RunEngineProps<State, Custom>) => Promise<{
|
|
240
|
+
sendEvent: (event: Custom) => void;
|
|
241
|
+
}>;
|
|
204
242
|
export declare const keyboardKeys: readonly ["ControlLeft", "ControlRight", "AltLeft", "AltRight", "CapsLock", "End", "Delete", "Tab", "Space", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ"];
|
|
205
243
|
export type KeyboardKeys = (typeof keyboardKeys)[number];
|
|
206
244
|
export type KeyboardState = Record<KeyboardKeys, boolean>;
|
package/lib/index.cjs
CHANGED
|
@@ -120,6 +120,35 @@ const sortByLayer = (renderables) => [...renderables].sort((a, b) => (a.layer ??
|
|
|
120
120
|
const anchorOf = (renderable) => renderable.type === "LINE" ? renderable.from : renderable.position;
|
|
121
121
|
// No RunEngineProps.camera set is the same as one that doesn't pan or zoom.
|
|
122
122
|
const DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
|
|
123
|
+
// Stands in for a resources[id].src image that fails to load — most
|
|
124
|
+
// often because it's a real, not-necessarily-open-source asset that
|
|
125
|
+
// (correctly) isn't checked into a public repo, rather than a bug. Drawn
|
|
126
|
+
// at the sheet's *declared* size (resources[id].size, not anything read
|
|
127
|
+
// off the failed image), so every existing frame/slice/animation still
|
|
128
|
+
// lines up exactly as if the real sheet had loaded — nothing about the
|
|
129
|
+
// example's own code has to know or care that this happened.
|
|
130
|
+
const createPlaceholderSheet = (size) => {
|
|
131
|
+
const canvas = window.document.createElement("canvas");
|
|
132
|
+
canvas.width = Math.max(1, size.width);
|
|
133
|
+
canvas.height = Math.max(1, size.height);
|
|
134
|
+
const context = canvas.getContext("2d");
|
|
135
|
+
if (context === null) {
|
|
136
|
+
return canvas;
|
|
137
|
+
}
|
|
138
|
+
// The old "missing texture" magenta/black checkerboard — deliberately
|
|
139
|
+
// eye-catching (rather than, say, a plain gray box) so a placeholder
|
|
140
|
+
// reads as "an asset is missing" at a glance instead of quietly
|
|
141
|
+
// passing for a real, if plain, sprite.
|
|
142
|
+
const cellSize = Math.max(4, Math.min(16, Math.round(Math.min(canvas.width, canvas.height) / 4)));
|
|
143
|
+
for (let y = 0; y < canvas.height; y += cellSize) {
|
|
144
|
+
for (let x = 0; x < canvas.width; x += cellSize) {
|
|
145
|
+
const isEvenCell = (x / cellSize + y / cellSize) % 2 === 0;
|
|
146
|
+
context.fillStyle = isEvenCell ? "#ff00ff" : "#000000";
|
|
147
|
+
context.fillRect(x, y, cellSize, cellSize);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return canvas;
|
|
151
|
+
};
|
|
123
152
|
const runEngine = async (props) => {
|
|
124
153
|
const runId = ++latestRunId;
|
|
125
154
|
resetCanvas?.();
|
|
@@ -147,13 +176,20 @@ const runEngine = async (props) => {
|
|
|
147
176
|
// leaking to the rest of the page (e.g. arrow keys scrolling the window).
|
|
148
177
|
canvas.tabIndex = 0;
|
|
149
178
|
let state = props.initialState;
|
|
179
|
+
const events = [];
|
|
180
|
+
// Lets a caller report something that happened outside the render loop
|
|
181
|
+
// (e.g. a fetch().then() callback) back into it — the event is queued
|
|
182
|
+
// here and delivered to nextState as a CustomGameEvent on the next tick,
|
|
183
|
+
// the same as any built-in event.
|
|
184
|
+
const sendEvent = (event) => {
|
|
185
|
+
events.push({ tag: "CUSTOM", event });
|
|
186
|
+
};
|
|
150
187
|
const resources = props.resources ?? {};
|
|
151
188
|
const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
|
|
152
189
|
const image = new Image();
|
|
153
|
-
|
|
154
|
-
image.onload = function () {
|
|
190
|
+
const settle = (loadedImage) => {
|
|
155
191
|
resolve({
|
|
156
|
-
image,
|
|
192
|
+
image: loadedImage,
|
|
157
193
|
size: {
|
|
158
194
|
width: value.size.width / value.slices.horizontal,
|
|
159
195
|
height: value.size.height / value.slices.vertical,
|
|
@@ -162,12 +198,25 @@ const runEngine = async (props) => {
|
|
|
162
198
|
animations: value.animations ?? {},
|
|
163
199
|
});
|
|
164
200
|
};
|
|
201
|
+
image.src = value.src;
|
|
202
|
+
image.onload = () => settle(image);
|
|
203
|
+
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
204
|
+
// note) — a placeholder sheet, sized to match what this resource
|
|
205
|
+
// declared, keeps every frame/slice/animation index the example
|
|
206
|
+
// already computes valid instead of drawing nothing or throwing.
|
|
207
|
+
image.onerror = () => settle(createPlaceholderSheet(value.size));
|
|
165
208
|
}));
|
|
166
209
|
const loadAudio = (src) => new Promise((resolve) => {
|
|
167
210
|
const audio = new Audio(src);
|
|
168
211
|
audio.oncanplaythrough = function () {
|
|
169
212
|
resolve(audio);
|
|
170
213
|
};
|
|
214
|
+
// Missing/failed-to-load audio — resolve anyway instead of hanging
|
|
215
|
+
// this Promise (and every resource after it, via Promise.all)
|
|
216
|
+
// forever waiting for a "canplaythrough" that's never coming.
|
|
217
|
+
// playSound/playMusic below already no-op safely on an element
|
|
218
|
+
// that can't actually play.
|
|
219
|
+
audio.onerror = () => resolve(audio);
|
|
171
220
|
});
|
|
172
221
|
const sounds = props.sounds ?? {};
|
|
173
222
|
const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
|
|
@@ -180,15 +229,31 @@ const runEngine = async (props) => {
|
|
|
180
229
|
return;
|
|
181
230
|
}
|
|
182
231
|
const instance = audio.cloneNode();
|
|
183
|
-
|
|
232
|
+
// A missing/failed-to-load sound (see loadAudio's onerror above)
|
|
233
|
+
// rejects here instead of playing — caught and dropped rather than
|
|
234
|
+
// left as an unhandled rejection, same as playMusic/resumeMusic below.
|
|
235
|
+
instance.play().catch(() => { });
|
|
184
236
|
};
|
|
185
237
|
const music = props.music ?? {};
|
|
186
238
|
const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
|
|
239
|
+
// Registered once per track at load time — fires only for a track
|
|
240
|
+
// whose `loop` is false, since a looping <audio> never reaches "ended"
|
|
241
|
+
// (the browser restarts it before the event would fire).
|
|
242
|
+
for (const id of getKeys(musicById)) {
|
|
243
|
+
musicById[id].addEventListener("ended", () => {
|
|
244
|
+
events.push({ tag: "MUSIC_END", id });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
187
247
|
// Unlike sounds, music reuses the same element instead of cloning it —
|
|
188
248
|
// there's only ever one track playing, and reusing it is what lets
|
|
189
249
|
// pauseMusic()/playMusic() resume from where playback left off instead
|
|
190
250
|
// of starting over.
|
|
191
251
|
let currentMusic = null;
|
|
252
|
+
// Volume is a property of each HTMLAudioElement, not global — tracked
|
|
253
|
+
// separately here and (re)applied on every playMusic() so switching
|
|
254
|
+
// tracks keeps the volume the game last set instead of resetting to
|
|
255
|
+
// each element's default of 1.
|
|
256
|
+
let musicVolume = 1;
|
|
192
257
|
const playMusic = (id) => {
|
|
193
258
|
const audio = musicById[id];
|
|
194
259
|
if (audio === undefined) {
|
|
@@ -197,18 +262,28 @@ const runEngine = async (props) => {
|
|
|
197
262
|
if (currentMusic !== null && currentMusic !== audio) {
|
|
198
263
|
currentMusic.pause();
|
|
199
264
|
}
|
|
200
|
-
audio.loop = true;
|
|
201
|
-
audio.
|
|
265
|
+
audio.loop = music[id]?.loop ?? true;
|
|
266
|
+
audio.volume = musicVolume;
|
|
267
|
+
audio.play().catch(() => { });
|
|
202
268
|
currentMusic = audio;
|
|
203
269
|
};
|
|
204
270
|
const pauseMusic = () => {
|
|
205
271
|
currentMusic?.pause();
|
|
206
272
|
};
|
|
273
|
+
const resumeMusic = () => {
|
|
274
|
+
currentMusic?.play().catch(() => { });
|
|
275
|
+
};
|
|
276
|
+
const setMusicVolume = (volume) => {
|
|
277
|
+
musicVolume = Math.min(1, Math.max(0, volume));
|
|
278
|
+
if (currentMusic !== null) {
|
|
279
|
+
currentMusic.volume = musicVolume;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
207
282
|
// A newer runEngine() call started while this one was still loading
|
|
208
283
|
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
209
284
|
// up a second, orphaned render loop alongside the newer one.
|
|
210
285
|
if (runId !== latestRunId) {
|
|
211
|
-
return;
|
|
286
|
+
return { sendEvent };
|
|
212
287
|
}
|
|
213
288
|
context.imageSmoothingEnabled = false;
|
|
214
289
|
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
@@ -498,8 +573,7 @@ const runEngine = async (props) => {
|
|
|
498
573
|
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
499
574
|
let lastFrame = Date.now();
|
|
500
575
|
let hoveredId = null;
|
|
501
|
-
const
|
|
502
|
-
canvas.addEventListener("click", (ev) => {
|
|
576
|
+
const handleClick = (ev) => {
|
|
503
577
|
const mouse = getCanvasPosition(ev);
|
|
504
578
|
if (hoveredId === null)
|
|
505
579
|
return;
|
|
@@ -508,7 +582,8 @@ const runEngine = async (props) => {
|
|
|
508
582
|
if (hovered !== undefined && hovered.isClickable) {
|
|
509
583
|
events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
|
|
510
584
|
}
|
|
511
|
-
}
|
|
585
|
+
};
|
|
586
|
+
canvas.addEventListener("click", handleClick);
|
|
512
587
|
const initialState = {
|
|
513
588
|
keyboardState: createRecord(keyboardKeys, () => false),
|
|
514
589
|
};
|
|
@@ -518,7 +593,7 @@ const runEngine = async (props) => {
|
|
|
518
593
|
const currentState = {
|
|
519
594
|
keyboardState: { ...initialState.keyboardState },
|
|
520
595
|
};
|
|
521
|
-
|
|
596
|
+
const handleKeyDown = (event) => {
|
|
522
597
|
const pressedKey = keyboardKeys.find((key) => key === event.code);
|
|
523
598
|
if (pressedKey !== undefined) {
|
|
524
599
|
// Stop tracked keys (arrows, space, ...) from also scrolling the
|
|
@@ -527,15 +602,17 @@ const runEngine = async (props) => {
|
|
|
527
602
|
event.preventDefault();
|
|
528
603
|
currentState.keyboardState[pressedKey] = true;
|
|
529
604
|
}
|
|
530
|
-
}
|
|
531
|
-
canvas.addEventListener("
|
|
605
|
+
};
|
|
606
|
+
canvas.addEventListener("keydown", handleKeyDown);
|
|
607
|
+
const handleKeyUp = (event) => {
|
|
532
608
|
const releasedKey = keyboardKeys.find((key) => key === event.code);
|
|
533
609
|
if (releasedKey !== undefined) {
|
|
534
610
|
event.preventDefault();
|
|
535
611
|
currentState.keyboardState[releasedKey] = false;
|
|
536
612
|
}
|
|
537
|
-
}
|
|
538
|
-
canvas.addEventListener("
|
|
613
|
+
};
|
|
614
|
+
canvas.addEventListener("keyup", handleKeyUp);
|
|
615
|
+
const handleMouseMoveHover = (ev) => {
|
|
539
616
|
const mouse = getCanvasPosition(ev);
|
|
540
617
|
const { renderables, camera } = renderState(state);
|
|
541
618
|
const worldMouse = toWorldPosition(mouse, camera);
|
|
@@ -552,8 +629,9 @@ const runEngine = async (props) => {
|
|
|
552
629
|
}
|
|
553
630
|
}
|
|
554
631
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
555
|
-
}
|
|
556
|
-
canvas.addEventListener("mousemove",
|
|
632
|
+
};
|
|
633
|
+
canvas.addEventListener("mousemove", handleMouseMoveHover);
|
|
634
|
+
const handleMouseMoveTracking = (ev) => {
|
|
557
635
|
const mouse = getCanvasPosition(ev);
|
|
558
636
|
if (hoveredId === null) {
|
|
559
637
|
return;
|
|
@@ -563,7 +641,33 @@ const runEngine = async (props) => {
|
|
|
563
641
|
if (hovered !== undefined && hovered.trackMouseMovement) {
|
|
564
642
|
events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
|
|
565
643
|
}
|
|
566
|
-
}
|
|
644
|
+
};
|
|
645
|
+
canvas.addEventListener("mousemove", handleMouseMoveTracking);
|
|
646
|
+
// No further mousemove fires once the mouse is off the canvas, so this
|
|
647
|
+
// is also the only chance to report a HOVER_OUT for whatever was
|
|
648
|
+
// hovered when it left — otherwise that hover would just dangle,
|
|
649
|
+
// never explicitly ended.
|
|
650
|
+
const handleMouseLeave = (ev) => {
|
|
651
|
+
const mouse = getCanvasPosition(ev);
|
|
652
|
+
const { renderables, camera } = renderState(state);
|
|
653
|
+
const worldMouse = toWorldPosition(mouse, camera);
|
|
654
|
+
if (hoveredId !== null) {
|
|
655
|
+
const lastHovered = renderables.find((r) => r.id === hoveredId);
|
|
656
|
+
if (lastHovered !== undefined) {
|
|
657
|
+
events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
hoveredId = null;
|
|
661
|
+
events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
|
|
662
|
+
};
|
|
663
|
+
canvas.addEventListener("mouseleave", handleMouseLeave);
|
|
664
|
+
// Tab switches are a document-level concern (visibilitychange), not
|
|
665
|
+
// something that ever reaches the canvas itself the way mouse/keyboard
|
|
666
|
+
// events do.
|
|
667
|
+
const handleVisibilityChange = () => {
|
|
668
|
+
events.push({ tag: window.document.hidden ? "TAB_BLUR" : "TAB_FOCUS" });
|
|
669
|
+
};
|
|
670
|
+
window.document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
567
671
|
context.imageSmoothingEnabled = false;
|
|
568
672
|
// Scale is a canvas transform around the renderable's anchor, applied
|
|
569
673
|
// before its type-specific drawing runs below — everything drawn under
|
|
@@ -614,6 +718,56 @@ const runEngine = async (props) => {
|
|
|
614
718
|
tintBufferContext.globalCompositeOperation = "source-over";
|
|
615
719
|
return tintBuffer;
|
|
616
720
|
};
|
|
721
|
+
// Palette-swapped frames, keyed by resourceId + frame + the exact swap
|
|
722
|
+
// list — unlike tintedSpriteFrame's cheap multiply-blend (redone fresh
|
|
723
|
+
// every draw off one shared buffer), a swap needs real pixel work
|
|
724
|
+
// (getImageData over the whole frame), so each unique combination is
|
|
725
|
+
// computed once here and reused on every later draw instead. Grows for
|
|
726
|
+
// as long as new combinations keep showing up — fine for a fixed small
|
|
727
|
+
// set of recolors (e.g. team A/B/C), a bad fit for one that varies
|
|
728
|
+
// continuously (e.g. a randomized hue per instance), which would cache-
|
|
729
|
+
// miss every time and just accumulate.
|
|
730
|
+
const swappedFrameCache = new Map();
|
|
731
|
+
const swappedSpriteFrame = (resourceId, frame, image, source, swapColors) => {
|
|
732
|
+
const cacheKey = `${resourceId}:${frame}:${swapColors.map(({ from, to }) => `${from}>${to}`).join(",")}`;
|
|
733
|
+
const cached = swappedFrameCache.get(cacheKey);
|
|
734
|
+
if (cached !== undefined) {
|
|
735
|
+
return cached;
|
|
736
|
+
}
|
|
737
|
+
const canvas = window.document.createElement("canvas");
|
|
738
|
+
canvas.width = source.width;
|
|
739
|
+
canvas.height = source.height;
|
|
740
|
+
const swapContext = canvas.getContext("2d");
|
|
741
|
+
// No 2d context to work with (shouldn't happen in a real browser) —
|
|
742
|
+
// draw the untouched frame rather than crash.
|
|
743
|
+
if (swapContext === null) {
|
|
744
|
+
return image;
|
|
745
|
+
}
|
|
746
|
+
swapContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
|
|
747
|
+
// Resolved once per unique `from`/`to` pair (resolveColor caches by
|
|
748
|
+
// string), not per pixel — the pixel loop below only ever compares
|
|
749
|
+
// against these already-resolved bytes.
|
|
750
|
+
const resolvedSwaps = swapColors.map(({ from, to }) => ({
|
|
751
|
+
from: resolveColor(from),
|
|
752
|
+
to: resolveColor(to),
|
|
753
|
+
}));
|
|
754
|
+
const imageData = swapContext.getImageData(0, 0, canvas.width, canvas.height);
|
|
755
|
+
const pixels = imageData.data;
|
|
756
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
757
|
+
for (const { from, to } of resolvedSwaps) {
|
|
758
|
+
if (pixels[i] === from[0] && pixels[i + 1] === from[1] && pixels[i + 2] === from[2] && pixels[i + 3] === from[3]) {
|
|
759
|
+
pixels[i] = to[0];
|
|
760
|
+
pixels[i + 1] = to[1];
|
|
761
|
+
pixels[i + 2] = to[2];
|
|
762
|
+
pixels[i + 3] = to[3];
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
swapContext.putImageData(imageData, 0, 0);
|
|
768
|
+
swappedFrameCache.set(cacheKey, canvas);
|
|
769
|
+
return canvas;
|
|
770
|
+
};
|
|
617
771
|
const intervalId = setInterval(() => {
|
|
618
772
|
const now = Date.now();
|
|
619
773
|
const delta = now - lastFrame;
|
|
@@ -628,7 +782,16 @@ const runEngine = async (props) => {
|
|
|
628
782
|
};
|
|
629
783
|
});
|
|
630
784
|
for (const nextState of nextStateFns) {
|
|
631
|
-
const result = nextState({
|
|
785
|
+
const result = nextState({
|
|
786
|
+
state,
|
|
787
|
+
event,
|
|
788
|
+
keyboard,
|
|
789
|
+
playSound,
|
|
790
|
+
playMusic,
|
|
791
|
+
pauseMusic,
|
|
792
|
+
resumeMusic,
|
|
793
|
+
setMusicVolume,
|
|
794
|
+
});
|
|
632
795
|
// STOP stops the rest of the list from running for this event,
|
|
633
796
|
// instead of every later mechanic needing to repeat the same
|
|
634
797
|
// guard. undefined just means this mechanic made no change, so
|
|
@@ -675,7 +838,7 @@ const runEngine = async (props) => {
|
|
|
675
838
|
continue;
|
|
676
839
|
}
|
|
677
840
|
if (renderable.type === "SPRITE") {
|
|
678
|
-
const { opacity = 1, flipX = false, modulate } = renderable;
|
|
841
|
+
const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
|
|
679
842
|
const resource = resourceById[renderable.resourceId];
|
|
680
843
|
const frame = {
|
|
681
844
|
x: renderable.frame % resource.slices.horizontal,
|
|
@@ -690,12 +853,23 @@ const runEngine = async (props) => {
|
|
|
690
853
|
};
|
|
691
854
|
const destWidth = resource.size.width;
|
|
692
855
|
const destHeight = resource.size.height;
|
|
693
|
-
//
|
|
694
|
-
// as the image to draw
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
856
|
+
// Swapping and tinting each swap in an already-processed offscreen
|
|
857
|
+
// copy of this frame as the image to draw from then on —
|
|
858
|
+
// everything past this point (flipX, positioning) treats it
|
|
859
|
+
// exactly like the untinted spritesheet, just drawn starting at
|
|
860
|
+
// (0, 0) instead of cropped from a sheet. Swap runs first (it's
|
|
861
|
+
// the sprite's "real" recolored identity), tint runs on top of
|
|
862
|
+
// that (e.g. a damage flash still applies over swapped colors).
|
|
863
|
+
let image = resource.image;
|
|
864
|
+
let imageSource = source;
|
|
865
|
+
if (swapColors !== undefined && swapColors.length > 0) {
|
|
866
|
+
image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
|
|
867
|
+
imageSource = { x: 0, y: 0, width: source.width, height: source.height };
|
|
868
|
+
}
|
|
869
|
+
if (modulate !== undefined) {
|
|
870
|
+
image = tintedSpriteFrame(image, imageSource, modulate);
|
|
871
|
+
imageSource = { x: 0, y: 0, width: source.width, height: source.height };
|
|
872
|
+
}
|
|
699
873
|
context.globalAlpha = opacity;
|
|
700
874
|
const drawSprite = (destX, destY) => {
|
|
701
875
|
context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
|
|
@@ -746,7 +920,34 @@ const runEngine = async (props) => {
|
|
|
746
920
|
}, 0);
|
|
747
921
|
resetCanvas = () => {
|
|
748
922
|
clearInterval(intervalId);
|
|
923
|
+
// Otherwise a track started by this run keeps playing underneath
|
|
924
|
+
// whatever the next runEngine() call starts — currentMusic is a
|
|
925
|
+
// per-run element, not something the next run has any way to reach.
|
|
926
|
+
currentMusic?.pause();
|
|
927
|
+
currentMusic = null;
|
|
928
|
+
// The canvas element itself is only thrown away between runs if the
|
|
929
|
+
// caller replaces it — in the playground it's the same persistent
|
|
930
|
+
// <canvas id="yuuna"> across every example switch and every
|
|
931
|
+
// Auto-Reload keystroke, so its listeners have to be removed
|
|
932
|
+
// explicitly here too, or each run stacks its own click/mousemove/
|
|
933
|
+
// keyboard handlers on top of every previous run's. Those old
|
|
934
|
+
// handlers still fire (each still does its own hit-testing and
|
|
935
|
+
// renderState() call against its own now-frozen state) even though
|
|
936
|
+
// their interval is long since cleared, quietly costing more CPU per
|
|
937
|
+
// click/mousemove the more times a run's been replaced — and on a
|
|
938
|
+
// slow enough device or long enough playground session, that pile-up
|
|
939
|
+
// is what "clicks stop working" actually looks like.
|
|
940
|
+
canvas.removeEventListener("click", handleClick);
|
|
941
|
+
canvas.removeEventListener("keydown", handleKeyDown);
|
|
942
|
+
canvas.removeEventListener("keyup", handleKeyUp);
|
|
943
|
+
canvas.removeEventListener("mousemove", handleMouseMoveHover);
|
|
944
|
+
canvas.removeEventListener("mousemove", handleMouseMoveTracking);
|
|
945
|
+
canvas.removeEventListener("mouseleave", handleMouseLeave);
|
|
946
|
+
// This one's on `document`, not the canvas — same reasoning as above,
|
|
947
|
+
// just doubly true since `document` isn't even scoped to this canvas.
|
|
948
|
+
window.document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
749
949
|
};
|
|
950
|
+
return { sendEvent };
|
|
750
951
|
};
|
|
751
952
|
|
|
752
953
|
exports.STOP = STOP;
|
package/lib/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { runEngine } from "./engine/runEngine";
|
|
2
2
|
import { STOP } from "./engine/types";
|
|
3
3
|
export { runEngine, STOP };
|
|
4
|
-
export type { NextStateFunction, NextStateProps, Renderable, GameEvent } from "./engine/types";
|
|
4
|
+
export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";
|
package/lib/index.js
CHANGED
|
@@ -118,6 +118,35 @@ const sortByLayer = (renderables) => [...renderables].sort((a, b) => (a.layer ??
|
|
|
118
118
|
const anchorOf = (renderable) => renderable.type === "LINE" ? renderable.from : renderable.position;
|
|
119
119
|
// No RunEngineProps.camera set is the same as one that doesn't pan or zoom.
|
|
120
120
|
const DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
|
|
121
|
+
// Stands in for a resources[id].src image that fails to load — most
|
|
122
|
+
// often because it's a real, not-necessarily-open-source asset that
|
|
123
|
+
// (correctly) isn't checked into a public repo, rather than a bug. Drawn
|
|
124
|
+
// at the sheet's *declared* size (resources[id].size, not anything read
|
|
125
|
+
// off the failed image), so every existing frame/slice/animation still
|
|
126
|
+
// lines up exactly as if the real sheet had loaded — nothing about the
|
|
127
|
+
// example's own code has to know or care that this happened.
|
|
128
|
+
const createPlaceholderSheet = (size) => {
|
|
129
|
+
const canvas = window.document.createElement("canvas");
|
|
130
|
+
canvas.width = Math.max(1, size.width);
|
|
131
|
+
canvas.height = Math.max(1, size.height);
|
|
132
|
+
const context = canvas.getContext("2d");
|
|
133
|
+
if (context === null) {
|
|
134
|
+
return canvas;
|
|
135
|
+
}
|
|
136
|
+
// The old "missing texture" magenta/black checkerboard — deliberately
|
|
137
|
+
// eye-catching (rather than, say, a plain gray box) so a placeholder
|
|
138
|
+
// reads as "an asset is missing" at a glance instead of quietly
|
|
139
|
+
// passing for a real, if plain, sprite.
|
|
140
|
+
const cellSize = Math.max(4, Math.min(16, Math.round(Math.min(canvas.width, canvas.height) / 4)));
|
|
141
|
+
for (let y = 0; y < canvas.height; y += cellSize) {
|
|
142
|
+
for (let x = 0; x < canvas.width; x += cellSize) {
|
|
143
|
+
const isEvenCell = (x / cellSize + y / cellSize) % 2 === 0;
|
|
144
|
+
context.fillStyle = isEvenCell ? "#ff00ff" : "#000000";
|
|
145
|
+
context.fillRect(x, y, cellSize, cellSize);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return canvas;
|
|
149
|
+
};
|
|
121
150
|
const runEngine = async (props) => {
|
|
122
151
|
const runId = ++latestRunId;
|
|
123
152
|
resetCanvas?.();
|
|
@@ -145,13 +174,20 @@ const runEngine = async (props) => {
|
|
|
145
174
|
// leaking to the rest of the page (e.g. arrow keys scrolling the window).
|
|
146
175
|
canvas.tabIndex = 0;
|
|
147
176
|
let state = props.initialState;
|
|
177
|
+
const events = [];
|
|
178
|
+
// Lets a caller report something that happened outside the render loop
|
|
179
|
+
// (e.g. a fetch().then() callback) back into it — the event is queued
|
|
180
|
+
// here and delivered to nextState as a CustomGameEvent on the next tick,
|
|
181
|
+
// the same as any built-in event.
|
|
182
|
+
const sendEvent = (event) => {
|
|
183
|
+
events.push({ tag: "CUSTOM", event });
|
|
184
|
+
};
|
|
148
185
|
const resources = props.resources ?? {};
|
|
149
186
|
const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
|
|
150
187
|
const image = new Image();
|
|
151
|
-
|
|
152
|
-
image.onload = function () {
|
|
188
|
+
const settle = (loadedImage) => {
|
|
153
189
|
resolve({
|
|
154
|
-
image,
|
|
190
|
+
image: loadedImage,
|
|
155
191
|
size: {
|
|
156
192
|
width: value.size.width / value.slices.horizontal,
|
|
157
193
|
height: value.size.height / value.slices.vertical,
|
|
@@ -160,12 +196,25 @@ const runEngine = async (props) => {
|
|
|
160
196
|
animations: value.animations ?? {},
|
|
161
197
|
});
|
|
162
198
|
};
|
|
199
|
+
image.src = value.src;
|
|
200
|
+
image.onload = () => settle(image);
|
|
201
|
+
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
202
|
+
// note) — a placeholder sheet, sized to match what this resource
|
|
203
|
+
// declared, keeps every frame/slice/animation index the example
|
|
204
|
+
// already computes valid instead of drawing nothing or throwing.
|
|
205
|
+
image.onerror = () => settle(createPlaceholderSheet(value.size));
|
|
163
206
|
}));
|
|
164
207
|
const loadAudio = (src) => new Promise((resolve) => {
|
|
165
208
|
const audio = new Audio(src);
|
|
166
209
|
audio.oncanplaythrough = function () {
|
|
167
210
|
resolve(audio);
|
|
168
211
|
};
|
|
212
|
+
// Missing/failed-to-load audio — resolve anyway instead of hanging
|
|
213
|
+
// this Promise (and every resource after it, via Promise.all)
|
|
214
|
+
// forever waiting for a "canplaythrough" that's never coming.
|
|
215
|
+
// playSound/playMusic below already no-op safely on an element
|
|
216
|
+
// that can't actually play.
|
|
217
|
+
audio.onerror = () => resolve(audio);
|
|
169
218
|
});
|
|
170
219
|
const sounds = props.sounds ?? {};
|
|
171
220
|
const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
|
|
@@ -178,15 +227,31 @@ const runEngine = async (props) => {
|
|
|
178
227
|
return;
|
|
179
228
|
}
|
|
180
229
|
const instance = audio.cloneNode();
|
|
181
|
-
|
|
230
|
+
// A missing/failed-to-load sound (see loadAudio's onerror above)
|
|
231
|
+
// rejects here instead of playing — caught and dropped rather than
|
|
232
|
+
// left as an unhandled rejection, same as playMusic/resumeMusic below.
|
|
233
|
+
instance.play().catch(() => { });
|
|
182
234
|
};
|
|
183
235
|
const music = props.music ?? {};
|
|
184
236
|
const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
|
|
237
|
+
// Registered once per track at load time — fires only for a track
|
|
238
|
+
// whose `loop` is false, since a looping <audio> never reaches "ended"
|
|
239
|
+
// (the browser restarts it before the event would fire).
|
|
240
|
+
for (const id of getKeys(musicById)) {
|
|
241
|
+
musicById[id].addEventListener("ended", () => {
|
|
242
|
+
events.push({ tag: "MUSIC_END", id });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
185
245
|
// Unlike sounds, music reuses the same element instead of cloning it —
|
|
186
246
|
// there's only ever one track playing, and reusing it is what lets
|
|
187
247
|
// pauseMusic()/playMusic() resume from where playback left off instead
|
|
188
248
|
// of starting over.
|
|
189
249
|
let currentMusic = null;
|
|
250
|
+
// Volume is a property of each HTMLAudioElement, not global — tracked
|
|
251
|
+
// separately here and (re)applied on every playMusic() so switching
|
|
252
|
+
// tracks keeps the volume the game last set instead of resetting to
|
|
253
|
+
// each element's default of 1.
|
|
254
|
+
let musicVolume = 1;
|
|
190
255
|
const playMusic = (id) => {
|
|
191
256
|
const audio = musicById[id];
|
|
192
257
|
if (audio === undefined) {
|
|
@@ -195,18 +260,28 @@ const runEngine = async (props) => {
|
|
|
195
260
|
if (currentMusic !== null && currentMusic !== audio) {
|
|
196
261
|
currentMusic.pause();
|
|
197
262
|
}
|
|
198
|
-
audio.loop = true;
|
|
199
|
-
audio.
|
|
263
|
+
audio.loop = music[id]?.loop ?? true;
|
|
264
|
+
audio.volume = musicVolume;
|
|
265
|
+
audio.play().catch(() => { });
|
|
200
266
|
currentMusic = audio;
|
|
201
267
|
};
|
|
202
268
|
const pauseMusic = () => {
|
|
203
269
|
currentMusic?.pause();
|
|
204
270
|
};
|
|
271
|
+
const resumeMusic = () => {
|
|
272
|
+
currentMusic?.play().catch(() => { });
|
|
273
|
+
};
|
|
274
|
+
const setMusicVolume = (volume) => {
|
|
275
|
+
musicVolume = Math.min(1, Math.max(0, volume));
|
|
276
|
+
if (currentMusic !== null) {
|
|
277
|
+
currentMusic.volume = musicVolume;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
205
280
|
// A newer runEngine() call started while this one was still loading
|
|
206
281
|
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
207
282
|
// up a second, orphaned render loop alongside the newer one.
|
|
208
283
|
if (runId !== latestRunId) {
|
|
209
|
-
return;
|
|
284
|
+
return { sendEvent };
|
|
210
285
|
}
|
|
211
286
|
context.imageSmoothingEnabled = false;
|
|
212
287
|
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
@@ -496,8 +571,7 @@ const runEngine = async (props) => {
|
|
|
496
571
|
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
497
572
|
let lastFrame = Date.now();
|
|
498
573
|
let hoveredId = null;
|
|
499
|
-
const
|
|
500
|
-
canvas.addEventListener("click", (ev) => {
|
|
574
|
+
const handleClick = (ev) => {
|
|
501
575
|
const mouse = getCanvasPosition(ev);
|
|
502
576
|
if (hoveredId === null)
|
|
503
577
|
return;
|
|
@@ -506,7 +580,8 @@ const runEngine = async (props) => {
|
|
|
506
580
|
if (hovered !== undefined && hovered.isClickable) {
|
|
507
581
|
events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
|
|
508
582
|
}
|
|
509
|
-
}
|
|
583
|
+
};
|
|
584
|
+
canvas.addEventListener("click", handleClick);
|
|
510
585
|
const initialState = {
|
|
511
586
|
keyboardState: createRecord(keyboardKeys, () => false),
|
|
512
587
|
};
|
|
@@ -516,7 +591,7 @@ const runEngine = async (props) => {
|
|
|
516
591
|
const currentState = {
|
|
517
592
|
keyboardState: { ...initialState.keyboardState },
|
|
518
593
|
};
|
|
519
|
-
|
|
594
|
+
const handleKeyDown = (event) => {
|
|
520
595
|
const pressedKey = keyboardKeys.find((key) => key === event.code);
|
|
521
596
|
if (pressedKey !== undefined) {
|
|
522
597
|
// Stop tracked keys (arrows, space, ...) from also scrolling the
|
|
@@ -525,15 +600,17 @@ const runEngine = async (props) => {
|
|
|
525
600
|
event.preventDefault();
|
|
526
601
|
currentState.keyboardState[pressedKey] = true;
|
|
527
602
|
}
|
|
528
|
-
}
|
|
529
|
-
canvas.addEventListener("
|
|
603
|
+
};
|
|
604
|
+
canvas.addEventListener("keydown", handleKeyDown);
|
|
605
|
+
const handleKeyUp = (event) => {
|
|
530
606
|
const releasedKey = keyboardKeys.find((key) => key === event.code);
|
|
531
607
|
if (releasedKey !== undefined) {
|
|
532
608
|
event.preventDefault();
|
|
533
609
|
currentState.keyboardState[releasedKey] = false;
|
|
534
610
|
}
|
|
535
|
-
}
|
|
536
|
-
canvas.addEventListener("
|
|
611
|
+
};
|
|
612
|
+
canvas.addEventListener("keyup", handleKeyUp);
|
|
613
|
+
const handleMouseMoveHover = (ev) => {
|
|
537
614
|
const mouse = getCanvasPosition(ev);
|
|
538
615
|
const { renderables, camera } = renderState(state);
|
|
539
616
|
const worldMouse = toWorldPosition(mouse, camera);
|
|
@@ -550,8 +627,9 @@ const runEngine = async (props) => {
|
|
|
550
627
|
}
|
|
551
628
|
}
|
|
552
629
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
553
|
-
}
|
|
554
|
-
canvas.addEventListener("mousemove",
|
|
630
|
+
};
|
|
631
|
+
canvas.addEventListener("mousemove", handleMouseMoveHover);
|
|
632
|
+
const handleMouseMoveTracking = (ev) => {
|
|
555
633
|
const mouse = getCanvasPosition(ev);
|
|
556
634
|
if (hoveredId === null) {
|
|
557
635
|
return;
|
|
@@ -561,7 +639,33 @@ const runEngine = async (props) => {
|
|
|
561
639
|
if (hovered !== undefined && hovered.trackMouseMovement) {
|
|
562
640
|
events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
|
|
563
641
|
}
|
|
564
|
-
}
|
|
642
|
+
};
|
|
643
|
+
canvas.addEventListener("mousemove", handleMouseMoveTracking);
|
|
644
|
+
// No further mousemove fires once the mouse is off the canvas, so this
|
|
645
|
+
// is also the only chance to report a HOVER_OUT for whatever was
|
|
646
|
+
// hovered when it left — otherwise that hover would just dangle,
|
|
647
|
+
// never explicitly ended.
|
|
648
|
+
const handleMouseLeave = (ev) => {
|
|
649
|
+
const mouse = getCanvasPosition(ev);
|
|
650
|
+
const { renderables, camera } = renderState(state);
|
|
651
|
+
const worldMouse = toWorldPosition(mouse, camera);
|
|
652
|
+
if (hoveredId !== null) {
|
|
653
|
+
const lastHovered = renderables.find((r) => r.id === hoveredId);
|
|
654
|
+
if (lastHovered !== undefined) {
|
|
655
|
+
events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
hoveredId = null;
|
|
659
|
+
events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
|
|
660
|
+
};
|
|
661
|
+
canvas.addEventListener("mouseleave", handleMouseLeave);
|
|
662
|
+
// Tab switches are a document-level concern (visibilitychange), not
|
|
663
|
+
// something that ever reaches the canvas itself the way mouse/keyboard
|
|
664
|
+
// events do.
|
|
665
|
+
const handleVisibilityChange = () => {
|
|
666
|
+
events.push({ tag: window.document.hidden ? "TAB_BLUR" : "TAB_FOCUS" });
|
|
667
|
+
};
|
|
668
|
+
window.document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
565
669
|
context.imageSmoothingEnabled = false;
|
|
566
670
|
// Scale is a canvas transform around the renderable's anchor, applied
|
|
567
671
|
// before its type-specific drawing runs below — everything drawn under
|
|
@@ -612,6 +716,56 @@ const runEngine = async (props) => {
|
|
|
612
716
|
tintBufferContext.globalCompositeOperation = "source-over";
|
|
613
717
|
return tintBuffer;
|
|
614
718
|
};
|
|
719
|
+
// Palette-swapped frames, keyed by resourceId + frame + the exact swap
|
|
720
|
+
// list — unlike tintedSpriteFrame's cheap multiply-blend (redone fresh
|
|
721
|
+
// every draw off one shared buffer), a swap needs real pixel work
|
|
722
|
+
// (getImageData over the whole frame), so each unique combination is
|
|
723
|
+
// computed once here and reused on every later draw instead. Grows for
|
|
724
|
+
// as long as new combinations keep showing up — fine for a fixed small
|
|
725
|
+
// set of recolors (e.g. team A/B/C), a bad fit for one that varies
|
|
726
|
+
// continuously (e.g. a randomized hue per instance), which would cache-
|
|
727
|
+
// miss every time and just accumulate.
|
|
728
|
+
const swappedFrameCache = new Map();
|
|
729
|
+
const swappedSpriteFrame = (resourceId, frame, image, source, swapColors) => {
|
|
730
|
+
const cacheKey = `${resourceId}:${frame}:${swapColors.map(({ from, to }) => `${from}>${to}`).join(",")}`;
|
|
731
|
+
const cached = swappedFrameCache.get(cacheKey);
|
|
732
|
+
if (cached !== undefined) {
|
|
733
|
+
return cached;
|
|
734
|
+
}
|
|
735
|
+
const canvas = window.document.createElement("canvas");
|
|
736
|
+
canvas.width = source.width;
|
|
737
|
+
canvas.height = source.height;
|
|
738
|
+
const swapContext = canvas.getContext("2d");
|
|
739
|
+
// No 2d context to work with (shouldn't happen in a real browser) —
|
|
740
|
+
// draw the untouched frame rather than crash.
|
|
741
|
+
if (swapContext === null) {
|
|
742
|
+
return image;
|
|
743
|
+
}
|
|
744
|
+
swapContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
|
|
745
|
+
// Resolved once per unique `from`/`to` pair (resolveColor caches by
|
|
746
|
+
// string), not per pixel — the pixel loop below only ever compares
|
|
747
|
+
// against these already-resolved bytes.
|
|
748
|
+
const resolvedSwaps = swapColors.map(({ from, to }) => ({
|
|
749
|
+
from: resolveColor(from),
|
|
750
|
+
to: resolveColor(to),
|
|
751
|
+
}));
|
|
752
|
+
const imageData = swapContext.getImageData(0, 0, canvas.width, canvas.height);
|
|
753
|
+
const pixels = imageData.data;
|
|
754
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
755
|
+
for (const { from, to } of resolvedSwaps) {
|
|
756
|
+
if (pixels[i] === from[0] && pixels[i + 1] === from[1] && pixels[i + 2] === from[2] && pixels[i + 3] === from[3]) {
|
|
757
|
+
pixels[i] = to[0];
|
|
758
|
+
pixels[i + 1] = to[1];
|
|
759
|
+
pixels[i + 2] = to[2];
|
|
760
|
+
pixels[i + 3] = to[3];
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
swapContext.putImageData(imageData, 0, 0);
|
|
766
|
+
swappedFrameCache.set(cacheKey, canvas);
|
|
767
|
+
return canvas;
|
|
768
|
+
};
|
|
615
769
|
const intervalId = setInterval(() => {
|
|
616
770
|
const now = Date.now();
|
|
617
771
|
const delta = now - lastFrame;
|
|
@@ -626,7 +780,16 @@ const runEngine = async (props) => {
|
|
|
626
780
|
};
|
|
627
781
|
});
|
|
628
782
|
for (const nextState of nextStateFns) {
|
|
629
|
-
const result = nextState({
|
|
783
|
+
const result = nextState({
|
|
784
|
+
state,
|
|
785
|
+
event,
|
|
786
|
+
keyboard,
|
|
787
|
+
playSound,
|
|
788
|
+
playMusic,
|
|
789
|
+
pauseMusic,
|
|
790
|
+
resumeMusic,
|
|
791
|
+
setMusicVolume,
|
|
792
|
+
});
|
|
630
793
|
// STOP stops the rest of the list from running for this event,
|
|
631
794
|
// instead of every later mechanic needing to repeat the same
|
|
632
795
|
// guard. undefined just means this mechanic made no change, so
|
|
@@ -673,7 +836,7 @@ const runEngine = async (props) => {
|
|
|
673
836
|
continue;
|
|
674
837
|
}
|
|
675
838
|
if (renderable.type === "SPRITE") {
|
|
676
|
-
const { opacity = 1, flipX = false, modulate } = renderable;
|
|
839
|
+
const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
|
|
677
840
|
const resource = resourceById[renderable.resourceId];
|
|
678
841
|
const frame = {
|
|
679
842
|
x: renderable.frame % resource.slices.horizontal,
|
|
@@ -688,12 +851,23 @@ const runEngine = async (props) => {
|
|
|
688
851
|
};
|
|
689
852
|
const destWidth = resource.size.width;
|
|
690
853
|
const destHeight = resource.size.height;
|
|
691
|
-
//
|
|
692
|
-
// as the image to draw
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
854
|
+
// Swapping and tinting each swap in an already-processed offscreen
|
|
855
|
+
// copy of this frame as the image to draw from then on —
|
|
856
|
+
// everything past this point (flipX, positioning) treats it
|
|
857
|
+
// exactly like the untinted spritesheet, just drawn starting at
|
|
858
|
+
// (0, 0) instead of cropped from a sheet. Swap runs first (it's
|
|
859
|
+
// the sprite's "real" recolored identity), tint runs on top of
|
|
860
|
+
// that (e.g. a damage flash still applies over swapped colors).
|
|
861
|
+
let image = resource.image;
|
|
862
|
+
let imageSource = source;
|
|
863
|
+
if (swapColors !== undefined && swapColors.length > 0) {
|
|
864
|
+
image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
|
|
865
|
+
imageSource = { x: 0, y: 0, width: source.width, height: source.height };
|
|
866
|
+
}
|
|
867
|
+
if (modulate !== undefined) {
|
|
868
|
+
image = tintedSpriteFrame(image, imageSource, modulate);
|
|
869
|
+
imageSource = { x: 0, y: 0, width: source.width, height: source.height };
|
|
870
|
+
}
|
|
697
871
|
context.globalAlpha = opacity;
|
|
698
872
|
const drawSprite = (destX, destY) => {
|
|
699
873
|
context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
|
|
@@ -744,7 +918,34 @@ const runEngine = async (props) => {
|
|
|
744
918
|
}, 0);
|
|
745
919
|
resetCanvas = () => {
|
|
746
920
|
clearInterval(intervalId);
|
|
921
|
+
// Otherwise a track started by this run keeps playing underneath
|
|
922
|
+
// whatever the next runEngine() call starts — currentMusic is a
|
|
923
|
+
// per-run element, not something the next run has any way to reach.
|
|
924
|
+
currentMusic?.pause();
|
|
925
|
+
currentMusic = null;
|
|
926
|
+
// The canvas element itself is only thrown away between runs if the
|
|
927
|
+
// caller replaces it — in the playground it's the same persistent
|
|
928
|
+
// <canvas id="yuuna"> across every example switch and every
|
|
929
|
+
// Auto-Reload keystroke, so its listeners have to be removed
|
|
930
|
+
// explicitly here too, or each run stacks its own click/mousemove/
|
|
931
|
+
// keyboard handlers on top of every previous run's. Those old
|
|
932
|
+
// handlers still fire (each still does its own hit-testing and
|
|
933
|
+
// renderState() call against its own now-frozen state) even though
|
|
934
|
+
// their interval is long since cleared, quietly costing more CPU per
|
|
935
|
+
// click/mousemove the more times a run's been replaced — and on a
|
|
936
|
+
// slow enough device or long enough playground session, that pile-up
|
|
937
|
+
// is what "clicks stop working" actually looks like.
|
|
938
|
+
canvas.removeEventListener("click", handleClick);
|
|
939
|
+
canvas.removeEventListener("keydown", handleKeyDown);
|
|
940
|
+
canvas.removeEventListener("keyup", handleKeyUp);
|
|
941
|
+
canvas.removeEventListener("mousemove", handleMouseMoveHover);
|
|
942
|
+
canvas.removeEventListener("mousemove", handleMouseMoveTracking);
|
|
943
|
+
canvas.removeEventListener("mouseleave", handleMouseLeave);
|
|
944
|
+
// This one's on `document`, not the canvas — same reasoning as above,
|
|
945
|
+
// just doubly true since `document` isn't even scoped to this canvas.
|
|
946
|
+
window.document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
747
947
|
};
|
|
948
|
+
return { sendEvent };
|
|
748
949
|
};
|
|
749
950
|
|
|
750
951
|
export { STOP, runEngine };
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/package.json
CHANGED