yuuna-engine 0.4.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
@@ -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
@@ -128,6 +129,24 @@ yarn watch # rebuild on change
128
129
  browser via a global `Yuuna` object and embeds a live Monaco editor so
129
130
  visitors can edit and run a game directly on the page.
130
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
+
131
150
  ## License
132
151
 
133
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,11 +153,28 @@ 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 GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent | MouseLeaveEvent | TabBlurEvent | TabFocusEvent | MusicEndEvent;
153
178
  export type CustomGameEvent<Custom> = {
154
179
  tag: "CUSTOM";
155
180
  event: Custom;
@@ -173,7 +198,7 @@ export type NextStateFunction<State, Custom = never> = (props: NextStateProps<St
173
198
  export type RunEngineProps<State, Custom = never> = {
174
199
  initialState: State;
175
200
  render: (state: State) => {
176
- cursor?: "default" | "pointer";
201
+ cursor?: "default" | "pointer" | "none";
177
202
  renderables: Renderable[];
178
203
  };
179
204
  nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
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?.();
@@ -158,10 +187,9 @@ const runEngine = async (props) => {
158
187
  const resources = props.resources ?? {};
159
188
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
160
189
  const image = new Image();
161
- image.src = value.src;
162
- image.onload = function () {
190
+ const settle = (loadedImage) => {
163
191
  resolve({
164
- image,
192
+ image: loadedImage,
165
193
  size: {
166
194
  width: value.size.width / value.slices.horizontal,
167
195
  height: value.size.height / value.slices.vertical,
@@ -170,12 +198,25 @@ const runEngine = async (props) => {
170
198
  animations: value.animations ?? {},
171
199
  });
172
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));
173
208
  }));
174
209
  const loadAudio = (src) => new Promise((resolve) => {
175
210
  const audio = new Audio(src);
176
211
  audio.oncanplaythrough = function () {
177
212
  resolve(audio);
178
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);
179
220
  });
180
221
  const sounds = props.sounds ?? {};
181
222
  const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
@@ -188,7 +229,10 @@ const runEngine = async (props) => {
188
229
  return;
189
230
  }
190
231
  const instance = audio.cloneNode();
191
- 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(() => { });
192
236
  };
193
237
  const music = props.music ?? {};
194
238
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
@@ -220,14 +264,14 @@ const runEngine = async (props) => {
220
264
  }
221
265
  audio.loop = music[id]?.loop ?? true;
222
266
  audio.volume = musicVolume;
223
- audio.play();
267
+ audio.play().catch(() => { });
224
268
  currentMusic = audio;
225
269
  };
226
270
  const pauseMusic = () => {
227
271
  currentMusic?.pause();
228
272
  };
229
273
  const resumeMusic = () => {
230
- currentMusic?.play();
274
+ currentMusic?.play().catch(() => { });
231
275
  };
232
276
  const setMusicVolume = (volume) => {
233
277
  musicVolume = Math.min(1, Math.max(0, volume));
@@ -529,7 +573,7 @@ const runEngine = async (props) => {
529
573
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
530
574
  let lastFrame = Date.now();
531
575
  let hoveredId = null;
532
- canvas.addEventListener("click", (ev) => {
576
+ const handleClick = (ev) => {
533
577
  const mouse = getCanvasPosition(ev);
534
578
  if (hoveredId === null)
535
579
  return;
@@ -538,7 +582,8 @@ const runEngine = async (props) => {
538
582
  if (hovered !== undefined && hovered.isClickable) {
539
583
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
540
584
  }
541
- });
585
+ };
586
+ canvas.addEventListener("click", handleClick);
542
587
  const initialState = {
543
588
  keyboardState: createRecord(keyboardKeys, () => false),
544
589
  };
@@ -548,7 +593,7 @@ const runEngine = async (props) => {
548
593
  const currentState = {
549
594
  keyboardState: { ...initialState.keyboardState },
550
595
  };
551
- canvas.addEventListener("keydown", (event) => {
596
+ const handleKeyDown = (event) => {
552
597
  const pressedKey = keyboardKeys.find((key) => key === event.code);
553
598
  if (pressedKey !== undefined) {
554
599
  // Stop tracked keys (arrows, space, ...) from also scrolling the
@@ -557,15 +602,17 @@ const runEngine = async (props) => {
557
602
  event.preventDefault();
558
603
  currentState.keyboardState[pressedKey] = true;
559
604
  }
560
- });
561
- canvas.addEventListener("keyup", (event) => {
605
+ };
606
+ canvas.addEventListener("keydown", handleKeyDown);
607
+ const handleKeyUp = (event) => {
562
608
  const releasedKey = keyboardKeys.find((key) => key === event.code);
563
609
  if (releasedKey !== undefined) {
564
610
  event.preventDefault();
565
611
  currentState.keyboardState[releasedKey] = false;
566
612
  }
567
- });
568
- canvas.addEventListener("mousemove", (ev) => {
613
+ };
614
+ canvas.addEventListener("keyup", handleKeyUp);
615
+ const handleMouseMoveHover = (ev) => {
569
616
  const mouse = getCanvasPosition(ev);
570
617
  const { renderables, camera } = renderState(state);
571
618
  const worldMouse = toWorldPosition(mouse, camera);
@@ -582,8 +629,9 @@ const runEngine = async (props) => {
582
629
  }
583
630
  }
584
631
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
585
- });
586
- canvas.addEventListener("mousemove", (ev) => {
632
+ };
633
+ canvas.addEventListener("mousemove", handleMouseMoveHover);
634
+ const handleMouseMoveTracking = (ev) => {
587
635
  const mouse = getCanvasPosition(ev);
588
636
  if (hoveredId === null) {
589
637
  return;
@@ -593,7 +641,33 @@ const runEngine = async (props) => {
593
641
  if (hovered !== undefined && hovered.trackMouseMovement) {
594
642
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
595
643
  }
596
- });
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);
597
671
  context.imageSmoothingEnabled = false;
598
672
  // Scale is a canvas transform around the renderable's anchor, applied
599
673
  // before its type-specific drawing runs below — everything drawn under
@@ -644,6 +718,56 @@ const runEngine = async (props) => {
644
718
  tintBufferContext.globalCompositeOperation = "source-over";
645
719
  return tintBuffer;
646
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
+ };
647
771
  const intervalId = setInterval(() => {
648
772
  const now = Date.now();
649
773
  const delta = now - lastFrame;
@@ -714,7 +838,7 @@ const runEngine = async (props) => {
714
838
  continue;
715
839
  }
716
840
  if (renderable.type === "SPRITE") {
717
- const { opacity = 1, flipX = false, modulate } = renderable;
841
+ const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
718
842
  const resource = resourceById[renderable.resourceId];
719
843
  const frame = {
720
844
  x: renderable.frame % resource.slices.horizontal,
@@ -729,12 +853,23 @@ const runEngine = async (props) => {
729
853
  };
730
854
  const destWidth = resource.size.width;
731
855
  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 };
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
+ }
738
873
  context.globalAlpha = opacity;
739
874
  const drawSprite = (destX, destY) => {
740
875
  context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
@@ -785,6 +920,32 @@ const runEngine = async (props) => {
785
920
  }, 0);
786
921
  resetCanvas = () => {
787
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);
788
949
  };
789
950
  return { sendEvent };
790
951
  };
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?.();
@@ -156,10 +185,9 @@ const runEngine = async (props) => {
156
185
  const resources = props.resources ?? {};
157
186
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
158
187
  const image = new Image();
159
- image.src = value.src;
160
- image.onload = function () {
188
+ const settle = (loadedImage) => {
161
189
  resolve({
162
- image,
190
+ image: loadedImage,
163
191
  size: {
164
192
  width: value.size.width / value.slices.horizontal,
165
193
  height: value.size.height / value.slices.vertical,
@@ -168,12 +196,25 @@ const runEngine = async (props) => {
168
196
  animations: value.animations ?? {},
169
197
  });
170
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));
171
206
  }));
172
207
  const loadAudio = (src) => new Promise((resolve) => {
173
208
  const audio = new Audio(src);
174
209
  audio.oncanplaythrough = function () {
175
210
  resolve(audio);
176
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);
177
218
  });
178
219
  const sounds = props.sounds ?? {};
179
220
  const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
@@ -186,7 +227,10 @@ const runEngine = async (props) => {
186
227
  return;
187
228
  }
188
229
  const instance = audio.cloneNode();
189
- 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(() => { });
190
234
  };
191
235
  const music = props.music ?? {};
192
236
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
@@ -218,14 +262,14 @@ const runEngine = async (props) => {
218
262
  }
219
263
  audio.loop = music[id]?.loop ?? true;
220
264
  audio.volume = musicVolume;
221
- audio.play();
265
+ audio.play().catch(() => { });
222
266
  currentMusic = audio;
223
267
  };
224
268
  const pauseMusic = () => {
225
269
  currentMusic?.pause();
226
270
  };
227
271
  const resumeMusic = () => {
228
- currentMusic?.play();
272
+ currentMusic?.play().catch(() => { });
229
273
  };
230
274
  const setMusicVolume = (volume) => {
231
275
  musicVolume = Math.min(1, Math.max(0, volume));
@@ -527,7 +571,7 @@ const runEngine = async (props) => {
527
571
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
528
572
  let lastFrame = Date.now();
529
573
  let hoveredId = null;
530
- canvas.addEventListener("click", (ev) => {
574
+ const handleClick = (ev) => {
531
575
  const mouse = getCanvasPosition(ev);
532
576
  if (hoveredId === null)
533
577
  return;
@@ -536,7 +580,8 @@ const runEngine = async (props) => {
536
580
  if (hovered !== undefined && hovered.isClickable) {
537
581
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
538
582
  }
539
- });
583
+ };
584
+ canvas.addEventListener("click", handleClick);
540
585
  const initialState = {
541
586
  keyboardState: createRecord(keyboardKeys, () => false),
542
587
  };
@@ -546,7 +591,7 @@ const runEngine = async (props) => {
546
591
  const currentState = {
547
592
  keyboardState: { ...initialState.keyboardState },
548
593
  };
549
- canvas.addEventListener("keydown", (event) => {
594
+ const handleKeyDown = (event) => {
550
595
  const pressedKey = keyboardKeys.find((key) => key === event.code);
551
596
  if (pressedKey !== undefined) {
552
597
  // Stop tracked keys (arrows, space, ...) from also scrolling the
@@ -555,15 +600,17 @@ const runEngine = async (props) => {
555
600
  event.preventDefault();
556
601
  currentState.keyboardState[pressedKey] = true;
557
602
  }
558
- });
559
- canvas.addEventListener("keyup", (event) => {
603
+ };
604
+ canvas.addEventListener("keydown", handleKeyDown);
605
+ const handleKeyUp = (event) => {
560
606
  const releasedKey = keyboardKeys.find((key) => key === event.code);
561
607
  if (releasedKey !== undefined) {
562
608
  event.preventDefault();
563
609
  currentState.keyboardState[releasedKey] = false;
564
610
  }
565
- });
566
- canvas.addEventListener("mousemove", (ev) => {
611
+ };
612
+ canvas.addEventListener("keyup", handleKeyUp);
613
+ const handleMouseMoveHover = (ev) => {
567
614
  const mouse = getCanvasPosition(ev);
568
615
  const { renderables, camera } = renderState(state);
569
616
  const worldMouse = toWorldPosition(mouse, camera);
@@ -580,8 +627,9 @@ const runEngine = async (props) => {
580
627
  }
581
628
  }
582
629
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
583
- });
584
- canvas.addEventListener("mousemove", (ev) => {
630
+ };
631
+ canvas.addEventListener("mousemove", handleMouseMoveHover);
632
+ const handleMouseMoveTracking = (ev) => {
585
633
  const mouse = getCanvasPosition(ev);
586
634
  if (hoveredId === null) {
587
635
  return;
@@ -591,7 +639,33 @@ const runEngine = async (props) => {
591
639
  if (hovered !== undefined && hovered.trackMouseMovement) {
592
640
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
593
641
  }
594
- });
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);
595
669
  context.imageSmoothingEnabled = false;
596
670
  // Scale is a canvas transform around the renderable's anchor, applied
597
671
  // before its type-specific drawing runs below — everything drawn under
@@ -642,6 +716,56 @@ const runEngine = async (props) => {
642
716
  tintBufferContext.globalCompositeOperation = "source-over";
643
717
  return tintBuffer;
644
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
+ };
645
769
  const intervalId = setInterval(() => {
646
770
  const now = Date.now();
647
771
  const delta = now - lastFrame;
@@ -712,7 +836,7 @@ const runEngine = async (props) => {
712
836
  continue;
713
837
  }
714
838
  if (renderable.type === "SPRITE") {
715
- const { opacity = 1, flipX = false, modulate } = renderable;
839
+ const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
716
840
  const resource = resourceById[renderable.resourceId];
717
841
  const frame = {
718
842
  x: renderable.frame % resource.slices.horizontal,
@@ -727,12 +851,23 @@ const runEngine = async (props) => {
727
851
  };
728
852
  const destWidth = resource.size.width;
729
853
  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 };
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
+ }
736
871
  context.globalAlpha = opacity;
737
872
  const drawSprite = (destX, destY) => {
738
873
  context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
@@ -783,6 +918,32 @@ const runEngine = async (props) => {
783
918
  }, 0);
784
919
  resetCanvas = () => {
785
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);
786
947
  };
787
948
  return { sendEvent };
788
949
  };
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.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",