-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPlayerVehicleAdapterFactory.ts
More file actions
469 lines (441 loc) · 18.7 KB
/
Copy pathPlayerVehicleAdapterFactory.ts
File metadata and controls
469 lines (441 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025-2026 Matthew Kissinger
import * as THREE from 'three';
import type { PlayerState } from '../../types';
import type { IGameRenderer, IHUDSystem } from '../../types/SystemInterfaces';
import type { PlayerCamera } from '../player/PlayerCamera';
import type { PlayerInput } from '../player/PlayerInput';
import { Emplacement } from './Emplacement';
import { EmplacementPlayerAdapter } from './EmplacementPlayerAdapter';
import {
GroundVehiclePlayerAdapter,
type IGroundVehicleModel,
} from './GroundVehiclePlayerAdapter';
import { GroundVehicleProximityChecker } from './GroundVehicleProximityChecker';
import type { IVehicle } from './IVehicle';
import {
WatercraftPlayerAdapter,
type WatercraftIVehicle,
} from './WatercraftPlayerAdapter';
import type {
PlayerVehicleAdapter,
VehicleTransitionContext,
VehicleUpdateContext,
} from './PlayerVehicleAdapter';
import { Tank } from './Tank';
import { TankPlayerAdapter } from './TankPlayerAdapter';
import type { VehicleManager } from './VehicleManager';
import { VehicleSessionController } from './VehicleSessionController';
/**
* Dependencies the boarding factory needs from its host (composer in split B).
* Kept narrow so the factory stays unit-testable: no `THREE.Scene`, no
* `GameEngine`, just the surfaces the adapters and session controller call.
*
* `hudSystem` and `gameRenderer` are optional because each adapter's
* `onEnter` / `onExit` already guards against their absence — tests can
* omit them, the composer wire (split B) supplies them.
*/
export interface PlayerVehicleAdapterFactoryDeps {
vehicleManager: VehicleManager;
vehicleSessionController: VehicleSessionController;
proximityChecker: GroundVehicleProximityChecker;
playerState: PlayerState;
input: PlayerInput;
cameraController: PlayerCamera;
hudSystem?: IHUDSystem;
gameRenderer?: IGameRenderer;
/**
* Optional: write back to the player's transform via the controller's
* canonical "I just teleported the player" path. If omitted, the factory
* falls back to mutating `playerState.position` directly — fine for tests,
* not fine for production where the streaming hooks rely on the explicit
* setter. Split B wires this.
*/
setPosition?: (position: THREE.Vector3, reason: string) => void;
/**
* Optional board-time hook fired after a successful `enterVehicle`, with
* the freshly-registered adapter + the boarded vehicle id. The composer
* uses this to attach weapon systems that live outside the adapter's
* construction surface — e.g. binding the player tank cannon
* (`TankPlayerAdapter.setCannonSystem`) or registering the player M2HB
* gunner adapter (`M2HBEmplacementSystem.attachPlayerAdapter`). Without it
* those weapon systems stay unwired and seated LMB fire is silent.
*/
onSessionEnter?: (adapter: PlayerVehicleAdapter, vehicleId: string) => void;
/**
* Optional exit-time hook fired after a successful `exitVehicle`, with the
* adapter that was just torn down + the vehicle id it released. Mirror of
* `onSessionEnter`: the composer detaches the weapon system it attached on
* board so a dismounted tank/emplacement can't keep firing through a stale
* binding.
*/
onSessionExit?: (adapter: PlayerVehicleAdapter, vehicleId: string) => void;
}
/**
* Build the per-vehicle `IGroundVehicleModel` bridge the
* `GroundVehiclePlayerAdapter` expects. M151 jeeps satisfy this surface
* directly through their `IVehicle` methods plus the wheeled-physics getter,
* but we keep the bridge here so the factory does not require a tighter
* upstream contract on `IVehicle`.
*/
function bridgeGroundModel(vehicle: IVehicle): IGroundVehicleModel | null {
const anyVehicle = vehicle as IVehicle & {
getPhysics?: () => ReturnType<IGroundVehicleModel['getPhysics']>;
setEngineActive?: (active: boolean) => void;
};
if (typeof anyVehicle.getPhysics !== 'function') return null;
return {
getVehiclePositionTo(_id, target) {
target.copy(vehicle.getPosition());
return true;
},
getVehicleQuaternionTo(_id, target) {
target.copy(vehicle.getQuaternion());
return true;
},
getPhysics(_id) {
return anyVehicle.getPhysics?.() ?? null;
},
setEngineActive(_id, active) {
anyVehicle.setEngineActive?.(active);
},
};
}
/**
* Bridge an IVehicle of category `'watercraft'` into the shape
* `WatercraftPlayerAdapter` expects. Sampan exposes the
* `id`/`position`/`quaternion`/`isGrounded` surface natively as getters,
* but PBR only exposes the IVehicle methods (`getPosition`,
* `getQuaternion`, `vehicleId`) and lacks `isGrounded` outright. The
* bridge papers over both shapes so the factory does not need to know
* which concrete hull is on the other end.
*/
function bridgeWatercraftModel(vehicle: IVehicle): WatercraftIVehicle | null {
const anyVehicle = vehicle as IVehicle & Partial<WatercraftIVehicle> & {
setControls?: (throttle: number, rudder: number) => void;
getForwardSpeed?: () => number;
setTerrain?: (terrain: any) => void;
isGrounded?: () => boolean;
};
if (
typeof anyVehicle.setControls !== 'function' ||
typeof anyVehicle.getForwardSpeed !== 'function' ||
typeof anyVehicle.setTerrain !== 'function'
) {
return null;
}
// Cache positions/quaternions to avoid allocating per access — the
// adapter reads these many times per frame for its camera pose.
return {
get id() {
return vehicle.vehicleId;
},
get position() {
return vehicle.getPosition();
},
get quaternion() {
return vehicle.getQuaternion();
},
setControls(throttle: number, rudder: number) {
anyVehicle.setControls!(throttle, rudder);
},
getForwardSpeed(): number {
return anyVehicle.getForwardSpeed!();
},
update(dt: number) {
vehicle.update(dt);
},
setTerrain(terrain) {
anyVehicle.setTerrain!(terrain);
},
isGrounded(): boolean {
// PBR omits the grounded check (deep-river craft); the adapter's
// exit-plan fallback handles `false` as "deep water dismount", which
// is the correct PBR default.
return typeof anyVehicle.isGrounded === 'function' ? anyVehicle.isGrounded!() : false;
},
};
}
/**
* Resolve which player-adapter family fits a given drivable vehicle. The
* factory dispatches on category first, then disambiguates inside `'ground'`
* (M151 wheeled vs M48 tracked) using the canonical id naming the seed-rotation
* registry hands out (`motor_pool_small_m151`, `m48_tank_of_us_fob`, etc).
*
* Pure function — exported for unit tests and for the composer to assert it
* recognises a vehicle before opening the boarding flow.
*/
export type ResolvedAdapterFamily = 'ground' | 'tank' | 'watercraft' | 'emplacement';
export function resolveAdapterFamily(vehicle: IVehicle): ResolvedAdapterFamily | null {
switch (vehicle.category) {
case 'emplacement':
return 'emplacement';
case 'watercraft':
return 'watercraft';
case 'ground': {
const id = vehicle.vehicleId;
// Tanks live in the `'ground'` category alongside wheeled jeeps. The
// M48 id pattern (`m48_*` or `*_m48_*`) is the same one the proximity
// prompt copy uses to switch labels.
if (id.startsWith('m48_') || id.includes('_m48_') || vehicle instanceof Tank) {
return 'tank';
}
return 'ground';
}
case 'helicopter':
case 'fixed_wing':
default:
return null;
}
}
/**
* Factory + dispatcher for the player-side vehicle adapters.
*
* Split A of `vekhikl-board-controller-factory` (the original task was cut
* in half after an executor died at 200k tokens). Split B wires this into
* `PlayerController` + the startup composer; this module is intentionally
* standalone so it can be unit-tested in isolation against fakes for the
* vehicle manager, session controller, and proximity checker.
*
* Lifecycle on `tryBoardNearest()`:
*
* 1. Read the currently-prompted vehicle id from the proximity checker.
* 2. Resolve the `IVehicle` instance through `VehicleManager.getVehicle`.
* 3. Dispatch on category (+ id pattern for the tank/jeep split) to pick
* a `PlayerVehicleAdapter` subclass and construct it against the live
* vehicle instance.
* 4. Lock a seat on the vehicle (`vehicle.enterVehicle('player', role)`).
* 5. Register the adapter on the session controller and call
* `session.enterVehicle(vehicleType, vehicleId, ctx)`.
*
* Lifecycle on `tryExit()`:
*
* 1. If `session.isInVehicle()` is false → no-op, returns `false`.
* 2. Build a `VehicleTransitionContext` and call `session.exitVehicle(ctx)`.
* 3. Release the seat on the underlying vehicle so NPCs can re-occupy it.
*
* Both helicopter and fixed-wing aircraft are intentionally out of scope —
* they have their own boarding paths (`HelicopterInteraction`,
* `FixedWingInteraction`) that long predate the unified session controller.
*/
export class PlayerVehicleAdapterFactory {
private readonly deps: PlayerVehicleAdapterFactoryDeps;
/**
* Adapter cache keyed by `IVehicle.vehicleId`. We rebuild the adapter on
* first board per vehicle (model wiring is per-instance), but keep the
* built adapter around so a board → exit → re-board on the same jeep
* does not allocate a fresh `GroundVehiclePlayerAdapter` each time.
*
* The session controller stores adapters by `vehicleType` string, so a
* re-register on board is needed regardless — the cache only saves the
* allocation.
*/
private readonly adapterCache = new Map<string, PlayerVehicleAdapter>();
constructor(deps: PlayerVehicleAdapterFactoryDeps) {
this.deps = deps;
// Make the session controller the single seat-truth chokepoint. Wiring the
// VehicleManager here means *every* exit path that funnels through
// `session.exitVehicle` — including Escape / requestVehicleExit, which
// never touch `tryExit()` — releases the occupied IVehicle seat exactly
// once. The heli/fixed-wing entry paths share this same session controller,
// so they get the same seat-lifecycle guarantee for free. Guarded so test
// doubles of the session controller without the setter keep working.
if (typeof this.deps.vehicleSessionController.setSeatBinder === 'function') {
this.deps.vehicleSessionController.setSeatBinder(this.deps.vehicleManager);
}
}
/**
* Resolve the nearest drivable vehicle from the proximity-prompt cache,
* dispatch by category, register the matching adapter on the session
* controller, and enter the vehicle. Returns `true` if the boarding
* round-trip completed end-to-end, `false` otherwise.
*
* Reasons this may return `false`:
* - No proximity prompt is currently up (player is not in range).
* - The prompted vehicle id no longer resolves through `VehicleManager`
* (it was unregistered between the prompt and the F-press).
* - The vehicle has no free pilot seat (NPC boarded ahead of the player).
* - The session controller refused the entry (e.g. an in-flight aircraft
* refused the swap).
*/
tryBoardNearest(): boolean {
const vehicleId = this.deps.proximityChecker.getLastShownVehicleId();
if (!vehicleId) return false;
const vehicle = this.deps.vehicleManager.getVehicle(vehicleId);
if (!vehicle) return false;
if (vehicle.isDestroyed()) return false;
const family = resolveAdapterFamily(vehicle);
if (!family) return false;
const preferredRole = family === 'emplacement' ? 'gunner' : 'pilot';
const seatIndex = vehicle.enterVehicle('player', preferredRole);
if (seatIndex === null) return false;
const adapter = this.getOrBuildAdapter(vehicle, family);
if (!adapter) {
// Failed to bridge the vehicle into the adapter's model surface — back
// the seat lock out so an NPC can mount it instead.
vehicle.exitVehicle('player');
return false;
}
this.deps.vehicleSessionController.registerAdapter(adapter);
// Snap the player onto the locked SEAT, not the chassis center. The seat's
// `localOffset` is in the vehicle's body frame; rotate it by the vehicle
// orientation and add the chassis position to get the world seat pose.
// Passing the chassis center here (the prior behavior) put the player at
// the geometric middle of the vehicle, which read as "mounted behind /
// inside" for the M151 (driver seat is forward-of-center).
const seatWorld = this.computeSeatWorldPosition(vehicle, seatIndex);
const ctx = this.buildTransitionContext(seatWorld, vehicle.vehicleId);
const entered = this.deps.vehicleSessionController.enterVehicle(
adapter.vehicleType,
vehicle.vehicleId,
ctx,
);
if (!entered) {
vehicle.exitVehicle('player');
return false;
}
// Board-time composer hook: attach weapon systems that live outside the
// adapter's construction surface (player tank cannon, M2HB gunner
// adapter). Fires only on a fully-committed session so a refused entry
// never leaves a dangling weapon binding.
this.deps.onSessionEnter?.(adapter, vehicle.vehicleId);
return true;
}
/**
* Voluntary exit; mirrors the helicopter handler shape. Returns `true`
* when the player was seated and the exit completed (which includes the
* session controller running the adapter's `onExit` hook). Returns
* `false` when the player was not in a vehicle, or when the adapter's
* exit plan blocked the dismount.
*/
tryExit(): boolean {
if (!this.deps.vehicleSessionController.isInVehicle()) return false;
const vehicleId = this.deps.vehicleSessionController.getVehicleId();
// Capture the active adapter BEFORE exitVehicle clears the session so the
// exit hook can detach whatever the enter hook attached to it.
const exitingAdapter = this.deps.vehicleSessionController.getActiveAdapter();
const ctx = this.buildTransitionContext(
this.deps.playerState.position.clone(),
vehicleId ?? undefined,
);
// The session controller now owns the IVehicle seat release (it is wired
// with the VehicleManager as its seat binder in this factory's
// constructor), so it frees the seat as part of `exitVehicle`. We no
// longer release it here — that would be a redundant double-call.
const result = this.deps.vehicleSessionController.exitVehicle(ctx, {
reason: 'input',
});
if (result.exited && exitingAdapter && vehicleId) {
this.deps.onSessionExit?.(exitingAdapter, vehicleId);
}
return result.exited;
}
trySwapSeat(): boolean {
if (!this.deps.vehicleSessionController.isInVehicle()) return false;
const adapter = this.deps.vehicleSessionController.getActiveAdapter() as (PlayerVehicleAdapter & {
swapSeat?: (ctx: VehicleUpdateContext) => unknown;
getCrewSeat?: () => string;
}) | null;
if (typeof adapter?.swapSeat !== 'function') return false;
const before = typeof adapter.getCrewSeat === 'function' ? adapter.getCrewSeat() : null;
const after = adapter.swapSeat({
deltaTime: 0,
input: this.deps.input,
cameraController: this.deps.cameraController,
hudSystem: this.deps.hudSystem,
});
const afterSeat = typeof adapter.getCrewSeat === 'function' ? adapter.getCrewSeat() : after;
return before === null || afterSeat !== before;
}
// ── Internals ──────────────────────────────────────────────────────────────
/**
* World-space position of a vehicle seat, given its local body-frame
* offset. Mirrors the seat math NPC mounting uses: rotate the seat's
* `localOffset` by the chassis orientation, then translate by the chassis
* world position. Falls back to the chassis center if the seat index does
* not resolve (defensive — `seatIndex` always comes from a successful
* `enterVehicle`, so the seat exists in practice).
*/
private computeSeatWorldPosition(
vehicle: IVehicle,
seatIndex: number,
): THREE.Vector3 {
const chassis = vehicle.getPosition().clone();
const seat = vehicle.getSeats()[seatIndex];
if (!seat) return chassis;
return seat.localOffset
.clone()
.applyQuaternion(vehicle.getQuaternion())
.add(chassis);
}
private getOrBuildAdapter(
vehicle: IVehicle,
family: ResolvedAdapterFamily,
): PlayerVehicleAdapter | null {
const cached = this.adapterCache.get(vehicle.vehicleId);
if (cached) return cached;
const built = this.buildAdapter(vehicle, family);
if (built) {
this.adapterCache.set(vehicle.vehicleId, built);
}
return built;
}
private buildAdapter(
vehicle: IVehicle,
family: ResolvedAdapterFamily,
): PlayerVehicleAdapter | null {
switch (family) {
case 'ground': {
const model = bridgeGroundModel(vehicle);
if (!model) return null;
return new GroundVehiclePlayerAdapter(model);
}
case 'tank': {
if (!(vehicle instanceof Tank)) return null;
return new TankPlayerAdapter(vehicle);
}
case 'watercraft': {
const model = bridgeWatercraftModel(vehicle);
if (!model) return null;
return new WatercraftPlayerAdapter(model);
}
case 'emplacement': {
if (!(vehicle instanceof Emplacement)) return null;
return new EmplacementPlayerAdapter(vehicle);
}
default:
return null;
}
}
private buildTransitionContext(
position: THREE.Vector3,
vehicleId?: string,
): VehicleTransitionContext {
const explicitSetter = this.deps.setPosition;
const setPosition = explicitSetter
? explicitSetter
: (p: THREE.Vector3, _reason: string): void => {
this.deps.playerState.position.copy(p);
};
// Resolve the vehicle id from the caller (the vehicle being boarded /
// exited) and fall back to the session's current id. The old code only
// read the session id, which is null on the *first* board (the session
// has not entered yet), so the adapter's `onEnter` ctx carried an empty
// `vehicleId`. The session controller re-spread the real id over the ctx
// on enter/exit, which papered over the bug — but adapters that read the
// ctx id directly (gunner-station snap reasons, future weapon wiring)
// would see ''. Threading the resolved id through fixes it at the source.
const resolvedId =
vehicleId ?? this.deps.vehicleSessionController.getVehicleId() ?? '';
return {
playerState: this.deps.playerState,
vehicleId: resolvedId,
position,
setPosition,
input: this.deps.input,
cameraController: this.deps.cameraController,
gameRenderer: this.deps.gameRenderer,
hudSystem: this.deps.hudSystem,
};
}
}