yuuna-engine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 — Yuuna owns the render
13
- loop, input handling, and canvas drawing.
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; credited here by choice,
156
- not requirement.
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
 
@@ -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,14 @@ export type RunEngineProps<State, Custom = never> = {
246
247
  y: number;
247
248
  zoom: number;
248
249
  };
250
+ timeScale?: (state: State) => number;
251
+ maxFps?: (state: State) => number;
249
252
  };
250
253
  export type RunEngineFunction = <State, Custom = never>(props: RunEngineProps<State, Custom>) => Promise<{
251
254
  sendEvent: (event: Custom) => void;
252
255
  requestFullscreen: () => Promise<void>;
253
256
  exitFullscreen: () => Promise<void>;
257
+ addResource: (id: string, resource: ResourceConfig) => Promise<void>;
254
258
  }>;
255
259
  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
260
  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, async ({ value }) => new Promise((resolve) => {
250
- const image = new Image();
251
- // A single, unsliced image (1x1) if unset only an actual
252
- // spritesheet needs this declared.
253
- const slices = value.slices ?? { horizontal: 1, vertical: 1 };
254
- // sheetSize is the whole loaded sheet's pixel dimensions — value's
255
- // declared `size` if set, otherwise whatever the image actually
256
- // measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
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 { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
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
- const timeScale = renderable.timeScale ?? 1;
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);
@@ -658,7 +680,18 @@ const runEngine = async (props) => {
658
680
  };
659
681
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
660
682
  let lastFrame = Date.now();
683
+ // When the next tick is due under RunEngineProps.maxFps — only read
684
+ // or advanced while a cap is set (see the top of the loop below).
685
+ let nextTickAt = 0;
661
686
  let hoveredId = null;
687
+ // How much this tick's simulated time is scaled by (see
688
+ // RunEngineProps.timeScale) — set once per tick, right before that
689
+ // tick's own TIME event delta is computed, and read again later the
690
+ // same tick by resolveAnimatedSprite (below) so a single number
691
+ // governs both game logic and animation playback consistently within
692
+ // one tick rather than each recomputing props.timeScale(state)
693
+ // separately (state may itself have just changed this same tick).
694
+ let currentTimeScale = 1;
662
695
  // Shared by the mouse "click" listener and the touch handlers below —
663
696
  // firing a CLICK is the same "is whatever's currently hovered
664
697
  // isClickable" check either way; only how `mouse` was determined
@@ -971,7 +1004,35 @@ const runEngine = async (props) => {
971
1004
  };
972
1005
  const intervalId = setInterval(() => {
973
1006
  const now = Date.now();
974
- const delta = now - lastFrame;
1007
+ const rawDelta = now - lastFrame;
1008
+ // Skipped rather than delayed (see RunEngineProps.maxFps) — returning
1009
+ // before anything else runs leaves `events`, keyboard/mouse state and
1010
+ // lastFrame all untouched, so whatever happened in the meantime is
1011
+ // still delivered by the next tick that does run, and its TIME delta
1012
+ // covers this skipped one too.
1013
+ const maxFps = props.maxFps?.(state);
1014
+ if (maxFps !== undefined && maxFps > 0) {
1015
+ if (now < nextTickAt) {
1016
+ return;
1017
+ }
1018
+ // Scheduled from the previous target rather than from `now`: the
1019
+ // interval only fires every ~4ms, so "at least 1000/maxFps since
1020
+ // the last tick" would always round late (a 60 cap landing on
1021
+ // 20ms ticks, i.e. 50 FPS), while this lets the early and late
1022
+ // ticks average out to the cap itself. Falling more than a whole
1023
+ // interval behind (a hitch, a backgrounded tab, the cap having
1024
+ // just been turned on) restarts the schedule from now instead of
1025
+ // bursting through every missed tick to catch up.
1026
+ const interval = 1000 / maxFps;
1027
+ nextTickAt = now - nextTickAt > interval ? now + interval : nextTickAt + interval;
1028
+ }
1029
+ // Read from state as this tick starts (i.e. still last tick's own
1030
+ // result) — resolveAnimatedSprite (below, during this same tick's
1031
+ // render pass) reads this same currentTimeScale rather than calling
1032
+ // props.timeScale itself, so a single value governs both the TIME
1033
+ // event about to fire and every animation's playback consistently.
1034
+ currentTimeScale = props.timeScale?.(state) ?? 1;
1035
+ const delta = rawDelta * currentTimeScale;
975
1036
  events.push({ tag: "TIME", delta: delta });
976
1037
  for (const event of events) {
977
1038
  const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
@@ -1164,7 +1225,7 @@ const runEngine = async (props) => {
1164
1225
  // Same pile-up risk as the canvas listeners above, but on `window`.
1165
1226
  window.removeEventListener("resize", applyResize);
1166
1227
  };
1167
- return { sendEvent, requestFullscreen, exitFullscreen };
1228
+ return { sendEvent, requestFullscreen, exitFullscreen, addResource };
1168
1229
  };
1169
1230
 
1170
1231
  // 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, async ({ value }) => new Promise((resolve) => {
248
- const image = new Image();
249
- // A single, unsliced image (1x1) if unset only an actual
250
- // spritesheet needs this declared.
251
- const slices = value.slices ?? { horizontal: 1, vertical: 1 };
252
- // sheetSize is the whole loaded sheet's pixel dimensions — value's
253
- // declared `size` if set, otherwise whatever the image actually
254
- // measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
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 { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
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
- const timeScale = renderable.timeScale ?? 1;
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);
@@ -656,7 +678,18 @@ const runEngine = async (props) => {
656
678
  };
657
679
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
658
680
  let lastFrame = Date.now();
681
+ // When the next tick is due under RunEngineProps.maxFps — only read
682
+ // or advanced while a cap is set (see the top of the loop below).
683
+ let nextTickAt = 0;
659
684
  let hoveredId = null;
685
+ // How much this tick's simulated time is scaled by (see
686
+ // RunEngineProps.timeScale) — set once per tick, right before that
687
+ // tick's own TIME event delta is computed, and read again later the
688
+ // same tick by resolveAnimatedSprite (below) so a single number
689
+ // governs both game logic and animation playback consistently within
690
+ // one tick rather than each recomputing props.timeScale(state)
691
+ // separately (state may itself have just changed this same tick).
692
+ let currentTimeScale = 1;
660
693
  // Shared by the mouse "click" listener and the touch handlers below —
661
694
  // firing a CLICK is the same "is whatever's currently hovered
662
695
  // isClickable" check either way; only how `mouse` was determined
@@ -969,7 +1002,35 @@ const runEngine = async (props) => {
969
1002
  };
970
1003
  const intervalId = setInterval(() => {
971
1004
  const now = Date.now();
972
- const delta = now - lastFrame;
1005
+ const rawDelta = now - lastFrame;
1006
+ // Skipped rather than delayed (see RunEngineProps.maxFps) — returning
1007
+ // before anything else runs leaves `events`, keyboard/mouse state and
1008
+ // lastFrame all untouched, so whatever happened in the meantime is
1009
+ // still delivered by the next tick that does run, and its TIME delta
1010
+ // covers this skipped one too.
1011
+ const maxFps = props.maxFps?.(state);
1012
+ if (maxFps !== undefined && maxFps > 0) {
1013
+ if (now < nextTickAt) {
1014
+ return;
1015
+ }
1016
+ // Scheduled from the previous target rather than from `now`: the
1017
+ // interval only fires every ~4ms, so "at least 1000/maxFps since
1018
+ // the last tick" would always round late (a 60 cap landing on
1019
+ // 20ms ticks, i.e. 50 FPS), while this lets the early and late
1020
+ // ticks average out to the cap itself. Falling more than a whole
1021
+ // interval behind (a hitch, a backgrounded tab, the cap having
1022
+ // just been turned on) restarts the schedule from now instead of
1023
+ // bursting through every missed tick to catch up.
1024
+ const interval = 1000 / maxFps;
1025
+ nextTickAt = now - nextTickAt > interval ? now + interval : nextTickAt + interval;
1026
+ }
1027
+ // Read from state as this tick starts (i.e. still last tick's own
1028
+ // result) — resolveAnimatedSprite (below, during this same tick's
1029
+ // render pass) reads this same currentTimeScale rather than calling
1030
+ // props.timeScale itself, so a single value governs both the TIME
1031
+ // event about to fire and every animation's playback consistently.
1032
+ currentTimeScale = props.timeScale?.(state) ?? 1;
1033
+ const delta = rawDelta * currentTimeScale;
973
1034
  events.push({ tag: "TIME", delta: delta });
974
1035
  for (const event of events) {
975
1036
  const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
@@ -1162,7 +1223,7 @@ const runEngine = async (props) => {
1162
1223
  // Same pile-up risk as the canvas listeners above, but on `window`.
1163
1224
  window.removeEventListener("resize", applyResize);
1164
1225
  };
1165
- return { sendEvent, requestFullscreen, exitFullscreen };
1226
+ return { sendEvent, requestFullscreen, exitFullscreen, addResource };
1166
1227
  };
1167
1228
 
1168
1229
  // 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuuna-engine",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A lightweight, state-machine-based TypeScript game engine for quick prototypes. Runs directly in the browser.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",