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.js CHANGED
@@ -147,6 +147,48 @@ const createPlaceholderSheet = (size) => {
147
147
  }
148
148
  return canvas;
149
149
  };
150
+ // Used only when a resource declares neither `size` (see settle() below)
151
+ // nor actually loads — there's no image to measure and nothing declared
152
+ // to fall back to, so there's no way to know the intended dimensions at
153
+ // all. An arbitrary, small-but-visible size, purely so the placeholder
154
+ // still draws as *something* instead of a 0x0/NaN canvas.
155
+ const DEFAULT_PLACEHOLDER_SIZE = { width: 64, height: 64 };
156
+ // Loads a single resources[id] entry into a ResourceEntry — shared by the
157
+ // startup resources (below) and addResource (returned from runEngine, at
158
+ // the bottom of this file) so a resource registered later loads exactly
159
+ // the same way, placeholder fallback included.
160
+ const loadResource = (value) => new Promise((resolve) => {
161
+ const image = new Image();
162
+ // A single, unsliced image (1x1) if unset — only an actual
163
+ // spritesheet needs this declared.
164
+ const slices = value.slices ?? { horizontal: 1, vertical: 1 };
165
+ // sheetSize is the whole loaded sheet's pixel dimensions — value's
166
+ // declared `size` if set, otherwise whatever the image actually
167
+ // measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
168
+ // that isn't available, i.e. no `size` declared *and* the image
169
+ // failed to load too).
170
+ const settle = (loadedImage, sheetSize) => {
171
+ resolve({
172
+ image: loadedImage,
173
+ size: {
174
+ width: sheetSize.width / slices.horizontal,
175
+ height: sheetSize.height / slices.vertical,
176
+ },
177
+ slices,
178
+ animations: value.animations ?? {},
179
+ });
180
+ };
181
+ image.src = value.src;
182
+ image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
183
+ // Missing/failed-to-load asset (see .gitignore's dist/resources/
184
+ // note) — a placeholder sheet, sized to match what this resource
185
+ // declared, keeps every frame/slice/animation index the caller
186
+ // already computes valid instead of drawing nothing or throwing.
187
+ image.onerror = () => {
188
+ const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
189
+ settle(createPlaceholderSheet(placeholderSize), placeholderSize);
190
+ };
191
+ });
150
192
  const runEngine = async (props) => {
151
193
  const runId = ++latestRunId;
152
194
  resetCanvas?.();
@@ -170,9 +212,39 @@ const runEngine = async (props) => {
170
212
  if (props.canvas?.backgroundColor !== undefined) {
171
213
  canvas.style.backgroundColor = props.canvas.backgroundColor;
172
214
  }
215
+ // The canvas's *logical* resolution — the fixed space every renderable
216
+ // position, and every mouse/touch coordinate, is expressed in. Captured
217
+ // here, before pixelRatio (below) scales the actual backing buffer past
218
+ // it, so both stay anchored to this regardless of what pixelRatio does.
219
+ const logicalWidth = canvas.width;
220
+ const logicalHeight = canvas.height;
221
+ // Scales the backing buffer beyond logicalWidth/logicalHeight so text
222
+ // and vector shapes (fillText, arc, ...) render crisply on a HiDPI
223
+ // screen — most phones — instead of the same logical-resolution buffer
224
+ // just being stretched larger by applyResize below. `true` follows the
225
+ // display's own devicePixelRatio; a number sets it explicitly; leaving
226
+ // this unset keeps today's 1x behavior. Sprites are unaffected either
227
+ // way — imageSmoothingEnabled stays false below regardless — so pixel
228
+ // art has no reason to turn this on.
229
+ const pixelRatio = props.canvas?.pixelRatio === true ? window.devicePixelRatio || 1 : props.canvas?.pixelRatio ?? 1;
230
+ if (pixelRatio !== 1) {
231
+ // Resizing the backing buffer resets the 2D context's transform (and
232
+ // everything else about its state), so context.scale below has to
233
+ // come after this, not before.
234
+ canvas.width = logicalWidth * pixelRatio;
235
+ canvas.height = logicalHeight * pixelRatio;
236
+ canvas.style.width = `${logicalWidth}px`;
237
+ canvas.style.height = `${logicalHeight}px`;
238
+ context.scale(pixelRatio, pixelRatio);
239
+ }
173
240
  // Make the canvas focusable so keyboard input is scoped to it instead of
174
241
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
175
242
  canvas.tabIndex = 0;
243
+ // Stops the browser from treating a drag/pinch on the canvas as page
244
+ // scroll/zoom — backs up the touchstart/touchmove preventDefault calls
245
+ // below for gestures (e.g. a pinch starting on the canvas) preventDefault
246
+ // alone doesn't reliably stop.
247
+ canvas.style.touchAction = "none";
176
248
  // Resizes the *display* size only (CSS width/height) — canvas.width/
177
249
  // height above stays the fixed logical resolution every renderable's
178
250
  // position is already expressed in, so this never needs to touch any
@@ -208,27 +280,14 @@ const runEngine = async (props) => {
208
280
  events.push({ tag: "CUSTOM", event });
209
281
  };
210
282
  const resources = props.resources ?? {};
211
- const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
212
- const image = new Image();
213
- const settle = (loadedImage) => {
214
- resolve({
215
- image: loadedImage,
216
- size: {
217
- width: value.size.width / value.slices.horizontal,
218
- height: value.size.height / value.slices.vertical,
219
- },
220
- slices: value.slices,
221
- animations: value.animations ?? {},
222
- });
223
- };
224
- image.src = value.src;
225
- image.onload = () => settle(image);
226
- // Missing/failed-to-load asset (see .gitignore's dist/resources/
227
- // note) — a placeholder sheet, sized to match what this resource
228
- // declared, keeps every frame/slice/animation index the example
229
- // already computes valid instead of drawing nothing or throwing.
230
- image.onerror = () => settle(createPlaceholderSheet(value.size));
231
- }));
283
+ const resourceById = await iterateRecordAsync(resources, ({ value }) => loadResource(value));
284
+ // Registers a resource after startup — loads it exactly like the
285
+ // resources above (loadResource is the same function), then adds it to
286
+ // the same resourceById map every SPRITE/ANIMATED_SPRITE lookup already
287
+ // reads from, so it's usable by `resourceId` as soon as this resolves.
288
+ const addResource = async (id, resource) => {
289
+ resourceById[id] = await loadResource(resource);
290
+ };
232
291
  const loadAudio = (src) => new Promise((resolve) => {
233
292
  const audio = new Audio(src);
234
293
  audio.oncanplaythrough = function () {
@@ -308,7 +367,12 @@ const runEngine = async (props) => {
308
367
  // fullscreen functions are no-ops here since this run never gets far
309
368
  // enough to own the canvas — the newer run's are the ones that matter.
310
369
  if (runId !== latestRunId) {
311
- return { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
370
+ return {
371
+ sendEvent,
372
+ requestFullscreen: () => Promise.resolve(),
373
+ exitFullscreen: () => Promise.resolve(),
374
+ addResource,
375
+ };
312
376
  }
313
377
  context.imageSmoothingEnabled = false;
314
378
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -396,7 +460,12 @@ const runEngine = async (props) => {
396
460
  seenAnimationIds.add(renderable.id);
397
461
  const resource = resourceById[renderable.resourceId];
398
462
  const animation = resource.animations[renderable.animation];
399
- const timeScale = renderable.timeScale ?? 1;
463
+ // Composed with RunEngineProps.timeScale (currentTimeScale, set once
464
+ // per tick below) — this renderable's own speed multiplied by
465
+ // whatever the whole game's currently running at, so a global 2x/4x/
466
+ // stop control speeds up (or freezes) every animation right along
467
+ // with the rest of the simulation, not just movement/timers.
468
+ const timeScale = (renderable.timeScale ?? 1) * currentTimeScale;
400
469
  const paused = renderable.paused ?? false;
401
470
  const now = Date.now();
402
471
  const tracked = animationStateById.get(renderable.id);
@@ -588,8 +657,20 @@ const runEngine = async (props) => {
588
657
  // the same conversion to line up.
589
658
  const getCanvasPosition = (ev) => {
590
659
  return {
591
- x: (ev.offsetX * canvas.width) / canvas.clientWidth,
592
- y: (ev.offsetY * canvas.height) / canvas.clientHeight,
660
+ x: (ev.offsetX * logicalWidth) / canvas.clientWidth,
661
+ y: (ev.offsetY * logicalHeight) / canvas.clientHeight,
662
+ };
663
+ };
664
+ // Touch's equivalent of getCanvasPosition — a Touch has no offsetX/Y
665
+ // (that's a MouseEvent-only convenience), so this gets there manually
666
+ // via getBoundingClientRect instead. clientX/Y and the rect are both
667
+ // viewport-relative, so their difference stays correct regardless of
668
+ // page scroll.
669
+ const getTouchPosition = (touch) => {
670
+ const rect = canvas.getBoundingClientRect();
671
+ return {
672
+ x: ((touch.clientX - rect.left) * logicalWidth) / canvas.clientWidth,
673
+ y: ((touch.clientY - rect.top) * logicalHeight) / canvas.clientHeight,
593
674
  };
594
675
  };
595
676
  const updateState = (updateFn) => {
@@ -598,8 +679,19 @@ const runEngine = async (props) => {
598
679
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
599
680
  let lastFrame = Date.now();
600
681
  let hoveredId = null;
601
- const handleClick = (ev) => {
602
- const mouse = getCanvasPosition(ev);
682
+ // How much this tick's simulated time is scaled by (see
683
+ // RunEngineProps.timeScale) set once per tick, right before that
684
+ // tick's own TIME event delta is computed, and read again later the
685
+ // same tick by resolveAnimatedSprite (below) so a single number
686
+ // governs both game logic and animation playback consistently within
687
+ // one tick rather than each recomputing props.timeScale(state)
688
+ // separately (state may itself have just changed this same tick).
689
+ let currentTimeScale = 1;
690
+ // Shared by the mouse "click" listener and the touch handlers below —
691
+ // firing a CLICK is the same "is whatever's currently hovered
692
+ // isClickable" check either way; only how `mouse` was determined
693
+ // differs (a real click event vs. a lifted finger).
694
+ const fireClick = (mouse) => {
603
695
  if (hoveredId === null)
604
696
  return;
605
697
  const { renderables, camera } = renderState(state);
@@ -608,6 +700,7 @@ const runEngine = async (props) => {
608
700
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
609
701
  }
610
702
  };
703
+ const handleClick = (ev) => fireClick(getCanvasPosition(ev));
611
704
  canvas.addEventListener("click", handleClick);
612
705
  const initialState = {
613
706
  keyboardState: createRecord(keyboardKeys, () => false),
@@ -637,8 +730,34 @@ const runEngine = async (props) => {
637
730
  }
638
731
  };
639
732
  canvas.addEventListener("keyup", handleKeyUp);
640
- const handleMouseMoveHover = (ev) => {
641
- const mouse = getCanvasPosition(ev);
733
+ // mouseButton state (see NextStateProps.mouseButton) a double-buffer
734
+ // exactly like keyboardState above, just for the primary mouse button.
735
+ let previousMouseButtonState = { isPressed: false };
736
+ const currentMouseButtonState = { isPressed: false };
737
+ const handleMouseDown = (event) => {
738
+ // Left/primary button only — matching CLICK, which already only
739
+ // ever fires for it.
740
+ if (event.button === 0) {
741
+ currentMouseButtonState.isPressed = true;
742
+ }
743
+ };
744
+ canvas.addEventListener("mousedown", handleMouseDown);
745
+ const handleMouseUp = (event) => {
746
+ if (event.button === 0) {
747
+ currentMouseButtonState.isPressed = false;
748
+ }
749
+ };
750
+ // On window rather than the canvas — so releasing the button after
751
+ // having dragged off the canvas while still holding it down still
752
+ // clears isPressed, instead of leaving it stuck true forever.
753
+ window.addEventListener("mouseup", handleMouseUp);
754
+ // Shared by mousemove and the touch handlers below — updates hoveredId
755
+ // from a canvas position and fires HOVER_IN/HOVER_OUT as whatever's
756
+ // underneath it changes. Touch has no ambient hover the way a mouse
757
+ // does (nothing is "hovered" until a finger actually touches down), but
758
+ // feeding a touch's position through this the same as the mouse's is
759
+ // what lets an isHoverable renderable react to a tap/drag at all.
760
+ const updateHover = (mouse) => {
642
761
  const { renderables, camera } = renderState(state);
643
762
  const worldMouse = toWorldPosition(mouse, camera);
644
763
  const hovered = [...renderables]
@@ -655,9 +774,9 @@ const runEngine = async (props) => {
655
774
  }
656
775
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
657
776
  };
777
+ const handleMouseMoveHover = (ev) => updateHover(getCanvasPosition(ev));
658
778
  canvas.addEventListener("mousemove", handleMouseMoveHover);
659
- const handleMouseMoveTracking = (ev) => {
660
- const mouse = getCanvasPosition(ev);
779
+ const updateTracking = (mouse) => {
661
780
  if (hoveredId === null) {
662
781
  return;
663
782
  }
@@ -667,13 +786,17 @@ const runEngine = async (props) => {
667
786
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
668
787
  }
669
788
  };
789
+ const handleMouseMoveTracking = (ev) => updateTracking(getCanvasPosition(ev));
670
790
  canvas.addEventListener("mousemove", handleMouseMoveTracking);
671
791
  // No further mousemove fires once the mouse is off the canvas, so this
672
792
  // is also the only chance to report a HOVER_OUT for whatever was
673
793
  // hovered when it left — otherwise that hover would just dangle,
674
794
  // never explicitly ended.
675
- const handleMouseLeave = (ev) => {
676
- const mouse = getCanvasPosition(ev);
795
+ // Shared by mouseleave and the touch handlers below — clears whatever's
796
+ // hovered (firing HOVER_OUT for it) without a HOVER_IN taking its
797
+ // place. Returns worldMouse so callers that need it (MOUSE_LEAVE below)
798
+ // don't have to call renderState() a second time just to get it.
799
+ const clearHover = (mouse) => {
677
800
  const { renderables, camera } = renderState(state);
678
801
  const worldMouse = toWorldPosition(mouse, camera);
679
802
  if (hoveredId !== null) {
@@ -683,9 +806,79 @@ const runEngine = async (props) => {
683
806
  }
684
807
  }
685
808
  hoveredId = null;
809
+ return worldMouse;
810
+ };
811
+ const handleMouseLeave = (ev) => {
812
+ const mouse = getCanvasPosition(ev);
813
+ const worldMouse = clearHover(mouse);
686
814
  events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
687
815
  };
688
816
  canvas.addEventListener("mouseleave", handleMouseLeave);
817
+ // Translates touch into the same HOVER_IN/HOVER_OUT/MOUSE_MOVE/CLICK
818
+ // events mouse input already produces (via updateHover/updateTracking/
819
+ // fireClick/clearHover above), so existing game code written against
820
+ // those events works on a touchscreen with no changes of its own.
821
+ // Single-touch only — touches[0]/changedTouches[0] — the same "one
822
+ // active pointer" model mouse input already assumes; a second finger is
823
+ // ignored rather than tracked as its own pointer.
824
+ //
825
+ // preventDefault on start/move keeps a drag/tap on the canvas from also
826
+ // scrolling, pinch-zooming, or triggering pull-to-refresh — the browser
827
+ // gestures a touchscreen normally reserves that space for; { passive:
828
+ // false } is what makes preventDefault actually take effect here.
829
+ const handleTouchStart = (ev) => {
830
+ ev.preventDefault();
831
+ const touch = ev.touches[0];
832
+ if (touch === undefined)
833
+ return;
834
+ currentMouseButtonState.isPressed = true;
835
+ const mouse = getTouchPosition(touch);
836
+ updateHover(mouse);
837
+ updateTracking(mouse);
838
+ };
839
+ canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
840
+ const handleTouchMove = (ev) => {
841
+ ev.preventDefault();
842
+ const touch = ev.touches[0];
843
+ if (touch === undefined)
844
+ return;
845
+ const mouse = getTouchPosition(touch);
846
+ updateHover(mouse);
847
+ updateTracking(mouse);
848
+ };
849
+ canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
850
+ // A lifted finger both releases (mirroring mouseup) and clicks
851
+ // (mirroring the browser's own click-after-mouseup) — touch has no
852
+ // separate "up" and "click" events of its own the way mouse does, so
853
+ // both happen here together, in that order. hover is updated once more
854
+ // first so a plain tap (touchstart immediately followed by touchend,
855
+ // with no touchmove between them to have already done this) still
856
+ // fires CLICK against whatever's actually under it; hover is then
857
+ // cleared, since nothing's left touching it once the finger lifts.
858
+ const handleTouchEnd = (ev) => {
859
+ ev.preventDefault();
860
+ currentMouseButtonState.isPressed = false;
861
+ const touch = ev.changedTouches[0];
862
+ if (touch === undefined)
863
+ return;
864
+ const mouse = getTouchPosition(touch);
865
+ updateHover(mouse);
866
+ fireClick(mouse);
867
+ clearHover(mouse);
868
+ };
869
+ canvas.addEventListener("touchend", handleTouchEnd, { passive: false });
870
+ // A cancelled touch (e.g. an incoming call interrupting the page, or
871
+ // the OS deciding it's a system gesture instead) never fires touchend —
872
+ // handled the same as lifting the finger, minus the click, since
873
+ // there's no tap to speak of once the touch itself has been cancelled.
874
+ const handleTouchCancel = (ev) => {
875
+ currentMouseButtonState.isPressed = false;
876
+ const touch = ev.changedTouches[0];
877
+ if (touch === undefined)
878
+ return;
879
+ clearHover(getTouchPosition(touch));
880
+ };
881
+ canvas.addEventListener("touchcancel", handleTouchCancel, { passive: false });
689
882
  // Tab switches are a document-level concern (visibilitychange), not
690
883
  // something that ever reaches the canvas itself the way mouse/keyboard
691
884
  // events do.
@@ -806,7 +999,14 @@ const runEngine = async (props) => {
806
999
  };
807
1000
  const intervalId = setInterval(() => {
808
1001
  const now = Date.now();
809
- const delta = now - lastFrame;
1002
+ const rawDelta = now - lastFrame;
1003
+ // Read from state as this tick starts (i.e. still last tick's own
1004
+ // result) — resolveAnimatedSprite (below, during this same tick's
1005
+ // render pass) reads this same currentTimeScale rather than calling
1006
+ // props.timeScale itself, so a single value governs both the TIME
1007
+ // event about to fire and every animation's playback consistently.
1008
+ currentTimeScale = props.timeScale?.(state) ?? 1;
1009
+ const delta = rawDelta * currentTimeScale;
810
1010
  events.push({ tag: "TIME", delta: delta });
811
1011
  for (const event of events) {
812
1012
  const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
@@ -817,11 +1017,17 @@ const runEngine = async (props) => {
817
1017
  isJustReleased: !isPressed && previouslyPressed,
818
1018
  };
819
1019
  });
1020
+ const mouseButton = {
1021
+ isPressed: currentMouseButtonState.isPressed,
1022
+ isJustPressed: currentMouseButtonState.isPressed && !previousMouseButtonState.isPressed,
1023
+ isJustReleased: !currentMouseButtonState.isPressed && previousMouseButtonState.isPressed,
1024
+ };
820
1025
  for (const nextState of nextStateFns) {
821
1026
  const result = nextState({
822
1027
  state,
823
1028
  event,
824
1029
  keyboard,
1030
+ mouseButton,
825
1031
  playSound,
826
1032
  playMusic,
827
1033
  pauseMusic,
@@ -841,7 +1047,7 @@ const runEngine = async (props) => {
841
1047
  }
842
1048
  }
843
1049
  events.splice(0, events.length);
844
- context.clearRect(0, 0, canvas.width, canvas.height);
1050
+ context.clearRect(0, 0, logicalWidth, logicalHeight);
845
1051
  const { cursor, renderables } = renderState(state);
846
1052
  canvas.style.cursor = cursor ?? "default";
847
1053
  for (const renderable of renderables) {
@@ -874,12 +1080,11 @@ const runEngine = async (props) => {
874
1080
  continue;
875
1081
  }
876
1082
  if (renderable.type === "SPRITE") {
877
- const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
1083
+ const { opacity = 1, flipX = false, frame: frameIndex = 0, modulate, swapColors } = renderable;
878
1084
  const resource = resourceById[renderable.resourceId];
879
1085
  const frame = {
880
- x: renderable.frame % resource.slices.horizontal,
881
- y: Math.floor(renderable.frame / resource.slices.horizontal) %
882
- resource.slices.vertical,
1086
+ x: frameIndex % resource.slices.horizontal,
1087
+ y: Math.floor(frameIndex / resource.slices.horizontal) % resource.slices.vertical,
883
1088
  };
884
1089
  const source = {
885
1090
  x: frame.x * resource.size.width,
@@ -899,7 +1104,7 @@ const runEngine = async (props) => {
899
1104
  let image = resource.image;
900
1105
  let imageSource = source;
901
1106
  if (swapColors !== undefined && swapColors.length > 0) {
902
- image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
1107
+ image = swappedSpriteFrame(renderable.resourceId, frameIndex, image, imageSource, swapColors);
903
1108
  imageSource = { x: 0, y: 0, width: source.width, height: source.height };
904
1109
  }
905
1110
  if (modulate !== undefined) {
@@ -953,6 +1158,7 @@ const runEngine = async (props) => {
953
1158
  }
954
1159
  lastFrame = now;
955
1160
  previousState.keyboardState = { ...currentState.keyboardState };
1161
+ previousMouseButtonState = { ...currentMouseButtonState };
956
1162
  }, 0);
957
1163
  resetCanvas = () => {
958
1164
  clearInterval(intervalId);
@@ -976,9 +1182,16 @@ const runEngine = async (props) => {
976
1182
  canvas.removeEventListener("click", handleClick);
977
1183
  canvas.removeEventListener("keydown", handleKeyDown);
978
1184
  canvas.removeEventListener("keyup", handleKeyUp);
1185
+ canvas.removeEventListener("mousedown", handleMouseDown);
979
1186
  canvas.removeEventListener("mousemove", handleMouseMoveHover);
980
1187
  canvas.removeEventListener("mousemove", handleMouseMoveTracking);
981
1188
  canvas.removeEventListener("mouseleave", handleMouseLeave);
1189
+ canvas.removeEventListener("touchstart", handleTouchStart);
1190
+ canvas.removeEventListener("touchmove", handleTouchMove);
1191
+ canvas.removeEventListener("touchend", handleTouchEnd);
1192
+ canvas.removeEventListener("touchcancel", handleTouchCancel);
1193
+ // On window, not the canvas — see where it's added above for why.
1194
+ window.removeEventListener("mouseup", handleMouseUp);
982
1195
  // This one's on `document`, not the canvas — same reasoning as above,
983
1196
  // just doubly true since `document` isn't even scoped to this canvas.
984
1197
  window.document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -986,8 +1199,45 @@ const runEngine = async (props) => {
986
1199
  // Same pile-up risk as the canvas listeners above, but on `window`.
987
1200
  window.removeEventListener("resize", applyResize);
988
1201
  };
989
- return { sendEvent, requestFullscreen, exitFullscreen };
1202
+ return { sendEvent, requestFullscreen, exitFullscreen, addResource };
990
1203
  };
991
1204
 
992
- export { STOP, runEngine };
1205
+ // One factory per Renderable variant — literally just `{ type: "X",
1206
+ // ...props }`, so `sprite({...})` (or `Yuuna.sprite({...})` in the
1207
+ // browser bundle/playground) reads the same as writing the object
1208
+ // literal by hand, minus needing to get `type` right yourself.
1209
+ // Deliberately not a place defaults live: besides SpriteRenderable.frame
1210
+ // (optional at the type level, see types.ts — the engine itself defaults
1211
+ // it to 0), each factory still requires whatever its Renderable type
1212
+ // still requires.
1213
+ const rectangle = (props) => ({
1214
+ type: "RECTANGLE",
1215
+ ...props,
1216
+ });
1217
+ const circle = (props) => ({
1218
+ type: "CIRCLE",
1219
+ ...props,
1220
+ });
1221
+ const text = (props) => ({
1222
+ type: "TEXT",
1223
+ ...props,
1224
+ });
1225
+ const sprite = (props) => ({
1226
+ type: "SPRITE",
1227
+ ...props,
1228
+ });
1229
+ const animatedSprite = (props) => ({
1230
+ type: "ANIMATED_SPRITE",
1231
+ ...props,
1232
+ });
1233
+ const line = (props) => ({
1234
+ type: "LINE",
1235
+ ...props,
1236
+ });
1237
+ const group = (props) => ({
1238
+ type: "GROUP",
1239
+ ...props,
1240
+ });
1241
+
1242
+ export { STOP, animatedSprite, circle, group, line, rectangle, runEngine, sprite, text };
993
1243
  //# sourceMappingURL=index.js.map
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.6.0",
3
+ "version": "0.8.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",
@@ -39,7 +39,9 @@
39
39
  "scripts": {
40
40
  "build": "node scripts/generate-playground-assets.mjs && rollup -c",
41
41
  "watch": "node scripts/generate-playground-assets.mjs && rollup -c -w",
42
- "prepublishOnly": "npm run build"
42
+ "prepublishOnly": "npm run build",
43
+ "sync-assets": "node scripts/sync-playground-assets.mjs",
44
+ "postinstall": "node -e \"const fs=require('fs');try{if(fs.existsSync('.githooks')&&fs.existsSync('.git')){require('child_process').execSync('git config core.hooksPath .githooks')}}catch(e){}\""
43
45
  },
44
46
  "devDependencies": {
45
47
  "nodemon": "^3.1.0",
@@ -1,6 +0,0 @@
1
- export declare const CONSTANTS: {
2
- WINDOW: {
3
- WIDTH: number;
4
- HEIGHT: number;
5
- };
6
- };