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 +22 -3
- package/lib/engine/types.d.ts +27 -2
- package/lib/index.cjs +184 -23
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +184 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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`, `
|
|
87
|
-
`CUSTOM` event of a type you define yourself, for
|
|
88
|
-
an async `fetch()` resolving back into your
|
|
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)
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -57,6 +57,10 @@ export type SpriteRenderable = BaseRenderable & {
|
|
|
57
57
|
frame: number;
|
|
58
58
|
opacity?: number;
|
|
59
59
|
flipX?: boolean;
|
|
60
|
+
swapColors?: {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
}[];
|
|
60
64
|
};
|
|
61
65
|
export type LineRenderable = BaseRenderable & {
|
|
62
66
|
type: "LINE";
|
|
@@ -83,6 +87,10 @@ export type AnimatedSpriteRenderable = BaseRenderable & {
|
|
|
83
87
|
paused?: boolean;
|
|
84
88
|
opacity?: number;
|
|
85
89
|
flipX?: boolean;
|
|
90
|
+
swapColors?: {
|
|
91
|
+
from: string;
|
|
92
|
+
to: string;
|
|
93
|
+
}[];
|
|
86
94
|
id: string;
|
|
87
95
|
};
|
|
88
96
|
export type GroupRenderable = BaseRenderable & {
|
|
@@ -145,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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",
|
|
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
|
-
//
|
|
733
|
-
// as the image to draw
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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",
|
|
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
|
-
//
|
|
731
|
-
// as the image to draw
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
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