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