sindicate 0.2.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/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/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/climbing.js +136 -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/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/enemy.js +442 -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/loot.js +303 -0
- package/src/systems/missions.js +418 -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/player.js +1619 -0
- package/src/systems/riding.js +580 -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/seatedRider.js +396 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/train.js +1957 -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,254 @@
|
|
|
1
|
+
// ROAD WALKERS — the men and women who are actually going somewhere.
|
|
2
|
+
//
|
|
3
|
+
// A Traveller IS AN NPC. That is the whole design, and everything good about this file falls out of
|
|
4
|
+
// it: he lives in game.npcs, so combat.js's bullet loop and melee arc find him, crime.js counts him
|
|
5
|
+
// as a witness and calls his killing a MURDER, loot.js spills his purse, alarmTown scatters him with
|
|
6
|
+
// the rest of the street, the stagecoach's look-ahead (coach.js _blocked) brakes for him, and
|
|
7
|
+
// entity.js's whisker steering makes the townsfolk walk round him. Not one of those systems needed a
|
|
8
|
+
// line adding. He is a townsman whose beat happens to be forty minutes long.
|
|
9
|
+
//
|
|
10
|
+
// The one thing he does differently is WHERE HE WALKS: NPC's wanderUpdate strolls a route of street
|
|
11
|
+
// points, and his overrides it with a road. So this file is the road, and nothing else.
|
|
12
|
+
//
|
|
13
|
+
// PERF CONTRACT (main.js:118 / npc.js): built ONCE on the loading screen, resident and scene-attached
|
|
14
|
+
// for life. Nothing here adds, removes or toggles a skinned mesh — Character.update's own distance +
|
|
15
|
+
// frustum cull is the only thing that hides him.
|
|
16
|
+
import { NPC } from './npc.js';
|
|
17
|
+
// road routes are GAME data: setTravellerRoutes(routes)
|
|
18
|
+
let ROUTES = [];
|
|
19
|
+
export function setTravellerRoutes(r) { ROUTES = r ?? []; }
|
|
20
|
+
import { steerCarrot, nearestEndpoint } from './pathFollow.js';
|
|
21
|
+
import { dist2D, tmpV1, tmpV2 } from '../core/utils.js';
|
|
22
|
+
|
|
23
|
+
const _faim = { x: 0, z: 0 }; // scratch: the point steerCarrot hands a foot traveller to aim at
|
|
24
|
+
|
|
25
|
+
// ── WHAT A ROAD TRAVELLER IS. Shared with mountedTraveller.js, because a horseman keeps to the same
|
|
26
|
+
// side of the same road, in the same daylight, and pays the same rent. One definition, two riders.
|
|
27
|
+
|
|
28
|
+
// HOW FAR OFF YOU SEE A MAN ON THE OPEN ROAD.
|
|
29
|
+
//
|
|
30
|
+
// A townsman is culled at 45/70/110 m by quality (core/quality.js entityCull) and that is right for a
|
|
31
|
+
// town: there is a building in front of him. A road traveller is out on a flat plain with a mile of
|
|
32
|
+
// nothing either side, and a man who winks out of existence at seventy metres of empty desert is the
|
|
33
|
+
// most expensive thing in this file — you notice him GO.
|
|
34
|
+
//
|
|
35
|
+
// So he gets further. But it is a MULTIPLE of the quality cull, not a number that ignores it: 45 m on
|
|
36
|
+
// a low machine is 45 m for a reason, and a rule that hands a weak GPU thirty extra skinned bodies at
|
|
37
|
+
// 140 m is not a rule, it is a bug with a comment. 1.6x, capped: low 72 m, medium 112 m, high 140 m.
|
|
38
|
+
const CULL_MULT = 1.6;
|
|
39
|
+
const CULL_MAX = 140;
|
|
40
|
+
export const roadCull = (game) => Math.min(CULL_MAX, (game.quality?.entityCull ?? 70) * CULL_MULT);
|
|
41
|
+
|
|
42
|
+
// KEEP RIGHT — 3.6 m off the centreline, and the number is arithmetic, not taste.
|
|
43
|
+
//
|
|
44
|
+
// The stagecoach drives 1.15 m right of the centreline (coach.js LANE) and brakes for anything inside
|
|
45
|
+
// a 2.0 m half-width corridor ahead of her (CORRIDOR). A traveller at 3.6 m is 2.45 m off her line —
|
|
46
|
+
// so she OVERTAKES him at a canter instead of crawling behind him for the length of the county, and
|
|
47
|
+
// passes about a metre and a quarter from his elbow. Meeting him head-on she is on the other side of
|
|
48
|
+
// the road entirely (her right is his left): 4.75 m apart. And the road's carriageway is graded
|
|
49
|
+
// full-strength out to 3.5 m and paints dirt to ~10 (terrainData rawHeight), so 3.6 m is the SHOULDER
|
|
50
|
+
// of the road, which is exactly where a man on foot walks and where a horse is ridden.
|
|
51
|
+
export const LANE = 3.6;
|
|
52
|
+
|
|
53
|
+
// THE WORKING DAY. The sky runs 24 game-hours per 15 real minutes (world/sky.js), so this is 8 real
|
|
54
|
+
// minutes of road traffic and 4½ of empty road — the right ratio for a county whose night is supposed
|
|
55
|
+
// to feel like something.
|
|
56
|
+
export const DAWN = 6.5, DUSK = 19.5;
|
|
57
|
+
|
|
58
|
+
// PAST THIS HE IS NOT WORTH A BVH SWEEP. It is the same 45 m at which entity.js's walkDir already
|
|
59
|
+
// switches its whisker probes off, so beyond it the capsule controller is sweeping the world BVH for a
|
|
60
|
+
// man walking down the middle of a graded, empty, dead-straight road — buying nothing. Terrain-follow
|
|
61
|
+
// only out there, exactly as horse.js does for its ambient grazers ("NO BVH… a needless cost").
|
|
62
|
+
export const BVH_R = 45;
|
|
63
|
+
|
|
64
|
+
// A MAN ON THE RAILS WHEN A TRAIN IS COMING — see railShut() at the bottom.
|
|
65
|
+
const XING_CLEAR = 12;
|
|
66
|
+
const XING_FLEE = 3;
|
|
67
|
+
|
|
68
|
+
const ARRIVE = 2.2; // waypoints are 20-30 m apart (world/scale.js dens): this never skips a leg
|
|
69
|
+
const BLOCKED_ARRIVE = 3.4; // …but if something is parked on the waypoint, take the next leg instead
|
|
70
|
+
|
|
71
|
+
// WHERE HE AIMS: the waypoint, shoved onto HIS right-hand side of the road. Two travellers walking
|
|
72
|
+
// opposite ways each keep their own right, so they pass each other rather than through each other.
|
|
73
|
+
// `dir` is which way along the polyline he is going, so a man on the way back is on the other side of
|
|
74
|
+
// the road — which is the whole of "keep right", and it comes out of the arithmetic for free.
|
|
75
|
+
//
|
|
76
|
+
// `spot` is his own personal patch of verge, applied only AT THE TWO ENDS OF A ROUTE. Nine of the
|
|
77
|
+
// county's routes start on the same vertex — Dustwater's crossroads — so without it, come dusk, nine
|
|
78
|
+
// travellers walk home and stand INSIDE one another in the middle of the street.
|
|
79
|
+
//
|
|
80
|
+
// IT IS EXPRESSED IN THE ROAD'S OWN FRAME, AND THAT IS NOT A STYLE CHOICE. `out` may only ever push
|
|
81
|
+
// him FURTHER from the centreline, never toward it, because a man standing all night inside the
|
|
82
|
+
// stagecoach's braking corridor (coach.js: 2.0 m half-width about a line 1.15 m right of centre) is a
|
|
83
|
+
// coach that brakes at ten at night and is still standing there at dawn. A world-space jitter could
|
|
84
|
+
// put him at x = 0.1 on the main street. This one cannot: the nearest he can ever stand to the
|
|
85
|
+
// centreline is LANE itself, which is 2.45 m clear of her line. See verify-travellers.mjs [10].
|
|
86
|
+
// …and at a CORNER the offset is MITERED — the bisector of the leg coming in and the leg going out,
|
|
87
|
+
// which is how a polyline is offset and how a man actually walks a bend. Take the incoming leg alone
|
|
88
|
+
// and he holds his old side of the road right up to the fork and then swings across it: at Ott's
|
|
89
|
+
// track, which meets the street at 108 degrees, that put him 1.1 m from the centreline — in the
|
|
90
|
+
// stagecoach's braking corridor, in the middle of a junction. The bisector cuts it to 2.1 m.
|
|
91
|
+
export function laneTarget(out, road, i, dir, lane = LANE, spot = null) {
|
|
92
|
+
const b = road[i];
|
|
93
|
+
const leg = (from, to) => {
|
|
94
|
+
if (!from || !to) return null;
|
|
95
|
+
const x = to.x - from.x, z = to.z - from.z, L = Math.hypot(x, z);
|
|
96
|
+
return L < 1e-6 ? null : { x: x / L, z: z / L };
|
|
97
|
+
};
|
|
98
|
+
const inL = leg(road[i - dir], b) ?? leg(b, road[i + dir]); // arriving at i…
|
|
99
|
+
const outL = leg(b, road[i + dir]) ?? inL; // …and leaving it
|
|
100
|
+
if (!inL) return out.set(b.x, 0, b.z);
|
|
101
|
+
let dx = inL.x + outL.x, dz = inL.z + outL.z;
|
|
102
|
+
const B = Math.hypot(dx, dz);
|
|
103
|
+
if (B < 0.2) { dx = inL.x; dz = inL.z; } // a hairpin: the bisector collapses
|
|
104
|
+
else { dx /= B; dz /= B; }
|
|
105
|
+
const end = spot && (i === 0 || i === road.length - 1); // …only where they pile up
|
|
106
|
+
const off = lane + (end ? spot.out : 0); // out = AWAY from the road, never toward it
|
|
107
|
+
const along = end ? spot.along : 0; // …and spread up and down the verge
|
|
108
|
+
return out.set(b.x + dz * off + dx * along, 0, b.z - dx * off + dz * along); // (dz, -dx) — the right-hand normal
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// NOBODY IS STANDING ON THE RAILS WHEN A TRAIN COMES.
|
|
112
|
+
//
|
|
113
|
+
// train.js already parks a CrossingGuard — a coach-shaped blocker — on the level crossing whenever a
|
|
114
|
+
// train is closing on it, because a blocker is the only language coach.js speaks. A man is not a coach
|
|
115
|
+
// and cannot be told to brake, so he is told in HIS language: the same flee the whole street uses when
|
|
116
|
+
// a shot goes off, pointed AT the crossing so it carries him away from it. He gets 12 s of warning
|
|
117
|
+
// (train.js CROSS_WARN is 190 m at line speed) and 12 m to cover at a run of 3.6 m/s. Returns the
|
|
118
|
+
// shut crossing he is standing on, or null.
|
|
119
|
+
export function railShut(game, x, z) {
|
|
120
|
+
for (const gd of game.trains?.guards ?? []) {
|
|
121
|
+
if (gd.shut && dist2D(x, z, gd.at.x, gd.at.z) <= XING_CLEAR) return gd;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class Traveller extends NPC {
|
|
127
|
+
constructor(game, def, clips) {
|
|
128
|
+
const rt = ROUTES[def.route];
|
|
129
|
+
if (!rt) throw new Error(`[traveller] ${def.id}: no such route '${def.route}'`);
|
|
130
|
+
const last = rt.pts.length - 1;
|
|
131
|
+
const wp = Math.max(0, Math.min(last, Math.round((def.start ?? 0) * last)));
|
|
132
|
+
const p = rt.pts[wp];
|
|
133
|
+
// A roam disc of ZERO radius. It is what makes NPC's constructor put him on his own road rather
|
|
134
|
+
// than on Dustwater's street route, and what gives Character._navHome a sensible centre if
|
|
135
|
+
// anything ever asks him to path. He never roams it — wanderUpdate is overridden below.
|
|
136
|
+
super(game, { ...def, roam: { x: p.x, z: p.z, r: 0 } }, clips);
|
|
137
|
+
|
|
138
|
+
this.road = rt.pts; // these vertices ARE terrainData's road vertices (data/travellers.js)
|
|
139
|
+
this.built = rt.built; // …and where they run through a town, where the BVH stays on
|
|
140
|
+
this.routeName = def.route;
|
|
141
|
+
this.dir = def.dir ?? 1;
|
|
142
|
+
this._wp = Math.max(0, Math.min(last, wp + this.dir)); // the waypoint he is walking toward
|
|
143
|
+
this.speed = def.speed ?? 1.7;
|
|
144
|
+
this.pauseT = 0;
|
|
145
|
+
this.spot = { out: Math.random() * 2.5, along: (Math.random() - 0.5) * 12 }; // his own bit of the verge
|
|
146
|
+
this.setPosition(p.x, game.world.groundAt(p.x, p.z), p.z);
|
|
147
|
+
this.heading = Math.atan2(rt.pts[this._wp].x - p.x, rt.pts[this._wp].z - p.z);
|
|
148
|
+
this.root.rotation.y = this.heading;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Character.update reads cullDist instead of quality.entityCull; npc.js reads aiRange as the range
|
|
152
|
+
// at which the AI sleeps. They are THE SAME NUMBER on purpose, so he walks for exactly as long as he
|
|
153
|
+
// is drawn. Get it wrong the other way and you have a man frozen mid-stride in the middle of an
|
|
154
|
+
// empty road, which is worse than not having him at all.
|
|
155
|
+
get cullDist() { return roadCull(this.game); }
|
|
156
|
+
get aiRange() { return this.cullDist; }
|
|
157
|
+
|
|
158
|
+
// THE ROAD. This replaces NPC's street-wander wholesale; everything above it in NPC.update — the
|
|
159
|
+
// cull, the sleep, the fear of a hunting outlaw, the flee, the reel after a bullet, the death, the
|
|
160
|
+
// murder report, the purse — is inherited untouched.
|
|
161
|
+
wanderUpdate(dt, player, pd) {
|
|
162
|
+
const g = this.game;
|
|
163
|
+
const xing = railShut(g, this.position.x, this.position.z);
|
|
164
|
+
if (xing) { this.scare(xing.at.x, xing.at.z, XING_FLEE, true); return; } // get off the crossing
|
|
165
|
+
|
|
166
|
+
// DUSK AND DAWN. He does not stand where he is until sunrise — a man asleep in the middle of the
|
|
167
|
+
// ore road is a scarecrow. Both ends of every route are a settlement, so at dusk he turns for the
|
|
168
|
+
// NEARER one, walks to it, and stands there until it is light. That is why the roads empty at
|
|
169
|
+
// night: not because the AI stopped, but because everybody went home.
|
|
170
|
+
const hour = g.world.sky?.hour ?? 10;
|
|
171
|
+
const day = hour >= DAWN && hour < DUSK;
|
|
172
|
+
const last = this.road.length - 1;
|
|
173
|
+
if (day !== this._day) {
|
|
174
|
+
this._day = day;
|
|
175
|
+
if (!day) this.dir = this._wp * 2 <= last ? -1 : 1; // turn for the nearer end
|
|
176
|
+
else if (this._home) { this._home = false; this.dir = this._wp <= 0 ? 1 : -1; } // …and set out again
|
|
177
|
+
}
|
|
178
|
+
if (this._home || this.pauseT > 0) {
|
|
179
|
+
// STANDING — but never standing INSIDE something. A route end (or a pause spot) can sit a
|
|
180
|
+
// hair inside a fence/prop, and a stood man runs no capsule move to push him out — the soak
|
|
181
|
+
// caught the ranch-road walker wintering inside Dustwater's east paddock rail. Correct once
|
|
182
|
+
// per stand; the flag rearms when he walks.
|
|
183
|
+
if (!this._standFixed) {
|
|
184
|
+
this._standFixed = true;
|
|
185
|
+
const c = this.game.world.collide(this.position.x, this.position.z, 0.4, 0, this.position.y);
|
|
186
|
+
if (c && Math.hypot(c.x - this.position.x, c.z - this.position.z) > 0.02) {
|
|
187
|
+
this.position.x = c.x; this.position.z = c.z;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (this.pauseT > 0) this.pauseT -= dt;
|
|
191
|
+
this.socialIdle(dt);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this._standFixed = false;
|
|
195
|
+
|
|
196
|
+
// tmpV2, not tmpV1: `dir` below is built in tmpV1 FROM this, and one shared scratch vector holding
|
|
197
|
+
// both the target and the direction to it is a bug waiting for someone to reorder two lines.
|
|
198
|
+
const t = laneTarget(tmpV2, this.road, this._wp, this.dir, LANE, this.spot);
|
|
199
|
+
const td = dist2D(this.position.x, this.position.z, t.x, t.z);
|
|
200
|
+
// Arrive EARLY when something is parked on the waypoint (a cart, another walker): take the next
|
|
201
|
+
// leg rather than pressing into whatever is sitting on the point.
|
|
202
|
+
if (td <= (this._steerBlockAhead ? BLOCKED_ARRIVE : ARRIVE)) { this._advance(); return; }
|
|
203
|
+
|
|
204
|
+
// STUCK. moveCapsule slides along static geometry, it does not path around it, so a building
|
|
205
|
+
// corner or a hitching rail can stop a man dead against a waypoint he will never reach. Skip the
|
|
206
|
+
// leg rather than tread water beside it for the rest of the game.
|
|
207
|
+
this._stuckT = (this._stuckT ?? 0) + dt;
|
|
208
|
+
if (this._stuckT >= 1.4) {
|
|
209
|
+
const moved = dist2D(this.position.x, this.position.z, this._lastX ?? this.position.x, this._lastZ ?? this.position.z);
|
|
210
|
+
this._lastX = this.position.x; this._lastZ = this.position.z; this._stuckT = 0;
|
|
211
|
+
if (moved < 0.5) { this._advance(); return; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The BVH is on inside 45 m of the player — and ALWAYS where the road runs through a town, whoever
|
|
215
|
+
// is watching. A man who walks through the front of the store because you were sixty metres away
|
|
216
|
+
// is not a saving. (data/travellers.js `built`.)
|
|
217
|
+
if (pd < BVH_R || this.built[this._wp]) {
|
|
218
|
+
// STATIC obstacles (a hitching rail, a building corner, a fence across the verge) are routed
|
|
219
|
+
// AROUND via the settlement's baked grid A*; the whiskers below then handle only DYNAMIC dodging
|
|
220
|
+
// (people, carts). When the way to the lane target is clear this is free and aims straight at it.
|
|
221
|
+
let ax = t.x, az = t.z;
|
|
222
|
+
const centre = nearestEndpoint(this.road, this.position.x, this.position.z);
|
|
223
|
+
if (steerCarrot(this, g, centre, this.position.x, this.position.z, t.x, t.z, _faim, { dt, arrive: ARRIVE })) { ax = _faim.x; az = _faim.z; }
|
|
224
|
+
const dir = tmpV1.set(ax - this.position.x, 0, az - this.position.z).normalize();
|
|
225
|
+
const sd = this.walkDir(dir, dt); // whiskers: swerve round people and props
|
|
226
|
+
this.move(sd, this.speed * (this._steerSlow ? 0.55 : 1), dt);
|
|
227
|
+
this.faceToward(this.position.x + sd.x, this.position.z + sd.z, dt);
|
|
228
|
+
} else {
|
|
229
|
+
this._rtPath = null; // open road: no statics to route around
|
|
230
|
+
const dir = tmpV1.set(t.x - this.position.x, 0, t.z - this.position.z).normalize();
|
|
231
|
+
const s = this.speed * dt; // terrain-follow only, no BVH
|
|
232
|
+
this.position.x += dir.x * s;
|
|
233
|
+
this.position.z += dir.z * s;
|
|
234
|
+
this.position.y = g.world.groundAt(this.position.x, this.position.z);
|
|
235
|
+
this.vy = 0; // the capsule takes over again inside 45 m
|
|
236
|
+
this.faceToward(this.position.x + dir.x, this.position.z + dir.z, dt);
|
|
237
|
+
}
|
|
238
|
+
this.play('walk');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// On to the next waypoint. At the end of the road he turns round and takes a breath — unless he is
|
|
242
|
+
// going home for the night, in which case he has arrived, and he stands.
|
|
243
|
+
_advance() {
|
|
244
|
+
const next = this._wp + this.dir;
|
|
245
|
+
if (next < 0 || next >= this.road.length) {
|
|
246
|
+
if (!this._day) { this._home = true; return; }
|
|
247
|
+
this.dir = -this.dir;
|
|
248
|
+
this.pauseT = 2 + Math.random() * 4;
|
|
249
|
+
this._wp += this.dir;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this._wp = next;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// BLOWING TUMBLEWEEDS — a small pool of tumbleweeds that roll across the ground on the GLOBAL WIND
|
|
2
|
+
// (world/wind.js), following the terrain, catching on fences and shouldered aside by walls, recycled
|
|
3
|
+
// upwind whenever one blows too far from the player so there are always a few tumbling through view.
|
|
4
|
+
// Mirrors the herd/casino cast pattern: one InstancedMesh built once at boot, ticked every frame.
|
|
5
|
+
//
|
|
6
|
+
// It reuses the geometry + atlas material of the town's own static tumbleweed prop (already built and
|
|
7
|
+
// textured by town.js), so there is no second model load and it matches the world exactly.
|
|
8
|
+
import * as THREE from 'three/webgpu';
|
|
9
|
+
// register the terrain height query before update: setTumbleweedTerrain({ heightAt })
|
|
10
|
+
let TT = { heightAt: () => 0 };
|
|
11
|
+
export function setTumbleweedTerrain(t) { TT = { ...TT, ...t }; }
|
|
12
|
+
|
|
13
|
+
const R = 0.318; // measured tumbleweed radius (origin at its CENTRE → it rests at heightAt + R·scale)
|
|
14
|
+
const COUNT = 14; // how many are live at once
|
|
15
|
+
const RING = 52; // spawn ring radius around the player
|
|
16
|
+
const CULL = 82; // recycle once this far from the player (just past the spawn ring)
|
|
17
|
+
const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3();
|
|
18
|
+
const _ax = new THREE.Vector3(), _tv = new THREE.Vector3(), _push = new THREE.Vector3();
|
|
19
|
+
const _UP = new THREE.Vector3(0, 1, 0);
|
|
20
|
+
|
|
21
|
+
export class Tumbleweeds {
|
|
22
|
+
constructor(game) { this.game = game; this.weeds = []; }
|
|
23
|
+
|
|
24
|
+
async load() {
|
|
25
|
+
// borrow the town's tumbleweed geometry + material (no second load; guaranteed to match the world)
|
|
26
|
+
let geo = null, mat = null;
|
|
27
|
+
this.game.scene.traverse((o) => { if (!geo && o.isMesh && /Tumbleweed/i.test(o.name || '')) { geo = o.geometry; mat = o.material; } });
|
|
28
|
+
if (!geo) return this; // the pack build has no tumbleweed → nothing to blow
|
|
29
|
+
this.mesh = new THREE.InstancedMesh(geo, mat, COUNT);
|
|
30
|
+
this.mesh.name = 'tumbleweeds:live';
|
|
31
|
+
this.mesh.frustumCulled = false; this.mesh.castShadow = false; this.mesh.receiveShadow = true;
|
|
32
|
+
this.game.scene.add(this.mesh);
|
|
33
|
+
const p = this.game.player?.position ?? { x: 0, z: 0 };
|
|
34
|
+
for (let i = 0; i < COUNT; i++) {
|
|
35
|
+
const w = { x: 0, z: 0, q: new THREE.Quaternion(), scl: 0.65 + Math.random() * 0.75, drag: 0.55 + Math.random() * 0.4, stuck: 0 };
|
|
36
|
+
this._respawn(w, p, true);
|
|
37
|
+
this.weeds.push(w); this._write(i, w);
|
|
38
|
+
}
|
|
39
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// (re)place a weed. On first fill scatter it all round the player; after that drop it UPWIND (fanned to
|
|
44
|
+
// the sides) so it blows back across view rather than popping in beside you.
|
|
45
|
+
_respawn(w, p, initial) {
|
|
46
|
+
const wind = this.game.wind;
|
|
47
|
+
const toward = wind ? Math.atan2(wind.z, wind.x) : Math.random() * Math.PI * 2;
|
|
48
|
+
const a = initial ? Math.random() * Math.PI * 2 : (toward + Math.PI) + (Math.random() - 0.5) * 1.6;
|
|
49
|
+
const d = initial ? Math.random() * RING : RING * (0.7 + Math.random() * 0.5);
|
|
50
|
+
w.x = p.x + Math.cos(a) * d; w.z = p.z + Math.sin(a) * d;
|
|
51
|
+
w.q.setFromAxisAngle(_ax.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize() || _UP, Math.random() * 6.2832);
|
|
52
|
+
w.stuck = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_write(i, w) {
|
|
56
|
+
_p.set(w.x, TT.heightAt(w.x, w.z) + R * w.scl, w.z);
|
|
57
|
+
this.mesh.setMatrixAt(i, _m.compose(_p, w.q, _s.setScalar(w.scl)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
update(dt) {
|
|
61
|
+
if (!this.mesh) return;
|
|
62
|
+
const p = this.game.player?.position, wind = this.game.wind;
|
|
63
|
+
if (!p || !wind) return;
|
|
64
|
+
dt = Math.min(dt, 0.05);
|
|
65
|
+
const world = this.game.world;
|
|
66
|
+
for (let i = 0; i < this.weeds.length; i++) {
|
|
67
|
+
const w = this.weeds[i];
|
|
68
|
+
const rad = R * w.scl;
|
|
69
|
+
let nx = w.x + wind.vx * w.drag * dt;
|
|
70
|
+
let nz = w.z + wind.vz * w.drag * dt;
|
|
71
|
+
// one call clears it of BOTH fence capsules/trees (it catches) AND building walls (it's shouldered
|
|
72
|
+
// aside) — the same push-out a coach uses. A weed pinned against a wall for a beat is recycled.
|
|
73
|
+
if (world?.solidPush) {
|
|
74
|
+
world.solidPush(nx, TT.heightAt(nx, nz) + rad, nz, rad, _push);
|
|
75
|
+
nx += _push.x; nz += _push.z;
|
|
76
|
+
if (_push.x * _push.x + _push.z * _push.z > 0.02) {
|
|
77
|
+
w.stuck += dt;
|
|
78
|
+
if (w.stuck > 2.2) { this._respawn(w, p, false); this._write(i, w); continue; }
|
|
79
|
+
} else w.stuck = 0;
|
|
80
|
+
}
|
|
81
|
+
const dx = nx - w.x, dz = nz - w.z, dist = Math.hypot(dx, dz);
|
|
82
|
+
if (dist > 1e-4) { // roll: spin about the horizontal axis ⟂ to travel
|
|
83
|
+
_ax.copy(_UP).cross(_tv.set(dx, 0, dz)).normalize();
|
|
84
|
+
w.q.premultiply(_q.setFromAxisAngle(_ax, dist / rad));
|
|
85
|
+
}
|
|
86
|
+
w.x = nx; w.z = nz;
|
|
87
|
+
if ((w.x - p.x) ** 2 + (w.z - p.z) ** 2 > CULL * CULL) this._respawn(w, p, false);
|
|
88
|
+
this._write(i, w);
|
|
89
|
+
}
|
|
90
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
91
|
+
}
|
|
92
|
+
}
|