sindicate 0.1.0 → 0.4.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/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// A HORSEMAN COMING THE OTHER WAY ON AN EMPTY ROAD.
|
|
2
|
+
//
|
|
3
|
+
// It is the single most western image there is, and until now this county had four thousand metres of
|
|
4
|
+
// road and not one man on a horse using them. So: a Horse path-following one of terrainData's ROADS,
|
|
5
|
+
// with a man in the saddle — trotting out to the ranch by day, home by dark, and coming past you on
|
|
6
|
+
// the fort road at the exact moment you had decided the desert was empty.
|
|
7
|
+
//
|
|
8
|
+
// ── THE MAN IN THE SADDLE IS AN NPC, AND THAT IS THE WHOLE TRICK ────────────────────────────────
|
|
9
|
+
// He is in game.npcs. So combat.js's bullet loop finds him (shoot him off his horse), crime.js counts
|
|
10
|
+
// him as a witness and calls his killing a MURDER, loot.js drops his purse, alarmTown scatters him,
|
|
11
|
+
// and — this is the one that matters — coach.js's _blocked() look-ahead sees him standing in the road
|
|
12
|
+
// and the stagecoach BRAKES for him, without coach.js knowing that horses exist. Not one of those
|
|
13
|
+
// files needed an edit. fable's version kept its riders in a private list and paid for it in five
|
|
14
|
+
// systems that could not see them.
|
|
15
|
+
//
|
|
16
|
+
// The horse goes in game.horses, which buys the same trick again: the whisker steering already
|
|
17
|
+
// considers horses (entity.js _probeSteer), the player is already pushed out of them
|
|
18
|
+
// (player.separateFromPeople), and they already trample the grass.
|
|
19
|
+
//
|
|
20
|
+
// ── WHAT IS REUSED, VERBATIM ───────────────────────────────────────────────────────────────────
|
|
21
|
+
// horse.js driveMove terrain-follow + gait + bob. A traveller's horse moves the way yours does.
|
|
22
|
+
// riding.js SADDLE the rider-root offset, dialled in on the player's own mount.
|
|
23
|
+
// riding.js makeSeatedPose(root, BIPED_TO_SYNTY) + twoBoneIK — the seated pose and the rein IK, the
|
|
24
|
+
// same two calls the player's Riding controller makes. Baked at BOOT (a retarget
|
|
25
|
+
// mid-gunfight is a stall) and cached per source FBX — see _poses.
|
|
26
|
+
// traveller.js LANE, DAWN/DUSK, laneTarget, railShut — a horseman keeps to the same side of the same
|
|
27
|
+
// road in the same daylight as a man on foot. One definition, two riders.
|
|
28
|
+
//
|
|
29
|
+
// PERF CONTRACT (main.js:118): horse and rider are built ONCE on the loading screen and never leave
|
|
30
|
+
// the scene. Nothing here spawns.
|
|
31
|
+
import * as THREE from 'three/webgpu';
|
|
32
|
+
import { Horse } from './horse.js';
|
|
33
|
+
import { Character } from './entity.js';
|
|
34
|
+
import { NPC } from './npc.js';
|
|
35
|
+
import { makeSeatedPose, twoBoneIK, realBonesOf, BIPED_TO_SYNTY, SADDLE } from './riding.js';
|
|
36
|
+
|
|
37
|
+
import { LANE, DAWN, DUSK, BVH_R, roadCull, laneTarget, railShut } from './traveller.js';
|
|
38
|
+
import { steerCarrot, mountedTurn, nearestEndpoint } from './pathFollow.js';
|
|
39
|
+
// routes + venue hours are GAME data: setMountedContent({ routes, venueOpen })
|
|
40
|
+
let MC = { routes: [], venueOpen: () => true };
|
|
41
|
+
export function setMountedContent(c) { MC = { ...MC, ...c }; }
|
|
42
|
+
import { dist2D } from '../core/utils.js';
|
|
43
|
+
|
|
44
|
+
// A WORKING TROT (3.4 m/s). horse.js's gaitFor puts anything from 0.4 to 4 m/s on the trot clip, so
|
|
45
|
+
// this is the gait as well as the speed. It is also the right speed against the traffic: the stage
|
|
46
|
+
// cruises at a canter (6.5, coach.js CRUISE), so she comes up behind a rider at 3.1 m/s and passes
|
|
47
|
+
// him — which is what a stage did, and why the man on the box has a whip.
|
|
48
|
+
const TROT = 3.4;
|
|
49
|
+
const BOLT = 9.0; // …and what a frightened horse does instead. gaitFor: a canter.
|
|
50
|
+
const BOLT_SECS = 4.5; // how long it runs before it remembers it has somewhere to be
|
|
51
|
+
const TURN_RATE = 2.4; // rad/s toward the steered waypoint line — a horse turns, it does not pivot
|
|
52
|
+
const HORSE_RMIN = 3.5; // minimum turn radius (m): the turn is capped at wmax = speed / Rmin so a
|
|
53
|
+
// boxed-in horse curves out and round a fence instead of pivoting into it
|
|
54
|
+
const ARRIVE = 3.2; // a horse is longer than a man: give the waypoint a wider gate
|
|
55
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
56
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
57
|
+
const _aim = { x: 0, z: 0 }; // scratch: the point steerCarrot hands back to steer at
|
|
58
|
+
// THE SALOON CALL — where a passing rider ties up for a drink. Two rail spots on the street's east
|
|
59
|
+
// side, clear of the town's three hitched horses (z −16/−4/10); riders alternate between them.
|
|
60
|
+
const SALOON_HITCH = [{ x: 6.5, z: 2 }, { x: 6.5, z: 18 }];
|
|
61
|
+
let _hitchNext = 0;
|
|
62
|
+
|
|
63
|
+
// THE SEATED POSE IS CACHED PER SOURCE FBX, NOT PER RIDER AND NOT ONCE FOR EVERYONE.
|
|
64
|
+
//
|
|
65
|
+
// A pose is {boneName → localQuaternion}, and it is only transferable between two bodies that share a
|
|
66
|
+
// BIND. The cast comes out of two different packs (data/characters.js: WesternTownChars.fbx and
|
|
67
|
+
// WesternCharacters.fbx), and this game has been bitten before by assuming two Synty rigs agree —
|
|
68
|
+
// see the retarget notes in clips.js and the contorted-NPC trap. A skeleton is a property of the FBX,
|
|
69
|
+
// so that is what the cache is keyed on: two bakes for six riders, both on the loading screen, and a
|
|
70
|
+
// pack that turns out to bind differently sits correctly anyway instead of subtly wrong.
|
|
71
|
+
const _poses = new Map(); // model src → {bone: localQuat}
|
|
72
|
+
const _tl = new THREE.Vector3(), _tr = new THREE.Vector3();
|
|
73
|
+
const _tgt = new THREE.Vector3(), _dir = new THREE.Vector3(), _prevT = new THREE.Vector3();
|
|
74
|
+
|
|
75
|
+
function angleDelta(a, b) { let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; }
|
|
76
|
+
|
|
77
|
+
// THE MAN. An ordinary NPC in every respect the rest of the game can see — but while he is up there
|
|
78
|
+
// his legs are not his own: the MountedTraveller writes his position and his pose every frame, so
|
|
79
|
+
// every AI branch NPC would otherwise take (wander the street, run away on foot, strike up a chat)
|
|
80
|
+
// has to be switched off, and switched back ON the moment he hits the dirt.
|
|
81
|
+
class Rider extends NPC {
|
|
82
|
+
get cullDist() { return roadCull(this.game); }
|
|
83
|
+
get aiRange() { return this.cullDist; }
|
|
84
|
+
|
|
85
|
+
update(dt) {
|
|
86
|
+
// SEATED: Character-level only. The health, the stagger clock, the distance cull, the frustum cull
|
|
87
|
+
// and the animator — everything a body needs — and NOT the AI, which would try to walk him home.
|
|
88
|
+
if (this.seated) { Character.prototype.update.call(this, dt); return; }
|
|
89
|
+
// INSIDE THE SALOON (the saloon-call, below): past the batwings he is fiction — invisible,
|
|
90
|
+
// untickable, waiting out his drink on the traveller's clock.
|
|
91
|
+
if (this._inSaloon) return;
|
|
92
|
+
// WALKING A GOAL (saloon-call): dismounted, crossing the street to the batwings or back to his
|
|
93
|
+
// horse. Whisker-steered, then his errand's callback fires and the traveller moves him on.
|
|
94
|
+
if (this._goal) {
|
|
95
|
+
const g = this._goal;
|
|
96
|
+
const dx = g.x - this.position.x, dz = g.z - this.position.z, L = Math.hypot(dx, dz);
|
|
97
|
+
if (L < 1.4) { const then = g.then; this._goal = null; this.play('idle'); then?.(); }
|
|
98
|
+
else {
|
|
99
|
+
const sd = this.walkDir(_dir.set(dx / L, 0, dz / L), dt);
|
|
100
|
+
this.heading = Math.atan2(sd.x, sd.z);
|
|
101
|
+
this.root.rotation.y = this.heading;
|
|
102
|
+
this.move(sd, 1.8, dt);
|
|
103
|
+
this.play('walk');
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
super.update(dt); // in the dirt: he is a townsman again, and NPC does the rest
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A MAN IN THE SADDLE DOES NOT RUN AWAY. HIS HORSE DOES.
|
|
111
|
+
// scare() is what a gunshot, a body in the street or a shut level crossing does to a townsman
|
|
112
|
+
// (npc.js alarmTown → scare → flee on foot). A seated man cannot flee on foot, and a rider who
|
|
113
|
+
// dismounts to jog away from a bang is a puppet. So the fright is passed to the animal under him,
|
|
114
|
+
// which is where a fright belongs: it BOLTS (see MountedTraveller.spook).
|
|
115
|
+
scare(x, z, secs = 2.5, force = false) {
|
|
116
|
+
if (!this.alive) return;
|
|
117
|
+
if (this.seated) { this.mount?.spook(x, z, secs); return; }
|
|
118
|
+
super.scare(x, z, secs, force);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// SHOT OFF THE HORSE. He goes into the dirt FIRST and dies there — because NPC.die() reads his
|
|
122
|
+
// position for the crime report and for the purse, and a purse that spills at saddle height hangs in
|
|
123
|
+
// the air a metre and a half over the road.
|
|
124
|
+
die() {
|
|
125
|
+
if (!this.alive) return;
|
|
126
|
+
this.mount?.unseat();
|
|
127
|
+
super.die();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class MountedTraveller {
|
|
132
|
+
constructor(game, def) {
|
|
133
|
+
this.game = game;
|
|
134
|
+
this.def = def;
|
|
135
|
+
const rt = MC.routes[def.route];
|
|
136
|
+
if (!rt) throw new Error(`[rider] ${def.id}: no such route '${def.route}'`);
|
|
137
|
+
this.road = rt.pts;
|
|
138
|
+
this.built = rt.built; // where the road runs through a town: keep the BVH on
|
|
139
|
+
this.routeName = def.route;
|
|
140
|
+
this.dir = def.dir ?? 1;
|
|
141
|
+
const last = this.road.length - 1;
|
|
142
|
+
this._start = Math.max(0, Math.min(last, Math.round((def.start ?? 0) * last)));
|
|
143
|
+
this._wp = Math.max(0, Math.min(last, this._start + this.dir));
|
|
144
|
+
this.pauseT = 0;
|
|
145
|
+
this.bolt = 0;
|
|
146
|
+
this._home = false;
|
|
147
|
+
this._day = true; // the sky starts at 10:00 (world/sky.js). A bolt can call _advance before the
|
|
148
|
+
// daylight branch has ever run, and an undefined `_day` there would send a
|
|
149
|
+
// frightened horse "home" at ten in the morning.
|
|
150
|
+
this.spot = { out: Math.random() * 3, along: (Math.random() - 0.5) * 16 }; // a horse wants more room
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async load(clips) {
|
|
154
|
+
const g = this.game;
|
|
155
|
+
const p = this.road[this._start];
|
|
156
|
+
const horse = new Horse(g, { x: p.x, z: p.z, heading: 0, saddle: true, coat: this.def.coat });
|
|
157
|
+
await horse._buildModel(); // the ctor already put it on the ground at (x,z)
|
|
158
|
+
horse.heading = Math.atan2(this.road[this._wp].x - p.x, this.road[this._wp].z - p.z);
|
|
159
|
+
this.horse = horse;
|
|
160
|
+
|
|
161
|
+
const rider = new Rider(g, { ...this.def, roam: { x: p.x, z: p.z, r: 0 } }, clips);
|
|
162
|
+
await rider.load();
|
|
163
|
+
// THE DEFERRED FOOT-GROUNDING MUST NOT RUN. Character.load() has already measured his feetDrop from
|
|
164
|
+
// the BIND pose, which is the right answer; the re-measure at t+0.5 s would measure him in the SEATED
|
|
165
|
+
// pose, find his boots up in the stirrups, and lift the whole man half a metre off his own horse.
|
|
166
|
+
// (riding.js never hits this because the player is not re-measured while he is mounted.)
|
|
167
|
+
rider._grounded = true;
|
|
168
|
+
rider.seated = true;
|
|
169
|
+
rider.mount = this;
|
|
170
|
+
this.rider = rider;
|
|
171
|
+
|
|
172
|
+
// The seated pose is a RETARGET — a retarget mid-gunfight is a stall, so it is baked HERE, on the
|
|
173
|
+
// loading screen, and never again. Cached per source FBX (see _poses).
|
|
174
|
+
const src = rider.modelUrl;
|
|
175
|
+
if (!_poses.has(src)) _poses.set(src, (await makeSeatedPose(rider.root, BIPED_TO_SYNTY)).pose);
|
|
176
|
+
this.pose = _poses.get(src);
|
|
177
|
+
this.pbone = {};
|
|
178
|
+
for (const b of realBonesOf(rider.root)) this.pbone[b.name] = b;
|
|
179
|
+
|
|
180
|
+
// EVERYTHING IS BUILT — only now does the world find out about them. Publish the horse before it is
|
|
181
|
+
// whole and a load that throws halfway leaves game.horses holding an animal marked `rider`: it can
|
|
182
|
+
// never graze, can never be mounted, and nothing will ever tick it, because the thing that was
|
|
183
|
+
// going to is the thing that threw.
|
|
184
|
+
horse.rider = this; // ridden → the horse skips its own graze/wander AI
|
|
185
|
+
g.scene.add(horse.root);
|
|
186
|
+
g.scene.add(rider.root);
|
|
187
|
+
g.horses.push(horse); // …now the steering, the shove and the trample see it
|
|
188
|
+
g.npcs.push(rider); // …and the bullet, the law, the alarm and the stage
|
|
189
|
+
this._seat();
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// WHERE HE IS. Always — on screen or off, drawn or not. His `position` is what combat.js aims a
|
|
194
|
+
// bullet at, what crime.js measures a witness by, and what the stagecoach's look-ahead brakes for,
|
|
195
|
+
// and a rider whose body is one place while his position is another is a rider you cannot shoot.
|
|
196
|
+
_place() {
|
|
197
|
+
const h = this.horse, r = this.rider;
|
|
198
|
+
h.saddlePos(r.root.position, SADDLE); // getWorldPosition walks the horse's root: no stale bone
|
|
199
|
+
r.root.rotation.y = h.heading;
|
|
200
|
+
r.heading = h.heading; // the minimap arrow and faceToward read this
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// …and how he SITS. The baked seated pose, then his hands two-bone-IK'd onto the horse's rein bones
|
|
204
|
+
// — exactly what riding.js does for the player, on the same horse. Only worth doing when he is
|
|
205
|
+
// actually being drawn: it is four solves and a matrix refresh, and nobody can see a hidden man's grip.
|
|
206
|
+
_pose() {
|
|
207
|
+
const h = this.horse, r = this.rider;
|
|
208
|
+
for (const n in this.pose) { const b = this.pbone[n]; if (b) b.quaternion.copy(this.pose[n]); }
|
|
209
|
+
if (!h.reinL || !h.reinR) return;
|
|
210
|
+
r.root.updateMatrixWorld(true); // pose applied → refresh before the arm IK
|
|
211
|
+
h.reinL.getWorldPosition(_tl);
|
|
212
|
+
h.reinR.getWorldPosition(_tr);
|
|
213
|
+
twoBoneIK(this.pbone.Shoulder_L, this.pbone.Elbow_L, this.pbone.Hand_L, _tl);
|
|
214
|
+
twoBoneIK(this.pbone.Shoulder_R, this.pbone.Elbow_R, this.pbone.Hand_R, _tr);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_seat() { this._place(); this._pose(); }
|
|
218
|
+
|
|
219
|
+
// Something frightened the horse — a shot, a body in the street, a train on the crossing. It runs,
|
|
220
|
+
// AWAY, at a canter, for a few seconds; if the fright is ahead of it on the road it turns round
|
|
221
|
+
// first. Then it remembers it has somewhere to be. (This is the mounted answer to npc.js's flee,
|
|
222
|
+
// and it is the reason a rider is not just a townsman with an expensive hat.)
|
|
223
|
+
spook(x, z, secs = 2.5) {
|
|
224
|
+
if (!this.rider.alive) return;
|
|
225
|
+
const first = this.bolt <= 0;
|
|
226
|
+
this.bolt = Math.max(this.bolt, Math.min(secs, BOLT_SECS));
|
|
227
|
+
this.pauseT = 0;
|
|
228
|
+
this._home = false;
|
|
229
|
+
// WHICH WAY TO RUN IS DECIDED ONCE, AT THE BANG. A gunfight is a dozen alarmTown calls in four
|
|
230
|
+
// seconds and a man standing on a level crossing is frightened off it every frame he is still on
|
|
231
|
+
// it — and a horse that re-chooses its direction on every one of them turns round, turns round
|
|
232
|
+
// again, and dies exactly where it was standing.
|
|
233
|
+
if (!first) return;
|
|
234
|
+
const h = this.horse;
|
|
235
|
+
const away = (h.position.x - x) * Math.sin(h.heading) + (h.position.z - z) * Math.cos(h.heading);
|
|
236
|
+
if (away < 0) this._turnBack(); // the fright is in FRONT of him — he does not gallop into it
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A rider who is shot goes into the dirt beside his horse, and the horse — reins slack, a shot going
|
|
240
|
+
// off behind its ears — bolts. Same beat as the coach's team (coach.js _boltTeam), same reason.
|
|
241
|
+
unseat() {
|
|
242
|
+
const r = this.rider, h = this.horse;
|
|
243
|
+
if (!r.seated) return;
|
|
244
|
+
r.seated = false;
|
|
245
|
+
// Down beside the horse, on the ground, with his own feet back under him: his model still carries
|
|
246
|
+
// the feetDrop Character.load() measured from the bind, so the root going to the terrain puts his
|
|
247
|
+
// boots on it. The death clip then plays on an ordinary standing body.
|
|
248
|
+
const bx = h.position.x + Math.cos(h.heading) * 1.3;
|
|
249
|
+
const bz = h.position.z - Math.sin(h.heading) * 1.3;
|
|
250
|
+
r.setPosition(bx, this.game.world.groundAt(bx, bz), bz);
|
|
251
|
+
r.root.rotation.set(0, h.heading, 0);
|
|
252
|
+
r._visualY = undefined; // let the descent smoother re-latch where he landed
|
|
253
|
+
// The bones are LEFT where the sit left them, and are not reset to identity: _seat() stops running
|
|
254
|
+
// the moment he is unseated, and the death clip his Animator is about to play (VILLAGER_CLIPS
|
|
255
|
+
// carries a full-body one) writes every bone in it. Identity is not the bind pose — snapping the
|
|
256
|
+
// skeleton to it would break every bone the clip does not happen to name.
|
|
257
|
+
this.bolt = BOLT_SECS;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// PULLED FROM THE SADDLE. The player walked up and stole the horse (main.js E-scan). The rider
|
|
261
|
+
// lands on his feet beside it and bolts on foot; horse.rider goes null, so update() returns early
|
|
262
|
+
// from here on and the horse is free for riding.mount() to take. Returns the horse to mount.
|
|
263
|
+
dismountForTheft(threatX, threatZ) {
|
|
264
|
+
if (!this.rider.seated) return null;
|
|
265
|
+
this.unseat(); // rider to the dirt, seated = false — now an ordinary foot NPC
|
|
266
|
+
this.horse.rider = null; // decouple: the loop below stops touching horse and rider
|
|
267
|
+
this.rider.scare?.(threatX, threatZ, 6, true); // on his feet now, scare = flee on foot
|
|
268
|
+
return this.horse;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
update(dt) {
|
|
272
|
+
const g = this.game, h = this.horse, r = this.rider;
|
|
273
|
+
|
|
274
|
+
// ONCE THE TRAVELLER NO LONGER OWNS THE HORSE, IT LETS GO ENTIRELY. horse.rider goes null when the
|
|
275
|
+
// rider is shot and the animal runs itself out — OR it becomes the PLAYER when he steals the horse
|
|
276
|
+
// (dismountForTheft). Either way it is not `this` any more, and the moment it isn't we stop: keep
|
|
277
|
+
// driving it and the ride controller fights us over the gait (legs freeze) while _place/_pose drag
|
|
278
|
+
// the thrown rider back onto the saddle. horse.js's own AI or the player's Riding ticks it now.
|
|
279
|
+
if (h.rider !== this) return;
|
|
280
|
+
|
|
281
|
+
// A DEAD MAN'S HORSE. It bolts — and it does so wherever it is, whether or not you are watching,
|
|
282
|
+
// because a horse left with `rider` set is a horse that never grazes again and cannot be mounted.
|
|
283
|
+
// Once it has run itself out, main.js's frustum net and horse.js's ambient AI take it from there,
|
|
284
|
+
// and it grazes wherever it fetched up. A riderless horse standing on an empty road is the most
|
|
285
|
+
// eloquent thing a road can have.
|
|
286
|
+
if (!r.alive) {
|
|
287
|
+
if (this.bolt > 0) {
|
|
288
|
+
this.bolt -= dt;
|
|
289
|
+
this._drive(h, BOLT, 0, dt, true);
|
|
290
|
+
h.mixer?.update(dt); // 4½ seconds of one horse: not worth a cull test
|
|
291
|
+
if (this.bolt <= 0) { h.rider = null; h.speed = 0; h.anchor = { x: h.position.x, z: h.position.z }; }
|
|
292
|
+
}
|
|
293
|
+
h._mixerTicked = true; // (once released, main.js's net takes its visibility back)
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── THE CULL, AND IT IS THE WALKERS' CULL, NOT A SECOND ONE ────────────────────────────────
|
|
298
|
+
// PAST cullDist he FREEZES, exactly as npc.js freezes a townsman past aiRange: hidden, no AI, no
|
|
299
|
+
// terrain sample, one dist2D a frame. That is what makes twenty-five extra bodies affordable.
|
|
300
|
+
// INSIDE it he keeps riding EVEN WHEN OFF SCREEN — the frustum only stops him being DRAWN and
|
|
301
|
+
// SKINNED. Freeze him on the frustum instead and a rider you glance away from is a rider standing
|
|
302
|
+
// in exactly the same place when you glance back, which is entity.js's stated "no pop" contract
|
|
303
|
+
// broken. So the two tests do different jobs, and this is the line that keeps them apart.
|
|
304
|
+
const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
|
|
305
|
+
if (pd > roadCull(g)) {
|
|
306
|
+
if (h.model) h.model.visible = false;
|
|
307
|
+
h._mixerTicked = true; // …and don't skin a horse nobody can see
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Inside the cull, his visibility IS the rider's: Character.update already frustum-tested the man
|
|
311
|
+
// in the saddle this frame (main.js ticks game.npcs before this loop), and the horse sits inside
|
|
312
|
+
// his 2.2 m body sphere. One cull, not two. main.js's own horse net skips ridden horses — which is
|
|
313
|
+
// exactly why this line has to exist.
|
|
314
|
+
const shown = !!r.model?.visible;
|
|
315
|
+
if (h.model) h.model.visible = shown;
|
|
316
|
+
|
|
317
|
+
// A SHUT LEVEL CROSSING IS A FRIGHT, NOT A NEGOTIATION — the same guard the walkers read. Asked
|
|
318
|
+
// only when he is NOT already running from it, or spook() would be re-deciding his direction every
|
|
319
|
+
// frame he stood on the rails.
|
|
320
|
+
if (this.bolt <= 0) {
|
|
321
|
+
const xing = railShut(g, h.position.x, h.position.z);
|
|
322
|
+
if (xing) this.spook(xing.at.x, xing.at.z, 3);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── THE SALOON CALL ──────────────────────────────────────────────────────────────────────────
|
|
326
|
+
// A rider passing Dustwater with the bar pouring sometimes turns in for one: hitch at the rail,
|
|
327
|
+
// walk to the batwings, a drink's worth of minutes inside, back out, swing up, ride on. Rolled
|
|
328
|
+
// ONCE per approach (reset when he's clear of town), never while bolting or after dark's ride home.
|
|
329
|
+
if (this._saloon) { this._saloonTick(dt, shown); return; }
|
|
330
|
+
if (this.bolt <= 0 && !this._home) {
|
|
331
|
+
const dS = dist2D(h.position.x, h.position.z, -11, 10);
|
|
332
|
+
if (dS < 45 && !this._saloonTried) {
|
|
333
|
+
this._saloonTried = true;
|
|
334
|
+
if (Math.random() < 0.45 && MC.venueOpen(g.world.sky?.hour ?? 12)) {
|
|
335
|
+
this._saloon = { phase: 'ride', hitch: SALOON_HITCH[(_hitchNext++) % SALOON_HITCH.length] };
|
|
336
|
+
}
|
|
337
|
+
} else if (dS > 90) this._saloonTried = false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let speed = 0, turn = 0;
|
|
341
|
+
if (this.bolt > 0) {
|
|
342
|
+
this.bolt -= dt;
|
|
343
|
+
// It runs down the ROAD, not off across the desert — and at 9 m/s it eats a 25 m leg in under
|
|
344
|
+
// three seconds, so it has to keep taking the next one or it circles the waypoint it overshot.
|
|
345
|
+
laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
|
|
346
|
+
if (dist2D(h.position.x, h.position.z, _tgt.x, _tgt.z) <= ARRIVE * 1.6) this._advance();
|
|
347
|
+
turn = this._steer(dt); // sets _steerMul (reins in on a hard turn)
|
|
348
|
+
speed = BOLT * (this._steerMul ?? 1);
|
|
349
|
+
} else {
|
|
350
|
+
const hour = g.world.sky?.hour ?? 10;
|
|
351
|
+
const day = hour >= DAWN && hour < DUSK;
|
|
352
|
+
const last = this.road.length - 1;
|
|
353
|
+
if (day !== this._day) {
|
|
354
|
+
this._day = day;
|
|
355
|
+
if (!day) this.dir = this._wp * 2 <= last ? -1 : 1; // home, by the nearer end
|
|
356
|
+
else if (this._home) { this._home = false; this.dir = this._wp <= 0 ? 1 : -1; } // …and out again at first light
|
|
357
|
+
}
|
|
358
|
+
if (this._home) speed = 0; // stood at the rail at the end of his road
|
|
359
|
+
else if (this.pauseT > 0) { this.pauseT -= dt; speed = 0; }
|
|
360
|
+
else {
|
|
361
|
+
laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
|
|
362
|
+
if (dist2D(h.position.x, h.position.z, _tgt.x, _tgt.z) <= ARRIVE) this._advance();
|
|
363
|
+
else { turn = this._steer(dt); speed = TROT * (h._steerSlow ? 0.6 : 1) * (this._steerMul ?? 1); } // rein in while boxed-in
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// STUCK BACKSTOP. A horse pinned on a static corner the whiskers can't clear used to grind there
|
|
368
|
+
// forever — the mount had NO give-up. The A* path is the proper way round; this catches what it
|
|
369
|
+
// can't (grid still baking, an unreachable goal, a dynamic pin): if we mean to move but haven't in
|
|
370
|
+
// 1.4 s, drop the held path and skip to the next road leg. Same backstop traveller.js gives a walker.
|
|
371
|
+
if (speed > 0.1) {
|
|
372
|
+
this._stuckT = (this._stuckT ?? 0) + dt;
|
|
373
|
+
if (this._stuckT >= 1.4) {
|
|
374
|
+
const moved = dist2D(h.position.x, h.position.z, this._lastSX ?? h.position.x, this._lastSZ ?? h.position.z);
|
|
375
|
+
this._lastSX = h.position.x; this._lastSZ = h.position.z; this._stuckT = 0;
|
|
376
|
+
if (moved < 0.5) { this._rtPath = null; if (this.bolt <= 0) this._advance(); }
|
|
377
|
+
}
|
|
378
|
+
} else { this._stuckT = 0; this._lastSX = h.position.x; this._lastSZ = h.position.z; }
|
|
379
|
+
|
|
380
|
+
this._drive(h, speed, turn, dt, this.bolt > 0 || this.built[this._wp]);
|
|
381
|
+
// THE GAIT TICKS BEFORE THE SEAT, and riding.js says why: read the saddle bone before the mixer has
|
|
382
|
+
// advanced it and the horse's fore-aft surge and tilt reach the rider a frame late, which reads as
|
|
383
|
+
// "the saddle moves but he doesn't". Hidden, there is no tick and the bone is last-drawn — but
|
|
384
|
+
// saddlePos walks the horse's ROOT, so he still rides with it, centimetres out and nobody looking.
|
|
385
|
+
if (shown) h.mixer?.update(dt);
|
|
386
|
+
h._mixerTicked = true; // either we ticked it, or it must not be ticked at all
|
|
387
|
+
this._place(); // he rides with the horse whether or not he is drawn…
|
|
388
|
+
if (shown) this._pose(); // …but only a man being LOOKED at needs his grip solved
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// THE SALOON CALL's clock (see update). Phases: ride → (dismount) → walk → drink → return → resume.
|
|
392
|
+
// While the man drinks, the horse stands the rail like the town's own — idle mixer, no seat solve.
|
|
393
|
+
_saloonTick(dt, shown) {
|
|
394
|
+
const s = this._saloon, h = this.horse, r = this.rider, g = this.game;
|
|
395
|
+
if (!r.alive) { this._saloon = null; return; } // shot on his way to a drink — update()'s death path owns it next frame
|
|
396
|
+
if (s.phase === 'ride') {
|
|
397
|
+
const d = dist2D(h.position.x, h.position.z, s.hitch.x, s.hitch.z);
|
|
398
|
+
if (d > 2.4) {
|
|
399
|
+
const sd = Character.prototype.walkDir.call(h, _dir.set((s.hitch.x - h.position.x) / d, 0, (s.hitch.z - h.position.z) / d), dt);
|
|
400
|
+
const m = mountedTurn(h.heading, h.position.x, h.position.z, h.position.x + sd.x, h.position.z + sd.z, Math.max(0.8, h.speed), HORSE_RMIN, TURN_RATE);
|
|
401
|
+
this._drive(h, TROT * 0.8 * m.speedMul, m.turn, dt, true);
|
|
402
|
+
if (shown) h.mixer?.update(dt);
|
|
403
|
+
h._mixerTicked = true;
|
|
404
|
+
this._place(); if (shown) this._pose();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// pulled up at the rail: step down (the CALM dismount — unseat() is the shot man's, it bolts the horse)
|
|
408
|
+
h.speed = 0;
|
|
409
|
+
r.seated = false;
|
|
410
|
+
const bx = h.position.x + Math.cos(h.heading) * 1.3, bz = h.position.z - Math.sin(h.heading) * 1.3;
|
|
411
|
+
r.setPosition(bx, g.world.groundAt(bx, bz), bz);
|
|
412
|
+
r.root.rotation.set(0, h.heading, 0);
|
|
413
|
+
r._visualY = undefined;
|
|
414
|
+
r._goal = { x: -10.6, z: 10, then: () => { r._inSaloon = true; if (r.root) r.root.visible = false; } };
|
|
415
|
+
s.phase = 'walk'; s.dwell = 26 + Math.random() * 22;
|
|
416
|
+
} else if (s.phase === 'walk') {
|
|
417
|
+
if (r._inSaloon) s.phase = 'drink';
|
|
418
|
+
} else if (s.phase === 'drink') {
|
|
419
|
+
s.dwell -= dt;
|
|
420
|
+
if (s.dwell <= 0) {
|
|
421
|
+
r._inSaloon = false;
|
|
422
|
+
if (r.root) r.root.visible = true;
|
|
423
|
+
r.setPosition(-10.6, g.world.groundAt(-10.6, 10), 10);
|
|
424
|
+
const bx = h.position.x + Math.cos(h.heading) * 1.3, bz = h.position.z - Math.sin(h.heading) * 1.3;
|
|
425
|
+
r._goal = { x: bx, z: bz, then: () => { r.seated = true; this._saloon = null; } }; // swing up, ride on
|
|
426
|
+
s.phase = 'return';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// walk/drink/return: the horse stands the rail — idle gait, breathing mixer, no rider to place
|
|
430
|
+
h.play?.('idle');
|
|
431
|
+
if (shown) h.mixer?.update(dt);
|
|
432
|
+
h._mixerTicked = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Point him at the lane target, but let the WHISKERS bend it — the same steering the walkers use,
|
|
436
|
+
// probed from the HORSE (which stands on the ground) and not from the saddle (which is a metre and a
|
|
437
|
+
// half in the air, where the fences are not). horse.js already borrows walkDir/_probeSteer for its
|
|
438
|
+
// grazers; this is the same borrow.
|
|
439
|
+
_steer(dt) {
|
|
440
|
+
const h = this.horse, g = this.game;
|
|
441
|
+
laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
|
|
442
|
+
let aimX = _tgt.x, aimZ = _tgt.z;
|
|
443
|
+
// LINE-FOLLOWING, NOT NODE-HOMING. Aiming AT the waypoint's lane point makes the steering error
|
|
444
|
+
// blow up as he closes on it — a half-metre of lateral drift is a few degrees at 20 m and thirty
|
|
445
|
+
// at three — so he over-turns, crosses the line, and the next node hauls him back: the
|
|
446
|
+
// left-right-left diagonals Nick watched. Project onto the LEG instead and chase a carrot a
|
|
447
|
+
// fixed distance ahead ALONG it: the correction stays proportional to the drift, and the line
|
|
448
|
+
// he rides is the road's own.
|
|
449
|
+
const pw = this._wp - this.dir;
|
|
450
|
+
if (pw >= 0 && pw < this.road.length) {
|
|
451
|
+
laneTarget(_prevT, this.road, pw, this.dir, LANE, this.spot);
|
|
452
|
+
const ex = _tgt.x - _prevT.x, ez = _tgt.z - _prevT.z, L2 = ex * ex + ez * ez;
|
|
453
|
+
if (L2 > 4) {
|
|
454
|
+
const L = Math.sqrt(L2);
|
|
455
|
+
const t = clamp(((h.position.x - _prevT.x) * ex + (h.position.z - _prevT.z) * ez) / L2, 0, 1);
|
|
456
|
+
const tc = Math.min(1, t + 7.0 / L); // the carrot: 7 m ahead along the leg, never past the node
|
|
457
|
+
aimX = _prevT.x + ex * tc; aimZ = _prevT.z + ez * tc;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// STATIC-obstacle pathing, but only where the capsule is even on — inside BVH_R of the player or on
|
|
461
|
+
// a built-up stretch. Out on the open road _drive is terrain-follow only (it rides THROUGH a fence
|
|
462
|
+
// harmlessly, unseen), so there is nothing to route around and we keep the cheap straight aim. When
|
|
463
|
+
// the grid says the road ahead is blocked, steerCarrot holds an A* path round it (the settlement's
|
|
464
|
+
// own baked grid — reused, not re-baked) and hands back a look-ahead carrot to curve onto.
|
|
465
|
+
const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
|
|
466
|
+
if (pd < BVH_R || this.built[this._wp]) {
|
|
467
|
+
const centre = nearestEndpoint(this.road, h.position.x, h.position.z);
|
|
468
|
+
if (steerCarrot(this, g, centre, h.position.x, h.position.z, _tgt.x, _tgt.z, _aim,
|
|
469
|
+
{ dt, arrive: ARRIVE, lookahead: clamp(h.speed * 0.5, 2.5, 4.5), wide: true })) { aimX = _aim.x; aimZ = _aim.z; }
|
|
470
|
+
} else { this._rtPath = null; }
|
|
471
|
+
// WHISKERS bend the aim for DYNAMIC things (people, other horses), probed from the HORSE — on the
|
|
472
|
+
// ground where the fences are, not from the saddle a metre and a half up in the air.
|
|
473
|
+
const dx = aimX - h.position.x, dz = aimZ - h.position.z, L = Math.hypot(dx, dz) || 1;
|
|
474
|
+
const sd = Character.prototype.walkDir.call(h, _dir.set(dx / L, 0, dz / L), dt);
|
|
475
|
+
// Pure-pursuit turn with a real minimum radius: TURN_RATE alone (an uncapped gain) let a boxed-in
|
|
476
|
+
// horse spin on the spot and snap straight back into the rail. mountedTurn caps it at wmax =
|
|
477
|
+
// speed/Rmin and reins the pace in on a hard heading error, so it slows and curves round instead.
|
|
478
|
+
const m = mountedTurn(h.heading, h.position.x, h.position.z, h.position.x + sd.x, h.position.z + sd.z, Math.max(0.6, h.speed), HORSE_RMIN, TURN_RATE);
|
|
479
|
+
this._steerMul = m.speedMul;
|
|
480
|
+
return m.turn;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Move the animal. Inside 45 m of the player — and ALWAYS through a town, and always while it is
|
|
484
|
+
// bolting — that is the full capsule controller (horse.driveMove: BVH + terrain-follow + gait + bob).
|
|
485
|
+
// Out on the open road, where the whiskers are already off (entity.js walkDir) and the going is
|
|
486
|
+
// graded, straight and empty, it is terrain-follow only: the trade horse.js's own ambient grazers
|
|
487
|
+
// make, for the reason they state ("NO BVH… a needless cost").
|
|
488
|
+
_drive(h, speed, turn, dt, hard = false) {
|
|
489
|
+
const g = this.game;
|
|
490
|
+
const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
|
|
491
|
+
if (pd < BVH_R || hard) { h.driveMove(speed, turn, dt); return; }
|
|
492
|
+
h.speed = speed;
|
|
493
|
+
h.heading += turn * dt;
|
|
494
|
+
h.position.x += Math.sin(h.heading) * speed * dt;
|
|
495
|
+
h.position.z += Math.cos(h.heading) * speed * dt;
|
|
496
|
+
h.position.y = g.world.groundAt(h.position.x, h.position.z);
|
|
497
|
+
h.root.quaternion.setFromAxisAngle(UP, h.heading);
|
|
498
|
+
if (!h.airborne) h.play(h.gaitFor(Math.abs(speed)));
|
|
499
|
+
h._applyBob(dt);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
_turnBack() {
|
|
503
|
+
this.dir = -this.dir;
|
|
504
|
+
this._wp = Math.max(0, Math.min(this.road.length - 1, this._wp + this.dir));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
_advance() {
|
|
508
|
+
const next = this._wp + this.dir;
|
|
509
|
+
if (next < 0 || next >= this.road.length) {
|
|
510
|
+
if (!this._day) { this._home = true; return; }
|
|
511
|
+
this.dir = -this.dir;
|
|
512
|
+
this.pauseT = 3 + Math.random() * 5; // he stands at the rail a while before he rides back
|
|
513
|
+
this._wp += this.dir;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this._wp = next;
|
|
517
|
+
}
|
|
518
|
+
}
|