yuuna-engine 0.4.0 → 0.6.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
@@ -83,9 +83,10 @@ runEngine<GameState>({
83
83
  `CIRCLE`, `TEXT`, `SPRITE`, `ANIMATED_SPRITE`, `LINE`, and `GROUP`. Give
84
84
  one an `id` plus `isClickable`/`isHoverable` to make it interactive.
85
85
  - **Events** — `nextState` receives one `GameEvent` per call: `TIME`,
86
- `CLICK`, `HOVER_IN`, `HOVER_OUT`, `MOUSE_MOVE`, `MUSIC_END`, or a
87
- `CUSTOM` event of a type you define yourself, for reporting things like
88
- an async `fetch()` resolving back into your state machine.
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.
89
90
  - **Keyboard, camera, sprites & animation, sound effects & music,
90
91
  canvas config, and mechanics pipelines** all follow the same idea:
91
92
  small, focused props and functions `runEngine`/`nextState` take, that
@@ -106,10 +107,14 @@ hand? Grab one from [`templates/`](templates):
106
107
  open it in a browser and it runs.
107
108
  - **[npm](templates/npm)** — TypeScript + a dev server with hot reload
108
109
  (via Vite), for a real local project.
110
+ - **[neutralino-desktop](templates/neutralino-desktop)** — the `npm`
111
+ template wrapped in [Neutralino](https://neutralino.js.org) to run as a
112
+ native desktop window instead of a browser tab.
109
113
 
110
114
  ```sh
111
115
  npx degit lucy-dot-exe/yuuna/templates/blank my-game
112
116
  # or: npx degit lucy-dot-exe/yuuna/templates/npm my-game
117
+ # or: npx degit lucy-dot-exe/yuuna/templates/neutralino-desktop my-game
113
118
  ```
114
119
 
115
120
  [`degit`](https://github.com/Rich-Harris/degit) copies the folder without
@@ -128,6 +133,24 @@ yarn watch # rebuild on change
128
133
  browser via a global `Yuuna` object and embeds a live Monaco editor so
129
134
  visitors can edit and run a game directly on the page.
130
135
 
136
+ ## Assets
137
+
138
+ The examples' art/sound/music lives in `dist/resources/`, gitignored
139
+ rather than committed — this repo being open source doesn't make every
140
+ asset in it free to redistribute. `runEngine()` falls back to a
141
+ generated placeholder for any image that isn't there (and simply plays
142
+ nothing for missing audio) instead of failing, so the examples still
143
+ run without them — just with placeholder art in place of the real
144
+ thing. Drop the real files in locally (or restore them from wherever
145
+ you got this repo from) to see them for real.
146
+
147
+ Currently used:
148
+
149
+ - **[Free Pixel Food!](https://henrysoftware.itch.io/pixel-food)** by
150
+ [Henry Software](https://henrysoftware.itch.io/) — the food icons in
151
+ the Food Clicker and Sprites examples. CC0; credited here by choice,
152
+ not requirement.
153
+
131
154
  ## License
132
155
 
133
156
  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,11 +153,32 @@ export type MouseMoveEvent = {
145
153
  y: number;
146
154
  };
147
155
  };
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
+ };
148
173
  export type MusicEndEvent = {
149
174
  tag: "MUSIC_END";
150
175
  id: string;
151
176
  };
152
- export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent | MusicEndEvent;
177
+ export type FullscreenChangeEvent = {
178
+ tag: "FULLSCREEN_CHANGE";
179
+ isFullscreen: boolean;
180
+ };
181
+ export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent | MouseLeaveEvent | TabBlurEvent | TabFocusEvent | MusicEndEvent | FullscreenChangeEvent;
153
182
  export type CustomGameEvent<Custom> = {
154
183
  tag: "CUSTOM";
155
184
  event: Custom;
@@ -173,7 +202,7 @@ export type NextStateFunction<State, Custom = never> = (props: NextStateProps<St
173
202
  export type RunEngineProps<State, Custom = never> = {
174
203
  initialState: State;
175
204
  render: (state: State) => {
176
- cursor?: "default" | "pointer";
205
+ cursor?: "default" | "pointer" | "none";
177
206
  renderables: Renderable[];
178
207
  };
179
208
  nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
@@ -204,6 +233,7 @@ export type RunEngineProps<State, Custom = never> = {
204
233
  width?: number;
205
234
  height?: number;
206
235
  backgroundColor?: string;
236
+ resize?: "none" | "fit" | "stretch";
207
237
  };
208
238
  camera?: (state: State) => {
209
239
  x: number;
@@ -213,6 +243,8 @@ export type RunEngineProps<State, Custom = never> = {
213
243
  };
214
244
  export type RunEngineFunction = <State, Custom = never>(props: RunEngineProps<State, Custom>) => Promise<{
215
245
  sendEvent: (event: Custom) => void;
246
+ requestFullscreen: () => Promise<void>;
247
+ exitFullscreen: () => Promise<void>;
216
248
  }>;
217
249
  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"];
218
250
  export type KeyboardKeys = (typeof keyboardKeys)[number];
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?.();
@@ -146,6 +175,31 @@ const runEngine = async (props) => {
146
175
  // Make the canvas focusable so keyboard input is scoped to it instead of
147
176
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
148
177
  canvas.tabIndex = 0;
178
+ // Resizes the *display* size only (CSS width/height) — canvas.width/
179
+ // height above stays the fixed logical resolution every renderable's
180
+ // position is already expressed in, so this never needs to touch any
181
+ // of that math. See RunEngineProps.canvas.resize for the mode semantics.
182
+ const applyResize = () => {
183
+ const mode = props.canvas?.resize;
184
+ if (mode === undefined || mode === "none") {
185
+ return;
186
+ }
187
+ if (mode === "stretch") {
188
+ canvas.style.width = `${window.innerWidth}px`;
189
+ canvas.style.height = `${window.innerHeight}px`;
190
+ return;
191
+ }
192
+ const scale = Math.min(window.innerWidth / canvas.width, window.innerHeight / canvas.height);
193
+ canvas.style.width = `${canvas.width * scale}px`;
194
+ canvas.style.height = `${canvas.height * scale}px`;
195
+ };
196
+ applyResize();
197
+ window.addEventListener("resize", applyResize);
198
+ const requestFullscreen = () => canvas.requestFullscreen();
199
+ // exitFullscreen() rejects with a TypeError if nothing is fullscreen —
200
+ // guarded into a no-op instead, so callers don't need to track that
201
+ // state themselves just to call this safely.
202
+ const exitFullscreen = () => window.document.fullscreenElement === null ? Promise.resolve() : window.document.exitFullscreen();
149
203
  let state = props.initialState;
150
204
  const events = [];
151
205
  // Lets a caller report something that happened outside the render loop
@@ -158,10 +212,9 @@ const runEngine = async (props) => {
158
212
  const resources = props.resources ?? {};
159
213
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
160
214
  const image = new Image();
161
- image.src = value.src;
162
- image.onload = function () {
215
+ const settle = (loadedImage) => {
163
216
  resolve({
164
- image,
217
+ image: loadedImage,
165
218
  size: {
166
219
  width: value.size.width / value.slices.horizontal,
167
220
  height: value.size.height / value.slices.vertical,
@@ -170,12 +223,25 @@ const runEngine = async (props) => {
170
223
  animations: value.animations ?? {},
171
224
  });
172
225
  };
226
+ image.src = value.src;
227
+ image.onload = () => settle(image);
228
+ // Missing/failed-to-load asset (see .gitignore's dist/resources/
229
+ // note) — a placeholder sheet, sized to match what this resource
230
+ // declared, keeps every frame/slice/animation index the example
231
+ // already computes valid instead of drawing nothing or throwing.
232
+ image.onerror = () => settle(createPlaceholderSheet(value.size));
173
233
  }));
174
234
  const loadAudio = (src) => new Promise((resolve) => {
175
235
  const audio = new Audio(src);
176
236
  audio.oncanplaythrough = function () {
177
237
  resolve(audio);
178
238
  };
239
+ // Missing/failed-to-load audio — resolve anyway instead of hanging
240
+ // this Promise (and every resource after it, via Promise.all)
241
+ // forever waiting for a "canplaythrough" that's never coming.
242
+ // playSound/playMusic below already no-op safely on an element
243
+ // that can't actually play.
244
+ audio.onerror = () => resolve(audio);
179
245
  });
180
246
  const sounds = props.sounds ?? {};
181
247
  const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
@@ -188,7 +254,10 @@ const runEngine = async (props) => {
188
254
  return;
189
255
  }
190
256
  const instance = audio.cloneNode();
191
- instance.play();
257
+ // A missing/failed-to-load sound (see loadAudio's onerror above)
258
+ // rejects here instead of playing — caught and dropped rather than
259
+ // left as an unhandled rejection, same as playMusic/resumeMusic below.
260
+ instance.play().catch(() => { });
192
261
  };
193
262
  const music = props.music ?? {};
194
263
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
@@ -220,14 +289,14 @@ const runEngine = async (props) => {
220
289
  }
221
290
  audio.loop = music[id]?.loop ?? true;
222
291
  audio.volume = musicVolume;
223
- audio.play();
292
+ audio.play().catch(() => { });
224
293
  currentMusic = audio;
225
294
  };
226
295
  const pauseMusic = () => {
227
296
  currentMusic?.pause();
228
297
  };
229
298
  const resumeMusic = () => {
230
- currentMusic?.play();
299
+ currentMusic?.play().catch(() => { });
231
300
  };
232
301
  const setMusicVolume = (volume) => {
233
302
  musicVolume = Math.min(1, Math.max(0, volume));
@@ -237,9 +306,11 @@ const runEngine = async (props) => {
237
306
  };
238
307
  // A newer runEngine() call started while this one was still loading
239
308
  // resources (e.g. a spritesheet) — abandon this run instead of setting
240
- // up a second, orphaned render loop alongside the newer one.
309
+ // up a second, orphaned render loop alongside the newer one. The
310
+ // fullscreen functions are no-ops here since this run never gets far
311
+ // enough to own the canvas — the newer run's are the ones that matter.
241
312
  if (runId !== latestRunId) {
242
- return { sendEvent };
313
+ return { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
243
314
  }
244
315
  context.imageSmoothingEnabled = false;
245
316
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -529,7 +600,7 @@ const runEngine = async (props) => {
529
600
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
530
601
  let lastFrame = Date.now();
531
602
  let hoveredId = null;
532
- canvas.addEventListener("click", (ev) => {
603
+ const handleClick = (ev) => {
533
604
  const mouse = getCanvasPosition(ev);
534
605
  if (hoveredId === null)
535
606
  return;
@@ -538,7 +609,8 @@ const runEngine = async (props) => {
538
609
  if (hovered !== undefined && hovered.isClickable) {
539
610
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
540
611
  }
541
- });
612
+ };
613
+ canvas.addEventListener("click", handleClick);
542
614
  const initialState = {
543
615
  keyboardState: createRecord(keyboardKeys, () => false),
544
616
  };
@@ -548,7 +620,7 @@ const runEngine = async (props) => {
548
620
  const currentState = {
549
621
  keyboardState: { ...initialState.keyboardState },
550
622
  };
551
- canvas.addEventListener("keydown", (event) => {
623
+ const handleKeyDown = (event) => {
552
624
  const pressedKey = keyboardKeys.find((key) => key === event.code);
553
625
  if (pressedKey !== undefined) {
554
626
  // Stop tracked keys (arrows, space, ...) from also scrolling the
@@ -557,15 +629,17 @@ const runEngine = async (props) => {
557
629
  event.preventDefault();
558
630
  currentState.keyboardState[pressedKey] = true;
559
631
  }
560
- });
561
- canvas.addEventListener("keyup", (event) => {
632
+ };
633
+ canvas.addEventListener("keydown", handleKeyDown);
634
+ const handleKeyUp = (event) => {
562
635
  const releasedKey = keyboardKeys.find((key) => key === event.code);
563
636
  if (releasedKey !== undefined) {
564
637
  event.preventDefault();
565
638
  currentState.keyboardState[releasedKey] = false;
566
639
  }
567
- });
568
- canvas.addEventListener("mousemove", (ev) => {
640
+ };
641
+ canvas.addEventListener("keyup", handleKeyUp);
642
+ const handleMouseMoveHover = (ev) => {
569
643
  const mouse = getCanvasPosition(ev);
570
644
  const { renderables, camera } = renderState(state);
571
645
  const worldMouse = toWorldPosition(mouse, camera);
@@ -582,8 +656,9 @@ const runEngine = async (props) => {
582
656
  }
583
657
  }
584
658
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
585
- });
586
- canvas.addEventListener("mousemove", (ev) => {
659
+ };
660
+ canvas.addEventListener("mousemove", handleMouseMoveHover);
661
+ const handleMouseMoveTracking = (ev) => {
587
662
  const mouse = getCanvasPosition(ev);
588
663
  if (hoveredId === null) {
589
664
  return;
@@ -593,7 +668,44 @@ const runEngine = async (props) => {
593
668
  if (hovered !== undefined && hovered.trackMouseMovement) {
594
669
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
595
670
  }
596
- });
671
+ };
672
+ canvas.addEventListener("mousemove", handleMouseMoveTracking);
673
+ // No further mousemove fires once the mouse is off the canvas, so this
674
+ // is also the only chance to report a HOVER_OUT for whatever was
675
+ // hovered when it left — otherwise that hover would just dangle,
676
+ // never explicitly ended.
677
+ const handleMouseLeave = (ev) => {
678
+ const mouse = getCanvasPosition(ev);
679
+ const { renderables, camera } = renderState(state);
680
+ const worldMouse = toWorldPosition(mouse, camera);
681
+ if (hoveredId !== null) {
682
+ const lastHovered = renderables.find((r) => r.id === hoveredId);
683
+ if (lastHovered !== undefined) {
684
+ events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
685
+ }
686
+ }
687
+ hoveredId = null;
688
+ events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
689
+ };
690
+ canvas.addEventListener("mouseleave", handleMouseLeave);
691
+ // Tab switches are a document-level concern (visibilitychange), not
692
+ // something that ever reaches the canvas itself the way mouse/keyboard
693
+ // events do.
694
+ const handleVisibilityChange = () => {
695
+ events.push({ tag: window.document.hidden ? "TAB_BLUR" : "TAB_FOCUS" });
696
+ };
697
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
698
+ // Fullscreen is a document-level concern too, and fires for every way
699
+ // fullscreen can change — the requestFullscreen()/exitFullscreen()
700
+ // above, but also things neither of those causes directly, like the
701
+ // user pressing Esc. Re-running applyResize() here (rather than relying
702
+ // solely on the "resize" listener) covers browsers that don't also fire
703
+ // a window resize when fullscreen is toggled.
704
+ const handleFullscreenChange = () => {
705
+ events.push({ tag: "FULLSCREEN_CHANGE", isFullscreen: window.document.fullscreenElement === canvas });
706
+ applyResize();
707
+ };
708
+ window.document.addEventListener("fullscreenchange", handleFullscreenChange);
597
709
  context.imageSmoothingEnabled = false;
598
710
  // Scale is a canvas transform around the renderable's anchor, applied
599
711
  // before its type-specific drawing runs below — everything drawn under
@@ -644,6 +756,56 @@ const runEngine = async (props) => {
644
756
  tintBufferContext.globalCompositeOperation = "source-over";
645
757
  return tintBuffer;
646
758
  };
759
+ // Palette-swapped frames, keyed by resourceId + frame + the exact swap
760
+ // list — unlike tintedSpriteFrame's cheap multiply-blend (redone fresh
761
+ // every draw off one shared buffer), a swap needs real pixel work
762
+ // (getImageData over the whole frame), so each unique combination is
763
+ // computed once here and reused on every later draw instead. Grows for
764
+ // as long as new combinations keep showing up — fine for a fixed small
765
+ // set of recolors (e.g. team A/B/C), a bad fit for one that varies
766
+ // continuously (e.g. a randomized hue per instance), which would cache-
767
+ // miss every time and just accumulate.
768
+ const swappedFrameCache = new Map();
769
+ const swappedSpriteFrame = (resourceId, frame, image, source, swapColors) => {
770
+ const cacheKey = `${resourceId}:${frame}:${swapColors.map(({ from, to }) => `${from}>${to}`).join(",")}`;
771
+ const cached = swappedFrameCache.get(cacheKey);
772
+ if (cached !== undefined) {
773
+ return cached;
774
+ }
775
+ const canvas = window.document.createElement("canvas");
776
+ canvas.width = source.width;
777
+ canvas.height = source.height;
778
+ const swapContext = canvas.getContext("2d");
779
+ // No 2d context to work with (shouldn't happen in a real browser) —
780
+ // draw the untouched frame rather than crash.
781
+ if (swapContext === null) {
782
+ return image;
783
+ }
784
+ swapContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
785
+ // Resolved once per unique `from`/`to` pair (resolveColor caches by
786
+ // string), not per pixel — the pixel loop below only ever compares
787
+ // against these already-resolved bytes.
788
+ const resolvedSwaps = swapColors.map(({ from, to }) => ({
789
+ from: resolveColor(from),
790
+ to: resolveColor(to),
791
+ }));
792
+ const imageData = swapContext.getImageData(0, 0, canvas.width, canvas.height);
793
+ const pixels = imageData.data;
794
+ for (let i = 0; i < pixels.length; i += 4) {
795
+ for (const { from, to } of resolvedSwaps) {
796
+ if (pixels[i] === from[0] && pixels[i + 1] === from[1] && pixels[i + 2] === from[2] && pixels[i + 3] === from[3]) {
797
+ pixels[i] = to[0];
798
+ pixels[i + 1] = to[1];
799
+ pixels[i + 2] = to[2];
800
+ pixels[i + 3] = to[3];
801
+ break;
802
+ }
803
+ }
804
+ }
805
+ swapContext.putImageData(imageData, 0, 0);
806
+ swappedFrameCache.set(cacheKey, canvas);
807
+ return canvas;
808
+ };
647
809
  const intervalId = setInterval(() => {
648
810
  const now = Date.now();
649
811
  const delta = now - lastFrame;
@@ -714,7 +876,7 @@ const runEngine = async (props) => {
714
876
  continue;
715
877
  }
716
878
  if (renderable.type === "SPRITE") {
717
- const { opacity = 1, flipX = false, modulate } = renderable;
879
+ const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
718
880
  const resource = resourceById[renderable.resourceId];
719
881
  const frame = {
720
882
  x: renderable.frame % resource.slices.horizontal,
@@ -729,12 +891,23 @@ const runEngine = async (props) => {
729
891
  };
730
892
  const destWidth = resource.size.width;
731
893
  const destHeight = resource.size.height;
732
- // Tinting swaps in an already-tinted offscreen copy of this frame
733
- // as the image to draw everything past this point (flipX,
734
- // positioning) treats it exactly like the untinted spritesheet,
735
- // just drawn starting at (0, 0) instead of cropped from a sheet.
736
- const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
737
- const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
894
+ // Swapping and tinting each swap in an already-processed offscreen
895
+ // copy of this frame as the image to draw from then on
896
+ // everything past this point (flipX, positioning) treats it
897
+ // exactly like the untinted spritesheet, just drawn starting at
898
+ // (0, 0) instead of cropped from a sheet. Swap runs first (it's
899
+ // the sprite's "real" recolored identity), tint runs on top of
900
+ // that (e.g. a damage flash still applies over swapped colors).
901
+ let image = resource.image;
902
+ let imageSource = source;
903
+ if (swapColors !== undefined && swapColors.length > 0) {
904
+ image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
905
+ imageSource = { x: 0, y: 0, width: source.width, height: source.height };
906
+ }
907
+ if (modulate !== undefined) {
908
+ image = tintedSpriteFrame(image, imageSource, modulate);
909
+ imageSource = { x: 0, y: 0, width: source.width, height: source.height };
910
+ }
738
911
  context.globalAlpha = opacity;
739
912
  const drawSprite = (destX, destY) => {
740
913
  context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
@@ -785,8 +958,37 @@ const runEngine = async (props) => {
785
958
  }, 0);
786
959
  resetCanvas = () => {
787
960
  clearInterval(intervalId);
961
+ // Otherwise a track started by this run keeps playing underneath
962
+ // whatever the next runEngine() call starts — currentMusic is a
963
+ // per-run element, not something the next run has any way to reach.
964
+ currentMusic?.pause();
965
+ currentMusic = null;
966
+ // The canvas element itself is only thrown away between runs if the
967
+ // caller replaces it — in the playground it's the same persistent
968
+ // <canvas id="yuuna"> across every example switch and every
969
+ // Auto-Reload keystroke, so its listeners have to be removed
970
+ // explicitly here too, or each run stacks its own click/mousemove/
971
+ // keyboard handlers on top of every previous run's. Those old
972
+ // handlers still fire (each still does its own hit-testing and
973
+ // renderState() call against its own now-frozen state) even though
974
+ // their interval is long since cleared, quietly costing more CPU per
975
+ // click/mousemove the more times a run's been replaced — and on a
976
+ // slow enough device or long enough playground session, that pile-up
977
+ // is what "clicks stop working" actually looks like.
978
+ canvas.removeEventListener("click", handleClick);
979
+ canvas.removeEventListener("keydown", handleKeyDown);
980
+ canvas.removeEventListener("keyup", handleKeyUp);
981
+ canvas.removeEventListener("mousemove", handleMouseMoveHover);
982
+ canvas.removeEventListener("mousemove", handleMouseMoveTracking);
983
+ canvas.removeEventListener("mouseleave", handleMouseLeave);
984
+ // This one's on `document`, not the canvas — same reasoning as above,
985
+ // just doubly true since `document` isn't even scoped to this canvas.
986
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
987
+ window.document.removeEventListener("fullscreenchange", handleFullscreenChange);
988
+ // Same pile-up risk as the canvas listeners above, but on `window`.
989
+ window.removeEventListener("resize", applyResize);
788
990
  };
789
- return { sendEvent };
991
+ return { sendEvent, requestFullscreen, exitFullscreen };
790
992
  };
791
993
 
792
994
  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.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?.();
@@ -144,6 +173,31 @@ const runEngine = async (props) => {
144
173
  // Make the canvas focusable so keyboard input is scoped to it instead of
145
174
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
146
175
  canvas.tabIndex = 0;
176
+ // Resizes the *display* size only (CSS width/height) — canvas.width/
177
+ // height above stays the fixed logical resolution every renderable's
178
+ // position is already expressed in, so this never needs to touch any
179
+ // of that math. See RunEngineProps.canvas.resize for the mode semantics.
180
+ const applyResize = () => {
181
+ const mode = props.canvas?.resize;
182
+ if (mode === undefined || mode === "none") {
183
+ return;
184
+ }
185
+ if (mode === "stretch") {
186
+ canvas.style.width = `${window.innerWidth}px`;
187
+ canvas.style.height = `${window.innerHeight}px`;
188
+ return;
189
+ }
190
+ const scale = Math.min(window.innerWidth / canvas.width, window.innerHeight / canvas.height);
191
+ canvas.style.width = `${canvas.width * scale}px`;
192
+ canvas.style.height = `${canvas.height * scale}px`;
193
+ };
194
+ applyResize();
195
+ window.addEventListener("resize", applyResize);
196
+ const requestFullscreen = () => canvas.requestFullscreen();
197
+ // exitFullscreen() rejects with a TypeError if nothing is fullscreen —
198
+ // guarded into a no-op instead, so callers don't need to track that
199
+ // state themselves just to call this safely.
200
+ const exitFullscreen = () => window.document.fullscreenElement === null ? Promise.resolve() : window.document.exitFullscreen();
147
201
  let state = props.initialState;
148
202
  const events = [];
149
203
  // Lets a caller report something that happened outside the render loop
@@ -156,10 +210,9 @@ const runEngine = async (props) => {
156
210
  const resources = props.resources ?? {};
157
211
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
158
212
  const image = new Image();
159
- image.src = value.src;
160
- image.onload = function () {
213
+ const settle = (loadedImage) => {
161
214
  resolve({
162
- image,
215
+ image: loadedImage,
163
216
  size: {
164
217
  width: value.size.width / value.slices.horizontal,
165
218
  height: value.size.height / value.slices.vertical,
@@ -168,12 +221,25 @@ const runEngine = async (props) => {
168
221
  animations: value.animations ?? {},
169
222
  });
170
223
  };
224
+ image.src = value.src;
225
+ image.onload = () => settle(image);
226
+ // Missing/failed-to-load asset (see .gitignore's dist/resources/
227
+ // note) — a placeholder sheet, sized to match what this resource
228
+ // declared, keeps every frame/slice/animation index the example
229
+ // already computes valid instead of drawing nothing or throwing.
230
+ image.onerror = () => settle(createPlaceholderSheet(value.size));
171
231
  }));
172
232
  const loadAudio = (src) => new Promise((resolve) => {
173
233
  const audio = new Audio(src);
174
234
  audio.oncanplaythrough = function () {
175
235
  resolve(audio);
176
236
  };
237
+ // Missing/failed-to-load audio — resolve anyway instead of hanging
238
+ // this Promise (and every resource after it, via Promise.all)
239
+ // forever waiting for a "canplaythrough" that's never coming.
240
+ // playSound/playMusic below already no-op safely on an element
241
+ // that can't actually play.
242
+ audio.onerror = () => resolve(audio);
177
243
  });
178
244
  const sounds = props.sounds ?? {};
179
245
  const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
@@ -186,7 +252,10 @@ const runEngine = async (props) => {
186
252
  return;
187
253
  }
188
254
  const instance = audio.cloneNode();
189
- instance.play();
255
+ // A missing/failed-to-load sound (see loadAudio's onerror above)
256
+ // rejects here instead of playing — caught and dropped rather than
257
+ // left as an unhandled rejection, same as playMusic/resumeMusic below.
258
+ instance.play().catch(() => { });
190
259
  };
191
260
  const music = props.music ?? {};
192
261
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
@@ -218,14 +287,14 @@ const runEngine = async (props) => {
218
287
  }
219
288
  audio.loop = music[id]?.loop ?? true;
220
289
  audio.volume = musicVolume;
221
- audio.play();
290
+ audio.play().catch(() => { });
222
291
  currentMusic = audio;
223
292
  };
224
293
  const pauseMusic = () => {
225
294
  currentMusic?.pause();
226
295
  };
227
296
  const resumeMusic = () => {
228
- currentMusic?.play();
297
+ currentMusic?.play().catch(() => { });
229
298
  };
230
299
  const setMusicVolume = (volume) => {
231
300
  musicVolume = Math.min(1, Math.max(0, volume));
@@ -235,9 +304,11 @@ const runEngine = async (props) => {
235
304
  };
236
305
  // A newer runEngine() call started while this one was still loading
237
306
  // resources (e.g. a spritesheet) — abandon this run instead of setting
238
- // up a second, orphaned render loop alongside the newer one.
307
+ // up a second, orphaned render loop alongside the newer one. The
308
+ // fullscreen functions are no-ops here since this run never gets far
309
+ // enough to own the canvas — the newer run's are the ones that matter.
239
310
  if (runId !== latestRunId) {
240
- return { sendEvent };
311
+ return { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
241
312
  }
242
313
  context.imageSmoothingEnabled = false;
243
314
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -527,7 +598,7 @@ const runEngine = async (props) => {
527
598
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
528
599
  let lastFrame = Date.now();
529
600
  let hoveredId = null;
530
- canvas.addEventListener("click", (ev) => {
601
+ const handleClick = (ev) => {
531
602
  const mouse = getCanvasPosition(ev);
532
603
  if (hoveredId === null)
533
604
  return;
@@ -536,7 +607,8 @@ const runEngine = async (props) => {
536
607
  if (hovered !== undefined && hovered.isClickable) {
537
608
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
538
609
  }
539
- });
610
+ };
611
+ canvas.addEventListener("click", handleClick);
540
612
  const initialState = {
541
613
  keyboardState: createRecord(keyboardKeys, () => false),
542
614
  };
@@ -546,7 +618,7 @@ const runEngine = async (props) => {
546
618
  const currentState = {
547
619
  keyboardState: { ...initialState.keyboardState },
548
620
  };
549
- canvas.addEventListener("keydown", (event) => {
621
+ const handleKeyDown = (event) => {
550
622
  const pressedKey = keyboardKeys.find((key) => key === event.code);
551
623
  if (pressedKey !== undefined) {
552
624
  // Stop tracked keys (arrows, space, ...) from also scrolling the
@@ -555,15 +627,17 @@ const runEngine = async (props) => {
555
627
  event.preventDefault();
556
628
  currentState.keyboardState[pressedKey] = true;
557
629
  }
558
- });
559
- canvas.addEventListener("keyup", (event) => {
630
+ };
631
+ canvas.addEventListener("keydown", handleKeyDown);
632
+ const handleKeyUp = (event) => {
560
633
  const releasedKey = keyboardKeys.find((key) => key === event.code);
561
634
  if (releasedKey !== undefined) {
562
635
  event.preventDefault();
563
636
  currentState.keyboardState[releasedKey] = false;
564
637
  }
565
- });
566
- canvas.addEventListener("mousemove", (ev) => {
638
+ };
639
+ canvas.addEventListener("keyup", handleKeyUp);
640
+ const handleMouseMoveHover = (ev) => {
567
641
  const mouse = getCanvasPosition(ev);
568
642
  const { renderables, camera } = renderState(state);
569
643
  const worldMouse = toWorldPosition(mouse, camera);
@@ -580,8 +654,9 @@ const runEngine = async (props) => {
580
654
  }
581
655
  }
582
656
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
583
- });
584
- canvas.addEventListener("mousemove", (ev) => {
657
+ };
658
+ canvas.addEventListener("mousemove", handleMouseMoveHover);
659
+ const handleMouseMoveTracking = (ev) => {
585
660
  const mouse = getCanvasPosition(ev);
586
661
  if (hoveredId === null) {
587
662
  return;
@@ -591,7 +666,44 @@ const runEngine = async (props) => {
591
666
  if (hovered !== undefined && hovered.trackMouseMovement) {
592
667
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
593
668
  }
594
- });
669
+ };
670
+ canvas.addEventListener("mousemove", handleMouseMoveTracking);
671
+ // No further mousemove fires once the mouse is off the canvas, so this
672
+ // is also the only chance to report a HOVER_OUT for whatever was
673
+ // hovered when it left — otherwise that hover would just dangle,
674
+ // never explicitly ended.
675
+ const handleMouseLeave = (ev) => {
676
+ const mouse = getCanvasPosition(ev);
677
+ const { renderables, camera } = renderState(state);
678
+ const worldMouse = toWorldPosition(mouse, camera);
679
+ if (hoveredId !== null) {
680
+ const lastHovered = renderables.find((r) => r.id === hoveredId);
681
+ if (lastHovered !== undefined) {
682
+ events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
683
+ }
684
+ }
685
+ hoveredId = null;
686
+ events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
687
+ };
688
+ canvas.addEventListener("mouseleave", handleMouseLeave);
689
+ // Tab switches are a document-level concern (visibilitychange), not
690
+ // something that ever reaches the canvas itself the way mouse/keyboard
691
+ // events do.
692
+ const handleVisibilityChange = () => {
693
+ events.push({ tag: window.document.hidden ? "TAB_BLUR" : "TAB_FOCUS" });
694
+ };
695
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
696
+ // Fullscreen is a document-level concern too, and fires for every way
697
+ // fullscreen can change — the requestFullscreen()/exitFullscreen()
698
+ // above, but also things neither of those causes directly, like the
699
+ // user pressing Esc. Re-running applyResize() here (rather than relying
700
+ // solely on the "resize" listener) covers browsers that don't also fire
701
+ // a window resize when fullscreen is toggled.
702
+ const handleFullscreenChange = () => {
703
+ events.push({ tag: "FULLSCREEN_CHANGE", isFullscreen: window.document.fullscreenElement === canvas });
704
+ applyResize();
705
+ };
706
+ window.document.addEventListener("fullscreenchange", handleFullscreenChange);
595
707
  context.imageSmoothingEnabled = false;
596
708
  // Scale is a canvas transform around the renderable's anchor, applied
597
709
  // before its type-specific drawing runs below — everything drawn under
@@ -642,6 +754,56 @@ const runEngine = async (props) => {
642
754
  tintBufferContext.globalCompositeOperation = "source-over";
643
755
  return tintBuffer;
644
756
  };
757
+ // Palette-swapped frames, keyed by resourceId + frame + the exact swap
758
+ // list — unlike tintedSpriteFrame's cheap multiply-blend (redone fresh
759
+ // every draw off one shared buffer), a swap needs real pixel work
760
+ // (getImageData over the whole frame), so each unique combination is
761
+ // computed once here and reused on every later draw instead. Grows for
762
+ // as long as new combinations keep showing up — fine for a fixed small
763
+ // set of recolors (e.g. team A/B/C), a bad fit for one that varies
764
+ // continuously (e.g. a randomized hue per instance), which would cache-
765
+ // miss every time and just accumulate.
766
+ const swappedFrameCache = new Map();
767
+ const swappedSpriteFrame = (resourceId, frame, image, source, swapColors) => {
768
+ const cacheKey = `${resourceId}:${frame}:${swapColors.map(({ from, to }) => `${from}>${to}`).join(",")}`;
769
+ const cached = swappedFrameCache.get(cacheKey);
770
+ if (cached !== undefined) {
771
+ return cached;
772
+ }
773
+ const canvas = window.document.createElement("canvas");
774
+ canvas.width = source.width;
775
+ canvas.height = source.height;
776
+ const swapContext = canvas.getContext("2d");
777
+ // No 2d context to work with (shouldn't happen in a real browser) —
778
+ // draw the untouched frame rather than crash.
779
+ if (swapContext === null) {
780
+ return image;
781
+ }
782
+ swapContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
783
+ // Resolved once per unique `from`/`to` pair (resolveColor caches by
784
+ // string), not per pixel — the pixel loop below only ever compares
785
+ // against these already-resolved bytes.
786
+ const resolvedSwaps = swapColors.map(({ from, to }) => ({
787
+ from: resolveColor(from),
788
+ to: resolveColor(to),
789
+ }));
790
+ const imageData = swapContext.getImageData(0, 0, canvas.width, canvas.height);
791
+ const pixels = imageData.data;
792
+ for (let i = 0; i < pixels.length; i += 4) {
793
+ for (const { from, to } of resolvedSwaps) {
794
+ if (pixels[i] === from[0] && pixels[i + 1] === from[1] && pixels[i + 2] === from[2] && pixels[i + 3] === from[3]) {
795
+ pixels[i] = to[0];
796
+ pixels[i + 1] = to[1];
797
+ pixels[i + 2] = to[2];
798
+ pixels[i + 3] = to[3];
799
+ break;
800
+ }
801
+ }
802
+ }
803
+ swapContext.putImageData(imageData, 0, 0);
804
+ swappedFrameCache.set(cacheKey, canvas);
805
+ return canvas;
806
+ };
645
807
  const intervalId = setInterval(() => {
646
808
  const now = Date.now();
647
809
  const delta = now - lastFrame;
@@ -712,7 +874,7 @@ const runEngine = async (props) => {
712
874
  continue;
713
875
  }
714
876
  if (renderable.type === "SPRITE") {
715
- const { opacity = 1, flipX = false, modulate } = renderable;
877
+ const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
716
878
  const resource = resourceById[renderable.resourceId];
717
879
  const frame = {
718
880
  x: renderable.frame % resource.slices.horizontal,
@@ -727,12 +889,23 @@ const runEngine = async (props) => {
727
889
  };
728
890
  const destWidth = resource.size.width;
729
891
  const destHeight = resource.size.height;
730
- // Tinting swaps in an already-tinted offscreen copy of this frame
731
- // as the image to draw everything past this point (flipX,
732
- // positioning) treats it exactly like the untinted spritesheet,
733
- // just drawn starting at (0, 0) instead of cropped from a sheet.
734
- const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
735
- const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
892
+ // Swapping and tinting each swap in an already-processed offscreen
893
+ // copy of this frame as the image to draw from then on
894
+ // everything past this point (flipX, positioning) treats it
895
+ // exactly like the untinted spritesheet, just drawn starting at
896
+ // (0, 0) instead of cropped from a sheet. Swap runs first (it's
897
+ // the sprite's "real" recolored identity), tint runs on top of
898
+ // that (e.g. a damage flash still applies over swapped colors).
899
+ let image = resource.image;
900
+ let imageSource = source;
901
+ if (swapColors !== undefined && swapColors.length > 0) {
902
+ image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
903
+ imageSource = { x: 0, y: 0, width: source.width, height: source.height };
904
+ }
905
+ if (modulate !== undefined) {
906
+ image = tintedSpriteFrame(image, imageSource, modulate);
907
+ imageSource = { x: 0, y: 0, width: source.width, height: source.height };
908
+ }
736
909
  context.globalAlpha = opacity;
737
910
  const drawSprite = (destX, destY) => {
738
911
  context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
@@ -783,8 +956,37 @@ const runEngine = async (props) => {
783
956
  }, 0);
784
957
  resetCanvas = () => {
785
958
  clearInterval(intervalId);
959
+ // Otherwise a track started by this run keeps playing underneath
960
+ // whatever the next runEngine() call starts — currentMusic is a
961
+ // per-run element, not something the next run has any way to reach.
962
+ currentMusic?.pause();
963
+ currentMusic = null;
964
+ // The canvas element itself is only thrown away between runs if the
965
+ // caller replaces it — in the playground it's the same persistent
966
+ // <canvas id="yuuna"> across every example switch and every
967
+ // Auto-Reload keystroke, so its listeners have to be removed
968
+ // explicitly here too, or each run stacks its own click/mousemove/
969
+ // keyboard handlers on top of every previous run's. Those old
970
+ // handlers still fire (each still does its own hit-testing and
971
+ // renderState() call against its own now-frozen state) even though
972
+ // their interval is long since cleared, quietly costing more CPU per
973
+ // click/mousemove the more times a run's been replaced — and on a
974
+ // slow enough device or long enough playground session, that pile-up
975
+ // is what "clicks stop working" actually looks like.
976
+ canvas.removeEventListener("click", handleClick);
977
+ canvas.removeEventListener("keydown", handleKeyDown);
978
+ canvas.removeEventListener("keyup", handleKeyUp);
979
+ canvas.removeEventListener("mousemove", handleMouseMoveHover);
980
+ canvas.removeEventListener("mousemove", handleMouseMoveTracking);
981
+ canvas.removeEventListener("mouseleave", handleMouseLeave);
982
+ // This one's on `document`, not the canvas — same reasoning as above,
983
+ // just doubly true since `document` isn't even scoped to this canvas.
984
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
985
+ window.document.removeEventListener("fullscreenchange", handleFullscreenChange);
986
+ // Same pile-up risk as the canvas listeners above, but on `window`.
987
+ window.removeEventListener("resize", applyResize);
786
988
  };
787
- return { sendEvent };
989
+ return { sendEvent, requestFullscreen, exitFullscreen };
788
990
  };
789
991
 
790
992
  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.4.0",
3
+ "version": "0.6.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",