yuuna-engine 0.2.1 → 0.4.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/lib/index.cjs CHANGED
@@ -107,6 +107,19 @@ const keyboardKeys = [
107
107
 
108
108
  let resetCanvas = null;
109
109
  let latestRunId = 0;
110
+ // Shared between drawing TEXT renderables and hit-testing them for
111
+ // clicks/hovers, so the clickable area always matches what's on screen.
112
+ const DEFAULT_TEXT_FONT_SIZE = 30;
113
+ const textFont = (fontSize) => `${fontSize}px Arial`;
114
+ // Renderables with the same layer keep render()'s order — Array#sort is
115
+ // stable — so `layer` only needs to move something relative to the rest,
116
+ // not say anything about where exactly it lands among equal layers.
117
+ const sortByLayer = (renderables) => [...renderables].sort((a, b) => (a.layer ?? 0) - (b.layer ?? 0));
118
+ // A LINE has no `position`, just two endpoints — `from` anchors its scale
119
+ // the same way `position` anchors every other renderable's.
120
+ const anchorOf = (renderable) => renderable.type === "LINE" ? renderable.from : renderable.position;
121
+ // No RunEngineProps.camera set is the same as one that doesn't pan or zoom.
122
+ const DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
110
123
  const runEngine = async (props) => {
111
124
  const runId = ++latestRunId;
112
125
  resetCanvas?.();
@@ -134,6 +147,14 @@ const runEngine = async (props) => {
134
147
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
135
148
  canvas.tabIndex = 0;
136
149
  let state = props.initialState;
150
+ const events = [];
151
+ // Lets a caller report something that happened outside the render loop
152
+ // (e.g. a fetch().then() callback) back into it — the event is queued
153
+ // here and delivered to nextState as a CustomGameEvent on the next tick,
154
+ // the same as any built-in event.
155
+ const sendEvent = (event) => {
156
+ events.push({ tag: "CUSTOM", event });
157
+ };
137
158
  const resources = props.resources ?? {};
138
159
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
139
160
  const image = new Image();
@@ -146,28 +167,260 @@ const runEngine = async (props) => {
146
167
  height: value.size.height / value.slices.vertical,
147
168
  },
148
169
  slices: value.slices,
170
+ animations: value.animations ?? {},
149
171
  });
150
172
  };
151
173
  }));
174
+ const loadAudio = (src) => new Promise((resolve) => {
175
+ const audio = new Audio(src);
176
+ audio.oncanplaythrough = function () {
177
+ resolve(audio);
178
+ };
179
+ });
180
+ const sounds = props.sounds ?? {};
181
+ const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
182
+ // Cloning the loaded element per play (instead of reusing it directly)
183
+ // lets the same sound overlap itself — e.g. rapid clicks each get their
184
+ // own playback instead of restarting/cutting off the previous one.
185
+ const playSound = (id) => {
186
+ const audio = audioById[id];
187
+ if (audio === undefined) {
188
+ return;
189
+ }
190
+ const instance = audio.cloneNode();
191
+ instance.play();
192
+ };
193
+ const music = props.music ?? {};
194
+ const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
195
+ // Registered once per track at load time — fires only for a track
196
+ // whose `loop` is false, since a looping <audio> never reaches "ended"
197
+ // (the browser restarts it before the event would fire).
198
+ for (const id of getKeys(musicById)) {
199
+ musicById[id].addEventListener("ended", () => {
200
+ events.push({ tag: "MUSIC_END", id });
201
+ });
202
+ }
203
+ // Unlike sounds, music reuses the same element instead of cloning it —
204
+ // there's only ever one track playing, and reusing it is what lets
205
+ // pauseMusic()/playMusic() resume from where playback left off instead
206
+ // of starting over.
207
+ let currentMusic = null;
208
+ // Volume is a property of each HTMLAudioElement, not global — tracked
209
+ // separately here and (re)applied on every playMusic() so switching
210
+ // tracks keeps the volume the game last set instead of resetting to
211
+ // each element's default of 1.
212
+ let musicVolume = 1;
213
+ const playMusic = (id) => {
214
+ const audio = musicById[id];
215
+ if (audio === undefined) {
216
+ return;
217
+ }
218
+ if (currentMusic !== null && currentMusic !== audio) {
219
+ currentMusic.pause();
220
+ }
221
+ audio.loop = music[id]?.loop ?? true;
222
+ audio.volume = musicVolume;
223
+ audio.play();
224
+ currentMusic = audio;
225
+ };
226
+ const pauseMusic = () => {
227
+ currentMusic?.pause();
228
+ };
229
+ const resumeMusic = () => {
230
+ currentMusic?.play();
231
+ };
232
+ const setMusicVolume = (volume) => {
233
+ musicVolume = Math.min(1, Math.max(0, volume));
234
+ if (currentMusic !== null) {
235
+ currentMusic.volume = musicVolume;
236
+ }
237
+ };
152
238
  // A newer runEngine() call started while this one was still loading
153
239
  // resources (e.g. a spritesheet) — abandon this run instead of setting
154
240
  // up a second, orphaned render loop alongside the newer one.
155
241
  if (runId !== latestRunId) {
156
- return;
242
+ return { sendEvent };
157
243
  }
158
244
  context.imageSmoothingEnabled = false;
159
- function getFocusedElement(position, r) {
160
- const isNonInteractable = r.type === "TEXT" ||
161
- r.type === "LINE" ||
245
+ // An offscreen 1x1 canvas used only to resolve a CSS color string (a
246
+ // name, hex, rgb(), hsl(), ...) into concrete RGBA bytes: paint it that
247
+ // color and read the pixel back. Lets modulateColor() below multiply
248
+ // any two colors together without hand-rolling a CSS color parser.
249
+ const colorSwatchContext = window.document.createElement("canvas").getContext("2d");
250
+ // Color strings are almost always the same literal reused every frame,
251
+ // and getImageData is one of the slower canvas operations — cached so a
252
+ // given color only actually gets resolved once.
253
+ const resolvedColorByString = {};
254
+ const resolveColor = (color) => {
255
+ const cached = resolvedColorByString[color];
256
+ if (cached !== undefined) {
257
+ return cached;
258
+ }
259
+ if (colorSwatchContext === null) {
260
+ return [255, 255, 255, 255];
261
+ }
262
+ colorSwatchContext.clearRect(0, 0, 1, 1);
263
+ colorSwatchContext.fillStyle = color;
264
+ colorSwatchContext.fillRect(0, 0, 1, 1);
265
+ const [r, g, b, a] = colorSwatchContext.getImageData(0, 0, 1, 1).data;
266
+ const resolved = [r, g, b, a];
267
+ resolvedColorByString[color] = resolved;
268
+ return resolved;
269
+ };
270
+ // Multiplies two colors channel-by-channel, the same way Godot's
271
+ // `modulate` works — e.g. modulating "white" by "#808080" halves
272
+ // brightness, by "#ff0000" keeps only the red channel.
273
+ const modulateColor = (color, modulate) => {
274
+ const [r1, g1, b1, a1] = resolveColor(color);
275
+ const [r2, g2, b2, a2] = resolveColor(modulate);
276
+ const mixChannel = (c1, c2) => (c1 * c2) / 255;
277
+ return `rgba(${mixChannel(r1, r2)}, ${mixChannel(g1, g2)}, ${mixChannel(b1, b2)}, ${mixChannel(a1, a2) / 255})`;
278
+ };
279
+ // Combines a parent's already-composed modulate with a child's own —
280
+ // undefined means "no tint from this side", so it just passes the
281
+ // other one through instead of multiplying against an implicit white.
282
+ const composeModulate = (parent, own) => {
283
+ if (parent === undefined)
284
+ return own;
285
+ if (own === undefined)
286
+ return parent;
287
+ return modulateColor(parent, own);
288
+ };
289
+ const IDENTITY_TRANSFORM = {
290
+ anchor: { x: 0, y: 0 },
291
+ scale: { x: 1, y: 1 },
292
+ modulate: undefined,
293
+ layer: 0,
294
+ };
295
+ // A child's own position is authored relative to its parent, in the
296
+ // parent's local (unscaled) units — the same way Godot composes
297
+ // Node2D transforms — so it needs both the parent's scale and its
298
+ // accumulated offset applied to land in absolute canvas coordinates.
299
+ const transformPoint = (parent, localPoint) => ({
300
+ x: parent.anchor.x + localPoint.x * parent.scale.x,
301
+ y: parent.anchor.y + localPoint.y * parent.scale.y,
302
+ });
303
+ // Each id's accumulated playback time for its current animation, plus
304
+ // when that was last updated — keyed by id, since an ANIMATED_SPRITE
305
+ // has no state of its own (render() returns brand-new objects every
306
+ // frame). elapsedMs only advances while not paused (see
307
+ // resolveAnimatedSprite), so pausing genuinely stops the clock rather
308
+ // than just freezing which frame gets drawn — unpausing resumes from
309
+ // the same frame instead of skipping ahead by however long it was
310
+ // paused. Accumulating incrementally like this (instead of computing
311
+ // elapsed from an absolute start time every call) is also what makes
312
+ // pausing possible at all, and as a side effect means a `timeScale`
313
+ // that changes over an animation's lifetime scales each tick as it
314
+ // happens rather than retroactively rescaling the whole duration so
315
+ // far. Pruned each renderState() call (see seenAnimationIds there) to
316
+ // only ids actually present that frame, so a game that spawns entities
317
+ // with ever-incrementing ids doesn't leak one entry per entity ever
318
+ // spawned.
319
+ const animationStateById = new Map();
320
+ const seenAnimationIds = new Set();
321
+ // Resolves an ANIMATED_SPRITE down to a plain SPRITE with `frame`
322
+ // computed from how long its current animation has been playing —
323
+ // everything past this point (flattenRenderable's position/scale/
324
+ // modulate/layer composition, hit-testing, drawing) treats the result
325
+ // exactly like any other SPRITE, with no idea ANIMATED_SPRITE exists.
326
+ const resolveAnimatedSprite = (renderable) => {
327
+ seenAnimationIds.add(renderable.id);
328
+ const resource = resourceById[renderable.resourceId];
329
+ const animation = resource.animations[renderable.animation];
330
+ const timeScale = renderable.timeScale ?? 1;
331
+ const paused = renderable.paused ?? false;
332
+ const now = Date.now();
333
+ const tracked = animationStateById.get(renderable.id);
334
+ // A new id, or the same id switching to a different animation, both
335
+ // start that animation over from frame 0 rather than picking up
336
+ // wherever the previous timer happened to be.
337
+ const previous = tracked !== undefined && tracked.animation === renderable.animation
338
+ ? tracked
339
+ : { animation: renderable.animation, elapsedMs: 0, lastUpdateTime: now };
340
+ const elapsedMs = paused
341
+ ? previous.elapsedMs
342
+ : previous.elapsedMs + Math.max(0, now - previous.lastUpdateTime) * timeScale;
343
+ // lastUpdateTime always moves forward, paused or not — otherwise the
344
+ // first tick after unpausing would see a gap stretching back to
345
+ // whenever it was paused, and (dis)count that whole gap as elapsed
346
+ // playback time in one jump.
347
+ animationStateById.set(renderable.id, { animation: renderable.animation, elapsedMs, lastUpdateTime: now });
348
+ const frameCursor = Math.floor(elapsedMs / animation.frameDuration);
349
+ const frameIndex = animation.loop
350
+ ? frameCursor % animation.frames.length
351
+ : Math.min(frameCursor, animation.frames.length - 1);
352
+ return { ...renderable, type: "SPRITE", frame: animation.frames[frameIndex] };
353
+ };
354
+ // Turns a render() tree into the flat list the rest of the engine
355
+ // already knows how to sort/draw/hit-test — each renderable's own
356
+ // position/from/to/scale/modulate/layer replaced by the *effective*
357
+ // (absolute, fully composed with its ancestors') values, and its
358
+ // children peeled off into their own entries in the returned list
359
+ // instead of staying nested. Nothing past this point needs to know
360
+ // parent/child relationships existed at all.
361
+ const flattenRenderable = (renderable, parent) => {
362
+ const resolved = renderable.type === "ANIMATED_SPRITE" ? resolveAnimatedSprite(renderable) : renderable;
363
+ const localScale = resolved.scale ?? { x: 1, y: 1 };
364
+ const scale = { x: parent.scale.x * localScale.x, y: parent.scale.y * localScale.y };
365
+ const modulate = composeModulate(parent.modulate, resolved.modulate);
366
+ const layer = parent.layer + (resolved.layer ?? 0);
367
+ const effective = resolved.type === "LINE"
368
+ ? {
369
+ ...resolved,
370
+ from: transformPoint(parent, resolved.from),
371
+ to: transformPoint(parent, resolved.to),
372
+ scale,
373
+ modulate,
374
+ layer,
375
+ children: undefined,
376
+ }
377
+ : {
378
+ ...resolved,
379
+ position: transformPoint(parent, resolved.position),
380
+ scale,
381
+ modulate,
382
+ layer,
383
+ children: undefined,
384
+ };
385
+ const childTransform = { anchor: anchorOf(effective), scale, modulate, layer };
386
+ const children = (resolved.children ?? []).flatMap((child) => flattenRenderable(child, childTransform));
387
+ return [effective, ...children];
388
+ };
389
+ // parent defaults to the identity transform (used for screen-space
390
+ // renderables, and by everything before the camera existed) — callers
391
+ // that need a different root, like renderState's camera below, pass
392
+ // their own.
393
+ const flattenRenderables = (renderables, parent = IDENTITY_TRANSFORM) => renderables.flatMap((renderable) => flattenRenderable(renderable, parent));
394
+ const getFocusedElement = (position, r) => {
395
+ const isNonInteractable = r.type === "LINE" ||
396
+ r.type === "GROUP" ||
397
+ // Unreachable — flattenRenderable always resolves ANIMATED_SPRITE
398
+ // to a plain SPRITE before hit-testing ever sees one. Listed here
399
+ // (rather than left for exhaust() to catch) so getFocusedElement
400
+ // itself type-checks as exhaustive.
401
+ r.type === "ANIMATED_SPRITE" ||
162
402
  ((r.isHoverable === undefined || !r.isHoverable) &&
163
403
  (r.isClickable === undefined || !r.isClickable));
164
404
  if (isNonInteractable) {
165
405
  return false;
166
406
  }
407
+ // Scale is applied around the renderable's anchor as a draw-time canvas
408
+ // transform (see applyScale below) rather than by inflating its
409
+ // size — so hit-testing does the inverse instead: bring the mouse
410
+ // position into the renderable's own unscaled coordinate space, then
411
+ // run the exact same math below as if scale were untouched. For
412
+ // CIRCLE this also happens to be the standard "is this point inside
413
+ // this ellipse" test, for free, once it's drawing as one.
414
+ const { x: scaleX, y: scaleY } = r.scale ?? { x: 1, y: 1 };
415
+ const anchor = anchorOf(r);
416
+ const localPosition = {
417
+ x: anchor.x + (position.x - anchor.x) / scaleX,
418
+ y: anchor.y + (position.y - anchor.y) / scaleY,
419
+ };
167
420
  if (r.type === "CIRCLE") {
168
421
  const delta = {
169
- x: Math.abs(position.x - r.position.x),
170
- y: Math.abs(position.y - r.position.y),
422
+ x: Math.abs(localPosition.x - r.position.x),
423
+ y: Math.abs(localPosition.y - r.position.y),
171
424
  };
172
425
  const distance = Math.sqrt(delta.x * delta.x + delta.y * delta.y);
173
426
  const isHovered = distance < r.radius;
@@ -179,26 +432,86 @@ const runEngine = async (props) => {
179
432
  x: r.position.x + r.size.width,
180
433
  y: r.position.y + r.size.height,
181
434
  };
182
- const isInsideX = position.x > topLeft.x && position.x < bottomRight.x;
183
- const isInsideY = position.y > topLeft.y && position.y < bottomRight.y;
435
+ const isInsideX = localPosition.x > topLeft.x && localPosition.x < bottomRight.x;
436
+ const isInsideY = localPosition.y > topLeft.y && localPosition.y < bottomRight.y;
184
437
  const isHovered = isInsideX && isInsideY;
185
438
  return isHovered;
186
439
  }
187
440
  if (r.type === "SPRITE") {
188
441
  const topLeft = { x: r.position.x, y: r.position.y };
189
442
  const resource = resourceById[r.resourceId];
190
- const { scale = 1 } = r;
191
443
  const bottomRight = {
192
- x: r.position.x + resource.size.width * scale,
193
- y: r.position.y + resource.size.height * scale,
444
+ x: r.position.x + resource.size.width,
445
+ y: r.position.y + resource.size.height,
194
446
  };
195
- const isInsideX = position.x > topLeft.x && position.x < bottomRight.x;
196
- const isInsideY = position.y > topLeft.y && position.y < bottomRight.y;
447
+ const isInsideX = localPosition.x > topLeft.x && localPosition.x < bottomRight.x;
448
+ const isInsideY = localPosition.y > topLeft.y && localPosition.y < bottomRight.y;
197
449
  const isHovered = isInsideX && isInsideY;
198
450
  return isHovered;
199
451
  }
452
+ if (r.type === "TEXT") {
453
+ // Text has no explicit size, so its clickable area is derived from
454
+ // measuring it the same way it's drawn (see the TEXT branch in the
455
+ // render loop below) — anchored the same way its `align` positions
456
+ // it relative to `position`.
457
+ const fontSize = r.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
458
+ context.font = textFont(fontSize);
459
+ const width = context.measureText(r.text).width;
460
+ const height = fontSize;
461
+ const alignX = r.align?.x ?? "left";
462
+ const left = alignX === "center" ? r.position.x - width / 2 : alignX === "right" ? r.position.x - width : r.position.x;
463
+ const alignY = r.align?.y ?? "top";
464
+ const top = alignY === "middle" ? r.position.y - height / 2 : alignY === "bottom" ? r.position.y - height : r.position.y;
465
+ const isInsideX = localPosition.x > left && localPosition.x < left + width;
466
+ const isInsideY = localPosition.y > top && localPosition.y < top + height;
467
+ return isInsideX && isInsideY;
468
+ }
200
469
  exhaust(r);
201
- }
470
+ };
471
+ // The camera is just another ancestor transform, exactly like a
472
+ // renderable's own parent — { x, y } is the world position mapped to
473
+ // canvas (0, 0), so it's the anchor transformPoint already expects:
474
+ // screen = (world - camera.position) * zoom.
475
+ const cameraTransformOf = (camera) => ({
476
+ anchor: { x: -camera.x * camera.zoom, y: -camera.y * camera.zoom },
477
+ scale: { x: camera.zoom, y: camera.zoom },
478
+ modulate: undefined,
479
+ layer: 0,
480
+ });
481
+ // The inverse of cameraTransformOf, for turning a raw canvas position
482
+ // (mouse) into a world position (worldMouse) — see ClickEvent etc.
483
+ const toWorldPosition = (position, camera) => ({
484
+ x: camera.x + position.x / camera.zoom,
485
+ y: camera.y + position.y / camera.zoom,
486
+ });
487
+ // Every call site needs renderables flattened and in draw order — the
488
+ // mousemove/click handlers below to find whichever's topmost under the
489
+ // mouse, the draw loop to actually draw them that way — so both are
490
+ // applied once here instead of separately wherever props.render() gets
491
+ // called. Screen-space renderables skip the camera entirely (flattened
492
+ // from the identity transform, same as before there was a camera);
493
+ // everything else is flattened as if the camera were its shared parent.
494
+ const renderState = (state) => {
495
+ const result = props.render(state);
496
+ const camera = props.camera?.(state) ?? DEFAULT_CAMERA;
497
+ const cameraTransform = cameraTransformOf(camera);
498
+ const worldRenderables = result.renderables.filter((r) => !r.screenSpace);
499
+ const screenRenderables = result.renderables.filter((r) => r.screenSpace);
500
+ seenAnimationIds.clear();
501
+ const renderables = sortByLayer([
502
+ ...flattenRenderables(worldRenderables, cameraTransform),
503
+ ...flattenRenderables(screenRenderables),
504
+ ]);
505
+ // Anything not seen this pass is no longer being rendered (e.g. the
506
+ // entity it belonged to died) — drop its tracked start time instead
507
+ // of keeping it forever.
508
+ for (const id of animationStateById.keys()) {
509
+ if (!seenAnimationIds.has(id)) {
510
+ animationStateById.delete(id);
511
+ }
512
+ }
513
+ return { cursor: result.cursor, renderables, camera };
514
+ };
202
515
  // ev.offsetX/offsetY are in CSS-rendered pixels, which differ from the
203
516
  // canvas's drawing-buffer resolution whenever it's displayed at a
204
517
  // different size (e.g. scaled down to fit its container). Renderable
@@ -216,15 +529,14 @@ const runEngine = async (props) => {
216
529
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
217
530
  let lastFrame = Date.now();
218
531
  let hoveredId = null;
219
- const events = [];
220
532
  canvas.addEventListener("click", (ev) => {
221
533
  const mouse = getCanvasPosition(ev);
222
534
  if (hoveredId === null)
223
535
  return;
224
- const { renderables } = props.render(state);
536
+ const { renderables, camera } = renderState(state);
225
537
  const hovered = renderables.find((e) => e.id === hoveredId);
226
538
  if (hovered !== undefined && hovered.isClickable) {
227
- events.push({ tag: "CLICK", id: hovered.id, mouse });
539
+ events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
228
540
  }
229
541
  });
230
542
  const initialState = {
@@ -255,17 +567,18 @@ const runEngine = async (props) => {
255
567
  });
256
568
  canvas.addEventListener("mousemove", (ev) => {
257
569
  const mouse = getCanvasPosition(ev);
258
- const { renderables } = props.render(state);
570
+ const { renderables, camera } = renderState(state);
571
+ const worldMouse = toWorldPosition(mouse, camera);
259
572
  const hovered = [...renderables]
260
573
  .reverse()
261
574
  .find((r) => getFocusedElement(mouse, r));
262
575
  if (hovered !== undefined && hovered.isHoverable) {
263
- events.push({ tag: "HOVER_IN", id: hovered.id, mouse });
576
+ events.push({ tag: "HOVER_IN", id: hovered.id, mouse, worldMouse });
264
577
  }
265
578
  if (hoveredId !== null) {
266
579
  const lastHovered = renderables.find((r) => r.id === hoveredId);
267
580
  if (lastHovered !== undefined && lastHovered.id !== hovered?.id) {
268
- events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse });
581
+ events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
269
582
  }
270
583
  }
271
584
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
@@ -275,13 +588,62 @@ const runEngine = async (props) => {
275
588
  if (hoveredId === null) {
276
589
  return;
277
590
  }
278
- const { renderables } = props.render(state);
591
+ const { renderables, camera } = renderState(state);
279
592
  const hovered = renderables.find((r) => r.id === hoveredId);
280
593
  if (hovered !== undefined && hovered.trackMouseMovement) {
281
- events.push({ tag: "MOUSE_MOVE", mouse, id: hovered.id });
594
+ events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
282
595
  }
283
596
  });
284
597
  context.imageSmoothingEnabled = false;
598
+ // Scale is a canvas transform around the renderable's anchor, applied
599
+ // before its type-specific drawing runs below — everything drawn under
600
+ // it (fills, strokes, images, even font size) comes out scaled without
601
+ // each renderable type needing its own size math, and a CIRCLE drawn
602
+ // under a non-uniform scale comes out an ellipse for free.
603
+ const applyScale = (renderable) => {
604
+ const { x: scaleX, y: scaleY } = renderable.scale ?? { x: 1, y: 1 };
605
+ if (scaleX === 1 && scaleY === 1) {
606
+ return;
607
+ }
608
+ const anchor = anchorOf(renderable);
609
+ context.translate(anchor.x, anchor.y);
610
+ context.scale(scaleX, scaleY);
611
+ context.translate(-anchor.x, -anchor.y);
612
+ };
613
+ // Reused across every tinted SPRITE draw this run, instead of allocating
614
+ // a fresh offscreen canvas per sprite per frame — see tintedSpriteFrame.
615
+ const tintBuffer = window.document.createElement("canvas");
616
+ const tintBufferContext = tintBuffer.getContext("2d");
617
+ // Renders one frame of a spritesheet, tinted by `modulate`, onto the
618
+ // shared offscreen buffer and returns it ready to draw — multiply-
619
+ // blending a filled rectangle over the frame would also color its
620
+ // fully-transparent pixels, so this clips that back down to the
621
+ // frame's own shape afterward with `destination-in`.
622
+ //
623
+ // This has to happen on an *isolated* buffer rather than directly on
624
+ // the main canvas: destination-in isn't scoped to this draw call's own
625
+ // area, it's a whole-buffer operation that erases anything the new
626
+ // draw doesn't cover. Doing it on the main canvas would erase whatever
627
+ // was already drawn underneath the sprite's transparent pixels (e.g.
628
+ // terrain showing through the gaps in a character) instead of leaving
629
+ // it alone. The buffer starts out empty, so there's nothing under it
630
+ // to lose — only the finished, correctly-masked result ever reaches
631
+ // the main canvas, via a normal (source-over) drawImage.
632
+ const tintedSpriteFrame = (image, source, modulate) => {
633
+ if (tintBufferContext === null) {
634
+ return image;
635
+ }
636
+ tintBuffer.width = source.width;
637
+ tintBuffer.height = source.height;
638
+ tintBufferContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
639
+ tintBufferContext.globalCompositeOperation = "multiply";
640
+ tintBufferContext.fillStyle = modulate;
641
+ tintBufferContext.fillRect(0, 0, source.width, source.height);
642
+ tintBufferContext.globalCompositeOperation = "destination-in";
643
+ tintBufferContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
644
+ tintBufferContext.globalCompositeOperation = "source-over";
645
+ return tintBuffer;
646
+ };
285
647
  const intervalId = setInterval(() => {
286
648
  const now = Date.now();
287
649
  const delta = now - lastFrame;
@@ -296,7 +658,16 @@ const runEngine = async (props) => {
296
658
  };
297
659
  });
298
660
  for (const nextState of nextStateFns) {
299
- const result = nextState({ state, event, keyboard });
661
+ const result = nextState({
662
+ state,
663
+ event,
664
+ keyboard,
665
+ playSound,
666
+ playMusic,
667
+ pauseMusic,
668
+ resumeMusic,
669
+ setMusicVolume,
670
+ });
300
671
  // STOP stops the rest of the list from running for this event,
301
672
  // instead of every later mechanic needing to repeat the same
302
673
  // guard. undefined just means this mechanic made no change, so
@@ -311,63 +682,102 @@ const runEngine = async (props) => {
311
682
  }
312
683
  events.splice(0, events.length);
313
684
  context.clearRect(0, 0, canvas.width, canvas.height);
314
- const { cursor, renderables } = props.render(state);
685
+ const { cursor, renderables } = renderState(state);
315
686
  canvas.style.cursor = cursor ?? "default";
316
687
  for (const renderable of renderables) {
688
+ context.save();
689
+ applyScale(renderable);
317
690
  if (renderable.type === "RECTANGLE") {
318
- context.fillStyle = renderable.color;
691
+ context.fillStyle =
692
+ renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
319
693
  context.fillRect(renderable.position.x, renderable.position.y, renderable.size.width, renderable.size.height);
694
+ context.restore();
320
695
  continue;
321
696
  }
322
697
  if (renderable.type === "CIRCLE") {
323
- context.fillStyle = renderable.color;
698
+ context.fillStyle =
699
+ renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
324
700
  context.beginPath();
325
701
  context.arc(renderable.position.x, renderable.position.y, renderable.radius, 0, 2 * Math.PI);
326
702
  context.fill();
703
+ context.restore();
327
704
  continue;
328
705
  }
329
706
  if (renderable.type === "TEXT") {
330
- const { color, position: { x, y }, text, align, } = renderable;
331
- context.fillStyle = color;
332
- context.font = "30px Arial";
707
+ const { color, position: { x, y }, text, align, fontSize, modulate, } = renderable;
708
+ context.fillStyle = modulate === undefined ? color : modulateColor(color, modulate);
709
+ context.font = textFont(fontSize ?? DEFAULT_TEXT_FONT_SIZE);
333
710
  context.textAlign = align?.x ?? "left";
334
711
  context.textBaseline = align?.y ?? "top";
335
712
  context.fillText(text, x, y);
713
+ context.restore();
336
714
  continue;
337
715
  }
338
716
  if (renderable.type === "SPRITE") {
339
- const { scale = 1, opacity = 1, flipX = false } = renderable;
717
+ const { opacity = 1, flipX = false, modulate } = renderable;
340
718
  const resource = resourceById[renderable.resourceId];
341
719
  const frame = {
342
720
  x: renderable.frame % resource.slices.horizontal,
343
721
  y: Math.floor(renderable.frame / resource.slices.horizontal) %
344
722
  resource.slices.vertical,
345
723
  };
346
- const destWidth = resource.size.width * scale;
347
- const destHeight = resource.size.height * scale;
724
+ const source = {
725
+ x: frame.x * resource.size.width,
726
+ y: frame.y * resource.size.height,
727
+ width: resource.size.width,
728
+ height: resource.size.height,
729
+ };
730
+ const destWidth = resource.size.width;
731
+ const destHeight = resource.size.height;
732
+ // Tinting swaps in an already-tinted offscreen copy of this frame
733
+ // as the image to draw — everything past this point (flipX,
734
+ // positioning) treats it exactly like the untinted spritesheet,
735
+ // just drawn starting at (0, 0) instead of cropped from a sheet.
736
+ const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
737
+ const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
348
738
  context.globalAlpha = opacity;
739
+ const drawSprite = (destX, destY) => {
740
+ context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
741
+ };
349
742
  if (flipX) {
350
743
  context.save();
351
744
  context.translate(renderable.position.x + destWidth, renderable.position.y);
352
745
  context.scale(-1, 1);
353
- context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, 0, 0, destWidth, destHeight);
746
+ drawSprite(0, 0);
354
747
  context.restore();
355
748
  }
356
749
  else {
357
- context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, renderable.position.x, renderable.position.y, destWidth, destHeight);
750
+ drawSprite(renderable.position.x, renderable.position.y);
358
751
  }
359
752
  context.globalAlpha = 1;
753
+ context.restore();
360
754
  continue;
361
755
  }
362
756
  if (renderable.type === "LINE") {
363
- context.strokeStyle = renderable.color;
757
+ context.strokeStyle =
758
+ renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
364
759
  context.lineWidth = renderable.width ?? 2;
365
760
  context.beginPath();
366
761
  context.moveTo(renderable.from.x, renderable.from.y);
367
762
  context.lineTo(renderable.to.x, renderable.to.y);
368
763
  context.stroke();
764
+ context.restore();
369
765
  continue;
370
766
  }
767
+ if (renderable.type === "GROUP") {
768
+ // Draws nothing itself — it only exists to give its children
769
+ // (already peeled off into their own entries by flattenRenderables)
770
+ // something to be positioned/scaled/tinted relative to.
771
+ context.restore();
772
+ continue;
773
+ }
774
+ if (renderable.type === "ANIMATED_SPRITE") {
775
+ // Unreachable — flattenRenderable always resolves ANIMATED_SPRITE
776
+ // to a plain SPRITE before it gets here. A real error (not a
777
+ // silent skip) if it's somehow still one, since that would mean
778
+ // an actual engine bug rather than anything a game author did.
779
+ throw new Error("ANIMATED_SPRITE reached the draw loop unresolved — this is an engine bug");
780
+ }
371
781
  exhaust(renderable);
372
782
  }
373
783
  lastFrame = now;
@@ -376,6 +786,7 @@ const runEngine = async (props) => {
376
786
  resetCanvas = () => {
377
787
  clearInterval(intervalId);
378
788
  };
789
+ return { sendEvent };
379
790
  };
380
791
 
381
792
  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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { runEngine } from "./engine/runEngine";
2
2
  import { STOP } from "./engine/types";
3
3
  export { runEngine, STOP };
4
- export type { NextStateFunction, NextStateProps, Renderable, GameEvent } from "./engine/types";
4
+ export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";