yuuna-engine 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -152
- package/lib/engine/renderables.d.ts +8 -0
- package/lib/engine/types.d.ts +24 -4
- package/lib/index.cjs +286 -25
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.js +280 -26
- package/lib/index.js.map +1 -1
- package/package.json +4 -2
- package/lib/utils/constants.d.ts +0 -6
package/lib/index.js
CHANGED
|
@@ -147,6 +147,12 @@ 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 };
|
|
150
156
|
const runEngine = async (props) => {
|
|
151
157
|
const runId = ++latestRunId;
|
|
152
158
|
resetCanvas?.();
|
|
@@ -170,9 +176,64 @@ const runEngine = async (props) => {
|
|
|
170
176
|
if (props.canvas?.backgroundColor !== undefined) {
|
|
171
177
|
canvas.style.backgroundColor = props.canvas.backgroundColor;
|
|
172
178
|
}
|
|
179
|
+
// The canvas's *logical* resolution — the fixed space every renderable
|
|
180
|
+
// position, and every mouse/touch coordinate, is expressed in. Captured
|
|
181
|
+
// here, before pixelRatio (below) scales the actual backing buffer past
|
|
182
|
+
// it, so both stay anchored to this regardless of what pixelRatio does.
|
|
183
|
+
const logicalWidth = canvas.width;
|
|
184
|
+
const logicalHeight = canvas.height;
|
|
185
|
+
// Scales the backing buffer beyond logicalWidth/logicalHeight so text
|
|
186
|
+
// and vector shapes (fillText, arc, ...) render crisply on a HiDPI
|
|
187
|
+
// screen — most phones — instead of the same logical-resolution buffer
|
|
188
|
+
// just being stretched larger by applyResize below. `true` follows the
|
|
189
|
+
// display's own devicePixelRatio; a number sets it explicitly; leaving
|
|
190
|
+
// this unset keeps today's 1x behavior. Sprites are unaffected either
|
|
191
|
+
// way — imageSmoothingEnabled stays false below regardless — so pixel
|
|
192
|
+
// art has no reason to turn this on.
|
|
193
|
+
const pixelRatio = props.canvas?.pixelRatio === true ? window.devicePixelRatio || 1 : props.canvas?.pixelRatio ?? 1;
|
|
194
|
+
if (pixelRatio !== 1) {
|
|
195
|
+
// Resizing the backing buffer resets the 2D context's transform (and
|
|
196
|
+
// everything else about its state), so context.scale below has to
|
|
197
|
+
// come after this, not before.
|
|
198
|
+
canvas.width = logicalWidth * pixelRatio;
|
|
199
|
+
canvas.height = logicalHeight * pixelRatio;
|
|
200
|
+
canvas.style.width = `${logicalWidth}px`;
|
|
201
|
+
canvas.style.height = `${logicalHeight}px`;
|
|
202
|
+
context.scale(pixelRatio, pixelRatio);
|
|
203
|
+
}
|
|
173
204
|
// Make the canvas focusable so keyboard input is scoped to it instead of
|
|
174
205
|
// leaking to the rest of the page (e.g. arrow keys scrolling the window).
|
|
175
206
|
canvas.tabIndex = 0;
|
|
207
|
+
// Stops the browser from treating a drag/pinch on the canvas as page
|
|
208
|
+
// scroll/zoom — backs up the touchstart/touchmove preventDefault calls
|
|
209
|
+
// below for gestures (e.g. a pinch starting on the canvas) preventDefault
|
|
210
|
+
// alone doesn't reliably stop.
|
|
211
|
+
canvas.style.touchAction = "none";
|
|
212
|
+
// Resizes the *display* size only (CSS width/height) — canvas.width/
|
|
213
|
+
// height above stays the fixed logical resolution every renderable's
|
|
214
|
+
// position is already expressed in, so this never needs to touch any
|
|
215
|
+
// of that math. See RunEngineProps.canvas.resize for the mode semantics.
|
|
216
|
+
const applyResize = () => {
|
|
217
|
+
const mode = props.canvas?.resize;
|
|
218
|
+
if (mode === undefined || mode === "none") {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (mode === "stretch") {
|
|
222
|
+
canvas.style.width = `${window.innerWidth}px`;
|
|
223
|
+
canvas.style.height = `${window.innerHeight}px`;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const scale = Math.min(window.innerWidth / canvas.width, window.innerHeight / canvas.height);
|
|
227
|
+
canvas.style.width = `${canvas.width * scale}px`;
|
|
228
|
+
canvas.style.height = `${canvas.height * scale}px`;
|
|
229
|
+
};
|
|
230
|
+
applyResize();
|
|
231
|
+
window.addEventListener("resize", applyResize);
|
|
232
|
+
const requestFullscreen = () => canvas.requestFullscreen();
|
|
233
|
+
// exitFullscreen() rejects with a TypeError if nothing is fullscreen —
|
|
234
|
+
// guarded into a no-op instead, so callers don't need to track that
|
|
235
|
+
// state themselves just to call this safely.
|
|
236
|
+
const exitFullscreen = () => window.document.fullscreenElement === null ? Promise.resolve() : window.document.exitFullscreen();
|
|
176
237
|
let state = props.initialState;
|
|
177
238
|
const events = [];
|
|
178
239
|
// Lets a caller report something that happened outside the render loop
|
|
@@ -185,24 +246,35 @@ const runEngine = async (props) => {
|
|
|
185
246
|
const resources = props.resources ?? {};
|
|
186
247
|
const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
|
|
187
248
|
const image = new Image();
|
|
188
|
-
|
|
249
|
+
// A single, unsliced image (1x1) if unset — only an actual
|
|
250
|
+
// spritesheet needs this declared.
|
|
251
|
+
const slices = value.slices ?? { horizontal: 1, vertical: 1 };
|
|
252
|
+
// sheetSize is the whole loaded sheet's pixel dimensions — value's
|
|
253
|
+
// declared `size` if set, otherwise whatever the image actually
|
|
254
|
+
// measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
|
|
255
|
+
// that isn't available, i.e. no `size` declared *and* the image
|
|
256
|
+
// failed to load too).
|
|
257
|
+
const settle = (loadedImage, sheetSize) => {
|
|
189
258
|
resolve({
|
|
190
259
|
image: loadedImage,
|
|
191
260
|
size: {
|
|
192
|
-
width:
|
|
193
|
-
height:
|
|
261
|
+
width: sheetSize.width / slices.horizontal,
|
|
262
|
+
height: sheetSize.height / slices.vertical,
|
|
194
263
|
},
|
|
195
|
-
slices
|
|
264
|
+
slices,
|
|
196
265
|
animations: value.animations ?? {},
|
|
197
266
|
});
|
|
198
267
|
};
|
|
199
268
|
image.src = value.src;
|
|
200
|
-
image.onload = () => settle(image);
|
|
269
|
+
image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
|
|
201
270
|
// Missing/failed-to-load asset (see .gitignore's dist/resources/
|
|
202
271
|
// note) — a placeholder sheet, sized to match what this resource
|
|
203
272
|
// declared, keeps every frame/slice/animation index the example
|
|
204
273
|
// already computes valid instead of drawing nothing or throwing.
|
|
205
|
-
image.onerror = () =>
|
|
274
|
+
image.onerror = () => {
|
|
275
|
+
const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
|
|
276
|
+
settle(createPlaceholderSheet(placeholderSize), placeholderSize);
|
|
277
|
+
};
|
|
206
278
|
}));
|
|
207
279
|
const loadAudio = (src) => new Promise((resolve) => {
|
|
208
280
|
const audio = new Audio(src);
|
|
@@ -279,9 +351,11 @@ const runEngine = async (props) => {
|
|
|
279
351
|
};
|
|
280
352
|
// A newer runEngine() call started while this one was still loading
|
|
281
353
|
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
282
|
-
// up a second, orphaned render loop alongside the newer one.
|
|
354
|
+
// up a second, orphaned render loop alongside the newer one. The
|
|
355
|
+
// fullscreen functions are no-ops here since this run never gets far
|
|
356
|
+
// enough to own the canvas — the newer run's are the ones that matter.
|
|
283
357
|
if (runId !== latestRunId) {
|
|
284
|
-
return { sendEvent };
|
|
358
|
+
return { sendEvent, requestFullscreen: () => Promise.resolve(), exitFullscreen: () => Promise.resolve() };
|
|
285
359
|
}
|
|
286
360
|
context.imageSmoothingEnabled = false;
|
|
287
361
|
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
@@ -561,8 +635,20 @@ const runEngine = async (props) => {
|
|
|
561
635
|
// the same conversion to line up.
|
|
562
636
|
const getCanvasPosition = (ev) => {
|
|
563
637
|
return {
|
|
564
|
-
x: (ev.offsetX *
|
|
565
|
-
y: (ev.offsetY *
|
|
638
|
+
x: (ev.offsetX * logicalWidth) / canvas.clientWidth,
|
|
639
|
+
y: (ev.offsetY * logicalHeight) / canvas.clientHeight,
|
|
640
|
+
};
|
|
641
|
+
};
|
|
642
|
+
// Touch's equivalent of getCanvasPosition — a Touch has no offsetX/Y
|
|
643
|
+
// (that's a MouseEvent-only convenience), so this gets there manually
|
|
644
|
+
// via getBoundingClientRect instead. clientX/Y and the rect are both
|
|
645
|
+
// viewport-relative, so their difference stays correct regardless of
|
|
646
|
+
// page scroll.
|
|
647
|
+
const getTouchPosition = (touch) => {
|
|
648
|
+
const rect = canvas.getBoundingClientRect();
|
|
649
|
+
return {
|
|
650
|
+
x: ((touch.clientX - rect.left) * logicalWidth) / canvas.clientWidth,
|
|
651
|
+
y: ((touch.clientY - rect.top) * logicalHeight) / canvas.clientHeight,
|
|
566
652
|
};
|
|
567
653
|
};
|
|
568
654
|
const updateState = (updateFn) => {
|
|
@@ -571,8 +657,11 @@ const runEngine = async (props) => {
|
|
|
571
657
|
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
572
658
|
let lastFrame = Date.now();
|
|
573
659
|
let hoveredId = null;
|
|
574
|
-
|
|
575
|
-
|
|
660
|
+
// Shared by the mouse "click" listener and the touch handlers below —
|
|
661
|
+
// firing a CLICK is the same "is whatever's currently hovered
|
|
662
|
+
// isClickable" check either way; only how `mouse` was determined
|
|
663
|
+
// differs (a real click event vs. a lifted finger).
|
|
664
|
+
const fireClick = (mouse) => {
|
|
576
665
|
if (hoveredId === null)
|
|
577
666
|
return;
|
|
578
667
|
const { renderables, camera } = renderState(state);
|
|
@@ -581,6 +670,7 @@ const runEngine = async (props) => {
|
|
|
581
670
|
events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
|
|
582
671
|
}
|
|
583
672
|
};
|
|
673
|
+
const handleClick = (ev) => fireClick(getCanvasPosition(ev));
|
|
584
674
|
canvas.addEventListener("click", handleClick);
|
|
585
675
|
const initialState = {
|
|
586
676
|
keyboardState: createRecord(keyboardKeys, () => false),
|
|
@@ -610,8 +700,34 @@ const runEngine = async (props) => {
|
|
|
610
700
|
}
|
|
611
701
|
};
|
|
612
702
|
canvas.addEventListener("keyup", handleKeyUp);
|
|
613
|
-
|
|
614
|
-
|
|
703
|
+
// mouseButton state (see NextStateProps.mouseButton) — a double-buffer
|
|
704
|
+
// exactly like keyboardState above, just for the primary mouse button.
|
|
705
|
+
let previousMouseButtonState = { isPressed: false };
|
|
706
|
+
const currentMouseButtonState = { isPressed: false };
|
|
707
|
+
const handleMouseDown = (event) => {
|
|
708
|
+
// Left/primary button only — matching CLICK, which already only
|
|
709
|
+
// ever fires for it.
|
|
710
|
+
if (event.button === 0) {
|
|
711
|
+
currentMouseButtonState.isPressed = true;
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
canvas.addEventListener("mousedown", handleMouseDown);
|
|
715
|
+
const handleMouseUp = (event) => {
|
|
716
|
+
if (event.button === 0) {
|
|
717
|
+
currentMouseButtonState.isPressed = false;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
// On window rather than the canvas — so releasing the button after
|
|
721
|
+
// having dragged off the canvas while still holding it down still
|
|
722
|
+
// clears isPressed, instead of leaving it stuck true forever.
|
|
723
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
724
|
+
// Shared by mousemove and the touch handlers below — updates hoveredId
|
|
725
|
+
// from a canvas position and fires HOVER_IN/HOVER_OUT as whatever's
|
|
726
|
+
// underneath it changes. Touch has no ambient hover the way a mouse
|
|
727
|
+
// does (nothing is "hovered" until a finger actually touches down), but
|
|
728
|
+
// feeding a touch's position through this the same as the mouse's is
|
|
729
|
+
// what lets an isHoverable renderable react to a tap/drag at all.
|
|
730
|
+
const updateHover = (mouse) => {
|
|
615
731
|
const { renderables, camera } = renderState(state);
|
|
616
732
|
const worldMouse = toWorldPosition(mouse, camera);
|
|
617
733
|
const hovered = [...renderables]
|
|
@@ -628,9 +744,9 @@ const runEngine = async (props) => {
|
|
|
628
744
|
}
|
|
629
745
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
630
746
|
};
|
|
747
|
+
const handleMouseMoveHover = (ev) => updateHover(getCanvasPosition(ev));
|
|
631
748
|
canvas.addEventListener("mousemove", handleMouseMoveHover);
|
|
632
|
-
const
|
|
633
|
-
const mouse = getCanvasPosition(ev);
|
|
749
|
+
const updateTracking = (mouse) => {
|
|
634
750
|
if (hoveredId === null) {
|
|
635
751
|
return;
|
|
636
752
|
}
|
|
@@ -640,13 +756,17 @@ const runEngine = async (props) => {
|
|
|
640
756
|
events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
|
|
641
757
|
}
|
|
642
758
|
};
|
|
759
|
+
const handleMouseMoveTracking = (ev) => updateTracking(getCanvasPosition(ev));
|
|
643
760
|
canvas.addEventListener("mousemove", handleMouseMoveTracking);
|
|
644
761
|
// No further mousemove fires once the mouse is off the canvas, so this
|
|
645
762
|
// is also the only chance to report a HOVER_OUT for whatever was
|
|
646
763
|
// hovered when it left — otherwise that hover would just dangle,
|
|
647
764
|
// never explicitly ended.
|
|
648
|
-
|
|
649
|
-
|
|
765
|
+
// Shared by mouseleave and the touch handlers below — clears whatever's
|
|
766
|
+
// hovered (firing HOVER_OUT for it) without a HOVER_IN taking its
|
|
767
|
+
// place. Returns worldMouse so callers that need it (MOUSE_LEAVE below)
|
|
768
|
+
// don't have to call renderState() a second time just to get it.
|
|
769
|
+
const clearHover = (mouse) => {
|
|
650
770
|
const { renderables, camera } = renderState(state);
|
|
651
771
|
const worldMouse = toWorldPosition(mouse, camera);
|
|
652
772
|
if (hoveredId !== null) {
|
|
@@ -656,9 +776,79 @@ const runEngine = async (props) => {
|
|
|
656
776
|
}
|
|
657
777
|
}
|
|
658
778
|
hoveredId = null;
|
|
779
|
+
return worldMouse;
|
|
780
|
+
};
|
|
781
|
+
const handleMouseLeave = (ev) => {
|
|
782
|
+
const mouse = getCanvasPosition(ev);
|
|
783
|
+
const worldMouse = clearHover(mouse);
|
|
659
784
|
events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
|
|
660
785
|
};
|
|
661
786
|
canvas.addEventListener("mouseleave", handleMouseLeave);
|
|
787
|
+
// Translates touch into the same HOVER_IN/HOVER_OUT/MOUSE_MOVE/CLICK
|
|
788
|
+
// events mouse input already produces (via updateHover/updateTracking/
|
|
789
|
+
// fireClick/clearHover above), so existing game code written against
|
|
790
|
+
// those events works on a touchscreen with no changes of its own.
|
|
791
|
+
// Single-touch only — touches[0]/changedTouches[0] — the same "one
|
|
792
|
+
// active pointer" model mouse input already assumes; a second finger is
|
|
793
|
+
// ignored rather than tracked as its own pointer.
|
|
794
|
+
//
|
|
795
|
+
// preventDefault on start/move keeps a drag/tap on the canvas from also
|
|
796
|
+
// scrolling, pinch-zooming, or triggering pull-to-refresh — the browser
|
|
797
|
+
// gestures a touchscreen normally reserves that space for; { passive:
|
|
798
|
+
// false } is what makes preventDefault actually take effect here.
|
|
799
|
+
const handleTouchStart = (ev) => {
|
|
800
|
+
ev.preventDefault();
|
|
801
|
+
const touch = ev.touches[0];
|
|
802
|
+
if (touch === undefined)
|
|
803
|
+
return;
|
|
804
|
+
currentMouseButtonState.isPressed = true;
|
|
805
|
+
const mouse = getTouchPosition(touch);
|
|
806
|
+
updateHover(mouse);
|
|
807
|
+
updateTracking(mouse);
|
|
808
|
+
};
|
|
809
|
+
canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
|
|
810
|
+
const handleTouchMove = (ev) => {
|
|
811
|
+
ev.preventDefault();
|
|
812
|
+
const touch = ev.touches[0];
|
|
813
|
+
if (touch === undefined)
|
|
814
|
+
return;
|
|
815
|
+
const mouse = getTouchPosition(touch);
|
|
816
|
+
updateHover(mouse);
|
|
817
|
+
updateTracking(mouse);
|
|
818
|
+
};
|
|
819
|
+
canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
820
|
+
// A lifted finger both releases (mirroring mouseup) and clicks
|
|
821
|
+
// (mirroring the browser's own click-after-mouseup) — touch has no
|
|
822
|
+
// separate "up" and "click" events of its own the way mouse does, so
|
|
823
|
+
// both happen here together, in that order. hover is updated once more
|
|
824
|
+
// first so a plain tap (touchstart immediately followed by touchend,
|
|
825
|
+
// with no touchmove between them to have already done this) still
|
|
826
|
+
// fires CLICK against whatever's actually under it; hover is then
|
|
827
|
+
// cleared, since nothing's left touching it once the finger lifts.
|
|
828
|
+
const handleTouchEnd = (ev) => {
|
|
829
|
+
ev.preventDefault();
|
|
830
|
+
currentMouseButtonState.isPressed = false;
|
|
831
|
+
const touch = ev.changedTouches[0];
|
|
832
|
+
if (touch === undefined)
|
|
833
|
+
return;
|
|
834
|
+
const mouse = getTouchPosition(touch);
|
|
835
|
+
updateHover(mouse);
|
|
836
|
+
fireClick(mouse);
|
|
837
|
+
clearHover(mouse);
|
|
838
|
+
};
|
|
839
|
+
canvas.addEventListener("touchend", handleTouchEnd, { passive: false });
|
|
840
|
+
// A cancelled touch (e.g. an incoming call interrupting the page, or
|
|
841
|
+
// the OS deciding it's a system gesture instead) never fires touchend —
|
|
842
|
+
// handled the same as lifting the finger, minus the click, since
|
|
843
|
+
// there's no tap to speak of once the touch itself has been cancelled.
|
|
844
|
+
const handleTouchCancel = (ev) => {
|
|
845
|
+
currentMouseButtonState.isPressed = false;
|
|
846
|
+
const touch = ev.changedTouches[0];
|
|
847
|
+
if (touch === undefined)
|
|
848
|
+
return;
|
|
849
|
+
clearHover(getTouchPosition(touch));
|
|
850
|
+
};
|
|
851
|
+
canvas.addEventListener("touchcancel", handleTouchCancel, { passive: false });
|
|
662
852
|
// Tab switches are a document-level concern (visibilitychange), not
|
|
663
853
|
// something that ever reaches the canvas itself the way mouse/keyboard
|
|
664
854
|
// events do.
|
|
@@ -666,6 +856,17 @@ const runEngine = async (props) => {
|
|
|
666
856
|
events.push({ tag: window.document.hidden ? "TAB_BLUR" : "TAB_FOCUS" });
|
|
667
857
|
};
|
|
668
858
|
window.document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
859
|
+
// Fullscreen is a document-level concern too, and fires for every way
|
|
860
|
+
// fullscreen can change — the requestFullscreen()/exitFullscreen()
|
|
861
|
+
// above, but also things neither of those causes directly, like the
|
|
862
|
+
// user pressing Esc. Re-running applyResize() here (rather than relying
|
|
863
|
+
// solely on the "resize" listener) covers browsers that don't also fire
|
|
864
|
+
// a window resize when fullscreen is toggled.
|
|
865
|
+
const handleFullscreenChange = () => {
|
|
866
|
+
events.push({ tag: "FULLSCREEN_CHANGE", isFullscreen: window.document.fullscreenElement === canvas });
|
|
867
|
+
applyResize();
|
|
868
|
+
};
|
|
869
|
+
window.document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
669
870
|
context.imageSmoothingEnabled = false;
|
|
670
871
|
// Scale is a canvas transform around the renderable's anchor, applied
|
|
671
872
|
// before its type-specific drawing runs below — everything drawn under
|
|
@@ -779,11 +980,17 @@ const runEngine = async (props) => {
|
|
|
779
980
|
isJustReleased: !isPressed && previouslyPressed,
|
|
780
981
|
};
|
|
781
982
|
});
|
|
983
|
+
const mouseButton = {
|
|
984
|
+
isPressed: currentMouseButtonState.isPressed,
|
|
985
|
+
isJustPressed: currentMouseButtonState.isPressed && !previousMouseButtonState.isPressed,
|
|
986
|
+
isJustReleased: !currentMouseButtonState.isPressed && previousMouseButtonState.isPressed,
|
|
987
|
+
};
|
|
782
988
|
for (const nextState of nextStateFns) {
|
|
783
989
|
const result = nextState({
|
|
784
990
|
state,
|
|
785
991
|
event,
|
|
786
992
|
keyboard,
|
|
993
|
+
mouseButton,
|
|
787
994
|
playSound,
|
|
788
995
|
playMusic,
|
|
789
996
|
pauseMusic,
|
|
@@ -803,7 +1010,7 @@ const runEngine = async (props) => {
|
|
|
803
1010
|
}
|
|
804
1011
|
}
|
|
805
1012
|
events.splice(0, events.length);
|
|
806
|
-
context.clearRect(0, 0,
|
|
1013
|
+
context.clearRect(0, 0, logicalWidth, logicalHeight);
|
|
807
1014
|
const { cursor, renderables } = renderState(state);
|
|
808
1015
|
canvas.style.cursor = cursor ?? "default";
|
|
809
1016
|
for (const renderable of renderables) {
|
|
@@ -836,12 +1043,11 @@ const runEngine = async (props) => {
|
|
|
836
1043
|
continue;
|
|
837
1044
|
}
|
|
838
1045
|
if (renderable.type === "SPRITE") {
|
|
839
|
-
const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
|
|
1046
|
+
const { opacity = 1, flipX = false, frame: frameIndex = 0, modulate, swapColors } = renderable;
|
|
840
1047
|
const resource = resourceById[renderable.resourceId];
|
|
841
1048
|
const frame = {
|
|
842
|
-
x:
|
|
843
|
-
y: Math.floor(
|
|
844
|
-
resource.slices.vertical,
|
|
1049
|
+
x: frameIndex % resource.slices.horizontal,
|
|
1050
|
+
y: Math.floor(frameIndex / resource.slices.horizontal) % resource.slices.vertical,
|
|
845
1051
|
};
|
|
846
1052
|
const source = {
|
|
847
1053
|
x: frame.x * resource.size.width,
|
|
@@ -861,7 +1067,7 @@ const runEngine = async (props) => {
|
|
|
861
1067
|
let image = resource.image;
|
|
862
1068
|
let imageSource = source;
|
|
863
1069
|
if (swapColors !== undefined && swapColors.length > 0) {
|
|
864
|
-
image = swappedSpriteFrame(renderable.resourceId,
|
|
1070
|
+
image = swappedSpriteFrame(renderable.resourceId, frameIndex, image, imageSource, swapColors);
|
|
865
1071
|
imageSource = { x: 0, y: 0, width: source.width, height: source.height };
|
|
866
1072
|
}
|
|
867
1073
|
if (modulate !== undefined) {
|
|
@@ -915,6 +1121,7 @@ const runEngine = async (props) => {
|
|
|
915
1121
|
}
|
|
916
1122
|
lastFrame = now;
|
|
917
1123
|
previousState.keyboardState = { ...currentState.keyboardState };
|
|
1124
|
+
previousMouseButtonState = { ...currentMouseButtonState };
|
|
918
1125
|
}, 0);
|
|
919
1126
|
resetCanvas = () => {
|
|
920
1127
|
clearInterval(intervalId);
|
|
@@ -938,15 +1145,62 @@ const runEngine = async (props) => {
|
|
|
938
1145
|
canvas.removeEventListener("click", handleClick);
|
|
939
1146
|
canvas.removeEventListener("keydown", handleKeyDown);
|
|
940
1147
|
canvas.removeEventListener("keyup", handleKeyUp);
|
|
1148
|
+
canvas.removeEventListener("mousedown", handleMouseDown);
|
|
941
1149
|
canvas.removeEventListener("mousemove", handleMouseMoveHover);
|
|
942
1150
|
canvas.removeEventListener("mousemove", handleMouseMoveTracking);
|
|
943
1151
|
canvas.removeEventListener("mouseleave", handleMouseLeave);
|
|
1152
|
+
canvas.removeEventListener("touchstart", handleTouchStart);
|
|
1153
|
+
canvas.removeEventListener("touchmove", handleTouchMove);
|
|
1154
|
+
canvas.removeEventListener("touchend", handleTouchEnd);
|
|
1155
|
+
canvas.removeEventListener("touchcancel", handleTouchCancel);
|
|
1156
|
+
// On window, not the canvas — see where it's added above for why.
|
|
1157
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
944
1158
|
// This one's on `document`, not the canvas — same reasoning as above,
|
|
945
1159
|
// just doubly true since `document` isn't even scoped to this canvas.
|
|
946
1160
|
window.document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1161
|
+
window.document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
1162
|
+
// Same pile-up risk as the canvas listeners above, but on `window`.
|
|
1163
|
+
window.removeEventListener("resize", applyResize);
|
|
947
1164
|
};
|
|
948
|
-
return { sendEvent };
|
|
1165
|
+
return { sendEvent, requestFullscreen, exitFullscreen };
|
|
949
1166
|
};
|
|
950
1167
|
|
|
951
|
-
|
|
1168
|
+
// One factory per Renderable variant — literally just `{ type: "X",
|
|
1169
|
+
// ...props }`, so `sprite({...})` (or `Yuuna.sprite({...})` in the
|
|
1170
|
+
// browser bundle/playground) reads the same as writing the object
|
|
1171
|
+
// literal by hand, minus needing to get `type` right yourself.
|
|
1172
|
+
// Deliberately not a place defaults live: besides SpriteRenderable.frame
|
|
1173
|
+
// (optional at the type level, see types.ts — the engine itself defaults
|
|
1174
|
+
// it to 0), each factory still requires whatever its Renderable type
|
|
1175
|
+
// still requires.
|
|
1176
|
+
const rectangle = (props) => ({
|
|
1177
|
+
type: "RECTANGLE",
|
|
1178
|
+
...props,
|
|
1179
|
+
});
|
|
1180
|
+
const circle = (props) => ({
|
|
1181
|
+
type: "CIRCLE",
|
|
1182
|
+
...props,
|
|
1183
|
+
});
|
|
1184
|
+
const text = (props) => ({
|
|
1185
|
+
type: "TEXT",
|
|
1186
|
+
...props,
|
|
1187
|
+
});
|
|
1188
|
+
const sprite = (props) => ({
|
|
1189
|
+
type: "SPRITE",
|
|
1190
|
+
...props,
|
|
1191
|
+
});
|
|
1192
|
+
const animatedSprite = (props) => ({
|
|
1193
|
+
type: "ANIMATED_SPRITE",
|
|
1194
|
+
...props,
|
|
1195
|
+
});
|
|
1196
|
+
const line = (props) => ({
|
|
1197
|
+
type: "LINE",
|
|
1198
|
+
...props,
|
|
1199
|
+
});
|
|
1200
|
+
const group = (props) => ({
|
|
1201
|
+
type: "GROUP",
|
|
1202
|
+
...props,
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
export { STOP, animatedSprite, circle, group, line, rectangle, runEngine, sprite, text };
|
|
952
1206
|
//# 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.
|
|
3
|
+
"version": "0.7.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",
|