yuuna-engine 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.cjs CHANGED
@@ -149,6 +149,48 @@ const createPlaceholderSheet = (size) => {
149
149
  }
150
150
  return canvas;
151
151
  };
152
+ // Used only when a resource declares neither `size` (see settle() below)
153
+ // nor actually loads — there's no image to measure and nothing declared
154
+ // to fall back to, so there's no way to know the intended dimensions at
155
+ // all. An arbitrary, small-but-visible size, purely so the placeholder
156
+ // still draws as *something* instead of a 0x0/NaN canvas.
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
+ });
152
194
  const runEngine = async (props) => {
153
195
  const runId = ++latestRunId;
154
196
  resetCanvas?.();
@@ -172,9 +214,39 @@ const runEngine = async (props) => {
172
214
  if (props.canvas?.backgroundColor !== undefined) {
173
215
  canvas.style.backgroundColor = props.canvas.backgroundColor;
174
216
  }
217
+ // The canvas's *logical* resolution — the fixed space every renderable
218
+ // position, and every mouse/touch coordinate, is expressed in. Captured
219
+ // here, before pixelRatio (below) scales the actual backing buffer past
220
+ // it, so both stay anchored to this regardless of what pixelRatio does.
221
+ const logicalWidth = canvas.width;
222
+ const logicalHeight = canvas.height;
223
+ // Scales the backing buffer beyond logicalWidth/logicalHeight so text
224
+ // and vector shapes (fillText, arc, ...) render crisply on a HiDPI
225
+ // screen — most phones — instead of the same logical-resolution buffer
226
+ // just being stretched larger by applyResize below. `true` follows the
227
+ // display's own devicePixelRatio; a number sets it explicitly; leaving
228
+ // this unset keeps today's 1x behavior. Sprites are unaffected either
229
+ // way — imageSmoothingEnabled stays false below regardless — so pixel
230
+ // art has no reason to turn this on.
231
+ const pixelRatio = props.canvas?.pixelRatio === true ? window.devicePixelRatio || 1 : props.canvas?.pixelRatio ?? 1;
232
+ if (pixelRatio !== 1) {
233
+ // Resizing the backing buffer resets the 2D context's transform (and
234
+ // everything else about its state), so context.scale below has to
235
+ // come after this, not before.
236
+ canvas.width = logicalWidth * pixelRatio;
237
+ canvas.height = logicalHeight * pixelRatio;
238
+ canvas.style.width = `${logicalWidth}px`;
239
+ canvas.style.height = `${logicalHeight}px`;
240
+ context.scale(pixelRatio, pixelRatio);
241
+ }
175
242
  // Make the canvas focusable so keyboard input is scoped to it instead of
176
243
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
177
244
  canvas.tabIndex = 0;
245
+ // Stops the browser from treating a drag/pinch on the canvas as page
246
+ // scroll/zoom — backs up the touchstart/touchmove preventDefault calls
247
+ // below for gestures (e.g. a pinch starting on the canvas) preventDefault
248
+ // alone doesn't reliably stop.
249
+ canvas.style.touchAction = "none";
178
250
  // Resizes the *display* size only (CSS width/height) — canvas.width/
179
251
  // height above stays the fixed logical resolution every renderable's
180
252
  // position is already expressed in, so this never needs to touch any
@@ -210,27 +282,14 @@ const runEngine = async (props) => {
210
282
  events.push({ tag: "CUSTOM", event });
211
283
  };
212
284
  const resources = props.resources ?? {};
213
- const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
214
- const image = new Image();
215
- const settle = (loadedImage) => {
216
- resolve({
217
- image: loadedImage,
218
- size: {
219
- width: value.size.width / value.slices.horizontal,
220
- height: value.size.height / value.slices.vertical,
221
- },
222
- slices: value.slices,
223
- animations: value.animations ?? {},
224
- });
225
- };
226
- image.src = value.src;
227
- image.onload = () => settle(image);
228
- // Missing/failed-to-load asset (see .gitignore's dist/resources/
229
- // note) — a placeholder sheet, sized to match what this resource
230
- // declared, keeps every frame/slice/animation index the example
231
- // already computes valid instead of drawing nothing or throwing.
232
- image.onerror = () => settle(createPlaceholderSheet(value.size));
233
- }));
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
+ };
234
293
  const loadAudio = (src) => new Promise((resolve) => {
235
294
  const audio = new Audio(src);
236
295
  audio.oncanplaythrough = function () {
@@ -310,7 +369,12 @@ const runEngine = async (props) => {
310
369
  // fullscreen functions are no-ops here since this run never gets far
311
370
  // enough to own the canvas — the newer run's are the ones that matter.
312
371
  if (runId !== latestRunId) {
313
- return { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
372
+ return {
373
+ sendEvent,
374
+ requestFullscreen: () => Promise.resolve(),
375
+ exitFullscreen: () => Promise.resolve(),
376
+ addResource,
377
+ };
314
378
  }
315
379
  context.imageSmoothingEnabled = false;
316
380
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -398,7 +462,12 @@ const runEngine = async (props) => {
398
462
  seenAnimationIds.add(renderable.id);
399
463
  const resource = resourceById[renderable.resourceId];
400
464
  const animation = resource.animations[renderable.animation];
401
- 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;
402
471
  const paused = renderable.paused ?? false;
403
472
  const now = Date.now();
404
473
  const tracked = animationStateById.get(renderable.id);
@@ -590,8 +659,20 @@ const runEngine = async (props) => {
590
659
  // the same conversion to line up.
591
660
  const getCanvasPosition = (ev) => {
592
661
  return {
593
- x: (ev.offsetX * canvas.width) / canvas.clientWidth,
594
- y: (ev.offsetY * canvas.height) / canvas.clientHeight,
662
+ x: (ev.offsetX * logicalWidth) / canvas.clientWidth,
663
+ y: (ev.offsetY * logicalHeight) / canvas.clientHeight,
664
+ };
665
+ };
666
+ // Touch's equivalent of getCanvasPosition — a Touch has no offsetX/Y
667
+ // (that's a MouseEvent-only convenience), so this gets there manually
668
+ // via getBoundingClientRect instead. clientX/Y and the rect are both
669
+ // viewport-relative, so their difference stays correct regardless of
670
+ // page scroll.
671
+ const getTouchPosition = (touch) => {
672
+ const rect = canvas.getBoundingClientRect();
673
+ return {
674
+ x: ((touch.clientX - rect.left) * logicalWidth) / canvas.clientWidth,
675
+ y: ((touch.clientY - rect.top) * logicalHeight) / canvas.clientHeight,
595
676
  };
596
677
  };
597
678
  const updateState = (updateFn) => {
@@ -600,8 +681,19 @@ const runEngine = async (props) => {
600
681
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
601
682
  let lastFrame = Date.now();
602
683
  let hoveredId = null;
603
- const handleClick = (ev) => {
604
- const mouse = getCanvasPosition(ev);
684
+ // How much this tick's simulated time is scaled by (see
685
+ // RunEngineProps.timeScale) set once per tick, right before that
686
+ // tick's own TIME event delta is computed, and read again later the
687
+ // same tick by resolveAnimatedSprite (below) so a single number
688
+ // governs both game logic and animation playback consistently within
689
+ // one tick rather than each recomputing props.timeScale(state)
690
+ // separately (state may itself have just changed this same tick).
691
+ let currentTimeScale = 1;
692
+ // Shared by the mouse "click" listener and the touch handlers below —
693
+ // firing a CLICK is the same "is whatever's currently hovered
694
+ // isClickable" check either way; only how `mouse` was determined
695
+ // differs (a real click event vs. a lifted finger).
696
+ const fireClick = (mouse) => {
605
697
  if (hoveredId === null)
606
698
  return;
607
699
  const { renderables, camera } = renderState(state);
@@ -610,6 +702,7 @@ const runEngine = async (props) => {
610
702
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
611
703
  }
612
704
  };
705
+ const handleClick = (ev) => fireClick(getCanvasPosition(ev));
613
706
  canvas.addEventListener("click", handleClick);
614
707
  const initialState = {
615
708
  keyboardState: createRecord(keyboardKeys, () => false),
@@ -639,8 +732,34 @@ const runEngine = async (props) => {
639
732
  }
640
733
  };
641
734
  canvas.addEventListener("keyup", handleKeyUp);
642
- const handleMouseMoveHover = (ev) => {
643
- const mouse = getCanvasPosition(ev);
735
+ // mouseButton state (see NextStateProps.mouseButton) a double-buffer
736
+ // exactly like keyboardState above, just for the primary mouse button.
737
+ let previousMouseButtonState = { isPressed: false };
738
+ const currentMouseButtonState = { isPressed: false };
739
+ const handleMouseDown = (event) => {
740
+ // Left/primary button only — matching CLICK, which already only
741
+ // ever fires for it.
742
+ if (event.button === 0) {
743
+ currentMouseButtonState.isPressed = true;
744
+ }
745
+ };
746
+ canvas.addEventListener("mousedown", handleMouseDown);
747
+ const handleMouseUp = (event) => {
748
+ if (event.button === 0) {
749
+ currentMouseButtonState.isPressed = false;
750
+ }
751
+ };
752
+ // On window rather than the canvas — so releasing the button after
753
+ // having dragged off the canvas while still holding it down still
754
+ // clears isPressed, instead of leaving it stuck true forever.
755
+ window.addEventListener("mouseup", handleMouseUp);
756
+ // Shared by mousemove and the touch handlers below — updates hoveredId
757
+ // from a canvas position and fires HOVER_IN/HOVER_OUT as whatever's
758
+ // underneath it changes. Touch has no ambient hover the way a mouse
759
+ // does (nothing is "hovered" until a finger actually touches down), but
760
+ // feeding a touch's position through this the same as the mouse's is
761
+ // what lets an isHoverable renderable react to a tap/drag at all.
762
+ const updateHover = (mouse) => {
644
763
  const { renderables, camera } = renderState(state);
645
764
  const worldMouse = toWorldPosition(mouse, camera);
646
765
  const hovered = [...renderables]
@@ -657,9 +776,9 @@ const runEngine = async (props) => {
657
776
  }
658
777
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
659
778
  };
779
+ const handleMouseMoveHover = (ev) => updateHover(getCanvasPosition(ev));
660
780
  canvas.addEventListener("mousemove", handleMouseMoveHover);
661
- const handleMouseMoveTracking = (ev) => {
662
- const mouse = getCanvasPosition(ev);
781
+ const updateTracking = (mouse) => {
663
782
  if (hoveredId === null) {
664
783
  return;
665
784
  }
@@ -669,13 +788,17 @@ const runEngine = async (props) => {
669
788
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
670
789
  }
671
790
  };
791
+ const handleMouseMoveTracking = (ev) => updateTracking(getCanvasPosition(ev));
672
792
  canvas.addEventListener("mousemove", handleMouseMoveTracking);
673
793
  // No further mousemove fires once the mouse is off the canvas, so this
674
794
  // is also the only chance to report a HOVER_OUT for whatever was
675
795
  // hovered when it left — otherwise that hover would just dangle,
676
796
  // never explicitly ended.
677
- const handleMouseLeave = (ev) => {
678
- const mouse = getCanvasPosition(ev);
797
+ // Shared by mouseleave and the touch handlers below — clears whatever's
798
+ // hovered (firing HOVER_OUT for it) without a HOVER_IN taking its
799
+ // place. Returns worldMouse so callers that need it (MOUSE_LEAVE below)
800
+ // don't have to call renderState() a second time just to get it.
801
+ const clearHover = (mouse) => {
679
802
  const { renderables, camera } = renderState(state);
680
803
  const worldMouse = toWorldPosition(mouse, camera);
681
804
  if (hoveredId !== null) {
@@ -685,9 +808,79 @@ const runEngine = async (props) => {
685
808
  }
686
809
  }
687
810
  hoveredId = null;
811
+ return worldMouse;
812
+ };
813
+ const handleMouseLeave = (ev) => {
814
+ const mouse = getCanvasPosition(ev);
815
+ const worldMouse = clearHover(mouse);
688
816
  events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
689
817
  };
690
818
  canvas.addEventListener("mouseleave", handleMouseLeave);
819
+ // Translates touch into the same HOVER_IN/HOVER_OUT/MOUSE_MOVE/CLICK
820
+ // events mouse input already produces (via updateHover/updateTracking/
821
+ // fireClick/clearHover above), so existing game code written against
822
+ // those events works on a touchscreen with no changes of its own.
823
+ // Single-touch only — touches[0]/changedTouches[0] — the same "one
824
+ // active pointer" model mouse input already assumes; a second finger is
825
+ // ignored rather than tracked as its own pointer.
826
+ //
827
+ // preventDefault on start/move keeps a drag/tap on the canvas from also
828
+ // scrolling, pinch-zooming, or triggering pull-to-refresh — the browser
829
+ // gestures a touchscreen normally reserves that space for; { passive:
830
+ // false } is what makes preventDefault actually take effect here.
831
+ const handleTouchStart = (ev) => {
832
+ ev.preventDefault();
833
+ const touch = ev.touches[0];
834
+ if (touch === undefined)
835
+ return;
836
+ currentMouseButtonState.isPressed = true;
837
+ const mouse = getTouchPosition(touch);
838
+ updateHover(mouse);
839
+ updateTracking(mouse);
840
+ };
841
+ canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
842
+ const handleTouchMove = (ev) => {
843
+ ev.preventDefault();
844
+ const touch = ev.touches[0];
845
+ if (touch === undefined)
846
+ return;
847
+ const mouse = getTouchPosition(touch);
848
+ updateHover(mouse);
849
+ updateTracking(mouse);
850
+ };
851
+ canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
852
+ // A lifted finger both releases (mirroring mouseup) and clicks
853
+ // (mirroring the browser's own click-after-mouseup) — touch has no
854
+ // separate "up" and "click" events of its own the way mouse does, so
855
+ // both happen here together, in that order. hover is updated once more
856
+ // first so a plain tap (touchstart immediately followed by touchend,
857
+ // with no touchmove between them to have already done this) still
858
+ // fires CLICK against whatever's actually under it; hover is then
859
+ // cleared, since nothing's left touching it once the finger lifts.
860
+ const handleTouchEnd = (ev) => {
861
+ ev.preventDefault();
862
+ currentMouseButtonState.isPressed = false;
863
+ const touch = ev.changedTouches[0];
864
+ if (touch === undefined)
865
+ return;
866
+ const mouse = getTouchPosition(touch);
867
+ updateHover(mouse);
868
+ fireClick(mouse);
869
+ clearHover(mouse);
870
+ };
871
+ canvas.addEventListener("touchend", handleTouchEnd, { passive: false });
872
+ // A cancelled touch (e.g. an incoming call interrupting the page, or
873
+ // the OS deciding it's a system gesture instead) never fires touchend —
874
+ // handled the same as lifting the finger, minus the click, since
875
+ // there's no tap to speak of once the touch itself has been cancelled.
876
+ const handleTouchCancel = (ev) => {
877
+ currentMouseButtonState.isPressed = false;
878
+ const touch = ev.changedTouches[0];
879
+ if (touch === undefined)
880
+ return;
881
+ clearHover(getTouchPosition(touch));
882
+ };
883
+ canvas.addEventListener("touchcancel", handleTouchCancel, { passive: false });
691
884
  // Tab switches are a document-level concern (visibilitychange), not
692
885
  // something that ever reaches the canvas itself the way mouse/keyboard
693
886
  // events do.
@@ -808,7 +1001,14 @@ const runEngine = async (props) => {
808
1001
  };
809
1002
  const intervalId = setInterval(() => {
810
1003
  const now = Date.now();
811
- const delta = now - lastFrame;
1004
+ const rawDelta = now - lastFrame;
1005
+ // Read from state as this tick starts (i.e. still last tick's own
1006
+ // result) — resolveAnimatedSprite (below, during this same tick's
1007
+ // render pass) reads this same currentTimeScale rather than calling
1008
+ // props.timeScale itself, so a single value governs both the TIME
1009
+ // event about to fire and every animation's playback consistently.
1010
+ currentTimeScale = props.timeScale?.(state) ?? 1;
1011
+ const delta = rawDelta * currentTimeScale;
812
1012
  events.push({ tag: "TIME", delta: delta });
813
1013
  for (const event of events) {
814
1014
  const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
@@ -819,11 +1019,17 @@ const runEngine = async (props) => {
819
1019
  isJustReleased: !isPressed && previouslyPressed,
820
1020
  };
821
1021
  });
1022
+ const mouseButton = {
1023
+ isPressed: currentMouseButtonState.isPressed,
1024
+ isJustPressed: currentMouseButtonState.isPressed && !previousMouseButtonState.isPressed,
1025
+ isJustReleased: !currentMouseButtonState.isPressed && previousMouseButtonState.isPressed,
1026
+ };
822
1027
  for (const nextState of nextStateFns) {
823
1028
  const result = nextState({
824
1029
  state,
825
1030
  event,
826
1031
  keyboard,
1032
+ mouseButton,
827
1033
  playSound,
828
1034
  playMusic,
829
1035
  pauseMusic,
@@ -843,7 +1049,7 @@ const runEngine = async (props) => {
843
1049
  }
844
1050
  }
845
1051
  events.splice(0, events.length);
846
- context.clearRect(0, 0, canvas.width, canvas.height);
1052
+ context.clearRect(0, 0, logicalWidth, logicalHeight);
847
1053
  const { cursor, renderables } = renderState(state);
848
1054
  canvas.style.cursor = cursor ?? "default";
849
1055
  for (const renderable of renderables) {
@@ -876,12 +1082,11 @@ const runEngine = async (props) => {
876
1082
  continue;
877
1083
  }
878
1084
  if (renderable.type === "SPRITE") {
879
- const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
1085
+ const { opacity = 1, flipX = false, frame: frameIndex = 0, modulate, swapColors } = renderable;
880
1086
  const resource = resourceById[renderable.resourceId];
881
1087
  const frame = {
882
- x: renderable.frame % resource.slices.horizontal,
883
- y: Math.floor(renderable.frame / resource.slices.horizontal) %
884
- resource.slices.vertical,
1088
+ x: frameIndex % resource.slices.horizontal,
1089
+ y: Math.floor(frameIndex / resource.slices.horizontal) % resource.slices.vertical,
885
1090
  };
886
1091
  const source = {
887
1092
  x: frame.x * resource.size.width,
@@ -901,7 +1106,7 @@ const runEngine = async (props) => {
901
1106
  let image = resource.image;
902
1107
  let imageSource = source;
903
1108
  if (swapColors !== undefined && swapColors.length > 0) {
904
- image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
1109
+ image = swappedSpriteFrame(renderable.resourceId, frameIndex, image, imageSource, swapColors);
905
1110
  imageSource = { x: 0, y: 0, width: source.width, height: source.height };
906
1111
  }
907
1112
  if (modulate !== undefined) {
@@ -955,6 +1160,7 @@ const runEngine = async (props) => {
955
1160
  }
956
1161
  lastFrame = now;
957
1162
  previousState.keyboardState = { ...currentState.keyboardState };
1163
+ previousMouseButtonState = { ...currentMouseButtonState };
958
1164
  }, 0);
959
1165
  resetCanvas = () => {
960
1166
  clearInterval(intervalId);
@@ -978,9 +1184,16 @@ const runEngine = async (props) => {
978
1184
  canvas.removeEventListener("click", handleClick);
979
1185
  canvas.removeEventListener("keydown", handleKeyDown);
980
1186
  canvas.removeEventListener("keyup", handleKeyUp);
1187
+ canvas.removeEventListener("mousedown", handleMouseDown);
981
1188
  canvas.removeEventListener("mousemove", handleMouseMoveHover);
982
1189
  canvas.removeEventListener("mousemove", handleMouseMoveTracking);
983
1190
  canvas.removeEventListener("mouseleave", handleMouseLeave);
1191
+ canvas.removeEventListener("touchstart", handleTouchStart);
1192
+ canvas.removeEventListener("touchmove", handleTouchMove);
1193
+ canvas.removeEventListener("touchend", handleTouchEnd);
1194
+ canvas.removeEventListener("touchcancel", handleTouchCancel);
1195
+ // On window, not the canvas — see where it's added above for why.
1196
+ window.removeEventListener("mouseup", handleMouseUp);
984
1197
  // This one's on `document`, not the canvas — same reasoning as above,
985
1198
  // just doubly true since `document` isn't even scoped to this canvas.
986
1199
  window.document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -988,9 +1201,53 @@ const runEngine = async (props) => {
988
1201
  // Same pile-up risk as the canvas listeners above, but on `window`.
989
1202
  window.removeEventListener("resize", applyResize);
990
1203
  };
991
- return { sendEvent, requestFullscreen, exitFullscreen };
1204
+ return { sendEvent, requestFullscreen, exitFullscreen, addResource };
992
1205
  };
993
1206
 
1207
+ // One factory per Renderable variant — literally just `{ type: "X",
1208
+ // ...props }`, so `sprite({...})` (or `Yuuna.sprite({...})` in the
1209
+ // browser bundle/playground) reads the same as writing the object
1210
+ // literal by hand, minus needing to get `type` right yourself.
1211
+ // Deliberately not a place defaults live: besides SpriteRenderable.frame
1212
+ // (optional at the type level, see types.ts — the engine itself defaults
1213
+ // it to 0), each factory still requires whatever its Renderable type
1214
+ // still requires.
1215
+ const rectangle = (props) => ({
1216
+ type: "RECTANGLE",
1217
+ ...props,
1218
+ });
1219
+ const circle = (props) => ({
1220
+ type: "CIRCLE",
1221
+ ...props,
1222
+ });
1223
+ const text = (props) => ({
1224
+ type: "TEXT",
1225
+ ...props,
1226
+ });
1227
+ const sprite = (props) => ({
1228
+ type: "SPRITE",
1229
+ ...props,
1230
+ });
1231
+ const animatedSprite = (props) => ({
1232
+ type: "ANIMATED_SPRITE",
1233
+ ...props,
1234
+ });
1235
+ const line = (props) => ({
1236
+ type: "LINE",
1237
+ ...props,
1238
+ });
1239
+ const group = (props) => ({
1240
+ type: "GROUP",
1241
+ ...props,
1242
+ });
1243
+
994
1244
  exports.STOP = STOP;
1245
+ exports.animatedSprite = animatedSprite;
1246
+ exports.circle = circle;
1247
+ exports.group = group;
1248
+ exports.line = line;
1249
+ exports.rectangle = rectangle;
995
1250
  exports.runEngine = runEngine;
1251
+ exports.sprite = sprite;
1252
+ exports.text = text;
996
1253
  //# sourceMappingURL=index.cjs.map
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,5 @@
1
1
  import { runEngine } from "./engine/runEngine";
2
2
  import { STOP } from "./engine/types";
3
- export { runEngine, STOP };
4
- export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";
3
+ import { animatedSprite, circle, group, line, rectangle, sprite, text } from "./engine/renderables";
4
+ export { runEngine, STOP, rectangle, circle, text, sprite, animatedSprite, line, group };
5
+ export type { NextStateFunction, NextStateProps, Renderable, ResourceConfig, GameEvent, CustomGameEvent, } from "./engine/types";