minimojs 1.0.0-alpha.2 → 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 +82 -285
- package/dist/internal/AnimationSystem.js +345 -0
- package/dist/internal/AssetSystem.d.ts +1 -0
- package/dist/internal/AssetSystem.js +101 -0
- package/dist/internal/BackgroundSystem.d.ts +1 -0
- package/dist/internal/BackgroundSystem.js +26 -0
- package/dist/internal/CanvasSystem.d.ts +1 -0
- package/dist/internal/CanvasSystem.js +51 -0
- package/dist/internal/ExplosionSystem.d.ts +1 -0
- package/dist/internal/ExplosionSystem.js +540 -0
- package/dist/internal/InputSystem.d.ts +1 -0
- package/dist/internal/InputSystem.js +265 -0
- package/dist/internal/LoopSystem.d.ts +1 -0
- package/dist/internal/LoopSystem.js +61 -0
- package/dist/internal/PhysicsSystem.d.ts +1 -0
- package/dist/internal/PhysicsSystem.js +174 -0
- package/dist/internal/RenderSystem.d.ts +1 -0
- package/dist/internal/RenderSystem.js +910 -0
- package/dist/internal/SoundSystem.d.ts +1 -0
- package/dist/internal/SoundSystem.js +55 -0
- package/dist/internal/SpriteSystem.d.ts +1 -0
- package/dist/internal/SpriteSystem.js +32 -0
- package/dist/internal/TextSystem.d.ts +1 -0
- package/dist/internal/TextSystem.js +16 -0
- package/dist/internal/TimerSystem.d.ts +1 -0
- package/dist/internal/TimerSystem.js +43 -0
- package/dist/internal/TrailSystem.d.ts +1 -0
- package/dist/internal/TrailSystem.js +116 -0
- package/dist/internal/TransitionSystem.d.ts +1 -0
- package/dist/internal/TransitionSystem.js +74 -0
- 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 +1431 -0
- package/dist/minimo-arcaderacer.js +2060 -0
- package/dist/minimo.d.ts +1412 -162
- package/dist/minimo.js +2083 -816
- package/package.json +3 -2
- package/dist/animations.js +0 -30
- package/dist/audio.js +0 -17
- package/dist/game.js +0 -1105
- package/dist/input.js +0 -185
- package/dist/internal-types.js +0 -4
- package/dist/physics.js +0 -10
- package/dist/render.js +0 -75
- package/dist/sprite.js +0 -149
- package/dist/timers.js +0 -23
- /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
|
@@ -0,0 +1,2060 @@
|
|
|
1
|
+
import { ArcadeRacerLaneSystem, sanitizeLaneSpacePositions, } from "./internal/arcade-racer/ArcadeRacerLaneSystem.js";
|
|
2
|
+
import { ArcadeRacerCollisionSystem } from "./internal/arcade-racer/ArcadeRacerCollisionSystem.js";
|
|
3
|
+
import { ArcadeRacerRenderSystem } from "./internal/arcade-racer/ArcadeRacerRenderSystem.js";
|
|
4
|
+
import { ArcadeRacerRoadSprite } from "./internal/arcade-racer/ArcadeRacerRoadSprite.js";
|
|
5
|
+
import { ArcadeRacerTrackSystem } from "./internal/arcade-racer/ArcadeRacerTrackSystem.js";
|
|
6
|
+
/**
|
|
7
|
+
* Default road drawer used by {@link ArcadeRacerEngine}.
|
|
8
|
+
*
|
|
9
|
+
* Extend this class when you want to keep the built-in pseudo-3D road geometry
|
|
10
|
+
* and only customize colors, lane markings, textures, or strip-level effects.
|
|
11
|
+
*/
|
|
12
|
+
export class DefaultArcadeRacerRoadDrawer {
|
|
13
|
+
/** Paints the full default strip in four stages: ground, shoulders, road, and lane markers. */
|
|
14
|
+
drawStrip(ctx, frame, strip) {
|
|
15
|
+
this.drawGround(ctx, frame, strip);
|
|
16
|
+
this.drawShoulders(ctx, frame, strip);
|
|
17
|
+
this.drawRoadSurface(ctx, frame, strip);
|
|
18
|
+
this.drawLaneMarkers(ctx, frame, strip);
|
|
19
|
+
}
|
|
20
|
+
/** Draws the default post-road vignette overlay. */
|
|
21
|
+
drawOverlay(ctx, frame) {
|
|
22
|
+
const vignette = ctx.createLinearGradient(0, frame.horizonY, 0, frame.height);
|
|
23
|
+
vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
|
|
24
|
+
vignette.addColorStop(1, "rgba(0, 0, 0, 0.18)");
|
|
25
|
+
ctx.fillStyle = vignette;
|
|
26
|
+
ctx.fillRect(0, frame.horizonY, frame.width, frame.height - frame.horizonY);
|
|
27
|
+
}
|
|
28
|
+
/** Draws the off-road ground area for one strip. */
|
|
29
|
+
drawGround(ctx, frame, strip) {
|
|
30
|
+
this.drawQuad(ctx, 0, strip.farY, frame.width, strip.farY, frame.width, strip.nearY, 0, strip.nearY, this.getGrassColor(frame, strip));
|
|
31
|
+
}
|
|
32
|
+
/** Draws the shoulder band for one strip. */
|
|
33
|
+
drawShoulders(ctx, frame, strip) {
|
|
34
|
+
this.drawQuad(ctx, strip.leftOuterFar, strip.farY, strip.rightOuterFar, strip.farY, strip.rightOuterNear, strip.nearY, strip.leftOuterNear, strip.nearY, this.getShoulderColor(frame, strip));
|
|
35
|
+
}
|
|
36
|
+
/** Draws the paved road surface for one strip. */
|
|
37
|
+
drawRoadSurface(ctx, frame, strip) {
|
|
38
|
+
this.drawQuad(ctx, strip.leftRoadFar, strip.farY, strip.rightRoadFar, strip.farY, strip.rightRoadNear, strip.nearY, strip.leftRoadNear, strip.nearY, this.getRoadColor(frame, strip));
|
|
39
|
+
}
|
|
40
|
+
/** Draws all lane markers for one strip when enabled. */
|
|
41
|
+
drawLaneMarkers(ctx, frame, strip) {
|
|
42
|
+
if (!this.shouldDrawLaneMarkers(frame, strip)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const color = this.getLaneMarkerColor(frame, strip);
|
|
46
|
+
for (const marker of strip.laneMarkers) {
|
|
47
|
+
this.drawQuad(ctx, marker.farX - marker.farWidth / 2, strip.farY, marker.farX + marker.farWidth / 2, strip.farY, marker.nearX + marker.nearWidth / 2, strip.nearY, marker.nearX - marker.nearWidth / 2, strip.nearY, color);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Returns the fill color used for the ground area of a strip. */
|
|
51
|
+
getGrassColor(frame, strip) {
|
|
52
|
+
return strip.grassBandIndex % 2 === 0
|
|
53
|
+
? frame.theme.grassA
|
|
54
|
+
: frame.theme.grassB;
|
|
55
|
+
}
|
|
56
|
+
/** Returns the fill color used for the shoulder area of a strip. */
|
|
57
|
+
getShoulderColor(frame, strip) {
|
|
58
|
+
return strip.shoulderBandIndex % 2 === 0
|
|
59
|
+
? frame.theme.shoulderA
|
|
60
|
+
: frame.theme.shoulderB;
|
|
61
|
+
}
|
|
62
|
+
/** Returns the fill color used for the paved road of a strip. */
|
|
63
|
+
getRoadColor(frame, strip) {
|
|
64
|
+
return strip.roadBandIndex % 2 === 0
|
|
65
|
+
? frame.theme.roadA
|
|
66
|
+
: frame.theme.roadB;
|
|
67
|
+
}
|
|
68
|
+
/** Returns the color used for lane-marker quads. */
|
|
69
|
+
getLaneMarkerColor(frame, _strip) {
|
|
70
|
+
return frame.theme.laneMarker;
|
|
71
|
+
}
|
|
72
|
+
/** Controls whether lane markers should be painted for the strip. */
|
|
73
|
+
shouldDrawLaneMarkers(_frame, strip) {
|
|
74
|
+
return strip.laneMarkers.length > 0;
|
|
75
|
+
}
|
|
76
|
+
/** Helper that fills a four-point polygon, useful for custom subclasses. */
|
|
77
|
+
drawQuad(ctx, x1, y1, x2, y2, x3, y3, x4, y4, color) {
|
|
78
|
+
ctx.fillStyle = color;
|
|
79
|
+
ctx.beginPath();
|
|
80
|
+
ctx.moveTo(x1, y1);
|
|
81
|
+
ctx.lineTo(x2, y2);
|
|
82
|
+
ctx.lineTo(x3, y3);
|
|
83
|
+
ctx.lineTo(x4, y4);
|
|
84
|
+
ctx.closePath();
|
|
85
|
+
ctx.fill();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Road drawer that forwards each paint call to multiple child drawers.
|
|
90
|
+
*
|
|
91
|
+
* Drawers are executed in array order, which makes this useful for layering a
|
|
92
|
+
* base road style with extra passes such as crops, palm fields, rocks, or
|
|
93
|
+
* tunnel accents.
|
|
94
|
+
*/
|
|
95
|
+
export class CompositeRoadDrawer {
|
|
96
|
+
/**
|
|
97
|
+
* Creates a composite drawer from the provided children.
|
|
98
|
+
*
|
|
99
|
+
* The order matters: earlier drawers paint first and later drawers paint on top.
|
|
100
|
+
*/
|
|
101
|
+
constructor(drawers = []) {
|
|
102
|
+
this.drawers = [...drawers];
|
|
103
|
+
}
|
|
104
|
+
/** Paints the strip by forwarding the call to each child drawer in order. */
|
|
105
|
+
drawStrip(ctx, frame, strip) {
|
|
106
|
+
for (const drawer of this.drawers) {
|
|
107
|
+
drawer.drawStrip(ctx, frame, strip);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Forwards the optional overlay pass to each child drawer that implements it. */
|
|
111
|
+
drawOverlay(ctx, frame) {
|
|
112
|
+
for (const drawer of this.drawers) {
|
|
113
|
+
drawer.drawOverlay?.(ctx, frame);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Appends another child drawer to the composite. */
|
|
117
|
+
addDrawer(drawer) {
|
|
118
|
+
this.drawers.push(drawer);
|
|
119
|
+
}
|
|
120
|
+
/** Removes all child drawers from the composite. */
|
|
121
|
+
clearDrawers() {
|
|
122
|
+
this.drawers.length = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Configurable roadside crop drawer.
|
|
127
|
+
*
|
|
128
|
+
* This drawer paints repeated crop sprites across the left and/or right grass
|
|
129
|
+
* bands. Use it inside {@link CompositeRoadDrawer} together with
|
|
130
|
+
* {@link DefaultArcadeRacerRoadDrawer} when you want the built-in road style
|
|
131
|
+
* plus crop layouts on top.
|
|
132
|
+
*/
|
|
133
|
+
export class CropRoadDrawer extends DefaultArcadeRacerRoadDrawer {
|
|
134
|
+
/** Creates a crop drawer with configurable density, spacing, and height variation. */
|
|
135
|
+
constructor(options) {
|
|
136
|
+
super();
|
|
137
|
+
/** @internal */
|
|
138
|
+
this.emojiSurfaceCache = null;
|
|
139
|
+
this.game = options.game;
|
|
140
|
+
this.visual = options.visual;
|
|
141
|
+
this.sides = options.sides?.length
|
|
142
|
+
? [...options.sides]
|
|
143
|
+
: ["left", "right"];
|
|
144
|
+
this.paintGround = options.paintGround ?? false;
|
|
145
|
+
this.groundColor = options.groundColor ?? "#7b5a32";
|
|
146
|
+
this.rowSpacing = Math.max(1, options.rowSpacing ?? 82);
|
|
147
|
+
this.columnSpacing = Math.max(1, options.columnSpacing ?? 34);
|
|
148
|
+
this.columnSpacingJitter = Math.max(0, options.columnSpacingJitter ?? 0);
|
|
149
|
+
this.columnInset = Math.max(0, options.columnInset ?? 42);
|
|
150
|
+
this.rowShift =
|
|
151
|
+
options.rowShift ?? Math.max(0, this.columnSpacing * 0.5);
|
|
152
|
+
this.minHeightRatio = Math.max(0.1, options.minHeightRatio ?? 0.85);
|
|
153
|
+
this.maxHeightRatio = Math.max(this.minHeightRatio, options.maxHeightRatio ?? 1.35);
|
|
154
|
+
this.baseHeightScale = Math.max(0.001, options.baseHeightScale ?? 0.12);
|
|
155
|
+
this.minDrawHeight = Math.max(1, options.minDrawHeight ?? 16);
|
|
156
|
+
this.alpha = Math.max(0, Math.min(1, options.alpha ?? 0.9));
|
|
157
|
+
this.drawBaseShadow = options.drawBaseShadow ?? true;
|
|
158
|
+
this.baseShadowColor = options.baseShadowColor ?? "rgba(68, 38, 18, 1)";
|
|
159
|
+
this.baseShadowAlpha = Math.max(0, Math.min(1, options.baseShadowAlpha ?? 0.28));
|
|
160
|
+
this.baseShadowRadiusScale = Math.max(0, options.baseShadowRadiusScale ?? 0.3);
|
|
161
|
+
this.baseShadowHeightScale = Math.max(0, options.baseShadowHeightScale ?? 0.42);
|
|
162
|
+
}
|
|
163
|
+
/** Paints crop ground and crop instances for the configured roadside bands. */
|
|
164
|
+
drawStrip(ctx, frame, strip) {
|
|
165
|
+
if (strip.nearProjection.roadWidth < 8) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (this.paintGround) {
|
|
169
|
+
for (const side of this.sides) {
|
|
170
|
+
this.drawCropGroundOnSide(ctx, frame, strip, side);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const metrics = this.resolveVisualMetrics();
|
|
174
|
+
if (!metrics) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const side of this.sides) {
|
|
178
|
+
this.drawCropRowsOnSide(ctx, frame, strip, side, metrics);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** @internal */
|
|
182
|
+
drawCropGroundOnSide(ctx, frame, strip, side) {
|
|
183
|
+
if (side === "left") {
|
|
184
|
+
this.drawQuad(ctx, 0, strip.farY, strip.leftOuterFar, strip.farY, strip.leftOuterNear, strip.nearY, 0, strip.nearY, this.groundColor);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.drawQuad(ctx, strip.rightOuterFar, strip.farY, frame.width, strip.farY, frame.width, strip.nearY, strip.rightOuterNear, strip.nearY, this.groundColor);
|
|
188
|
+
}
|
|
189
|
+
/** @internal */
|
|
190
|
+
drawCropRowsOnSide(ctx, frame, strip, side, metrics) {
|
|
191
|
+
const minDistance = Math.min(strip.farDistance, strip.nearDistance);
|
|
192
|
+
const maxDistance = Math.max(strip.farDistance, strip.nearDistance);
|
|
193
|
+
const startRow = Math.floor(minDistance / this.rowSpacing);
|
|
194
|
+
const endRow = Math.floor(maxDistance / this.rowSpacing);
|
|
195
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
196
|
+
const worldDistance = (row + 0.5) * this.rowSpacing;
|
|
197
|
+
if (worldDistance < minDistance || worldDistance > maxDistance) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const surface = frame.projectDistance(worldDistance);
|
|
201
|
+
const baseY = surface.projection.y;
|
|
202
|
+
const edgeOuterX = side === "left" ? surface.leftOuterX : surface.rightOuterX;
|
|
203
|
+
const scale = surface.projection.scale;
|
|
204
|
+
const roadWidth = surface.projection.roadWidth;
|
|
205
|
+
const placements = this.getPlantPlacementsForSurface(surface, frame.width, side, row);
|
|
206
|
+
for (const placement of placements) {
|
|
207
|
+
const x = side === "left"
|
|
208
|
+
? edgeOuterX - placement.offset * scale
|
|
209
|
+
: edgeOuterX + placement.offset * scale;
|
|
210
|
+
const drawHeight = Math.max(this.minDrawHeight, roadWidth * this.baseHeightScale * placement.heightScale);
|
|
211
|
+
const drawWidth = drawHeight * metrics.aspect;
|
|
212
|
+
if (this.drawBaseShadow) {
|
|
213
|
+
this.drawCropShadow(ctx, x, baseY, drawWidth);
|
|
214
|
+
}
|
|
215
|
+
ctx.save();
|
|
216
|
+
ctx.globalAlpha = this.alpha;
|
|
217
|
+
ctx.drawImage(metrics.surface, Math.round(x - drawWidth * 0.5), Math.round(baseY - drawHeight), drawWidth, drawHeight);
|
|
218
|
+
ctx.restore();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** @internal */
|
|
223
|
+
drawCropShadow(ctx, x, baseY, drawWidth) {
|
|
224
|
+
const shadowRadius = Math.max(2, drawWidth * this.baseShadowRadiusScale);
|
|
225
|
+
ctx.save();
|
|
226
|
+
ctx.globalAlpha = this.baseShadowAlpha;
|
|
227
|
+
ctx.fillStyle = this.baseShadowColor;
|
|
228
|
+
ctx.beginPath();
|
|
229
|
+
ctx.ellipse(Math.round(x), Math.round(baseY - shadowRadius * 0.12), shadowRadius, Math.max(1.5, shadowRadius * this.baseShadowHeightScale), 0, 0, Math.PI * 2);
|
|
230
|
+
ctx.fill();
|
|
231
|
+
ctx.restore();
|
|
232
|
+
}
|
|
233
|
+
/** @internal */
|
|
234
|
+
getPlantPlacementsForSurface(surface, frameWidth, side, row) {
|
|
235
|
+
const spanPixels = side === "left" ? surface.leftOuterX : frameWidth - surface.rightOuterX;
|
|
236
|
+
const scale = Math.max(surface.projection.scale, 0.0001);
|
|
237
|
+
const maxWorldOffset = Math.max(this.columnInset, spanPixels / scale + this.columnSpacing);
|
|
238
|
+
const sideSeed = side === "left" ? 0 : 1000;
|
|
239
|
+
const placements = [];
|
|
240
|
+
let offset = this.columnInset +
|
|
241
|
+
(row % 2 === 0 ? 0 : this.rowShift) +
|
|
242
|
+
this.hashValue(row * 17 + sideSeed + 1) * this.columnSpacingJitter;
|
|
243
|
+
let index = 0;
|
|
244
|
+
while (offset <= maxWorldOffset) {
|
|
245
|
+
const spacingJitter = this.hashValue(row * 43 + sideSeed + index * 7 + 3);
|
|
246
|
+
const heightJitter = this.hashValue(row * 61 + sideSeed + index * 11 + 9);
|
|
247
|
+
placements.push({
|
|
248
|
+
offset,
|
|
249
|
+
heightScale: this.minHeightRatio +
|
|
250
|
+
heightJitter * (this.maxHeightRatio - this.minHeightRatio),
|
|
251
|
+
});
|
|
252
|
+
offset +=
|
|
253
|
+
this.columnSpacing +
|
|
254
|
+
(spacingJitter - 0.5) * 2 * this.columnSpacingJitter;
|
|
255
|
+
index += 1;
|
|
256
|
+
}
|
|
257
|
+
return placements;
|
|
258
|
+
}
|
|
259
|
+
/** @internal */
|
|
260
|
+
resolveVisualMetrics() {
|
|
261
|
+
const source = this.visual;
|
|
262
|
+
if (source.type === "image") {
|
|
263
|
+
const image = this.game.getImage(source.key);
|
|
264
|
+
if (!image) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const width = source.width ??
|
|
268
|
+
(image instanceof HTMLImageElement
|
|
269
|
+
? image.naturalWidth || image.width
|
|
270
|
+
: image.width);
|
|
271
|
+
const height = source.height ??
|
|
272
|
+
(image instanceof HTMLImageElement
|
|
273
|
+
? image.naturalHeight || image.height
|
|
274
|
+
: image.height);
|
|
275
|
+
return {
|
|
276
|
+
surface: image,
|
|
277
|
+
aspect: width / Math.max(1, height),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const canvas = this.emojiSurfaceCache ?? this.createEmojiSurface(source);
|
|
281
|
+
this.emojiSurfaceCache = canvas;
|
|
282
|
+
return {
|
|
283
|
+
surface: canvas,
|
|
284
|
+
aspect: canvas.width / Math.max(1, canvas.height),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** @internal */
|
|
288
|
+
createEmojiSurface(source) {
|
|
289
|
+
const size = Math.max(8, source.size ?? 64);
|
|
290
|
+
const canvas = document.createElement("canvas");
|
|
291
|
+
canvas.width = size;
|
|
292
|
+
canvas.height = size;
|
|
293
|
+
const ctx = canvas.getContext("2d");
|
|
294
|
+
if (!ctx) {
|
|
295
|
+
throw new Error("Unable to create crop drawer emoji surface");
|
|
296
|
+
}
|
|
297
|
+
ctx.clearRect(0, 0, size, size);
|
|
298
|
+
ctx.textAlign = "center";
|
|
299
|
+
ctx.textBaseline = "middle";
|
|
300
|
+
ctx.font = `${Math.round(size * 0.82)}px system-ui`;
|
|
301
|
+
if (source.color) {
|
|
302
|
+
ctx.fillStyle = source.color;
|
|
303
|
+
}
|
|
304
|
+
ctx.fillText(source.value, size * 0.5, size * 0.56);
|
|
305
|
+
return canvas;
|
|
306
|
+
}
|
|
307
|
+
/** @internal */
|
|
308
|
+
hashValue(value) {
|
|
309
|
+
const s = Math.sin(value * 127.1 + 311.7) * 43758.5453123;
|
|
310
|
+
return s - Math.floor(s);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const DEFAULT_THEME = {
|
|
314
|
+
skyTop: "#6ab7ff",
|
|
315
|
+
skyBottom: "#10203d",
|
|
316
|
+
mountainBack: "#21395f",
|
|
317
|
+
mountainFront: "#152744",
|
|
318
|
+
grassA: "#2f5f33",
|
|
319
|
+
grassB: "#356c3a",
|
|
320
|
+
roadA: "#555761",
|
|
321
|
+
roadB: "#62646f",
|
|
322
|
+
shoulderA: "#d85b63",
|
|
323
|
+
shoulderB: "#f4dfcf",
|
|
324
|
+
laneMarker: "#fff9da",
|
|
325
|
+
horizonGlow: "rgba(255, 214, 122, 0.24)",
|
|
326
|
+
sunInner: "rgba(255, 250, 205, 0.98)",
|
|
327
|
+
sunOuter: "rgba(255, 205, 113, 0)",
|
|
328
|
+
};
|
|
329
|
+
const DEFAULT_PERFORMANCE = {
|
|
330
|
+
zeroToSixtySeconds: 6.2,
|
|
331
|
+
sixtyToZeroSeconds: 3.6,
|
|
332
|
+
topSpeedMph: 145,
|
|
333
|
+
steeringLowSpeedScale: 0.9,
|
|
334
|
+
steeringHighSpeedScale: 0.34,
|
|
335
|
+
steeringSpeedCurvePower: 1.8,
|
|
336
|
+
lateralDamping: 6.8,
|
|
337
|
+
};
|
|
338
|
+
const DEFAULT_HORIZON = {
|
|
339
|
+
influenceAngle: 120,
|
|
340
|
+
parallaxFactor: 0.26,
|
|
341
|
+
baseYOffset: 26,
|
|
342
|
+
maxVisibleMarkers: 3,
|
|
343
|
+
headingResponse: 0.24,
|
|
344
|
+
};
|
|
345
|
+
const DEFAULT_MINIMAP = {
|
|
346
|
+
visible: false,
|
|
347
|
+
width: 148,
|
|
348
|
+
height: 148,
|
|
349
|
+
padding: 10,
|
|
350
|
+
alpha: 1,
|
|
351
|
+
backgroundColor: "rgba(6, 10, 18, 0.72)",
|
|
352
|
+
borderColor: "rgba(126, 224, 255, 0.45)",
|
|
353
|
+
borderWidth: 2,
|
|
354
|
+
trackColor: "rgba(245, 248, 255, 0.9)",
|
|
355
|
+
trackLineWidth: 3,
|
|
356
|
+
playerColor: "#ff6a6e",
|
|
357
|
+
playerStrokeColor: "#fff7dd",
|
|
358
|
+
playerRadius: 4,
|
|
359
|
+
};
|
|
360
|
+
const AUTO_BODY_WIDTH_VISUAL_RATIO = 0.76;
|
|
361
|
+
const AUTO_BODY_LENGTH_PER_PIXEL = 1.92;
|
|
362
|
+
const DEFAULT_DEBUG = {
|
|
363
|
+
enabled: false,
|
|
364
|
+
visualBounds: true,
|
|
365
|
+
collisionBounds: true,
|
|
366
|
+
realCollisionBounds: true,
|
|
367
|
+
visualBoundsColor: "rgba(80, 220, 255, 0.9)",
|
|
368
|
+
playerCollisionBoundsColor: "rgba(255, 238, 88, 0.95)",
|
|
369
|
+
trafficCollisionBoundsColor: "rgba(255, 82, 82, 0.95)",
|
|
370
|
+
playerRealCollisionBoundsColor: "rgba(255, 170, 0, 0.95)",
|
|
371
|
+
trafficRealCollisionBoundsColor: "rgba(255, 0, 170, 0.95)",
|
|
372
|
+
lineWidth: 2,
|
|
373
|
+
};
|
|
374
|
+
const MPH_PER_SPEED_UNIT = 0.22;
|
|
375
|
+
const DEFAULT_GROUND_FILL = {
|
|
376
|
+
enabled: true,
|
|
377
|
+
color: null,
|
|
378
|
+
};
|
|
379
|
+
const DEFAULT_OPTIONS = {
|
|
380
|
+
stripCount: 78,
|
|
381
|
+
drawDistance: 1200,
|
|
382
|
+
laneCount: 3,
|
|
383
|
+
playerLane: 0,
|
|
384
|
+
speed: 0,
|
|
385
|
+
playerBaseScale: 1,
|
|
386
|
+
playerBodyWidth: 36,
|
|
387
|
+
playerBodyLength: 48,
|
|
388
|
+
steeringRate: 1.85,
|
|
389
|
+
curveScale: 0.9,
|
|
390
|
+
hillScale: 0.22,
|
|
391
|
+
playerRoadInfluence: 0.46,
|
|
392
|
+
playerLaneLimit: 1.8,
|
|
393
|
+
offRoadThreshold: 0.82,
|
|
394
|
+
offRoadTargetSpeedMph: 18,
|
|
395
|
+
offRoadBrakeRate: 3.2,
|
|
396
|
+
offRoadAccelerationScale: 0.28,
|
|
397
|
+
realCollisionVerticalShiftBlend: 0.5,
|
|
398
|
+
};
|
|
399
|
+
const DEFAULT_TRAFFIC_MANAGER_OPTIONS = {
|
|
400
|
+
maxActive: 14,
|
|
401
|
+
initialActive: 6,
|
|
402
|
+
spawnInterval: 0.45,
|
|
403
|
+
spawnAheadMin: 900,
|
|
404
|
+
spawnAheadMax: 2200,
|
|
405
|
+
spawnBehindMin: 320,
|
|
406
|
+
spawnBehindMax: 760,
|
|
407
|
+
despawnBehindDistance: 260,
|
|
408
|
+
despawnAheadDistance: 2800,
|
|
409
|
+
minGapDistance: 220,
|
|
410
|
+
oncomingChance: 0.35,
|
|
411
|
+
fasterTrafficSpawnsBehind: true,
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* Main public API for the MinimoJS arcade racing module.
|
|
415
|
+
*
|
|
416
|
+
* `ArcadeRacerEngine` is a pseudo-3D road renderer and gameplay helper built
|
|
417
|
+
* on top of MinimoJS. It is designed for highway racers, Out Run style arcade
|
|
418
|
+
* games, looping circuits, and simple traffic-heavy road scenes where the
|
|
419
|
+
* player remains screen-anchored while the road, traffic, roadside objects, and
|
|
420
|
+
* heading-based background move around them.
|
|
421
|
+
*
|
|
422
|
+
* Instead of building the scene out of many ordinary MinimoJS sprites, the
|
|
423
|
+
* engine owns one internal {@link DrawSprite} and composes most of the frame
|
|
424
|
+
* inside that surface. That design keeps the API compact while still supporting:
|
|
425
|
+
*
|
|
426
|
+
* - straight roads and curved roads using path primitives
|
|
427
|
+
* - optional elevation changes on straights and arcs
|
|
428
|
+
* - roadside billboards and traffic vehicles
|
|
429
|
+
* - heading-based horizon markers
|
|
430
|
+
* - layered image backgrounds with parallax
|
|
431
|
+
* - minimap rendering
|
|
432
|
+
* - off-road detection and optional automatic slowdown
|
|
433
|
+
* - player speed control in engine units or mph
|
|
434
|
+
*
|
|
435
|
+
* Typical usage follows this flow:
|
|
436
|
+
*
|
|
437
|
+
* ```ts
|
|
438
|
+
* const racer = new ArcadeRacerEngine(game, {
|
|
439
|
+
* width: game.width,
|
|
440
|
+
* height: game.height,
|
|
441
|
+
* horizonY: 180,
|
|
442
|
+
* });
|
|
443
|
+
*
|
|
444
|
+
* racer.clearTrack();
|
|
445
|
+
* racer.straight(2600, { elevationDelta: 40 });
|
|
446
|
+
* racer.arcRight(1200, 24);
|
|
447
|
+
* racer.straight(1800);
|
|
448
|
+
*
|
|
449
|
+
* racer.addTraffic({ type: "image", key: "truck" }, {
|
|
450
|
+
* lane: 0.4,
|
|
451
|
+
* distance: 800,
|
|
452
|
+
* speed: 120,
|
|
453
|
+
* });
|
|
454
|
+
*
|
|
455
|
+
* const roadSprite = racer.build();
|
|
456
|
+
* game.add(roadSprite);
|
|
457
|
+
*
|
|
458
|
+
* game.onUpdate = (dt) => {
|
|
459
|
+
* racer.accelerateToMph(110, dt);
|
|
460
|
+
* if (game.isKeyDown("ArrowLeft")) racer.steerLeft(dt);
|
|
461
|
+
* if (game.isKeyDown("ArrowRight")) racer.steerRight(dt);
|
|
462
|
+
* racer.update(dt);
|
|
463
|
+
* };
|
|
464
|
+
* ```
|
|
465
|
+
*
|
|
466
|
+
* Conceptually, the engine is split into five parts:
|
|
467
|
+
*
|
|
468
|
+
* 1. Track definition:
|
|
469
|
+
* Use {@link clearTrack}, {@link straight}, {@link arcLeft}, and
|
|
470
|
+
* {@link arcRight} to build the center path of the road. These methods are
|
|
471
|
+
* the primary geometry API.
|
|
472
|
+
* 2. Player state:
|
|
473
|
+
* The player is controlled through properties like {@link speedMph},
|
|
474
|
+
* {@link playerLane}, and helper methods such as {@link steerLeft},
|
|
475
|
+
* {@link steerRight}, {@link accelerateToMph}, and {@link brakeToMph}.
|
|
476
|
+
* 3. Scene objects:
|
|
477
|
+
* Use {@link addTraffic} and {@link addBillboard} to place vehicles,
|
|
478
|
+
* roadside decoration, and wide anchored structures such as bridges,
|
|
479
|
+
* arches, or fences that extend into the road.
|
|
480
|
+
* 4. Background and atmosphere:
|
|
481
|
+
* Configure atmosphere in the constructor, then use
|
|
482
|
+
* {@link addBackgroundLayer} and {@link addHorizonMarker} to place skies,
|
|
483
|
+
* mountains, distant landmarks, and horizon visuals.
|
|
484
|
+
* 5. Presentation and HUD helpers:
|
|
485
|
+
* Use constructor options such as `theme`, `minimap`, `horizon`,
|
|
486
|
+
* `performance`, and `debug` to set the look and HUD overlays up front.
|
|
487
|
+
*
|
|
488
|
+
* Important behavioral notes:
|
|
489
|
+
*
|
|
490
|
+
* - The player is screen-anchored. They do not move through MinimoJS world
|
|
491
|
+
* coordinates like a regular sprite; instead, the engine projects the road
|
|
492
|
+
* and scene around the player.
|
|
493
|
+
* - `distance` keeps increasing even on looping tracks. The renderer wraps
|
|
494
|
+
* internally when sampling road geometry.
|
|
495
|
+
* - `playerLane` is the main lateral control value. Around `-1..1` the player
|
|
496
|
+
* is on the paved road; beyond that they enter shoulders and off-road space
|
|
497
|
+
* depending on `playerLaneLimit`.
|
|
498
|
+
* - `laneCount` controls how many visible lane bands the road renders. When
|
|
499
|
+
* you also provide `laneDirections`, the engine can tell which zero-based
|
|
500
|
+
* lanes belong to same-direction versus oncoming traffic, and helpers such as
|
|
501
|
+
* {@link getLanePositions} can return those subsets for you.
|
|
502
|
+
* - Traffic collisions are resolved in road space, not by 2D sprite overlap.
|
|
503
|
+
* - If no image background layers are configured, the engine falls back to a
|
|
504
|
+
* built-in procedural sky and mountain backdrop.
|
|
505
|
+
*
|
|
506
|
+
* Visual source model:
|
|
507
|
+
*
|
|
508
|
+
* Most methods that draw objects accept {@link ArcadeRacerVisualSource}. That
|
|
509
|
+
* means you can mix emoji-backed visuals and preloaded MinimoJS images in the
|
|
510
|
+
* same scene. This is especially useful for quick prototypes that later evolve
|
|
511
|
+
* into art-driven scenes without changing the rest of the engine API.
|
|
512
|
+
*
|
|
513
|
+
* Choosing the right APIs:
|
|
514
|
+
*
|
|
515
|
+
* - Use {@link speed} / {@link accelerateTo} when working in engine units.
|
|
516
|
+
* - Use {@link speedMph} / {@link accelerateToMph} for player-facing gameplay.
|
|
517
|
+
* - Use {@link addBackgroundLayer} for wide, repeating, parallax image bands.
|
|
518
|
+
* - Use {@link addHorizonMarker} for directional landmarks that appear only at
|
|
519
|
+
* certain headings.
|
|
520
|
+
* - Prefer {@link ArcadeRacerTrafficManager} as the first solution for dynamic
|
|
521
|
+
* gameplay traffic. It is the recommended default before building a custom
|
|
522
|
+
* spawning system with direct {@link addTraffic} and {@link removeTrafficById}
|
|
523
|
+
* calls.
|
|
524
|
+
* - Use constructor options such as `playerBodyWidth`, `playerBodyLength`,
|
|
525
|
+
* `playerBodyOffsetX`, and `playerBodyOffsetY` when you need to tune
|
|
526
|
+
* collisions in local image pixels without changing the player artwork.
|
|
527
|
+
* - Use {@link getLanePosition} and {@link getLanePositions} when you want to
|
|
528
|
+
* place traffic, race competitors, or billboards directly on lane centers
|
|
529
|
+
* instead of guessing lane-space numbers by hand.
|
|
530
|
+
* - Use constructor `debug` options when you need to visualize car image
|
|
531
|
+
* bounds and collision bodies while tuning traffic hits.
|
|
532
|
+
* - Use `roadDrawer`, {@link addRoadDrawerRange}, and
|
|
533
|
+
* {@link DefaultArcadeRacerRoadDrawer} when you want to restyle the road
|
|
534
|
+
* globally or switch to custom road painting on specific track spans.
|
|
535
|
+
*
|
|
536
|
+
* This class is the canonical public surface of the module. Its JSDoc is
|
|
537
|
+
* intended to be consumed directly by AI agents and tooling that generate code
|
|
538
|
+
* against `minimo-arcaderacer.js`.
|
|
539
|
+
*/
|
|
540
|
+
export class ArcadeRacerEngine {
|
|
541
|
+
/** @internal */
|
|
542
|
+
constructor(game, options = {}) {
|
|
543
|
+
/** @internal */
|
|
544
|
+
this.collisionHandlers = [];
|
|
545
|
+
/** @internal */
|
|
546
|
+
this.emojiSurfaceCache = new Map();
|
|
547
|
+
/** @internal */
|
|
548
|
+
this.trafficBehindVisibilityDistance = 220;
|
|
549
|
+
/** @internal */
|
|
550
|
+
this.playerProjectionAheadDistance = 210;
|
|
551
|
+
/** @internal */
|
|
552
|
+
this.roadBandSpan = 4;
|
|
553
|
+
/** @internal */
|
|
554
|
+
this.shoulderBandSpan = 4;
|
|
555
|
+
/** @internal */
|
|
556
|
+
this.grassBandSpan = 3;
|
|
557
|
+
/** @internal */
|
|
558
|
+
this.roadDrawerRangesState = [];
|
|
559
|
+
/** @internal */
|
|
560
|
+
this.billboardsState = [];
|
|
561
|
+
/** @internal */
|
|
562
|
+
this.trafficState = [];
|
|
563
|
+
/** @internal */
|
|
564
|
+
this.horizonMarkersState = [];
|
|
565
|
+
/** @internal */
|
|
566
|
+
this.backgroundLayersState = [];
|
|
567
|
+
/** @internal */
|
|
568
|
+
this.playerBodyWidthManual = false;
|
|
569
|
+
/** @internal */
|
|
570
|
+
this.playerBodyLengthManual = false;
|
|
571
|
+
/** @internal */
|
|
572
|
+
this.distanceState = 0;
|
|
573
|
+
/** @internal */
|
|
574
|
+
this.playerSteeringInputState = 0;
|
|
575
|
+
/** @internal */
|
|
576
|
+
this.playerLateralVelocityState = 0;
|
|
577
|
+
/** @internal */
|
|
578
|
+
this.nextId = 1;
|
|
579
|
+
/** @internal */
|
|
580
|
+
this.roadSprite = null;
|
|
581
|
+
/** @internal */
|
|
582
|
+
this.activeCollisionIds = new Set();
|
|
583
|
+
/** @internal */
|
|
584
|
+
this.headingState = 0;
|
|
585
|
+
this.game = game;
|
|
586
|
+
this.width = Math.max(1, Math.round(options.width ?? game.width));
|
|
587
|
+
this.height = Math.max(1, Math.round(options.height ?? game.height));
|
|
588
|
+
this.x = options.x ?? this.width / 2;
|
|
589
|
+
this.y = options.y ?? this.height / 2;
|
|
590
|
+
this.layer = options.layer ?? 0;
|
|
591
|
+
this.horizonY =
|
|
592
|
+
options.horizonY ?? Math.round(this.height * 0.27);
|
|
593
|
+
this.stripCount = Math.max(12, Math.round(options.stripCount ?? DEFAULT_OPTIONS.stripCount));
|
|
594
|
+
this.drawDistance = Math.max(100, options.drawDistance ?? DEFAULT_OPTIONS.drawDistance);
|
|
595
|
+
this.roadNearWidth = Math.max(16, options.roadNearWidth ?? this.width * 0.98);
|
|
596
|
+
this.roadFarWidth = Math.max(4, options.roadFarWidth ?? this.width * 0.1);
|
|
597
|
+
const explicitLaneCount = Math.max(1, Math.round(options.laneCount ?? DEFAULT_OPTIONS.laneCount));
|
|
598
|
+
this.laneSystem = new ArcadeRacerLaneSystem(explicitLaneCount, options.laneDirections);
|
|
599
|
+
this.trackSystem = new ArcadeRacerTrackSystem();
|
|
600
|
+
this.collisionSystem = new ArcadeRacerCollisionSystem(this);
|
|
601
|
+
this.renderSystem = new ArcadeRacerRenderSystem(this);
|
|
602
|
+
this.defaultRoadDrawer = new DefaultArcadeRacerRoadDrawer();
|
|
603
|
+
this.roadDrawerState = options.roadDrawer ?? this.defaultRoadDrawer;
|
|
604
|
+
this.roadDrawerRangesState = [...(options.roadDrawerRanges ?? [])];
|
|
605
|
+
this.steeringRate = Math.max(0, options.steeringRate ?? DEFAULT_OPTIONS.steeringRate);
|
|
606
|
+
this.curveScale = Math.max(0, options.curveScale ?? DEFAULT_OPTIONS.curveScale);
|
|
607
|
+
this.hillScale = Math.max(0, options.hillScale ?? DEFAULT_OPTIONS.hillScale);
|
|
608
|
+
this.playerRoadInfluence = Math.max(0, options.playerRoadInfluence ?? DEFAULT_OPTIONS.playerRoadInfluence);
|
|
609
|
+
this.playerLaneLimit = Math.max(1, options.playerLaneLimit ?? DEFAULT_OPTIONS.playerLaneLimit);
|
|
610
|
+
this.offRoadThreshold = this.clamp01(Math.min(options.offRoadThreshold ?? DEFAULT_OPTIONS.offRoadThreshold, this.playerLaneLimit));
|
|
611
|
+
this.offRoadSlowdownEnabled = options.offRoadSlowdownEnabled ?? false;
|
|
612
|
+
this.offRoadTargetSpeedMph = Math.max(0, options.offRoadTargetSpeedMph ?? DEFAULT_OPTIONS.offRoadTargetSpeedMph);
|
|
613
|
+
this.offRoadBrakeRate = Math.max(0, options.offRoadBrakeRate ?? DEFAULT_OPTIONS.offRoadBrakeRate);
|
|
614
|
+
this.offRoadAccelerationScale = this.clamp01(options.offRoadAccelerationScale ??
|
|
615
|
+
DEFAULT_OPTIONS.offRoadAccelerationScale);
|
|
616
|
+
this.realCollisionVerticalShiftBlend = this.clamp01(options.realCollisionVerticalShiftBlend ??
|
|
617
|
+
DEFAULT_OPTIONS.realCollisionVerticalShiftBlend);
|
|
618
|
+
this.themeState = {
|
|
619
|
+
...DEFAULT_THEME,
|
|
620
|
+
...(options.theme ?? {}),
|
|
621
|
+
};
|
|
622
|
+
this.groundFillState = this.sanitizeGroundFillOptions({
|
|
623
|
+
...DEFAULT_GROUND_FILL,
|
|
624
|
+
...(options.groundFill ?? {}),
|
|
625
|
+
});
|
|
626
|
+
this.performanceState = this.sanitizePerformanceOptions({
|
|
627
|
+
...DEFAULT_PERFORMANCE,
|
|
628
|
+
...(options.performance ?? {}),
|
|
629
|
+
});
|
|
630
|
+
this.horizonState = this.sanitizeHorizonOptions({
|
|
631
|
+
...DEFAULT_HORIZON,
|
|
632
|
+
...(options.horizon ?? {}),
|
|
633
|
+
});
|
|
634
|
+
this.minimapState = this.sanitizeMinimapOptions({
|
|
635
|
+
visible: options.minimap?.visible ?? DEFAULT_MINIMAP.visible,
|
|
636
|
+
x: options.minimap?.x ?? this.width - 164,
|
|
637
|
+
y: options.minimap?.y ?? 16,
|
|
638
|
+
width: options.minimap?.width ?? DEFAULT_MINIMAP.width,
|
|
639
|
+
height: options.minimap?.height ?? DEFAULT_MINIMAP.height,
|
|
640
|
+
padding: options.minimap?.padding ?? DEFAULT_MINIMAP.padding,
|
|
641
|
+
alpha: options.minimap?.alpha ?? DEFAULT_MINIMAP.alpha,
|
|
642
|
+
backgroundColor: options.minimap?.backgroundColor ?? DEFAULT_MINIMAP.backgroundColor,
|
|
643
|
+
borderColor: options.minimap?.borderColor ?? DEFAULT_MINIMAP.borderColor,
|
|
644
|
+
borderWidth: options.minimap?.borderWidth ?? DEFAULT_MINIMAP.borderWidth,
|
|
645
|
+
trackColor: options.minimap?.trackColor ?? DEFAULT_MINIMAP.trackColor,
|
|
646
|
+
trackLineWidth: options.minimap?.trackLineWidth ?? DEFAULT_MINIMAP.trackLineWidth,
|
|
647
|
+
playerColor: options.minimap?.playerColor ?? DEFAULT_MINIMAP.playerColor,
|
|
648
|
+
playerStrokeColor: options.minimap?.playerStrokeColor ?? DEFAULT_MINIMAP.playerStrokeColor,
|
|
649
|
+
playerRadius: options.minimap?.playerRadius ?? DEFAULT_MINIMAP.playerRadius,
|
|
650
|
+
});
|
|
651
|
+
this.debugState = this.sanitizeDebugOptions(options.debug ?? false);
|
|
652
|
+
this.playerVisualState =
|
|
653
|
+
// Falls back to an emoji because the module cannot assume any image key is
|
|
654
|
+
// loaded. Pass `playerVisual: { type: "image", key }` to use the game's art.
|
|
655
|
+
options.playerVisual ?? { type: "emoji", value: "🏎️", size: 48 };
|
|
656
|
+
this.playerScreenYState =
|
|
657
|
+
options.playerScreenY ?? Math.round(this.height * 0.86);
|
|
658
|
+
this.playerBaseScaleState =
|
|
659
|
+
options.playerBaseScale ?? DEFAULT_OPTIONS.playerBaseScale;
|
|
660
|
+
this.playerBodyWidthManual = Number.isFinite(options.playerBodyWidth);
|
|
661
|
+
this.playerBodyLengthManual = Number.isFinite(options.playerBodyLength);
|
|
662
|
+
const autoPlayerBody = this.resolveAutoBodyFromVisual(this.playerVisualState, this.playerBaseScaleState);
|
|
663
|
+
this.playerBodyWidthState =
|
|
664
|
+
options.playerBodyWidth ??
|
|
665
|
+
autoPlayerBody?.width ??
|
|
666
|
+
DEFAULT_OPTIONS.playerBodyWidth;
|
|
667
|
+
this.playerBodyLengthState =
|
|
668
|
+
options.playerBodyLength ??
|
|
669
|
+
autoPlayerBody?.length ??
|
|
670
|
+
DEFAULT_OPTIONS.playerBodyLength;
|
|
671
|
+
this.playerBodyOffsetXState = Number.isFinite(options.playerBodyOffsetX)
|
|
672
|
+
? options.playerBodyOffsetX
|
|
673
|
+
: 0;
|
|
674
|
+
this.playerBodyOffsetYState = Number.isFinite(options.playerBodyOffsetY)
|
|
675
|
+
? options.playerBodyOffsetY
|
|
676
|
+
: 0;
|
|
677
|
+
this.playerResolvedBodyState = this.resolveCollisionBodyMetrics(this.playerVisualState, this.playerBaseScaleState, this.playerBodyWidthState, this.playerBodyLengthState, this.playerBodyOffsetXState, this.playerBodyOffsetYState);
|
|
678
|
+
this.speedState = options.speed ?? DEFAULT_OPTIONS.speed;
|
|
679
|
+
this.playerLaneState = this.clampPlayerLane(options.playerLane ?? DEFAULT_OPTIONS.playerLane);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Number of visible driving lanes configured for the road.
|
|
683
|
+
*/
|
|
684
|
+
get laneCount() {
|
|
685
|
+
return this.laneSystem.laneCount;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Returns the lane-space center position for a zero-based lane index.
|
|
689
|
+
*
|
|
690
|
+
* The returned value is expressed in the same `[-1, 1]` road-space used by
|
|
691
|
+
* `playerLane`, `addTraffic({ lane })`, and billboard lane placement.
|
|
692
|
+
* Out-of-range indices are clamped to the nearest valid lane.
|
|
693
|
+
*/
|
|
694
|
+
getLanePosition(laneIndex) {
|
|
695
|
+
return this.laneSystem.getLanePosition(laneIndex);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Returns lane-space center positions for all lanes or a directional subset.
|
|
699
|
+
*
|
|
700
|
+
* When `laneDirections` was not configured, `"same"` and `"oncoming"` both
|
|
701
|
+
* fall back to all lanes.
|
|
702
|
+
*/
|
|
703
|
+
getLanePositions(direction = "all") {
|
|
704
|
+
return this.laneSystem.getLanePositions(direction);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Current player speed in world units per second.
|
|
708
|
+
*/
|
|
709
|
+
get speed() {
|
|
710
|
+
return this.speedState;
|
|
711
|
+
}
|
|
712
|
+
set speed(value) {
|
|
713
|
+
this.speedState = Number.isFinite(value) ? value : 0;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Current player speed in miles per hour.
|
|
717
|
+
*/
|
|
718
|
+
get speedMph() {
|
|
719
|
+
return this.speedToMph(this.speedState);
|
|
720
|
+
}
|
|
721
|
+
set speedMph(value) {
|
|
722
|
+
this.speedState = this.mphToSpeed(value);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Current heading in degrees in range `[0, 360)`.
|
|
726
|
+
*/
|
|
727
|
+
get headingDegrees() {
|
|
728
|
+
return this.headingState;
|
|
729
|
+
}
|
|
730
|
+
set headingDegrees(value) {
|
|
731
|
+
this.headingState = this.normalizeAngle(value);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Total forward distance traveled by the player.
|
|
735
|
+
*/
|
|
736
|
+
get distance() {
|
|
737
|
+
return this.distanceState;
|
|
738
|
+
}
|
|
739
|
+
set distance(value) {
|
|
740
|
+
this.distanceState = Number.isFinite(value) ? value : 0;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Player lane position.
|
|
744
|
+
*/
|
|
745
|
+
get playerLane() {
|
|
746
|
+
return this.playerLaneState;
|
|
747
|
+
}
|
|
748
|
+
set playerLane(value) {
|
|
749
|
+
this.playerLaneState = this.clampPlayerLane(value);
|
|
750
|
+
this.playerLateralVelocityState = 0;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Whether the player is currently outside the configured road threshold.
|
|
754
|
+
*/
|
|
755
|
+
get isOffRoad() {
|
|
756
|
+
return this.getOffRoadAmount() > 0;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Normalized amount of how far the player has gone off-road in range `[0, 1]`.
|
|
760
|
+
*/
|
|
761
|
+
get offRoadAmount() {
|
|
762
|
+
return this.getOffRoadAmount();
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Removes all existing track primitives and legacy segments.
|
|
766
|
+
*/
|
|
767
|
+
clearTrack() {
|
|
768
|
+
this.trackSystem.clearTrack();
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Appends a straight path section.
|
|
772
|
+
*/
|
|
773
|
+
straight(length, options = {}) {
|
|
774
|
+
this.trackSystem.appendPathLine(length, options);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Replaces the track with a naturally closed rounded rectangle circuit.
|
|
778
|
+
*/
|
|
779
|
+
roundedRect(width, height, cornerRadius) {
|
|
780
|
+
this.trackSystem.setRoundedRectTrack(width, height, cornerRadius);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Appends a path arc that bends left by the given number of degrees.
|
|
784
|
+
*/
|
|
785
|
+
arcLeft(radius, degrees, options = {}) {
|
|
786
|
+
this.trackSystem.appendPathArc(radius, degrees, -1, options);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Appends a path arc that bends right by the given number of degrees.
|
|
790
|
+
*/
|
|
791
|
+
arcRight(radius, degrees, options = {}) {
|
|
792
|
+
this.trackSystem.appendPathArc(radius, degrees, 1, options);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Creates the internal road sprite if it does not yet exist and returns it.
|
|
796
|
+
*
|
|
797
|
+
* The sprite is not added to the scene automatically. Add the returned
|
|
798
|
+
* `DrawSprite` yourself so the display-list order stays explicit.
|
|
799
|
+
*/
|
|
800
|
+
build() {
|
|
801
|
+
if (this.roadSprite)
|
|
802
|
+
return this.roadSprite;
|
|
803
|
+
const sprite = new ArcadeRacerRoadSprite(this.width, this.height, (ctx, width, height) => this.render(ctx, width, height));
|
|
804
|
+
sprite.x = this.x;
|
|
805
|
+
sprite.y = this.y;
|
|
806
|
+
sprite.ignoreScroll = true;
|
|
807
|
+
sprite.layer = this.layer;
|
|
808
|
+
this.roadSprite = sprite;
|
|
809
|
+
return this.roadSprite;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Returns the internal road render sprite, or `null` if `build()` has not run yet.
|
|
813
|
+
*/
|
|
814
|
+
getRoadSprite() {
|
|
815
|
+
return this.roadSprite;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Advances the racer simulation and redraws on the next MinimoJS render pass.
|
|
819
|
+
*/
|
|
820
|
+
update(dt) {
|
|
821
|
+
this.build();
|
|
822
|
+
const safeDt = Number.isFinite(dt) && dt > 0 ? Math.min(dt, 0.1) : 0;
|
|
823
|
+
this.distanceState += this.speedState * safeDt;
|
|
824
|
+
const current = this.trackSystem.sampleTrack(this.distanceState);
|
|
825
|
+
if (this.trackSystem.hasPathPrimitives) {
|
|
826
|
+
const targetHeading = this.normalizeAngle((current.heading * 180) / Math.PI);
|
|
827
|
+
const headingDelta = this.getShortestAngleDelta(this.headingState, targetHeading);
|
|
828
|
+
const followStrength = Math.min(1, this.horizonState.headingResponse * 2.5);
|
|
829
|
+
this.headingState = this.normalizeAngle(this.headingState + headingDelta * followStrength);
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
this.headingState = this.normalizeAngle(this.headingState +
|
|
833
|
+
current.curve *
|
|
834
|
+
this.speedToMph(Math.abs(this.speedState)) *
|
|
835
|
+
this.horizonState.headingResponse *
|
|
836
|
+
safeDt);
|
|
837
|
+
}
|
|
838
|
+
const forwardSpeed = Math.abs(this.speedState);
|
|
839
|
+
this.updatePlayerLateralDynamics(current, forwardSpeed, safeDt);
|
|
840
|
+
if (this.offRoadSlowdownEnabled && this.isOffRoad) {
|
|
841
|
+
this.brakeToMph(this.offRoadTargetSpeedMph, safeDt, this.offRoadBrakeRate);
|
|
842
|
+
}
|
|
843
|
+
for (const traffic of this.trafficState) {
|
|
844
|
+
traffic.distance += traffic.speed * safeDt;
|
|
845
|
+
if (traffic.loop && this.trackSystem.totalTrackLength > 0) {
|
|
846
|
+
while (traffic.distance <
|
|
847
|
+
this.distanceState - this.trafficBehindVisibilityDistance) {
|
|
848
|
+
traffic.distance += this.trackSystem.totalTrackLength;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
this.resolvePlayerTrafficCollisions();
|
|
853
|
+
this.playerSteeringInputState = 0;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Moves the player left using the configured steering rate.
|
|
857
|
+
*/
|
|
858
|
+
steerLeft(dt, strength = 1) {
|
|
859
|
+
void dt;
|
|
860
|
+
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 1;
|
|
861
|
+
this.playerSteeringInputState = Math.max(-1, Math.min(1, this.playerSteeringInputState - safeStrength));
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Moves the player right using the configured steering rate.
|
|
865
|
+
*/
|
|
866
|
+
steerRight(dt, strength = 1) {
|
|
867
|
+
void dt;
|
|
868
|
+
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 1;
|
|
869
|
+
this.playerSteeringInputState = Math.max(-1, Math.min(1, this.playerSteeringInputState + safeStrength));
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Smoothly accelerates the player speed toward a target value.
|
|
873
|
+
*/
|
|
874
|
+
accelerateTo(targetSpeed, dt, rate = 2.8) {
|
|
875
|
+
const cappedTarget = Math.min(Math.max(0, targetSpeed), this.mphToSpeed(this.performanceState.topSpeedMph));
|
|
876
|
+
const accelerationScale = this.getOffRoadAccelerationScale();
|
|
877
|
+
if (arguments.length >= 3) {
|
|
878
|
+
this.speedState = this.approach(this.speedState, cappedTarget, rate * accelerationScale, dt);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const accelerationPerSecond = this.mphToSpeed(60) / this.performanceState.zeroToSixtySeconds;
|
|
882
|
+
this.speedState = this.approachLinear(this.speedState, cappedTarget, accelerationPerSecond * accelerationScale, dt);
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Smoothly brakes the player speed toward a target value.
|
|
886
|
+
*/
|
|
887
|
+
brakeTo(targetSpeed, dt, rate = 4) {
|
|
888
|
+
const cappedTarget = Math.max(0, targetSpeed);
|
|
889
|
+
if (arguments.length >= 3) {
|
|
890
|
+
this.speedState = this.approach(this.speedState, cappedTarget, rate, dt);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const brakingPerSecond = this.mphToSpeed(60) / this.performanceState.sixtyToZeroSeconds;
|
|
894
|
+
this.speedState = this.approachLinear(this.speedState, cappedTarget, brakingPerSecond, dt);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Smoothly accelerates the player toward a target speed expressed in mph.
|
|
898
|
+
*/
|
|
899
|
+
accelerateToMph(targetMph, dt, rate) {
|
|
900
|
+
if (Number.isFinite(rate)) {
|
|
901
|
+
this.accelerateTo(this.mphToSpeed(targetMph), dt, rate);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
this.accelerateTo(this.mphToSpeed(targetMph), dt);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Smoothly brakes the player toward a target speed expressed in mph.
|
|
908
|
+
*/
|
|
909
|
+
brakeToMph(targetMph, dt, rate) {
|
|
910
|
+
if (Number.isFinite(rate)) {
|
|
911
|
+
this.brakeTo(this.mphToSpeed(targetMph), dt, rate);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
this.brakeTo(this.mphToSpeed(targetMph), dt);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Registers a collision callback without using the builder API.
|
|
918
|
+
*/
|
|
919
|
+
onPlayerTrafficHit(handler) {
|
|
920
|
+
this.collisionHandlers.push(handler);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Removes all registered player-traffic collision handlers.
|
|
924
|
+
*/
|
|
925
|
+
clearPlayerTrafficHitHandlers() {
|
|
926
|
+
this.collisionHandlers.length = 0;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Adds a billboard directly and returns its generated id.
|
|
930
|
+
*/
|
|
931
|
+
addBillboard(visual, config) {
|
|
932
|
+
const ids = [];
|
|
933
|
+
const repeat = Math.max(1, Math.round(config.repeat ?? 1));
|
|
934
|
+
const spacing = Math.max(1, config.spacing ?? 240);
|
|
935
|
+
for (let i = 0; i < repeat; i++) {
|
|
936
|
+
const id = this.makeId("bb");
|
|
937
|
+
ids.push(id);
|
|
938
|
+
this.billboardsState.push({
|
|
939
|
+
id,
|
|
940
|
+
visual,
|
|
941
|
+
distance: Math.max(0, config.distance + i * spacing),
|
|
942
|
+
lane: Number.isFinite(config.lane)
|
|
943
|
+
? this.laneSystem.clampRoadLane(config.lane)
|
|
944
|
+
: null,
|
|
945
|
+
side: config.side ?? "right",
|
|
946
|
+
offset: Number.isFinite(config.offset) ? Math.max(0, config.offset) : 48,
|
|
947
|
+
anchorX: this.clamp01(config.anchorX ?? 0.5),
|
|
948
|
+
anchorY: this.clamp01(config.anchorY ?? 1),
|
|
949
|
+
baseScale: Number.isFinite(config.baseScale)
|
|
950
|
+
? Math.max(0, config.baseScale)
|
|
951
|
+
: 1,
|
|
952
|
+
alpha: this.clamp01(config.alpha ?? 1),
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return ids[0];
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Adds a traffic vehicle directly and returns its generated id.
|
|
959
|
+
*/
|
|
960
|
+
addTraffic(visual, config) {
|
|
961
|
+
const id = this.makeId("car");
|
|
962
|
+
const baseScale = Number.isFinite(config.baseScale)
|
|
963
|
+
? Math.max(0, config.baseScale)
|
|
964
|
+
: 1;
|
|
965
|
+
const autoBody = this.resolveAutoBodyFromVisual(visual, baseScale);
|
|
966
|
+
const bodyWidth = Number.isFinite(config.bodyWidth)
|
|
967
|
+
? Math.max(1, config.bodyWidth)
|
|
968
|
+
: autoBody?.width ?? 36;
|
|
969
|
+
const bodyLength = Number.isFinite(config.bodyLength)
|
|
970
|
+
? Math.max(1, config.bodyLength)
|
|
971
|
+
: autoBody?.length ?? 48;
|
|
972
|
+
const bodyOffsetX = Number.isFinite(config.bodyOffsetX)
|
|
973
|
+
? config.bodyOffsetX
|
|
974
|
+
: 0;
|
|
975
|
+
const bodyOffsetY = Number.isFinite(config.bodyOffsetY)
|
|
976
|
+
? config.bodyOffsetY
|
|
977
|
+
: 0;
|
|
978
|
+
this.trafficState.push({
|
|
979
|
+
id,
|
|
980
|
+
visual,
|
|
981
|
+
distance: Number.isFinite(config.distance) ? config.distance : 0,
|
|
982
|
+
lane: this.laneSystem.clampRoadLane(config.lane),
|
|
983
|
+
speed: Number.isFinite(config.speed) ? config.speed : 140,
|
|
984
|
+
baseScale,
|
|
985
|
+
bodyWidth,
|
|
986
|
+
bodyLength,
|
|
987
|
+
bodyOffsetX,
|
|
988
|
+
bodyOffsetY,
|
|
989
|
+
resolvedBody: this.resolveCollisionBodyMetrics(visual, baseScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY),
|
|
990
|
+
loop: config.loop ?? true,
|
|
991
|
+
alpha: this.clamp01(config.alpha ?? 1),
|
|
992
|
+
});
|
|
993
|
+
return id;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Removes a traffic vehicle by id.
|
|
997
|
+
*
|
|
998
|
+
* Returns `true` when a vehicle was found and removed.
|
|
999
|
+
*/
|
|
1000
|
+
removeTrafficById(id) {
|
|
1001
|
+
const index = this.trafficState.findIndex((entry) => entry.id === id);
|
|
1002
|
+
if (index < 0)
|
|
1003
|
+
return false;
|
|
1004
|
+
this.trafficState.splice(index, 1);
|
|
1005
|
+
this.activeCollisionIds.delete(id);
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Updates an existing traffic vehicle by id.
|
|
1010
|
+
*
|
|
1011
|
+
* This is intended for higher-level systems such as race managers that need
|
|
1012
|
+
* to drive opponent state explicitly over time.
|
|
1013
|
+
*/
|
|
1014
|
+
updateTrafficById(id, config) {
|
|
1015
|
+
const entry = this.trafficState.find((item) => item.id === id);
|
|
1016
|
+
if (!entry)
|
|
1017
|
+
return false;
|
|
1018
|
+
if (Number.isFinite(config.distance)) {
|
|
1019
|
+
entry.distance = config.distance;
|
|
1020
|
+
}
|
|
1021
|
+
if (Number.isFinite(config.lane)) {
|
|
1022
|
+
entry.lane = this.laneSystem.clampRoadLane(config.lane);
|
|
1023
|
+
}
|
|
1024
|
+
if (Number.isFinite(config.speed)) {
|
|
1025
|
+
entry.speed = config.speed;
|
|
1026
|
+
}
|
|
1027
|
+
if (Number.isFinite(config.baseScale)) {
|
|
1028
|
+
entry.baseScale = Math.max(0, config.baseScale);
|
|
1029
|
+
}
|
|
1030
|
+
if (Number.isFinite(config.bodyWidth)) {
|
|
1031
|
+
entry.bodyWidth = Math.max(1, config.bodyWidth);
|
|
1032
|
+
}
|
|
1033
|
+
if (Number.isFinite(config.bodyLength)) {
|
|
1034
|
+
entry.bodyLength = Math.max(1, config.bodyLength);
|
|
1035
|
+
}
|
|
1036
|
+
if (Number.isFinite(config.bodyOffsetX)) {
|
|
1037
|
+
entry.bodyOffsetX = config.bodyOffsetX;
|
|
1038
|
+
}
|
|
1039
|
+
if (Number.isFinite(config.bodyOffsetY)) {
|
|
1040
|
+
entry.bodyOffsetY = config.bodyOffsetY;
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof config.loop === "boolean") {
|
|
1043
|
+
entry.loop = config.loop;
|
|
1044
|
+
}
|
|
1045
|
+
if (Number.isFinite(config.alpha)) {
|
|
1046
|
+
entry.alpha = this.clamp01(config.alpha);
|
|
1047
|
+
}
|
|
1048
|
+
entry.resolvedBody = this.resolveCollisionBodyMetrics(entry.visual, entry.baseScale, entry.bodyWidth, entry.bodyLength, entry.bodyOffsetX, entry.bodyOffsetY);
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Clears all configured traffic vehicles.
|
|
1053
|
+
*/
|
|
1054
|
+
clearTraffic() {
|
|
1055
|
+
this.trafficState.length = 0;
|
|
1056
|
+
this.activeCollisionIds.clear();
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Clears all configured billboards.
|
|
1060
|
+
*/
|
|
1061
|
+
clearBillboards() {
|
|
1062
|
+
this.billboardsState.length = 0;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Adds a wrapped track-distance range that swaps the active road drawer.
|
|
1066
|
+
*
|
|
1067
|
+
* When multiple ranges overlap, the most recently added range wins.
|
|
1068
|
+
*/
|
|
1069
|
+
addRoadDrawerRange(range) {
|
|
1070
|
+
this.roadDrawerRangesState.push(range);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Clears all per-range road drawer overrides.
|
|
1074
|
+
*/
|
|
1075
|
+
clearRoadDrawerRanges() {
|
|
1076
|
+
this.roadDrawerRangesState.length = 0;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Adds a heading-based horizon marker directly and returns its generated id.
|
|
1080
|
+
*/
|
|
1081
|
+
addHorizonMarker(visual, config) {
|
|
1082
|
+
const id = this.makeId("hz");
|
|
1083
|
+
this.horizonMarkersState.push({
|
|
1084
|
+
id,
|
|
1085
|
+
visual,
|
|
1086
|
+
angle: this.normalizeAngle(config.angle),
|
|
1087
|
+
baseScale: Number.isFinite(config.baseScale)
|
|
1088
|
+
? Math.max(0, config.baseScale)
|
|
1089
|
+
: 1,
|
|
1090
|
+
alpha: this.clamp01(config.alpha ?? 1),
|
|
1091
|
+
yOffset: Number.isFinite(config.yOffset)
|
|
1092
|
+
? config.yOffset
|
|
1093
|
+
: 0,
|
|
1094
|
+
influenceAngle: Number.isFinite(config.influenceAngle)
|
|
1095
|
+
? Math.max(1, Math.abs(config.influenceAngle))
|
|
1096
|
+
: null,
|
|
1097
|
+
parallaxFactor: Number.isFinite(config.parallaxFactor)
|
|
1098
|
+
? Math.max(0, Math.abs(config.parallaxFactor))
|
|
1099
|
+
: null,
|
|
1100
|
+
});
|
|
1101
|
+
return id;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Clears all configured horizon markers.
|
|
1105
|
+
*/
|
|
1106
|
+
clearHorizonMarkers() {
|
|
1107
|
+
this.horizonMarkersState.length = 0;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Adds a parallax background image layer and returns its generated id.
|
|
1111
|
+
*/
|
|
1112
|
+
addBackgroundLayer(visual, config = {}) {
|
|
1113
|
+
const id = this.makeId("bg");
|
|
1114
|
+
this.backgroundLayersState.push({
|
|
1115
|
+
id,
|
|
1116
|
+
visual,
|
|
1117
|
+
y: Number.isFinite(config.y) ? config.y : 0,
|
|
1118
|
+
xOffset: Number.isFinite(config.xOffset) ? config.xOffset : 0,
|
|
1119
|
+
baseScale: Number.isFinite(config.baseScale)
|
|
1120
|
+
? Math.max(0, config.baseScale)
|
|
1121
|
+
: 1,
|
|
1122
|
+
alpha: this.clamp01(config.alpha ?? 1),
|
|
1123
|
+
repeat: config.repeat ?? true,
|
|
1124
|
+
spacing: Number.isFinite(config.spacing) ? Math.max(0, config.spacing) : 0,
|
|
1125
|
+
parallaxDistance: Number.isFinite(config.parallaxDistance)
|
|
1126
|
+
? config.parallaxDistance
|
|
1127
|
+
: 0,
|
|
1128
|
+
parallaxHeading: Number.isFinite(config.parallaxHeading)
|
|
1129
|
+
? Math.max(0, Math.abs(config.parallaxHeading))
|
|
1130
|
+
: 0,
|
|
1131
|
+
});
|
|
1132
|
+
return id;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Clears all configured background image layers.
|
|
1136
|
+
*/
|
|
1137
|
+
clearBackgroundLayers() {
|
|
1138
|
+
this.backgroundLayersState.length = 0;
|
|
1139
|
+
}
|
|
1140
|
+
/** @internal */
|
|
1141
|
+
render(ctx, width, height) {
|
|
1142
|
+
this.renderSystem.renderFrame(ctx, width, height);
|
|
1143
|
+
}
|
|
1144
|
+
/** @internal */
|
|
1145
|
+
getDebugCollisionRectFromVisual(visualRect, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
|
|
1146
|
+
return this.collisionSystem.getDebugCollisionRectFromVisual(visualRect, visual, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
|
|
1147
|
+
}
|
|
1148
|
+
/** @internal */
|
|
1149
|
+
getRealCollisionRect(current, width, height, ahead, lane, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY, resolvedBody, anchorCenterX, anchorBottomY) {
|
|
1150
|
+
return this.collisionSystem.getRealCollisionRect(current, width, height, ahead, lane, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY, resolvedBody, anchorCenterX, anchorBottomY);
|
|
1151
|
+
}
|
|
1152
|
+
/** @internal */
|
|
1153
|
+
resolveAutoBodyFromVisual(visual, scale) {
|
|
1154
|
+
return this.collisionSystem.resolveAutoBodyFromVisual(visual, scale);
|
|
1155
|
+
}
|
|
1156
|
+
/** @internal */
|
|
1157
|
+
resolveCollisionBodyMetrics(visual, scale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
|
|
1158
|
+
return this.collisionSystem.resolveCollisionBodyMetrics(visual, scale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
|
|
1159
|
+
}
|
|
1160
|
+
/** @internal */
|
|
1161
|
+
getBodyDistanceCenterFromAnchor(anchorDistance, bodyLength, bodyDistanceOffset) {
|
|
1162
|
+
return this.collisionSystem.getBodyDistanceCenterFromAnchor(anchorDistance, bodyLength, bodyDistanceOffset);
|
|
1163
|
+
}
|
|
1164
|
+
/** @internal */
|
|
1165
|
+
refreshAutoPlayerBodyFromVisual() {
|
|
1166
|
+
const autoBody = this.resolveAutoBodyFromVisual(this.playerVisualState, this.playerBaseScaleState);
|
|
1167
|
+
if (!autoBody)
|
|
1168
|
+
return;
|
|
1169
|
+
if (!this.playerBodyWidthManual) {
|
|
1170
|
+
this.playerBodyWidthState = autoBody.width;
|
|
1171
|
+
}
|
|
1172
|
+
if (!this.playerBodyLengthManual) {
|
|
1173
|
+
this.playerBodyLengthState = autoBody.length;
|
|
1174
|
+
}
|
|
1175
|
+
this.playerResolvedBodyState = this.resolveCollisionBodyMetrics(this.playerVisualState, this.playerBaseScaleState, this.playerBodyWidthState, this.playerBodyLengthState, this.playerBodyOffsetXState, this.playerBodyOffsetYState);
|
|
1176
|
+
}
|
|
1177
|
+
/** @internal */
|
|
1178
|
+
getPlayerScreenX() {
|
|
1179
|
+
return (this.width / 2 +
|
|
1180
|
+
this.playerLaneState * this.collisionSystem.getPlayerScreenLaneUnit());
|
|
1181
|
+
}
|
|
1182
|
+
/** @internal */
|
|
1183
|
+
resolveVisualSurface(visual) {
|
|
1184
|
+
if (visual.type === "image") {
|
|
1185
|
+
const image = this.game.getImage(visual.key);
|
|
1186
|
+
if (!image) {
|
|
1187
|
+
throw new Error(`MinimoJS Arcade Racer: Image '${visual.key}' is not loaded.`);
|
|
1188
|
+
}
|
|
1189
|
+
const naturalWidth = Math.max(1, image instanceof HTMLImageElement ? image.naturalWidth || image.width : image.width);
|
|
1190
|
+
const naturalHeight = Math.max(1, image instanceof HTMLImageElement ? image.naturalHeight || image.height : image.height);
|
|
1191
|
+
if (visual.width && visual.height) {
|
|
1192
|
+
return {
|
|
1193
|
+
source: image,
|
|
1194
|
+
width: Math.max(1, visual.width),
|
|
1195
|
+
height: Math.max(1, visual.height),
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
if (visual.width) {
|
|
1199
|
+
return {
|
|
1200
|
+
source: image,
|
|
1201
|
+
width: Math.max(1, visual.width),
|
|
1202
|
+
height: Math.max(1, (visual.width / naturalWidth) * naturalHeight),
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
if (visual.height) {
|
|
1206
|
+
return {
|
|
1207
|
+
source: image,
|
|
1208
|
+
width: Math.max(1, (visual.height / naturalHeight) * naturalWidth),
|
|
1209
|
+
height: Math.max(1, visual.height),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
source: image,
|
|
1214
|
+
width: naturalWidth,
|
|
1215
|
+
height: naturalHeight,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const size = Math.max(1, Math.round(visual.size ?? 64));
|
|
1219
|
+
const color = visual.color ?? "#ffffff";
|
|
1220
|
+
const cacheKey = [visual.value, size, color].join("|");
|
|
1221
|
+
let surface = this.emojiSurfaceCache.get(cacheKey);
|
|
1222
|
+
if (!surface) {
|
|
1223
|
+
surface = document.createElement("canvas");
|
|
1224
|
+
const boxSize = Math.max(2, Math.ceil(size * 2));
|
|
1225
|
+
surface.width = boxSize;
|
|
1226
|
+
surface.height = boxSize;
|
|
1227
|
+
const glyphCtx = surface.getContext("2d");
|
|
1228
|
+
if (!glyphCtx) {
|
|
1229
|
+
throw new Error("MinimoJS Arcade Racer: Could not acquire an emoji rendering context.");
|
|
1230
|
+
}
|
|
1231
|
+
glyphCtx.clearRect(0, 0, boxSize, boxSize);
|
|
1232
|
+
glyphCtx.shadowColor = "transparent";
|
|
1233
|
+
glyphCtx.shadowBlur = 0;
|
|
1234
|
+
glyphCtx.shadowOffsetX = 0;
|
|
1235
|
+
glyphCtx.shadowOffsetY = 0;
|
|
1236
|
+
glyphCtx.fillStyle = color;
|
|
1237
|
+
glyphCtx.font = `${size}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
|
1238
|
+
glyphCtx.textAlign = "center";
|
|
1239
|
+
glyphCtx.textBaseline = "middle";
|
|
1240
|
+
glyphCtx.fillText(visual.value, boxSize / 2, boxSize / 2);
|
|
1241
|
+
this.emojiSurfaceCache.set(cacheKey, surface);
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
source: surface,
|
|
1245
|
+
width: size,
|
|
1246
|
+
height: size,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
/** @internal */
|
|
1250
|
+
projectObject(current, ahead, billboard, width, height) {
|
|
1251
|
+
const projection = this.projectRoadPoint(current, ahead, width, height);
|
|
1252
|
+
const roadsideX = billboard.lane !== null
|
|
1253
|
+
? projection.x + billboard.lane * projection.roadWidth * 0.38
|
|
1254
|
+
: projection.x +
|
|
1255
|
+
(billboard.side === "left" ? -1 : 1) *
|
|
1256
|
+
(projection.roadWidth * 0.56 + billboard.offset * projection.scale);
|
|
1257
|
+
if (roadsideX < -200 || roadsideX > width + 200) {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
return {
|
|
1261
|
+
...projection,
|
|
1262
|
+
x: roadsideX,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/** @internal */
|
|
1266
|
+
projectTrafficPoint(current, ahead, width, height) {
|
|
1267
|
+
if (ahead >= 0) {
|
|
1268
|
+
return this.projectRoadPoint(current, Math.max(1, ahead), width, height);
|
|
1269
|
+
}
|
|
1270
|
+
const near = this.projectRoadPoint(current, 1, width, height);
|
|
1271
|
+
const behindT = Math.min(1, Math.max(0, -ahead / this.trafficBehindVisibilityDistance));
|
|
1272
|
+
return {
|
|
1273
|
+
x: near.x,
|
|
1274
|
+
y: near.y + behindT * Math.max(90, height * 0.32),
|
|
1275
|
+
roadWidth: near.roadWidth,
|
|
1276
|
+
scale: near.scale * (1 + behindT * 0.18),
|
|
1277
|
+
t: near.t,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
/** @internal */
|
|
1281
|
+
projectRoadPoint(current, ahead, width, height) {
|
|
1282
|
+
if (this.trackSystem.hasPathPrimitives) {
|
|
1283
|
+
const distanceAhead = Math.max(0, Math.min(ahead, this.drawDistance));
|
|
1284
|
+
const t = 1 - distanceAhead / this.drawDistance;
|
|
1285
|
+
const p = t * t;
|
|
1286
|
+
const sample = this.trackSystem.sampleTrack(this.distanceState + distanceAhead);
|
|
1287
|
+
const dx = sample.worldX - current.worldX;
|
|
1288
|
+
const dy = sample.worldY - current.worldY;
|
|
1289
|
+
const rightX = -Math.sin(current.heading);
|
|
1290
|
+
const rightY = Math.cos(current.heading);
|
|
1291
|
+
const lateral = dx * rightX + dy * rightY;
|
|
1292
|
+
const relElevation = sample.elevation - current.elevation;
|
|
1293
|
+
const roadWidth = this.lerp(this.roadFarWidth, this.roadNearWidth, p);
|
|
1294
|
+
const scale = roadWidth / this.roadNearWidth;
|
|
1295
|
+
const hillPerspective = 0.32 + p * 0.68;
|
|
1296
|
+
return {
|
|
1297
|
+
x: width / 2 + lateral * this.curveScale,
|
|
1298
|
+
y: this.horizonY +
|
|
1299
|
+
p * (height - this.horizonY) -
|
|
1300
|
+
relElevation * this.hillScale * hillPerspective,
|
|
1301
|
+
roadWidth,
|
|
1302
|
+
scale,
|
|
1303
|
+
t: p,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
const distanceAhead = Math.max(0, Math.min(ahead, this.drawDistance));
|
|
1307
|
+
const t = 1 - distanceAhead / this.drawDistance;
|
|
1308
|
+
const p = t * t;
|
|
1309
|
+
const sample = this.trackSystem.sampleTrack(this.distanceState + distanceAhead);
|
|
1310
|
+
const relOffset = sample.lateralOffset - current.lateralOffset;
|
|
1311
|
+
const relElevation = sample.elevation - current.elevation;
|
|
1312
|
+
const roadWidth = this.lerp(this.roadFarWidth, this.roadNearWidth, p);
|
|
1313
|
+
const scale = roadWidth / this.roadNearWidth;
|
|
1314
|
+
const x = width / 2 + relOffset * this.curveScale;
|
|
1315
|
+
const hillPerspective = 0.32 + p * 0.68;
|
|
1316
|
+
const y = this.horizonY +
|
|
1317
|
+
p * (height - this.horizonY) -
|
|
1318
|
+
relElevation * this.hillScale * hillPerspective;
|
|
1319
|
+
return { x, y, roadWidth, scale, t: p };
|
|
1320
|
+
}
|
|
1321
|
+
/** @internal */
|
|
1322
|
+
sampleMinimapPoint(points, distance) {
|
|
1323
|
+
if (points.length === 0 || this.trackSystem.totalTrackLength <= 0) {
|
|
1324
|
+
return { x: 0, y: 0, distance: 0 };
|
|
1325
|
+
}
|
|
1326
|
+
const wrapped = this.trackSystem.wrapDistance(distance);
|
|
1327
|
+
const sampleT = (wrapped / this.trackSystem.totalTrackLength) * (points.length - 1);
|
|
1328
|
+
const index = Math.max(0, Math.min(points.length - 1, Math.floor(sampleT)));
|
|
1329
|
+
const nextIndex = Math.min(points.length - 1, index + 1);
|
|
1330
|
+
const t = sampleT - index;
|
|
1331
|
+
const from = points[index];
|
|
1332
|
+
const to = points[nextIndex];
|
|
1333
|
+
return {
|
|
1334
|
+
x: this.lerp(from.x, to.x, t),
|
|
1335
|
+
y: this.lerp(from.y, to.y, t),
|
|
1336
|
+
distance: wrapped,
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
/** @internal */
|
|
1340
|
+
resolveRoadDrawer(trackDistance) {
|
|
1341
|
+
for (let i = this.roadDrawerRangesState.length - 1; i >= 0; i--) {
|
|
1342
|
+
const range = this.roadDrawerRangesState[i];
|
|
1343
|
+
if (this.matchesRoadDrawerRange(trackDistance, range)) {
|
|
1344
|
+
return range.drawer;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return this.roadDrawerState;
|
|
1348
|
+
}
|
|
1349
|
+
/** @internal */
|
|
1350
|
+
matchesRoadDrawerRange(trackDistance, range) {
|
|
1351
|
+
const total = this.trackSystem.totalTrackLength;
|
|
1352
|
+
const wrappedTrackDistance = total > 0
|
|
1353
|
+
? this.trackSystem.wrapDistance(trackDistance)
|
|
1354
|
+
: trackDistance;
|
|
1355
|
+
const from = total > 0 ? this.trackSystem.wrapDistance(range.from) : range.from;
|
|
1356
|
+
const to = total > 0 ? this.trackSystem.wrapDistance(range.to) : range.to;
|
|
1357
|
+
if (from === to) {
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
if (from < to) {
|
|
1361
|
+
return wrappedTrackDistance >= from && wrappedTrackDistance < to;
|
|
1362
|
+
}
|
|
1363
|
+
return wrappedTrackDistance >= from || wrappedTrackDistance < to;
|
|
1364
|
+
}
|
|
1365
|
+
/** @internal */
|
|
1366
|
+
resolvePlayerTrafficCollisions() {
|
|
1367
|
+
this.collisionSystem.resolvePlayerTrafficCollisions();
|
|
1368
|
+
}
|
|
1369
|
+
/** @internal */
|
|
1370
|
+
makeId(prefix) {
|
|
1371
|
+
return `${prefix}-${this.nextId++}`;
|
|
1372
|
+
}
|
|
1373
|
+
/** @internal */
|
|
1374
|
+
clampPlayerLane(value) {
|
|
1375
|
+
if (!Number.isFinite(value))
|
|
1376
|
+
return 0;
|
|
1377
|
+
return Math.max(-this.playerLaneLimit, Math.min(this.playerLaneLimit, value));
|
|
1378
|
+
}
|
|
1379
|
+
/** @internal */
|
|
1380
|
+
clamp01(value) {
|
|
1381
|
+
if (!Number.isFinite(value))
|
|
1382
|
+
return 0;
|
|
1383
|
+
return Math.max(0, Math.min(1, value));
|
|
1384
|
+
}
|
|
1385
|
+
/** @internal */
|
|
1386
|
+
getOffRoadAmount() {
|
|
1387
|
+
const absLane = Math.abs(this.playerLaneState);
|
|
1388
|
+
if (absLane <= this.offRoadThreshold) {
|
|
1389
|
+
return 0;
|
|
1390
|
+
}
|
|
1391
|
+
const maxOffRoadSpan = Math.max(0.0001, this.playerLaneLimit - this.offRoadThreshold);
|
|
1392
|
+
const normalized = (absLane - this.offRoadThreshold) / maxOffRoadSpan;
|
|
1393
|
+
return this.clamp01(normalized);
|
|
1394
|
+
}
|
|
1395
|
+
/** @internal */
|
|
1396
|
+
getOffRoadAccelerationScale() {
|
|
1397
|
+
if (!this.isOffRoad) {
|
|
1398
|
+
return 1;
|
|
1399
|
+
}
|
|
1400
|
+
return this.lerp(1, this.offRoadAccelerationScale, this.getOffRoadAmount());
|
|
1401
|
+
}
|
|
1402
|
+
/** @internal */
|
|
1403
|
+
getOffRoadLateralGripScale() {
|
|
1404
|
+
if (!this.isOffRoad) {
|
|
1405
|
+
return 1;
|
|
1406
|
+
}
|
|
1407
|
+
return this.lerp(1, 0.58, this.getOffRoadAmount());
|
|
1408
|
+
}
|
|
1409
|
+
/** @internal */
|
|
1410
|
+
getSteeringScale() {
|
|
1411
|
+
const speedRatio = this.clamp01(this.speedToMph(Math.abs(this.speedState)) / this.performanceState.topSpeedMph);
|
|
1412
|
+
const eased = Math.pow(speedRatio, this.performanceState.steeringSpeedCurvePower);
|
|
1413
|
+
return this.lerp(this.performanceState.steeringLowSpeedScale, this.performanceState.steeringHighSpeedScale, eased);
|
|
1414
|
+
}
|
|
1415
|
+
/** @internal */
|
|
1416
|
+
updatePlayerLateralDynamics(current, forwardSpeed, dt) {
|
|
1417
|
+
if (dt <= 0) {
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const forwardMph = this.speedToMph(forwardSpeed);
|
|
1421
|
+
const topSpeedMph = Math.max(1, this.performanceState.topSpeedMph);
|
|
1422
|
+
const speedRatio = this.clamp01(forwardMph / topSpeedMph);
|
|
1423
|
+
const steeringAuthority = this.getSteeringScale();
|
|
1424
|
+
const offRoadGripScale = this.getOffRoadLateralGripScale();
|
|
1425
|
+
const lowSpeedLateralScale = 0.06 + Math.pow(speedRatio, 0.72) * 0.94;
|
|
1426
|
+
const steeringAcceleration = this.playerSteeringInputState *
|
|
1427
|
+
this.steeringRate *
|
|
1428
|
+
steeringAuthority *
|
|
1429
|
+
offRoadGripScale *
|
|
1430
|
+
lowSpeedLateralScale *
|
|
1431
|
+
7.5;
|
|
1432
|
+
const curvePush = current.curve *
|
|
1433
|
+
forwardSpeed *
|
|
1434
|
+
this.playerRoadInfluence *
|
|
1435
|
+
(0.28 + speedRatio * 1.42) *
|
|
1436
|
+
(1 + Math.abs(current.curve) * 900) *
|
|
1437
|
+
(1.05 + (1 - offRoadGripScale) * 0.9);
|
|
1438
|
+
this.playerLateralVelocityState +=
|
|
1439
|
+
(steeringAcceleration - curvePush) * dt;
|
|
1440
|
+
const maxLateralVelocity = (0.18 + speedRatio * 1.65) * offRoadGripScale;
|
|
1441
|
+
this.playerLateralVelocityState = Math.max(-maxLateralVelocity, Math.min(maxLateralVelocity, this.playerLateralVelocityState));
|
|
1442
|
+
const damping = this.performanceState.lateralDamping *
|
|
1443
|
+
(0.85 + (1 - offRoadGripScale) * 0.9);
|
|
1444
|
+
this.playerLateralVelocityState = this.approach(this.playerLateralVelocityState, 0, damping, dt);
|
|
1445
|
+
const nextLane = this.clampPlayerLane(this.playerLaneState + this.playerLateralVelocityState * dt);
|
|
1446
|
+
if (nextLane === -this.playerLaneLimit || nextLane === this.playerLaneLimit) {
|
|
1447
|
+
this.playerLateralVelocityState = 0;
|
|
1448
|
+
}
|
|
1449
|
+
this.playerLaneState = nextLane;
|
|
1450
|
+
}
|
|
1451
|
+
/** @internal */
|
|
1452
|
+
mphToSpeed(mph) {
|
|
1453
|
+
if (!Number.isFinite(mph))
|
|
1454
|
+
return 0;
|
|
1455
|
+
return Math.max(0, mph / MPH_PER_SPEED_UNIT);
|
|
1456
|
+
}
|
|
1457
|
+
/** @internal */
|
|
1458
|
+
speedToMph(speed) {
|
|
1459
|
+
if (!Number.isFinite(speed))
|
|
1460
|
+
return 0;
|
|
1461
|
+
return Math.max(0, speed * MPH_PER_SPEED_UNIT);
|
|
1462
|
+
}
|
|
1463
|
+
/** @internal */
|
|
1464
|
+
approach(current, target, rate, dt) {
|
|
1465
|
+
const safeRate = Number.isFinite(rate) ? Math.max(0, rate) : 0;
|
|
1466
|
+
const safeDt = Number.isFinite(dt) ? Math.max(0, dt) : 0;
|
|
1467
|
+
return current + (target - current) * Math.min(1, safeRate * safeDt);
|
|
1468
|
+
}
|
|
1469
|
+
/** @internal */
|
|
1470
|
+
approachLinear(current, target, unitsPerSecond, dt) {
|
|
1471
|
+
const safeUnits = Number.isFinite(unitsPerSecond)
|
|
1472
|
+
? Math.max(0, unitsPerSecond)
|
|
1473
|
+
: 0;
|
|
1474
|
+
const safeDt = Number.isFinite(dt) ? Math.max(0, dt) : 0;
|
|
1475
|
+
const step = safeUnits * safeDt;
|
|
1476
|
+
if (current < target)
|
|
1477
|
+
return Math.min(target, current + step);
|
|
1478
|
+
if (current > target)
|
|
1479
|
+
return Math.max(target, current - step);
|
|
1480
|
+
return current;
|
|
1481
|
+
}
|
|
1482
|
+
/** @internal */
|
|
1483
|
+
sanitizePerformanceOptions(options) {
|
|
1484
|
+
return {
|
|
1485
|
+
zeroToSixtySeconds: Number.isFinite(options.zeroToSixtySeconds)
|
|
1486
|
+
? Math.max(0.1, options.zeroToSixtySeconds)
|
|
1487
|
+
: DEFAULT_PERFORMANCE.zeroToSixtySeconds,
|
|
1488
|
+
sixtyToZeroSeconds: Number.isFinite(options.sixtyToZeroSeconds)
|
|
1489
|
+
? Math.max(0.1, options.sixtyToZeroSeconds)
|
|
1490
|
+
: DEFAULT_PERFORMANCE.sixtyToZeroSeconds,
|
|
1491
|
+
topSpeedMph: Number.isFinite(options.topSpeedMph)
|
|
1492
|
+
? Math.max(1, options.topSpeedMph)
|
|
1493
|
+
: DEFAULT_PERFORMANCE.topSpeedMph,
|
|
1494
|
+
steeringLowSpeedScale: Number.isFinite(options.steeringLowSpeedScale)
|
|
1495
|
+
? Math.max(0, options.steeringLowSpeedScale)
|
|
1496
|
+
: DEFAULT_PERFORMANCE.steeringLowSpeedScale,
|
|
1497
|
+
steeringHighSpeedScale: Number.isFinite(options.steeringHighSpeedScale)
|
|
1498
|
+
? Math.max(0, options.steeringHighSpeedScale)
|
|
1499
|
+
: DEFAULT_PERFORMANCE.steeringHighSpeedScale,
|
|
1500
|
+
steeringSpeedCurvePower: Number.isFinite(options.steeringSpeedCurvePower)
|
|
1501
|
+
? Math.max(0.1, options.steeringSpeedCurvePower)
|
|
1502
|
+
: DEFAULT_PERFORMANCE.steeringSpeedCurvePower,
|
|
1503
|
+
lateralDamping: Number.isFinite(options.lateralDamping)
|
|
1504
|
+
? Math.max(0, options.lateralDamping)
|
|
1505
|
+
: DEFAULT_PERFORMANCE.lateralDamping,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
/** @internal */
|
|
1509
|
+
sanitizeHorizonOptions(options) {
|
|
1510
|
+
return {
|
|
1511
|
+
influenceAngle: Number.isFinite(options.influenceAngle)
|
|
1512
|
+
? Math.max(1, Math.abs(options.influenceAngle))
|
|
1513
|
+
: DEFAULT_HORIZON.influenceAngle,
|
|
1514
|
+
parallaxFactor: Number.isFinite(options.parallaxFactor)
|
|
1515
|
+
? Math.max(0, Math.abs(options.parallaxFactor))
|
|
1516
|
+
: DEFAULT_HORIZON.parallaxFactor,
|
|
1517
|
+
baseYOffset: Number.isFinite(options.baseYOffset)
|
|
1518
|
+
? options.baseYOffset
|
|
1519
|
+
: DEFAULT_HORIZON.baseYOffset,
|
|
1520
|
+
maxVisibleMarkers: Number.isFinite(options.maxVisibleMarkers)
|
|
1521
|
+
? Math.max(1, Math.round(options.maxVisibleMarkers))
|
|
1522
|
+
: DEFAULT_HORIZON.maxVisibleMarkers,
|
|
1523
|
+
headingResponse: Number.isFinite(options.headingResponse)
|
|
1524
|
+
? Math.max(0, Math.abs(options.headingResponse))
|
|
1525
|
+
: DEFAULT_HORIZON.headingResponse,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
/** @internal */
|
|
1529
|
+
sanitizeMinimapOptions(options) {
|
|
1530
|
+
return {
|
|
1531
|
+
visible: options.visible !== false,
|
|
1532
|
+
x: Number.isFinite(options.x) ? options.x : this.width - 164,
|
|
1533
|
+
y: Number.isFinite(options.y) ? options.y : 16,
|
|
1534
|
+
width: Number.isFinite(options.width) ? Math.max(48, options.width) : 148,
|
|
1535
|
+
height: Number.isFinite(options.height) ? Math.max(48, options.height) : 148,
|
|
1536
|
+
padding: Number.isFinite(options.padding) ? Math.max(0, options.padding) : 10,
|
|
1537
|
+
alpha: this.clamp01(options.alpha),
|
|
1538
|
+
backgroundColor: options.backgroundColor || DEFAULT_MINIMAP.backgroundColor,
|
|
1539
|
+
borderColor: options.borderColor || DEFAULT_MINIMAP.borderColor,
|
|
1540
|
+
borderWidth: Number.isFinite(options.borderWidth)
|
|
1541
|
+
? Math.max(0, options.borderWidth)
|
|
1542
|
+
: DEFAULT_MINIMAP.borderWidth,
|
|
1543
|
+
trackColor: options.trackColor || DEFAULT_MINIMAP.trackColor,
|
|
1544
|
+
trackLineWidth: Number.isFinite(options.trackLineWidth)
|
|
1545
|
+
? Math.max(1, options.trackLineWidth)
|
|
1546
|
+
: DEFAULT_MINIMAP.trackLineWidth,
|
|
1547
|
+
playerColor: options.playerColor || DEFAULT_MINIMAP.playerColor,
|
|
1548
|
+
playerStrokeColor: options.playerStrokeColor || DEFAULT_MINIMAP.playerStrokeColor,
|
|
1549
|
+
playerRadius: Number.isFinite(options.playerRadius)
|
|
1550
|
+
? Math.max(1, options.playerRadius)
|
|
1551
|
+
: DEFAULT_MINIMAP.playerRadius,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
/** @internal */
|
|
1555
|
+
sanitizeGroundFillOptions(options) {
|
|
1556
|
+
return {
|
|
1557
|
+
enabled: options.enabled !== false,
|
|
1558
|
+
color: options.color === null || options.color === undefined || options.color === ""
|
|
1559
|
+
? null
|
|
1560
|
+
: options.color,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
/** @internal */
|
|
1564
|
+
sanitizeDebugOptions(options) {
|
|
1565
|
+
if (typeof options === "boolean") {
|
|
1566
|
+
return { ...DEFAULT_DEBUG, enabled: options };
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
enabled: options.enabled ?? DEFAULT_DEBUG.enabled,
|
|
1570
|
+
visualBounds: options.visualBounds ?? DEFAULT_DEBUG.visualBounds,
|
|
1571
|
+
collisionBounds: options.collisionBounds ?? DEFAULT_DEBUG.collisionBounds,
|
|
1572
|
+
realCollisionBounds: options.realCollisionBounds ?? DEFAULT_DEBUG.realCollisionBounds,
|
|
1573
|
+
visualBoundsColor: options.visualBoundsColor || DEFAULT_DEBUG.visualBoundsColor,
|
|
1574
|
+
playerCollisionBoundsColor: options.playerCollisionBoundsColor ||
|
|
1575
|
+
DEFAULT_DEBUG.playerCollisionBoundsColor,
|
|
1576
|
+
trafficCollisionBoundsColor: options.trafficCollisionBoundsColor ||
|
|
1577
|
+
DEFAULT_DEBUG.trafficCollisionBoundsColor,
|
|
1578
|
+
playerRealCollisionBoundsColor: options.playerRealCollisionBoundsColor ||
|
|
1579
|
+
DEFAULT_DEBUG.playerRealCollisionBoundsColor,
|
|
1580
|
+
trafficRealCollisionBoundsColor: options.trafficRealCollisionBoundsColor ||
|
|
1581
|
+
DEFAULT_DEBUG.trafficRealCollisionBoundsColor,
|
|
1582
|
+
lineWidth: Number.isFinite(options.lineWidth)
|
|
1583
|
+
? Math.max(1, options.lineWidth)
|
|
1584
|
+
: DEFAULT_DEBUG.lineWidth,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
/** @internal */
|
|
1588
|
+
normalizeAngle(angle) {
|
|
1589
|
+
if (!Number.isFinite(angle))
|
|
1590
|
+
return 0;
|
|
1591
|
+
const wrapped = angle % 360;
|
|
1592
|
+
return wrapped < 0 ? wrapped + 360 : wrapped;
|
|
1593
|
+
}
|
|
1594
|
+
/** @internal */
|
|
1595
|
+
getShortestAngleDelta(fromAngle, toAngle) {
|
|
1596
|
+
const from = this.normalizeAngle(fromAngle);
|
|
1597
|
+
const to = this.normalizeAngle(toAngle);
|
|
1598
|
+
let delta = to - from;
|
|
1599
|
+
if (delta > 180)
|
|
1600
|
+
delta -= 360;
|
|
1601
|
+
if (delta < -180)
|
|
1602
|
+
delta += 360;
|
|
1603
|
+
return delta;
|
|
1604
|
+
}
|
|
1605
|
+
/** @internal */
|
|
1606
|
+
easeInOut(value) {
|
|
1607
|
+
return value * value * (3 - 2 * value);
|
|
1608
|
+
}
|
|
1609
|
+
/** @internal */
|
|
1610
|
+
lerp(a, b, t) {
|
|
1611
|
+
return a + (b - a) * t;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Automatic traffic orchestration for {@link ArcadeRacerEngine}.
|
|
1616
|
+
*
|
|
1617
|
+
* `ArcadeRacerTrafficManager` owns a pool of manager-spawned vehicles and keeps
|
|
1618
|
+
* that pool populated by:
|
|
1619
|
+
*
|
|
1620
|
+
* - choosing weighted traffic profiles
|
|
1621
|
+
* - selecting lanes and spawn distances ahead of the player
|
|
1622
|
+
* - removing vehicles that have fallen sufficiently behind
|
|
1623
|
+
* - respecting a simple same-lane minimum gap
|
|
1624
|
+
*
|
|
1625
|
+
* The manager is intentionally separate from {@link ArcadeRacerEngine} so AI
|
|
1626
|
+
* agents and game code can opt into dynamic traffic only when they need it.
|
|
1627
|
+
* You construct it explicitly with `new ArcadeRacerTrafficManager(racer, ...)`
|
|
1628
|
+
* and call {@link update} each frame.
|
|
1629
|
+
*
|
|
1630
|
+
* Recommended usage:
|
|
1631
|
+
*
|
|
1632
|
+
* Start with `ArcadeRacerTrafficManager` as the default solution for gameplay
|
|
1633
|
+
* traffic. It covers the common arcade-racer needs of weighted vehicle mixes,
|
|
1634
|
+
* lane-aware spacing, spawn windows, and cleanup with a small API surface.
|
|
1635
|
+
* If your game later needs more specialized behavior such as scripted events,
|
|
1636
|
+
* convoy logic, mission traffic, branching-road orchestration, or highly custom
|
|
1637
|
+
* spawning rules, you can replace it with your own traffic strategy while still
|
|
1638
|
+
* using {@link ArcadeRacerEngine.addTraffic} and
|
|
1639
|
+
* {@link ArcadeRacerEngine.removeTrafficById} directly.
|
|
1640
|
+
*
|
|
1641
|
+
* The manager only tracks vehicles that it spawned itself. Vehicles added
|
|
1642
|
+
* manually with {@link ArcadeRacerEngine.addTraffic} remain untouched.
|
|
1643
|
+
*/
|
|
1644
|
+
export class ArcadeRacerTrafficManager {
|
|
1645
|
+
constructor(racer, options) {
|
|
1646
|
+
/** @internal */
|
|
1647
|
+
this.entries = [];
|
|
1648
|
+
/** @internal */
|
|
1649
|
+
this.spawnCooldown = 0;
|
|
1650
|
+
this.racer = racer;
|
|
1651
|
+
this.profiles = options.profiles.filter(Boolean);
|
|
1652
|
+
this.lanePositions = sanitizeLaneSpacePositions(options.lanePositions);
|
|
1653
|
+
this.onSpawn = options.onSpawn;
|
|
1654
|
+
this.onDespawn = options.onDespawn;
|
|
1655
|
+
this.maxActive = Math.max(1, Math.round(options.maxActive ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.maxActive));
|
|
1656
|
+
this.spawnAheadMin = Math.max(1, options.spawnAheadMin ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.spawnAheadMin);
|
|
1657
|
+
this.spawnAheadMax = Math.max(this.spawnAheadMin, options.spawnAheadMax ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.spawnAheadMax);
|
|
1658
|
+
this.spawnBehindMin = Math.max(1, options.spawnBehindMin ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.spawnBehindMin);
|
|
1659
|
+
this.spawnBehindMax = Math.max(this.spawnBehindMin, options.spawnBehindMax ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.spawnBehindMax);
|
|
1660
|
+
this.despawnBehindDistance = Math.max(1, options.despawnBehindDistance ??
|
|
1661
|
+
DEFAULT_TRAFFIC_MANAGER_OPTIONS.despawnBehindDistance);
|
|
1662
|
+
this.despawnAheadDistance = Math.max(this.spawnAheadMax, options.despawnAheadDistance ??
|
|
1663
|
+
DEFAULT_TRAFFIC_MANAGER_OPTIONS.despawnAheadDistance);
|
|
1664
|
+
this.minGapDistance = Math.max(1, options.minGapDistance ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.minGapDistance);
|
|
1665
|
+
this.spawnInterval = Math.max(0.05, options.spawnInterval ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.spawnInterval);
|
|
1666
|
+
this.oncomingChance = Math.max(0, Math.min(1, options.oncomingChance ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.oncomingChance));
|
|
1667
|
+
this.fasterTrafficSpawnsBehind =
|
|
1668
|
+
options.fasterTrafficSpawnsBehind ??
|
|
1669
|
+
DEFAULT_TRAFFIC_MANAGER_OPTIONS.fasterTrafficSpawnsBehind;
|
|
1670
|
+
this.enabledState = options.enabled ?? true;
|
|
1671
|
+
const initialActive = Math.max(0, Math.min(this.maxActive, Math.round(options.initialActive ?? DEFAULT_TRAFFIC_MANAGER_OPTIONS.initialActive)));
|
|
1672
|
+
if (initialActive > 0) {
|
|
1673
|
+
this.spawnNow(initialActive);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Whether the manager is allowed to spawn new vehicles.
|
|
1678
|
+
*
|
|
1679
|
+
* Disabling the manager does not remove existing managed traffic; it only
|
|
1680
|
+
* pauses new spawn attempts. Existing entries continue to be tracked and
|
|
1681
|
+
* despawned when they fall behind the player.
|
|
1682
|
+
*/
|
|
1683
|
+
get enabled() {
|
|
1684
|
+
return this.enabledState;
|
|
1685
|
+
}
|
|
1686
|
+
set enabled(value) {
|
|
1687
|
+
this.enabledState = Boolean(value);
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Number of currently active manager-owned traffic vehicles.
|
|
1691
|
+
*/
|
|
1692
|
+
get activeCount() {
|
|
1693
|
+
return this.entries.length;
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Updates manager-owned traffic bookkeeping and performs spawn/despawn work.
|
|
1697
|
+
*/
|
|
1698
|
+
update(dt) {
|
|
1699
|
+
const safeDt = Number.isFinite(dt) && dt > 0 ? Math.min(dt, 0.1) : 0;
|
|
1700
|
+
if (safeDt > 0) {
|
|
1701
|
+
for (const entry of this.entries) {
|
|
1702
|
+
entry.distance += entry.speed * safeDt;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
this.pruneBehindPlayer();
|
|
1706
|
+
if (!this.enabledState || this.profiles.length === 0) {
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
this.spawnCooldown -= safeDt;
|
|
1710
|
+
while (this.spawnCooldown <= 0 &&
|
|
1711
|
+
this.entries.length < this.maxActive) {
|
|
1712
|
+
this.spawnCooldown += this.spawnInterval;
|
|
1713
|
+
if (!this.spawnOne()) {
|
|
1714
|
+
break;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Immediately spawns up to `count` new traffic vehicles if valid slots exist.
|
|
1720
|
+
*/
|
|
1721
|
+
spawnNow(count = 1) {
|
|
1722
|
+
const target = Math.max(0, Math.round(count));
|
|
1723
|
+
let spawned = 0;
|
|
1724
|
+
for (let i = 0; i < target && this.entries.length < this.maxActive; i++) {
|
|
1725
|
+
if (!this.spawnOne())
|
|
1726
|
+
break;
|
|
1727
|
+
spawned += 1;
|
|
1728
|
+
}
|
|
1729
|
+
return spawned;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Removes all manager-owned vehicles from the racer.
|
|
1733
|
+
*/
|
|
1734
|
+
clear() {
|
|
1735
|
+
while (this.entries.length > 0) {
|
|
1736
|
+
const entry = this.entries.pop();
|
|
1737
|
+
this.racer.removeTrafficById(entry.id);
|
|
1738
|
+
this.onDespawn?.({
|
|
1739
|
+
id: entry.id,
|
|
1740
|
+
reason: "cleared",
|
|
1741
|
+
profile: entry.profile,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
/** @internal */
|
|
1746
|
+
spawnOne() {
|
|
1747
|
+
if (this.profiles.length === 0)
|
|
1748
|
+
return false;
|
|
1749
|
+
const maxAttempts = Math.max(4, this.profiles.length * 3);
|
|
1750
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1751
|
+
const profile = this.pickProfile();
|
|
1752
|
+
if (!profile)
|
|
1753
|
+
return false;
|
|
1754
|
+
const oncoming = this.resolveOncoming(profile);
|
|
1755
|
+
const lane = this.pickLane(profile, oncoming);
|
|
1756
|
+
if (!Number.isFinite(lane))
|
|
1757
|
+
continue;
|
|
1758
|
+
const speedMph = this.pickSpeedMph(profile, oncoming);
|
|
1759
|
+
const speed = (oncoming ? -1 : 1) * Math.max(0, speedMph / MPH_PER_SPEED_UNIT);
|
|
1760
|
+
const distance = this.pickSpawnDistance(speed, oncoming);
|
|
1761
|
+
if (!this.isGapSafe(lane, distance))
|
|
1762
|
+
continue;
|
|
1763
|
+
const baseScale = this.pickBaseScale(profile);
|
|
1764
|
+
const trafficId = this.racer.addTraffic(profile.visual, {
|
|
1765
|
+
lane,
|
|
1766
|
+
distance,
|
|
1767
|
+
speed,
|
|
1768
|
+
baseScale,
|
|
1769
|
+
bodyWidth: profile.bodyWidth,
|
|
1770
|
+
bodyLength: profile.bodyLength,
|
|
1771
|
+
bodyOffsetX: profile.bodyOffsetX,
|
|
1772
|
+
bodyOffsetY: profile.bodyOffsetY,
|
|
1773
|
+
});
|
|
1774
|
+
this.entries.push({
|
|
1775
|
+
id: trafficId,
|
|
1776
|
+
lane,
|
|
1777
|
+
distance,
|
|
1778
|
+
speed,
|
|
1779
|
+
oncoming,
|
|
1780
|
+
profile,
|
|
1781
|
+
});
|
|
1782
|
+
this.onSpawn?.({
|
|
1783
|
+
id: trafficId,
|
|
1784
|
+
lane,
|
|
1785
|
+
distance,
|
|
1786
|
+
speed,
|
|
1787
|
+
speedMph,
|
|
1788
|
+
oncoming,
|
|
1789
|
+
profile,
|
|
1790
|
+
});
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
/** @internal */
|
|
1796
|
+
pruneBehindPlayer() {
|
|
1797
|
+
const cutoff = this.racer.distance -
|
|
1798
|
+
Math.max(this.despawnBehindDistance, this.spawnBehindMax + this.minGapDistance * 0.5);
|
|
1799
|
+
const aheadCutoff = this.racer.distance + this.despawnAheadDistance;
|
|
1800
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
1801
|
+
const entry = this.entries[i];
|
|
1802
|
+
if (entry.distance <= cutoff) {
|
|
1803
|
+
this.entries.splice(i, 1);
|
|
1804
|
+
this.racer.removeTrafficById(entry.id);
|
|
1805
|
+
this.onDespawn?.({
|
|
1806
|
+
id: entry.id,
|
|
1807
|
+
reason: "behind",
|
|
1808
|
+
profile: entry.profile,
|
|
1809
|
+
});
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (entry.distance < aheadCutoff)
|
|
1813
|
+
continue;
|
|
1814
|
+
this.entries.splice(i, 1);
|
|
1815
|
+
this.racer.removeTrafficById(entry.id);
|
|
1816
|
+
this.onDespawn?.({
|
|
1817
|
+
id: entry.id,
|
|
1818
|
+
reason: "ahead",
|
|
1819
|
+
profile: entry.profile,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
/** @internal */
|
|
1824
|
+
pickProfile() {
|
|
1825
|
+
let totalWeight = 0;
|
|
1826
|
+
for (const profile of this.profiles) {
|
|
1827
|
+
totalWeight += Math.max(0.0001, profile.weight ?? 1);
|
|
1828
|
+
}
|
|
1829
|
+
if (totalWeight <= 0)
|
|
1830
|
+
return null;
|
|
1831
|
+
let cursor = Math.random() * totalWeight;
|
|
1832
|
+
for (const profile of this.profiles) {
|
|
1833
|
+
cursor -= Math.max(0.0001, profile.weight ?? 1);
|
|
1834
|
+
if (cursor <= 0)
|
|
1835
|
+
return profile;
|
|
1836
|
+
}
|
|
1837
|
+
return this.profiles[this.profiles.length - 1] ?? null;
|
|
1838
|
+
}
|
|
1839
|
+
/** @internal */
|
|
1840
|
+
pickLane(profile, oncoming) {
|
|
1841
|
+
if (Number.isFinite(profile.lane)) {
|
|
1842
|
+
return profile.lane;
|
|
1843
|
+
}
|
|
1844
|
+
const lanes = sanitizeLaneSpacePositions(profile.lanes) ??
|
|
1845
|
+
this.lanePositions ??
|
|
1846
|
+
this.racer.getLanePositions(oncoming ? "oncoming" : "same");
|
|
1847
|
+
if (lanes.length === 0)
|
|
1848
|
+
return Number.NaN;
|
|
1849
|
+
return lanes[Math.floor(Math.random() * lanes.length)] ?? 0;
|
|
1850
|
+
}
|
|
1851
|
+
/** @internal */
|
|
1852
|
+
resolveOncoming(profile) {
|
|
1853
|
+
if (profile.direction === "oncoming")
|
|
1854
|
+
return true;
|
|
1855
|
+
if (profile.direction === "either")
|
|
1856
|
+
return Math.random() < this.oncomingChance;
|
|
1857
|
+
return false;
|
|
1858
|
+
}
|
|
1859
|
+
/** @internal */
|
|
1860
|
+
pickSpeedMph(profile, oncoming) {
|
|
1861
|
+
if (Number.isFinite(profile.speedMph)) {
|
|
1862
|
+
return Math.max(1, Math.abs(profile.speedMph));
|
|
1863
|
+
}
|
|
1864
|
+
const defaultMin = oncoming ? 135 : 92;
|
|
1865
|
+
const defaultMax = oncoming ? 185 : 145;
|
|
1866
|
+
const min = Math.max(1, profile.speedMphMin ?? defaultMin);
|
|
1867
|
+
const max = Math.max(min, profile.speedMphMax ?? defaultMax);
|
|
1868
|
+
return min + Math.random() * (max - min);
|
|
1869
|
+
}
|
|
1870
|
+
/** @internal */
|
|
1871
|
+
pickBaseScale(profile) {
|
|
1872
|
+
if (Number.isFinite(profile.baseScale)) {
|
|
1873
|
+
return Math.max(0, profile.baseScale);
|
|
1874
|
+
}
|
|
1875
|
+
const min = Math.max(0, profile.baseScaleMin ?? 1);
|
|
1876
|
+
const max = Math.max(min, profile.baseScaleMax ?? min);
|
|
1877
|
+
return min + Math.random() * (max - min);
|
|
1878
|
+
}
|
|
1879
|
+
/** @internal */
|
|
1880
|
+
pickSpawnDistance(speed, oncoming) {
|
|
1881
|
+
const playerSpeed = Math.max(0, this.racer.speed);
|
|
1882
|
+
if (!oncoming &&
|
|
1883
|
+
this.fasterTrafficSpawnsBehind &&
|
|
1884
|
+
speed > playerSpeed + 0.001) {
|
|
1885
|
+
return (this.racer.distance -
|
|
1886
|
+
(this.spawnBehindMin +
|
|
1887
|
+
Math.random() * (this.spawnBehindMax - this.spawnBehindMin)));
|
|
1888
|
+
}
|
|
1889
|
+
return (this.racer.distance +
|
|
1890
|
+
this.spawnAheadMin +
|
|
1891
|
+
Math.random() * (this.spawnAheadMax - this.spawnAheadMin));
|
|
1892
|
+
}
|
|
1893
|
+
/** @internal */
|
|
1894
|
+
isGapSafe(lane, distance) {
|
|
1895
|
+
return this.entries.every((entry) => {
|
|
1896
|
+
if (Math.abs(entry.lane - lane) > 0.08)
|
|
1897
|
+
return true;
|
|
1898
|
+
return Math.abs(entry.distance - distance) >= this.minGapDistance;
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Fixed-field race competitor orchestration for {@link ArcadeRacerEngine}.
|
|
1904
|
+
*
|
|
1905
|
+
* Use `ArcadeRacerRaceManager` when the player is competing against a known set
|
|
1906
|
+
* of opponents instead of ambient road traffic. The manager creates one traffic
|
|
1907
|
+
* vehicle per competitor, advances each competitor explicitly every frame, and
|
|
1908
|
+
* exposes simple race standings based on forward progress toward a finish
|
|
1909
|
+
* distance.
|
|
1910
|
+
*
|
|
1911
|
+
* This manager is best suited to classic arcade races where:
|
|
1912
|
+
*
|
|
1913
|
+
* - the roster is fixed at race start
|
|
1914
|
+
* - opponents have defined names and pacing
|
|
1915
|
+
* - finishing place matters more than ambient traffic density
|
|
1916
|
+
*
|
|
1917
|
+
* For world-traffic scenarios or open-road cruising, prefer
|
|
1918
|
+
* {@link ArcadeRacerTrafficManager} instead.
|
|
1919
|
+
*/
|
|
1920
|
+
export class ArcadeRacerRaceManager {
|
|
1921
|
+
constructor(racer, options) {
|
|
1922
|
+
/** @internal */
|
|
1923
|
+
this.entries = [];
|
|
1924
|
+
this.racer = racer;
|
|
1925
|
+
this.finishDistance = Math.max(1, options.finishDistance);
|
|
1926
|
+
this.lanePositions =
|
|
1927
|
+
sanitizeLaneSpacePositions(options.lanePositions) ??
|
|
1928
|
+
(() => {
|
|
1929
|
+
const sameDirectionLanes = this.racer.getLanePositions("same");
|
|
1930
|
+
return sameDirectionLanes.length > 0
|
|
1931
|
+
? sameDirectionLanes
|
|
1932
|
+
: this.racer.getLanePositions();
|
|
1933
|
+
})();
|
|
1934
|
+
for (const competitor of options.competitors) {
|
|
1935
|
+
const lane = this.pickCompetitorLane(competitor);
|
|
1936
|
+
const startDistance = Number.isFinite(competitor.startDistance)
|
|
1937
|
+
? competitor.startDistance
|
|
1938
|
+
: 0;
|
|
1939
|
+
const speedMph = this.pickCompetitorSpeedMph(competitor);
|
|
1940
|
+
const speed = Math.max(0, speedMph / MPH_PER_SPEED_UNIT);
|
|
1941
|
+
const trafficId = this.racer.addTraffic(competitor.visual, {
|
|
1942
|
+
lane,
|
|
1943
|
+
distance: startDistance,
|
|
1944
|
+
speed: 0,
|
|
1945
|
+
baseScale: competitor.baseScale,
|
|
1946
|
+
bodyWidth: competitor.bodyWidth,
|
|
1947
|
+
bodyLength: competitor.bodyLength,
|
|
1948
|
+
bodyOffsetX: competitor.bodyOffsetX,
|
|
1949
|
+
bodyOffsetY: competitor.bodyOffsetY,
|
|
1950
|
+
loop: false,
|
|
1951
|
+
});
|
|
1952
|
+
this.entries.push({
|
|
1953
|
+
id: trafficId,
|
|
1954
|
+
name: competitor.name,
|
|
1955
|
+
distance: startDistance,
|
|
1956
|
+
lane,
|
|
1957
|
+
speed,
|
|
1958
|
+
finished: startDistance >= this.finishDistance,
|
|
1959
|
+
profile: competitor,
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Number of AI competitors currently managed by the race.
|
|
1965
|
+
*/
|
|
1966
|
+
get competitorCount() {
|
|
1967
|
+
return this.entries.length;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Advances competitor progress and synchronizes their traffic vehicles.
|
|
1971
|
+
*/
|
|
1972
|
+
update(dt) {
|
|
1973
|
+
const safeDt = Number.isFinite(dt) && dt > 0 ? Math.min(dt, 0.1) : 0;
|
|
1974
|
+
if (safeDt <= 0)
|
|
1975
|
+
return;
|
|
1976
|
+
for (const entry of this.entries) {
|
|
1977
|
+
if (!entry.finished) {
|
|
1978
|
+
entry.distance += entry.speed * safeDt;
|
|
1979
|
+
if (entry.distance >= this.finishDistance) {
|
|
1980
|
+
entry.distance = this.finishDistance;
|
|
1981
|
+
entry.finished = true;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
this.racer.updateTrafficById(entry.id, {
|
|
1985
|
+
distance: entry.distance,
|
|
1986
|
+
lane: entry.lane,
|
|
1987
|
+
speed: 0,
|
|
1988
|
+
loop: false,
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Returns race standings including the player.
|
|
1994
|
+
*/
|
|
1995
|
+
getStandings() {
|
|
1996
|
+
const standings = [
|
|
1997
|
+
{
|
|
1998
|
+
id: "player",
|
|
1999
|
+
name: "PLAYER",
|
|
2000
|
+
distance: Math.min(this.racer.distance, this.finishDistance),
|
|
2001
|
+
finished: this.racer.distance >= this.finishDistance,
|
|
2002
|
+
isPlayer: true,
|
|
2003
|
+
position: 0,
|
|
2004
|
+
},
|
|
2005
|
+
...this.entries.map((entry) => ({
|
|
2006
|
+
id: entry.id,
|
|
2007
|
+
name: entry.name,
|
|
2008
|
+
distance: Math.min(entry.distance, this.finishDistance),
|
|
2009
|
+
finished: entry.finished,
|
|
2010
|
+
isPlayer: false,
|
|
2011
|
+
position: 0,
|
|
2012
|
+
})),
|
|
2013
|
+
];
|
|
2014
|
+
standings.sort((a, b) => {
|
|
2015
|
+
if (a.distance !== b.distance)
|
|
2016
|
+
return b.distance - a.distance;
|
|
2017
|
+
if (a.finished !== b.finished)
|
|
2018
|
+
return Number(b.finished) - Number(a.finished);
|
|
2019
|
+
return a.name.localeCompare(b.name);
|
|
2020
|
+
});
|
|
2021
|
+
standings.forEach((entry, index) => {
|
|
2022
|
+
entry.position = index + 1;
|
|
2023
|
+
});
|
|
2024
|
+
return standings;
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Returns the player's current race position.
|
|
2028
|
+
*/
|
|
2029
|
+
getPlayerPosition() {
|
|
2030
|
+
return this.getStandings().find((entry) => entry.isPlayer)?.position ?? 1;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Removes all race competitors from the racer.
|
|
2034
|
+
*/
|
|
2035
|
+
clear() {
|
|
2036
|
+
while (this.entries.length > 0) {
|
|
2037
|
+
const entry = this.entries.pop();
|
|
2038
|
+
this.racer.removeTrafficById(entry.id);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
/** @internal */
|
|
2042
|
+
pickCompetitorLane(competitor) {
|
|
2043
|
+
if (Number.isFinite(competitor.lane)) {
|
|
2044
|
+
return competitor.lane;
|
|
2045
|
+
}
|
|
2046
|
+
const lanes = competitor.lanes?.length
|
|
2047
|
+
? competitor.lanes
|
|
2048
|
+
: this.lanePositions;
|
|
2049
|
+
return lanes[Math.floor(Math.random() * lanes.length)] ?? 0;
|
|
2050
|
+
}
|
|
2051
|
+
/** @internal */
|
|
2052
|
+
pickCompetitorSpeedMph(competitor) {
|
|
2053
|
+
if (Number.isFinite(competitor.speedMph)) {
|
|
2054
|
+
return Math.max(1, competitor.speedMph);
|
|
2055
|
+
}
|
|
2056
|
+
const min = Math.max(1, competitor.speedMphMin ?? 112);
|
|
2057
|
+
const max = Math.max(min, competitor.speedMphMax ?? 136);
|
|
2058
|
+
return min + Math.random() * (max - min);
|
|
2059
|
+
}
|
|
2060
|
+
}
|