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 +26 -3
- package/lib/engine/types.d.ts +34 -2
- package/lib/index.cjs +228 -26
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +228 -26
- 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
|
|
@@ -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)
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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",
|
|
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
|
-
//
|
|
733
|
-
// as the image to draw
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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",
|
|
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
|
-
//
|
|
731
|
-
// as the image to draw
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
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