sindicate 0.4.0 → 0.5.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.
- package/package.json +1 -1
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
// THE STAGECOACH LINE — Dustwater's public transport.
|
|
2
|
+
//
|
|
3
|
+
// A coach is a box on four wheels, a team of two, and a man on the box. It runs a ROUTE between
|
|
4
|
+
// the places on the map, and it does the one thing that makes a coach feel like a service rather
|
|
5
|
+
// than set dressing: IT CALLS AT TOWN. It rolls in off the north road, pulls up at the depot,
|
|
6
|
+
// the doors swing open, whoever was riding gets down and walks away into the town, whoever was
|
|
7
|
+
// waiting climbs aboard, the doors shut, the driver takes them out again — and later you meet
|
|
8
|
+
// the same coach on the road between two places, doing exactly what it said it was doing.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS AUTHORED, WHAT IS DERIVED:
|
|
11
|
+
// coachRig.json — where the horses and the driver sit, RELATIVE TO THE COACH (tuned in
|
|
12
|
+
// /coachtest.html, because that relationship is a thing you look at, not a
|
|
13
|
+
// thing you calculate).
|
|
14
|
+
// ROUTE — the stops, here. Everything else — headings, gaits, wheel spin, when a
|
|
15
|
+
// passenger opens a door — falls out of the coach's own motion.
|
|
16
|
+
//
|
|
17
|
+
// PERF: everything is built ONCE, on the loading screen, and stays resident for life — the same
|
|
18
|
+
// law the horses and the townsfolk obey (main.js:118: adding or toggling skinned meshes during
|
|
19
|
+
// play triggers pipeline setup and multi-second freezes). Passengers do not spawn when a coach
|
|
20
|
+
// arrives; they are already aboard, riding along inside the box with their models hidden, and
|
|
21
|
+
// "getting out" is them becoming visible at the door and walking off.
|
|
22
|
+
import * as THREE from 'three/webgpu';
|
|
23
|
+
import { instantiate, loadClips, loadModel } from '../core/assets.js';
|
|
24
|
+
import { RawAnimator } from '../core/anim.js';
|
|
25
|
+
import { SeatedRider, SEAT, passengerPosture, driverPosture } from './seatedRider.js';
|
|
26
|
+
import { rayCapsule } from './aim.js';
|
|
27
|
+
import { makeSeatedPose, BIPED_TO_SYNTY } from './riding.js';
|
|
28
|
+
// Coach content is GAME data, registered before construction (partials merge):
|
|
29
|
+
// setCoachContent({ characters, colliders, dropItemsFor, rig, route })
|
|
30
|
+
// route = { ROUTE, LEGS, TOTAL_LEN, placeAt, LANE, EASE_M } (from the game's route derivation)
|
|
31
|
+
let CHARACTERS = {}, colliders = [], dropItemsFor = () => null, RIG = {};
|
|
32
|
+
let ROUTE = [], LEGS = [], TOTAL_LEN = 0, placeAt = () => null, LANE = 1.6, EASE_M = 6;
|
|
33
|
+
export function setCoachContent(c = {}) {
|
|
34
|
+
if (c.characters) CHARACTERS = c.characters;
|
|
35
|
+
if (c.colliders) colliders = c.colliders;
|
|
36
|
+
if (c.dropItemsFor) dropItemsFor = c.dropItemsFor;
|
|
37
|
+
if (c.rig) RIG = c.rig;
|
|
38
|
+
if (c.route) ({ ROUTE = ROUTE, LEGS = LEGS, TOTAL_LEN = TOTAL_LEN, placeAt = placeAt, LANE = LANE, EASE_M = EASE_M } = c.route);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// WHO IS ON THE COACH TODAY. Not a fixed four: the same faces riding the same box past you every
|
|
42
|
+
// half hour is the thing that turns a service into a prop. So each coach draws its own fares from
|
|
43
|
+
// the pool, in their own clothes, and how many are actually aboard changes at every stop —
|
|
44
|
+
// sometimes the coach rolls through empty, sometimes it is full.
|
|
45
|
+
export const FARE_POOL = [
|
|
46
|
+
'Character_Woman_01', 'Character_Salesman_Male_01', 'Character_Priest_Male_01', 'Character_Cowgirl_01',
|
|
47
|
+
'Character_Business_Man_01', 'Character_Traveller_Female_01', 'Character_GoldMiner_Male_01',
|
|
48
|
+
'Character_Mexican_Male_01', 'Character_Mexican_Female_01', 'Character_Sheriff_01',
|
|
49
|
+
];
|
|
50
|
+
// The Synty bodies are UV'd for their own pack's atlas, but the pack ships several colourways of
|
|
51
|
+
// it — so the same body in a different atlas is a different person in different clothes, for free.
|
|
52
|
+
export const FARE_SKINS = [
|
|
53
|
+
'/assets/textures/atlas_western.png', '/assets/textures/atlas_western_b.png',
|
|
54
|
+
'/assets/textures/atlas_western_c.png', '/assets/textures/atlas_western_alt1.png',
|
|
55
|
+
'/assets/textures/atlas_western_alt2.png', '/assets/textures/atlas_western_alt3.png',
|
|
56
|
+
];
|
|
57
|
+
export const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
58
|
+
|
|
59
|
+
// Draw a fresh coachload: n different bodies, each in a colourway of its own. Shared with
|
|
60
|
+
// /coachtest.html so the fares you shuffle in the bench are the fares the game actually runs.
|
|
61
|
+
export function castFares(n) {
|
|
62
|
+
const cast = [...FARE_POOL].sort(() => Math.random() - 0.5).slice(0, n); // no twins aboard
|
|
63
|
+
return cast.map((id) => {
|
|
64
|
+
const known = CHARACTERS.find((c) => c.id === id) ?? CHARACTERS[0];
|
|
65
|
+
return { id, src: known.src, keep: id, tex: pick(FARE_SKINS) };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
import { dist2D } from '../core/utils.js';
|
|
69
|
+
import { alarmTown } from './npc.js';
|
|
70
|
+
export { ROUTE }; // the timetable's shape is public, as it always was — it just isn't authored here
|
|
71
|
+
|
|
72
|
+
const COACH_URL = '/assets/western/SM_Veh_Stagecoach_01.fbx';
|
|
73
|
+
const ATLAS = '/assets/textures/atlas_western.png';
|
|
74
|
+
const HORSE_URL = '/assets/horse/SyntyHorse.fbx';
|
|
75
|
+
// Six coats in the pack. A team of two identical horses, on every coach, forever, is the kind of
|
|
76
|
+
// thing you stop seeing after a week and a player notices in ten seconds — so each coach draws its
|
|
77
|
+
// own pair. Deliberately allowed to draw the same coat twice: a MATCHED team is a real thing a
|
|
78
|
+
// stage line would be proud of, and forbidding it would be a different kind of wrong.
|
|
79
|
+
const HORSE_COATS = [
|
|
80
|
+
'/assets/textures/horse_01.png', '/assets/textures/horse_02.png', '/assets/textures/horse_03.png',
|
|
81
|
+
'/assets/textures/horse_04.png', '/assets/textures/horse_05.png', '/assets/textures/horse_06.png',
|
|
82
|
+
];
|
|
83
|
+
const HORSE_TEX = HORSE_COATS[0];
|
|
84
|
+
const HA = '/assets/horse/anims';
|
|
85
|
+
// A man on a BENCH, not on a horse: the Malbers chair sit (knees forward, feet down).
|
|
86
|
+
const SIT_CLIP = `${HA}/Sit_Loop.fbx`;
|
|
87
|
+
const DRIVER_DROOP = 0.3; // radians: a coachman's hands rest lower than a rider's (tuned in /coachtest.html)
|
|
88
|
+
|
|
89
|
+
// Road speeds, metres/sec — the same numbers the bench rolls at, so what you tuned is what runs.
|
|
90
|
+
const GAIT_SPEED = { idle: 0, walk: 1.6, trot: 3.4, canter: 6.5, gallop: 10.5 };
|
|
91
|
+
// A CANTER, NOT A TROT — AND THAT IS THE COUNTY DOUBLING, NOT A TASTE CHANGE.
|
|
92
|
+
// The circuit (depot → fort → depot → mission → depot) was ~1,703 m and is ~3,406 m now. At a trot
|
|
93
|
+
// (3.4 m/s) plus four 24 s stands that is an EIGHTEEN AND A HALF MINUTE lap, and with two coaches you
|
|
94
|
+
// stand at the depot for nine minutes waiting for one. Unplayable. At a canter (6.5) with three coaches
|
|
95
|
+
// (main.js: build(3)) it is a 10.3-minute lap and a coach at the depot every ~3.4 minutes — better than
|
|
96
|
+
// it was before the resize. It is also simply more honest: a stage trotted through town and MADE TIME on
|
|
97
|
+
// the open road, which is exactly what it now does. (Traffic braking is unchanged: LOOK scales with
|
|
98
|
+
// speed, so she still sees further the faster she goes.)
|
|
99
|
+
const CRUISE = 'canter'; // a working coach makes time on the open road; it does not gallop
|
|
100
|
+
const STOP_SECS = 24; // how long it stands at a stop
|
|
101
|
+
const DOOR_HOLD = 20; // ...of which the door stands OPEN for this long
|
|
102
|
+
const DOOR_OPEN = 1.45; // radians the door swings
|
|
103
|
+
const DOOR_SECS = 0.8; // and how long the swing takes
|
|
104
|
+
|
|
105
|
+
// TRAFFIC. A coach that drives through you, through the townsfolk and through the other coach is
|
|
106
|
+
// not a vehicle, it is a poster of a vehicle sliding along a rail.
|
|
107
|
+
// (LANE — metres right of the route line each coach drives — lives in coachRoute.js now, beside
|
|
108
|
+
// the polylines it offsets and the deck that must cover it. The keep-right story is told there:
|
|
109
|
+
// the return leg runs the same line the other way, so offsetting each direction to ITS OWN right
|
|
110
|
+
// puts them on opposite sides of the road and they pass instead of sharing one strip of dirt.)
|
|
111
|
+
const LOOK = 6.0; // metres it looks ahead even at a DEAD STOP...
|
|
112
|
+
const LOOK_PER_MS = 1.0; // ...plus this much per m/s: a trotting coach looks ~9m ahead
|
|
113
|
+
const HOLD_PAD = 1.5; // once stopped, the way must be clear by THIS much more before she pulls
|
|
114
|
+
// away. Without it the look-ahead shrinks as she slows, so she stops seeing
|
|
115
|
+
// the man in front of her, pulls away, sees him again, stops — and creeps
|
|
116
|
+
// into him at 1.8m/s forever. Hysteresis, not a bigger number.
|
|
117
|
+
const CORRIDOR = 2.0; // how wide the road ahead has to be clear (half-width, metres)
|
|
118
|
+
const YIELD_NEAR = 7.0; // people inside this are STRAGGLERS the coach swings round; further out it
|
|
119
|
+
// keeps rolling — they have been shouted at and they are clearing
|
|
120
|
+
const YELL_FLEE = 1.3; // seconds of perpendicular scurry per shout (re-upped while in the corridor)
|
|
121
|
+
const PASS_SHIFT = 2.6; // opposing coaches: how far each pulls to her own right to pass
|
|
122
|
+
const BRAKE = 3.2; // how hard it hauls up (1/s). A loaded coach does not stop dead.
|
|
123
|
+
const PULL = 0.6; // and how lazily it gets going again
|
|
124
|
+
const TURN_RATE = 0.9; // radians/sec at speed, when YOU are driving — she is four tons on wood
|
|
125
|
+
const REVERSE = 1.4; // m/s: what a team will back a coach at, and no more. She gets a reverse because
|
|
126
|
+
// she now has COLLISION (BODY, below) — and without one, a coach nosed into the
|
|
127
|
+
// front of the saloon is a coach that can never be driven again: the steering
|
|
128
|
+
// bites off her speed, and her speed against a wall is nought.
|
|
129
|
+
|
|
130
|
+
// HER BODY, for the world to push on. Four discs down her long axis: the box is 2.4 x 5.0 m (col:
|
|
131
|
+
// hx 1.2, hz 2.5) and the team is hitched 3.5 m out in front of it (coachRig.json), so the thing
|
|
132
|
+
// that actually reaches a wall first is a horse's nose, and leaving the team out would sink two
|
|
133
|
+
// horses into the planking before anything touched. Local +z is forward — the same frame
|
|
134
|
+
// _syncColliders already writes her horse colliders in.
|
|
135
|
+
const BODY = [
|
|
136
|
+
{ z: 3.5, r: 1.05 }, // the team
|
|
137
|
+
{ z: 1.7, r: 1.20 }, // the box and the fore-carriage
|
|
138
|
+
{ z: 0.0, r: 1.20 },
|
|
139
|
+
{ z: -1.7, r: 1.20 }, // the boot
|
|
140
|
+
];
|
|
141
|
+
const _push = new THREE.Vector3();
|
|
142
|
+
|
|
143
|
+
// THE ROOF. Sprint at her, jump, and you are riding on top — see game/platform.js, which owns the
|
|
144
|
+
// primitive; the coach is only its first client.
|
|
145
|
+
//
|
|
146
|
+
// MEASURED, not guessed: the body mesh's top surface rasterised in coach-local metres (a 10cm grid,
|
|
147
|
+
// max triangle Y per cell). The cabin top is DEAD FLAT at y = 2.71 from x -1.02..+1.08 and
|
|
148
|
+
// z -1.27..+1.13, with a luggage rail 22cm proud of it around the rim. The rect below is pulled in
|
|
149
|
+
// from that: off the rail at the sides and the back, and stopped short at the FRONT, because the
|
|
150
|
+
// driver's pivot is at z 1.385 and his head sits 0.49m above the deck — walk any further and your
|
|
151
|
+
// boots go through the coachman's hat. (Nothing would push you apart: a SeatedRider is in none of
|
|
152
|
+
// separateFromPeople's lists.)
|
|
153
|
+
const ROOF = { x0: -0.90, x1: 0.90, z0: -1.15, z1: 0.70, y: 2.71, surface: 'stone' };
|
|
154
|
+
// ...and the BODY, as a wall for an entity only — never a collider, so it cannot stop a bullet and
|
|
155
|
+
// the band that lets you shoot the driver (below) is preserved by construction. Its floor is the
|
|
156
|
+
// footboard, where `col` stops. Its CEILING is the roof less the ledge-catch reach, which leaves
|
|
157
|
+
// the top 0.85m of her open: that is the lip, and a man whose feet are up there is grabbing it, not
|
|
158
|
+
// bouncing off it. Below that you are hitting the side of a coach, and you fall.
|
|
159
|
+
const HULL = { x0: -1.10, x1: 1.10, z0: -1.30, z1: 1.15, y0: 1.50, y1: ROOF.y - 0.85 };
|
|
160
|
+
|
|
161
|
+
// SHOOT THE MAN ON THE BOX.
|
|
162
|
+
// Horses do not stand politely over a dead coachman. The reins go slack, the shot goes off behind
|
|
163
|
+
// their ears, and they BOLT: she runs flat out with nobody steering — past the depot without
|
|
164
|
+
// calling, straight through the traffic she would have braked for — until the team blows itself
|
|
165
|
+
// out and drags her to a halt in the middle of the road with a corpse folded over the box. If the
|
|
166
|
+
// guard beside him lived, he takes the reins from where he sits and the line goes on. If he did
|
|
167
|
+
// not, that coach never runs again, and the road has a derelict on it. That is the price.
|
|
168
|
+
const BOLT_SECS = 14; // how long a panicked team can hold a flat canter before it is blown
|
|
169
|
+
const BOLT_RUSH = 2.4; // and how violently it gets there (PULL is 0.6 — this is not a working start)
|
|
170
|
+
const DRAG_DOWN = 0.8; // 1/s: a blown team hauling itself up is far slower than a driver braking (BRAKE 3.2)
|
|
171
|
+
const REINS_SECS = 3.5; // how long the guard looks at the body before he climbs onto the leather
|
|
172
|
+
|
|
173
|
+
// WHAT THEY CARRY. The driver has the takings — the whole reason a man ever shot a coachman — the
|
|
174
|
+
// guard beside him has his wages, and a fare has whatever was in his pocket.
|
|
175
|
+
const TAKINGS = [70, 130];
|
|
176
|
+
const WAGES = [18, 40];
|
|
177
|
+
const FARE_PURSE = [4, 22];
|
|
178
|
+
const roll = ([a, b]) => a + Math.floor(Math.random() * (b - a + 1));
|
|
179
|
+
|
|
180
|
+
// THE LINE. Neither the stops nor the waypoints are declared here. The stops come from
|
|
181
|
+
// world/data/coachStop.js — one list, two consumers, so the timetable and the stage buildings can
|
|
182
|
+
// never drift apart — and the LEGS BETWEEN THEM come from the ROAD GRAPH (coachRoute.js, imported
|
|
183
|
+
// above): the same net the minimap routes on, snapped at the stops, thinned, and threaded through
|
|
184
|
+
// the bridge chord because the chord IS the spliced road. The generations of hand-authored
|
|
185
|
+
// waypoints this block used to carry — the ±2 m offsets that fought the lane logic, the 'Town
|
|
186
|
+
// North' on-ramp with its site-local scaling trap, the BRIDGE_SPOTS-derived chord pair — all
|
|
187
|
+
// retire BY DERIVATION: the on-ramp is the route's own ≤6 m kerb stub (from live STOPS + live
|
|
188
|
+
// ROADS, scale-proof by construction), the crossing falls out of the road line with 0.00 m of
|
|
189
|
+
// deviation, and a leg that leaves the graded dirt cannot be authored at all. The complaint that
|
|
190
|
+
// forced this ("has just taken me through a building… and now across a field") lived on ONE
|
|
191
|
+
// straight 385 m leg that no waypoint patch was ever going to keep fixed; scripts/verify-coach.mjs
|
|
192
|
+
// now walks every driven lane line at 1 m and fails the build on the first off-road metre.
|
|
193
|
+
|
|
194
|
+
// One coach: the box, its team, its driver, and whoever is riding inside it.
|
|
195
|
+
class Coach {
|
|
196
|
+
constructor(game, leg = 0, t = 0) {
|
|
197
|
+
this.game = game;
|
|
198
|
+
this.leg = leg; // which stretch of the route it is on
|
|
199
|
+
this.t = t; // progress along that stretch, 0..1
|
|
200
|
+
this.state = 'rolling'; // rolling | standing
|
|
201
|
+
this.wait = 0;
|
|
202
|
+
this.doorK = 0; // 0 shut … 1 open (eased)
|
|
203
|
+
this.speed = 0;
|
|
204
|
+
this.root = new THREE.Group();
|
|
205
|
+
this.passengers = []; // NPCs riding inside
|
|
206
|
+
this.bolt = 0; // >0 = the team has run away with her (the driver is dead)
|
|
207
|
+
this.spent = false; // ...and now they're blown: dragging to a halt in the road
|
|
208
|
+
this.reinsT = 0;
|
|
209
|
+
this.crew = []; // who is aboard and shootable — combat.js walks this
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async load() {
|
|
213
|
+
const coach = await instantiate(COACH_URL, { texture: ATLAS }); // GLB twin: already metres
|
|
214
|
+
this.root.add(coach);
|
|
215
|
+
// Wheels and doors are separate nodes, but every node sits at the origin with its geometry
|
|
216
|
+
// baked out at its real place — so re-pivot each one onto its own hub/hinge before turning
|
|
217
|
+
// it, or it swings around the coach's centre like a hammer throw. (Same as /coachtest.html.)
|
|
218
|
+
this.wheels = [];
|
|
219
|
+
this.doors = [];
|
|
220
|
+
const box = new THREE.Box3(), c = new THREE.Vector3();
|
|
221
|
+
coach.traverse((o) => {
|
|
222
|
+
if (!o.isMesh || !o.geometry) return;
|
|
223
|
+
const spin = /Wheel_(fl|fr|rl|rr)$/i.test(o.name);
|
|
224
|
+
const door = /Door_(l|r)$/i.test(o.name);
|
|
225
|
+
if (!spin && !door) return;
|
|
226
|
+
// COPY THE GEOMETRY BEFORE YOU MOVE IT.
|
|
227
|
+
// instantiate() clones the node tree but SHARES the geometry with the master — that is the
|
|
228
|
+
// whole point of the cache. Re-pivoting a wheel edits the geometry IN PLACE, so the second
|
|
229
|
+
// coach on the line re-pivoted the geometry the first coach had already re-pivoted: every
|
|
230
|
+
// wheel translated twice, hubs computed from an already-moved bounding box, and a coach that
|
|
231
|
+
// arrived with its wheels scattered across the desert. One coach hid it; two did not.
|
|
232
|
+
o.geometry = o.geometry.clone();
|
|
233
|
+
o.geometry.computeBoundingBox();
|
|
234
|
+
box.copy(o.geometry.boundingBox);
|
|
235
|
+
box.getCenter(c);
|
|
236
|
+
if (spin) {
|
|
237
|
+
o.geometry.translate(-c.x, -c.y, -c.z);
|
|
238
|
+
o.position.add(c);
|
|
239
|
+
this.wheels.push({ mesh: o, radius: (box.max.y - box.min.y) / 2 || 0.5 });
|
|
240
|
+
} else {
|
|
241
|
+
// hinge on the door's FORWARD edge, not its middle, or it scissors open through itself
|
|
242
|
+
const hinge = new THREE.Vector3(c.x, c.y, box.max.z);
|
|
243
|
+
o.geometry.translate(-hinge.x, -hinge.y, -hinge.z);
|
|
244
|
+
o.position.add(hinge);
|
|
245
|
+
// Which side of the coach this leaf is on. The coach's local +X is its RIGHT (it travels
|
|
246
|
+
// +Z), and the lane offset puts its right side against the kerb — so the right-hand door
|
|
247
|
+
// is the one that opens onto the depot, and the left one opens onto the road, which is
|
|
248
|
+
// where nobody gets out.
|
|
249
|
+
this.doors.push({ mesh: o, sign: c.x < 0 ? 1 : -1, right: c.x > 0, k: 0, want: 0 });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// the team
|
|
254
|
+
const clips = await loadClips({
|
|
255
|
+
idle: `${HA}/H_Idle_01.fbx`, walk: `${HA}/H_Walk_IP.fbx`,
|
|
256
|
+
trot: `${HA}/H_Trot_IP.fbx`, canter: `${HA}/H_Canter_IP.fbx`,
|
|
257
|
+
gallop: `${HA}/H_Gallop_IP.fbx`, // Shift on the box asks for it, so the team must have it
|
|
258
|
+
});
|
|
259
|
+
this.team = [];
|
|
260
|
+
for (const h of RIG.horses) {
|
|
261
|
+
const model = await instantiate(HORSE_URL, { texture: pick(HORSE_COATS), scale: 0.01 });
|
|
262
|
+
model.scale.setScalar(0.01);
|
|
263
|
+
const pivot = new THREE.Group();
|
|
264
|
+
pivot.position.set(h.x, h.y, h.z);
|
|
265
|
+
pivot.rotation.y = h.ry;
|
|
266
|
+
pivot.add(model);
|
|
267
|
+
this.root.add(pivot);
|
|
268
|
+
const animator = new RawAnimator(model, clips); // horse clips speak the horse's skeleton
|
|
269
|
+
animator.play('idle');
|
|
270
|
+
this.team.push({ animator, gait: 'idle' });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// THE PEOPLE. Driver, his mate beside him, and the fares on the benches inside — all of them
|
|
274
|
+
// SeatedRider, all of them wearing the ONE posture tuned in /coachtest.html. Nobody gets a
|
|
275
|
+
// bespoke sit: the day a driver's hands move, every passenger's hands move with him.
|
|
276
|
+
//
|
|
277
|
+
// The fares are BUILT NOW AND KEPT FOREVER, riding along inside the box with their models
|
|
278
|
+
// hidden — because adding a skinned mesh to a live scene costs a pipeline build and a
|
|
279
|
+
// multi-second freeze (main.js:118), and a coach pulling into town is precisely the worst
|
|
280
|
+
// moment to take one. "Getting out" is a man becoming visible, not a man being born.
|
|
281
|
+
const defOf = (role, fallbackId) => {
|
|
282
|
+
const id = RIG[`${role}Id`] ?? fallbackId;
|
|
283
|
+
const known = CHARACTERS.find((c2) => c2.id === id) ?? CHARACTERS[0];
|
|
284
|
+
return { id, src: RIG[`${role}Src`] ?? known.src, keep: id, tex: RIG[`${role}Tex`] ?? known.tex };
|
|
285
|
+
};
|
|
286
|
+
const PAX = passengerPosture(SEAT); // hands in the lap — only the driver holds reins
|
|
287
|
+
const DRV = driverPosture(SEAT);
|
|
288
|
+
this.driver = await SeatedRider.create(this.root, defOf('driver', 'Character_Business_Man_01'), RIG.driver, DRV);
|
|
289
|
+
if (RIG.mate) {
|
|
290
|
+
this.mate = await SeatedRider.create(this.root, defOf('mate', 'Character_Cowboy_01'), RIG.mate, PAX);
|
|
291
|
+
}
|
|
292
|
+
this.fares = [];
|
|
293
|
+
const seats = RIG.fares ?? [];
|
|
294
|
+
for (const def of castFares(seats.length)) {
|
|
295
|
+
this.fares.push(await SeatedRider.create(this.root, def, seats[this.fares.length], PAX));
|
|
296
|
+
}
|
|
297
|
+
this.riders = [this.driver, this.mate, ...this.fares].filter(Boolean);
|
|
298
|
+
// EVERY MAN ABOARD IS A SHOOTABLE PERSON (the seatedRider.js contract). Hand him the world he
|
|
299
|
+
// lives in, the coach he is riding, and who he is on it — the role decides what his purse is
|
|
300
|
+
// worth and, for the driver, what happens to the team when he dies.
|
|
301
|
+
this._drvPosture = DRV; // kept: the guard puts it on if he ever takes the reins
|
|
302
|
+
for (const r of this.riders) {
|
|
303
|
+
r.game = this.game;
|
|
304
|
+
r.coach = this;
|
|
305
|
+
r.role = r === this.driver ? 'driver' : r === this.mate ? 'mate' : 'fare';
|
|
306
|
+
}
|
|
307
|
+
this._seatFares(); // (also builds this.crew — must come AFTER this.riders)
|
|
308
|
+
|
|
309
|
+
// SOLID. The world's collider list is walked live by World.collide, so a moving vehicle can
|
|
310
|
+
// simply keep its entry up to date each frame — the box for the coach, a circle for each horse.
|
|
311
|
+
// Without these you walk straight through the team and out the far side of the coach, which is
|
|
312
|
+
// the single fastest way to tell a player that none of this is real.
|
|
313
|
+
// A HEIGHT BAND, or the coach shoots down your bullets for you.
|
|
314
|
+
// World.collide kills a bullet that crosses a collider, and a box with no band is solid all the
|
|
315
|
+
// way to the sky — so every round fired at the DRIVER, who sits on top of the thing, died on
|
|
316
|
+
// the chassis 2m below his boots. You could not rob the stage because the stage was bulletproof
|
|
317
|
+
// in a way it had never agreed to be. The band is the body of the coach: below it you are
|
|
318
|
+
// stopped, above it (the box seat, the roof rack, the man on it) you are not. y0/y1 are world
|
|
319
|
+
// heights, so they ride with her over the ground (see _syncColliders).
|
|
320
|
+
this.col = { x: 0, z: 0, hx: 1.2, hz: 2.5, cs: 1, sn: 0, y0: 0, y1: 0 };
|
|
321
|
+
colliders.push(this.col);
|
|
322
|
+
this.horseCols = this.team.map(() => {
|
|
323
|
+
const c2 = { x: 0, z: 0, r: 0.85 };
|
|
324
|
+
colliders.push(c2);
|
|
325
|
+
return c2;
|
|
326
|
+
});
|
|
327
|
+
// WHAT SHE MUST NEVER BE PUSHED OUT OF: herself. One list, two readers — platform.js hands it to
|
|
328
|
+
// the man on her roof so her own box does not shove him off it, and _drive hands it to the world
|
|
329
|
+
// so she does not shove herself across the road.
|
|
330
|
+
this.myCols = [this.col, ...this.horseCols];
|
|
331
|
+
|
|
332
|
+
this._place();
|
|
333
|
+
this._syncColliders();
|
|
334
|
+
this._syncRiders();
|
|
335
|
+
return this;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
doorsShut() { return this.doors.every((d) => d.k < 0.02); }
|
|
339
|
+
|
|
340
|
+
// WHERE EACH MAN ACTUALLY IS, IN THE WORLD. He is a child of a moving, turning coach, so his
|
|
341
|
+
// world position has to be PRODUCED, not read. Same rotate-the-offset arithmetic the horse
|
|
342
|
+
// colliders already use (_syncColliders), written into a Vector3 he already owns: no
|
|
343
|
+
// getWorldPosition matrix walk, no allocation, twelve riders a frame, called from the coach's
|
|
344
|
+
// own update and again from combat.js before it tests a bullet (main.js ticks combat BEFORE the
|
|
345
|
+
// coaches, so without that second call every round is fired at where he was last frame).
|
|
346
|
+
//
|
|
347
|
+
// The y is his STANDING DATUM, not his seat — see seatedRider.js's STAND_HEAD. It sits below the
|
|
348
|
+
// floorboards on purpose: it is the ground he would be standing on if he stood up, which is the
|
|
349
|
+
// only thing combat.js's HEAD_Y (1.5m above the feet) can be measured from.
|
|
350
|
+
_syncRiders() {
|
|
351
|
+
const ry = this.root.rotation.y;
|
|
352
|
+
const s = Math.sin(ry), c = Math.cos(ry);
|
|
353
|
+
const ox = this.root.position.x, oy = this.root.position.y, oz = this.root.position.z;
|
|
354
|
+
for (const r of this.riders) {
|
|
355
|
+
const p = r.pivot.position;
|
|
356
|
+
r.position.set(ox + p.x * c + p.z * s, oy + p.y - r.feetDrop, oz - p.x * s + p.z * c);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Drag the colliders along with the thing they belong to. Cheap: three objects, no allocation.
|
|
361
|
+
_syncColliders() {
|
|
362
|
+
if (!this.col) return;
|
|
363
|
+
const ry = this.root.rotation.y;
|
|
364
|
+
// the prompts ride with her: the door on her kerb side, the box up front
|
|
365
|
+
if (this._itDoor) {
|
|
366
|
+
const c2 = Math.cos(ry), s2 = Math.sin(ry);
|
|
367
|
+
this._itDoor.x = this.root.position.x + c2 * 1.4;
|
|
368
|
+
this._itDoor.z = this.root.position.z - s2 * 1.4;
|
|
369
|
+
this._itBox.x = this.root.position.x + s2 * 1.4 + c2 * 0.9;
|
|
370
|
+
this._itBox.z = this.root.position.z + c2 * 1.4 - s2 * 0.9;
|
|
371
|
+
}
|
|
372
|
+
this.col.x = this.root.position.x;
|
|
373
|
+
this.col.z = this.root.position.z;
|
|
374
|
+
this.col.cs = Math.cos(ry);
|
|
375
|
+
this.col.sn = Math.sin(ry);
|
|
376
|
+
this.col.y0 = this.root.position.y - 0.2; // the chassis...
|
|
377
|
+
this.col.y1 = this.root.position.y + 1.55; // ...up to the footboard. The crew sit above it.
|
|
378
|
+
for (let i = 0; i < this.horseCols.length; i++) {
|
|
379
|
+
const h = RIG.horses[i];
|
|
380
|
+
const s2 = Math.sin(ry), c2 = Math.cos(ry);
|
|
381
|
+
this.horseCols[i].x = this.root.position.x + h.x * c2 + h.z * s2;
|
|
382
|
+
this.horseCols[i].z = this.root.position.z - h.x * s2 + h.z * c2;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// where the coach is right now, and which way it points: straight off the route line.
|
|
387
|
+
_place(dt = 0.05) {
|
|
388
|
+
const a = ROUTE[this.leg], b = ROUTE[(this.leg + 1) % ROUTE.length];
|
|
389
|
+
let x = a.x + (b.x - a.x) * this.t;
|
|
390
|
+
let z = a.z + (b.z - a.z) * this.t;
|
|
391
|
+
// KEEP RIGHT. Slide off the route line onto its own side of the road. The return leg runs the
|
|
392
|
+
// same polyline the other way, so ITS right is the other side of the dirt — which is why two
|
|
393
|
+
// coaches meeting head-on pass each other rather than driving through one another. The ease is
|
|
394
|
+
// over the STOP-TO-STOP PATH (LEGS carries where this leg sits on it), not over each leg: road
|
|
395
|
+
// legs are ~20 m now, and a per-leg ease would send her back to the centreline at every survey
|
|
396
|
+
// peg — a 1.15 m weave every 20 m, and two coaches meeting near any vertex on the same dirt.
|
|
397
|
+
// She pulls out, takes her side within EASE_M metres, and holds it to the next stop.
|
|
398
|
+
const dx = b.x - a.x, dz = b.z - a.z;
|
|
399
|
+
const len = Math.hypot(dx, dz) || 1;
|
|
400
|
+
const rx = dz / len, rz = -dx / len; // the right-hand normal of the direction of travel
|
|
401
|
+
const meta = LEGS[this.leg];
|
|
402
|
+
const s = meta.s0 + this.t * meta.len;
|
|
403
|
+
const ease = Math.max(0, Math.min(1, s / EASE_M, (meta.pathLen - s) / EASE_M));
|
|
404
|
+
// THE OVERTAKE SWING (_blocked's dodge): a temporary extra lane offset, eased like a driver
|
|
405
|
+
// hauls the team — out, past the walker, and back to her side when the way ahead clears.
|
|
406
|
+
this._dodgeCur = (this._dodgeCur ?? 0) + ((this._dodgeTarget ?? 0) - (this._dodgeCur ?? 0)) * Math.min(1, dt * 1.6);
|
|
407
|
+
if (Math.abs(this._dodgeCur) < 0.02 && !this._dodgeTarget) this._dodgeCur = 0;
|
|
408
|
+
x += rx * (LANE * ease + this._dodgeCur);
|
|
409
|
+
z += rz * (LANE * ease + this._dodgeCur);
|
|
410
|
+
this.laneOffset = LANE * ease + this._dodgeCur; // what the road view reports: how far right of her own leg
|
|
411
|
+
const y = this.game.world.groundAt(x, z);
|
|
412
|
+
this.root.position.set(x, y, z);
|
|
413
|
+
// face the way you are going (not the way you were told to) — a coach cannot crab sideways
|
|
414
|
+
const want = Math.atan2(b.x - a.x, b.z - a.z);
|
|
415
|
+
if (this.state === 'standing') {
|
|
416
|
+
// ...except while standing, when it parks the way the stop says to
|
|
417
|
+
const park = a.ry ?? want;
|
|
418
|
+
let d = park - this.root.rotation.y;
|
|
419
|
+
while (d > Math.PI) d -= Math.PI * 2;
|
|
420
|
+
while (d < -Math.PI) d += Math.PI * 2;
|
|
421
|
+
this.root.rotation.y += d * 0.06;
|
|
422
|
+
} else {
|
|
423
|
+
let d = want - this.root.rotation.y;
|
|
424
|
+
while (d > Math.PI) d -= Math.PI * 2;
|
|
425
|
+
while (d < -Math.PI) d += Math.PI * 2;
|
|
426
|
+
this.root.rotation.y += d * 0.08; // eased, so it swings round a corner rather than snapping
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// IS THE ROAD AHEAD CLEAR? Everything that can be stood in front of a coach gets asked: the
|
|
431
|
+
// player, the townsfolk, the other coach. The test is a CORRIDOR, not a radius — a man beside
|
|
432
|
+
// the road is not in the way, and a man ten metres back is not in the way either, but a man on
|
|
433
|
+
// the road in front is, and the further you are going the further ahead you have to care.
|
|
434
|
+
// THE COACH ROBBERY (the bloodless one — shooting the driver has always worked, and bolts the
|
|
435
|
+
// team). A drawn gun AIMED at the driver from the road (≤14m, ray-tested at his seat) reins the
|
|
436
|
+
// team up; hold it ~2 seconds and the takings come down off the box — a fat purse (dropFor
|
|
437
|
+
// 'driver' ×4), a 'coachRobbery' on the county's books, and a coach that stands trembling until
|
|
438
|
+
// you lower the iron or walk away, then drives on poorer. Once per coach per session.
|
|
439
|
+
_heldUp(dt) {
|
|
440
|
+
const g = this.game, p = g.player;
|
|
441
|
+
if (!p || this.rider || this.driver?.alive === false) return false;
|
|
442
|
+
const d = dist2D(this.root.position.x, this.root.position.z, p.position.x, p.position.z);
|
|
443
|
+
if (this._robbed) {
|
|
444
|
+
// stood and delivered already: she stays reined only while the gun still says so
|
|
445
|
+
return p.drawn && p.aiming && d < 16;
|
|
446
|
+
}
|
|
447
|
+
let aimedAtDriver = false;
|
|
448
|
+
if (p.drawn && p.aiming && d < 14 && !p.riding?.mounted) {
|
|
449
|
+
const dp = (this._drvPos ??= new THREE.Vector3());
|
|
450
|
+
this.driver?.pivot?.getWorldPosition?.(dp);
|
|
451
|
+
const from = (this._huFrom ??= new THREE.Vector3()).copy(p.position); from.y += 1.5;
|
|
452
|
+
const dir = p.aimDir?.((this._huDir ??= new THREE.Vector3()));
|
|
453
|
+
if (dir) { dp.y -= 1.0; aimedAtDriver = !!rayCapsule(from.x, from.y, from.z, dir.x, dir.y, dir.z, dp, 0.7); }
|
|
454
|
+
}
|
|
455
|
+
if (!aimedAtDriver) { this._holdT = 0; return false; }
|
|
456
|
+
this._holdT = (this._holdT ?? 0) + dt;
|
|
457
|
+
if (this._holdT < 0.3) g.ui?.toast?.(`The driver's hands come off the reins. Hold the iron on him.`, 3200);
|
|
458
|
+
if (this._holdT > 2.2) {
|
|
459
|
+
this._robbed = true; this._holdT = 0;
|
|
460
|
+
const at = this.root.position.clone(); at.x += Math.cos(this.root.rotation.y) * 2.2; at.z -= Math.sin(this.root.rotation.y) * 2.2;
|
|
461
|
+
at.y = g.world.groundAt(at.x, at.z) + 0.1;
|
|
462
|
+
g.loot?.dropFor?.('driver', at, 4); // the STRONGBOX takings, not his pocket
|
|
463
|
+
g.crime?.report?.('coachRobbery', this.root.position.clone());
|
|
464
|
+
g.missions?.addStat?.('reputation', -2);
|
|
465
|
+
g.ui?.toast?.(`The box comes down without a word. The line will wire every lawman in the county.`, 6500);
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_blocked() {
|
|
471
|
+
const fwd = this.root.rotation.y;
|
|
472
|
+
const fx = Math.sin(fwd), fz = Math.cos(fwd);
|
|
473
|
+
const ox = this.root.position.x, oz = this.root.position.z;
|
|
474
|
+
const reach = LOOK + this.speed * LOOK_PER_MS + (this.held ? HOLD_PAD : 0);
|
|
475
|
+
// `shift` tests a corridor slid sideways — the overtake question: "if I swung out this far,
|
|
476
|
+
// would the way be clear?" (positive = to her right)
|
|
477
|
+
const inTheWay = (px, pz, pad = 0, shift = 0) => {
|
|
478
|
+
const dx = px - (ox + fz * shift), dz = pz - (oz - fx * shift);
|
|
479
|
+
const ahead = dx * fx + dz * fz; // how far in front (negative = behind us)
|
|
480
|
+
if (ahead < 0.5 || ahead > reach + pad) return false;
|
|
481
|
+
const side = Math.abs(dx * fz - dz * fx); // how far off the centreline
|
|
482
|
+
return side < CORRIDOR + pad;
|
|
483
|
+
};
|
|
484
|
+
// ...but NOT the man riding inside her. His position is the seat, which is half a metre forward
|
|
485
|
+
// of her centre — squarely inside her own look-ahead — so she braked for her own passenger and
|
|
486
|
+
// stood in the road forever, refusing to carry the person she was carrying.
|
|
487
|
+
// ...nor the man standing on her ROOF, for exactly the same reason: the front half of the deck
|
|
488
|
+
// is inside her own look-ahead corridor, so she would brake to a halt for her own passenger and
|
|
489
|
+
// stand in the road forever with him on top of her.
|
|
490
|
+
const people = []; // { x, z, ent } — ent null for the player (a coach does not shout the player aside)
|
|
491
|
+
const p = this.game.player;
|
|
492
|
+
const pp = p?.position ?? p?.root?.position;
|
|
493
|
+
const mine = this.rider?.player === p || this.game.platforms?.carrier(p) === this;
|
|
494
|
+
if (pp && !mine) people.push({ x: pp.x, z: pp.z, ent: null });
|
|
495
|
+
for (const n of this.game.npcs ?? []) {
|
|
496
|
+
if (!n.alive) continue; // a coach drives over a body. It does not queue behind one
|
|
497
|
+
// for the rest of the game, which is what it did.
|
|
498
|
+
const np = n.position ?? n.root?.position;
|
|
499
|
+
if (np) people.push({ x: np.x, z: np.z, ent: n });
|
|
500
|
+
}
|
|
501
|
+
// OTHER COACHES: two rules, by direction. SAME way → queue behind her (a bigger thing, more room,
|
|
502
|
+
// never overtaken). OPPOSING way → each keeps to her own RIGHT and they pass — the old rule braked
|
|
503
|
+
// BOTH of them nose to nose and they stood facing each other in the road until the end of days.
|
|
504
|
+
let oppose = false;
|
|
505
|
+
for (const other of this.game.coaches?.coaches ?? []) {
|
|
506
|
+
if (other === this) continue;
|
|
507
|
+
const ov = other.root.position;
|
|
508
|
+
const oy = other.root.rotation.y;
|
|
509
|
+
const sameWay = fx * Math.sin(oy) + fz * Math.cos(oy) >= -0.2;
|
|
510
|
+
if (sameWay) {
|
|
511
|
+
if (inTheWay(ov.x, ov.z, 2.6)) { this._dodgeTarget = 0; return true; }
|
|
512
|
+
} else {
|
|
513
|
+
// a WIDER ahead-window than the brake corridor, held through the pass itself (ahead > -4:
|
|
514
|
+
// don't straighten while we are abreast of her) so the swing lasts until she is truly behind
|
|
515
|
+
const dx = ov.x - ox, dz = ov.z - oz;
|
|
516
|
+
const ahead = dx * fx + dz * fz;
|
|
517
|
+
const side = Math.abs(dx * fz - dz * fx);
|
|
518
|
+
if (ahead > -4 && ahead < reach + 8 && side < CORRIDOR + 2.6) oppose = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// THE DRIVER'S SHOUT — people clear the road for a coach, not the coach for people (Nick).
|
|
523
|
+
// Everyone in the look-ahead corridor scurries PERPENDICULAR off her line: the scare point is a
|
|
524
|
+
// virtual spot planted a step to their road-centre side, so "run away from it" is exactly "step
|
|
525
|
+
// off the road", not the run-along-the-road-forever a scare at the coach itself produces. A
|
|
526
|
+
// seated rider's scare() spooks his horse aside instead — same effect from the saddle.
|
|
527
|
+
// Refresh while fleeT runs low so a man still in the corridor keeps clearing.
|
|
528
|
+
const blockers = people.filter((q) => inTheWay(q.x, q.z));
|
|
529
|
+
let nearest = Infinity;
|
|
530
|
+
for (const b of blockers) {
|
|
531
|
+
const dxq = b.x - ox, dzq = b.z - oz;
|
|
532
|
+
nearest = Math.min(nearest, dxq * fx + dzq * fz);
|
|
533
|
+
const n = b.ent;
|
|
534
|
+
if (n?.scare && (n.fleeT ?? 0) <= 0.35) {
|
|
535
|
+
const sgn = (dxq * fz - dzq * fx) >= 0 ? 1 : -1; // which side of her line he is already on
|
|
536
|
+
n.scare(b.x - sgn * fz * 1.5, b.z + sgn * fx * 1.5, YELL_FLEE);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (oppose) {
|
|
541
|
+
// pull to her own right and hold it; brake only while a person is standing in the passing lane
|
|
542
|
+
// (he has just been shouted at — he will clear)
|
|
543
|
+
if (!people.some((q) => inTheWay(q.x, q.z, 0.4, PASS_SHIFT))) { this._dodgeTarget = PASS_SHIFT; return false; }
|
|
544
|
+
this._dodgeTarget = 0;
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!blockers.length) { this._dodgeTarget = 0; return false; }
|
|
549
|
+
// they are clearing: keep rolling while the nearest man is still a team's length out
|
|
550
|
+
if (nearest > YIELD_NEAR) { this._dodgeTarget = 0; return false; }
|
|
551
|
+
// a CLOSE straggler (the player, a man who will not flee): swing round him like before. Try a
|
|
552
|
+
// sidestep either side; take the first whose slid corridor is clear of EVERY person. Blocked
|
|
553
|
+
// both sides (a crowd, a wall of folk) → brake like before.
|
|
554
|
+
for (const shift of [2.0, -2.0]) {
|
|
555
|
+
if (!people.some((q) => inTheWay(q.x, q.z, 0.4, shift))) { this._dodgeTarget = shift; return false; }
|
|
556
|
+
}
|
|
557
|
+
this._dodgeTarget = 0;
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── GETTING ABOARD ────────────────────────────────────────────────────────────────────────
|
|
562
|
+
// Two ways on, and they mean different things. At the DOOR you are a fare: you get in, she runs
|
|
563
|
+
// her route, you look out of the window. At the BOX you take the reins: she stops being a
|
|
564
|
+
// timetable and starts being a vehicle, and the man who was driving her gets down.
|
|
565
|
+
//
|
|
566
|
+
// The player is not reparented onto the coach. His position is written to the seat every frame
|
|
567
|
+
// instead — because every other system in this game (collision, the camera, the aim, the law's
|
|
568
|
+
// line of sight) reads player.position, and a player who is secretly a child of a moving group is
|
|
569
|
+
// a player none of them can find.
|
|
570
|
+
seatWorld(out, kind) {
|
|
571
|
+
const r = kind === 'box' ? RIG.driver : (RIG.fares?.[0] ?? RIG.driver);
|
|
572
|
+
const ry = this.root.rotation.y;
|
|
573
|
+
const c = Math.cos(ry), sn = Math.sin(ry);
|
|
574
|
+
return out.set(
|
|
575
|
+
this.root.position.x + r.x * c + r.z * sn,
|
|
576
|
+
this.root.position.y + r.y,
|
|
577
|
+
this.root.position.z - r.x * sn + r.z * c,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// WHO HAS THE REINS IS A FACT, NOT A FLAG.
|
|
582
|
+
// This used to be a boolean: set in board(), cleared in alight() — and alight() is not the only way
|
|
583
|
+
// a man leaves the box. Get shot on it and main.js respawns you at the trough without ever telling
|
|
584
|
+
// the coach; mount a horse from it and the same. She kept the flag, and `driven` is the ONLY gate on
|
|
585
|
+
// _drive(), which reads the LIVE KEYBOARD. So she went on being steered by a man who was not there:
|
|
586
|
+
// walking away with A held turned her at 55 deg/s — TURN_RATE exactly — which is a closed circle,
|
|
587
|
+
// for the rest of the game, through the station building and anything else in the way, because the
|
|
588
|
+
// driven path consults neither the route nor a collider. Derived from the rider, it cannot lie.
|
|
589
|
+
get driven() { return this.rider?.kind === 'box'; }
|
|
590
|
+
|
|
591
|
+
board(player, kind) {
|
|
592
|
+
// a man who sits down is no longer a man standing on the roof (and `aboard` writes his position
|
|
593
|
+
// from the seat, so the two states must never both be live)
|
|
594
|
+
this.game.platforms?.leave(player, 'aboard');
|
|
595
|
+
this.rider = { player, kind };
|
|
596
|
+
// POINT THE CAMERA WHERE SHE IS GOING. It orbits the player at camYaw, and camYaw is whatever
|
|
597
|
+
// you happened to be looking at when you climbed up — so you took the reins and found yourself
|
|
598
|
+
// staring at the side of your own coach. The camera's forward is -(sin,cos) of camYaw, so
|
|
599
|
+
// looking along her heading means camYaw = ry + PI. (player.js keeps easing it there while you
|
|
600
|
+
// drive, so she stays in front of you through a turn.)
|
|
601
|
+
player.camYaw = this.root.rotation.y + Math.PI;
|
|
602
|
+
player.aboard = this; // player.js reads this and stops walking about
|
|
603
|
+
// he stays visible: he is the man on the box (player.js applies the seated pose each frame)
|
|
604
|
+
if (kind === 'box') {
|
|
605
|
+
this.speed = 0;
|
|
606
|
+
// the man on the box gets down and walks away — you cannot have two drivers
|
|
607
|
+
if (this.driver) this.driver.pivot.visible = false;
|
|
608
|
+
if (this.mate) this.mate.pivot.visible = false;
|
|
609
|
+
this.game.ui?.toast?.('You have the reins. W drives, S brakes and backs her, A/D steer, E to get down.');
|
|
610
|
+
} else {
|
|
611
|
+
this.game.ui?.toast?.('Aboard. E to get out.');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
alight(player) {
|
|
616
|
+
if (!this.rider) return;
|
|
617
|
+
player._visualY = undefined; // let the smoother re-latch onto wherever he lands
|
|
618
|
+
// step down on the KERB side (her right), clear of the wheels
|
|
619
|
+
const ry = this.root.rotation.y;
|
|
620
|
+
const rx = Math.cos(ry), rz = -Math.sin(ry);
|
|
621
|
+
const x = this.root.position.x + rx * 2.4;
|
|
622
|
+
const z = this.root.position.z + rz * 2.4;
|
|
623
|
+
player.position.set(x, this.game.world.groundAt(x, z), z);
|
|
624
|
+
this._dropRider(); // ...and the one owner of the transition does the rest
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// THE ONE PLACE A RIDE ENDS. Getting down is one way; being shot off the box, being teleported off
|
|
628
|
+
// it, respawning at the trough and throwing a leg over a horse are the others, and none of them
|
|
629
|
+
// used to say a word to the coach. So they don't have to: they only ever touch player.aboard, and
|
|
630
|
+
// update() calls this the instant the man on the box stops being hers. Whatever ends the ride, the
|
|
631
|
+
// reins are put down in exactly one place.
|
|
632
|
+
_dropRider() {
|
|
633
|
+
const r = this.rider;
|
|
634
|
+
if (!r) return;
|
|
635
|
+
this.rider = null; // `driven` is derived from this — she is nobody's now
|
|
636
|
+
if (r.player.aboard === this) r.player.aboard = null;
|
|
637
|
+
if (r.kind !== 'box') return;
|
|
638
|
+
if (this.driver?.alive) this.driver.pivot.visible = true;
|
|
639
|
+
if (this.mate?.alive) this.mate.pivot.visible = true;
|
|
640
|
+
this._rejoinRoute(); // she picks her timetable back up from where you left her
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Back on the timetable: find the leg she is nearest to, and how far along it she is. Without
|
|
644
|
+
// this she would teleport back to wherever she was when you took the reins.
|
|
645
|
+
_rejoinRoute() {
|
|
646
|
+
let bestD = Infinity;
|
|
647
|
+
for (let i = 0; i < ROUTE.length; i++) {
|
|
648
|
+
const a = ROUTE[i], b = ROUTE[(i + 1) % ROUTE.length];
|
|
649
|
+
const dx = b.x - a.x, dz = b.z - a.z;
|
|
650
|
+
const L2 = dx * dx + dz * dz || 1;
|
|
651
|
+
let t = ((this.root.position.x - a.x) * dx + (this.root.position.z - a.z) * dz) / L2;
|
|
652
|
+
t = Math.max(0, Math.min(1, t));
|
|
653
|
+
const px = a.x + dx * t, pz = a.z + dz * t;
|
|
654
|
+
const d = dist2D(px, pz, this.root.position.x, this.root.position.z);
|
|
655
|
+
if (d < bestD) { bestD = d; this.leg = i; this.t = t; }
|
|
656
|
+
}
|
|
657
|
+
this.state = 'rolling';
|
|
658
|
+
this.wait = 0;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// YOU are driving her. No route, no waypoints — a heading and a throttle, which is all a coach
|
|
662
|
+
// has ever been. She steers like the thing she is: four tons on wooden wheels, so she does not
|
|
663
|
+
// turn on the spot and she does not stop dead.
|
|
664
|
+
_drive(dt, input) {
|
|
665
|
+
const fwd = input?.down?.('KeyW') ? 1 : 0;
|
|
666
|
+
const brake = input?.down?.('KeyS') ? 1 : 0;
|
|
667
|
+
// SHIFT PUTS THEM AT A GALLOP. A team at a canter is a coach going somewhere; a team at a
|
|
668
|
+
// gallop is a coach being got away with. Same key that sprints a man, because that is the key
|
|
669
|
+
// your hand is already on.
|
|
670
|
+
const whip = input?.down?.('ShiftLeft') || input?.down?.('ShiftRight');
|
|
671
|
+
// S is the brake. S with nothing left to brake is the team BACKING her — a walk, no more, and
|
|
672
|
+
// the only way out of a wall now that a wall is a thing she can hit.
|
|
673
|
+
const want = brake ? (this.speed <= 0.05 ? -REVERSE : 0)
|
|
674
|
+
: fwd ? (whip ? GAIT_SPEED.gallop : GAIT_SPEED.canter) : 0;
|
|
675
|
+
const rate = brake ? BRAKE : (fwd ? PULL : 0.35);
|
|
676
|
+
this.speed += (want - this.speed) * Math.min(1, dt * rate);
|
|
677
|
+
if (Math.abs(this.speed) < 0.02) this.speed = 0;
|
|
678
|
+
// steering only bites when she is rolling — a stationary coach cannot be turned by the reins.
|
|
679
|
+
// The bite is SIGNED, so backing up swings her the other way, exactly as a wagon does.
|
|
680
|
+
const steer = (input?.down?.('KeyA') ? 1 : 0) - (input?.down?.('KeyD') ? 1 : 0);
|
|
681
|
+
const bite = Math.max(-1, Math.min(1, this.speed / 3));
|
|
682
|
+
this.root.rotation.y += steer * TURN_RATE * bite * dt;
|
|
683
|
+
const ry = this.root.rotation.y;
|
|
684
|
+
const s = Math.sin(ry), c = Math.cos(ry);
|
|
685
|
+
const ox = this.root.position.x, oz = this.root.position.z;
|
|
686
|
+
let x = ox + s * this.speed * dt;
|
|
687
|
+
let z = oz + c * this.speed * dt;
|
|
688
|
+
// AND SHE IS SOLID. This is the ONE path on which a coach's position is not written from the
|
|
689
|
+
// route — the timetable goes through _place(), and _place() is not called here — so nothing else
|
|
690
|
+
// in the game was ever going to stop her, and nothing did: driven at the station building she went
|
|
691
|
+
// straight through the middle of it at 6.3 m/s and came out the far side without losing a tenth of
|
|
692
|
+
// it (measured: closest approach to its centre, 0.1 m; speed through it, 6.25 → 6.37). So ask the
|
|
693
|
+
// same world the player walks in, at four points down her body, and take the DEEPEST push rather
|
|
694
|
+
// than the sum of them: her step is 11 cm at a canter, so a frame's penetration is small and the
|
|
695
|
+
// next contact resolves on the next frame, where summing four probes that are all in the same wall
|
|
696
|
+
// would fling her off it.
|
|
697
|
+
const W = this.game.world;
|
|
698
|
+
let bx = 0, bz = 0, worst = 0;
|
|
699
|
+
for (const b of BODY) {
|
|
700
|
+
W.solidPush(x + b.z * s, this.root.position.y, z + b.z * c, b.r, _push, this.myCols);
|
|
701
|
+
const m = _push.x * _push.x + _push.z * _push.z;
|
|
702
|
+
if (m > worst) { worst = m; bx = _push.x; bz = _push.z; }
|
|
703
|
+
}
|
|
704
|
+
if (worst > 0) {
|
|
705
|
+
x += bx; z += bz;
|
|
706
|
+
// ...and what she ACTUALLY made along her own heading is what she is actually doing. Head-on
|
|
707
|
+
// that is nothing, and she stands against the wall; at a graze it is most of it, and she scrapes
|
|
708
|
+
// along the front of the saloon and drives on. Her wheels and her team's gait both read
|
|
709
|
+
// this.speed, so a coach a wall has stopped stops her wheels too, instead of spinning them at a
|
|
710
|
+
// canter against the planking.
|
|
711
|
+
const made = ((x - ox) * s + (z - oz) * c) / Math.max(dt, 1e-4);
|
|
712
|
+
this.speed = this.speed >= 0 ? Math.min(this.speed, Math.max(0, made))
|
|
713
|
+
: Math.max(this.speed, Math.min(0, made));
|
|
714
|
+
}
|
|
715
|
+
this.root.position.set(x, W.groundAt(x, z), z);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
update(dt) {
|
|
719
|
+
// IS THE MAN ON THE BOX STILL ON THE BOX? player.aboard is the one fact about who is riding what,
|
|
720
|
+
// and everything that can end a ride already maintains it — so ask it, every frame, instead of
|
|
721
|
+
// trusting a flag that four other systems would have to remember to clear. He is gone if he is
|
|
722
|
+
// aboard something else (or nothing), if he is dead, or if he is on a horse: any of those and the
|
|
723
|
+
// reins go down here. Without it she kept the reins after he died and drove herself in circles.
|
|
724
|
+
const r = this.rider;
|
|
725
|
+
if (r && (r.player.aboard !== this || !r.player.alive || r.player.riding?.mounted)) this._dropRider();
|
|
726
|
+
|
|
727
|
+
// DRIVEN BY THE PLAYER: she is a vehicle now, not a timetable.
|
|
728
|
+
if (this.driven) {
|
|
729
|
+
this._drive(dt, this.game.input);
|
|
730
|
+
for (const d of this.doors) d.want = 0; // doors shut while she's rolling under your hand
|
|
731
|
+
this._syncColliders();
|
|
732
|
+
this._syncRiders();
|
|
733
|
+
this._afterMove(dt);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const a = ROUTE[this.leg], b = ROUTE[(this.leg + 1) % ROUTE.length];
|
|
738
|
+
const legLen = Math.max(1, Math.hypot(b.x - a.x, b.z - a.z));
|
|
739
|
+
|
|
740
|
+
if (this.state === 'standing') {
|
|
741
|
+
this.speed = 0;
|
|
742
|
+
this.wait -= dt;
|
|
743
|
+
// THE KERB-SIDE DOOR. It swings open the moment she pulls up, stands open for DOOR_HOLD
|
|
744
|
+
// while they get down and the next lot climb in, and is shut before she pulls away — the
|
|
745
|
+
// coach physically cannot leave until it is (see the doorsShut test below). The road-side
|
|
746
|
+
// door stays shut: nobody steps down into the traffic.
|
|
747
|
+
const open = this.wait > STOP_SECS - DOOR_HOLD ? 1 : 0;
|
|
748
|
+
for (const d of this.doors) d.want = d.right ? open : 0;
|
|
749
|
+
if (this.wait <= 0 && this.doorsShut()) {
|
|
750
|
+
this.state = 'rolling';
|
|
751
|
+
this.t = 0;
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
for (const d of this.doors) d.want = 0;
|
|
755
|
+
if (this.bolt > 0) {
|
|
756
|
+
// RUNAWAY. _blocked() is not even asked: a bolting team does not brake for a man in the
|
|
757
|
+
// road. The coach's colliders are walked live by World.collide, so whoever is in front of
|
|
758
|
+
// her gets shoved out of the way — being in the road is now HIS problem, and that is
|
|
759
|
+
// exactly the point of the beat.
|
|
760
|
+
this.bolt -= dt;
|
|
761
|
+
this.held = false;
|
|
762
|
+
this.speed += (GAIT_SPEED.canter - this.speed) * Math.min(1, dt * BOLT_RUSH);
|
|
763
|
+
if (this.bolt <= 0) this.spent = true;
|
|
764
|
+
} else if (this.spent) {
|
|
765
|
+
// BLOWN. Nobody on the reins, the team has had enough, and she drags herself down to a
|
|
766
|
+
// dead stop wherever she happens to be — in the road, across the road, it doesn't matter.
|
|
767
|
+
this.held = true;
|
|
768
|
+
this.speed += (0 - this.speed) * Math.min(1, dt * DRAG_DOWN);
|
|
769
|
+
if (this.speed < 0.05) this.speed = 0;
|
|
770
|
+
this._tryTheReins(dt);
|
|
771
|
+
} else {
|
|
772
|
+
// WHOA. Brake for whatever is in the road — or for the GUN ON THE DRIVER (_heldUp);
|
|
773
|
+
// pull away again only once the road is clear and the iron is down.
|
|
774
|
+
this.held = this._blocked() || this._heldUp(dt);
|
|
775
|
+
const want = this.held ? 0 : GAIT_SPEED[CRUISE];
|
|
776
|
+
const rate = this.held ? BRAKE : PULL;
|
|
777
|
+
this.speed += (want - this.speed) * Math.min(1, dt * rate);
|
|
778
|
+
if (this.speed < 0.02) this.speed = 0;
|
|
779
|
+
}
|
|
780
|
+
this.t += (this.speed * dt) / legLen;
|
|
781
|
+
if (this.t >= 1) {
|
|
782
|
+
this.t = 1;
|
|
783
|
+
this.leg = (this.leg + 1) % ROUTE.length;
|
|
784
|
+
this.t = 0;
|
|
785
|
+
const stop = ROUTE[this.leg];
|
|
786
|
+
// A runaway does not call at the depot, and a coach with a dead man on the box does not
|
|
787
|
+
// pull up and open its doors for fares.
|
|
788
|
+
if (stop.stop && this.bolt <= 0 && !this.spent) {
|
|
789
|
+
this.state = 'standing';
|
|
790
|
+
this.wait = STOP_SECS;
|
|
791
|
+
this._callAt(stop);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
this._place(dt);
|
|
797
|
+
this._syncColliders();
|
|
798
|
+
this._syncRiders();
|
|
799
|
+
|
|
800
|
+
this._afterMove(dt);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// EVERYTHING THAT FOLLOWS FROM HER HAVING MOVED — however she came to move. The timetable and the
|
|
804
|
+
// player's hands on the reins both end up here, so a coach you are driving turns its wheels,
|
|
805
|
+
// works its team and holds its passengers exactly as one you are watching does. Two copies of
|
|
806
|
+
// this would drift apart the first time either was touched.
|
|
807
|
+
_afterMove(dt) {
|
|
808
|
+
// the wheels are turned BY THE GROUND: radians = distance / radius. Never by a guess.
|
|
809
|
+
for (const w of this.wheels) w.mesh.rotation.x += (this.speed * dt) / w.radius;
|
|
810
|
+
// Each leaf runs its own swing, eased — a door is heavy off the latch and it settles, it does
|
|
811
|
+
// not snap. (One shared angle meant both leaves always did the same thing, so the coach opened
|
|
812
|
+
// its road-side door into the traffic every time it called anywhere.)
|
|
813
|
+
for (const d of this.doors) {
|
|
814
|
+
const step = dt / DOOR_SECS;
|
|
815
|
+
d.k = Math.max(0, Math.min(1, d.k + Math.sign(d.want - d.k) * step));
|
|
816
|
+
const e = d.k * d.k * (3 - 2 * d.k);
|
|
817
|
+
d.mesh.rotation.y = d.sign * DOOR_OPEN * e;
|
|
818
|
+
}
|
|
819
|
+
// Everyone aboard holds their pose. It is four two-bone solves a man, no mixer, no clip — and
|
|
820
|
+
// it must run every frame because the IK is solved fresh from the base pose each time (solve on
|
|
821
|
+
// top of yesterday's solve and he shakes himself apart; see seatedRider.js).
|
|
822
|
+
// A DEAD MAN IS NOT SOLVED. He was solved once, limp, at the moment he was shot; from then on
|
|
823
|
+
// he only falls (slump), because re-solving him would sit him back up straight with his hands
|
|
824
|
+
// out for the reins — every frame, forever.
|
|
825
|
+
for (const r of this.riders) {
|
|
826
|
+
if (!r.pivot.visible) continue;
|
|
827
|
+
if (r.alive) r.solve();
|
|
828
|
+
else r.slump(dt);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// the team's gait follows the coach's actual speed, so they never trot on the spot
|
|
832
|
+
// THE GAIT FOLLOWS THE GROUND SPEED, and there is a gait for every speed she can reach. The
|
|
833
|
+
// ceiling used to be 'canter' — so the moment you took the whip to them (Shift: 10.5m/s) the
|
|
834
|
+
// team ran at gallop pace while still playing a canter, legs turning over half as fast as the
|
|
835
|
+
// ground going under them. The thresholds are where each gait's stride actually covers the
|
|
836
|
+
// ground: below them the horse moonwalks, above them he skates.
|
|
837
|
+
// ...off her GROUND SPEED, not her signed one: a team backing a coach at 1.4 m/s is walking, and
|
|
838
|
+
// asking a negative number which gait it is gets you a team standing stock still while the coach
|
|
839
|
+
// slides backwards out of the front of the saloon.
|
|
840
|
+
const v = Math.abs(this.speed);
|
|
841
|
+
const gait = v < 0.3 ? 'idle' : v < 2.4 ? 'walk' : v < 5 ? 'trot' : v < 8.2 ? 'canter' : 'gallop';
|
|
842
|
+
for (const t of this.team) {
|
|
843
|
+
if (t.gait !== gait) { t.gait = gait; t.animator.play(gait, { fade: 0.25 }); }
|
|
844
|
+
t.animator.update(dt);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// How full is she leaving? Nought to four, drawn fresh each time she pulls out — the coach that
|
|
849
|
+
// is always half full is as fake as the coach that is always empty.
|
|
850
|
+
//
|
|
851
|
+
// A DEAD FARE IS NOT A FARE. He does not get down at the next stop, and he is certainly not
|
|
852
|
+
// shuffled out of existence to hide the evidence: he rides on, visible, in the seat he was shot
|
|
853
|
+
// in. Only the living are re-drawn.
|
|
854
|
+
_seatFares() {
|
|
855
|
+
const live = this.fares.filter((f) => f.alive);
|
|
856
|
+
const n = Math.floor(Math.random() * (live.length + 1));
|
|
857
|
+
const who = live.map((_, i) => i).sort(() => Math.random() - 0.5).slice(0, n);
|
|
858
|
+
live.forEach((f, i) => { f.pivot.visible = who.includes(i); });
|
|
859
|
+
this._crew();
|
|
860
|
+
return n;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Who is actually aboard, and therefore who can be shot: the list combat.js walks. Rebuilt only
|
|
864
|
+
// when the load changes (at a stop, or when a man dies) — never per frame. A hidden fare is a
|
|
865
|
+
// man who isn't on this coach today, and a bullet must pass straight through where he isn't.
|
|
866
|
+
_crew() {
|
|
867
|
+
this.crew = this.riders.filter((r) => r.pivot.visible);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Somebody aboard has been killed (called by SeatedRider.die). His purse hits the DIRT — not the
|
|
871
|
+
// floor of a box that is about to run away with it — the town hears about it, and if it was the
|
|
872
|
+
// man on the box, the team goes.
|
|
873
|
+
_crewDown(r, killer = null) {
|
|
874
|
+
const g = this.game;
|
|
875
|
+
const x = r.position.x, z = r.position.z;
|
|
876
|
+
const at = new THREE.Vector3(x, g.world.groundAt(x, z), z);
|
|
877
|
+
// only the player's kills are the player's crimes — an outlaw's stray round is not a robbery
|
|
878
|
+
if (killer == null || killer === g.player) {
|
|
879
|
+
g.crime?.report('coachRobbery', at);
|
|
880
|
+
g.loot?.drop(at, roll(r.role === 'driver' ? TAKINGS : r.role === 'mate' ? WAGES : FARE_PURSE), dropItemsFor(r.role));
|
|
881
|
+
}
|
|
882
|
+
alarmTown(g, x, z, 26, 6);
|
|
883
|
+
this._crew(); // (he stays in it — combat skips the dead — but keep it honest)
|
|
884
|
+
if (r === this.driver) this._boltTeam();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// The reins go slack. See the BOLT block at the top of the file.
|
|
888
|
+
_boltTeam() {
|
|
889
|
+
if (this.bolt > 0) return;
|
|
890
|
+
this.bolt = BOLT_SECS;
|
|
891
|
+
this.spent = false;
|
|
892
|
+
this.reinsT = 0;
|
|
893
|
+
// she leaves NOW, whatever she was doing — the doors swing shut behind her as she goes
|
|
894
|
+
if (this.state === 'standing') { this.state = 'rolling'; this.wait = 0; this.t = 0; }
|
|
895
|
+
for (const d of this.doors) d.want = 0;
|
|
896
|
+
console.log('[coach] the driver is down — the team has bolted');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// THE GUARD TAKES THE REINS. He does not change seats: he is sitting eighteen inches from the
|
|
900
|
+
// driver on the same box, so taking the reins is a POSTURE, not a journey — his hands come up off
|
|
901
|
+
// his lap and onto the leather, and the dead man stays folded over beside him for the rest of the
|
|
902
|
+
// run. (Moving him into the driver's seat would have put two bodies in one seat, one of them a
|
|
903
|
+
// corpse.) He keeps his own role: the strongbox went into the road with the man who was carrying
|
|
904
|
+
// it, and shooting the guard now does not pay twice.
|
|
905
|
+
_tryTheReins(dt) {
|
|
906
|
+
if (this.speed > 0.05) return; // not until she has actually stopped
|
|
907
|
+
const m = this.mate;
|
|
908
|
+
if (!m || !m.alive || !m.pivot.visible) return; // nobody left who can drive — she stands here for good
|
|
909
|
+
this.reinsT += dt;
|
|
910
|
+
if (this.reinsT < REINS_SECS) return;
|
|
911
|
+
m.posture = this._drvPosture;
|
|
912
|
+
this.driver = m; // whoever is on the leather IS the driver now
|
|
913
|
+
this.mate = null;
|
|
914
|
+
this.spent = false;
|
|
915
|
+
this.reinsT = 0;
|
|
916
|
+
console.log('[coach] the guard takes the reins — the line goes on');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Pulling in somewhere: put down whoever is aboard, take up whoever is waiting.
|
|
920
|
+
// (The NPC board/alight walk is the next piece of work; for now the load changes at each stop.)
|
|
921
|
+
_callAt(stop) {
|
|
922
|
+
this._seatFares();
|
|
923
|
+
const world = this.root.position;
|
|
924
|
+
for (const p of this.passengers) {
|
|
925
|
+
p.alight(world.x, world.z); // step down and walk off into wherever this is
|
|
926
|
+
}
|
|
927
|
+
this.passengers = [];
|
|
928
|
+
// and take up anyone standing about at this stop who fancies the ride
|
|
929
|
+
const board = (this.game.coachPool ?? []).filter((n) => n.wantsCoach && !n.aboard
|
|
930
|
+
&& dist2D(n.position.x, n.position.z, world.x, world.z) < 14).slice(0, 3);
|
|
931
|
+
for (const n of board) { n.board(this); this.passengers.push(n); }
|
|
932
|
+
if (board.length || stop.stop) {
|
|
933
|
+
console.log(`[coach] calling at ${stop.name} — ${board.length} aboard`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export class CoachLine {
|
|
939
|
+
constructor(game) { this.game = game; this.coaches = []; }
|
|
940
|
+
|
|
941
|
+
// Built on the loading screen, resident for life. The coaches are staggered by DISTANCE around
|
|
942
|
+
// the circuit — an equal share of TOTAL_LEN each, resolved to (leg, t) — so the road always has
|
|
943
|
+
// one on it somewhere and the depot is neither always empty nor always full. (It used to stagger
|
|
944
|
+
// by leg INDEX, which was fair when the legs were four uniform quarters; the road-derived legs
|
|
945
|
+
// run 6–630 m, and an index stagger would put the whole line nose to tail.)
|
|
946
|
+
async build(count = 2) {
|
|
947
|
+
for (let i = 0; i < count; i++) {
|
|
948
|
+
try {
|
|
949
|
+
const start = placeAt((TOTAL_LEN / count) * i);
|
|
950
|
+
const c = new Coach(this.game, start.leg, start.t);
|
|
951
|
+
await c.load();
|
|
952
|
+
this.game.scene.add(c.root);
|
|
953
|
+
this.coaches.push(c);
|
|
954
|
+
// A THING YOU CAN STAND ON. The registry measures her motion itself, so this is the whole
|
|
955
|
+
// of it — no per-frame hook, nothing for the coach to remember, and the same four lines
|
|
956
|
+
// will put a man on a boxcar. `cols` is what he stops being pushed by while he is up there.
|
|
957
|
+
c.plat = this.game.platforms?.register({
|
|
958
|
+
root: c.root, owner: c, decks: [ROOF], hull: HULL,
|
|
959
|
+
cols: c.myCols, radius: 4,
|
|
960
|
+
});
|
|
961
|
+
// TWO WAYS ON. The prompts are ordinary interactables — but a coach MOVES, so their points
|
|
962
|
+
// are rewritten every frame (_syncColliders) instead of being planted once in the dirt.
|
|
963
|
+
// `when` is what makes one prompt turn into the other: standing beside a coach you are
|
|
964
|
+
// already aboard, the only thing you want offered is the way OUT.
|
|
965
|
+
const p = this.game;
|
|
966
|
+
c._itDoor = {
|
|
967
|
+
x: 0, z: 0, r: 3.0,
|
|
968
|
+
get label() { return c.rider ? 'E — Get out' : 'E — Ride the stage'; },
|
|
969
|
+
// ...but not from her own roof: a man up there is offered the REINS (the box prompt
|
|
970
|
+
// below), not a seat in the cabin he is standing on.
|
|
971
|
+
when: () => c.rider?.player === p.player
|
|
972
|
+
|| (!c.rider && c.speed < 1.2 && p.platforms?.carrier(p.player) !== c),
|
|
973
|
+
action: () => (c.rider ? c.alight(p.player) : c.board(p.player, 'inside')),
|
|
974
|
+
};
|
|
975
|
+
c._itBox = {
|
|
976
|
+
x: 0, z: 0, r: 2.6,
|
|
977
|
+
get label() { return c.rider?.kind === 'box' ? 'E — Get down' : 'E — Take the reins'; },
|
|
978
|
+
when: () => c.rider?.kind === 'box' || (!c.rider && c.speed < 1.2),
|
|
979
|
+
action: () => (c.rider ? c.alight(p.player) : c.board(p.player, 'box')),
|
|
980
|
+
};
|
|
981
|
+
this.game.interactables.push(c._itDoor, c._itBox);
|
|
982
|
+
} catch (e) { console.warn('[coach] build failed', e); }
|
|
983
|
+
}
|
|
984
|
+
// AND A SEAT FOR YOU. Baked HERE, on the loading screen, because makeSeatedPose does a retarget
|
|
985
|
+
// and a retarget in the middle of a gunfight is a stall. The player is a Synty body on the same
|
|
986
|
+
// skeleton as everyone else, so he wears the same posture the coach's own driver does — which
|
|
987
|
+
// means the man on the box looks like a man on the box, and not like an empty seat with a
|
|
988
|
+
// camera behind it. (He used to be simply HIDDEN when he boarded. Nobody was driving.)
|
|
989
|
+
// AND A SEAT FOR YOU. Baked HERE, on the loading screen, because makeSeatedPose does a retarget
|
|
990
|
+
// and a retarget in the middle of a gunfight is a stall. The player is a Synty body on the same
|
|
991
|
+
// skeleton as everyone else, so he wears the same posture the coach's own driver does.
|
|
992
|
+
const pl = this.game.player;
|
|
993
|
+
if (pl?.model) {
|
|
994
|
+
try {
|
|
995
|
+
const r = new SeatedRider(pl.root, pl.model, driverPosture(SEAT));
|
|
996
|
+
// NEVER TOUCH THE LIVE PLAYER'S SKELETON. I called skeleton.pose() here once to read his
|
|
997
|
+
// bind — and main.js runs newGame() AFTER the coaches are built, so respawning re-ran
|
|
998
|
+
// _groundFeet(), which measures the model's lowest BONE. It measured the bind skeleton,
|
|
999
|
+
// computed a foot-drop of TWENTY-FOUR METRES, and fired the player into the sky.
|
|
1000
|
+
const { pose, pbone } = await makeSeatedPose(pl.model, BIPED_TO_SYNTY);
|
|
1001
|
+
r.bones = pbone;
|
|
1002
|
+
r.pose = pose;
|
|
1003
|
+
for (const n of ['UpperLeg_L', 'LowerLeg_L', 'Ankle_L', 'UpperLeg_R', 'LowerLeg_R', 'Ankle_R']) {
|
|
1004
|
+
if (pbone[n]) r.bind[n] = pbone[n].quaternion.clone();
|
|
1005
|
+
}
|
|
1006
|
+
// Which side his left is on, asked of the POSE (arms out) rather than of whatever frame of
|
|
1007
|
+
// the idle he happens to be on — then his bones go back exactly as they were.
|
|
1008
|
+
const was = {};
|
|
1009
|
+
for (const n of Object.keys(pose)) if (pbone[n]) was[n] = pbone[n].quaternion.clone();
|
|
1010
|
+
for (const [n, q] of Object.entries(pose)) pbone[n]?.quaternion.copy(q);
|
|
1011
|
+
pl.model.updateWorldMatrix(true, true);
|
|
1012
|
+
r._measureSide();
|
|
1013
|
+
for (const [n, q] of Object.entries(was)) pbone[n].quaternion.copy(q);
|
|
1014
|
+
pl.model.updateWorldMatrix(true, true);
|
|
1015
|
+
this.playerRider = r;
|
|
1016
|
+
} catch (e) { console.warn('[coach] no seated pose for the player', e); }
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
console.log(`[coach] the line is running: ${this.coaches.length} coaches on ${ROUTE.length} legs`);
|
|
1020
|
+
return this;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
update(dt) {
|
|
1024
|
+
for (const c of this.coaches) c.update(dt);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Bring every rider's world position up to date RIGHT NOW. combat.js calls this immediately
|
|
1028
|
+
// before it sweeps its bullets, because main.js ticks combat BEFORE the coaches: without it,
|
|
1029
|
+
// every round is tested against where the crew were a frame ago (11cm out at a canter — which
|
|
1030
|
+
// is survivable, but being exact is two sines and a loop).
|
|
1031
|
+
syncRiders() {
|
|
1032
|
+
for (const c of this.coaches) c._syncRiders();
|
|
1033
|
+
}
|
|
1034
|
+
}
|