sindicate 0.2.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/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/engine.js +284 -0
- package/src/index.js +13 -1
- 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,107 @@
|
|
|
1
|
+
// pathFollow.js — the piece the ROAD travellers were missing.
|
|
2
|
+
//
|
|
3
|
+
// Enemies (enemy.js) and town wanderers (npc.js) already route AROUND static obstacles: they ask the
|
|
4
|
+
// baked grid A* in nav.js for a path and follow its waypoints, with the whisker layer (entity.js
|
|
5
|
+
// walkDir) only handling DYNAMIC dodging on top. The men and women OUT ON THE ROADS — travellers on
|
|
6
|
+
// foot and the horsemen — never got wired to that A*. They aimed straight at the next road point and
|
|
7
|
+
// let the whiskers swerve, which is a few metres of reactive side-step: fine for a person crossing the
|
|
8
|
+
// street, useless against a fence PANEL that runs twenty metres. So a horseman met a paddock rail
|
|
9
|
+
// leaving Dustwater, the whiskers could not find a way round a wall that long, and he rode into it and
|
|
10
|
+
// kept riding into it (mountedTraveller had no give-up at all). Nick: "he keeps trying to ride through
|
|
11
|
+
// it instead of turning to find a better route."
|
|
12
|
+
//
|
|
13
|
+
// This module is the shared bridge to the EXISTING grid A*, for both riders. It is deliberately small:
|
|
14
|
+
// steerCarrot LOS-gate + follow. When the straight line to the goal is clear (the common case,
|
|
15
|
+
// every metre of open road), it does nothing and the caller aims straight — zero cost,
|
|
16
|
+
// identical behaviour. Only when the grid says the line is BLOCKED by static geometry
|
|
17
|
+
// does it hold an A* path around it and hand back a look-ahead "carrot" to steer at.
|
|
18
|
+
// mountedTurn pure-pursuit turn with a real MINIMUM TURN RADIUS. The horse's TURN_RATE had no cap,
|
|
19
|
+
// so a boxed-in animal spun on the spot and snapped straight back into the rail; a
|
|
20
|
+
// wmax = speed/Rmin gives it an arc it has to follow, so it curves out and round.
|
|
21
|
+
//
|
|
22
|
+
// It allocates nothing in the hot path: module scratch objects, and the A* path array is only rebuilt
|
|
23
|
+
// on a throttled re-path (nav.js reuses its own scratch inside path()).
|
|
24
|
+
import { dist2D } from '../core/utils.js';
|
|
25
|
+
|
|
26
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
27
|
+
const _f = { x: 0, z: 0 }, _t = { x: 0, z: 0 }; // scratch endpoints handed to the nav queries
|
|
28
|
+
|
|
29
|
+
export 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; }
|
|
30
|
+
|
|
31
|
+
// The nearer of a route's two ENDS. Both ends of every route are a settlement (data/travellers.js), so
|
|
32
|
+
// this is the settlement whose baked grid should serve the traveller while he is near it — and it is
|
|
33
|
+
// the same 80 m tile that settlement's own wanderers keyed their grid on, so the grid is SHARED, not
|
|
34
|
+
// baked twice. Out in the middle of the road it is far from both ends and nav returns null → straight.
|
|
35
|
+
export function nearestEndpoint(road, x, z) {
|
|
36
|
+
const a = road[0], b = road[road.length - 1];
|
|
37
|
+
return dist2D(x, z, a.x, a.z) <= dist2D(x, z, b.x, b.z) ? a : b;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// LOS-gated A* follow. Writes the point to aim at into `out` and returns true when it is steering along
|
|
41
|
+
// a path (out ≠ the raw goal); returns false when the way is clear or ungridded and the caller should
|
|
42
|
+
// aim straight at the goal itself. Path state lives on `s` under _rt* so it never collides with the
|
|
43
|
+
// Character._navPath the wanderers/enemies use.
|
|
44
|
+
//
|
|
45
|
+
// s the traveller (any object — foot NPC or the MountedTraveller); holds _rtPath/_rtI/…
|
|
46
|
+
// centre nearestEndpoint(): which settlement grid to use when no baked one already covers us
|
|
47
|
+
// cx,cz where the agent is now gx,gz the raw goal (the lane target on the road)
|
|
48
|
+
// out {x,z} written with the aim point
|
|
49
|
+
// opt.dt frame time (throttles re-paths) opt.arrive waypoint-reached radius
|
|
50
|
+
// opt.lookahead carrot distance ahead along the path (0 = aim at the current waypoint; horses use >0)
|
|
51
|
+
export function steerCarrot(s, game, centre, cx, cz, gx, gz, out, opt = {}) {
|
|
52
|
+
out.x = gx; out.z = gz;
|
|
53
|
+
const nav = game.nav;
|
|
54
|
+
if (!nav) return false;
|
|
55
|
+
const wide = !!opt.wide; // horses test/route on the clearance grid
|
|
56
|
+
_f.x = cx; _f.z = cz; _t.x = gx; _t.z = gz;
|
|
57
|
+
const clear = nav.losHere(_f, _t, centre, wide); // true = clear · false = blocked · null = no grid
|
|
58
|
+
if (clear !== false) { s._rtPath = null; return false; } // clear / unknown → aim straight (no statics to route)
|
|
59
|
+
|
|
60
|
+
// A static obstacle sits across the line to the goal — keep (or refresh) an A* path around it.
|
|
61
|
+
s._rtRepathT = (s._rtRepathT ?? 0) - (opt.dt ?? 0);
|
|
62
|
+
const goalMoved = !s._rtGoal || dist2D(s._rtGoal.x, s._rtGoal.z, gx, gz) > 3;
|
|
63
|
+
if (!s._rtPath || goalMoved || s._rtRepathT <= 0) {
|
|
64
|
+
_f.x = cx; _f.z = cz; _t.x = gx; _t.z = gz;
|
|
65
|
+
const p = nav.pathHere(_f, _t, centre, wide);
|
|
66
|
+
if (p && p.length) {
|
|
67
|
+
s._rtPath = p; s._rtI = 0;
|
|
68
|
+
(s._rtGoal ??= { x: 0, z: 0 }).x = gx; s._rtGoal.z = gz;
|
|
69
|
+
s._rtRepathT = 0.75;
|
|
70
|
+
} else {
|
|
71
|
+
s._rtRepathT = 0.4; // grid still baking / unreachable — try again soon
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const path = s._rtPath;
|
|
75
|
+
if (!path || !path.length) return false; // nothing to follow yet → straight (+ caller's stuck backstop)
|
|
76
|
+
|
|
77
|
+
// advance past any waypoints we have reached
|
|
78
|
+
const arrive = opt.arrive ?? 1.2;
|
|
79
|
+
while (s._rtI < path.length - 1 && dist2D(cx, cz, path[s._rtI].x, path[s._rtI].z) <= arrive) s._rtI++;
|
|
80
|
+
|
|
81
|
+
const Ld = opt.lookahead ?? 0;
|
|
82
|
+
if (Ld <= 0) { out.x = path[s._rtI].x; out.z = path[s._rtI].z; return true; }
|
|
83
|
+
// CARROT: walk forward along the smoothed polyline accumulating look-ahead distance. Aiming a few
|
|
84
|
+
// metres down the path (not at the 0.8 m next node) is what stops a horse snapping between close
|
|
85
|
+
// waypoints — it reads the bend early and curves through it.
|
|
86
|
+
let ax = cx, az = cz, rem = Ld;
|
|
87
|
+
for (let i = s._rtI; i < path.length; i++) {
|
|
88
|
+
const wx = path[i].x, wz = path[i].z, seg = dist2D(ax, az, wx, wz);
|
|
89
|
+
if (seg >= rem || i === path.length - 1) {
|
|
90
|
+
const u = seg > 1e-6 ? clamp(rem / seg, 0, 1) : 1;
|
|
91
|
+
out.x = ax + (wx - ax) * u; out.z = az + (wz - az) * u;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
rem -= seg; ax = wx; az = wz;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pure-pursuit turn toward (aimX,aimZ). Returns a turn RATE (rad/s) capped by a MINIMUM TURN RADIUS
|
|
100
|
+
// (wmax = speed / Rmin) so the animal cannot pivot on the spot, plus a speed multiplier that reins it
|
|
101
|
+
// in on a hard heading error so it slows and turns instead of overshooting straight into the obstacle.
|
|
102
|
+
export function mountedTurn(heading, cx, cz, aimX, aimZ, speed, Rmin = 3.5, gain = 2.4) {
|
|
103
|
+
const desired = Math.atan2(aimX - cx, aimZ - cz);
|
|
104
|
+
const e = angleDelta(desired, heading);
|
|
105
|
+
const wmax = Math.max(0.3, speed / Rmin);
|
|
106
|
+
return { turn: clamp(e * gain, -wmax, wmax), speedMul: clamp(1 - Math.abs(e) / (Math.PI / 2), 0.35, 1), err: e };
|
|
107
|
+
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// A MOVING FLOOR. Sprint at a stagecoach, jump, and land on the roof — and then stay on it while
|
|
2
|
+
// she rolls and turns underneath you. The coach is the first client; a boxcar is the same problem
|
|
3
|
+
// and a train is a line of them, so this is a PRIMITIVE, not a coach feature.
|
|
4
|
+
//
|
|
5
|
+
// THE RIDER IS LOGICALLY PARENTED, NOT REPARENTED. While he is up there, the thing that is true
|
|
6
|
+
// about him is (lx, ly, lz) — where he stands in the platform's OWN frame. His world position is
|
|
7
|
+
// DERIVED from that once a frame, AFTER the platform has moved (postUpdate). That is the whole
|
|
8
|
+
// trick, and it is why standing still on a coach at a gallop does not drift: nothing is integrated,
|
|
9
|
+
// no delta is accumulated, and the yaw ORBIT falls out for free — a man on the back of a coach that
|
|
10
|
+
// turns is simply re-derived at the rotated point, which is exactly what happens to him.
|
|
11
|
+
//
|
|
12
|
+
// He is NOT added to the coach's Object3D. coach.js:426 says why, and it still holds: collision,
|
|
13
|
+
// the camera, the aim and the law's line of sight all read player.position, and a player who is
|
|
14
|
+
// secretly a child of a moving group is a player none of them can find. So his position stays a
|
|
15
|
+
// real world position, every frame, and everything else in the game keeps working on the roof —
|
|
16
|
+
// the capsule, the locomotion FSM, the gun, the dodge. He is a man standing on a coach, not a
|
|
17
|
+
// passenger: `aboard` (player.js:513) is the OTHER thing, and the two can never both be set.
|
|
18
|
+
//
|
|
19
|
+
// ORDER. main.js ticks the player BEFORE the coaches, so nothing inside player.update() may depend
|
|
20
|
+
// on where the coach ends up this frame — and nothing does, because everything he does up there is
|
|
21
|
+
// said in LOCAL terms. postUpdate() runs after the coaches and the trains have moved, turns the
|
|
22
|
+
// local coords back into a world position, and hands him his camera at that final position (which
|
|
23
|
+
// is why _camera is a separate method — player.js:755).
|
|
24
|
+
import * as THREE from 'three/webgpu';
|
|
25
|
+
import { clamp } from '../core/utils.js';
|
|
26
|
+
|
|
27
|
+
// Gravity, as the capsule controller applies it (world.js:347). The mantle impulse is solved
|
|
28
|
+
// against it, so if one changes the other must.
|
|
29
|
+
const GRAV = 20;
|
|
30
|
+
|
|
31
|
+
// THE LEDGE CATCH, and it is load-bearing — the numbers do not otherwise meet.
|
|
32
|
+
// A flat-out sprint jump takes off at vy = 7.2 × 1.33 = 9.576 m/s (player.js:1141), so the FEET
|
|
33
|
+
// reach v²/2g = 2.29m. The coach roof is at 2.71m. A running jump is 0.42m SHORT of it and always
|
|
34
|
+
// will be, so a man who gets his feet up level with the roof lip catches it and pulls himself up.
|
|
35
|
+
// MANTLE_REACH is how far below the deck the lip can still be grabbed: 0.85 clears the 0.42m
|
|
36
|
+
// deficit with room for a coach standing on ground higher than yours.
|
|
37
|
+
const MANTLE_REACH = 0.85;
|
|
38
|
+
// ...but ONLY at the top of the arc. At the top of the coach's body (1.86m — where the hull ends,
|
|
39
|
+
// see coach.js) a sprint jump is still travelling upward at 4.4 m/s, so a 2.0 ceiling cannot fire
|
|
40
|
+
// as a free boost off the ground: you have to have actually got up there.
|
|
41
|
+
const MANTLE_VY = 2.0;
|
|
42
|
+
const MANTLE_CLEAR = 0.12; // how far above the lip the vault carries him — a hand's breadth, no more
|
|
43
|
+
const MANTLE_PULL = 1.5; // m/s of inward drag, so he comes up OVER the rail and not beside it
|
|
44
|
+
const MANTLE_LOCK = 0.4; // seconds: no re-trigger, and the body hull lets him through while it runs
|
|
45
|
+
const GRAB = 0.50; // ≈ the player's capsule radius (0.45): you catch the lip from against the flank
|
|
46
|
+
|
|
47
|
+
// LANDING. The test is SWEPT — feet above the plane at the top of the frame, below it at the
|
|
48
|
+
// bottom — so a 20 m/s fall (0.33m in a frame) cannot tunnel through a zero-thickness deck.
|
|
49
|
+
const LAND_PAD = 0.20; // ...plus however far the platform moved last frame: main.js ticks the
|
|
50
|
+
// player BEFORE the coaches, so the transform this test reads is one
|
|
51
|
+
// frame stale (17cm at a gallop, 27cm at line speed on a train).
|
|
52
|
+
const RELEASE_PAD = 0.30; // and you keep the deck until you are 30cm off it. Acquire tight,
|
|
53
|
+
// release loose, or the roof edge strobes grounded/airborne and the
|
|
54
|
+
// locomotion FSM flickers between run and jumpIdle.
|
|
55
|
+
const STEP_UP = 0.45; // walk up onto a deck this much higher (a tender to a footplate)...
|
|
56
|
+
const STEP_DOWN = 0.60; // ...and stay glued to one this much lower as she crests a rise.
|
|
57
|
+
|
|
58
|
+
// A TELEPORT IS NOT A MOVE. Coach._rejoinRoute (coach.js:485) can snap her 20m back onto the route
|
|
59
|
+
// line in a single frame; a train car can be stabled 400m underground (train.js). She goes without
|
|
60
|
+
// him. A legitimate frame at the gallop, at the 0.1s dt clamp, is 1.05m — and a route terminus
|
|
61
|
+
// swings her ~0.25 rad in ONE frame (coach.js:381 eases yaw per frame, not per second), which is
|
|
62
|
+
// violent but real, so the yaw threshold must sit well above it.
|
|
63
|
+
const TELE_XZ = 2.0, TELE_Y = 2.0, TELE_YAW = 0.5;
|
|
64
|
+
|
|
65
|
+
// The lens, not the man. The position orbit and his body yaw are carried IN FULL — that is
|
|
66
|
+
// physically what the coach does to you — but the CAMERA is rate-limited, so a terminus reversal
|
|
67
|
+
// reads as a swing rather than an 14° snap in one frame.
|
|
68
|
+
const CAM_YAW_RATE = 2.5; // rad/s
|
|
69
|
+
|
|
70
|
+
const AIR_BLEED = 0.25; // seconds: how fast inherited speed dies once his feet are down again.
|
|
71
|
+
// There is NO bleed in the air — a jump off a moving coach is pure
|
|
72
|
+
// ballistics, which is what makes "jump straight up and land back on
|
|
73
|
+
// her" true, and what flings you when you jump off a galloping team.
|
|
74
|
+
const COL_GRACE = 0.25; // he keeps ignoring his own platform's collider box for this long after
|
|
75
|
+
// leaving it — a train car's box is banded to its own ROOF (train.js),
|
|
76
|
+
// so without it stepping off one would shove him out of the flank.
|
|
77
|
+
|
|
78
|
+
const _v = new THREE.Vector3();
|
|
79
|
+
const _v2 = new THREE.Vector3();
|
|
80
|
+
const _m = new THREE.Matrix4();
|
|
81
|
+
|
|
82
|
+
const wrapPi = (a) => { while (a > Math.PI) a -= Math.PI * 2; while (a < -Math.PI) a += Math.PI * 2; return a; };
|
|
83
|
+
const inRect = (d, lx, lz, pad) => lx > d.x0 - pad && lx < d.x1 + pad && lz > d.z0 - pad && lz < d.z1 + pad;
|
|
84
|
+
|
|
85
|
+
export class PlatformRegistry {
|
|
86
|
+
constructor(game) {
|
|
87
|
+
this.game = game;
|
|
88
|
+
this.hosts = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The host contract is duck-typed on purpose — the same shape a train car can be registered with
|
|
92
|
+
// from OUTSIDE its own file:
|
|
93
|
+
// root a Group whose position and rotation.y are the platform (pitch/roll tolerated)
|
|
94
|
+
// owner whoever it belongs to — the identity a look-ahead exempts its own roof rider by
|
|
95
|
+
// decks local-space rects with a flat top: {x0,x1,z0,z1,y,surface}. Highest wins.
|
|
96
|
+
// hull optional local box that pushes an airborne man OUT of the body (never a bullet)
|
|
97
|
+
// cols its entries in world/town.js's `colliders` — skipped for whoever is riding it
|
|
98
|
+
// mantle how far below a deck its lip can be caught (0 = no ledge catch)
|
|
99
|
+
// radius broadphase reach from the root, XZ
|
|
100
|
+
register(spec) {
|
|
101
|
+
const h = {
|
|
102
|
+
root: spec.root,
|
|
103
|
+
owner: spec.owner ?? null,
|
|
104
|
+
decks: spec.decks ?? [],
|
|
105
|
+
hull: spec.hull ?? null,
|
|
106
|
+
cols: spec.cols ?? null,
|
|
107
|
+
mantle: spec.mantle ?? MANTLE_REACH,
|
|
108
|
+
radius: spec.radius ?? 4,
|
|
109
|
+
enabled: spec.enabled ?? (() => true),
|
|
110
|
+
// The registry measures the platform's own per-frame delta itself, from a snapshot it takes
|
|
111
|
+
// at the end of every postUpdate. The owner therefore needs no per-frame hook and no
|
|
112
|
+
// bookkeeping — which is the only reason a train car can be registered without editing
|
|
113
|
+
// train.js at all.
|
|
114
|
+
px: 0, py: 0, pz: 0, pry: 0, pm: new THREE.Matrix4(),
|
|
115
|
+
dx: 0, dy: 0, dz: 0, dyaw: 0,
|
|
116
|
+
vx: 0, vy: 0, vz: 0, yawRate: 0,
|
|
117
|
+
teleported: false,
|
|
118
|
+
};
|
|
119
|
+
this._snap(h);
|
|
120
|
+
this.hosts.push(h);
|
|
121
|
+
return h;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
unregister(host) {
|
|
125
|
+
const i = this.hosts.indexOf(host);
|
|
126
|
+
if (i < 0) return;
|
|
127
|
+
const p = this.game.player;
|
|
128
|
+
if (p?.platform?.host === host) this.leave(p, 'drop');
|
|
129
|
+
this.hosts.splice(i, 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Who is carrying him — the accessor a coach's look-ahead asks, so it does not brake for the man
|
|
133
|
+
// standing on its own roof. Handed out rather than compared by identity, because a train car is
|
|
134
|
+
// proxied into game.coaches.coaches as a plain object and the identities would not line up.
|
|
135
|
+
carrier(entity) { return entity?.platform?.host.owner ?? null; }
|
|
136
|
+
|
|
137
|
+
// ── local ↔ world ────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// The yaw-only fast path is the same rotate-the-offset arithmetic the coach already uses for its
|
|
139
|
+
// riders and its horse colliders (coach.js:314-322/345), and its exact inverse. A platform that
|
|
140
|
+
// PITCHES (a train car on a bank/grade: train.js sets rotation.order='YXZ' and writes rotation.x)
|
|
141
|
+
// takes the matrix path instead — a flat deck plane read through a yaw-only frame is wrong by
|
|
142
|
+
// halfLength·sin(pitch), which is half a metre at the end of a 17m carriage.
|
|
143
|
+
_pitched(h) { return Math.abs(h.root.rotation.x) + Math.abs(h.root.rotation.z) > 1e-4; }
|
|
144
|
+
|
|
145
|
+
_toWorld(h, lx, ly, lz, out) {
|
|
146
|
+
if (this._pitched(h)) {
|
|
147
|
+
h.root.updateWorldMatrix(true, false);
|
|
148
|
+
return out.set(lx, ly, lz).applyMatrix4(h.root.matrixWorld);
|
|
149
|
+
}
|
|
150
|
+
const p = h.root.position, ry = h.root.rotation.y;
|
|
151
|
+
const c = Math.cos(ry), s = Math.sin(ry);
|
|
152
|
+
return out.set(p.x + lx * c + lz * s, p.y + ly, p.z - lx * s + lz * c);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ...the same point through LAST frame's transform. Only the vertical is ever wanted (what the
|
|
156
|
+
// deck under his boots did while he stood still), and that is what keeps the visual-height
|
|
157
|
+
// smoother honest — see postUpdate.
|
|
158
|
+
_toWorldPrev(h, lx, ly, lz, out) {
|
|
159
|
+
if (this._pitched(h)) return out.set(lx, ly, lz).applyMatrix4(h.pm);
|
|
160
|
+
const c = Math.cos(h.pry), s = Math.sin(h.pry);
|
|
161
|
+
return out.set(h.px + lx * c + lz * s, h.py + ly, h.pz - lx * s + lz * c);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_toLocal(h, wx, wy, wz, out) {
|
|
165
|
+
if (this._pitched(h)) {
|
|
166
|
+
h.root.updateWorldMatrix(true, false);
|
|
167
|
+
_m.copy(h.root.matrixWorld).invert();
|
|
168
|
+
return out.set(wx, wy, wz).applyMatrix4(_m);
|
|
169
|
+
}
|
|
170
|
+
const p = h.root.position, ry = h.root.rotation.y;
|
|
171
|
+
const c = Math.cos(ry), s = Math.sin(ry);
|
|
172
|
+
const dx = wx - p.x, dz = wz - p.z;
|
|
173
|
+
return out.set(dx * c - dz * s, wy - p.y, dx * s + dz * c);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_near(h, x, z) {
|
|
177
|
+
const p = h.root.position;
|
|
178
|
+
const dx = x - p.x, dz = z - p.z;
|
|
179
|
+
const r = h.radius + 2;
|
|
180
|
+
return dx * dx + dz * dz < r * r;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_snap(h) {
|
|
184
|
+
const p = h.root.position;
|
|
185
|
+
h.px = p.x; h.py = p.y; h.pz = p.z; h.pry = h.root.rotation.y;
|
|
186
|
+
if (this._pitched(h)) { h.root.updateWorldMatrix(true, false); h.pm.copy(h.root.matrixWorld); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_measure(h, dt) {
|
|
190
|
+
const p = h.root.position;
|
|
191
|
+
h.dx = p.x - h.px; h.dy = p.y - h.py; h.dz = p.z - h.pz;
|
|
192
|
+
h.dyaw = wrapPi(h.root.rotation.y - h.pry);
|
|
193
|
+
h.teleported = Math.abs(h.dx) + Math.abs(h.dz) > TELE_XZ || Math.abs(h.dy) > TELE_Y || Math.abs(h.dyaw) > TELE_YAW;
|
|
194
|
+
// the velocities SURVIVE a teleport frame: the last sane ones are what a rider thrown off by
|
|
195
|
+
// one takes with him
|
|
196
|
+
if (!h.teleported && dt > 1e-5) {
|
|
197
|
+
h.vx = h.dx / dt; h.vy = h.dy / dt; h.vz = h.dz / dt; h.yawRate = h.dyaw / dt;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// How fast the platform is moving THE POINT HE IS STANDING ON — the translation plus the
|
|
202
|
+
// tangential term of the yaw. (Check: a man at local +X on a platform whose yaw increases is
|
|
203
|
+
// swung toward -Z, and this gives (0, -yawRate·offX). Correct.)
|
|
204
|
+
_velAt(h, wx, wz, out) {
|
|
205
|
+
const p = h.root.position;
|
|
206
|
+
const ox = wx - p.x, oz = wz - p.z;
|
|
207
|
+
return out.set(h.vx + h.yawRate * oz, h.vy, h.vz - h.yawRate * ox);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── the deck search ──────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// The HIGHEST deck under his feet wins, across every platform — so walking from one train car to
|
|
212
|
+
// the next is a rebind on the same frame, with no airborne frame and no release. `ride` is the
|
|
213
|
+
// one he is already on, and only IT gets the release pad: acquire tight, release loose.
|
|
214
|
+
_deckUnder(wx, feetY, wz, ride) {
|
|
215
|
+
let best = null;
|
|
216
|
+
for (const h of this.hosts) {
|
|
217
|
+
if (!h.enabled() || !this._near(h, wx, wz)) continue;
|
|
218
|
+
this._toLocal(h, wx, feetY, wz, _v);
|
|
219
|
+
for (const d of h.decks) {
|
|
220
|
+
const mine = ride && ride.host === h && ride.deck === d;
|
|
221
|
+
if (!inRect(d, _v.x, _v.z, mine ? RELEASE_PAD : 0)) continue;
|
|
222
|
+
const y = this._toWorld(h, _v.x, d.y, _v.z, _v2).y;
|
|
223
|
+
if (y > feetY + STEP_UP || y < feetY - STEP_DOWN) continue;
|
|
224
|
+
if (!best || y > best.y) best = { host: h, deck: d, y, lx: _v.x, lz: _v.z };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return best;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── getting on ───────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
// Called from player.js right after the capsule solve, and ONLY when he is not already riding.
|
|
232
|
+
// `prevFeetY` is his height at the top of the frame, so the plane test is over the whole frame's
|
|
233
|
+
// vertical sweep rather than an instant.
|
|
234
|
+
tryLand(e, prevFeetY, wasAir) {
|
|
235
|
+
if (!e.rideable || e.platform || !e.alive) return;
|
|
236
|
+
const fx = e.position.x, fy = e.position.y, fz = e.position.z;
|
|
237
|
+
let best = null, mantle = null;
|
|
238
|
+
for (const h of this.hosts) {
|
|
239
|
+
if (!h.enabled() || h.teleported || !this._near(h, fx, fz)) continue;
|
|
240
|
+
this._toLocal(h, fx, fy, fz, _v);
|
|
241
|
+
const lx = _v.x, lz = _v.z;
|
|
242
|
+
// the transform is one frame stale (the coaches move after the player), so forgive the
|
|
243
|
+
// footprint by however far she actually travelled in that frame
|
|
244
|
+
const pad = LAND_PAD + Math.abs(h.dx) + Math.abs(h.dz);
|
|
245
|
+
for (const d of h.decks) {
|
|
246
|
+
const y = this._toWorld(h, lx, d.y, lz, _v2).y;
|
|
247
|
+
// DOWN through the plane: the ordinary landing. `h.dy` is in the test because the deck can
|
|
248
|
+
// come UP past a man who is falling — a coach cresting a rise into a jumper — and he was
|
|
249
|
+
// above it before she moved, which still counts.
|
|
250
|
+
const down = e.vy <= 0.01 && prevFeetY >= y - 0.02 - Math.max(0, h.dy) && fy < y;
|
|
251
|
+
// UP through it: he is finishing a vault (below), and pulling yourself onto a roof does not
|
|
252
|
+
// require you to fall onto it afterwards.
|
|
253
|
+
const up = e._mantleT > 0 && e.vy > 0 && prevFeetY < y && fy >= y - 0.02;
|
|
254
|
+
if ((down || up) && inRect(d, lx, lz, pad)) {
|
|
255
|
+
if (!best || y > best.y) best = { host: h, deck: d, y, lx, lz };
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// THE LEDGE. Feet up level with the lip, at the top of the arc, and inside the footprint by
|
|
259
|
+
// a capsule's reach: he catches it. Gated on MANTLE_VY so it cannot be a free boost taken
|
|
260
|
+
// off the ground beside her — see the constants.
|
|
261
|
+
if (!wasAir && !e.airborne) continue;
|
|
262
|
+
if (e._mantleT > 0 || e.vy > MANTLE_VY || h.mantle <= 0) continue;
|
|
263
|
+
if (fy < y - h.mantle || fy >= y - 0.02) continue;
|
|
264
|
+
if (!inRect(d, lx, lz, GRAB)) continue;
|
|
265
|
+
if (!mantle || y > mantle.y) mantle = { host: h, deck: d, y, lx, lz };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (best) { this._board(e, best); return; }
|
|
269
|
+
if (mantle) this._vault(e, mantle);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_vault(e, m) {
|
|
273
|
+
const gap = m.y - e.position.y;
|
|
274
|
+
e.vy = Math.sqrt(2 * GRAV * (gap + MANTLE_CLEAR));
|
|
275
|
+
e._mantleT = MANTLE_LOCK;
|
|
276
|
+
// Take her speed with you or she rolls out from under the vault — at a trot she covers 0.4m in
|
|
277
|
+
// the 0.12s it takes him to get his feet over the lip. Plus a drag INWARD, so he comes up over
|
|
278
|
+
// the rail rather than beside it.
|
|
279
|
+
this._velAt(m.host, e.position.x, e.position.z, _v2);
|
|
280
|
+
const d = m.deck;
|
|
281
|
+
const cx = clamp(m.lx, d.x0, d.x1) - m.lx, cz = clamp(m.lz, d.z0, d.z1) - m.lz;
|
|
282
|
+
const len = Math.hypot(cx, cz);
|
|
283
|
+
const ix = len > 1e-3 ? cx / len : 0, iz = len > 1e-3 ? cz / len : 0;
|
|
284
|
+
const ry = m.host.root.rotation.y, c = Math.cos(ry), s = Math.sin(ry);
|
|
285
|
+
e._airVX = _v2.x + (ix * c + iz * s) * MANTLE_PULL;
|
|
286
|
+
e._airVZ = _v2.z + (-ix * s + iz * c) * MANTLE_PULL;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_board(e, b) {
|
|
290
|
+
e.position.y = b.y;
|
|
291
|
+
this._toLocal(b.host, e.position.x, e.position.y, e.position.z, _v);
|
|
292
|
+
b.lx = _v.x; b.lz = _v.z;
|
|
293
|
+
e.vy = 0;
|
|
294
|
+
e.airborne = false;
|
|
295
|
+
e._capGrounded = false; // the capsule's snap-to-ground pass (world.js:314) must never
|
|
296
|
+
// raycast a man off a roof onto the dirt below
|
|
297
|
+
e._airVX = 0; e._airVZ = 0; // his flight is over: no 1.5m skid across the roof
|
|
298
|
+
e._mantleT = 0;
|
|
299
|
+
e.platform = {
|
|
300
|
+
host: b.host, deck: b.deck,
|
|
301
|
+
lx: b.lx, ly: b.deck.y, lz: b.lz,
|
|
302
|
+
yawDebt: 0, vx: 0, vy: 0, vz: 0, fresh: true,
|
|
303
|
+
};
|
|
304
|
+
e._skipCols = b.host.cols; e._skipT = COL_GRACE;
|
|
305
|
+
if (e._visualY === undefined) e._visualY = e.position.y;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── staying on ───────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
// Replaces world.moveCapsule while he is riding: no gravity, no terrain, no BVH — the deck IS the
|
|
310
|
+
// floor. He still gets the 2D collider pass (props, trees, a hitching rail) so the road can still
|
|
311
|
+
// scrape him off, with his own platform's box skipped.
|
|
312
|
+
deckMove(e, hx, hz, dt) {
|
|
313
|
+
const ride = e.platform;
|
|
314
|
+
const W = this.game.world;
|
|
315
|
+
if (!ride.host.enabled() || ride.host.teleported || !e.alive) {
|
|
316
|
+
this.leave(e, 'drop');
|
|
317
|
+
return W.moveCapsule(e, hx, hz, dt);
|
|
318
|
+
}
|
|
319
|
+
// jump() runs at the END of player.update, after this — so a positive vy here is the first
|
|
320
|
+
// frame of a jump that was taken ON the deck, and he is ballistic from now on.
|
|
321
|
+
if (e.vy > 0.01) {
|
|
322
|
+
this.leave(e, 'jump');
|
|
323
|
+
return W.moveCapsule(e, hx, hz, dt);
|
|
324
|
+
}
|
|
325
|
+
// WHERE HE STANDS IS A LOCAL FACT. Start from his deck coords put through the platform's
|
|
326
|
+
// transform as it is RIGHT NOW — never from his last world position. The two are the same
|
|
327
|
+
// number in the ordinary tick, but only this one is immune to the platform having been moved
|
|
328
|
+
// by something other than its own update: an out-of-band transform is then a carry, which is
|
|
329
|
+
// what it is, instead of quietly becoming local drift that slides him off the back.
|
|
330
|
+
const r = e.capsuleR ?? e.radius ?? 0.45;
|
|
331
|
+
this._toWorld(ride.host, ride.lx, ride.ly, ride.lz, _v);
|
|
332
|
+
const feetY = _v.y;
|
|
333
|
+
let wx = _v.x + hx, wz = _v.z + hz;
|
|
334
|
+
const air = feetY - W.groundAt(wx, wz);
|
|
335
|
+
const c = W.collide(wx, wz, r, air, feetY, e._skipCols);
|
|
336
|
+
wx = c.x; wz = c.z;
|
|
337
|
+
|
|
338
|
+
const found = this._deckUnder(wx, feetY, wz, ride);
|
|
339
|
+
if (!found) {
|
|
340
|
+
// he has walked off the edge. Not a fall from a standstill: he goes with her.
|
|
341
|
+
e.position.x = wx; e.position.z = wz;
|
|
342
|
+
this.leave(e, 'step');
|
|
343
|
+
e.vy = Math.min(e.vy, 0);
|
|
344
|
+
return W.moveCapsule(e, 0, 0, dt);
|
|
345
|
+
}
|
|
346
|
+
if (found.host !== ride.host || found.deck !== ride.deck) {
|
|
347
|
+
ride.host = found.host; ride.deck = found.deck; ride.yawDebt = 0; ride.fresh = true;
|
|
348
|
+
e._skipCols = found.host.cols;
|
|
349
|
+
}
|
|
350
|
+
ride.lx = found.lx; ride.lz = found.lz; ride.ly = found.deck.y;
|
|
351
|
+
e.position.set(wx, found.y, wz);
|
|
352
|
+
e.vy = 0;
|
|
353
|
+
e.airborne = false;
|
|
354
|
+
e._capGrounded = false;
|
|
355
|
+
e._skipT = COL_GRACE;
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The dodge burst (player.js:564) goes through Character.move → moveCapsule, which would drop him
|
|
360
|
+
// off the roof onto the terrain. On a deck it is a plain local displacement instead.
|
|
361
|
+
step(e, dx, dz) {
|
|
362
|
+
if (!e.platform) return;
|
|
363
|
+
this.deckMove(e, dx, dz, 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── getting off ──────────────────────────────────────────────────────────────────────────────
|
|
367
|
+
// Every exit funnels through here so they cannot drift apart: jumping off, walking off the edge,
|
|
368
|
+
// the platform dropping him, and sitting down on the box as her driver.
|
|
369
|
+
// 'jump' / 'step' — you keep her speed. Jump off a galloping team and you are FLUNG.
|
|
370
|
+
// 'aboard' — you are sitting down on her; player.js's aboard path takes over.
|
|
371
|
+
// 'drop' — she teleported, was stabled, or you died. No inheritance from a lie.
|
|
372
|
+
leave(e, why) {
|
|
373
|
+
const ride = e.platform;
|
|
374
|
+
if (!ride) return;
|
|
375
|
+
e.platform = null;
|
|
376
|
+
e._capGrounded = false;
|
|
377
|
+
e._skipT = COL_GRACE;
|
|
378
|
+
if (why === 'jump' || why === 'step') {
|
|
379
|
+
e._airVX = clamp(ride.vx, -18, 18);
|
|
380
|
+
e._airVZ = clamp(ride.vz, -18, 18);
|
|
381
|
+
e.airborne = true;
|
|
382
|
+
e._visualY = undefined; // let the smoother re-latch wherever he lands (Coach.alight:465)
|
|
383
|
+
} else {
|
|
384
|
+
e._airVX = 0; e._airVZ = 0;
|
|
385
|
+
e._skipCols = null; e._skipT = 0;
|
|
386
|
+
if (why !== 'aboard') e._visualY = undefined;
|
|
387
|
+
}
|
|
388
|
+
ride.host.owner?.onRiderLeave?.(e);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── the carry ────────────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// main.js calls this AFTER the coaches and the trains have moved, and unconditionally — with the
|
|
393
|
+
// map open the platform has not moved, the delta is zero, and this degrades to handing him his
|
|
394
|
+
// camera. It is idempotent: the snapshot is retaken at the end, so a second call in the same
|
|
395
|
+
// frame measures a zero delta and carries nothing twice.
|
|
396
|
+
postUpdate(dt, input, camera) {
|
|
397
|
+
const p = this.game.player;
|
|
398
|
+
const riding = !!p?.platform;
|
|
399
|
+
for (const h of this.hosts) this._measure(h, dt);
|
|
400
|
+
|
|
401
|
+
if (riding) {
|
|
402
|
+
const ride = p.platform;
|
|
403
|
+
const h = ride.host;
|
|
404
|
+
if (!p.alive || p.riding?.mounted || p.aboard || !h.enabled() || h.teleported) {
|
|
405
|
+
this.leave(p, 'drop');
|
|
406
|
+
} else {
|
|
407
|
+
// where the deck under his boots WAS, before she moved — the platform's own vertical motion,
|
|
408
|
+
// measured at his feet
|
|
409
|
+
const wasY = this._toWorldPrev(h, ride.lx, ride.ly, ride.lz, _v2).y;
|
|
410
|
+
this._toWorld(h, ride.lx, ride.ly, ride.lz, p.position);
|
|
411
|
+
const deckDY = p.position.y - wasY;
|
|
412
|
+
|
|
413
|
+
p.heading += h.dyaw;
|
|
414
|
+
p.root.rotation.y = p.heading;
|
|
415
|
+
// the lens is rate-limited where the body is not: coach.js eases her yaw PER FRAME, so a
|
|
416
|
+
// route terminus reverses her by ~0.25 rad in one of them. Pay the debt off over a few.
|
|
417
|
+
ride.yawDebt += h.dyaw;
|
|
418
|
+
const step = clamp(ride.yawDebt, -CAM_YAW_RATE * dt, CAM_YAW_RATE * dt);
|
|
419
|
+
p.camYaw += step;
|
|
420
|
+
ride.yawDebt -= step;
|
|
421
|
+
|
|
422
|
+
this._velAt(h, p.position.x, p.position.z, _v);
|
|
423
|
+
const k = ride.fresh ? 1 : Math.min(1, dt * 20); // a couple of frames of smoothing, so one
|
|
424
|
+
ride.vx += (_v.x - ride.vx) * k; // noisy frame cannot fling him
|
|
425
|
+
ride.vy += (_v.y - ride.vy) * k;
|
|
426
|
+
ride.vz += (_v.z - ride.vz) * k;
|
|
427
|
+
ride.fresh = false;
|
|
428
|
+
|
|
429
|
+
// THE VISUAL HEIGHT. player.js's smoother eases the drawn feet DOWN toward the real ones
|
|
430
|
+
// over micro-bumps, and the camera is driven from it (player.js:763). Left alone on a coach
|
|
431
|
+
// it would smooth HER descent as if it were a bump in his path: on a downhill he would be
|
|
432
|
+
// drawn hanging above the roof for as long as the hill lasted, and the camera with him. So
|
|
433
|
+
// the deck's own rise and fall is added to it at the same instant it lands in his feet —
|
|
434
|
+
// then the only thing left in (feet - _visualY) is HIS motion relative to the deck, which
|
|
435
|
+
// on a flat roof is nothing at all. The smoother keeps its real job (a step between decks)
|
|
436
|
+
// and contributes no error to a moving one.
|
|
437
|
+
if (p._visualY === undefined) p._visualY = p.position.y;
|
|
438
|
+
else p._visualY += deckDY;
|
|
439
|
+
const py = p.position.y;
|
|
440
|
+
if (Math.abs(py - p._visualY) > 1.5) p._visualY = py;
|
|
441
|
+
else p._visualY += (py - p._visualY) * Math.min(1, dt * 12);
|
|
442
|
+
if (p.model) p.model.position.y = (p._baseModelY2 ?? 0) + (p._visualY - py);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const h of this.hosts) this._snap(h);
|
|
447
|
+
|
|
448
|
+
// ONE camera update per frame, always. player.update skips its own when he is riding (its
|
|
449
|
+
// position would be a frame behind the coach he is standing on); it runs it itself on the frame
|
|
450
|
+
// he leaves. A dead man's camera is left where it was, exactly as player.update leaves it.
|
|
451
|
+
if (riding && p.alive) p._camera(dt, input, camera);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── the body ─────────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
// The coach's collider box stops at the footboard (coach.js:341: y1 = root.y + 1.55) so that a
|
|
456
|
+
// bullet aimed at the driver does not die on the chassis — which leaves the CABIN, between the
|
|
457
|
+
// footboard and the roof, as a metre-high hole a mistimed jump sails straight into. This is that
|
|
458
|
+
// wall: entity-only, never pushed into world/town.js's `colliders`, so it cannot stop a round.
|
|
459
|
+
// Mistime the jump and you smack into the side of the coach and fall, which is the honest answer.
|
|
460
|
+
hullPush(e) {
|
|
461
|
+
if (!e.rideable || e.platform || e._mantleT > 0 || !e.alive) return;
|
|
462
|
+
const r = e.capsuleR ?? e.radius ?? 0.45;
|
|
463
|
+
for (const h of this.hosts) {
|
|
464
|
+
const hull = h.hull;
|
|
465
|
+
if (!hull || !h.enabled() || !this._near(h, e.position.x, e.position.z)) continue;
|
|
466
|
+
const y0 = h.root.position.y + hull.y0, y1 = h.root.position.y + hull.y1;
|
|
467
|
+
// the same band test world.collide uses (world.js:254): feet above the top or a whole body
|
|
468
|
+
// below the bottom and the box is not there
|
|
469
|
+
if (e.position.y > y1 || e.position.y + 1.8 < y0) continue;
|
|
470
|
+
this._toLocal(h, e.position.x, e.position.y, e.position.z, _v);
|
|
471
|
+
const cx = (hull.x0 + hull.x1) / 2, cz = (hull.z0 + hull.z1) / 2;
|
|
472
|
+
const ex = (hull.x1 - hull.x0) / 2 + r, ez = (hull.z1 - hull.z0) / 2 + r;
|
|
473
|
+
const lx = _v.x - cx, lz = _v.z - cz;
|
|
474
|
+
if (Math.abs(lx) >= ex || Math.abs(lz) >= ez) continue;
|
|
475
|
+
// push out of the nearest face
|
|
476
|
+
const px = ex - Math.abs(lx), pz = ez - Math.abs(lz);
|
|
477
|
+
const nx = px < pz ? (lx < 0 ? -ex : ex) : lx;
|
|
478
|
+
const nz = px < pz ? lz : (lz < 0 ? -ez : ez);
|
|
479
|
+
this._toWorld(h, nx + cx, _v.y, nz + cz, _v2);
|
|
480
|
+
e.position.x = _v2.x;
|
|
481
|
+
e.position.z = _v2.z;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|