minimojs 1.0.0-alpha.18 → 1.0.0-alpha.19

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.
@@ -216,6 +216,25 @@ export interface ArcadeRacerMinimapOptions {
216
216
  /** Player marker radius in pixels. Default: `4`. */
217
217
  playerRadius: number;
218
218
  }
219
+ /**
220
+ * Debug overlay settings for inspecting projected car bounds and collisions.
221
+ */
222
+ export interface ArcadeRacerDebugOptions {
223
+ /** Whether the debug overlay is rendered. Default: `false`. */
224
+ enabled: boolean;
225
+ /** Draws the actual screen-space image/emoji rectangle. Default: `true`. */
226
+ visualBounds: boolean;
227
+ /** Draws the approximate road-space collision body rectangle. Default: `true`. */
228
+ collisionBounds: boolean;
229
+ /** Stroke color for the rendered visual rectangle. Default: `rgba(80, 220, 255, 0.9)`. */
230
+ visualBoundsColor: string;
231
+ /** Stroke color for the player collision body. Default: `rgba(255, 238, 88, 0.95)`. */
232
+ playerCollisionBoundsColor: string;
233
+ /** Stroke color for traffic collision bodies. Default: `rgba(255, 82, 82, 0.95)`. */
234
+ trafficCollisionBoundsColor: string;
235
+ /** Debug stroke width in screen pixels. Default: `2`. */
236
+ lineWidth: number;
237
+ }
219
238
  /**
220
239
  * Construction options for {@link ArcadeRacerEngine}.
221
240
  */
@@ -258,9 +277,9 @@ export interface ArcadeRacerEngineOptions {
258
277
  playerBaseScale?: number;
259
278
  /** Player visual source. Default: `🏎️`. */
260
279
  playerVisual?: ArcadeRacerVisualSource;
261
- /** Player collision width in lane units. Default: `0.26`. */
280
+ /** Player collision width in lane units. Defaults to a value derived from the player visual size. */
262
281
  playerBodyWidth?: number;
263
- /** Player collision length in world distance units. Default: `120`. */
282
+ /** Player collision length in world distance units. Defaults to a value derived from the player visual size. */
264
283
  playerBodyLength?: number;
265
284
  /** Steering speed in lane units per second. Default: `1.85`. */
266
285
  steeringRate?: number;
@@ -304,6 +323,8 @@ export interface ArcadeRacerEngineOptions {
304
323
  horizon?: Partial<ArcadeRacerHorizonOptions>;
305
324
  /** Optional partial minimap overlay override. */
306
325
  minimap?: Partial<ArcadeRacerMinimapOptions>;
326
+ /** Optional debug overlay for car visual and collision bounds. */
327
+ debug?: boolean | Partial<ArcadeRacerDebugOptions>;
307
328
  /** Optional partial theme override. */
308
329
  theme?: Partial<ArcadeRacerTheme>;
309
330
  }
@@ -343,9 +364,9 @@ export interface ArcadeRacerTrafficConfig {
343
364
  speed?: number;
344
365
  /** Optional multiplier applied to the projected scale. Default: `1`. */
345
366
  baseScale?: number;
346
- /** Collision width in lane units. Default: `0.26`. */
367
+ /** Collision width in lane units. Defaults to a value derived from the traffic visual size. */
347
368
  bodyWidth?: number;
348
- /** Collision length in world distance units. Default: `120`. */
369
+ /** Collision length in world distance units. Defaults to a value derived from the traffic visual size. */
349
370
  bodyLength?: number;
350
371
  /** Whether the car should be recycled one lap ahead when it falls behind. Default: `true`. */
351
372
  loop?: boolean;
@@ -465,6 +486,8 @@ export interface ArcadeRacerTrafficCollision {
465
486
  * certain headings.
466
487
  * - Use {@link setPlayerBody} when you need to tune collisions without changing
467
488
  * the player artwork.
489
+ * - Use {@link setDebug} when you need to visualize car image bounds and
490
+ * collision bodies while tuning traffic hits.
468
491
  *
469
492
  * This interface is the canonical public surface of the module. Its JSDoc is
470
493
  * intended to be consumed directly by AI agents and tooling that generate code
@@ -683,6 +706,14 @@ export interface ArcadeRacerEngine {
683
706
  * Merges minimap settings into the current minimap config.
684
707
  */
685
708
  setMinimap(options: Partial<ArcadeRacerMinimapOptions>): void;
709
+ /**
710
+ * Enables, disables, or updates the debug overlay for car bounds.
711
+ *
712
+ * Pass `true` to enable defaults, `false` to disable, or a partial options
713
+ * object to customize colors and which rectangles are shown. The overlay draws
714
+ * visual rectangles and collision-body rectangles for the player and traffic.
715
+ */
716
+ setDebug(options: boolean | Partial<ArcadeRacerDebugOptions>): void;
686
717
  /**
687
718
  * Shows the minimap and optionally applies a partial minimap config update.
688
719
  */
@@ -45,6 +45,17 @@ const DEFAULT_MINIMAP = {
45
45
  playerStrokeColor: "#fff7dd",
46
46
  playerRadius: 4,
47
47
  };
48
+ const AUTO_BODY_WIDTH_VISUAL_RATIO = 0.76;
49
+ const AUTO_BODY_LENGTH_PER_PIXEL = 1.92;
50
+ const DEFAULT_DEBUG = {
51
+ enabled: false,
52
+ visualBounds: true,
53
+ collisionBounds: true,
54
+ visualBoundsColor: "rgba(80, 220, 255, 0.9)",
55
+ playerCollisionBoundsColor: "rgba(255, 238, 88, 0.95)",
56
+ trafficCollisionBoundsColor: "rgba(255, 82, 82, 0.95)",
57
+ lineWidth: 2,
58
+ };
48
59
  const MPH_PER_SPEED_UNIT = 0.22;
49
60
  const DEFAULT_GROUND_FILL = {
50
61
  enabled: true,
@@ -104,6 +115,10 @@ class ArcadeRacerEngineImpl {
104
115
  /** @internal */
105
116
  this.backgroundLayersState = [];
106
117
  /** @internal */
118
+ this.playerBodyWidthManual = false;
119
+ /** @internal */
120
+ this.playerBodyLengthManual = false;
121
+ /** @internal */
107
122
  this.distanceState = 0;
108
123
  /** @internal */
109
124
  this.nextId = 1;
@@ -180,16 +195,24 @@ class ArcadeRacerEngineImpl {
180
195
  playerStrokeColor: options.minimap?.playerStrokeColor ?? DEFAULT_MINIMAP.playerStrokeColor,
181
196
  playerRadius: options.minimap?.playerRadius ?? DEFAULT_MINIMAP.playerRadius,
182
197
  });
198
+ this.debugState = this.sanitizeDebugOptions(options.debug ?? false);
183
199
  this.playerVisualState =
184
200
  options.playerVisual ?? { type: "emoji", value: "🏎️", size: 48 };
185
201
  this.playerScreenYState =
186
202
  options.playerScreenY ?? Math.round(this.height * 0.86);
187
203
  this.playerBaseScaleState =
188
204
  options.playerBaseScale ?? DEFAULT_OPTIONS.playerBaseScale;
205
+ this.playerBodyWidthManual = Number.isFinite(options.playerBodyWidth);
206
+ this.playerBodyLengthManual = Number.isFinite(options.playerBodyLength);
207
+ const autoPlayerBody = this.resolveAutoBodyFromVisual(this.playerVisualState, this.playerBaseScaleState);
189
208
  this.playerBodyWidthState =
190
- options.playerBodyWidth ?? DEFAULT_OPTIONS.playerBodyWidth;
209
+ options.playerBodyWidth ??
210
+ autoPlayerBody?.width ??
211
+ DEFAULT_OPTIONS.playerBodyWidth;
191
212
  this.playerBodyLengthState =
192
- options.playerBodyLength ?? DEFAULT_OPTIONS.playerBodyLength;
213
+ options.playerBodyLength ??
214
+ autoPlayerBody?.length ??
215
+ DEFAULT_OPTIONS.playerBodyLength;
193
216
  this.speedState = options.speed ?? DEFAULT_OPTIONS.speed;
194
217
  this.playerLaneState = this.clampPlayerLane(options.playerLane ?? DEFAULT_OPTIONS.playerLane);
195
218
  this.resetTrack();
@@ -468,21 +491,23 @@ class ArcadeRacerEngineImpl {
468
491
  */
469
492
  addTraffic(visual, config) {
470
493
  const id = this.makeId("car");
494
+ const baseScale = Number.isFinite(config.baseScale)
495
+ ? Math.max(0, config.baseScale)
496
+ : 1;
497
+ const autoBody = this.resolveAutoBodyFromVisual(visual, baseScale);
471
498
  this.trafficState.push({
472
499
  id,
473
500
  visual,
474
501
  distance: Math.max(0, config.distance),
475
502
  lane: this.clampRoadLane(config.lane),
476
503
  speed: Number.isFinite(config.speed) ? config.speed : 140,
477
- baseScale: Number.isFinite(config.baseScale)
478
- ? Math.max(0, config.baseScale)
479
- : 1,
504
+ baseScale,
480
505
  bodyWidth: Number.isFinite(config.bodyWidth)
481
506
  ? Math.max(0.01, config.bodyWidth)
482
- : 0.26,
507
+ : autoBody?.width ?? 0.26,
483
508
  bodyLength: Number.isFinite(config.bodyLength)
484
509
  ? Math.max(1, config.bodyLength)
485
- : 120,
510
+ : autoBody?.length ?? 120,
486
511
  loop: config.loop ?? true,
487
512
  alpha: this.clamp01(config.alpha ?? 1),
488
513
  });
@@ -598,6 +623,12 @@ class ArcadeRacerEngineImpl {
598
623
  ...options,
599
624
  });
600
625
  }
626
+ /**
627
+ * Enables, disables, or updates car bounds debug rendering.
628
+ */
629
+ setDebug(options) {
630
+ this.debugState = this.sanitizeDebugOptions(typeof options === "boolean" ? options : { ...this.debugState, ...options });
631
+ }
601
632
  /**
602
633
  * Shows the minimap and optionally applies overrides.
603
634
  */
@@ -615,6 +646,7 @@ class ArcadeRacerEngineImpl {
615
646
  */
616
647
  setPlayerVisual(source) {
617
648
  this.playerVisualState = source;
649
+ this.refreshAutoPlayerBodyFromVisual();
618
650
  }
619
651
  /**
620
652
  * Updates the player screen-space Y position.
@@ -630,9 +662,11 @@ class ArcadeRacerEngineImpl {
630
662
  setPlayerBody(width, length) {
631
663
  if (Number.isFinite(width)) {
632
664
  this.playerBodyWidthState = Math.max(0.01, width);
665
+ this.playerBodyWidthManual = true;
633
666
  }
634
667
  if (Number.isFinite(length)) {
635
668
  this.playerBodyLengthState = Math.max(1, length);
669
+ this.playerBodyLengthManual = true;
636
670
  }
637
671
  }
638
672
  /**
@@ -642,6 +676,7 @@ class ArcadeRacerEngineImpl {
642
676
  if (!Number.isFinite(value))
643
677
  return;
644
678
  this.playerBaseScaleState = Math.max(0, value);
679
+ this.refreshAutoPlayerBodyFromVisual();
645
680
  }
646
681
  /**
647
682
  * Updates the player lane position.
@@ -1076,6 +1111,7 @@ class ArcadeRacerEngineImpl {
1076
1111
  scale: projection.scale * billboard.baseScale,
1077
1112
  alpha: billboard.alpha,
1078
1113
  visual: billboard.visual,
1114
+ kind: "billboard",
1079
1115
  });
1080
1116
  }
1081
1117
  for (const traffic of this.trafficState) {
@@ -1093,34 +1129,135 @@ class ArcadeRacerEngineImpl {
1093
1129
  scale: projection.scale * traffic.baseScale,
1094
1130
  alpha: traffic.alpha,
1095
1131
  visual: traffic.visual,
1132
+ kind: "traffic",
1133
+ bodyWidth: traffic.bodyWidth,
1134
+ bodyLength: traffic.bodyLength,
1135
+ bodyReferenceScale: traffic.baseScale,
1096
1136
  });
1097
1137
  }
1098
1138
  const playerProjection = this.projectTrafficPoint(current, this.playerProjectionAheadDistance, width, height);
1099
- const playerLaneWidth = this.roadNearWidth * 0.52;
1139
+ const playerCenterX = this.getPlayerScreenX();
1100
1140
  visible.push({
1101
1141
  depth: this.playerProjectionAheadDistance,
1102
- x: width / 2 + this.playerLaneState * playerLaneWidth,
1142
+ x: playerCenterX,
1103
1143
  y: this.playerScreenYState,
1104
1144
  scale: playerProjection.scale * this.playerBaseScaleState,
1105
1145
  alpha: 1,
1106
1146
  visual: this.playerVisualState,
1147
+ kind: "player",
1148
+ bodyWidth: this.playerBodyWidthState,
1149
+ bodyLength: this.playerBodyLengthState,
1150
+ bodyReferenceScale: this.playerBaseScaleState,
1107
1151
  });
1108
1152
  visible.sort((a, b) => b.depth - a.depth);
1109
1153
  for (const entry of visible) {
1110
- this.drawVisual(ctx, entry.visual, entry.x, entry.y, entry.scale, entry.alpha);
1154
+ const visualRect = this.drawVisual(ctx, entry.visual, entry.x, entry.y, entry.scale, entry.alpha);
1155
+ if (entry.kind !== "billboard") {
1156
+ this.drawCarDebugBounds(ctx, entry.kind, visualRect, entry.visual, entry.bodyReferenceScale, entry.bodyWidth, entry.bodyLength);
1157
+ }
1111
1158
  }
1112
1159
  }
1113
1160
  /** @internal */
1114
1161
  drawVisual(ctx, visual, x, y, scale, alpha) {
1115
1162
  const surface = this.resolveVisualSurface(visual);
1163
+ const rect = this.getVisualRect(surface, x, y, scale);
1164
+ ctx.save();
1165
+ ctx.globalAlpha = this.clamp01(alpha);
1166
+ ctx.drawImage(surface.source, rect.x, rect.y, rect.width, rect.height);
1167
+ ctx.restore();
1168
+ return rect;
1169
+ }
1170
+ /** @internal */
1171
+ getVisualRect(surface, x, y, scale) {
1116
1172
  const drawWidth = Math.max(1, surface.width * Math.max(0, scale));
1117
1173
  const drawHeight = Math.max(1, surface.height * Math.max(0, scale));
1174
+ return {
1175
+ x: Math.round(x - drawWidth / 2),
1176
+ y: Math.round(y - drawHeight),
1177
+ width: drawWidth,
1178
+ height: drawHeight,
1179
+ };
1180
+ }
1181
+ /** @internal */
1182
+ drawCarDebugBounds(ctx, kind, visualRect, visual, bodyReferenceScale, bodyWidth, bodyLength) {
1183
+ if (!this.debugState.enabled)
1184
+ return;
1118
1185
  ctx.save();
1119
- ctx.globalAlpha = this.clamp01(alpha);
1120
- ctx.drawImage(surface.source, Math.round(x - drawWidth / 2), Math.round(y - drawHeight), drawWidth, drawHeight);
1186
+ ctx.lineWidth = this.debugState.lineWidth;
1187
+ ctx.setLineDash([]);
1188
+ if (this.debugState.visualBounds) {
1189
+ ctx.strokeStyle = this.debugState.visualBoundsColor;
1190
+ ctx.strokeRect(visualRect.x, visualRect.y, visualRect.width, visualRect.height);
1191
+ }
1192
+ const collisionRect = this.debugState.collisionBounds &&
1193
+ Number.isFinite(bodyWidth) &&
1194
+ Number.isFinite(bodyLength)
1195
+ ? this.getDebugCollisionRectFromVisual(visualRect, visual, bodyReferenceScale ?? 1, bodyWidth, bodyLength)
1196
+ : null;
1197
+ if (this.debugState.collisionBounds && collisionRect) {
1198
+ ctx.strokeStyle =
1199
+ kind === "player"
1200
+ ? this.debugState.playerCollisionBoundsColor
1201
+ : this.debugState.trafficCollisionBoundsColor;
1202
+ ctx.setLineDash([6, 4]);
1203
+ ctx.strokeRect(collisionRect.x, collisionRect.y, collisionRect.width, collisionRect.height);
1204
+ }
1121
1205
  ctx.restore();
1122
1206
  }
1123
1207
  /** @internal */
1208
+ getDebugCollisionRectFromVisual(visualRect, visual, referenceScale, bodyWidth, bodyLength) {
1209
+ const autoBody = this.resolveAutoBodyFromVisual(visual, referenceScale);
1210
+ if (!autoBody)
1211
+ return null;
1212
+ const widthRatio = Math.max(0.05, bodyWidth / autoBody.width);
1213
+ const heightRatio = Math.max(0.05, bodyLength / autoBody.length);
1214
+ const rectWidth = Math.max(4, visualRect.width * AUTO_BODY_WIDTH_VISUAL_RATIO * widthRatio);
1215
+ const rectHeight = Math.max(8, visualRect.height * heightRatio);
1216
+ return {
1217
+ x: visualRect.x + (visualRect.width - rectWidth) / 2,
1218
+ y: visualRect.y + visualRect.height - rectHeight,
1219
+ width: rectWidth,
1220
+ height: rectHeight,
1221
+ };
1222
+ }
1223
+ /** @internal */
1224
+ getPlayerScreenLaneUnit() {
1225
+ return this.roadNearWidth * 0.38;
1226
+ }
1227
+ /** @internal */
1228
+ resolveAutoBodyFromVisual(visual, scale) {
1229
+ try {
1230
+ const surface = this.resolveVisualSurface(visual);
1231
+ const safeScale = Number.isFinite(scale) ? Math.max(0, scale) : 1;
1232
+ const visualWidth = Math.max(1, surface.width * safeScale);
1233
+ const visualHeight = Math.max(1, surface.height * safeScale);
1234
+ const laneUnit = Math.max(1, this.getPlayerScreenLaneUnit());
1235
+ return {
1236
+ width: Math.max(0.01, (visualWidth * AUTO_BODY_WIDTH_VISUAL_RATIO) / laneUnit),
1237
+ length: Math.max(1, visualHeight * AUTO_BODY_LENGTH_PER_PIXEL),
1238
+ };
1239
+ }
1240
+ catch {
1241
+ return null;
1242
+ }
1243
+ }
1244
+ /** @internal */
1245
+ refreshAutoPlayerBodyFromVisual() {
1246
+ const autoBody = this.resolveAutoBodyFromVisual(this.playerVisualState, this.playerBaseScaleState);
1247
+ if (!autoBody)
1248
+ return;
1249
+ if (!this.playerBodyWidthManual) {
1250
+ this.playerBodyWidthState = autoBody.width;
1251
+ }
1252
+ if (!this.playerBodyLengthManual) {
1253
+ this.playerBodyLengthState = autoBody.length;
1254
+ }
1255
+ }
1256
+ /** @internal */
1257
+ getPlayerScreenX() {
1258
+ return this.width / 2 + this.playerLaneState * this.getPlayerScreenLaneUnit();
1259
+ }
1260
+ /** @internal */
1124
1261
  resolveVisualSurface(visual) {
1125
1262
  if (visual.type === "image") {
1126
1263
  const image = this.game.getImage(visual.key);
@@ -1824,6 +1961,25 @@ class ArcadeRacerEngineImpl {
1824
1961
  };
1825
1962
  }
1826
1963
  /** @internal */
1964
+ sanitizeDebugOptions(options) {
1965
+ if (typeof options === "boolean") {
1966
+ return { ...DEFAULT_DEBUG, enabled: options };
1967
+ }
1968
+ return {
1969
+ enabled: options.enabled ?? DEFAULT_DEBUG.enabled,
1970
+ visualBounds: options.visualBounds ?? DEFAULT_DEBUG.visualBounds,
1971
+ collisionBounds: options.collisionBounds ?? DEFAULT_DEBUG.collisionBounds,
1972
+ visualBoundsColor: options.visualBoundsColor || DEFAULT_DEBUG.visualBoundsColor,
1973
+ playerCollisionBoundsColor: options.playerCollisionBoundsColor ||
1974
+ DEFAULT_DEBUG.playerCollisionBoundsColor,
1975
+ trafficCollisionBoundsColor: options.trafficCollisionBoundsColor ||
1976
+ DEFAULT_DEBUG.trafficCollisionBoundsColor,
1977
+ lineWidth: Number.isFinite(options.lineWidth)
1978
+ ? Math.max(1, options.lineWidth)
1979
+ : DEFAULT_DEBUG.lineWidth,
1980
+ };
1981
+ }
1982
+ /** @internal */
1827
1983
  normalizeAngle(angle) {
1828
1984
  if (!Number.isFinite(angle))
1829
1985
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.18",
3
+ "version": "1.0.0-alpha.19",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Emoji-only sprites, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",