sindicate 0.16.0 → 0.17.0

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.
@@ -175,11 +175,11 @@ export class Enemy extends Character {
175
175
  // A hostile is not: killing an Enemy reports nothing, which is the self-defence rule.
176
176
  const murder = this.civilian && (this._killer == null || this._killer === this.game.player);
177
177
  super.die();
178
- if (murder) this.game.crime?.report('murder', this.position.clone());
178
+ if (murder) this.game.crime?.report?.('murder', this.position.clone());
179
179
  if (this.faction === 'ally') return;
180
180
  // outlaws bleed where they fall
181
181
  const gy = this.game.world.groundAt(this.position.x, this.position.z);
182
- this.game.vfx?.bloodDecal({ x: this.position.x, y: gy, z: this.position.z });
182
+ this.game.vfx?.bloodDecal?.({ x: this.position.x, y: gy, z: this.position.z });
183
183
  // tell the mission engine — a kill stage tallies hostiles by the mission tag stamped on them
184
184
  // when they spawned (game/missions.js), so it advances only on the RIGHT deaths.
185
185
  this.game.missions?.enemyKilled?.(this);
@@ -221,16 +221,16 @@ export class Enemy extends Character {
221
221
  speed: rg.projSpeed, range: rg.max + 12, friendly: false, shooter: this,
222
222
  });
223
223
  this.game.vfx?.muzzleFlash?.(from.addScaledVector(dir, 0.35), dir);
224
- this.game.audio?.play('gunshot');
224
+ this.game.audio?.play?.('gunshot');
225
225
  return;
226
226
  }
227
227
  const dd = dist2D(this.position.x, this.position.z, player.position.x, player.position.z);
228
228
  if (dd < 2.3 && !this.game.world.losBlocked(this.position, player.position)) { // not through a wall
229
229
  player.takeDamage(Math.round(this.def.damage * (this._dmgMul ?? 1)), this.position, { attacker: this });
230
- if (player === this.game.player) this.game.audio?.play('hurt'); // YOU took it — the full body thud
230
+ if (player === this.game.player) this.game.audio?.play?.('hurt'); // YOU took it — the full body thud
231
231
  else { // a brawl you're only near — impact, distance-faded
232
232
  const dL = dist2D(this.position.x, this.position.z, this.game.player.position.x, this.game.player.position.z);
233
- if (dL < 20) this.game.audio?.play('hit', 1 - dL / 20);
233
+ if (dL < 20) this.game.audio?.play?.('hit', 1 - dL / 20);
234
234
  }
235
235
  }
236
236
  }
@@ -271,9 +271,9 @@ export class Enemy extends Character {
271
271
  this._enraged = true;
272
272
  this.speed *= 1.3;
273
273
  this._dmgMul = 1.3;
274
- this.game.ui?.toast(`${this.name} is enraged!`);
274
+ this.game.ui?.toast?.(`${this.name} is enraged!`);
275
275
  this.game.shake?.(0.25);
276
- this.game.audio?.play('aggro');
276
+ this.game.audio?.play?.('aggro');
277
277
  this.game.vfx?.spawn?.({ pos: this.position.clone().setY(this.position.y + 1.2), count: 16, size: 0.5, life: 0.7, color: 0xff3020, spread: 2.0 });
278
278
  }
279
279
 
@@ -284,7 +284,7 @@ export class Enemy extends Character {
284
284
  if (!this.passive && d < this.def.aggroR) {
285
285
  this.state = 'chase';
286
286
  if (player === this.game.player) { // don't announce a scrap the player isn't in
287
- this.game.audio?.play('aggro');
287
+ this.game.audio?.play?.('aggro');
288
288
  this.game.hint?.('combat', 'Tab locks on. Ctrl dodge-rolls — time it as the blow lands.');
289
289
  }
290
290
  } else {
@@ -366,7 +366,7 @@ export class Enemy extends Character {
366
366
  // the WHIFF of the blow — attenuated by the listener's (player's) distance so a brawl across
367
367
  // the street is faint, not full-blast. The uppercut gets the beefier swing.
368
368
  const dL = dist2D(this.position.x, this.position.z, this.game.player.position.x, this.game.player.position.z);
369
- if (dL < 20) this.game.audio?.play(swing === 'punchUpper' ? 'swingHeavy' : 'swing', 1 - dL / 20);
369
+ if (dL < 20) this.game.audio?.play?.(swing === 'punchUpper' ? 'swingHeavy' : 'swing', 1 - dL / 20);
370
370
  // damage lands mid-swing — stored so a hit on this enemy can cancel it (interrupt)
371
371
  this._swing = { t: 0.42, kind: 'melee', target: player }; // lands via _landSwing (game-time)
372
372
  } else if (!this.busy) {
@@ -50,7 +50,7 @@ export class Footsteps {
50
50
  const F = '/assets/audio/footsteps';
51
51
  const map = {};
52
52
  for (const s of SURFACES) for (let i = 1; i <= VARIANTS; i++) map[`${s}${i}`] = `${F}/${s}${i}.wav`;
53
- this.game.audio?.loadSamples(map);
53
+ this.game.audio?.loadSamples?.(map);
54
54
  }
55
55
 
56
56
  // What is the hero standing on? A desert plain: wading water (the creek) > packed
@@ -67,7 +67,7 @@ export class Loot {
67
67
  n = Math.round(n);
68
68
  if (!n) return this.money;
69
69
  this.money = Math.max(0, this.money + n);
70
- if (n > 0) { this._bump = true; this.game.audio?.play('coin'); } // synth.js has a 'coin' case
70
+ if (n > 0) { this._bump = true; this.game.audio?.play?.('coin'); } // synth.js has a 'coin' case
71
71
  return this.money;
72
72
  }
73
73
 
@@ -153,7 +153,7 @@ export class Loot {
153
153
  // TAKEN. The float reads like a damage number because that's the beat it lands on —
154
154
  // showDamage() takes any {position} and any string (combat.js passes 'HEADSHOT').
155
155
  this.add(p.amount);
156
- this.game.ui?.showDamage({ position: p.root.position }, `$${p.amount}`);
156
+ this.game.ui?.showDamage?.({ position: p.root.position }, `$${p.amount}`);
157
157
  this._giveItems(p.items); // BEFORE _park clears the slot — the money floats, the things get a toast
158
158
  this._park(p);
159
159
  }
@@ -566,7 +566,7 @@ export class NPC extends Character {
566
566
  this.animator?.play?.('gunFire', { once: true, fade: 0.08, onDone: () => {} });
567
567
  const mz = tmpV1.set(this.position.x, this.position.y + 1.35, this.position.z);
568
568
  this.game.vfx?.muzzleFlash?.(mz, null);
569
- this.game.audio?.play('gunshot_far');
569
+ this.game.audio?.play?.('gunshot_far');
570
570
  if (Math.random() < 0.38) t.takeDamage(12, this.position, { attacker: this });
571
571
  } else if (!this.animator?.actions?.gunFire?.isRunning?.()) this.play('gunAim');
572
572
  return; // standing his ground — no follow-move this frame
@@ -135,15 +135,20 @@ export class Player extends Character {
135
135
  this.capsuleSteps = 5; // smoother capsule stepping for the player (stairs)
136
136
 
137
137
  // ---- gun state (HUD contract: weapon/aiming/ammo/ammoMax/reloading/deadEye/drawn)
138
- this.weapon = 'revolver'; // 'revolver' | 'rifle' | 'fists'
138
+ // WHATEVER THE GAME REGISTERED, not western's two names. Everything downstream already
139
+ // reads WEAPONS[this.weapon] generically; only these few lines named `revolver` and
140
+ // `rifle` — and naming them meant a game without both could not construct a Player at
141
+ // all. The starting weapon is the first registered, or 'fists' if none are.
142
+ this.weapon = Object.keys(WEAPONS)[0] ?? 'fists';
139
143
  this.aiming = false; // RMB held — ADS. NOT required to fire.
140
144
  this.drawn = false; // gun in hand vs in leather (HUD contract)
141
145
  this.reloading = false;
142
146
  this.deadEye = 1; // 0..1 meter — OWNED by game/deadeye.js (Ctrl). HUD contract.
143
147
  this.deadEyeActive = false; // slow-mo + tagging running — set by deadeye.js. HUD contract.
144
- this._mags = { revolver: WEAPONS.revolver.mag, rifle: WEAPONS.rifle.mag }; // per-weapon mag persists across Q cycling
145
- this.ammo = this._mags.revolver;
146
- this.ammoMax = WEAPONS.revolver.mag;
148
+ // per-weapon mag persists across Q cycling — one entry per weapon the game registered
149
+ this._mags = Object.fromEntries(Object.entries(WEAPONS).map(([k, w]) => [k, w.mag]));
150
+ this.ammo = this._mags[this.weapon] ?? 0;
151
+ this.ammoMax = WEAPONS[this.weapon]?.mag ?? 0;
147
152
  this._drawT = 0; // gunDraw one-shot input gate
148
153
  this._holsterT = 0; // unaimed seconds until the gun goes back to leather
149
154
  this._holstering = false; // holster clip in flight — the gun is still in hand
@@ -927,7 +932,19 @@ export class Player extends Character {
927
932
  const gunPose = !!gunW && (this.aiming || this.reloading || this._drawT > 0 || this._gunRecoilT > 0);
928
933
  if (!this.busy && !this.airborne) {
929
934
  if (this.animator._overlay) this.animator.setOverlay(null, { fade: BASE_FADE });
930
- if (this.drawn && gunW) {
935
+ // WHICH CLIP IS THE BODY PLAYING? A decision point (core/overrides.js), because the
936
+ // answer below is right for a pack that has a full-body clip for every gun state and
937
+ // wrong for one that does not: Kubold's rifle set has a single standing reload, so
938
+ // `gunReloadWalk` maps onto it and walking into a reload stops the legs dead. A game
939
+ // whose pack differs answers for itself; one that says nothing gets exactly this.
940
+ const chosen = this.game?.engine?.overrides?.ask('locomotionClip', {
941
+ moving, sprinting, drawn: this.drawn, aiming: this.aiming,
942
+ reloading: this.reloading, holstering: this._holstering,
943
+ weapon: this.weapon, animator: this.animator,
944
+ has: (n) => !!this.animator.actions[n],
945
+ });
946
+ if (chosen) this.animator.play(chosen);
947
+ else if (this.drawn && gunW) {
931
948
  if (this.reloading) this.animator.play(moving ? 'gunReloadWalk' : 'gunReload');
932
949
  else if (this._holstering) this.animator.play(moving ? 'gunWalkHolster' : 'gunHolster');
933
950
  else if (this.aiming) this.animator.play(moving ? 'gunWalkAimed' : 'gunAim');
@@ -1153,7 +1170,7 @@ export class Player extends Character {
1153
1170
  this.drawn = false; // fresh weapon starts holstered — first fire/aim plays the draw
1154
1171
  this._holstering = false;
1155
1172
  this.placeWeapons();
1156
- this.game.audio?.play('ui');
1173
+ this.game.audio?.play?.('ui');
1157
1174
  }
1158
1175
  // WHISTLE (H) — call the companion horse over. On foot only (this whole block never runs mounted).
1159
1176
  if (input.hit('KeyH') && !this.busy) this.whistle();
@@ -1252,11 +1269,11 @@ export class Player extends Character {
1252
1269
  this._fireBuf = 0; // consumed — one click, one shot
1253
1270
  this._holsterT = HOLSTER_IDLE;
1254
1271
  if (this.ammo > 0) this._fire(w, movin);
1255
- else { this.game.audio?.play('dryfire'); this._gunFireT = 0.25; }
1272
+ else { this.game.audio?.play?.('dryfire'); this._gunFireT = 0.25; }
1256
1273
  }
1257
1274
  }
1258
1275
 
1259
- if (this.drawn) this.game.ui?.aimTarget(this._aimingAtBody());
1276
+ if (this.drawn) this.game.ui?.aimTarget?.(this._aimingAtBody());
1260
1277
  if (this.drawn) this._menaceCheck(dt);
1261
1278
  if (this.aiming || this.reloading || this._drawT > 0) this._holsterT = HOLSTER_IDLE;
1262
1279
  if (this.drawn) this.game.hint?.('gun', 'Left-click to fire — hold right-click to aim down the sights. R reloads.');
@@ -1302,7 +1319,7 @@ export class Player extends Character {
1302
1319
  if (!this.drawn) { this.drawn = true; this.placeWeapons(); }
1303
1320
  if (this._holstering) this._abortHolster();
1304
1321
  this._holsterT = HOLSTER_IDLE;
1305
- this.game.audio?.play('reload');
1322
+ this.game.audio?.play?.('reload');
1306
1323
  const done = () => {
1307
1324
  if (!this.reloading) return; // cancelled — the old ammo stands
1308
1325
  this.reloading = false;
@@ -1379,7 +1396,7 @@ export class Player extends Character {
1379
1396
  // at 5 Hz meant walking all 150 souls 750 times a second to re-panic the same dozen men.
1380
1397
  if (this._menaceCool <= 0) {
1381
1398
  this._menaceCool = 4;
1382
- this.game.crime?.report('menace', best.position.clone());
1399
+ this.game.crime?.report?.('menace', best.position.clone());
1383
1400
  }
1384
1401
  }
1385
1402
 
@@ -1424,7 +1441,7 @@ export class Player extends Character {
1424
1441
  this.ammo = this._mags[this.weapon] = Math.max(0, this.ammo - 1);
1425
1442
  this.game.combat?.fireBullet?.({ from, dir, damage: w.damage, speed: 90, range: w.range, friendly: true, shooter: this });
1426
1443
  this.game.vfx?.muzzleFlash?.(from, dir);
1427
- this.game.audio?.play('gunshot');
1444
+ this.game.audio?.play?.('gunshot');
1428
1445
  // camera kick (RECOIL_KICK × recoil, eased settle — see update)
1429
1446
  this._recoilAmp = RECOIL_KICK * w.recoil;
1430
1447
  this._recoilT = RECOIL_TIME;
@@ -1527,7 +1544,7 @@ export class Player extends Character {
1527
1544
  const run = moving ? Math.min(1, (this._hspeed ?? 0) / top) : 0;
1528
1545
  this.vy = 7.2 * (1 + 0.33 * run);
1529
1546
  this.busy = true; // suppress locomotion overrides; cleared on landing
1530
- this.game.audio?.play('whoosh');
1547
+ this.game.audio?.play?.('whoosh');
1531
1548
  // start/speed/end come from CLIP_TIMING (tuned in /anim.html)
1532
1549
  this.animator.play(moving ? 'jumpRun' : 'jumpIdle', { once: true, fade: 0.03 });
1533
1550
  this.busy = false; // allow air control; anim continues as one-shot
@@ -1559,7 +1576,7 @@ export class Player extends Character {
1559
1576
  }
1560
1577
 
1561
1578
  dodge(input) {
1562
- if (this.stamina < 20) { this.game.audio?.play('whoosh'); return; } // too winded to roll
1579
+ if (this.stamina < 20) { this.game.audio?.play?.('whoosh'); return; } // too winded to roll
1563
1580
  this.stamina -= 20;
1564
1581
  this._stamPause = 1;
1565
1582
  let anim = 'dodgeF';
@@ -1568,7 +1585,7 @@ export class Player extends Character {
1568
1585
  else if (input.down('KeyD')) anim = 'dodgeR';
1569
1586
  this.busy = true;
1570
1587
  this.dodgeT = 0.7;
1571
- this.game.audio?.play('whoosh');
1588
+ this.game.audio?.play?.('whoosh');
1572
1589
  this.animator.play(anim, { once: true, fade: 0.08, timeScale: 1.25, onDone: () => { this.busy = false; } });
1573
1590
  // BURST OF MOVEMENT — WHERE YOU ARE RUNNING, not where the lens is pointed.
1574
1591
  //
@@ -1607,7 +1624,7 @@ export class Player extends Character {
1607
1624
  super.takeDamage(amount, fromPos, opts);
1608
1625
  this._combatT = Math.max(this._combatT, 4); // struck → stay in combat
1609
1626
  this.game.noteDamageTaken?.();
1610
- this.game.ui?.flashDamage();
1627
+ this.game.ui?.flashDamage?.();
1611
1628
  if (!this.alive) {
1612
1629
  // dying mid-aim/mid-reload: cancel the gun so the corpse doesn't fall with the aim
1613
1630
  // overlay blended over the death clip, and a cancelled reload can't refill on respawn
@@ -9,12 +9,31 @@ import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
9
9
  import { loadModel, loadClips } from '../core/assets.js';
10
10
  import { clamp, angleLerp } from '../core/utils.js';
11
11
 
12
+ // WHERE THE SEATED SOURCE POSE COMES FROM. Hardcoded, these are western's horse assets, and a
13
+ // seated pose is not a horse thing: a soldier in a tank hatch, a driver in a car and a rider on
14
+ // a horse all want the same retarget from the same kind of source clip. The paths are content,
15
+ // so they are injected — `setSeatedContent()` — and default to exactly what was here.
16
+ //
17
+ // This is the pattern createCar() already follows and riding.js did not: createCar takes
18
+ // surfaceAt/obstacleAt and knows nothing about any game, which is why it needed no changes to
19
+ // serve a third one.
20
+ let SEAT_RIG = '/assets/horse/Rider.fbx';
21
+ let SEAT_AUDIO = '/assets/audio/horse';
12
22
  const HA = '/assets/horse/anims';
13
- const RIDER_CLIPS = {
23
+ let RIDER_CLIPS = {
14
24
  rideIdle: `${HA}/Rider_Idle_01.fbx`,
15
25
  mountL: `${HA}/Rider_Mount_Dismount_Left.fbx`,
16
26
  mountR: `${HA}/Rider_Mount_Dismount_Right.fbx`,
17
27
  };
28
+
29
+ // A game supplies its own seated source: the rig the pose is read FROM, the clips on it, and
30
+ // where the mount/dismount audio lives. Anything omitted keeps western's.
31
+ // setSeatedContent({ rig: '/assets/mil/Soldier.fbx', clips: { rideIdle: '…/Sit_Turret.fbx' } })
32
+ export function setSeatedContent({ rig, clips, audioDir } = {}) {
33
+ if (rig) { SEAT_RIG = rig; _riderRigPromise = null; } // drop the cache — it holds the old rig
34
+ if (clips) { RIDER_CLIPS = { ...RIDER_CLIPS, ...clips }; _riderRigPromise = null; }
35
+ if (audioDir) SEAT_AUDIO = audioDir;
36
+ }
18
37
  // Rider Biped → player-rig bone map, from Unity's Humanoid avatar (R_CG is the Hips). The
19
38
  // player is the Modular Fantasy Hero rig (Hips/Shoulder/Elbow/UpperLeg naming), NOT the old
20
39
  // peasant (Pelvis/UpperArm/Thigh). Foot→Ball, Toe→Toes (modular has no separate ankle bone).
@@ -112,7 +131,7 @@ const _poseCache = new Map(); // clip url -> the loaded clip (the rig master i
112
131
  export async function makeSeatedPose(targetRoot, boneMap = BIPED_TO_PEASANT, clipUrl = null) {
113
132
  // FRESH FBX load (not the shared loadModel master — the player's Riding setup poses that and
114
133
  // never restores it, so captureBind on it would read a posed rig, not bind).
115
- if (!_riderRigPromise) _riderRigPromise = Promise.all([new FBXLoader().loadAsync('/assets/horse/Rider.fbx'), loadClips({ rideIdle: RIDER_CLIPS.rideIdle })]);
134
+ if (!_riderRigPromise) _riderRigPromise = Promise.all([new FBXLoader().loadAsync(SEAT_RIG), loadClips({ rideIdle: RIDER_CLIPS.rideIdle })]);
116
135
  const [riderMaster, rClips] = await _riderRigPromise;
117
136
  // Pick the clip into a LOCAL — never write it back into rClips. That shared object is the
118
137
  // cache: overwriting rideIdle with the chair sit meant the very next call that asked for the
@@ -244,8 +263,8 @@ export class Riding {
244
263
  _loadSfx() {
245
264
  if (this._sfxLoaded) return;
246
265
  this._sfxLoaded = true;
247
- const H = '/assets/audio/horse';
248
- this.game.audio?.loadSamples({
266
+ const H = SEAT_AUDIO;
267
+ this.game.audio?.loadSamples?.({
249
268
  step1: `${H}/step1.m4a`, step2: `${H}/step2.m4a`, step3: `${H}/step3.m4a`, step4: `${H}/step4.m4a`, step5: `${H}/step5.m4a`,
250
269
  whinny1: `${H}/whinny1.m4a`, whinny2: `${H}/whinny2.m4a`, whinny3: `${H}/whinny3.m4a`,
251
270
  neigh: `${H}/neigh.m4a`, neigh2: `${H}/neigh2.m4a`, neigh3: `${H}/neigh3.m4a`,
@@ -288,7 +307,7 @@ export class Riding {
288
307
  async setup() {
289
308
  const player = this.player.model;
290
309
  const [riderRig, rClips] = await Promise.all([
291
- loadModel('/assets/horse/Rider.fbx'),
310
+ loadModel(SEAT_RIG),
292
311
  loadClips(RIDER_CLIPS),
293
312
  ]);
294
313
  for (const b of realBonesOf(player)) this.pbone[b.name] = b; // first-occurrence bones (modular dupes)
@@ -395,7 +414,7 @@ export class Riding {
395
414
  if (this.game.ownHorse && this.game.ownHorse !== horse) this.game.ownHorse.owner = null;
396
415
  this.game.ownHorse = horse; horse.owner = this.player;
397
416
  this.mode = 'mounting'; this.mountT = 0; this.speed = 0;
398
- this._loadSfx(); this.game.audio?.sample('snort', { gain: 0.5 }); // silent the very first mount (samples still decoding)
417
+ this._loadSfx(); this.game.audio?.sample?.('snort', { gain: 0.5 }); // silent the very first mount (samples still decoding)
399
418
  this.startClimb(this.climbSide());
400
419
  this.besidePos(this.player.position);
401
420
  this.player.root.rotation.y = horse.heading;
@@ -521,7 +540,7 @@ export class Riding {
521
540
  this.besidePos(this.player.root.position);
522
541
  this.player.heading = horse.heading; this.player.root.rotation.set(0, horse.heading, 0); // clear saddle tilt
523
542
  this._seatQuat = null; this._reinLoc = null; // recapture seat + rein hands on the next mount
524
- this.game.audio?.sample('snort', { gain: 0.45 });
543
+ this.game.audio?.sample?.('snort', { gain: 0.45 });
525
544
  horse.rider = null; horse.speed = 0;
526
545
  this.horse = null; this.mode = 'foot';
527
546
  this.player.vy = 0; this.player._capGrounded = false;
@@ -213,7 +213,7 @@ export class Shop {
213
213
  _buy(id) {
214
214
  if (this._curStock(id) <= 0) { this.ui.toast?.(`He's clean out of that.`); this.game.audio?.play?.('ui'); return; }
215
215
  const price = SH.buyPrice(id);
216
- if (!this.game.loot?.spend(price)) { // spend() returns false and takes nothing if he's short
216
+ if (!this.game.loot?.spend?.(price)) { // spend() returns false and takes nothing if he's short
217
217
  this.ui.toast?.(`You're short the coin for that.`);
218
218
  this.game.audio?.play?.('ui');
219
219
  return;
@@ -229,7 +229,7 @@ export class Shop {
229
229
  if (!this.ui.satchel.has(id, 1)) return;
230
230
  const price = SH.sellPrice(id);
231
231
  this.ui.satchel.remove(id, 1);
232
- this.game.loot?.add(price); // add() plays the coin chime
232
+ this.game.loot?.add?.(price); // add() plays the coin chime
233
233
  this.ui.toast?.(`Sold ${SH.items[id].name} — $${price}.`);
234
234
  this._rebuild();
235
235
  }
@@ -34,10 +34,23 @@ import { SeatedRider, SEAT, passengerPosture, driverPosture } from './seatedRide
34
34
  // setTrainContent({ colliders, dropItemsFor, railPaths, railStops, crossing, halts })
35
35
  let colliders = [], dropItemsFor = () => null;
36
36
  let SURVEY_PATHS = [], SURVEY_STOPS = [], SURVEY_CROSSING = null, RAIL_HALTS = [];
37
+ // KEYS THIS ACCEPTS AND NEVER READS. `railPaths`, `railStops` and `crossing` are assigned to
38
+ // module state that nothing consults — a game supplying them gets silence, and no way to tell
39
+ // that from working. They are kept (removing them would break callers that pass them today)
40
+ // and now say so once, so the next game to reach for one finds out at the call rather than by
41
+ // wondering why its level crossings do nothing.
42
+ const DEAD = new Set();
43
+ const dead = (k) => {
44
+ if (DEAD.has(k)) return;
45
+ DEAD.add(k);
46
+ console.warn(`[train] setTrainContent({ ${k} }) is accepted but never read — it has no effect. `
47
+ + 'Use railLines / railLineStops / levelCrossing / junction / railTop / halts.');
48
+ };
49
+
37
50
  export function setTrainContent(c = {}) {
38
51
  if (c.colliders) colliders = c.colliders;
39
52
  if (c.dropItemsFor) dropItemsFor = c.dropItemsFor;
40
- if (c.railPaths) SURVEY_PATHS = c.railPaths;
53
+ if (c.railPaths) { SURVEY_PATHS = c.railPaths; dead('railPaths'); }
41
54
  // THE TRACK AUTHOR'S OWN SURVEY. Without it the fleet grades its own polyline and runs
42
55
  // beside the rails the county actually laid.
43
56
  if (c.railLines) LINES_IN = c.railLines;
@@ -45,8 +58,8 @@ export function setTrainContent(c = {}) {
45
58
  if (c.levelCrossing) XING_IN = c.levelCrossing;
46
59
  if (c.junction) JUNC_IN = c.junction;
47
60
  if (c.railTop != null) { RAIL_TOP = c.railTop; RAIL_Y = RAIL_TOP - FLANGE; }
48
- if (c.railStops) SURVEY_STOPS = c.railStops;
49
- if (c.crossing !== undefined) SURVEY_CROSSING = c.crossing;
61
+ if (c.railStops) { SURVEY_STOPS = c.railStops; dead('railStops'); }
62
+ if (c.crossing !== undefined) { SURVEY_CROSSING = c.crossing; dead('crossing'); }
50
63
  if (c.halts) RAIL_HALTS = c.halts;
51
64
  }
52
65
  import { dist2D } from '../core/utils.js';
@@ -164,7 +164,10 @@ export function createCar({ surfaceAt, obstacleAt = null, x = 0, y = 0, z = 0, h
164
164
  const seatY = surfaceAt?.(x, z, y);
165
165
  const car = {
166
166
  cfg,
167
- pos: { x, y: (seatY ?? y) + CAR_DEFAULTS.rideHeight, z },
167
+ // cfg, NOT CAR_DEFAULTS. Every later frame uses cfg.rideHeight, so spawning from the
168
+ // default drops a vehicle with a custom one at the wrong height and pops it up on the
169
+ // first update — a tank at 0.9 starts 0.56 m low and jumps.
170
+ pos: { x, y: (seatY ?? y) + cfg.rideHeight, z },
168
171
  heading, // where the car POINTS
169
172
  travel: heading, // where it is actually GOING — the two differ when it slides
170
173
  speed: 0,