minimojs 1.0.0-alpha.20 → 1.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,315 @@
1
- import { DrawSprite } from "./minimo.js";
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
+ }
2
313
  const DEFAULT_THEME = {
3
314
  skyTop: "#6ab7ff",
4
315
  skyBottom: "#10203d",
@@ -19,9 +330,10 @@ const DEFAULT_PERFORMANCE = {
19
330
  zeroToSixtySeconds: 6.2,
20
331
  sixtyToZeroSeconds: 3.6,
21
332
  topSpeedMph: 145,
22
- steeringLowSpeedScale: 0.18,
23
- steeringHighSpeedScale: 0.78,
24
- steeringSpeedCurvePower: 1.5,
333
+ steeringLowSpeedScale: 0.9,
334
+ steeringHighSpeedScale: 0.34,
335
+ steeringSpeedCurvePower: 1.8,
336
+ lateralDamping: 6.8,
25
337
  };
26
338
  const DEFAULT_HORIZON = {
27
339
  influenceAngle: 120,
@@ -81,6 +393,22 @@ const DEFAULT_OPTIONS = {
81
393
  offRoadThreshold: 0.82,
82
394
  offRoadTargetSpeedMph: 18,
83
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,
84
412
  };
85
413
  /**
86
414
  * Main public API for the MinimoJS arcade racing module.
@@ -124,7 +452,8 @@ const DEFAULT_OPTIONS = {
124
452
  * speed: 120,
125
453
  * });
126
454
  *
127
- * racer.build();
455
+ * const roadSprite = racer.build();
456
+ * game.add(roadSprite);
128
457
  *
129
458
  * game.onUpdate = (dt) => {
130
459
  * racer.accelerateToMph(110, dt);
@@ -145,8 +474,9 @@ const DEFAULT_OPTIONS = {
145
474
  * {@link playerLane}, and helper methods such as {@link steerLeft},
146
475
  * {@link steerRight}, {@link accelerateToMph}, and {@link brakeToMph}.
147
476
  * 3. Scene objects:
148
- * Use {@link addTraffic} and {@link addBillboard} to place vehicles and
149
- * roadside decoration along the road.
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.
150
480
  * 4. Background and atmosphere:
151
481
  * Configure atmosphere in the constructor, then use
152
482
  * {@link addBackgroundLayer} and {@link addHorizonMarker} to place skies,
@@ -165,6 +495,10 @@ const DEFAULT_OPTIONS = {
165
495
  * - `playerLane` is the main lateral control value. Around `-1..1` the player
166
496
  * is on the paved road; beyond that they enter shoulders and off-road space
167
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.
168
502
  * - Traffic collisions are resolved in road space, not by 2D sprite overlap.
169
503
  * - If no image background layers are configured, the engine falls back to a
170
504
  * built-in procedural sky and mountain backdrop.
@@ -183,17 +517,28 @@ const DEFAULT_OPTIONS = {
183
517
  * - Use {@link addBackgroundLayer} for wide, repeating, parallax image bands.
184
518
  * - Use {@link addHorizonMarker} for directional landmarks that appear only at
185
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.
186
524
  * - Use constructor options such as `playerBodyWidth`, `playerBodyLength`,
187
525
  * `playerBodyOffsetX`, and `playerBodyOffsetY` when you need to tune
188
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.
189
530
  * - Use constructor `debug` options when you need to visualize car image
190
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.
191
535
  *
192
536
  * This class is the canonical public surface of the module. Its JSDoc is
193
537
  * intended to be consumed directly by AI agents and tooling that generate code
194
538
  * against `minimo-arcaderacer.js`.
195
539
  */
196
540
  export class ArcadeRacerEngine {
541
+ /** @internal */
197
542
  constructor(game, options = {}) {
198
543
  /** @internal */
199
544
  this.collisionHandlers = [];
@@ -210,15 +555,7 @@ export class ArcadeRacerEngine {
210
555
  /** @internal */
211
556
  this.grassBandSpan = 3;
212
557
  /** @internal */
213
- this.segments = [];
214
- /** @internal */
215
- this.pathPrimitives = [];
216
- /** @internal */
217
- this.pathPointsCacheVersion = -1;
218
- /** @internal */
219
- this.pathPointsCache = [];
220
- /** @internal */
221
- this.totalTrackLength = 0;
558
+ this.roadDrawerRangesState = [];
222
559
  /** @internal */
223
560
  this.billboardsState = [];
224
561
  /** @internal */
@@ -234,6 +571,10 @@ export class ArcadeRacerEngine {
234
571
  /** @internal */
235
572
  this.distanceState = 0;
236
573
  /** @internal */
574
+ this.playerSteeringInputState = 0;
575
+ /** @internal */
576
+ this.playerLateralVelocityState = 0;
577
+ /** @internal */
237
578
  this.nextId = 1;
238
579
  /** @internal */
239
580
  this.roadSprite = null;
@@ -241,18 +582,6 @@ export class ArcadeRacerEngine {
241
582
  this.activeCollisionIds = new Set();
242
583
  /** @internal */
243
584
  this.headingState = 0;
244
- /** @internal */
245
- this.trackVersion = 0;
246
- /** @internal */
247
- this.trackExplicitlyClosed = false;
248
- /** @internal */
249
- this.trackClosureMetricsVersion = -1;
250
- /** @internal */
251
- this.trackClosureMetrics = null;
252
- /** @internal */
253
- this.minimapPathCacheVersion = -1;
254
- /** @internal */
255
- this.minimapPathCache = [];
256
585
  this.game = game;
257
586
  this.width = Math.max(1, Math.round(options.width ?? game.width));
258
587
  this.height = Math.max(1, Math.round(options.height ?? game.height));
@@ -265,7 +594,14 @@ export class ArcadeRacerEngine {
265
594
  this.drawDistance = Math.max(100, options.drawDistance ?? DEFAULT_OPTIONS.drawDistance);
266
595
  this.roadNearWidth = Math.max(16, options.roadNearWidth ?? this.width * 0.98);
267
596
  this.roadFarWidth = Math.max(4, options.roadFarWidth ?? this.width * 0.1);
268
- this.laneCount = Math.max(1, Math.round(options.laneCount ?? DEFAULT_OPTIONS.laneCount));
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 ?? [])];
269
605
  this.steeringRate = Math.max(0, options.steeringRate ?? DEFAULT_OPTIONS.steeringRate);
270
606
  this.curveScale = Math.max(0, options.curveScale ?? DEFAULT_OPTIONS.curveScale);
271
607
  this.hillScale = Math.max(0, options.hillScale ?? DEFAULT_OPTIONS.hillScale);
@@ -275,6 +611,10 @@ export class ArcadeRacerEngine {
275
611
  this.offRoadSlowdownEnabled = options.offRoadSlowdownEnabled ?? false;
276
612
  this.offRoadTargetSpeedMph = Math.max(0, options.offRoadTargetSpeedMph ?? DEFAULT_OPTIONS.offRoadTargetSpeedMph);
277
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);
278
618
  this.themeState = {
279
619
  ...DEFAULT_THEME,
280
620
  ...(options.theme ?? {}),
@@ -310,6 +650,8 @@ export class ArcadeRacerEngine {
310
650
  });
311
651
  this.debugState = this.sanitizeDebugOptions(options.debug ?? false);
312
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.
313
655
  options.playerVisual ?? { type: "emoji", value: "🏎️", size: 48 };
314
656
  this.playerScreenYState =
315
657
  options.playerScreenY ?? Math.round(this.height * 0.86);
@@ -335,8 +677,31 @@ export class ArcadeRacerEngine {
335
677
  this.playerResolvedBodyState = this.resolveCollisionBodyMetrics(this.playerVisualState, this.playerBaseScaleState, this.playerBodyWidthState, this.playerBodyLengthState, this.playerBodyOffsetXState, this.playerBodyOffsetYState);
336
678
  this.speedState = options.speed ?? DEFAULT_OPTIONS.speed;
337
679
  this.playerLaneState = this.clampPlayerLane(options.playerLane ?? DEFAULT_OPTIONS.playerLane);
338
- this.resetTrack();
339
- this.addTrackSegment({ length: 4000, curve: 0, hill: 0 });
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);
340
705
  }
341
706
  /**
342
707
  * Current player speed in world units per second.
@@ -382,6 +747,7 @@ export class ArcadeRacerEngine {
382
747
  }
383
748
  set playerLane(value) {
384
749
  this.playerLaneState = this.clampPlayerLane(value);
750
+ this.playerLateralVelocityState = 0;
385
751
  }
386
752
  /**
387
753
  * Whether the player is currently outside the configured road threshold.
@@ -399,34 +765,37 @@ export class ArcadeRacerEngine {
399
765
  * Removes all existing track primitives and legacy segments.
400
766
  */
401
767
  clearTrack() {
402
- this.resetTrack();
768
+ this.trackSystem.clearTrack();
403
769
  }
404
770
  /**
405
771
  * Appends a straight path section.
406
772
  */
407
773
  straight(length, options = {}) {
408
- this.appendPathLine(length, options);
774
+ this.trackSystem.appendPathLine(length, options);
409
775
  }
410
776
  /**
411
777
  * Replaces the track with a naturally closed rounded rectangle circuit.
412
778
  */
413
779
  roundedRect(width, height, cornerRadius) {
414
- this.setRoundedRectTrack(width, height, cornerRadius);
780
+ this.trackSystem.setRoundedRectTrack(width, height, cornerRadius);
415
781
  }
416
782
  /**
417
783
  * Appends a path arc that bends left by the given number of degrees.
418
784
  */
419
785
  arcLeft(radius, degrees, options = {}) {
420
- this.appendPathArc(radius, degrees, -1, options);
786
+ this.trackSystem.appendPathArc(radius, degrees, -1, options);
421
787
  }
422
788
  /**
423
789
  * Appends a path arc that bends right by the given number of degrees.
424
790
  */
425
791
  arcRight(radius, degrees, options = {}) {
426
- this.appendPathArc(radius, degrees, 1, options);
792
+ this.trackSystem.appendPathArc(radius, degrees, 1, options);
427
793
  }
428
794
  /**
429
- * Creates and registers the internal road sprite if it does not yet exist.
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.
430
799
  */
431
800
  build() {
432
801
  if (this.roadSprite)
@@ -436,7 +805,7 @@ export class ArcadeRacerEngine {
436
805
  sprite.y = this.y;
437
806
  sprite.ignoreScroll = true;
438
807
  sprite.layer = this.layer;
439
- this.roadSprite = this.game.add(sprite);
808
+ this.roadSprite = sprite;
440
809
  return this.roadSprite;
441
810
  }
442
811
  /**
@@ -452,8 +821,8 @@ export class ArcadeRacerEngine {
452
821
  this.build();
453
822
  const safeDt = Number.isFinite(dt) && dt > 0 ? Math.min(dt, 0.1) : 0;
454
823
  this.distanceState += this.speedState * safeDt;
455
- const current = this.sampleTrack(this.distanceState);
456
- if (this.pathPrimitives.length > 0) {
824
+ const current = this.trackSystem.sampleTrack(this.distanceState);
825
+ if (this.trackSystem.hasPathPrimitives) {
457
826
  const targetHeading = this.normalizeAngle((current.heading * 180) / Math.PI);
458
827
  const headingDelta = this.getShortestAngleDelta(this.headingState, targetHeading);
459
828
  const followStrength = Math.min(1, this.horizonState.headingResponse * 2.5);
@@ -467,60 +836,50 @@ export class ArcadeRacerEngine {
467
836
  safeDt);
468
837
  }
469
838
  const forwardSpeed = Math.abs(this.speedState);
470
- const speedRatio = this.clamp01(forwardSpeed / 420);
471
- const roadInfluenceScale = 0.18 + speedRatio * 0.82;
472
- if (!this.isOffRoad) {
473
- this.playerLaneState = this.clampPlayerLane(this.playerLaneState -
474
- current.curve *
475
- forwardSpeed *
476
- this.playerRoadInfluence *
477
- roadInfluenceScale *
478
- safeDt *
479
- 0.32);
480
- }
839
+ this.updatePlayerLateralDynamics(current, forwardSpeed, safeDt);
481
840
  if (this.offRoadSlowdownEnabled && this.isOffRoad) {
482
841
  this.brakeToMph(this.offRoadTargetSpeedMph, safeDt, this.offRoadBrakeRate);
483
842
  }
484
843
  for (const traffic of this.trafficState) {
485
844
  traffic.distance += traffic.speed * safeDt;
486
- if (traffic.loop && this.totalTrackLength > 0) {
845
+ if (traffic.loop && this.trackSystem.totalTrackLength > 0) {
487
846
  while (traffic.distance <
488
847
  this.distanceState - this.trafficBehindVisibilityDistance) {
489
- traffic.distance += this.totalTrackLength;
848
+ traffic.distance += this.trackSystem.totalTrackLength;
490
849
  }
491
850
  }
492
851
  }
493
852
  this.resolvePlayerTrafficCollisions();
853
+ this.playerSteeringInputState = 0;
494
854
  }
495
855
  /**
496
856
  * Moves the player left using the configured steering rate.
497
857
  */
498
858
  steerLeft(dt, strength = 1) {
859
+ void dt;
499
860
  const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 1;
500
- const steeringScale = this.getSteeringScale();
501
- this.playerLaneState = this.clampPlayerLane(this.playerLaneState -
502
- this.steeringRate * steeringScale * safeStrength * Math.max(0, dt));
861
+ this.playerSteeringInputState = Math.max(-1, Math.min(1, this.playerSteeringInputState - safeStrength));
503
862
  }
504
863
  /**
505
864
  * Moves the player right using the configured steering rate.
506
865
  */
507
866
  steerRight(dt, strength = 1) {
867
+ void dt;
508
868
  const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 1;
509
- const steeringScale = this.getSteeringScale();
510
- this.playerLaneState = this.clampPlayerLane(this.playerLaneState +
511
- this.steeringRate * steeringScale * safeStrength * Math.max(0, dt));
869
+ this.playerSteeringInputState = Math.max(-1, Math.min(1, this.playerSteeringInputState + safeStrength));
512
870
  }
513
871
  /**
514
872
  * Smoothly accelerates the player speed toward a target value.
515
873
  */
516
874
  accelerateTo(targetSpeed, dt, rate = 2.8) {
517
875
  const cappedTarget = Math.min(Math.max(0, targetSpeed), this.mphToSpeed(this.performanceState.topSpeedMph));
876
+ const accelerationScale = this.getOffRoadAccelerationScale();
518
877
  if (arguments.length >= 3) {
519
- this.speedState = this.approach(this.speedState, cappedTarget, rate, dt);
878
+ this.speedState = this.approach(this.speedState, cappedTarget, rate * accelerationScale, dt);
520
879
  return;
521
880
  }
522
881
  const accelerationPerSecond = this.mphToSpeed(60) / this.performanceState.zeroToSixtySeconds;
523
- this.speedState = this.approachLinear(this.speedState, cappedTarget, accelerationPerSecond, dt);
882
+ this.speedState = this.approachLinear(this.speedState, cappedTarget, accelerationPerSecond * accelerationScale, dt);
524
883
  }
525
884
  /**
526
885
  * Smoothly brakes the player speed toward a target value.
@@ -580,9 +939,13 @@ export class ArcadeRacerEngine {
580
939
  id,
581
940
  visual,
582
941
  distance: Math.max(0, config.distance + i * spacing),
583
- lane: Number.isFinite(config.lane) ? this.clampRoadLane(config.lane) : null,
942
+ lane: Number.isFinite(config.lane)
943
+ ? this.laneSystem.clampRoadLane(config.lane)
944
+ : null,
584
945
  side: config.side ?? "right",
585
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),
586
949
  baseScale: Number.isFinite(config.baseScale)
587
950
  ? Math.max(0, config.baseScale)
588
951
  : 1,
@@ -615,8 +978,8 @@ export class ArcadeRacerEngine {
615
978
  this.trafficState.push({
616
979
  id,
617
980
  visual,
618
- distance: Math.max(0, config.distance),
619
- lane: this.clampRoadLane(config.lane),
981
+ distance: Number.isFinite(config.distance) ? config.distance : 0,
982
+ lane: this.laneSystem.clampRoadLane(config.lane),
620
983
  speed: Number.isFinite(config.speed) ? config.speed : 140,
621
984
  baseScale,
622
985
  bodyWidth,
@@ -629,6 +992,62 @@ export class ArcadeRacerEngine {
629
992
  });
630
993
  return id;
631
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
+ }
632
1051
  /**
633
1052
  * Clears all configured traffic vehicles.
634
1053
  */
@@ -642,6 +1061,20 @@ export class ArcadeRacerEngine {
642
1061
  clearBillboards() {
643
1062
  this.billboardsState.length = 0;
644
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
+ }
645
1078
  /**
646
1079
  * Adds a heading-based horizon marker directly and returns its generated id.
647
1080
  */
@@ -705,637 +1138,28 @@ export class ArcadeRacerEngine {
705
1138
  this.backgroundLayersState.length = 0;
706
1139
  }
707
1140
  /** @internal */
708
- setRoundedRectTrack(circuitWidth, circuitHeight, cornerRadius) {
709
- const safeWidth = Math.max(48, Math.abs(circuitWidth));
710
- const safeHeight = Math.max(48, Math.abs(circuitHeight));
711
- const radius = Math.max(12, Math.min(Math.abs(cornerRadius), safeWidth / 2 - 1, safeHeight / 2 - 1));
712
- const horizontal = Math.max(1, safeWidth - radius * 2);
713
- const vertical = Math.max(1, safeHeight - radius * 2);
714
- const arcAngle = Math.PI / 2;
715
- this.resetTrack();
716
- this.pathPrimitives = [
717
- { type: "line", length: horizontal, elevationDelta: 0 },
718
- { type: "arc", radius, angleRad: arcAngle, elevationDelta: 0 },
719
- { type: "line", length: vertical, elevationDelta: 0 },
720
- { type: "arc", radius, angleRad: arcAngle, elevationDelta: 0 },
721
- { type: "line", length: horizontal, elevationDelta: 0 },
722
- { type: "arc", radius, angleRad: arcAngle, elevationDelta: 0 },
723
- { type: "line", length: vertical, elevationDelta: 0 },
724
- { type: "arc", radius, angleRad: arcAngle, elevationDelta: 0 },
725
- ];
726
- this.totalTrackLength =
727
- horizontal * 2 + vertical * 2 + radius * Math.PI * 2;
728
- this.trackExplicitlyClosed = true;
729
- this.trackVersion += 1;
730
- this.pathPointsCache = [];
731
- this.pathPointsCacheVersion = -1;
732
- }
733
- /** @internal */
734
- appendPathLine(length, options = {}) {
735
- const safeLength = Math.max(1, Math.abs(length));
736
- if (this.pathPrimitives.length === 0 && this.segments.length > 0) {
737
- this.resetTrack();
738
- }
739
- this.pathPrimitives.push({
740
- type: "line",
741
- length: safeLength,
742
- elevationDelta: Number.isFinite(options.elevationDelta)
743
- ? options.elevationDelta
744
- : 0,
745
- });
746
- this.totalTrackLength += safeLength;
747
- this.trackVersion += 1;
748
- this.pathPointsCache = [];
749
- this.pathPointsCacheVersion = -1;
750
- this.trackExplicitlyClosed = false;
751
- }
752
- /** @internal */
753
- appendPathArc(radius, degrees, direction, options = {}) {
754
- const safeRadius = Math.max(1, Math.abs(radius));
755
- const safeDegrees = Math.max(0.1, Math.abs(degrees));
756
- if (this.pathPrimitives.length === 0 && this.segments.length > 0) {
757
- this.resetTrack();
758
- }
759
- const angleRad = ((safeDegrees * Math.PI) / 180) * direction;
760
- this.pathPrimitives.push({
761
- type: "arc",
762
- radius: safeRadius,
763
- angleRad,
764
- elevationDelta: Number.isFinite(options.elevationDelta)
765
- ? options.elevationDelta
766
- : 0,
767
- });
768
- this.totalTrackLength += Math.abs(safeRadius * angleRad);
769
- this.trackVersion += 1;
770
- this.pathPointsCache = [];
771
- this.pathPointsCacheVersion = -1;
772
- this.trackExplicitlyClosed = false;
773
- }
774
- /** @internal */
775
1141
  render(ctx, width, height) {
776
- const current = this.sampleTrack(this.distanceState);
777
- this.drawBackground(ctx, width, height);
778
- this.drawRoad(ctx, width, height, current);
779
- this.drawSceneObjects(ctx, width, height, current);
780
- this.drawMinimap(ctx);
781
- }
782
- /** @internal */
783
- drawBackground(ctx, width, height) {
784
- if (this.backgroundLayersState.length > 0) {
785
- this.drawImageBackground(ctx, width, height);
786
- return;
787
- }
788
- this.drawFallbackBackground(ctx, width, height);
789
- }
790
- /** @internal */
791
- drawFallbackBackground(ctx, width, height) {
792
- const sky = ctx.createLinearGradient(0, 0, 0, this.horizonY + 80);
793
- sky.addColorStop(0, this.themeState.skyTop);
794
- sky.addColorStop(0.55, this.themeState.skyBottom);
795
- sky.addColorStop(1, this.themeState.skyBottom);
796
- ctx.fillStyle = sky;
797
- ctx.fillRect(0, 0, width, this.horizonY + 80);
798
- ctx.fillStyle = this.themeState.horizonGlow;
799
- ctx.fillRect(0, this.horizonY - 8, width, 18);
800
- const sunX = width * 0.74;
801
- const sunY = Math.max(40, this.horizonY * 0.54);
802
- const sun = ctx.createRadialGradient(sunX, sunY, 8, sunX, sunY, 56);
803
- sun.addColorStop(0, this.themeState.sunInner);
804
- sun.addColorStop(0.45, "rgba(255, 205, 113, 0.75)");
805
- sun.addColorStop(1, this.themeState.sunOuter);
806
- ctx.fillStyle = sun;
807
- ctx.beginPath();
808
- ctx.arc(sunX, sunY, 56, 0, Math.PI * 2);
809
- ctx.fill();
810
- ctx.fillStyle = this.themeState.mountainBack;
811
- ctx.beginPath();
812
- ctx.moveTo(0, this.horizonY + 18);
813
- for (let x = 0; x <= width; x += 8) {
814
- const y = this.horizonY +
815
- 24 +
816
- Math.sin(x * 0.012 + this.distanceState * 0.00015) * 22 +
817
- Math.sin(x * 0.021 - this.distanceState * 0.00008) * 11;
818
- ctx.lineTo(x, y);
819
- }
820
- ctx.lineTo(width, this.horizonY + 90);
821
- ctx.lineTo(0, this.horizonY + 90);
822
- ctx.closePath();
823
- ctx.fill();
824
- ctx.fillStyle = this.themeState.mountainFront;
825
- ctx.beginPath();
826
- ctx.moveTo(0, this.horizonY + 34);
827
- for (let x = 0; x <= width; x += 7) {
828
- const y = this.horizonY +
829
- 42 +
830
- Math.sin(x * 0.01 + this.distanceState * 0.00019 + 1.1) * 30 +
831
- Math.sin(x * 0.018 - this.distanceState * 0.0001 - 0.7) * 16;
832
- ctx.lineTo(x, y);
833
- }
834
- ctx.lineTo(width, this.horizonY + 115);
835
- ctx.lineTo(0, this.horizonY + 115);
836
- ctx.closePath();
837
- ctx.fill();
838
- this.drawDirectionalHorizon(ctx, width);
839
- if (this.groundFillState.enabled) {
840
- ctx.fillStyle = this.groundFillState.color ?? this.themeState.grassA;
841
- ctx.fillRect(0, this.horizonY, width, height - this.horizonY);
842
- }
843
- }
844
- /** @internal */
845
- drawImageBackground(ctx, width, height) {
846
- ctx.fillStyle = this.themeState.skyBottom;
847
- ctx.fillRect(0, 0, width, this.horizonY + 80);
848
- for (const layer of this.backgroundLayersState) {
849
- const surface = this.resolveVisualSurface(layer.visual);
850
- const drawWidth = Math.max(1, surface.width * layer.baseScale);
851
- const drawHeight = Math.max(1, surface.height * layer.baseScale);
852
- const distanceOffset = this.distanceState * layer.parallaxDistance;
853
- const headingOffset = (this.headingState / 90) * width * layer.parallaxHeading;
854
- const stride = drawWidth + layer.spacing;
855
- const y = layer.y;
856
- ctx.save();
857
- ctx.globalAlpha = layer.alpha;
858
- if (layer.repeat) {
859
- let startX = layer.xOffset - distanceOffset - headingOffset;
860
- startX = ((startX % stride) + stride) % stride - stride;
861
- for (let x = startX; x < width + stride; x += stride) {
862
- ctx.drawImage(surface.source, Math.round(x), Math.round(y), drawWidth, drawHeight);
863
- }
864
- }
865
- else {
866
- const x = width / 2 -
867
- drawWidth / 2 +
868
- layer.xOffset -
869
- distanceOffset -
870
- headingOffset;
871
- ctx.drawImage(surface.source, Math.round(x), Math.round(y), drawWidth, drawHeight);
872
- }
873
- ctx.restore();
874
- }
875
- this.drawDirectionalHorizon(ctx, width);
876
- if (this.groundFillState.enabled) {
877
- ctx.fillStyle = this.groundFillState.color ?? this.themeState.grassA;
878
- ctx.fillRect(0, this.horizonY, width, height - this.horizonY);
879
- }
880
- }
881
- /** @internal */
882
- drawDirectionalHorizon(ctx, width) {
883
- if (this.horizonMarkersState.length === 0) {
884
- return;
885
- }
886
- const visible = this.horizonMarkersState
887
- .map((marker) => {
888
- const influenceAngle = marker.influenceAngle ?? this.horizonState.influenceAngle;
889
- const signedDelta = this.getShortestAngleDelta(this.headingState, marker.angle);
890
- const absDelta = Math.abs(signedDelta);
891
- if (absDelta > influenceAngle) {
892
- return null;
893
- }
894
- const weight = 1 - absDelta / influenceAngle;
895
- const easedWeight = this.easeInOut(weight);
896
- const parallaxFactor = marker.parallaxFactor ?? this.horizonState.parallaxFactor;
897
- const x = width / 2 + (signedDelta / 90) * width * parallaxFactor;
898
- return {
899
- marker,
900
- absDelta,
901
- alpha: marker.alpha * easedWeight,
902
- x,
903
- y: this.horizonY + this.horizonState.baseYOffset + marker.yOffset,
904
- };
905
- })
906
- .filter((entry) => entry !== null && entry.alpha > 0.001)
907
- .sort((a, b) => a.absDelta - b.absDelta)
908
- .slice(0, this.horizonState.maxVisibleMarkers)
909
- .sort((a, b) => a.alpha - b.alpha);
910
- for (const entry of visible) {
911
- this.drawVisual(ctx, entry.marker.visual, entry.x, entry.y, entry.marker.baseScale, entry.alpha);
912
- }
913
- }
914
- /** @internal */
915
- drawMinimap(ctx) {
916
- if (!this.minimapState.visible || this.totalTrackLength <= 0) {
917
- return;
918
- }
919
- const points = this.getMinimapPathPoints();
920
- if (points.length < 2) {
921
- return;
922
- }
923
- const x = this.minimapState.x;
924
- const y = this.minimapState.y;
925
- const width = this.minimapState.width;
926
- const height = this.minimapState.height;
927
- const padding = this.minimapState.padding;
928
- let minX = Number.POSITIVE_INFINITY;
929
- let maxX = Number.NEGATIVE_INFINITY;
930
- let minY = Number.POSITIVE_INFINITY;
931
- let maxY = Number.NEGATIVE_INFINITY;
932
- for (const point of points) {
933
- if (point.x < minX)
934
- minX = point.x;
935
- if (point.x > maxX)
936
- maxX = point.x;
937
- if (point.y < minY)
938
- minY = point.y;
939
- if (point.y > maxY)
940
- maxY = point.y;
941
- }
942
- const innerWidth = Math.max(1, width - padding * 2);
943
- const innerHeight = Math.max(1, height - padding * 2);
944
- const boundsWidth = Math.max(1, maxX - minX);
945
- const boundsHeight = Math.max(1, maxY - minY);
946
- const scale = Math.min(innerWidth / boundsWidth, innerHeight / boundsHeight);
947
- const offsetX = x + padding + (innerWidth - boundsWidth * scale) / 2 - minX * scale;
948
- const offsetY = y + padding + (innerHeight - boundsHeight * scale) / 2 - minY * scale;
949
- ctx.save();
950
- ctx.globalAlpha = this.clamp01(this.minimapState.alpha);
951
- ctx.fillStyle = this.minimapState.backgroundColor;
952
- this.fillRoundedRect(ctx, x, y, width, height, 12);
953
- if (this.minimapState.borderWidth > 0) {
954
- ctx.strokeStyle = this.minimapState.borderColor;
955
- ctx.lineWidth = this.minimapState.borderWidth;
956
- this.strokeRoundedRect(ctx, x, y, width, height, 12);
957
- }
958
- ctx.beginPath();
959
- ctx.lineJoin = "round";
960
- ctx.lineCap = "round";
961
- ctx.lineWidth = this.minimapState.trackLineWidth;
962
- ctx.strokeStyle = this.minimapState.trackColor;
963
- ctx.moveTo(offsetX + points[0].x * scale, offsetY + points[0].y * scale);
964
- for (let i = 1; i < points.length; i++) {
965
- ctx.lineTo(offsetX + points[i].x * scale, offsetY + points[i].y * scale);
966
- }
967
- const startPoint = points[0];
968
- const endPoint = points[points.length - 1];
969
- const closureDistance = Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
970
- const closureTolerance = Math.max(boundsWidth, boundsHeight) * 0.08;
971
- if (closureDistance <= closureTolerance) {
972
- ctx.closePath();
973
- }
974
- ctx.stroke();
975
- const wrappedDistance = this.wrapDistance(this.distanceState);
976
- const playerPoint = this.sampleMinimapPoint(points, wrappedDistance);
977
- const playerX = offsetX + playerPoint.x * scale;
978
- const playerY = offsetY + playerPoint.y * scale;
979
- const radius = this.minimapState.playerRadius;
980
- ctx.beginPath();
981
- ctx.arc(playerX, playerY, radius, 0, Math.PI * 2);
982
- ctx.fillStyle = this.minimapState.playerColor;
983
- ctx.fill();
984
- ctx.lineWidth = Math.max(1, this.minimapState.borderWidth * 0.75);
985
- ctx.strokeStyle = this.minimapState.playerStrokeColor;
986
- ctx.stroke();
987
- ctx.restore();
988
- }
989
- /** @internal */
990
- drawRoad(ctx, width, height, current) {
991
- for (let i = 0; i < this.stripCount; i++) {
992
- const tFar = i / this.stripCount;
993
- const tNear = (i + 1) / this.stripCount;
994
- const aheadFar = (1 - tFar) * this.drawDistance;
995
- const aheadNear = (1 - tNear) * this.drawDistance;
996
- const far = this.projectRoadPoint(current, aheadFar, width, height);
997
- const near = this.projectRoadPoint(current, aheadNear, width, height);
998
- const stripeIndex = Math.floor(this.distanceState * 0.05 + i);
999
- const grassBandIndex = Math.floor(stripeIndex / this.grassBandSpan);
1000
- const shoulderBandIndex = Math.floor(stripeIndex / this.shoulderBandSpan);
1001
- const roadBandIndex = Math.floor(stripeIndex / this.roadBandSpan);
1002
- const farY = Math.floor(far.y);
1003
- const nearY = Math.ceil(near.y) + 1;
1004
- if (nearY <= farY) {
1005
- continue;
1006
- }
1007
- this.drawQuad(ctx, 0, farY, width, farY, width, nearY, 0, nearY, grassBandIndex % 2 === 0 ? this.themeState.grassA : this.themeState.grassB);
1008
- const shoulderFar = far.roadWidth * 0.12;
1009
- const shoulderNear = near.roadWidth * 0.12;
1010
- const leftOuterFar = far.x - far.roadWidth / 2 - shoulderFar;
1011
- const rightOuterFar = far.x + far.roadWidth / 2 + shoulderFar;
1012
- const leftOuterNear = near.x - near.roadWidth / 2 - shoulderNear;
1013
- const rightOuterNear = near.x + near.roadWidth / 2 + shoulderNear;
1014
- const leftRoadFar = far.x - far.roadWidth / 2;
1015
- const rightRoadFar = far.x + far.roadWidth / 2;
1016
- const leftRoadNear = near.x - near.roadWidth / 2;
1017
- const rightRoadNear = near.x + near.roadWidth / 2;
1018
- this.drawQuad(ctx, leftOuterFar, farY, rightOuterFar, farY, rightOuterNear, nearY, leftOuterNear, nearY, shoulderBandIndex % 2 === 0
1019
- ? this.themeState.shoulderA
1020
- : this.themeState.shoulderB);
1021
- this.drawQuad(ctx, leftRoadFar, farY, rightRoadFar, farY, rightRoadNear, nearY, leftRoadNear, nearY, roadBandIndex % 2 === 0 ? this.themeState.roadA : this.themeState.roadB);
1022
- const laneSeparators = Math.max(0, this.laneCount - 1);
1023
- if (laneSeparators > 0 && near.roadWidth > 90 && stripeIndex % 7 < 3) {
1024
- for (let laneIndex = 1; laneIndex < this.laneCount; laneIndex++) {
1025
- const laneT = laneIndex / this.laneCount;
1026
- const markerFarX = leftRoadFar + laneT * (rightRoadFar - leftRoadFar);
1027
- const markerNearX = leftRoadNear + laneT * (rightRoadNear - leftRoadNear);
1028
- const markerFarWidth = Math.max(2, far.roadWidth * 0.028);
1029
- const markerNearWidth = Math.max(2, near.roadWidth * 0.028);
1030
- this.drawQuad(ctx, markerFarX - markerFarWidth / 2, farY, markerFarX + markerFarWidth / 2, farY, markerNearX + markerNearWidth / 2, nearY, markerNearX - markerNearWidth / 2, nearY, this.themeState.laneMarker);
1031
- }
1032
- }
1033
- }
1034
- const vignette = ctx.createLinearGradient(0, this.horizonY, 0, height);
1035
- vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
1036
- vignette.addColorStop(1, "rgba(0, 0, 0, 0.18)");
1037
- ctx.fillStyle = vignette;
1038
- ctx.fillRect(0, this.horizonY, width, height - this.horizonY);
1039
- }
1040
- /** @internal */
1041
- drawSceneObjects(ctx, width, height, current) {
1042
- const visible = [];
1043
- for (const billboard of this.billboardsState) {
1044
- const ahead = this.getLoopingAheadDistance(billboard.distance);
1045
- if (ahead <= 0 || ahead > this.drawDistance)
1046
- continue;
1047
- const projection = this.projectObject(current, ahead, billboard, width, height);
1048
- if (!projection)
1049
- continue;
1050
- visible.push({
1051
- depth: ahead,
1052
- x: projection.x,
1053
- y: projection.y,
1054
- bottom: this.getVisualBottom(billboard.visual, projection.x, projection.y, projection.scale * billboard.baseScale),
1055
- scale: projection.scale * billboard.baseScale,
1056
- alpha: billboard.alpha,
1057
- visual: billboard.visual,
1058
- kind: "billboard",
1059
- });
1060
- }
1061
- for (const traffic of this.trafficState) {
1062
- const ahead = traffic.distance - this.distanceState;
1063
- if (ahead <= -this.trafficBehindVisibilityDistance ||
1064
- ahead > this.drawDistance) {
1065
- continue;
1066
- }
1067
- const projection = this.projectTrafficPoint(current, ahead, width, height);
1068
- const x = projection.x + traffic.lane * projection.roadWidth * 0.38;
1069
- visible.push({
1070
- depth: ahead,
1071
- x,
1072
- y: projection.y,
1073
- bottom: this.getVisualBottom(traffic.visual, x, projection.y, projection.scale * traffic.baseScale),
1074
- scale: projection.scale * traffic.baseScale,
1075
- alpha: traffic.alpha,
1076
- visual: traffic.visual,
1077
- kind: "traffic",
1078
- bodyWidth: traffic.bodyWidth,
1079
- bodyLength: traffic.bodyLength,
1080
- bodyReferenceScale: traffic.baseScale,
1081
- bodyOffsetX: traffic.bodyOffsetX,
1082
- bodyOffsetY: traffic.bodyOffsetY,
1083
- resolvedBody: traffic.resolvedBody,
1084
- collisionAhead: ahead,
1085
- collisionLane: traffic.lane,
1086
- collisionCenterX: x,
1087
- });
1088
- }
1089
- const playerProjection = this.projectTrafficPoint(current, this.playerProjectionAheadDistance, width, height);
1090
- const playerCenterX = this.getPlayerScreenX();
1091
- visible.push({
1092
- depth: this.playerProjectionAheadDistance,
1093
- x: playerCenterX,
1094
- y: this.playerScreenYState,
1095
- bottom: this.getVisualBottom(this.playerVisualState, playerCenterX, this.playerScreenYState, playerProjection.scale * this.playerBaseScaleState),
1096
- scale: playerProjection.scale * this.playerBaseScaleState,
1097
- alpha: 1,
1098
- visual: this.playerVisualState,
1099
- kind: "player",
1100
- bodyWidth: this.playerBodyWidthState,
1101
- bodyLength: this.playerBodyLengthState,
1102
- bodyReferenceScale: this.playerBaseScaleState,
1103
- bodyOffsetX: this.playerBodyOffsetXState,
1104
- bodyOffsetY: this.playerBodyOffsetYState,
1105
- resolvedBody: this.playerResolvedBodyState,
1106
- collisionAhead: this.playerProjectionAheadDistance,
1107
- collisionLane: this.playerLaneState,
1108
- collisionCenterX: playerCenterX,
1109
- });
1110
- visible.sort((a, b) => {
1111
- if (a.bottom !== b.bottom) {
1112
- return a.bottom - b.bottom;
1113
- }
1114
- return b.depth - a.depth;
1115
- });
1116
- for (const entry of visible) {
1117
- const visualRect = this.drawVisual(ctx, entry.visual, entry.x, entry.y, entry.scale, entry.alpha);
1118
- if (entry.kind !== "billboard") {
1119
- this.drawCarDebugBounds(ctx, entry.kind, visualRect, entry.visual, entry.bodyReferenceScale, entry.bodyWidth, entry.bodyLength, entry.bodyOffsetX, entry.bodyOffsetY, entry.resolvedBody, current, width, height, entry.collisionAhead, entry.collisionLane, entry.collisionCenterX);
1120
- }
1121
- }
1122
- }
1123
- /** @internal */
1124
- drawVisual(ctx, visual, x, y, scale, alpha) {
1125
- const surface = this.resolveVisualSurface(visual);
1126
- const rect = this.getVisualRect(surface, x, y, scale);
1127
- ctx.save();
1128
- ctx.globalAlpha = this.clamp01(alpha);
1129
- ctx.drawImage(surface.source, rect.x, rect.y, rect.width, rect.height);
1130
- ctx.restore();
1131
- return rect;
1132
- }
1133
- /** @internal */
1134
- getVisualRect(surface, x, y, scale) {
1135
- const drawWidth = Math.max(1, surface.width * Math.max(0, scale));
1136
- const drawHeight = Math.max(1, surface.height * Math.max(0, scale));
1137
- return {
1138
- x: Math.round(x - drawWidth / 2),
1139
- y: Math.round(y - drawHeight),
1140
- width: drawWidth,
1141
- height: drawHeight,
1142
- };
1143
- }
1144
- /** @internal */
1145
- getVisualBottom(visual, x, y, scale) {
1146
- const surface = this.resolveVisualSurface(visual);
1147
- const rect = this.getVisualRect(surface, x, y, scale);
1148
- return rect.y + rect.height;
1149
- }
1150
- /** @internal */
1151
- drawCarDebugBounds(ctx, kind, visualRect, visual, bodyReferenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY, resolvedBody, current, width, height, collisionAhead, collisionLane, collisionCenterX) {
1152
- if (!this.debugState.enabled)
1153
- return;
1154
- ctx.save();
1155
- ctx.lineWidth = this.debugState.lineWidth;
1156
- ctx.setLineDash([]);
1157
- if (this.debugState.visualBounds) {
1158
- ctx.strokeStyle = this.debugState.visualBoundsColor;
1159
- ctx.strokeRect(visualRect.x, visualRect.y, visualRect.width, visualRect.height);
1160
- }
1161
- const collisionRect = this.debugState.collisionBounds &&
1162
- Number.isFinite(bodyWidth) &&
1163
- Number.isFinite(bodyLength)
1164
- ? this.getDebugCollisionRectFromVisual(visualRect, visual, bodyReferenceScale ?? 1, bodyWidth, bodyLength, bodyOffsetX ?? 0, bodyOffsetY ?? 0)
1165
- : null;
1166
- if (this.debugState.collisionBounds && collisionRect) {
1167
- ctx.strokeStyle =
1168
- kind === "player"
1169
- ? this.debugState.playerCollisionBoundsColor
1170
- : this.debugState.trafficCollisionBoundsColor;
1171
- ctx.setLineDash([6, 4]);
1172
- ctx.strokeRect(collisionRect.x, collisionRect.y, collisionRect.width, collisionRect.height);
1173
- }
1174
- const realCollisionRect = this.debugState.realCollisionBounds &&
1175
- current &&
1176
- Number.isFinite(width) &&
1177
- Number.isFinite(height) &&
1178
- Number.isFinite(bodyWidth) &&
1179
- Number.isFinite(bodyLength) &&
1180
- Number.isFinite(collisionAhead) &&
1181
- Number.isFinite(collisionLane)
1182
- ? this.getRealCollisionRect(current, width, height, collisionAhead, collisionLane, visual, bodyReferenceScale ?? 1, bodyWidth, bodyLength, bodyOffsetX ?? 0, bodyOffsetY ?? 0, resolvedBody, visualRect.x + visualRect.width / 2)
1183
- : null;
1184
- if (this.debugState.realCollisionBounds && realCollisionRect) {
1185
- ctx.strokeStyle =
1186
- kind === "player"
1187
- ? this.debugState.playerRealCollisionBoundsColor
1188
- : this.debugState.trafficRealCollisionBoundsColor;
1189
- ctx.setLineDash([2, 4]);
1190
- ctx.strokeRect(realCollisionRect.x, realCollisionRect.y, realCollisionRect.width, realCollisionRect.height);
1191
- }
1192
- ctx.restore();
1142
+ this.renderSystem.renderFrame(ctx, width, height);
1193
1143
  }
1194
1144
  /** @internal */
1195
1145
  getDebugCollisionRectFromVisual(visualRect, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
1196
- const surface = this.resolveVisualSurface(visual);
1197
- const projectedScale = Math.max(0, Math.min(visualRect.width / Math.max(1, surface.width), visualRect.height / Math.max(1, surface.height)));
1198
- const rectWidth = Math.max(4, bodyWidth * projectedScale);
1199
- const rectHeight = Math.max(8, bodyLength * projectedScale);
1200
- const offsetX = bodyOffsetX * projectedScale;
1201
- const offsetY = bodyOffsetY * projectedScale;
1202
- return {
1203
- x: visualRect.x + (visualRect.width - rectWidth) / 2 + offsetX,
1204
- y: visualRect.y + visualRect.height - rectHeight + offsetY,
1205
- width: rectWidth,
1206
- height: rectHeight,
1207
- };
1208
- }
1209
- /** @internal */
1210
- getRealCollisionRect(current, width, height, ahead, lane, visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY, resolvedBody, anchorCenterX) {
1211
- const metrics = resolvedBody ??
1212
- this.resolveCollisionBodyMetrics(visual, referenceScale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
1213
- const bodyLaneWidth = metrics.laneWidth;
1214
- const bodyDistanceLength = metrics.distanceLength;
1215
- const laneCenter = lane + metrics.laneOffset;
1216
- const bodyDistanceOffset = metrics.distanceOffset;
1217
- const depthCenter = this.getBodyDistanceCenterFromAnchor(ahead, bodyDistanceLength, bodyDistanceOffset);
1218
- const center = this.projectTrafficPoint(current, depthCenter, width, height);
1219
- const far = this.projectTrafficPoint(current, depthCenter + bodyDistanceLength * 0.5, width, height);
1220
- const near = this.projectTrafficPoint(current, depthCenter - bodyDistanceLength * 0.5, width, height);
1221
- const laneUnit = center.roadWidth * 0.38;
1222
- const centerX = Number.isFinite(anchorCenterX)
1223
- ? anchorCenterX +
1224
- this.getBodyOffsetXInScreenPixels(visual, referenceScale, bodyOffsetX)
1225
- : center.x + laneCenter * laneUnit;
1226
- const rectWidth = Math.max(4, bodyLaneWidth * laneUnit);
1227
- const top = Math.min(far.y, near.y);
1228
- const bottom = Math.max(far.y, near.y);
1229
- return {
1230
- x: centerX - rectWidth / 2,
1231
- y: top,
1232
- width: rectWidth,
1233
- height: Math.max(8, bottom - top),
1234
- };
1235
- }
1236
- /** @internal */
1237
- getPlayerScreenLaneUnit() {
1238
- return this.roadNearWidth * 0.38;
1146
+ return this.collisionSystem.getDebugCollisionRectFromVisual(visualRect, visual, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
1239
1147
  }
1240
1148
  /** @internal */
1241
- getBodyOffsetXInScreenPixels(visual, scale, bodyOffsetX) {
1242
- if (!Number.isFinite(bodyOffsetX) || bodyOffsetX === 0)
1243
- return 0;
1244
- try {
1245
- const surface = this.resolveVisualSurface(visual);
1246
- const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1247
- const visualWidth = Math.max(1, surface.width * safeScale);
1248
- return Math.max(-visualWidth * 0.5, Math.min(visualWidth * 0.5, bodyOffsetX * safeScale));
1249
- }
1250
- catch {
1251
- return 0;
1252
- }
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);
1253
1151
  }
1254
1152
  /** @internal */
1255
- resolveAutoBodyFromVisual(visual, _scale) {
1256
- try {
1257
- const surface = this.resolveVisualSurface(visual);
1258
- return {
1259
- width: Math.max(1, surface.width * AUTO_BODY_WIDTH_VISUAL_RATIO),
1260
- length: Math.max(1, surface.height),
1261
- };
1262
- }
1263
- catch {
1264
- return null;
1265
- }
1266
- }
1267
- /** @internal */
1268
- getBodyLaneWidthFromVisual(_visual, scale, bodyWidth) {
1269
- if (!Number.isFinite(bodyWidth) || bodyWidth <= 0)
1270
- return 0;
1271
- try {
1272
- const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1273
- const projectedWidth = Math.max(1, bodyWidth * safeScale);
1274
- const laneUnit = Math.max(1, this.getPlayerScreenLaneUnit());
1275
- return Math.max(0.01, projectedWidth / laneUnit);
1276
- }
1277
- catch {
1278
- return 0;
1279
- }
1280
- }
1281
- /** @internal */
1282
- getBodyLengthFromVisual(_visual, scale, bodyLength) {
1283
- if (!Number.isFinite(bodyLength) || bodyLength <= 0)
1284
- return 0;
1285
- try {
1286
- const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1287
- const projectedLength = Math.max(1, bodyLength * safeScale);
1288
- return Math.max(1, projectedLength * AUTO_BODY_LENGTH_PER_PIXEL);
1289
- }
1290
- catch {
1291
- return 0;
1292
- }
1293
- }
1294
- /** @internal */
1295
- getBodyLaneOffsetFromVisual(visual, scale, bodyOffsetX) {
1296
- if (!Number.isFinite(bodyOffsetX) || bodyOffsetX === 0)
1297
- return 0;
1298
- try {
1299
- const surface = this.resolveVisualSurface(visual);
1300
- const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1301
- const projectedOffsetX = bodyOffsetX * safeScale;
1302
- const laneUnit = Math.max(1, this.getPlayerScreenLaneUnit());
1303
- const visualWidth = Math.max(1, surface.width * safeScale);
1304
- const clampedOffsetX = Math.max(-visualWidth * 0.5, Math.min(visualWidth * 0.5, projectedOffsetX));
1305
- return clampedOffsetX / laneUnit;
1306
- }
1307
- catch {
1308
- return 0;
1309
- }
1310
- }
1311
- /** @internal */
1312
- getBodyDistanceOffsetFromVisual(visual, scale, bodyOffsetY) {
1313
- if (!Number.isFinite(bodyOffsetY) || bodyOffsetY === 0)
1314
- return 0;
1315
- try {
1316
- const surface = this.resolveVisualSurface(visual);
1317
- const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1318
- const projectedOffsetY = bodyOffsetY * safeScale;
1319
- const visualHeight = Math.max(1, surface.height * safeScale);
1320
- const clampedOffsetY = Math.max(-visualHeight * 0.5, Math.min(visualHeight * 0.5, projectedOffsetY));
1321
- return clampedOffsetY * AUTO_BODY_LENGTH_PER_PIXEL;
1322
- }
1323
- catch {
1324
- return 0;
1325
- }
1153
+ resolveAutoBodyFromVisual(visual, scale) {
1154
+ return this.collisionSystem.resolveAutoBodyFromVisual(visual, scale);
1326
1155
  }
1327
1156
  /** @internal */
1328
1157
  resolveCollisionBodyMetrics(visual, scale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY) {
1329
- return {
1330
- laneWidth: this.getBodyLaneWidthFromVisual(visual, scale, bodyWidth),
1331
- distanceLength: this.getBodyLengthFromVisual(visual, scale, bodyLength),
1332
- laneOffset: this.getBodyLaneOffsetFromVisual(visual, scale, bodyOffsetX),
1333
- distanceOffset: this.getBodyDistanceOffsetFromVisual(visual, scale, bodyOffsetY),
1334
- };
1158
+ return this.collisionSystem.resolveCollisionBodyMetrics(visual, scale, bodyWidth, bodyLength, bodyOffsetX, bodyOffsetY);
1335
1159
  }
1336
1160
  /** @internal */
1337
1161
  getBodyDistanceCenterFromAnchor(anchorDistance, bodyLength, bodyDistanceOffset) {
1338
- return anchorDistance + bodyLength * 0.5 - bodyDistanceOffset;
1162
+ return this.collisionSystem.getBodyDistanceCenterFromAnchor(anchorDistance, bodyLength, bodyDistanceOffset);
1339
1163
  }
1340
1164
  /** @internal */
1341
1165
  refreshAutoPlayerBodyFromVisual() {
@@ -1352,7 +1176,8 @@ export class ArcadeRacerEngine {
1352
1176
  }
1353
1177
  /** @internal */
1354
1178
  getPlayerScreenX() {
1355
- return this.width / 2 + this.playerLaneState * this.getPlayerScreenLaneUnit();
1179
+ return (this.width / 2 +
1180
+ this.playerLaneState * this.collisionSystem.getPlayerScreenLaneUnit());
1356
1181
  }
1357
1182
  /** @internal */
1358
1183
  resolveVisualSurface(visual) {
@@ -1361,8 +1186,8 @@ export class ArcadeRacerEngine {
1361
1186
  if (!image) {
1362
1187
  throw new Error(`MinimoJS Arcade Racer: Image '${visual.key}' is not loaded.`);
1363
1188
  }
1364
- const naturalWidth = Math.max(1, image.naturalWidth || image.width);
1365
- const naturalHeight = Math.max(1, image.naturalHeight || image.height);
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);
1366
1191
  if (visual.width && visual.height) {
1367
1192
  return {
1368
1193
  source: image,
@@ -1454,11 +1279,11 @@ export class ArcadeRacerEngine {
1454
1279
  }
1455
1280
  /** @internal */
1456
1281
  projectRoadPoint(current, ahead, width, height) {
1457
- if (this.pathPrimitives.length > 0) {
1282
+ if (this.trackSystem.hasPathPrimitives) {
1458
1283
  const distanceAhead = Math.max(0, Math.min(ahead, this.drawDistance));
1459
1284
  const t = 1 - distanceAhead / this.drawDistance;
1460
1285
  const p = t * t;
1461
- const sample = this.sampleTrack(this.distanceState + distanceAhead);
1286
+ const sample = this.trackSystem.sampleTrack(this.distanceState + distanceAhead);
1462
1287
  const dx = sample.worldX - current.worldX;
1463
1288
  const dy = sample.worldY - current.worldY;
1464
1289
  const rightX = -Math.sin(current.heading);
@@ -1481,7 +1306,7 @@ export class ArcadeRacerEngine {
1481
1306
  const distanceAhead = Math.max(0, Math.min(ahead, this.drawDistance));
1482
1307
  const t = 1 - distanceAhead / this.drawDistance;
1483
1308
  const p = t * t;
1484
- const sample = this.sampleTrack(this.distanceState + distanceAhead);
1309
+ const sample = this.trackSystem.sampleTrack(this.distanceState + distanceAhead);
1485
1310
  const relOffset = sample.lateralOffset - current.lateralOffset;
1486
1311
  const relElevation = sample.elevation - current.elevation;
1487
1312
  const roadWidth = this.lerp(this.roadFarWidth, this.roadNearWidth, p);
@@ -1494,62 +1319,12 @@ export class ArcadeRacerEngine {
1494
1319
  return { x, y, roadWidth, scale, t: p };
1495
1320
  }
1496
1321
  /** @internal */
1497
- getMinimapPathPoints() {
1498
- if (this.pathPrimitives.length > 0) {
1499
- const pathPoints = this.getPathTrackPoints();
1500
- let visiblePathPoints = pathPoints;
1501
- if (pathPoints.length >= 3) {
1502
- const first = pathPoints[0];
1503
- const last = pathPoints[pathPoints.length - 1];
1504
- const previous = pathPoints[pathPoints.length - 2];
1505
- const lastMatchesStart = Math.hypot(last.x - first.x, last.y - first.y) <= 0.001;
1506
- const previousStillOpen = Math.hypot(previous.x - first.x, previous.y - first.y) > 0.001;
1507
- if (lastMatchesStart && previousStillOpen) {
1508
- visiblePathPoints = pathPoints.slice(0, -1);
1509
- }
1510
- }
1511
- return visiblePathPoints.map((point) => ({
1512
- x: point.x,
1513
- y: point.y,
1514
- distance: point.distance,
1515
- }));
1516
- }
1517
- if (this.minimapPathCacheVersion === this.trackVersion) {
1518
- return this.minimapPathCache;
1519
- }
1520
- if (this.totalTrackLength <= 0) {
1521
- this.minimapPathCache = [];
1522
- this.minimapPathCacheVersion = this.trackVersion;
1523
- return this.minimapPathCache;
1524
- }
1525
- const sampleCount = Math.max(64, Math.round(this.totalTrackLength / 80));
1526
- const points = [{ x: 0, y: 0, distance: 0 }];
1527
- let x = 0;
1528
- let y = 0;
1529
- let heading = -Math.PI / 2;
1530
- let previousDistance = 0;
1531
- const turnFactor = 0.0034;
1532
- for (let i = 1; i <= sampleCount; i++) {
1533
- const distance = (i / sampleCount) * this.totalTrackLength;
1534
- const delta = distance - previousDistance;
1535
- const sample = this.sampleTrack(previousDistance + delta * 0.5);
1536
- heading += sample.curve * delta * turnFactor;
1537
- x += Math.cos(heading) * delta;
1538
- y += Math.sin(heading) * delta;
1539
- points.push({ x, y, distance });
1540
- previousDistance = distance;
1541
- }
1542
- this.minimapPathCache = points;
1543
- this.minimapPathCacheVersion = this.trackVersion;
1544
- return this.minimapPathCache;
1545
- }
1546
- /** @internal */
1547
1322
  sampleMinimapPoint(points, distance) {
1548
- if (points.length === 0 || this.totalTrackLength <= 0) {
1323
+ if (points.length === 0 || this.trackSystem.totalTrackLength <= 0) {
1549
1324
  return { x: 0, y: 0, distance: 0 };
1550
1325
  }
1551
- const wrapped = this.wrapDistance(distance);
1552
- const sampleT = (wrapped / this.totalTrackLength) * (points.length - 1);
1326
+ const wrapped = this.trackSystem.wrapDistance(distance);
1327
+ const sampleT = (wrapped / this.trackSystem.totalTrackLength) * (points.length - 1);
1553
1328
  const index = Math.max(0, Math.min(points.length - 1, Math.floor(sampleT)));
1554
1329
  const nextIndex = Math.min(points.length - 1, index + 1);
1555
1330
  const t = sampleT - index;
@@ -1562,361 +1337,40 @@ export class ArcadeRacerEngine {
1562
1337
  };
1563
1338
  }
1564
1339
  /** @internal */
1565
- resolvePlayerTrafficCollisions() {
1566
- const nextCollisionIds = new Set();
1567
- const playerBodyWidth = this.playerResolvedBodyState.laneWidth;
1568
- const playerBodyLength = this.playerResolvedBodyState.distanceLength;
1569
- const playerLaneCenter = this.playerLaneState + this.playerResolvedBodyState.laneOffset;
1570
- const playerBodyDistanceOffset = this.playerResolvedBodyState.distanceOffset;
1571
- const playerDistanceCenter = this.distanceState +
1572
- this.getBodyDistanceCenterFromAnchor(this.playerProjectionAheadDistance, playerBodyLength, playerBodyDistanceOffset);
1573
- for (const traffic of this.trafficState) {
1574
- const trafficBodyWidth = traffic.resolvedBody.laneWidth;
1575
- const trafficBodyLength = traffic.resolvedBody.distanceLength;
1576
- const trafficBodyDistanceOffset = traffic.resolvedBody.distanceOffset;
1577
- const trafficLaneCenter = traffic.lane + traffic.resolvedBody.laneOffset;
1578
- const trafficDistanceCenter = this.getBodyDistanceCenterFromAnchor(traffic.distance, trafficBodyLength, trafficBodyDistanceOffset);
1579
- const distanceDelta = trafficDistanceCenter - playerDistanceCenter;
1580
- const laneDelta = trafficLaneCenter - playerLaneCenter;
1581
- const hitDepth = Math.abs(distanceDelta) <
1582
- (playerBodyLength + trafficBodyLength) * 0.5;
1583
- const hitLane = Math.abs(laneDelta) <
1584
- (playerBodyWidth + trafficBodyWidth) * 0.5;
1585
- if (!hitDepth || !hitLane)
1586
- continue;
1587
- nextCollisionIds.add(traffic.id);
1588
- if (this.activeCollisionIds.has(traffic.id)) {
1589
- continue;
1590
- }
1591
- const collision = {
1592
- trafficId: traffic.id,
1593
- distanceDelta,
1594
- laneDelta,
1595
- trafficDistance: traffic.distance,
1596
- trafficLane: traffic.lane,
1597
- };
1598
- for (const handler of this.collisionHandlers) {
1599
- handler(collision);
1600
- }
1601
- }
1602
- this.activeCollisionIds = nextCollisionIds;
1603
- }
1604
- /** @internal */
1605
- getLoopingAheadDistance(targetDistance) {
1606
- if (this.totalTrackLength <= 0) {
1607
- return targetDistance - this.distanceState;
1608
- }
1609
- const currentWrapped = this.wrapDistance(this.distanceState);
1610
- const targetWrapped = this.wrapDistance(targetDistance);
1611
- let delta = targetWrapped - currentWrapped;
1612
- if (delta < 0) {
1613
- delta += this.totalTrackLength;
1614
- }
1615
- return delta;
1616
- }
1617
- /** @internal */
1618
- sampleTrack(distance) {
1619
- if (this.pathPrimitives.length > 0) {
1620
- return this.samplePathTrack(distance);
1621
- }
1622
- if (this.segments.length === 0 || this.totalTrackLength <= 0) {
1623
- return {
1624
- lateralOffset: 0,
1625
- elevation: 0,
1626
- curve: 0,
1627
- hill: 0,
1628
- worldX: 0,
1629
- worldY: 0,
1630
- heading: 0,
1631
- };
1632
- }
1633
- const raw = this.sampleRawTrack(distance);
1634
- const metrics = this.getTrackClosureMetrics();
1635
- const wrapped = this.wrapDistance(distance);
1636
- const t = this.totalTrackLength <= 0 ? 0 : Math.max(0, Math.min(1, wrapped / this.totalTrackLength));
1637
- return {
1638
- lateralOffset: raw.lateralOffset - metrics.offsetDrift * t,
1639
- elevation: raw.elevation - metrics.elevationDrift * t,
1640
- curve: raw.curve - metrics.curveDrift * t,
1641
- hill: raw.hill - metrics.hillDrift * t,
1642
- worldX: raw.lateralOffset - metrics.offsetDrift * t,
1643
- worldY: wrapped,
1644
- heading: 0,
1645
- };
1646
- }
1647
- /** @internal */
1648
- sampleRawTrack(distance) {
1649
- if (this.segments.length === 0 || this.totalTrackLength <= 0) {
1650
- return {
1651
- lateralOffset: 0,
1652
- elevation: 0,
1653
- curve: 0,
1654
- hill: 0,
1655
- worldX: 0,
1656
- worldY: 0,
1657
- heading: 0,
1658
- };
1659
- }
1660
- const wrapped = this.wrapDistance(distance);
1661
- let segment = this.segments[this.segments.length - 1];
1662
- for (const candidate of this.segments) {
1663
- if (wrapped >= candidate.start && wrapped <= candidate.end) {
1664
- segment = candidate;
1665
- break;
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;
1666
1345
  }
1667
1346
  }
1668
- const local = wrapped - segment.start;
1669
- const t = segment.length <= 0 ? 0 : Math.max(0, Math.min(1, local / segment.length));
1670
- const eased = this.easeInOut(t);
1671
- const curve = this.lerp(segment.curveFrom, segment.curveTo, eased);
1672
- const hill = this.lerp(segment.hillFrom, segment.hillTo, eased);
1673
- const localOffset = ((segment.curveFrom + curve) * 0.5) * local;
1674
- const localElevation = ((segment.hillFrom + hill) * 0.5) * local;
1675
- return {
1676
- lateralOffset: segment.offsetAtStart + localOffset,
1677
- elevation: segment.elevationAtStart + localElevation,
1678
- curve,
1679
- hill,
1680
- worldX: segment.offsetAtStart + localOffset,
1681
- worldY: wrapped,
1682
- heading: 0,
1683
- };
1347
+ return this.roadDrawerState;
1684
1348
  }
1685
1349
  /** @internal */
1686
- samplePathTrack(distance) {
1687
- const points = this.getPathTrackPoints();
1688
- if (points.length === 0 || this.totalTrackLength <= 0) {
1689
- return {
1690
- lateralOffset: 0,
1691
- elevation: 0,
1692
- curve: 0,
1693
- hill: 0,
1694
- worldX: 0,
1695
- worldY: 0,
1696
- heading: 0,
1697
- };
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;
1698
1359
  }
1699
- const wrapped = this.wrapDistance(distance);
1700
- for (let i = 1; i < points.length; i++) {
1701
- const from = points[i - 1];
1702
- const to = points[i];
1703
- if (wrapped > to.distance)
1704
- continue;
1705
- const span = Math.max(0.0001, to.distance - from.distance);
1706
- const t = Math.max(0, Math.min(1, (wrapped - from.distance) / span));
1707
- const heading = this.lerp(from.heading, to.heading, t);
1708
- const elevation = this.lerp(from.elevation, to.elevation, t);
1709
- const curve = this.lerp(from.curve, to.curve, t);
1710
- const hill = this.lerp(from.hill, to.hill, t);
1711
- const worldX = this.lerp(from.x, to.x, t);
1712
- const worldY = this.lerp(from.y, to.y, t);
1713
- return {
1714
- lateralOffset: worldX,
1715
- elevation,
1716
- curve,
1717
- hill,
1718
- worldX,
1719
- worldY,
1720
- heading,
1721
- };
1360
+ if (from < to) {
1361
+ return wrappedTrackDistance >= from && wrappedTrackDistance < to;
1722
1362
  }
1723
- const last = points[points.length - 1];
1724
- return {
1725
- lateralOffset: last.x,
1726
- elevation: last.elevation,
1727
- curve: last.curve,
1728
- hill: last.hill,
1729
- worldX: last.x,
1730
- worldY: last.y,
1731
- heading: last.heading,
1732
- };
1733
- }
1734
- /** @internal */
1735
- getPathTrackPoints() {
1736
- if (this.pathPointsCacheVersion === this.trackVersion) {
1737
- return this.pathPointsCache;
1738
- }
1739
- const points = [];
1740
- if (this.pathPrimitives.length === 0) {
1741
- this.pathPointsCache = points;
1742
- this.pathPointsCacheVersion = this.trackVersion;
1743
- return points;
1744
- }
1745
- let x = 0;
1746
- let y = 0;
1747
- let heading = 0;
1748
- let distance = 0;
1749
- let elevation = 0;
1750
- points.push({
1751
- distance,
1752
- x,
1753
- y,
1754
- heading,
1755
- elevation,
1756
- curve: 0,
1757
- hill: 0,
1758
- });
1759
- for (const primitive of this.pathPrimitives) {
1760
- if (primitive.type === "line") {
1761
- const steps = Math.max(1, Math.ceil(primitive.length / 40));
1762
- const stepLength = primitive.length / steps;
1763
- const stepElevation = primitive.elevationDelta / steps;
1764
- const hill = primitive.elevationDelta / Math.max(1, primitive.length);
1765
- for (let i = 0; i < steps; i++) {
1766
- x += Math.cos(heading) * stepLength;
1767
- y += Math.sin(heading) * stepLength;
1768
- distance += stepLength;
1769
- elevation += stepElevation;
1770
- points.push({
1771
- distance,
1772
- x,
1773
- y,
1774
- heading,
1775
- elevation,
1776
- curve: 0,
1777
- hill,
1778
- });
1779
- }
1780
- continue;
1781
- }
1782
- const arcLength = Math.abs(primitive.radius * primitive.angleRad);
1783
- const steps = Math.max(4, Math.ceil(arcLength / 32));
1784
- const stepAngle = primitive.angleRad / steps;
1785
- const stepLength = arcLength / steps;
1786
- const curve = primitive.angleRad >= 0 ? 1 / primitive.radius : -1 / primitive.radius;
1787
- const stepElevation = primitive.elevationDelta / steps;
1788
- const hill = primitive.elevationDelta / Math.max(1, arcLength);
1789
- for (let i = 0; i < steps; i++) {
1790
- const headingMid = heading + stepAngle * 0.5;
1791
- x += Math.cos(headingMid) * stepLength;
1792
- y += Math.sin(headingMid) * stepLength;
1793
- heading += stepAngle;
1794
- distance += stepLength;
1795
- elevation += stepElevation;
1796
- points.push({
1797
- distance,
1798
- x,
1799
- y,
1800
- heading,
1801
- elevation,
1802
- curve,
1803
- hill,
1804
- });
1805
- }
1806
- }
1807
- const start = points[0];
1808
- const end = points[points.length - 1];
1809
- if (Math.hypot(end.x - start.x, end.y - start.y) > 0.001) {
1810
- points.push({
1811
- distance: this.totalTrackLength,
1812
- x: start.x,
1813
- y: start.y,
1814
- heading: start.heading + Math.PI * 2,
1815
- elevation: start.elevation,
1816
- curve: 0,
1817
- hill: 0,
1818
- });
1819
- }
1820
- else {
1821
- end.x = start.x;
1822
- end.y = start.y;
1823
- end.heading = start.heading + Math.PI * 2;
1824
- end.elevation = start.elevation;
1825
- }
1826
- this.pathPointsCache = points;
1827
- this.pathPointsCacheVersion = this.trackVersion;
1828
- return this.pathPointsCache;
1829
- }
1830
- /** @internal */
1831
- getTrackClosureMetrics() {
1832
- if (this.trackClosureMetrics &&
1833
- this.trackClosureMetricsVersion === this.trackVersion) {
1834
- return this.trackClosureMetrics;
1835
- }
1836
- const start = this.sampleRawTrack(0);
1837
- const end = this.sampleRawTrack(Math.max(0, this.totalTrackLength - 0.0001));
1838
- this.trackClosureMetrics = {
1839
- startCurve: start.curve,
1840
- startHill: start.hill,
1841
- startOffset: start.lateralOffset,
1842
- startElevation: start.elevation,
1843
- endCurve: end.curve,
1844
- endHill: end.hill,
1845
- endOffset: end.lateralOffset,
1846
- endElevation: end.elevation,
1847
- curveDrift: end.curve - start.curve,
1848
- hillDrift: end.hill - start.hill,
1849
- offsetDrift: end.lateralOffset - start.lateralOffset,
1850
- elevationDrift: end.elevation - start.elevation,
1851
- };
1852
- this.trackClosureMetricsVersion = this.trackVersion;
1853
- return this.trackClosureMetrics;
1363
+ return wrappedTrackDistance >= from || wrappedTrackDistance < to;
1854
1364
  }
1855
1365
  /** @internal */
1856
- addTrackSegment(config) {
1857
- const length = Math.max(1, Number.isFinite(config.length) ? config.length : 1);
1858
- const previous = this.segments[this.segments.length - 1];
1859
- const curveFrom = previous ? previous.curveTo : 0;
1860
- const hillFrom = previous ? previous.hillTo : 0;
1861
- const curveTo = Number.isFinite(config.curve)
1862
- ? config.curve
1863
- : curveFrom;
1864
- const hillTo = Number.isFinite(config.hill)
1865
- ? config.hill
1866
- : hillFrom;
1867
- const start = previous ? previous.end : 0;
1868
- const offsetAtStart = previous
1869
- ? previous.offsetAtStart +
1870
- ((previous.curveFrom + previous.curveTo) * 0.5) * previous.length
1871
- : 0;
1872
- const elevationAtStart = previous
1873
- ? previous.elevationAtStart +
1874
- ((previous.hillFrom + previous.hillTo) * 0.5) * previous.length
1875
- : 0;
1876
- this.segments.push({
1877
- length,
1878
- curveFrom,
1879
- curveTo,
1880
- hillFrom,
1881
- hillTo,
1882
- start,
1883
- end: start + length,
1884
- offsetAtStart,
1885
- elevationAtStart,
1886
- });
1887
- this.totalTrackLength = this.segments[this.segments.length - 1].end;
1888
- this.trackVersion += 1;
1889
- }
1890
- /** @internal */
1891
- resetTrack() {
1892
- this.segments = [];
1893
- this.pathPrimitives = [];
1894
- this.pathPointsCache = [];
1895
- this.pathPointsCacheVersion = -1;
1896
- this.totalTrackLength = 0;
1897
- this.trackExplicitlyClosed = false;
1898
- this.trackVersion += 1;
1899
- this.trackClosureMetrics = null;
1900
- this.trackClosureMetricsVersion = -1;
1901
- }
1902
- /** @internal */
1903
- wrapDistance(distance) {
1904
- if (this.totalTrackLength <= 0)
1905
- return 0;
1906
- const wrapped = distance % this.totalTrackLength;
1907
- return wrapped < 0 ? wrapped + this.totalTrackLength : wrapped;
1366
+ resolvePlayerTrafficCollisions() {
1367
+ this.collisionSystem.resolvePlayerTrafficCollisions();
1908
1368
  }
1909
1369
  /** @internal */
1910
1370
  makeId(prefix) {
1911
1371
  return `${prefix}-${this.nextId++}`;
1912
1372
  }
1913
1373
  /** @internal */
1914
- clampRoadLane(value) {
1915
- if (!Number.isFinite(value))
1916
- return 0;
1917
- return Math.max(-1, Math.min(1, value));
1918
- }
1919
- /** @internal */
1920
1374
  clampPlayerLane(value) {
1921
1375
  if (!Number.isFinite(value))
1922
1376
  return 0;
@@ -1939,12 +1393,62 @@ export class ArcadeRacerEngine {
1939
1393
  return this.clamp01(normalized);
1940
1394
  }
1941
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 */
1942
1410
  getSteeringScale() {
1943
1411
  const speedRatio = this.clamp01(this.speedToMph(Math.abs(this.speedState)) / this.performanceState.topSpeedMph);
1944
1412
  const eased = Math.pow(speedRatio, this.performanceState.steeringSpeedCurvePower);
1945
1413
  return this.lerp(this.performanceState.steeringLowSpeedScale, this.performanceState.steeringHighSpeedScale, eased);
1946
1414
  }
1947
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 */
1948
1452
  mphToSpeed(mph) {
1949
1453
  if (!Number.isFinite(mph))
1950
1454
  return 0;
@@ -1996,6 +1500,9 @@ export class ArcadeRacerEngine {
1996
1500
  steeringSpeedCurvePower: Number.isFinite(options.steeringSpeedCurvePower)
1997
1501
  ? Math.max(0.1, options.steeringSpeedCurvePower)
1998
1502
  : DEFAULT_PERFORMANCE.steeringSpeedCurvePower,
1503
+ lateralDamping: Number.isFinite(options.lateralDamping)
1504
+ ? Math.max(0, options.lateralDamping)
1505
+ : DEFAULT_PERFORMANCE.lateralDamping,
1999
1506
  };
2000
1507
  }
2001
1508
  /** @internal */
@@ -2103,48 +1610,451 @@ export class ArcadeRacerEngine {
2103
1610
  lerp(a, b, t) {
2104
1611
  return a + (b - a) * t;
2105
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
+ }
2106
1745
  /** @internal */
2107
- fillRoundedRect(ctx, x, y, width, height, radius) {
2108
- const r = Math.max(0, Math.min(radius, width / 2, height / 2));
2109
- ctx.beginPath();
2110
- ctx.moveTo(x + r, y);
2111
- ctx.arcTo(x + width, y, x + width, y + height, r);
2112
- ctx.arcTo(x + width, y + height, x, y + height, r);
2113
- ctx.arcTo(x, y + height, x, y, r);
2114
- ctx.arcTo(x, y, x + width, y, r);
2115
- ctx.closePath();
2116
- ctx.fill();
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
+ }
2117
1822
  }
2118
1823
  /** @internal */
2119
- strokeRoundedRect(ctx, x, y, width, height, radius) {
2120
- const r = Math.max(0, Math.min(radius, width / 2, height / 2));
2121
- ctx.beginPath();
2122
- ctx.moveTo(x + r, y);
2123
- ctx.arcTo(x + width, y, x + width, y + height, r);
2124
- ctx.arcTo(x + width, y + height, x, y + height, r);
2125
- ctx.arcTo(x, y + height, x, y, r);
2126
- ctx.arcTo(x, y, x + width, y, r);
2127
- ctx.closePath();
2128
- ctx.stroke();
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;
2129
1838
  }
2130
1839
  /** @internal */
2131
- drawQuad(ctx, x1, y1, x2, y2, x3, y3, x4, y4, color) {
2132
- ctx.fillStyle = color;
2133
- ctx.beginPath();
2134
- ctx.moveTo(x1, y1);
2135
- ctx.lineTo(x2, y2);
2136
- ctx.lineTo(x3, y3);
2137
- ctx.lineTo(x4, y4);
2138
- ctx.closePath();
2139
- ctx.fill();
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
+ });
2140
1900
  }
2141
1901
  }
2142
- class ArcadeRacerRoadSprite extends DrawSprite {
2143
- constructor(width, height, redrawFrame) {
2144
- super(width, height);
2145
- this.redrawFrame = redrawFrame;
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;
2146
2031
  }
2147
- redraw(ctx) {
2148
- this.redrawFrame(ctx, this.width, this.height);
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);
2149
2059
  }
2150
2060
  }