minimojs 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +5 -5
- package/dist/internal/AssetSystem.js +33 -0
- package/dist/internal/RenderSystem.js +4 -4
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
- package/dist/minimo-arcaderacer.d.ts +782 -11
- package/dist/minimo-arcaderacer.js +1026 -1116
- package/dist/minimo.d.ts +138 -81
- package/dist/minimo.js +137 -56
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ This README is intentionally high-level. It explains what the project is and how
|
|
|
10
10
|
|
|
11
11
|
- ESM-only TypeScript-first engine
|
|
12
12
|
- Single `Game` entry point
|
|
13
|
-
-
|
|
13
|
+
- Image-backed sprite rendering, with emoji as a zero-asset alternative
|
|
14
14
|
- rAF-driven loop (timers/animations/updates)
|
|
15
15
|
- Responsive auto-centered canvas
|
|
16
16
|
|
|
@@ -27,7 +27,7 @@ This README is intentionally high-level. It explains what the project is and how
|
|
|
27
27
|
|
|
28
28
|
- Heavy scene-manager/ECS architecture
|
|
29
29
|
- Heavy physics engine features
|
|
30
|
-
-
|
|
30
|
+
- Spritesheets, texture atlases, and asset pipelines
|
|
31
31
|
- Nested subsystem APIs
|
|
32
32
|
- `setTimeout` / `setInterval` game loops
|
|
33
33
|
|
|
@@ -40,7 +40,7 @@ npm install minimojs
|
|
|
40
40
|
## Quick Start
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
|
-
import { DrawSprite,
|
|
43
|
+
import { DrawSprite, EmojiSprite, Game, type IScene } from "minimojs";
|
|
44
44
|
|
|
45
45
|
const game = new Game(720, 1280);
|
|
46
46
|
game.gravityY = 980;
|
|
@@ -61,11 +61,11 @@ class MeterSprite extends DrawSprite {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
class DemoScene implements IScene {
|
|
64
|
-
private player:
|
|
64
|
+
private player: EmojiSprite | null = null;
|
|
65
65
|
private meter: MeterSprite | null = null;
|
|
66
66
|
|
|
67
67
|
onCreate() {
|
|
68
|
-
this.player = game.add(new
|
|
68
|
+
this.player = game.add(new EmojiSprite("🐢", 360, 640, 48));
|
|
69
69
|
this.player.gravityScale = 1;
|
|
70
70
|
this.meter = game.add(new MeterSprite(360, 80));
|
|
71
71
|
this.meter.ignoreScroll = true;
|
|
@@ -7,6 +7,8 @@ export class AssetSystem {
|
|
|
7
7
|
this._loadedImages = new Map();
|
|
8
8
|
/** @internal */
|
|
9
9
|
this._imageSources = new Map();
|
|
10
|
+
/** @internal */
|
|
11
|
+
this._imageVersions = new Map();
|
|
10
12
|
}
|
|
11
13
|
queueImage(key, src) {
|
|
12
14
|
const safeKey = key.trim();
|
|
@@ -24,9 +26,35 @@ export class AssetSystem {
|
|
|
24
26
|
this._imageSources.set(safeKey, safeSrc);
|
|
25
27
|
this._queuedImages.set(safeKey, safeSrc);
|
|
26
28
|
}
|
|
29
|
+
createTexture(key, width, height, painter) {
|
|
30
|
+
const safeKey = key.trim();
|
|
31
|
+
if (safeKey.length === 0) {
|
|
32
|
+
throw new Error("MinimoJS: Texture keys must be non-empty strings.");
|
|
33
|
+
}
|
|
34
|
+
if (typeof painter !== "function") {
|
|
35
|
+
throw new Error("MinimoJS: createTexture() requires a painter function.");
|
|
36
|
+
}
|
|
37
|
+
const canvas = document.createElement("canvas");
|
|
38
|
+
canvas.width = Math.max(1, Math.round(Number.isFinite(width) ? width : 1));
|
|
39
|
+
canvas.height = Math.max(1, Math.round(Number.isFinite(height) ? height : 1));
|
|
40
|
+
const ctx = canvas.getContext("2d");
|
|
41
|
+
if (!ctx) {
|
|
42
|
+
throw new Error("MinimoJS: Could not acquire a texture rendering context.");
|
|
43
|
+
}
|
|
44
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
45
|
+
painter(ctx, canvas);
|
|
46
|
+
this._queuedImages.delete(safeKey);
|
|
47
|
+
this._imageSources.delete(safeKey);
|
|
48
|
+
this._loadedImages.set(safeKey, canvas);
|
|
49
|
+
this.bumpImageVersion(safeKey);
|
|
50
|
+
return canvas;
|
|
51
|
+
}
|
|
27
52
|
getImage(key) {
|
|
28
53
|
return this._loadedImages.get(key);
|
|
29
54
|
}
|
|
55
|
+
getImageVersion(key) {
|
|
56
|
+
return this._imageVersions.get(key) ?? 0;
|
|
57
|
+
}
|
|
30
58
|
hasImage(key) {
|
|
31
59
|
return this._loadedImages.has(key);
|
|
32
60
|
}
|
|
@@ -49,6 +77,7 @@ export class AssetSystem {
|
|
|
49
77
|
await Promise.all(entries.map(async ([key, src]) => {
|
|
50
78
|
const image = await this.loadImage(src);
|
|
51
79
|
this._loadedImages.set(key, image);
|
|
80
|
+
this.bumpImageVersion(key);
|
|
52
81
|
loaded += 1;
|
|
53
82
|
onProgress?.(loaded, total, key);
|
|
54
83
|
}));
|
|
@@ -65,4 +94,8 @@ export class AssetSystem {
|
|
|
65
94
|
image.src = src;
|
|
66
95
|
});
|
|
67
96
|
}
|
|
97
|
+
/** @internal */
|
|
98
|
+
bumpImageVersion(key) {
|
|
99
|
+
this._imageVersions.set(key, (this._imageVersions.get(key) ?? 0) + 1);
|
|
100
|
+
}
|
|
68
101
|
}
|
|
@@ -496,8 +496,8 @@ export class RenderSystem {
|
|
|
496
496
|
throw new Error(`MinimoJS: Image '${sprite.imageKey}' is not loaded.`);
|
|
497
497
|
}
|
|
498
498
|
const canvas = document.createElement("canvas");
|
|
499
|
-
canvas.width = Math.max(1, image.naturalWidth || image.width);
|
|
500
|
-
canvas.height = Math.max(1, image.naturalHeight || image.height);
|
|
499
|
+
canvas.width = Math.max(1, image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width);
|
|
500
|
+
canvas.height = Math.max(1, image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height);
|
|
501
501
|
const ctx = canvas.getContext("2d");
|
|
502
502
|
if (!ctx) {
|
|
503
503
|
throw new Error("MinimoJS: Could not acquire an image rendering context.");
|
|
@@ -774,8 +774,8 @@ export class RenderSystem {
|
|
|
774
774
|
ctx.restore();
|
|
775
775
|
return;
|
|
776
776
|
}
|
|
777
|
-
const imageW = Math.max(1, image.naturalWidth || image.width);
|
|
778
|
-
const imageH = Math.max(1, image.naturalHeight || image.height);
|
|
777
|
+
const imageW = Math.max(1, image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width);
|
|
778
|
+
const imageH = Math.max(1, image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height);
|
|
779
779
|
const scale = this.getBackgroundScale(layer.fit, destW, destH, imageW, imageH);
|
|
780
780
|
const drawW = imageW * scale;
|
|
781
781
|
const drawH = imageH * scale;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const AUTO_BODY_WIDTH_VISUAL_RATIO = 0.76;
|
|
2
|
+
const AUTO_BODY_LENGTH_PER_PIXEL = 1.92;
|
|
3
|
+
/** @internal */
|
|
4
|
+
export class ArcadeRacerCollisionSystem {
|
|
5
|
+
constructor(
|
|
6
|
+
/** @internal */ engine) {
|
|
7
|
+
this.engine = engine;
|
|
8
|
+
}
|
|
9
|
+
resolveAutoBodyFromVisual(visual, _scale) {
|
|
10
|
+
try {
|
|
11
|
+
const surface = this.engine.resolveVisualSurface(visual);
|
|
12
|
+
return {
|
|
13
|
+
width: Math.max(1, surface.width * AUTO_BODY_WIDTH_VISUAL_RATIO),
|
|
14
|
+
length: Math.max(1, surface.height),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
resolveCollisionBodyMetrics(visual, scale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
|
|
22
|
+
return {
|
|
23
|
+
laneWidth: this.getBodyLaneWidthFromVisual(scale, bodyWidth),
|
|
24
|
+
distanceLength: this.getBodyLengthFromVisual(scale, bodyLength),
|
|
25
|
+
laneOffset: this.getBodyLaneOffsetFromVisual(visual, scale, bodyOffsetX),
|
|
26
|
+
distanceOffset: this.getBodyDistanceOffsetFromVisual(visual, scale, bodyOffsetY),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
getDebugCollisionRectFromVisual(visualRect, visual, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
|
|
30
|
+
const surface = this.engine.resolveVisualSurface(visual);
|
|
31
|
+
const projectedScale = Math.max(0, Math.min(visualRect.width / Math.max(1, surface.width), visualRect.height / Math.max(1, surface.height)));
|
|
32
|
+
const rectWidth = Math.max(4, bodyWidth * projectedScale);
|
|
33
|
+
const rectHeight = Math.max(8, bodyLength * projectedScale);
|
|
34
|
+
const offsetX = bodyOffsetX * projectedScale;
|
|
35
|
+
const offsetY = bodyOffsetY * projectedScale;
|
|
36
|
+
return {
|
|
37
|
+
x: visualRect.x + (visualRect.width - rectWidth) / 2 + offsetX,
|
|
38
|
+
y: visualRect.y + visualRect.height - rectHeight + offsetY,
|
|
39
|
+
width: rectWidth,
|
|
40
|
+
height: rectHeight,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
getRealCollisionRect(current, width, height, ahead, lane, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY, resolvedBody, anchorCenterX, anchorBottomY) {
|
|
44
|
+
const metrics = resolvedBody ??
|
|
45
|
+
this.resolveCollisionBodyMetrics(visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
|
|
46
|
+
const bodyLaneWidth = metrics.laneWidth;
|
|
47
|
+
const bodyDistanceLength = metrics.distanceLength;
|
|
48
|
+
const laneCenter = lane + metrics.laneOffset;
|
|
49
|
+
const bodyDistanceOffset = metrics.distanceOffset;
|
|
50
|
+
const depthCenter = this.getBodyDistanceCenterFromAnchor(ahead, bodyDistanceLength, bodyDistanceOffset);
|
|
51
|
+
const center = this.engine.projectTrafficPoint(current, depthCenter, width, height);
|
|
52
|
+
const far = this.engine.projectTrafficPoint(current, depthCenter + bodyDistanceLength * 0.5, width, height);
|
|
53
|
+
const near = this.engine.projectTrafficPoint(current, depthCenter - bodyDistanceLength * 0.5, width, height);
|
|
54
|
+
const laneUnit = center.roadWidth * 0.38;
|
|
55
|
+
const centerX = Number.isFinite(anchorCenterX)
|
|
56
|
+
? anchorCenterX +
|
|
57
|
+
this.getBodyOffsetXInScreenPixels(visual, referenceScale, bodyOffsetX)
|
|
58
|
+
: center.x + laneCenter * laneUnit;
|
|
59
|
+
const rectWidth = Math.max(4, bodyLaneWidth * laneUnit);
|
|
60
|
+
const rawTop = Math.min(far.y, near.y);
|
|
61
|
+
const rawBottom = Math.max(far.y, near.y);
|
|
62
|
+
const rawHeight = Math.max(8, rawBottom - rawTop);
|
|
63
|
+
const bottom = Number.isFinite(anchorBottomY)
|
|
64
|
+
? rawBottom +
|
|
65
|
+
(anchorBottomY - rawBottom) *
|
|
66
|
+
this.engine.realCollisionVerticalShiftBlend
|
|
67
|
+
: rawBottom;
|
|
68
|
+
const top = bottom - rawHeight;
|
|
69
|
+
return {
|
|
70
|
+
x: centerX - rectWidth / 2,
|
|
71
|
+
y: top,
|
|
72
|
+
width: rectWidth,
|
|
73
|
+
height: rawHeight,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
resolvePlayerTrafficCollisions() {
|
|
77
|
+
const nextCollisionIds = new Set();
|
|
78
|
+
const current = this.engine.trackSystem.sampleTrack(this.engine.distanceState);
|
|
79
|
+
const playerBodyWidth = this.engine.playerResolvedBodyState.laneWidth;
|
|
80
|
+
const playerBodyLength = this.engine.playerResolvedBodyState.distanceLength;
|
|
81
|
+
const playerLaneCenter = this.engine.playerLaneState + this.engine.playerResolvedBodyState.laneOffset;
|
|
82
|
+
const playerBodyDistanceOffset = this.engine.playerResolvedBodyState.distanceOffset;
|
|
83
|
+
const playerDistanceCenter = this.engine.distanceState +
|
|
84
|
+
this.getBodyDistanceCenterFromAnchor(this.engine.playerProjectionAheadDistance, playerBodyLength, playerBodyDistanceOffset);
|
|
85
|
+
const playerRect = this.getRealCollisionRect(current, this.engine.width, this.engine.height, this.engine.playerProjectionAheadDistance, this.engine.playerLaneState, this.engine.playerVisualState, this.engine.playerBaseScaleState, this.engine.playerBodyWidthState, this.engine.playerBodyLengthState, this.engine.playerBodyOffsetXState, this.engine.playerBodyOffsetYState, this.engine.playerResolvedBodyState, this.engine.getPlayerScreenX(), this.engine.playerScreenYState);
|
|
86
|
+
for (const traffic of this.engine.trafficState) {
|
|
87
|
+
const trafficBodyWidth = traffic.resolvedBody.laneWidth;
|
|
88
|
+
const trafficBodyLength = traffic.resolvedBody.distanceLength;
|
|
89
|
+
const trafficBodyDistanceOffset = traffic.resolvedBody.distanceOffset;
|
|
90
|
+
const trafficLaneCenter = traffic.lane + traffic.resolvedBody.laneOffset;
|
|
91
|
+
const trafficDistanceCenter = this.getBodyDistanceCenterFromAnchor(traffic.distance, trafficBodyLength, trafficBodyDistanceOffset);
|
|
92
|
+
const distanceDelta = trafficDistanceCenter - playerDistanceCenter;
|
|
93
|
+
const laneDelta = trafficLaneCenter - playerLaneCenter;
|
|
94
|
+
const hitDepth = Math.abs(distanceDelta) < (playerBodyLength + trafficBodyLength) * 0.5;
|
|
95
|
+
const hitLane = Math.abs(laneDelta) < (playerBodyWidth + trafficBodyWidth) * 0.5;
|
|
96
|
+
if (!hitDepth || !hitLane)
|
|
97
|
+
continue;
|
|
98
|
+
const trafficVisualBottom = this.engine.projectTrafficPoint(current, traffic.distance - this.engine.distanceState, this.engine.width, this.engine.height).y;
|
|
99
|
+
const trafficRect = this.getRealCollisionRect(current, this.engine.width, this.engine.height, traffic.distance - this.engine.distanceState, traffic.lane, traffic.visual, traffic.baseScale, traffic.bodyWidth, traffic.bodyLength, traffic.bodyOffsetX, traffic.bodyOffsetY, traffic.resolvedBody, undefined, trafficVisualBottom);
|
|
100
|
+
if (playerRect &&
|
|
101
|
+
trafficRect &&
|
|
102
|
+
!this.rectsOverlap(playerRect, trafficRect)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
nextCollisionIds.add(traffic.id);
|
|
106
|
+
if (this.engine.activeCollisionIds.has(traffic.id))
|
|
107
|
+
continue;
|
|
108
|
+
const collision = {
|
|
109
|
+
trafficId: traffic.id,
|
|
110
|
+
distanceDelta,
|
|
111
|
+
laneDelta,
|
|
112
|
+
trafficDistance: traffic.distance,
|
|
113
|
+
trafficLane: traffic.lane,
|
|
114
|
+
};
|
|
115
|
+
for (const handler of this.engine.collisionHandlers) {
|
|
116
|
+
handler(collision);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
this.engine.activeCollisionIds = nextCollisionIds;
|
|
120
|
+
}
|
|
121
|
+
getPlayerScreenLaneUnit() {
|
|
122
|
+
return this.engine.roadNearWidth * 0.38;
|
|
123
|
+
}
|
|
124
|
+
getBodyDistanceCenterFromAnchor(anchorDistance, bodyLength, bodyDistanceOffset) {
|
|
125
|
+
return anchorDistance + bodyLength * 0.5 - bodyDistanceOffset;
|
|
126
|
+
}
|
|
127
|
+
/** @internal */
|
|
128
|
+
getBodyOffsetXInScreenPixels(visual, scale, bodyOffsetX) {
|
|
129
|
+
if (!Number.isFinite(bodyOffsetX) || bodyOffsetX === 0)
|
|
130
|
+
return 0;
|
|
131
|
+
try {
|
|
132
|
+
const surface = this.engine.resolveVisualSurface(visual);
|
|
133
|
+
const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
134
|
+
const visualWidth = Math.max(1, surface.width * safeScale);
|
|
135
|
+
return Math.max(-visualWidth * 0.5, Math.min(visualWidth * 0.5, bodyOffsetX * safeScale));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** @internal */
|
|
142
|
+
getBodyLaneWidthFromVisual(scale, bodyWidth) {
|
|
143
|
+
if (!Number.isFinite(bodyWidth) || bodyWidth <= 0)
|
|
144
|
+
return 0;
|
|
145
|
+
const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
146
|
+
const projectedWidth = Math.max(1, bodyWidth * safeScale);
|
|
147
|
+
const laneUnit = Math.max(1, this.getPlayerScreenLaneUnit());
|
|
148
|
+
return Math.max(0.01, projectedWidth / laneUnit);
|
|
149
|
+
}
|
|
150
|
+
/** @internal */
|
|
151
|
+
getBodyLengthFromVisual(scale, bodyLength) {
|
|
152
|
+
if (!Number.isFinite(bodyLength) || bodyLength <= 0)
|
|
153
|
+
return 0;
|
|
154
|
+
const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
155
|
+
const projectedLength = Math.max(1, bodyLength * safeScale);
|
|
156
|
+
return Math.max(1, projectedLength * AUTO_BODY_LENGTH_PER_PIXEL);
|
|
157
|
+
}
|
|
158
|
+
/** @internal */
|
|
159
|
+
getBodyLaneOffsetFromVisual(visual, scale, bodyOffsetX) {
|
|
160
|
+
if (!Number.isFinite(bodyOffsetX) || bodyOffsetX === 0)
|
|
161
|
+
return 0;
|
|
162
|
+
try {
|
|
163
|
+
const surface = this.engine.resolveVisualSurface(visual);
|
|
164
|
+
const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
165
|
+
const projectedOffsetX = bodyOffsetX * safeScale;
|
|
166
|
+
const laneUnit = Math.max(1, this.getPlayerScreenLaneUnit());
|
|
167
|
+
const visualWidth = Math.max(1, surface.width * safeScale);
|
|
168
|
+
const clampedOffsetX = Math.max(-visualWidth * 0.5, Math.min(visualWidth * 0.5, projectedOffsetX));
|
|
169
|
+
return clampedOffsetX / laneUnit;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** @internal */
|
|
176
|
+
getBodyDistanceOffsetFromVisual(visual, scale, bodyOffsetY) {
|
|
177
|
+
if (!Number.isFinite(bodyOffsetY) || bodyOffsetY === 0)
|
|
178
|
+
return 0;
|
|
179
|
+
try {
|
|
180
|
+
const surface = this.engine.resolveVisualSurface(visual);
|
|
181
|
+
const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
|
182
|
+
const projectedOffsetY = bodyOffsetY * safeScale;
|
|
183
|
+
const visualHeight = Math.max(1, surface.height * safeScale);
|
|
184
|
+
const clampedOffsetY = Math.max(-visualHeight * 0.5, Math.min(visualHeight * 0.5, projectedOffsetY));
|
|
185
|
+
return clampedOffsetY * AUTO_BODY_LENGTH_PER_PIXEL;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** @internal */
|
|
192
|
+
rectsOverlap(a, b) {
|
|
193
|
+
return !(a.x + a.width <= b.x ||
|
|
194
|
+
b.x + b.width <= a.x ||
|
|
195
|
+
a.y + a.height <= b.y ||
|
|
196
|
+
b.y + b.height <= a.y);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export function sanitizeLaneSpacePositions(values) {
|
|
3
|
+
if (!Array.isArray(values))
|
|
4
|
+
return null;
|
|
5
|
+
const sanitized = [];
|
|
6
|
+
for (const value of values) {
|
|
7
|
+
if (!Number.isFinite(value))
|
|
8
|
+
continue;
|
|
9
|
+
sanitized.push(Math.max(-1, Math.min(1, value)));
|
|
10
|
+
}
|
|
11
|
+
return sanitized;
|
|
12
|
+
}
|
|
13
|
+
/** @internal */
|
|
14
|
+
export class ArcadeRacerLaneSystem {
|
|
15
|
+
constructor(requestedLaneCount, laneDirections) {
|
|
16
|
+
const highestConfiguredLaneIndex = this.getHighestConfiguredLaneIndex(laneDirections);
|
|
17
|
+
this.laneCountState = Math.max(1, Math.round(requestedLaneCount), highestConfiguredLaneIndex + 1);
|
|
18
|
+
this.lanePositionsState = this.buildLanePositions(this.laneCountState);
|
|
19
|
+
const laneDirectionGroups = this.resolveLaneDirectionIndices(laneDirections, this.laneCountState);
|
|
20
|
+
this.sameDirectionLaneIndicesState = laneDirectionGroups.same;
|
|
21
|
+
this.oncomingLaneIndicesState = laneDirectionGroups.oncoming;
|
|
22
|
+
}
|
|
23
|
+
get laneCount() {
|
|
24
|
+
return this.laneCountState;
|
|
25
|
+
}
|
|
26
|
+
getLanePosition(laneIndex) {
|
|
27
|
+
if (this.lanePositionsState.length === 0)
|
|
28
|
+
return 0;
|
|
29
|
+
const safeIndex = Number.isFinite(laneIndex)
|
|
30
|
+
? Math.max(0, Math.min(this.laneCountState - 1, Math.round(laneIndex)))
|
|
31
|
+
: 0;
|
|
32
|
+
return this.lanePositionsState[safeIndex] ?? 0;
|
|
33
|
+
}
|
|
34
|
+
getLanePositions(direction = "all") {
|
|
35
|
+
if (direction === "all") {
|
|
36
|
+
return [...this.lanePositionsState];
|
|
37
|
+
}
|
|
38
|
+
const indices = direction === "same"
|
|
39
|
+
? this.sameDirectionLaneIndicesState
|
|
40
|
+
: this.oncomingLaneIndicesState;
|
|
41
|
+
if (indices === null) {
|
|
42
|
+
return [...this.lanePositionsState];
|
|
43
|
+
}
|
|
44
|
+
return indices.map((index) => this.getLanePosition(index));
|
|
45
|
+
}
|
|
46
|
+
clampRoadLane(value) {
|
|
47
|
+
if (!Number.isFinite(value))
|
|
48
|
+
return 0;
|
|
49
|
+
return Math.max(-1, Math.min(1, value));
|
|
50
|
+
}
|
|
51
|
+
/** @internal */
|
|
52
|
+
buildLanePositions(laneCount) {
|
|
53
|
+
const positions = [];
|
|
54
|
+
for (let index = 0; index < laneCount; index++) {
|
|
55
|
+
positions.push(((index + 0.5) / laneCount) * 2 - 1);
|
|
56
|
+
}
|
|
57
|
+
return positions;
|
|
58
|
+
}
|
|
59
|
+
/** @internal */
|
|
60
|
+
sanitizeLaneIndexList(values, laneCount) {
|
|
61
|
+
if (!Array.isArray(values))
|
|
62
|
+
return [];
|
|
63
|
+
const result = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
if (!Number.isFinite(value))
|
|
67
|
+
continue;
|
|
68
|
+
const index = Math.round(value);
|
|
69
|
+
if (index < 0 || index >= laneCount || seen.has(index))
|
|
70
|
+
continue;
|
|
71
|
+
seen.add(index);
|
|
72
|
+
result.push(index);
|
|
73
|
+
}
|
|
74
|
+
return result.sort((a, b) => a - b);
|
|
75
|
+
}
|
|
76
|
+
/** @internal */
|
|
77
|
+
getHighestConfiguredLaneIndex(config) {
|
|
78
|
+
let highest = -1;
|
|
79
|
+
for (const list of [config?.same, config?.oncoming]) {
|
|
80
|
+
if (!Array.isArray(list))
|
|
81
|
+
continue;
|
|
82
|
+
for (const value of list) {
|
|
83
|
+
if (!Number.isFinite(value))
|
|
84
|
+
continue;
|
|
85
|
+
highest = Math.max(highest, Math.round(value));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return highest;
|
|
89
|
+
}
|
|
90
|
+
/** @internal */
|
|
91
|
+
resolveLaneDirectionIndices(config, laneCount) {
|
|
92
|
+
const hasSame = Array.isArray(config?.same);
|
|
93
|
+
const hasOncoming = Array.isArray(config?.oncoming);
|
|
94
|
+
if (!hasSame && !hasOncoming) {
|
|
95
|
+
return { same: null, oncoming: null };
|
|
96
|
+
}
|
|
97
|
+
const allLaneIndices = Array.from({ length: laneCount }, (_, index) => index);
|
|
98
|
+
const same = this.sanitizeLaneIndexList(config?.same, laneCount);
|
|
99
|
+
const oncoming = this.sanitizeLaneIndexList(config?.oncoming, laneCount);
|
|
100
|
+
if (hasSame && hasOncoming) {
|
|
101
|
+
const sameSet = new Set(same);
|
|
102
|
+
return {
|
|
103
|
+
same,
|
|
104
|
+
oncoming: oncoming.filter((index) => !sameSet.has(index)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (hasSame) {
|
|
108
|
+
const sameSet = new Set(same);
|
|
109
|
+
return {
|
|
110
|
+
same,
|
|
111
|
+
oncoming: allLaneIndices.filter((index) => !sameSet.has(index)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const oncomingSet = new Set(oncoming);
|
|
115
|
+
return {
|
|
116
|
+
same: allLaneIndices.filter((index) => !oncomingSet.has(index)),
|
|
117
|
+
oncoming,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|