minimojs 1.0.0-alpha.1 → 1.0.0-alpha.2
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/dist/animations.js +30 -0
- package/dist/audio.js +17 -0
- package/dist/game.js +1105 -0
- package/dist/input.js +185 -0
- package/dist/internal-types.js +4 -0
- package/dist/minimo.d.ts +123 -0
- package/dist/minimo.js +308 -39
- package/dist/physics.js +10 -0
- package/dist/pointer-info.js +1 -0
- package/dist/render.js +75 -0
- package/dist/sprite.js +149 -0
- package/dist/timers.js +23 -0
- package/package.json +1 -1
package/dist/minimo.js
CHANGED
|
@@ -386,12 +386,19 @@ export class Game {
|
|
|
386
386
|
/** @internal */ this._pointerPressed = false;
|
|
387
387
|
/** @internal */ this._pointerX = 0;
|
|
388
388
|
/** @internal */ this._pointerY = 0;
|
|
389
|
+
/** @internal */ this._mouseDown = false;
|
|
390
|
+
/** @internal */ this._mousePressed = false;
|
|
391
|
+
/** @internal */ this._mouseX = 0;
|
|
392
|
+
/** @internal */ this._mouseY = 0;
|
|
393
|
+
/** @internal */ this._touchPointers = new Map();
|
|
394
|
+
/** @internal */ this._primaryTouchId = null;
|
|
389
395
|
/** @internal */ this._rafId = null;
|
|
390
396
|
/** @internal */ this._lastTimestamp = null;
|
|
391
397
|
/** @internal */ this._running = false;
|
|
392
398
|
/** @internal */ this._hasCreated = false;
|
|
393
399
|
/** @internal */ this._onResize = () => this._applyResponsiveCanvasLayout();
|
|
394
400
|
/** @internal */ this._audioCtx = null;
|
|
401
|
+
/** @internal */ this._spriteGlyphCache = new Map();
|
|
395
402
|
/** @internal */ this._lastAppliedPageBackground = undefined;
|
|
396
403
|
// -------------------------------------------------------------------------
|
|
397
404
|
// Public state
|
|
@@ -791,6 +798,136 @@ export class Game {
|
|
|
791
798
|
isPointerPressed() {
|
|
792
799
|
return this._pointerPressed;
|
|
793
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Returns `true` while any active pointer is held down over the target sprite.
|
|
803
|
+
* Works with both mouse input and multiple simultaneous touches.
|
|
804
|
+
*
|
|
805
|
+
* Pointer hit testing uses a circular area centered on the sprite. The radius
|
|
806
|
+
* is `sprite.size * radiusScale`. World-space sprites are tested against the
|
|
807
|
+
* current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
|
|
808
|
+
* screen space.
|
|
809
|
+
*
|
|
810
|
+
* Use this for continuous virtual buttons such as touch movement controls.
|
|
811
|
+
*
|
|
812
|
+
* @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
|
|
813
|
+
* @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
|
|
814
|
+
* Default: `0.5`.
|
|
815
|
+
* @returns `true` if any currently held pointer overlaps the sprite hit area.
|
|
816
|
+
*
|
|
817
|
+
* @example
|
|
818
|
+
* ```ts
|
|
819
|
+
* if (game.isPointerDownOverSprite(leftButton, 0.72)) {
|
|
820
|
+
* player.x -= 200 * dt;
|
|
821
|
+
* }
|
|
822
|
+
* ```
|
|
823
|
+
*/
|
|
824
|
+
isPointerDownOverSprite(sprite, radiusScale = 0.5) {
|
|
825
|
+
if (!sprite)
|
|
826
|
+
return false;
|
|
827
|
+
if (this._mouseDown &&
|
|
828
|
+
this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
for (const pointer of this._touchPointers.values()) {
|
|
832
|
+
if (this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Returns `true` only on the frame any pointer first pressed over the target
|
|
840
|
+
* sprite. Works with both mouse input and multiple simultaneous touches.
|
|
841
|
+
*
|
|
842
|
+
* Pointer hit testing uses a circular area centered on the sprite. The radius
|
|
843
|
+
* is `sprite.size * radiusScale`. World-space sprites are tested against the
|
|
844
|
+
* current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
|
|
845
|
+
* screen space.
|
|
846
|
+
*
|
|
847
|
+
* Use this for one-shot virtual buttons such as menu taps.
|
|
848
|
+
*
|
|
849
|
+
* @param sprite - Target sprite to test. If `null` / `undefined`, returns `false`.
|
|
850
|
+
* @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
|
|
851
|
+
* Default: `0.5`.
|
|
852
|
+
* @returns `true` if any pointer began pressing this frame over the sprite hit area.
|
|
853
|
+
*
|
|
854
|
+
* @example
|
|
855
|
+
* ```ts
|
|
856
|
+
* if (game.isPointerPressedOverSprite(startButton, 0.8)) {
|
|
857
|
+
* startGame();
|
|
858
|
+
* }
|
|
859
|
+
* ```
|
|
860
|
+
*/
|
|
861
|
+
isPointerPressedOverSprite(sprite, radiusScale = 0.5) {
|
|
862
|
+
if (!sprite)
|
|
863
|
+
return false;
|
|
864
|
+
if (this._mousePressed &&
|
|
865
|
+
this._isScreenPointOverSprite(this._mouseX, this._mouseY, sprite, radiusScale)) {
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
for (const pointer of this._touchPointers.values()) {
|
|
869
|
+
if (pointer.pressed &&
|
|
870
|
+
this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale)) {
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Returns a read-only snapshot of all currently active pointers.
|
|
878
|
+
*
|
|
879
|
+
* Mouse appears in the list only while the mouse button is held down. Touches
|
|
880
|
+
* appear while they remain active on the canvas. Coordinates are returned in
|
|
881
|
+
* canvas/screen space.
|
|
882
|
+
*
|
|
883
|
+
* Use this for advanced multitouch controls such as joysticks, drag handles,
|
|
884
|
+
* or gesture-like gameplay logic.
|
|
885
|
+
*
|
|
886
|
+
* @returns A read-only array of active pointer snapshots.
|
|
887
|
+
*
|
|
888
|
+
* @example
|
|
889
|
+
* ```ts
|
|
890
|
+
* const pointers = game.getPointers();
|
|
891
|
+
* if (pointers.length > 0) {
|
|
892
|
+
* const first = pointers[0];
|
|
893
|
+
* game.text(`Pointer: ${first.x}, ${first.y}`, 10, 10);
|
|
894
|
+
* }
|
|
895
|
+
* ```
|
|
896
|
+
*/
|
|
897
|
+
getPointers() {
|
|
898
|
+
return this._collectPointers();
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Returns a read-only snapshot of all active pointers currently overlapping
|
|
902
|
+
* the target sprite.
|
|
903
|
+
*
|
|
904
|
+
* Pointer hit testing uses a circular area centered on the sprite. The radius
|
|
905
|
+
* is `sprite.size * radiusScale`. World-space sprites are tested against the
|
|
906
|
+
* current camera scroll. HUD sprites with `ignoreScroll = true` are tested in
|
|
907
|
+
* screen space.
|
|
908
|
+
*
|
|
909
|
+
* Use this when you need more than a boolean result, such as reading the exact
|
|
910
|
+
* pointer position over a virtual joystick or draggable control.
|
|
911
|
+
*
|
|
912
|
+
* @param sprite - Target sprite to test. If `null` / `undefined`, returns an empty array.
|
|
913
|
+
* @param radiusScale - Multiplier applied to `sprite.size` to define the hit radius.
|
|
914
|
+
* Default: `0.5`.
|
|
915
|
+
* @returns A read-only array of active pointer snapshots currently over the sprite.
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* ```ts
|
|
919
|
+
* const touches = game.getPointersOverSprite(joystickBase, 1.0);
|
|
920
|
+
* if (touches.length > 0) {
|
|
921
|
+
* const p = touches[0];
|
|
922
|
+
* const dx = p.x - joystickBase.x;
|
|
923
|
+
* }
|
|
924
|
+
* ```
|
|
925
|
+
*/
|
|
926
|
+
getPointersOverSprite(sprite, radiusScale = 0.5) {
|
|
927
|
+
if (!sprite)
|
|
928
|
+
return [];
|
|
929
|
+
return this._collectPointers().filter((pointer) => this._isScreenPointOverSprite(pointer.x, pointer.y, sprite, radiusScale));
|
|
930
|
+
}
|
|
794
931
|
/**
|
|
795
932
|
* Returns `true` when the current device appears to be mobile/touch-first.
|
|
796
933
|
*
|
|
@@ -1095,7 +1232,11 @@ export class Game {
|
|
|
1095
1232
|
this._animations = [];
|
|
1096
1233
|
this._textOverlays = [];
|
|
1097
1234
|
this._keysPressed.clear();
|
|
1098
|
-
this.
|
|
1235
|
+
this._mousePressed = false;
|
|
1236
|
+
for (const pointer of this._touchPointers.values()) {
|
|
1237
|
+
pointer.pressed = false;
|
|
1238
|
+
}
|
|
1239
|
+
this._syncPrimaryPointer();
|
|
1099
1240
|
this.scrollX = 0;
|
|
1100
1241
|
this.scrollY = 0;
|
|
1101
1242
|
this._invokeCreate();
|
|
@@ -1161,58 +1302,152 @@ export class Game {
|
|
|
1161
1302
|
window.addEventListener("keyup", (e) => {
|
|
1162
1303
|
this._keysDown.delete(e.key);
|
|
1163
1304
|
});
|
|
1164
|
-
const getCanvasPoint = (
|
|
1305
|
+
const getCanvasPoint = (clientX, clientY) => {
|
|
1165
1306
|
const rect = this._canvas.getBoundingClientRect();
|
|
1166
1307
|
const scaleX = this._canvas.width / rect.width;
|
|
1167
1308
|
const scaleY = this._canvas.height / rect.height;
|
|
1168
|
-
let clientX;
|
|
1169
|
-
let clientY;
|
|
1170
|
-
if (e instanceof MouseEvent) {
|
|
1171
|
-
clientX = e.clientX;
|
|
1172
|
-
clientY = e.clientY;
|
|
1173
|
-
}
|
|
1174
|
-
else {
|
|
1175
|
-
clientX = e.touches[0]?.clientX ?? this._pointerX;
|
|
1176
|
-
clientY = e.touches[0]?.clientY ?? this._pointerY;
|
|
1177
|
-
}
|
|
1178
1309
|
return {
|
|
1179
1310
|
x: (clientX - rect.left) * scaleX,
|
|
1180
1311
|
y: (clientY - rect.top) * scaleY,
|
|
1181
1312
|
};
|
|
1182
1313
|
};
|
|
1183
1314
|
this._canvas.addEventListener("mousedown", (e) => {
|
|
1184
|
-
const p = getCanvasPoint(e);
|
|
1185
|
-
this.
|
|
1186
|
-
this.
|
|
1187
|
-
this.
|
|
1188
|
-
this.
|
|
1315
|
+
const p = getCanvasPoint(e.clientX, e.clientY);
|
|
1316
|
+
this._mouseX = p.x;
|
|
1317
|
+
this._mouseY = p.y;
|
|
1318
|
+
this._mouseDown = true;
|
|
1319
|
+
this._mousePressed = true;
|
|
1320
|
+
this._syncPrimaryPointer();
|
|
1189
1321
|
});
|
|
1190
1322
|
this._canvas.addEventListener("mouseup", () => {
|
|
1191
|
-
this.
|
|
1323
|
+
this._mouseDown = false;
|
|
1324
|
+
this._syncPrimaryPointer();
|
|
1192
1325
|
});
|
|
1193
1326
|
this._canvas.addEventListener("mousemove", (e) => {
|
|
1194
|
-
const p = getCanvasPoint(e);
|
|
1195
|
-
this.
|
|
1196
|
-
this.
|
|
1327
|
+
const p = getCanvasPoint(e.clientX, e.clientY);
|
|
1328
|
+
this._mouseX = p.x;
|
|
1329
|
+
this._mouseY = p.y;
|
|
1330
|
+
this._syncPrimaryPointer();
|
|
1197
1331
|
});
|
|
1198
1332
|
this._canvas.addEventListener("touchstart", (e) => {
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1333
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
1334
|
+
const p = getCanvasPoint(touch.clientX, touch.clientY);
|
|
1335
|
+
this._touchPointers.set(touch.identifier, {
|
|
1336
|
+
x: p.x,
|
|
1337
|
+
y: p.y,
|
|
1338
|
+
pressed: true,
|
|
1339
|
+
});
|
|
1340
|
+
if (this._primaryTouchId === null) {
|
|
1341
|
+
this._primaryTouchId = touch.identifier;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
this._syncPrimaryPointer();
|
|
1345
|
+
e.preventDefault();
|
|
1346
|
+
}, { passive: false });
|
|
1347
|
+
this._canvas.addEventListener("touchend", (e) => {
|
|
1348
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
1349
|
+
this._touchPointers.delete(touch.identifier);
|
|
1350
|
+
if (this._primaryTouchId === touch.identifier) {
|
|
1351
|
+
this._primaryTouchId = null;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
this._syncPrimaryPointer();
|
|
1355
|
+
e.preventDefault();
|
|
1356
|
+
}, { passive: false });
|
|
1357
|
+
this._canvas.addEventListener("touchcancel", (e) => {
|
|
1358
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
1359
|
+
this._touchPointers.delete(touch.identifier);
|
|
1360
|
+
if (this._primaryTouchId === touch.identifier) {
|
|
1361
|
+
this._primaryTouchId = null;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
this._syncPrimaryPointer();
|
|
1204
1365
|
e.preventDefault();
|
|
1205
1366
|
}, { passive: false });
|
|
1206
|
-
this._canvas.addEventListener("touchend", () => {
|
|
1207
|
-
this._pointerDown = false;
|
|
1208
|
-
});
|
|
1209
1367
|
this._canvas.addEventListener("touchmove", (e) => {
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
1368
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
1369
|
+
const p = getCanvasPoint(touch.clientX, touch.clientY);
|
|
1370
|
+
const existing = this._touchPointers.get(touch.identifier);
|
|
1371
|
+
if (existing) {
|
|
1372
|
+
existing.x = p.x;
|
|
1373
|
+
existing.y = p.y;
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
this._touchPointers.set(touch.identifier, {
|
|
1377
|
+
x: p.x,
|
|
1378
|
+
y: p.y,
|
|
1379
|
+
pressed: false,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
this._syncPrimaryPointer();
|
|
1213
1384
|
e.preventDefault();
|
|
1214
1385
|
}, { passive: false });
|
|
1215
1386
|
}
|
|
1387
|
+
/** @internal */
|
|
1388
|
+
_isScreenPointOverSprite(x, y, sprite, radiusScale) {
|
|
1389
|
+
const safeScale = Math.max(0, radiusScale);
|
|
1390
|
+
const radius = sprite.size * safeScale;
|
|
1391
|
+
const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
|
|
1392
|
+
const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
|
|
1393
|
+
const dx = x - drawX;
|
|
1394
|
+
const dy = y - drawY;
|
|
1395
|
+
return dx * dx + dy * dy <= radius * radius;
|
|
1396
|
+
}
|
|
1397
|
+
/** @internal */
|
|
1398
|
+
_collectPointers() {
|
|
1399
|
+
const pointers = [];
|
|
1400
|
+
if (this._mouseDown) {
|
|
1401
|
+
pointers.push({
|
|
1402
|
+
id: "mouse",
|
|
1403
|
+
kind: "mouse",
|
|
1404
|
+
x: this._mouseX,
|
|
1405
|
+
y: this._mouseY,
|
|
1406
|
+
pressed: this._mousePressed,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
for (const [id, pointer] of this._touchPointers.entries()) {
|
|
1410
|
+
pointers.push({
|
|
1411
|
+
id,
|
|
1412
|
+
kind: "touch",
|
|
1413
|
+
x: pointer.x,
|
|
1414
|
+
y: pointer.y,
|
|
1415
|
+
pressed: pointer.pressed,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
return pointers;
|
|
1419
|
+
}
|
|
1420
|
+
/** @internal */
|
|
1421
|
+
_syncPrimaryPointer() {
|
|
1422
|
+
if (this._primaryTouchId !== null) {
|
|
1423
|
+
const primaryTouch = this._touchPointers.get(this._primaryTouchId);
|
|
1424
|
+
if (primaryTouch) {
|
|
1425
|
+
this._pointerX = primaryTouch.x;
|
|
1426
|
+
this._pointerY = primaryTouch.y;
|
|
1427
|
+
this._pointerDown = true;
|
|
1428
|
+
this._pointerPressed = primaryTouch.pressed;
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
this._primaryTouchId = null;
|
|
1432
|
+
}
|
|
1433
|
+
if (this._touchPointers.size > 0) {
|
|
1434
|
+
const firstTouchEntry = this._touchPointers.entries().next().value;
|
|
1435
|
+
if (firstTouchEntry) {
|
|
1436
|
+
const [touchId, touch] = firstTouchEntry;
|
|
1437
|
+
this._primaryTouchId = touchId;
|
|
1438
|
+
this._pointerX = touch.x;
|
|
1439
|
+
this._pointerY = touch.y;
|
|
1440
|
+
this._pointerDown = true;
|
|
1441
|
+
this._pointerPressed = touch.pressed;
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
this._primaryTouchId = null;
|
|
1446
|
+
this._pointerX = this._mouseX;
|
|
1447
|
+
this._pointerY = this._mouseY;
|
|
1448
|
+
this._pointerDown = this._mouseDown;
|
|
1449
|
+
this._pointerPressed = this._mousePressed;
|
|
1450
|
+
}
|
|
1216
1451
|
// -------------------------------------------------------------------------
|
|
1217
1452
|
// Private — rAF loop
|
|
1218
1453
|
// -------------------------------------------------------------------------
|
|
@@ -1235,7 +1470,11 @@ export class Game {
|
|
|
1235
1470
|
this.onUpdate(dt);
|
|
1236
1471
|
this._render();
|
|
1237
1472
|
this._keysPressed.clear();
|
|
1238
|
-
this.
|
|
1473
|
+
this._mousePressed = false;
|
|
1474
|
+
for (const pointer of this._touchPointers.values()) {
|
|
1475
|
+
pointer.pressed = false;
|
|
1476
|
+
}
|
|
1477
|
+
this._syncPrimaryPointer();
|
|
1239
1478
|
this._textOverlays = [];
|
|
1240
1479
|
this._rafId = requestAnimationFrame(this._loop.bind(this));
|
|
1241
1480
|
}
|
|
@@ -1294,6 +1533,34 @@ export class Game {
|
|
|
1294
1533
|
// Private — rendering
|
|
1295
1534
|
// -------------------------------------------------------------------------
|
|
1296
1535
|
/** @internal */
|
|
1536
|
+
_getSpriteGlyphCanvas(sprite) {
|
|
1537
|
+
const size = Math.max(1, Math.round(sprite.size));
|
|
1538
|
+
const cacheKey = `${sprite.sprite}::${size}`;
|
|
1539
|
+
const cached = this._spriteGlyphCache.get(cacheKey);
|
|
1540
|
+
if (cached)
|
|
1541
|
+
return cached;
|
|
1542
|
+
const glyphCanvas = document.createElement("canvas");
|
|
1543
|
+
const boxSize = Math.max(2, Math.ceil(size * 2));
|
|
1544
|
+
glyphCanvas.width = boxSize;
|
|
1545
|
+
glyphCanvas.height = boxSize;
|
|
1546
|
+
const glyphCtx = glyphCanvas.getContext("2d");
|
|
1547
|
+
if (!glyphCtx) {
|
|
1548
|
+
throw new Error("MinimoJS: Could not acquire a glyph rendering context.");
|
|
1549
|
+
}
|
|
1550
|
+
glyphCtx.clearRect(0, 0, boxSize, boxSize);
|
|
1551
|
+
glyphCtx.shadowColor = "transparent";
|
|
1552
|
+
glyphCtx.shadowBlur = 0;
|
|
1553
|
+
glyphCtx.shadowOffsetX = 0;
|
|
1554
|
+
glyphCtx.shadowOffsetY = 0;
|
|
1555
|
+
glyphCtx.fillStyle = "#ffffff";
|
|
1556
|
+
glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
|
1557
|
+
glyphCtx.textAlign = "center";
|
|
1558
|
+
glyphCtx.textBaseline = "middle";
|
|
1559
|
+
glyphCtx.fillText(sprite.sprite, boxSize / 2, boxSize / 2);
|
|
1560
|
+
this._spriteGlyphCache.set(cacheKey, glyphCanvas);
|
|
1561
|
+
return glyphCanvas;
|
|
1562
|
+
}
|
|
1563
|
+
/** @internal */
|
|
1297
1564
|
_render() {
|
|
1298
1565
|
const ctx = this._ctx;
|
|
1299
1566
|
const W = this._canvas.width;
|
|
@@ -1317,8 +1584,8 @@ export class Game {
|
|
|
1317
1584
|
continue;
|
|
1318
1585
|
ctx.save();
|
|
1319
1586
|
ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
|
|
1320
|
-
const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX;
|
|
1321
|
-
const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY;
|
|
1587
|
+
const drawX = Math.round(sprite.ignoreScroll ? sprite.x : sprite.x - this.scrollX);
|
|
1588
|
+
const drawY = Math.round(sprite.ignoreScroll ? sprite.y : sprite.y - this.scrollY);
|
|
1322
1589
|
ctx.translate(drawX, drawY);
|
|
1323
1590
|
if (sprite.rotation !== 0) {
|
|
1324
1591
|
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
|
@@ -1326,10 +1593,12 @@ export class Game {
|
|
|
1326
1593
|
if (sprite.flipX || sprite.flipY) {
|
|
1327
1594
|
ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
|
|
1328
1595
|
}
|
|
1329
|
-
ctx.
|
|
1330
|
-
ctx.
|
|
1331
|
-
ctx.
|
|
1332
|
-
ctx.
|
|
1596
|
+
ctx.shadowColor = "transparent";
|
|
1597
|
+
ctx.shadowBlur = 0;
|
|
1598
|
+
ctx.shadowOffsetX = 0;
|
|
1599
|
+
ctx.shadowOffsetY = 0;
|
|
1600
|
+
const glyphCanvas = this._getSpriteGlyphCanvas(sprite);
|
|
1601
|
+
ctx.drawImage(glyphCanvas, -glyphCanvas.width / 2, -glyphCanvas.height / 2);
|
|
1333
1602
|
ctx.restore();
|
|
1334
1603
|
}
|
|
1335
1604
|
for (const entry of this._textOverlays) {
|
package/dist/physics.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function updatePhysics(sprites, gravityX, gravityY, dt) {
|
|
2
|
+
for (const sprite of sprites) {
|
|
3
|
+
if (sprite.gravityScale !== 0) {
|
|
4
|
+
sprite.vx += gravityX * sprite.gravityScale * dt;
|
|
5
|
+
sprite.vy += gravityY * sprite.gravityScale * dt;
|
|
6
|
+
}
|
|
7
|
+
sprite.x += sprite.vx * dt;
|
|
8
|
+
sprite.y += sprite.vy * dt;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function renderFrame(options) {
|
|
2
|
+
const { ctx, canvas, sprites, textOverlays, background, backgroundGradient, scrollX, scrollY, } = options;
|
|
3
|
+
const width = canvas.width;
|
|
4
|
+
const height = canvas.height;
|
|
5
|
+
ctx.clearRect(0, 0, width, height);
|
|
6
|
+
if (backgroundGradient !== null) {
|
|
7
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
8
|
+
gradient.addColorStop(0, backgroundGradient.from);
|
|
9
|
+
gradient.addColorStop(1, backgroundGradient.to);
|
|
10
|
+
ctx.fillStyle = gradient;
|
|
11
|
+
ctx.fillRect(0, 0, width, height);
|
|
12
|
+
}
|
|
13
|
+
else if (background !== null) {
|
|
14
|
+
ctx.fillStyle = background;
|
|
15
|
+
ctx.fillRect(0, 0, width, height);
|
|
16
|
+
}
|
|
17
|
+
const sortedSprites = [...sprites].sort((a, b) => a.layer - b.layer);
|
|
18
|
+
for (const sprite of sortedSprites) {
|
|
19
|
+
if (!sprite.visible)
|
|
20
|
+
continue;
|
|
21
|
+
ctx.save();
|
|
22
|
+
ctx.globalAlpha = Math.max(0, Math.min(1, sprite.alpha));
|
|
23
|
+
const drawX = sprite.ignoreScroll ? sprite.x : sprite.x - scrollX;
|
|
24
|
+
const drawY = sprite.ignoreScroll ? sprite.y : sprite.y - scrollY;
|
|
25
|
+
ctx.translate(drawX, drawY);
|
|
26
|
+
if (sprite.rotation !== 0) {
|
|
27
|
+
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
|
28
|
+
}
|
|
29
|
+
if (sprite.flipX || sprite.flipY) {
|
|
30
|
+
ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
|
|
31
|
+
}
|
|
32
|
+
ctx.font = `${sprite.size}px serif`;
|
|
33
|
+
ctx.textAlign = "center";
|
|
34
|
+
ctx.textBaseline = "middle";
|
|
35
|
+
ctx.fillText(sprite.sprite, 0, 0);
|
|
36
|
+
ctx.restore();
|
|
37
|
+
}
|
|
38
|
+
for (const entry of textOverlays) {
|
|
39
|
+
ctx.save();
|
|
40
|
+
ctx.font = `${entry.fontSize}px monospace`;
|
|
41
|
+
ctx.fillStyle = entry.color;
|
|
42
|
+
ctx.textAlign = entry.centered ? "center" : "left";
|
|
43
|
+
ctx.textBaseline = entry.centered ? "middle" : "top";
|
|
44
|
+
ctx.fillText(entry.text, entry.x, entry.y);
|
|
45
|
+
ctx.restore();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function applyPageBackground(pageBackground, lastAppliedPageBackground) {
|
|
49
|
+
if (!document.body)
|
|
50
|
+
return lastAppliedPageBackground;
|
|
51
|
+
if (lastAppliedPageBackground === pageBackground)
|
|
52
|
+
return lastAppliedPageBackground;
|
|
53
|
+
if (pageBackground === null) {
|
|
54
|
+
document.body.style.removeProperty("background");
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
document.body.style.background = pageBackground;
|
|
58
|
+
}
|
|
59
|
+
return pageBackground;
|
|
60
|
+
}
|
|
61
|
+
export function applyResponsiveCanvasLayout(canvas) {
|
|
62
|
+
if (!document.body)
|
|
63
|
+
return;
|
|
64
|
+
document.body.style.margin = "0";
|
|
65
|
+
document.body.style.minHeight = "100vh";
|
|
66
|
+
document.body.style.display = "grid";
|
|
67
|
+
document.body.style.placeItems = "center";
|
|
68
|
+
document.body.style.touchAction = "manipulation";
|
|
69
|
+
const viewportWidth = Math.max(1, window.innerWidth);
|
|
70
|
+
const viewportHeight = Math.max(1, window.innerHeight);
|
|
71
|
+
const scale = Math.min(viewportWidth / canvas.width, viewportHeight / canvas.height);
|
|
72
|
+
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
73
|
+
canvas.style.width = `${Math.floor(canvas.width * safeScale)}px`;
|
|
74
|
+
canvas.style.height = `${Math.floor(canvas.height * safeScale)}px`;
|
|
75
|
+
}
|
package/dist/sprite.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Sprite
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/**
|
|
5
|
+
* A 2D game object rendered as an emoji on the canvas.
|
|
6
|
+
*
|
|
7
|
+
* Instantiate directly or extend to create custom sprite types.
|
|
8
|
+
* Register with the engine by passing the instance to {@link Game.add}.
|
|
9
|
+
*
|
|
10
|
+
* **Coordinate system:** center-based world space. `(x, y)` is the center of
|
|
11
|
+
* the sprite. Positive X = right, positive Y = down.
|
|
12
|
+
*
|
|
13
|
+
* **Lifecycle:** A sprite exists until {@link Game.destroySprite} is called or
|
|
14
|
+
* {@link Game.reset} is invoked. After destruction, do not read or write its fields.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* // Direct instantiation
|
|
19
|
+
* const coin = new Sprite("🪙");
|
|
20
|
+
* coin.x = 300;
|
|
21
|
+
* coin.y = 200;
|
|
22
|
+
* coin.size = 32;
|
|
23
|
+
* game.add(coin);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // Subclassing for custom game objects
|
|
29
|
+
* class Player extends Sprite {
|
|
30
|
+
* health = 3;
|
|
31
|
+
*
|
|
32
|
+
* constructor(x: number, y: number) {
|
|
33
|
+
* super("🐢");
|
|
34
|
+
* this.x = x;
|
|
35
|
+
* this.y = y;
|
|
36
|
+
* this.size = 48;
|
|
37
|
+
* this.gravityScale = 1;
|
|
38
|
+
* }
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* const player = new Player(400, 300);
|
|
42
|
+
* game.add(player);
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class Sprite {
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new Sprite with the given emoji and optional position.
|
|
48
|
+
* All other properties use their defaults and can be set after construction.
|
|
49
|
+
*
|
|
50
|
+
* @param sprite - The emoji character to render. Must be a single emoji.
|
|
51
|
+
* Image sprites are NOT supported — emoji only.
|
|
52
|
+
* @example "🔥", "⭐", "🐢", "💣", "👾"
|
|
53
|
+
* @param x - Initial X position in world space (center), in pixels. Default: `0`.
|
|
54
|
+
* @param y - Initial Y position in world space (center), in pixels. Default: `0`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const enemy = new Sprite("👾", 200, 100);
|
|
59
|
+
* enemy.size = 40;
|
|
60
|
+
* game.add(enemy);
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
constructor(sprite, x = 0, y = 0) {
|
|
64
|
+
/**
|
|
65
|
+
* X position in world space (horizontal center of the sprite), in pixels.
|
|
66
|
+
* Positive X points right. Updated each frame by: `x += vx * dt`.
|
|
67
|
+
* May be set directly to teleport the sprite.
|
|
68
|
+
*/
|
|
69
|
+
this.x = 0;
|
|
70
|
+
/**
|
|
71
|
+
* Y position in world space (vertical center of the sprite), in pixels.
|
|
72
|
+
* Positive Y points down. Updated each frame by: `y += vy * dt`.
|
|
73
|
+
* May be set directly to teleport the sprite.
|
|
74
|
+
*/
|
|
75
|
+
this.y = 0;
|
|
76
|
+
/**
|
|
77
|
+
* Width and height of the sprite's bounding square, in pixels.
|
|
78
|
+
* Used for both canvas rendering (font size) and AABB collision detection.
|
|
79
|
+
*/
|
|
80
|
+
this.size = 32;
|
|
81
|
+
/**
|
|
82
|
+
* Visual rotation of the sprite in degrees.
|
|
83
|
+
* `0` = upright. Positive values rotate clockwise.
|
|
84
|
+
* **Note:** rotation does NOT affect the AABB collision box — it remains axis-aligned.
|
|
85
|
+
*/
|
|
86
|
+
this.rotation = 0;
|
|
87
|
+
/**
|
|
88
|
+
* Horizontal visual flip.
|
|
89
|
+
* When `true`, the sprite is mirrored left-right during rendering.
|
|
90
|
+
* This is visual-only and does NOT affect collision detection.
|
|
91
|
+
*/
|
|
92
|
+
this.flipX = false;
|
|
93
|
+
/**
|
|
94
|
+
* Vertical visual flip.
|
|
95
|
+
* When `true`, the sprite is mirrored top-bottom during rendering.
|
|
96
|
+
* This is visual-only and does NOT affect collision detection.
|
|
97
|
+
*/
|
|
98
|
+
this.flipY = false;
|
|
99
|
+
/**
|
|
100
|
+
* Camera scroll toggle for this sprite.
|
|
101
|
+
* When `false` (default), the sprite is rendered in world space and is affected
|
|
102
|
+
* by {@link Game.scrollX} and {@link Game.scrollY}.
|
|
103
|
+
* When `true`, the sprite is rendered in canvas/screen space and ignores camera
|
|
104
|
+
* scrolling. Useful for HUD-like sprites that should stay fixed on screen.
|
|
105
|
+
*/
|
|
106
|
+
this.ignoreScroll = false;
|
|
107
|
+
/**
|
|
108
|
+
* Opacity of the sprite. Must be in range `[0, 1]`.
|
|
109
|
+
* `0` = fully transparent, `1` = fully opaque.
|
|
110
|
+
* Clamped to `[0, 1]` at render time.
|
|
111
|
+
*/
|
|
112
|
+
this.alpha = 1;
|
|
113
|
+
/**
|
|
114
|
+
* Controls whether this sprite is drawn each frame.
|
|
115
|
+
* When `false`, the sprite still receives physics updates (gravity, velocity)
|
|
116
|
+
* but is not rendered. Use {@link Game.destroySprite} to fully remove a sprite.
|
|
117
|
+
*/
|
|
118
|
+
this.visible = true;
|
|
119
|
+
/**
|
|
120
|
+
* Render layer order. Sprites with higher `layer` values are drawn on top.
|
|
121
|
+
* Sprites on the same layer render in creation order (first created = bottom).
|
|
122
|
+
* Changing this at runtime takes effect on the next frame.
|
|
123
|
+
*/
|
|
124
|
+
this.layer = 0;
|
|
125
|
+
/**
|
|
126
|
+
* Horizontal velocity in pixels per second.
|
|
127
|
+
* Applied each frame: `x += vx * dt`.
|
|
128
|
+
* Gravity also modifies this: `vx += gravityX * gravityScale * dt`.
|
|
129
|
+
*/
|
|
130
|
+
this.vx = 0;
|
|
131
|
+
/**
|
|
132
|
+
* Vertical velocity in pixels per second.
|
|
133
|
+
* Applied each frame: `y += vy * dt`.
|
|
134
|
+
* Gravity also modifies this: `vy += gravityY * gravityScale * dt`.
|
|
135
|
+
*/
|
|
136
|
+
this.vy = 0;
|
|
137
|
+
/**
|
|
138
|
+
* Gravity multiplier for this sprite.
|
|
139
|
+
* Each frame: `vx += game.gravityX * gravityScale * dt`
|
|
140
|
+
* `vy += game.gravityY * gravityScale * dt`.
|
|
141
|
+
* `0` = immune to gravity (default). `1` = full gravity.
|
|
142
|
+
* Values > 1 amplify gravity; negative values invert it.
|
|
143
|
+
*/
|
|
144
|
+
this.gravityScale = 0;
|
|
145
|
+
this.sprite = sprite;
|
|
146
|
+
this.x = x;
|
|
147
|
+
this.y = y;
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/timers.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function updateTimers(timers, dtMs) {
|
|
2
|
+
const toRemove = new Set();
|
|
3
|
+
const toFire = [];
|
|
4
|
+
for (const timer of timers) {
|
|
5
|
+
timer.elapsed += dtMs;
|
|
6
|
+
if (timer.elapsed >= timer.delayMs) {
|
|
7
|
+
toFire.push(timer);
|
|
8
|
+
if (timer.repeat) {
|
|
9
|
+
timer.elapsed -= timer.delayMs;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
toRemove.add(timer.id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const nextTimers = toRemove.size > 0
|
|
17
|
+
? timers.filter((timer) => !toRemove.has(timer.id))
|
|
18
|
+
: timers;
|
|
19
|
+
for (const timer of toFire) {
|
|
20
|
+
timer.callback();
|
|
21
|
+
}
|
|
22
|
+
return nextTimers;
|
|
23
|
+
}
|