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 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
- runEngine<GameState>({
38
- initialState: { cookies: 0 },
39
+ // What that state looks like before anything has happened yet
40
+ const initialState: GameState = { cookies: 0 };
39
41
 
40
- // Optional — size and color the canvas from code instead of HTML/CSS
41
- canvas: { width: 960, height: 540, backgroundColor: "#0d1831" },
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`, `LINE`, `GROUP`, and `ANIMATED_SPRITE`. Give
76
- one an `id` plus `isClickable` / `isHoverable` / `trackMouseMovement` to
77
- make it interactive. `TEXT` also takes a `fontSize` (defaults to `30`).
78
- `GROUP` draws nothing itself — it's just a `position` (plus the usual
79
- `scale`/`modulate`/`layer` below) for `children` to hang off of, for
80
- grouping renderables that should move/scale/tint together without
81
- needing a shape of its own; it's never interactable, since it has no
82
- shape to hit-test. `ANIMATED_SPRITE` is `SPRITE` with an `animation`
83
- (name of one defined on that resource see Sprites below) and an
84
- optional `timeScale` instead of a `frame` number; the engine picks the
85
- frame for you based on how long it's been playing. Unlike every other
86
- renderable, its `id` is required — that's how the engine recognizes
87
- "this is the same sprite as last frame" across renders (and switches to
88
- frame 0 if `animation` changes for that `id`), so reusing an id across
89
- two different entities will make their animations bleed together.
90
- Every renderable also takes:
91
- - `layer` higher values render later, i.e. in front of lower ones.
92
- Defaults to `0`; renderables on the same layer keep `render()`'s order.
93
- - `scale` — `{ x, y }` multiplier on the renderable's size, anchored at
94
- its `position` (or `from`, for `LINE`). Defaults to `{ x: 1, y: 1 }`.
95
- A `CIRCLE` scaled unevenly draws (and hit-tests) as an ellipse.
96
- - `modulate` a CSS color string that multiplies the renderable's color
97
- channel-by-channel, the same way Godot's `modulate` works — e.g.
98
- `"#808080"` halves brightness, `"#ff0000"` keeps only the red channel.
99
- - `children` nested renderables, positioned relative to this one, like
100
- Godot's parent/child nodes. A child's `position` is added to its
101
- parent's (scaled by the parent's own `scale`), and `scale` / `modulate`
102
- / `layer` all compose down the tree — a child's effective scale is the
103
- parent's times its own, `modulate` multiplies the same way, and
104
- `layer` adds (relative to the parent's, matching Godot's default: a
105
- deeply-nested child can still end up drawn in front of an unrelated
106
- top-level renderable if its accumulated layer says so). A child is a
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
- runEngine<GameState>({
174
- initialState,
175
- render,
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)
@@ -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 GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent;
149
- export type NextStateProps<State> = {
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<void>;
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
- image.src = value.src;
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
- instance.play();
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.play();
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 events = [];
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
- canvas.addEventListener("keydown", (event) => {
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("keyup", (event) => {
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("mousemove", (ev) => {
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", (ev) => {
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({ state, event, keyboard, playSound, playMusic, pauseMusic });
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
- // Tinting swaps in an already-tinted offscreen copy of this frame
694
- // as the image to draw everything past this point (flipX,
695
- // positioning) treats it exactly like the untinted spritesheet,
696
- // just drawn starting at (0, 0) instead of cropped from a sheet.
697
- const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
698
- const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
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
- image.src = value.src;
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
- instance.play();
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.play();
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 events = [];
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
- canvas.addEventListener("keydown", (event) => {
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("keyup", (event) => {
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("mousemove", (ev) => {
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", (ev) => {
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({ state, event, keyboard, playSound, playMusic, pauseMusic });
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
- // Tinting swaps in an already-tinted offscreen copy of this frame
692
- // as the image to draw everything past this point (flipX,
693
- // positioning) treats it exactly like the untinted spritesheet,
694
- // just drawn starting at (0, 0) instead of cropped from a sheet.
695
- const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
696
- const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuuna-engine",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A lightweight, state-machine-based TypeScript game engine for quick prototypes. Runs directly in the browser.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",