yuuna-engine 0.7.0 → 0.8.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 +21 -4
- package/lib/engine/types.d.ts +19 -16
- package/lib/index.cjs +73 -36
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +73 -36
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
A lightweight, state-machine-based TypeScript game engine — drop it into a
|
|
10
10
|
page and it's running, no editor or build step required. You describe your
|
|
11
11
|
game as a `state`, a `render(state)` function, and a
|
|
12
|
-
`nextState({ state, event, keyboard })` function —
|
|
13
|
-
|
|
12
|
+
`nextState({ state, event, keyboard })` function — the reducer pattern
|
|
13
|
+
(`(state, event) => nextState`, same shape as a Redux reducer or
|
|
14
|
+
`useReducer`) — and Yuuna owns the render loop, input handling, and canvas
|
|
15
|
+
drawing.
|
|
14
16
|
|
|
15
17
|
## Install
|
|
16
18
|
|
|
@@ -90,6 +92,10 @@ runEngine<GameState>({
|
|
|
90
92
|
canvas config, and mechanics pipelines** all follow the same idea:
|
|
91
93
|
small, focused props and functions `runEngine`/`nextState` take, that
|
|
92
94
|
compose with everything above instead of replacing it.
|
|
95
|
+
- **Mechanics pipelines** — `nextState` can be an array of small reducers
|
|
96
|
+
instead of one big function; each runs in order per event, the way
|
|
97
|
+
Redux middleware chains do, and any of them can return `STOP` to end
|
|
98
|
+
the pipeline early for that event.
|
|
93
99
|
|
|
94
100
|
This README stays intentionally thin — the full concept-by-concept
|
|
95
101
|
reference, with every option and example, lives on the
|
|
@@ -148,12 +154,23 @@ run without them — just with placeholder art in place of the real
|
|
|
148
154
|
thing. Drop the real files in locally (or restore them from wherever
|
|
149
155
|
you got this repo from) to see them for real.
|
|
150
156
|
|
|
157
|
+
Only MIT- or CC0-licensed assets are used, so nothing here needs
|
|
158
|
+
attribution to run or redistribute — the credits below are given by
|
|
159
|
+
choice, not requirement.
|
|
160
|
+
|
|
151
161
|
Currently used:
|
|
152
162
|
|
|
153
163
|
- **[Free Pixel Food!](https://henrysoftware.itch.io/pixel-food)** by
|
|
154
164
|
[Henry Software](https://henrysoftware.itch.io/) — the food icons in
|
|
155
|
-
the Food Clicker and Sprites examples. CC0
|
|
156
|
-
|
|
165
|
+
the Food Clicker and Sprites examples. CC0.
|
|
166
|
+
- **[Sunny Land Pixel Game Art](https://ansimuz.itch.io/sunny-land-pixel-game-art)**
|
|
167
|
+
by [ansimuz](https://ansimuz.itch.io/) — the fox (idle/walk/jump), the
|
|
168
|
+
ground/platform tile, and the parallax sky background in the
|
|
169
|
+
Platformer example. CC0.
|
|
170
|
+
- **[Mini Pixel Pack 3](https://grafxkid.itch.io/mini-pixel-pack-3)** by
|
|
171
|
+
[GrafxKid](https://grafxkid.itch.io/) — the ship, the charged-beam
|
|
172
|
+
bullet, Alan (the enemy), and the parallax starfield in the Shoot Em
|
|
173
|
+
Up example. CC0.
|
|
157
174
|
|
|
158
175
|
## License
|
|
159
176
|
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -204,6 +204,22 @@ export type NextStateProps<State, Custom = never> = {
|
|
|
204
204
|
};
|
|
205
205
|
export declare const STOP: "Yuuna.STOP";
|
|
206
206
|
export type NextStateFunction<State, Custom = never> = (props: NextStateProps<State, Custom>) => State | typeof STOP | undefined;
|
|
207
|
+
export type ResourceConfig = {
|
|
208
|
+
src: string;
|
|
209
|
+
size?: {
|
|
210
|
+
width: number;
|
|
211
|
+
height: number;
|
|
212
|
+
};
|
|
213
|
+
slices?: {
|
|
214
|
+
vertical: number;
|
|
215
|
+
horizontal: number;
|
|
216
|
+
};
|
|
217
|
+
animations?: Record<string, {
|
|
218
|
+
frames: number[];
|
|
219
|
+
frameDuration: number;
|
|
220
|
+
loop: boolean;
|
|
221
|
+
}>;
|
|
222
|
+
};
|
|
207
223
|
export type RunEngineProps<State, Custom = never> = {
|
|
208
224
|
initialState: State;
|
|
209
225
|
render: (state: State) => {
|
|
@@ -211,22 +227,7 @@ export type RunEngineProps<State, Custom = never> = {
|
|
|
211
227
|
renderables: Renderable[];
|
|
212
228
|
};
|
|
213
229
|
nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
|
|
214
|
-
resources?: Record<string,
|
|
215
|
-
src: string;
|
|
216
|
-
size?: {
|
|
217
|
-
width: number;
|
|
218
|
-
height: number;
|
|
219
|
-
};
|
|
220
|
-
slices?: {
|
|
221
|
-
vertical: number;
|
|
222
|
-
horizontal: number;
|
|
223
|
-
};
|
|
224
|
-
animations?: Record<string, {
|
|
225
|
-
frames: number[];
|
|
226
|
-
frameDuration: number;
|
|
227
|
-
loop: boolean;
|
|
228
|
-
}>;
|
|
229
|
-
}>;
|
|
230
|
+
resources?: Record<string, ResourceConfig>;
|
|
230
231
|
sounds?: Record<string, {
|
|
231
232
|
src: string;
|
|
232
233
|
}>;
|
|
@@ -246,11 +247,13 @@ export type RunEngineProps<State, Custom = never> = {
|
|
|
246
247
|
y: number;
|
|
247
248
|
zoom: number;
|
|
248
249
|
};
|
|
250
|
+
timeScale?: (state: State) => number;
|
|
249
251
|
};
|
|
250
252
|
export type RunEngineFunction = <State, Custom = never>(props: RunEngineProps<State, Custom>) => Promise<{
|
|
251
253
|
sendEvent: (event: Custom) => void;
|
|
252
254
|
requestFullscreen: () => Promise<void>;
|
|
253
255
|
exitFullscreen: () => Promise<void>;
|
|
256
|
+
addResource: (id: string, resource: ResourceConfig) => Promise<void>;
|
|
254
257
|
}>;
|
|
255
258
|
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"];
|
|
256
259
|
export type KeyboardKeys = (typeof keyboardKeys)[number];
|
package/lib/index.cjs
CHANGED
|
@@ -155,6 +155,42 @@ const createPlaceholderSheet = (size) => {
|
|
|
155
155
|
// all. An arbitrary, small-but-visible size, purely so the placeholder
|
|
156
156
|
// still draws as *something* instead of a 0x0/NaN canvas.
|
|
157
157
|
const DEFAULT_PLACEHOLDER_SIZE = { width: 64, height: 64 };
|
|
158
|
+
// Loads a single resources[id] entry into a ResourceEntry — shared by the
|
|
159
|
+
// startup resources (below) and addResource (returned from runEngine, at
|
|
160
|
+
// the bottom of this file) so a resource registered later loads exactly
|
|
161
|
+
// the same way, placeholder fallback included.
|
|
162
|
+
const loadResource = (value) => new Promise((resolve) => {
|
|
163
|
+
const image = new Image();
|
|
164
|
+
// A single, unsliced image (1x1) if unset — only an actual
|
|
165
|
+
// spritesheet needs this declared.
|
|
166
|
+
const slices = value.slices ?? { horizontal: 1, vertical: 1 };
|
|
167
|
+
// sheetSize is the whole loaded sheet's pixel dimensions — value's
|
|
168
|
+
// declared `size` if set, otherwise whatever the image actually
|
|
169
|
+
// measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
|
|
170
|
+
// that isn't available, i.e. no `size` declared *and* the image
|
|
171
|
+
// failed to load too).
|
|
172
|
+
const settle = (loadedImage, sheetSize) => {
|
|
173
|
+
resolve({
|
|
174
|
+
image: loadedImage,
|
|
175
|
+
size: {
|
|
176
|
+
width: sheetSize.width / slices.horizontal,
|
|
177
|
+
height: sheetSize.height / slices.vertical,
|
|
178
|
+
},
|
|
179
|
+
slices,
|
|
180
|
+
animations: value.animations ?? {},
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
image.src = value.src;
|
|
184
|
+
image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
|
|
185
|
+
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
186
|
+
// note) — a placeholder sheet, sized to match what this resource
|
|
187
|
+
// declared, keeps every frame/slice/animation index the caller
|
|
188
|
+
// already computes valid instead of drawing nothing or throwing.
|
|
189
|
+
image.onerror = () => {
|
|
190
|
+
const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
|
|
191
|
+
settle(createPlaceholderSheet(placeholderSize), placeholderSize);
|
|
192
|
+
};
|
|
193
|
+
});
|
|
158
194
|
const runEngine = async (props) => {
|
|
159
195
|
const runId = ++latestRunId;
|
|
160
196
|
resetCanvas?.();
|
|
@@ -246,38 +282,14 @@ const runEngine = async (props) => {
|
|
|
246
282
|
events.push({ tag: "CUSTOM", event });
|
|
247
283
|
};
|
|
248
284
|
const resources = props.resources ?? {};
|
|
249
|
-
const resourceById = await iterateRecordAsync(resources,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// that isn't available, i.e. no `size` declared *and* the image
|
|
258
|
-
// failed to load too).
|
|
259
|
-
const settle = (loadedImage, sheetSize) => {
|
|
260
|
-
resolve({
|
|
261
|
-
image: loadedImage,
|
|
262
|
-
size: {
|
|
263
|
-
width: sheetSize.width / slices.horizontal,
|
|
264
|
-
height: sheetSize.height / slices.vertical,
|
|
265
|
-
},
|
|
266
|
-
slices,
|
|
267
|
-
animations: value.animations ?? {},
|
|
268
|
-
});
|
|
269
|
-
};
|
|
270
|
-
image.src = value.src;
|
|
271
|
-
image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
|
|
272
|
-
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
273
|
-
// note) — a placeholder sheet, sized to match what this resource
|
|
274
|
-
// declared, keeps every frame/slice/animation index the example
|
|
275
|
-
// already computes valid instead of drawing nothing or throwing.
|
|
276
|
-
image.onerror = () => {
|
|
277
|
-
const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
|
|
278
|
-
settle(createPlaceholderSheet(placeholderSize), placeholderSize);
|
|
279
|
-
};
|
|
280
|
-
}));
|
|
285
|
+
const resourceById = await iterateRecordAsync(resources, ({ value }) => loadResource(value));
|
|
286
|
+
// Registers a resource after startup — loads it exactly like the
|
|
287
|
+
// resources above (loadResource is the same function), then adds it to
|
|
288
|
+
// the same resourceById map every SPRITE/ANIMATED_SPRITE lookup already
|
|
289
|
+
// reads from, so it's usable by `resourceId` as soon as this resolves.
|
|
290
|
+
const addResource = async (id, resource) => {
|
|
291
|
+
resourceById[id] = await loadResource(resource);
|
|
292
|
+
};
|
|
281
293
|
const loadAudio = (src) => new Promise((resolve) => {
|
|
282
294
|
const audio = new Audio(src);
|
|
283
295
|
audio.oncanplaythrough = function () {
|
|
@@ -357,7 +369,12 @@ const runEngine = async (props) => {
|
|
|
357
369
|
// fullscreen functions are no-ops here since this run never gets far
|
|
358
370
|
// enough to own the canvas — the newer run's are the ones that matter.
|
|
359
371
|
if (runId !== latestRunId) {
|
|
360
|
-
return {
|
|
372
|
+
return {
|
|
373
|
+
sendEvent,
|
|
374
|
+
requestFullscreen: () => Promise.resolve(),
|
|
375
|
+
exitFullscreen: () => Promise.resolve(),
|
|
376
|
+
addResource,
|
|
377
|
+
};
|
|
361
378
|
}
|
|
362
379
|
context.imageSmoothingEnabled = false;
|
|
363
380
|
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
@@ -445,7 +462,12 @@ const runEngine = async (props) => {
|
|
|
445
462
|
seenAnimationIds.add(renderable.id);
|
|
446
463
|
const resource = resourceById[renderable.resourceId];
|
|
447
464
|
const animation = resource.animations[renderable.animation];
|
|
448
|
-
|
|
465
|
+
// Composed with RunEngineProps.timeScale (currentTimeScale, set once
|
|
466
|
+
// per tick below) — this renderable's own speed multiplied by
|
|
467
|
+
// whatever the whole game's currently running at, so a global 2x/4x/
|
|
468
|
+
// stop control speeds up (or freezes) every animation right along
|
|
469
|
+
// with the rest of the simulation, not just movement/timers.
|
|
470
|
+
const timeScale = (renderable.timeScale ?? 1) * currentTimeScale;
|
|
449
471
|
const paused = renderable.paused ?? false;
|
|
450
472
|
const now = Date.now();
|
|
451
473
|
const tracked = animationStateById.get(renderable.id);
|
|
@@ -659,6 +681,14 @@ const runEngine = async (props) => {
|
|
|
659
681
|
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
660
682
|
let lastFrame = Date.now();
|
|
661
683
|
let hoveredId = null;
|
|
684
|
+
// How much this tick's simulated time is scaled by (see
|
|
685
|
+
// RunEngineProps.timeScale) — set once per tick, right before that
|
|
686
|
+
// tick's own TIME event delta is computed, and read again later the
|
|
687
|
+
// same tick by resolveAnimatedSprite (below) so a single number
|
|
688
|
+
// governs both game logic and animation playback consistently within
|
|
689
|
+
// one tick rather than each recomputing props.timeScale(state)
|
|
690
|
+
// separately (state may itself have just changed this same tick).
|
|
691
|
+
let currentTimeScale = 1;
|
|
662
692
|
// Shared by the mouse "click" listener and the touch handlers below —
|
|
663
693
|
// firing a CLICK is the same "is whatever's currently hovered
|
|
664
694
|
// isClickable" check either way; only how `mouse` was determined
|
|
@@ -971,7 +1001,14 @@ const runEngine = async (props) => {
|
|
|
971
1001
|
};
|
|
972
1002
|
const intervalId = setInterval(() => {
|
|
973
1003
|
const now = Date.now();
|
|
974
|
-
const
|
|
1004
|
+
const rawDelta = now - lastFrame;
|
|
1005
|
+
// Read from state as this tick starts (i.e. still last tick's own
|
|
1006
|
+
// result) — resolveAnimatedSprite (below, during this same tick's
|
|
1007
|
+
// render pass) reads this same currentTimeScale rather than calling
|
|
1008
|
+
// props.timeScale itself, so a single value governs both the TIME
|
|
1009
|
+
// event about to fire and every animation's playback consistently.
|
|
1010
|
+
currentTimeScale = props.timeScale?.(state) ?? 1;
|
|
1011
|
+
const delta = rawDelta * currentTimeScale;
|
|
975
1012
|
events.push({ tag: "TIME", delta: delta });
|
|
976
1013
|
for (const event of events) {
|
|
977
1014
|
const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
|
|
@@ -1164,7 +1201,7 @@ const runEngine = async (props) => {
|
|
|
1164
1201
|
// Same pile-up risk as the canvas listeners above, but on `window`.
|
|
1165
1202
|
window.removeEventListener("resize", applyResize);
|
|
1166
1203
|
};
|
|
1167
|
-
return { sendEvent, requestFullscreen, exitFullscreen };
|
|
1204
|
+
return { sendEvent, requestFullscreen, exitFullscreen, addResource };
|
|
1168
1205
|
};
|
|
1169
1206
|
|
|
1170
1207
|
// One factory per Renderable variant — literally just `{ type: "X",
|
package/lib/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/lib/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ import { runEngine } from "./engine/runEngine";
|
|
|
2
2
|
import { STOP } from "./engine/types";
|
|
3
3
|
import { animatedSprite, circle, group, line, rectangle, sprite, text } from "./engine/renderables";
|
|
4
4
|
export { runEngine, STOP, rectangle, circle, text, sprite, animatedSprite, line, group };
|
|
5
|
-
export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";
|
|
5
|
+
export type { NextStateFunction, NextStateProps, Renderable, ResourceConfig, GameEvent, CustomGameEvent, } from "./engine/types";
|
package/lib/index.js
CHANGED
|
@@ -153,6 +153,42 @@ const createPlaceholderSheet = (size) => {
|
|
|
153
153
|
// all. An arbitrary, small-but-visible size, purely so the placeholder
|
|
154
154
|
// still draws as *something* instead of a 0x0/NaN canvas.
|
|
155
155
|
const DEFAULT_PLACEHOLDER_SIZE = { width: 64, height: 64 };
|
|
156
|
+
// Loads a single resources[id] entry into a ResourceEntry — shared by the
|
|
157
|
+
// startup resources (below) and addResource (returned from runEngine, at
|
|
158
|
+
// the bottom of this file) so a resource registered later loads exactly
|
|
159
|
+
// the same way, placeholder fallback included.
|
|
160
|
+
const loadResource = (value) => new Promise((resolve) => {
|
|
161
|
+
const image = new Image();
|
|
162
|
+
// A single, unsliced image (1x1) if unset — only an actual
|
|
163
|
+
// spritesheet needs this declared.
|
|
164
|
+
const slices = value.slices ?? { horizontal: 1, vertical: 1 };
|
|
165
|
+
// sheetSize is the whole loaded sheet's pixel dimensions — value's
|
|
166
|
+
// declared `size` if set, otherwise whatever the image actually
|
|
167
|
+
// measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
|
|
168
|
+
// that isn't available, i.e. no `size` declared *and* the image
|
|
169
|
+
// failed to load too).
|
|
170
|
+
const settle = (loadedImage, sheetSize) => {
|
|
171
|
+
resolve({
|
|
172
|
+
image: loadedImage,
|
|
173
|
+
size: {
|
|
174
|
+
width: sheetSize.width / slices.horizontal,
|
|
175
|
+
height: sheetSize.height / slices.vertical,
|
|
176
|
+
},
|
|
177
|
+
slices,
|
|
178
|
+
animations: value.animations ?? {},
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
image.src = value.src;
|
|
182
|
+
image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
|
|
183
|
+
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
184
|
+
// note) — a placeholder sheet, sized to match what this resource
|
|
185
|
+
// declared, keeps every frame/slice/animation index the caller
|
|
186
|
+
// already computes valid instead of drawing nothing or throwing.
|
|
187
|
+
image.onerror = () => {
|
|
188
|
+
const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
|
|
189
|
+
settle(createPlaceholderSheet(placeholderSize), placeholderSize);
|
|
190
|
+
};
|
|
191
|
+
});
|
|
156
192
|
const runEngine = async (props) => {
|
|
157
193
|
const runId = ++latestRunId;
|
|
158
194
|
resetCanvas?.();
|
|
@@ -244,38 +280,14 @@ const runEngine = async (props) => {
|
|
|
244
280
|
events.push({ tag: "CUSTOM", event });
|
|
245
281
|
};
|
|
246
282
|
const resources = props.resources ?? {};
|
|
247
|
-
const resourceById = await iterateRecordAsync(resources,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// that isn't available, i.e. no `size` declared *and* the image
|
|
256
|
-
// failed to load too).
|
|
257
|
-
const settle = (loadedImage, sheetSize) => {
|
|
258
|
-
resolve({
|
|
259
|
-
image: loadedImage,
|
|
260
|
-
size: {
|
|
261
|
-
width: sheetSize.width / slices.horizontal,
|
|
262
|
-
height: sheetSize.height / slices.vertical,
|
|
263
|
-
},
|
|
264
|
-
slices,
|
|
265
|
-
animations: value.animations ?? {},
|
|
266
|
-
});
|
|
267
|
-
};
|
|
268
|
-
image.src = value.src;
|
|
269
|
-
image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
|
|
270
|
-
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
271
|
-
// note) — a placeholder sheet, sized to match what this resource
|
|
272
|
-
// declared, keeps every frame/slice/animation index the example
|
|
273
|
-
// already computes valid instead of drawing nothing or throwing.
|
|
274
|
-
image.onerror = () => {
|
|
275
|
-
const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
|
|
276
|
-
settle(createPlaceholderSheet(placeholderSize), placeholderSize);
|
|
277
|
-
};
|
|
278
|
-
}));
|
|
283
|
+
const resourceById = await iterateRecordAsync(resources, ({ value }) => loadResource(value));
|
|
284
|
+
// Registers a resource after startup — loads it exactly like the
|
|
285
|
+
// resources above (loadResource is the same function), then adds it to
|
|
286
|
+
// the same resourceById map every SPRITE/ANIMATED_SPRITE lookup already
|
|
287
|
+
// reads from, so it's usable by `resourceId` as soon as this resolves.
|
|
288
|
+
const addResource = async (id, resource) => {
|
|
289
|
+
resourceById[id] = await loadResource(resource);
|
|
290
|
+
};
|
|
279
291
|
const loadAudio = (src) => new Promise((resolve) => {
|
|
280
292
|
const audio = new Audio(src);
|
|
281
293
|
audio.oncanplaythrough = function () {
|
|
@@ -355,7 +367,12 @@ const runEngine = async (props) => {
|
|
|
355
367
|
// fullscreen functions are no-ops here since this run never gets far
|
|
356
368
|
// enough to own the canvas — the newer run's are the ones that matter.
|
|
357
369
|
if (runId !== latestRunId) {
|
|
358
|
-
return {
|
|
370
|
+
return {
|
|
371
|
+
sendEvent,
|
|
372
|
+
requestFullscreen: () => Promise.resolve(),
|
|
373
|
+
exitFullscreen: () => Promise.resolve(),
|
|
374
|
+
addResource,
|
|
375
|
+
};
|
|
359
376
|
}
|
|
360
377
|
context.imageSmoothingEnabled = false;
|
|
361
378
|
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
@@ -443,7 +460,12 @@ const runEngine = async (props) => {
|
|
|
443
460
|
seenAnimationIds.add(renderable.id);
|
|
444
461
|
const resource = resourceById[renderable.resourceId];
|
|
445
462
|
const animation = resource.animations[renderable.animation];
|
|
446
|
-
|
|
463
|
+
// Composed with RunEngineProps.timeScale (currentTimeScale, set once
|
|
464
|
+
// per tick below) — this renderable's own speed multiplied by
|
|
465
|
+
// whatever the whole game's currently running at, so a global 2x/4x/
|
|
466
|
+
// stop control speeds up (or freezes) every animation right along
|
|
467
|
+
// with the rest of the simulation, not just movement/timers.
|
|
468
|
+
const timeScale = (renderable.timeScale ?? 1) * currentTimeScale;
|
|
447
469
|
const paused = renderable.paused ?? false;
|
|
448
470
|
const now = Date.now();
|
|
449
471
|
const tracked = animationStateById.get(renderable.id);
|
|
@@ -657,6 +679,14 @@ const runEngine = async (props) => {
|
|
|
657
679
|
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
658
680
|
let lastFrame = Date.now();
|
|
659
681
|
let hoveredId = null;
|
|
682
|
+
// How much this tick's simulated time is scaled by (see
|
|
683
|
+
// RunEngineProps.timeScale) — set once per tick, right before that
|
|
684
|
+
// tick's own TIME event delta is computed, and read again later the
|
|
685
|
+
// same tick by resolveAnimatedSprite (below) so a single number
|
|
686
|
+
// governs both game logic and animation playback consistently within
|
|
687
|
+
// one tick rather than each recomputing props.timeScale(state)
|
|
688
|
+
// separately (state may itself have just changed this same tick).
|
|
689
|
+
let currentTimeScale = 1;
|
|
660
690
|
// Shared by the mouse "click" listener and the touch handlers below —
|
|
661
691
|
// firing a CLICK is the same "is whatever's currently hovered
|
|
662
692
|
// isClickable" check either way; only how `mouse` was determined
|
|
@@ -969,7 +999,14 @@ const runEngine = async (props) => {
|
|
|
969
999
|
};
|
|
970
1000
|
const intervalId = setInterval(() => {
|
|
971
1001
|
const now = Date.now();
|
|
972
|
-
const
|
|
1002
|
+
const rawDelta = now - lastFrame;
|
|
1003
|
+
// Read from state as this tick starts (i.e. still last tick's own
|
|
1004
|
+
// result) — resolveAnimatedSprite (below, during this same tick's
|
|
1005
|
+
// render pass) reads this same currentTimeScale rather than calling
|
|
1006
|
+
// props.timeScale itself, so a single value governs both the TIME
|
|
1007
|
+
// event about to fire and every animation's playback consistently.
|
|
1008
|
+
currentTimeScale = props.timeScale?.(state) ?? 1;
|
|
1009
|
+
const delta = rawDelta * currentTimeScale;
|
|
973
1010
|
events.push({ tag: "TIME", delta: delta });
|
|
974
1011
|
for (const event of events) {
|
|
975
1012
|
const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
|
|
@@ -1162,7 +1199,7 @@ const runEngine = async (props) => {
|
|
|
1162
1199
|
// Same pile-up risk as the canvas listeners above, but on `window`.
|
|
1163
1200
|
window.removeEventListener("resize", applyResize);
|
|
1164
1201
|
};
|
|
1165
|
-
return { sendEvent, requestFullscreen, exitFullscreen };
|
|
1202
|
+
return { sendEvent, requestFullscreen, exitFullscreen, addResource };
|
|
1166
1203
|
};
|
|
1167
1204
|
|
|
1168
1205
|
// One factory per Renderable variant — literally just `{ type: "X",
|
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