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/README.md +177 -156
- package/lib/engine/renderables.d.ts +8 -0
- package/lib/engine/types.d.ts +33 -17
- package/lib/index.cjs +298 -41
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +292 -42
- 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,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,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 {
|
|
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
|
-
|
|
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 *
|
|
592
|
-
y: (ev.offsetY *
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
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,
|
|
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:
|
|
881
|
-
y: Math.floor(
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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",
|