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,136 @@
|
|
|
1
|
+
// Climbing: the player's ladder controller — ported from fable/src/game/climbing.js and adapted to
|
|
2
|
+
// western. The climb clips (climbMount/Up/Down/Top/Idle) are already retargeted onto the player rig
|
|
3
|
+
// through the normal clip pipeline (clips.js → the default Synty rig), so this just snaps the player to
|
|
4
|
+
// a registered ladder and runs mount → up/down loop (code-driven vertical) → climb-off.
|
|
5
|
+
//
|
|
6
|
+
// Ladders come from world.ladders (town.js buildLadders): [{ base:Vector3(world), quat:Quaternion(world),
|
|
7
|
+
// height }]. main.js's E-contest resolves the nearest one and calls mount(l). Mirrors Riding's
|
|
8
|
+
// "takes over the player's update + camera" shape; player.update early-returns while active.
|
|
9
|
+
import * as THREE from 'three/webgpu';
|
|
10
|
+
|
|
11
|
+
const CLIMB_SPEED = 1.7; // m/s along the rungs (match the up/down loop cadence)
|
|
12
|
+
const ANIM_SPEED = 2; // clip playback ×; the mocap loops are slow, so 2× keeps the hands with the movement
|
|
13
|
+
const BODY_OFF = 0.30; // player root sits this far back from the ladder line (gripping side)
|
|
14
|
+
const FACE_SIGN = 1; // +1 for western's ladder props (fable used −1); flips which side the climber grips + faces
|
|
15
|
+
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
16
|
+
const _axis = new THREE.Vector3(), _face = new THREE.Vector3();
|
|
17
|
+
|
|
18
|
+
export class Climbing {
|
|
19
|
+
constructor(player) {
|
|
20
|
+
this.player = player;
|
|
21
|
+
this.game = player.game;
|
|
22
|
+
this.mode = 'off'; // off | mounting | climbing | dismounting
|
|
23
|
+
this.ladder = null; this.h = 0; this.faceYaw = 0; this.height = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get active() { return this.mode !== 'off'; } // getter — never assign it; forceOff() resets the raw fields
|
|
27
|
+
|
|
28
|
+
// up-the-rungs axis (from the prop quat) + the horizontal yaw to face INTO the ladder
|
|
29
|
+
_solve(l) {
|
|
30
|
+
_axis.set(0, 1, 0).applyQuaternion(l.quat).normalize(); // climb direction
|
|
31
|
+
_face.set(_axis.x, 0, _axis.z); // a leaning ladder tilts away from the wall
|
|
32
|
+
if (_face.lengthSq() < 1e-3) _face.set(0, 0, 1).applyQuaternion(l.quat).setY(0); // vertical ladder → prop +Z
|
|
33
|
+
_face.normalize();
|
|
34
|
+
this.faceYaw = Math.atan2(FACE_SIGN * _face.x, FACE_SIGN * _face.z);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Cap the climb at the SURFACE you step onto. The ladder MESH runs from its base to its own top, which
|
|
38
|
+
// sits above the roofline (a real ladder pokes past the eaves). Raycast down over the ladder top, nudged
|
|
39
|
+
// toward the building, for the highest roof/floor at/below the mesh top, and top the climb out there so
|
|
40
|
+
// you don't climb off into thin air. Falls back to the mesh height if no surface is found.
|
|
41
|
+
_resolveHeight(l) {
|
|
42
|
+
const W = this.game.world;
|
|
43
|
+
const bvhs = [W.buildingBVH, ...(W.sideBVHs || [])].filter((b) => b && b.raycast);
|
|
44
|
+
if (!bvhs.length || !W._snapRay) return l.height;
|
|
45
|
+
_axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
|
|
46
|
+
const sx = l.base.x + _axis.x * l.height + Math.sin(this.faceYaw) * 0.7; // over the top, onto the roof
|
|
47
|
+
const sz = l.base.z + _axis.z * l.height + Math.cos(this.faceYaw) * 0.7;
|
|
48
|
+
const ray = W._snapRay; ray.origin.set(sx, l.base.y + l.height + 4, sz); ray.direction.set(0, -1, 0);
|
|
49
|
+
let roof = -Infinity;
|
|
50
|
+
for (const b of bvhs) {
|
|
51
|
+
const hs = b.raycast(ray, 2) || [];
|
|
52
|
+
for (const h of hs) {
|
|
53
|
+
const n = h.face?.normal;
|
|
54
|
+
if (n && Math.abs(n.y) > 0.4 && h.point.y > roof && h.point.y <= l.base.y + l.height + 0.3) roof = h.point.y;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return roof > l.base.y + 1 ? Math.min(l.height, roof - l.base.y + 0.15) : l.height;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ENTRY — main.js's E-contest resolves the nearest ladder and calls this. Enters at the bottom, or the
|
|
61
|
+
// top if you walked onto it from the roof platform.
|
|
62
|
+
mount(l) {
|
|
63
|
+
if (this.active || !l) return;
|
|
64
|
+
this._solve(l);
|
|
65
|
+
this.height = this._resolveHeight(l); // cap the climb at the surface you step ONTO (the mesh pokes above)
|
|
66
|
+
// Enter at YOUR height on the rungs — project (you − base) onto the climb axis — so grabbing from the
|
|
67
|
+
// street enters at the bottom, from the roof at the top, and RE-GRABBING MID-AIR (after a hop-off)
|
|
68
|
+
// continues from where you are instead of snapping to an end.
|
|
69
|
+
_axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
|
|
70
|
+
const p = this.player.position;
|
|
71
|
+
const hAlong = (p.x - l.base.x) * _axis.x + (p.y - l.base.y) * _axis.y + (p.z - l.base.z) * _axis.z;
|
|
72
|
+
this.ladder = l; this.mode = 'mounting'; this.h = clamp(hAlong, 0, this.height);
|
|
73
|
+
this._place(); this.player.busy = true;
|
|
74
|
+
this.player.animator.play('climbMount', { once: true, fade: 0.12, timeScale: ANIM_SPEED, onDone: () => { if (this.mode === 'mounting') this.mode = 'climbing'; } });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// place the player at height h along the rungs, set back from the ladder by BODY_OFF, facing it
|
|
78
|
+
_place() {
|
|
79
|
+
const l = this.ladder, p = this.player;
|
|
80
|
+
_axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
|
|
81
|
+
p.position.x = l.base.x + _axis.x * this.h - Math.sin(this.faceYaw) * BODY_OFF;
|
|
82
|
+
p.position.z = l.base.z + _axis.z * this.h - Math.cos(this.faceYaw) * BODY_OFF;
|
|
83
|
+
p.position.y = l.base.y + _axis.y * this.h;
|
|
84
|
+
p._visualY = p.position.y; // keep the model lift glued to the climb height
|
|
85
|
+
p.heading = this.faceYaw; p.root.rotation.y = this.faceYaw;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
update(dt, input, camera) {
|
|
89
|
+
const p = this.player, l = this.ladder;
|
|
90
|
+
const ui = this.game.ui;
|
|
91
|
+
const inMenu = ui?.anyPanelOpen || ui?.dialogueOpen || ui?.cinematic;
|
|
92
|
+
if (this.mode === 'climbing') {
|
|
93
|
+
let v = 0;
|
|
94
|
+
if (!inMenu) { if (input.down('KeyW')) v += 1; if (input.down('KeyS')) v -= 1; } // held W/S
|
|
95
|
+
p.animator.play(v > 0 ? 'climbUp' : v < 0 ? 'climbDown' : 'climbIdle', { fade: 0.12, timeScale: ANIM_SPEED });
|
|
96
|
+
this.h = clamp(this.h + v * CLIMB_SPEED * dt, 0, this.height);
|
|
97
|
+
this._place();
|
|
98
|
+
if (this.h >= this.height && v > 0) { // reached the top → climb off
|
|
99
|
+
this.mode = 'dismounting';
|
|
100
|
+
p.animator.play('climbTop', { once: true, fade: 0.12, timeScale: ANIM_SPEED, onDone: () => this._dismount(true) });
|
|
101
|
+
} else if (this.h <= 0 && v < 0) this._dismount(false); // stepped off the bottom
|
|
102
|
+
else if (!inMenu && input.hit('Space')) this._dismount(false); // hop off anywhere
|
|
103
|
+
} else {
|
|
104
|
+
this._place(); // mounting | dismounting — hold while the clip plays
|
|
105
|
+
}
|
|
106
|
+
// Reuse the player's OWN look + follow camera (not a bespoke one) so the on-foot ↔ climb transition is
|
|
107
|
+
// seamless — _camera reads position + _visualY, which _place() keeps synced to the climb height.
|
|
108
|
+
if (!inMenu) p._look?.(input, this.game.rawDt ?? dt);
|
|
109
|
+
p._camera(dt, input, camera);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_dismount(top) {
|
|
113
|
+
const l = this.ladder, p = this.player;
|
|
114
|
+
if (top) { // step forward off the top onto the surface
|
|
115
|
+
_axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
|
|
116
|
+
p.position.x = l.base.x + _axis.x * this.height + Math.sin(this.faceYaw) * 0.7;
|
|
117
|
+
p.position.z = l.base.z + _axis.z * this.height + Math.cos(this.faceYaw) * 0.7;
|
|
118
|
+
p.position.y = l.base.y + _axis.y * this.height + 0.1;
|
|
119
|
+
}
|
|
120
|
+
this.mode = 'off'; this.ladder = null;
|
|
121
|
+
// PIN _visualY to the current feet height, NOT undefined: this same frame the climb update still calls
|
|
122
|
+
// p._camera(), which reads `_visualY + 1.55` — undefined there = NaN = a grey (void) screen. position.y
|
|
123
|
+
// is valid, and the on-foot smoother continues cleanly from it next frame.
|
|
124
|
+
p.busy = false; p.vy = 0; p.airborne = false; p._visualY = p.position.y;
|
|
125
|
+
p.animator.play('idle', { fade: 0.15 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Hard exit for death / fast-travel / load — a LIVE controller re-glues the player to the ladder every
|
|
129
|
+
// frame via _place(), silently undoing any teleport. Reset the raw fields (never assign .active).
|
|
130
|
+
forceOff() {
|
|
131
|
+
if (!this.active) return;
|
|
132
|
+
const p = this.player;
|
|
133
|
+
this.mode = 'off'; this.ladder = null;
|
|
134
|
+
p.busy = false; p.vy = 0; p.airborne = false; p._visualY = p.position.y;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// DEAD EYE — the RDR2 signature, on CTRL.
|
|
2
|
+
//
|
|
3
|
+
// THE CONTRACT (why this file exists at all): AIMING must not change the screen or the clock.
|
|
4
|
+
// RMB is a lean, nothing more. THIS is the thing that slows time, drains the world of colour
|
|
5
|
+
// and lets you paint shots onto a man before you take them.
|
|
6
|
+
//
|
|
7
|
+
// FLOW
|
|
8
|
+
// Ctrl (gun drawn, meter has charge) → ACTIVE
|
|
9
|
+
// · the WORLD runs at ~0.3× (main.js multiplies its world-dt chain by this.scale) while
|
|
10
|
+
// the camera and the aim IK keep running on wall-clock dt — you aim at full speed
|
|
11
|
+
// inside a slow world, which is the whole trick. (player.js reads game.rawDt.)
|
|
12
|
+
// · the screen desaturates to sepia + a red vignette closes in + grain (ONE uniform:
|
|
13
|
+
// renderer.js's deadEyeGrade, driven from this.amount).
|
|
14
|
+
// · the audio goes underwater (audio.setDeadEye — lowpass + slowed sample pitch).
|
|
15
|
+
// · CLICKING a man marks a shot at the exact body point under the reticle. One click, one bullet.
|
|
16
|
+
// Ctrl again / LMB / an empty meter → RELEASE: the tags fire in sequence, ~0.12s apart,
|
|
17
|
+
// each one down the SAME combat.fireBullet path as a normal shot (identical damage,
|
|
18
|
+
// blood, impacts, ammo, recoil), and time ramps back to normal as the queue empties.
|
|
19
|
+
//
|
|
20
|
+
// TIME DISCIPLINE: every timer in this file runs on RAW (wall-clock) dt. A Dead Eye that
|
|
21
|
+
// metered itself in slow-mo time would drain 3× slower the moment it started slowing the
|
|
22
|
+
// world — the feature would fight its own clock. main.js calls update(rawDt) BEFORE it
|
|
23
|
+
// applies the dt multiplier chain, so the scale returned here lands on the SAME frame.
|
|
24
|
+
import * as THREE from 'three/webgpu';
|
|
25
|
+
import { clamp, lerp } from '../core/utils.js';
|
|
26
|
+
|
|
27
|
+
// ---- TUNABLES (all of them, in one place) -------------------------------------------
|
|
28
|
+
export const DEAD_EYE = {
|
|
29
|
+
meterMax: 1, // the meter is normalised 0..1 (player.deadEye — the HUD contract)
|
|
30
|
+
drainRate: 0.15, // meter/sec while ACTIVE → ~6.7s of slow-mo from full. It was 0.30 and three
|
|
31
|
+
// seconds is not long enough to read a street, pick your men and paint them —
|
|
32
|
+
// you spent the whole meter deciding. Twice the time, same meter.
|
|
33
|
+
refillRate: 0.022, // meter/sec passive → ~45s from empty doing nothing
|
|
34
|
+
refillPerDamage: 0.004, // meter per point of damage dealt (a 16-dmg revolver hit ≈ 0.064)
|
|
35
|
+
refillOnKill: 0.20, // meter per kill — the loop that keeps a gunfight in Dead Eye
|
|
36
|
+
timeScale: 0.3, // the WORLD's dt multiplier at full amount (camera/aim stay 1×)
|
|
37
|
+
maxTags: 6, // hard cap (also capped by ammo in the chamber and meter left)
|
|
38
|
+
tagRadius: 1.0, // metres — how close the aim ray must pass to a body to paint it
|
|
39
|
+
tagCost: 0.09, // meter spent per tag — this is what "cap tags by meter" means
|
|
40
|
+
tagCooldown: 0.16, // seconds between tags, so one hover doesn't dump the whole cylinder
|
|
41
|
+
tagInterval: 0.12, // seconds between the queued shots on release
|
|
42
|
+
rampIn: 9, // amount lerp rate into Dead Eye (fast — the snap is the drama)
|
|
43
|
+
rampOut: 5, // and out of it
|
|
44
|
+
minCharge: 0.12, // meter needed to activate at all (a sliver can't buy a shot)
|
|
45
|
+
range: 60, // metres — how far a tag can be painted
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const _o = new THREE.Vector3(); // ray origin (camera)
|
|
49
|
+
const _d = new THREE.Vector3(); // ray direction (the aim ray)
|
|
50
|
+
const _a = new THREE.Vector3(); // body segment foot
|
|
51
|
+
const _b = new THREE.Vector3(); // body segment head
|
|
52
|
+
const _p = new THREE.Vector3(); // closest point on the body
|
|
53
|
+
const _hit = new THREE.Vector3();
|
|
54
|
+
const _pt = new THREE.Vector3();
|
|
55
|
+
|
|
56
|
+
export class DeadEye {
|
|
57
|
+
constructor(game) {
|
|
58
|
+
this.game = game;
|
|
59
|
+
this.active = false; // Ctrl-slow-mo running, painting tags
|
|
60
|
+
this.firing = false; // the volley is going out
|
|
61
|
+
this.amount = 0; // 0..1 — the ONE blend: grade uniform, audio, time scale
|
|
62
|
+
this.scale = 1; // world-dt multiplier main.js reads (1 → DEAD_EYE.timeScale)
|
|
63
|
+
this.tags = []; // [{ enemy, off: Vector3, point: Vector3 }] — painted shots
|
|
64
|
+
this.queue = []; // tags being fired out, in paint order
|
|
65
|
+
this._queueN = 0; // queue length at release (drives the ramp back to full speed)
|
|
66
|
+
this._tagT = 0; // tag cooldown
|
|
67
|
+
this._fireT = 0; // next-shot timer during the volley
|
|
68
|
+
this._hpSum = null; // enemy-health census, for the hit/kill refill (no combat hook)
|
|
69
|
+
this._aliveN = null;
|
|
70
|
+
this._lastAmt = -1; // change-gates for the uniform + audio writes
|
|
71
|
+
this._lastAudio = -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- state ------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
// Everything that must be true to hold Dead Eye open. Mirrors player.js's own "stolen"
|
|
77
|
+
// guards (menu / dialogue / cinematic / mounted / dead) plus: you need a GUN IN HAND.
|
|
78
|
+
_canUse() {
|
|
79
|
+
const p = this.game.player, ui = this.game.ui;
|
|
80
|
+
if (!p || !p.alive) return false;
|
|
81
|
+
if (p.riding?.mounted) return false;
|
|
82
|
+
if (ui?.menuOpen || ui?.dialogueOpen || ui?.cinematic) return false;
|
|
83
|
+
if (!p.drawn || p.weapon === 'fists' || !(p.ammoMax > 0)) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
activate() {
|
|
88
|
+
if (this.active || this.firing) return false;
|
|
89
|
+
const p = this.game.player;
|
|
90
|
+
// A MAN WHO CALLS FOR DEAD EYE WANTS HIS GUN IN HIS HAND. Ctrl used to be refused outright with
|
|
91
|
+
// the revolver still in leather (_canUse rejects !p.drawn), so it took two keys to express one
|
|
92
|
+
// intention: draw, then Ctrl. Now Ctrl IS the draw — it clears leather and comes up aiming, which
|
|
93
|
+
// is the whole beat. (player.js reads deadEyeActive as an aim hold, so the stance follows.)
|
|
94
|
+
if (p?.alive && !p.riding?.mounted) {
|
|
95
|
+
if (p.weapon === 'fists' && p.ammoMax !== 0) p.weapon = 'revolver';
|
|
96
|
+
if (!p.drawn && p._drawT <= 0) p._startDraw();
|
|
97
|
+
}
|
|
98
|
+
if (!this._canUse()) return false;
|
|
99
|
+
if ((p.deadEye ?? 0) < DEAD_EYE.minCharge) { this.game.audio?.play('dryfire'); return false; }
|
|
100
|
+
this.active = true;
|
|
101
|
+
this._tagT = 0;
|
|
102
|
+
this.tags.length = 0;
|
|
103
|
+
p.deadEyeActive = true;
|
|
104
|
+
this.game.hint?.('deadeye', 'DEAD EYE — CLICK each man to mark him. Let go (or Ctrl) and they all go.');
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Let the painted shots fly. Empty queue = just drop out of slow-mo.
|
|
109
|
+
release() {
|
|
110
|
+
if (!this.active) return;
|
|
111
|
+
this.active = false;
|
|
112
|
+
this.game.player.deadEyeActive = false;
|
|
113
|
+
this.queue = this.tags.slice();
|
|
114
|
+
this.tags = [];
|
|
115
|
+
this._queueN = this.queue.length;
|
|
116
|
+
if (!this._queueN) { this.firing = false; return; }
|
|
117
|
+
this.firing = true;
|
|
118
|
+
this._fireT = 0; // the first shot leaves immediately
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Hard stop, no shots fired — death, mount, menu, holster.
|
|
122
|
+
cancel() {
|
|
123
|
+
if (!this.active && !this.firing) return;
|
|
124
|
+
this.active = false;
|
|
125
|
+
this.firing = false;
|
|
126
|
+
this.tags.length = 0;
|
|
127
|
+
this.queue.length = 0;
|
|
128
|
+
this._queueN = 0;
|
|
129
|
+
if (this.game.player) this.game.player.deadEyeActive = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- frame --------------------------------------------------------------------------
|
|
133
|
+
// rawDt is WALL-CLOCK seconds (see the header). Called from main.js before the dt chain.
|
|
134
|
+
update(rawDt) {
|
|
135
|
+
const p = this.game.player;
|
|
136
|
+
if (!p) return;
|
|
137
|
+
const input = this.game.input;
|
|
138
|
+
const dt = Math.min(rawDt, 0.1);
|
|
139
|
+
|
|
140
|
+
// an interruption (death / mount / menu / the gun going back to leather) kills it dead
|
|
141
|
+
if ((this.active || this.firing) && !this._canUse()) this.cancel();
|
|
142
|
+
|
|
143
|
+
// ---- input. Ctrl TOGGLES. The MIDDLE MOUSE BUTTON HOLDS: press it and she is up, let go and the
|
|
144
|
+
// volley leaves — the gesture your hand already knows, and one that cannot strand you in slow-mo
|
|
145
|
+
// because you forgot you were in it. LMB also looses the volley (and is CONSUMED so player.js's
|
|
146
|
+
// gun state machine never sees it — otherwise the same click would fire a hip shot as well).
|
|
147
|
+
if (input && !this.game.ui?.menuOpen) {
|
|
148
|
+
if (input.hit('ControlLeft') || input.hit('ControlRight')) {
|
|
149
|
+
if (this.active) this.release();
|
|
150
|
+
else if (!this.firing) this.activate();
|
|
151
|
+
}
|
|
152
|
+
const mmb = input.held(1) && input.acting;
|
|
153
|
+
if (mmb && !this._mmb && !this.active && !this.firing) this.activate(); // pressed: she comes up
|
|
154
|
+
else if (!mmb && this._mmb && this.active) this.release(); // let go: they go
|
|
155
|
+
this._mmb = mmb;
|
|
156
|
+
|
|
157
|
+
// LEFT CLICK MARKS A MAN. It used to LOOSE the volley, and the marks painted themselves off
|
|
158
|
+
// whatever the reticle happened to sweep across — so the shots were chosen by where you waved
|
|
159
|
+
// the mouse, not by you, and a man you merely glanced past took a bullet. Now it is deliberate:
|
|
160
|
+
// hold Dead Eye, click each man you want, let go and they all go. Consumed, so player.js's gun
|
|
161
|
+
// state machine never sees the click and does not also fire a hip shot down the same barrel.
|
|
162
|
+
if (this.active && input.mouseHit(0) && input.acting) {
|
|
163
|
+
input.consumeMouse?.(0);
|
|
164
|
+
this._mark();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- meter. Drains ONLY while active; refills on time, and on damage/kills (polled
|
|
169
|
+
// from the enemy roster, so combat.js needs no hook).
|
|
170
|
+
this._refill(dt);
|
|
171
|
+
if (this.active) {
|
|
172
|
+
p.deadEye = clamp((p.deadEye ?? 0) - DEAD_EYE.drainRate * dt, 0, DEAD_EYE.meterMax);
|
|
173
|
+
if (p.deadEye <= 0) this.release(); // ran dry → the shots go anyway
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- painting. NOT automatic any more: a mark is a click (see _mark, called from the input
|
|
177
|
+
// block above). The cooldown still ticks, so a held mouse button cannot empty the cylinder.
|
|
178
|
+
if (this.active) this._tagT = Math.max(0, this._tagT - dt);
|
|
179
|
+
|
|
180
|
+
// ---- the volley
|
|
181
|
+
if (this.firing) this._volley(dt);
|
|
182
|
+
|
|
183
|
+
// ---- ONE blend drives everything. While the volley empties, the target falls with the
|
|
184
|
+
// queue — so time is already most of the way back to normal by the last shot.
|
|
185
|
+
const target = this.active ? 1
|
|
186
|
+
: (this.firing && this._queueN) ? clamp(this.queue.length / this._queueN, 0, 1)
|
|
187
|
+
: 0;
|
|
188
|
+
const rate = target > this.amount ? DEAD_EYE.rampIn : DEAD_EYE.rampOut;
|
|
189
|
+
this.amount = lerp(this.amount, target, Math.min(1, dt * rate));
|
|
190
|
+
if (this.amount < 0.002 && target === 0) this.amount = 0; // settle exactly to zero
|
|
191
|
+
this.scale = 1 + (DEAD_EYE.timeScale - 1) * this.amount;
|
|
192
|
+
|
|
193
|
+
// screen grade: ONE uniform (renderer.js). Written only when it actually moves.
|
|
194
|
+
const amt = this.amount;
|
|
195
|
+
if (Math.abs(amt - this._lastAmt) > 0.002) {
|
|
196
|
+
this._lastAmt = amt;
|
|
197
|
+
const u = this.game.postProcessing?.gradeAmount;
|
|
198
|
+
if (u) u.value = amt;
|
|
199
|
+
}
|
|
200
|
+
// audio: lowpass + slowed sample pitch. setTargetAtTime ramps, so a coarse gate is fine.
|
|
201
|
+
if (Math.abs(amt - this._lastAudio) > 0.02 || (amt === 0 && this._lastAudio !== 0)) {
|
|
202
|
+
this._lastAudio = amt;
|
|
203
|
+
this.game.audio?.setDeadEye?.(amt);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// markers (screen-space DOM Xs, drawn by ui.js)
|
|
207
|
+
this.game.ui?.deadEyeTags?.(this.active || this.firing ? this._live() : null);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Refill: passive trickle + the damage/kill census. No combat.js hook — we just watch the
|
|
211
|
+
// enemy roster's total health and headcount. A drop in health can only have come from the
|
|
212
|
+
// player (nothing else hurts them), and a drop in the alive count is a kill. Respawns
|
|
213
|
+
// raise both, and a rise clamps to zero credit.
|
|
214
|
+
_refill(dt) {
|
|
215
|
+
const p = this.game.player;
|
|
216
|
+
const es = this.game.combat?.enemies;
|
|
217
|
+
let credit = 0;
|
|
218
|
+
if (es) {
|
|
219
|
+
let sum = 0, n = 0;
|
|
220
|
+
for (const e of es) { if (e.alive) { n++; sum += Math.max(0, e.health); } }
|
|
221
|
+
if (this._hpSum !== null) {
|
|
222
|
+
credit += Math.max(0, this._hpSum - sum) * DEAD_EYE.refillPerDamage;
|
|
223
|
+
credit += Math.max(0, this._aliveN - n) * DEAD_EYE.refillOnKill;
|
|
224
|
+
}
|
|
225
|
+
this._hpSum = sum;
|
|
226
|
+
this._aliveN = n;
|
|
227
|
+
}
|
|
228
|
+
if (this.active) return; // drains only while active — no fighting it
|
|
229
|
+
credit += DEAD_EYE.refillRate * dt;
|
|
230
|
+
if (credit > 0) p.deadEye = clamp((p.deadEye ?? 0) + credit, 0, DEAD_EYE.meterMax);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- marking ------------------------------------------------------------------------
|
|
234
|
+
// ONE CLICK, ONE MARK. The aim ray is the CAMERA ray (player.aimDir — the same vector _fire()
|
|
235
|
+
// shoots down), so the man under the reticle when you click is the man who gets the bullet, and
|
|
236
|
+
// nobody else does. A dry click (no man under the reticle, no chambers left, no meter) says so and
|
|
237
|
+
// paints nothing.
|
|
238
|
+
_mark() {
|
|
239
|
+
const p = this.game.player;
|
|
240
|
+
const cap = Math.min(DEAD_EYE.maxTags, p.ammo ?? 0);
|
|
241
|
+
// A refused mark must SAY it was refused — a click that silently does nothing reads as a broken
|
|
242
|
+
// button, and you will keep clicking it. Out of chambers and out of meter are the two caps.
|
|
243
|
+
if (this.tags.length >= cap) { this.game.audio?.play('dryfire'); return; }
|
|
244
|
+
if ((p.deadEye ?? 0) < DEAD_EYE.tagCost) { this.game.audio?.play('dryfire'); return; }
|
|
245
|
+
if (this._tagT > 0) return; // ...but a double-click inside 0.16s is just a hand
|
|
246
|
+
|
|
247
|
+
_o.copy(this.game.camera.position);
|
|
248
|
+
p.aimDir(_d).normalize();
|
|
249
|
+
|
|
250
|
+
let best = null, bestT = Infinity;
|
|
251
|
+
for (const e of this.game.combat?.enemies ?? []) {
|
|
252
|
+
// noTarget (npc.js) — belt and braces. A child is never an Enemy, so this list cannot hold
|
|
253
|
+
// one today; the guard is here so that the guarantee survives someone making one tomorrow.
|
|
254
|
+
if (!e.alive || e.faction === 'ally' || e.noTarget) continue;
|
|
255
|
+
if (e.position.distanceTo(p.position) > DEAD_EYE.range) continue;
|
|
256
|
+
// body = a vertical segment from shin to head — a tag lands where the ray crossed it
|
|
257
|
+
_a.set(e.position.x, e.position.y + 0.35, e.position.z);
|
|
258
|
+
_b.set(e.position.x, e.position.y + 1.75, e.position.z);
|
|
259
|
+
const t = this._raySeg(_o, _d, _a, _b, _p);
|
|
260
|
+
if (t < 0 || t > bestT) continue;
|
|
261
|
+
if (_p.distanceTo(_hit.copy(_o).addScaledVector(_d, t)) > DEAD_EYE.tagRadius) continue;
|
|
262
|
+
if (this.game.world?.losBlocked?.(p.position, e.position)) continue; // no marking through walls
|
|
263
|
+
best = { e, t, point: _p.clone() };
|
|
264
|
+
bestT = t;
|
|
265
|
+
}
|
|
266
|
+
if (!best) return;
|
|
267
|
+
|
|
268
|
+
// store the point as an offset from the enemy's origin, so the marker (and the bullet)
|
|
269
|
+
// track him as he keeps walking in slow motion
|
|
270
|
+
this.tags.push({
|
|
271
|
+
enemy: best.e,
|
|
272
|
+
off: best.point.clone().sub(best.e.position),
|
|
273
|
+
point: best.point.clone(),
|
|
274
|
+
});
|
|
275
|
+
p.deadEye = clamp(p.deadEye - DEAD_EYE.tagCost, 0, DEAD_EYE.meterMax);
|
|
276
|
+
this._tagT = DEAD_EYE.tagCooldown;
|
|
277
|
+
this.game.audio?.play('ui');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Closest approach of ray (o,dir — dir normalized) to segment [a,b]. Returns the ray
|
|
281
|
+
// parameter t of the closest point (>= 0), or -1 if the closest approach is behind the
|
|
282
|
+
// lens; writes the closest point ON THE SEGMENT into `out`. (Ericson, clamped.)
|
|
283
|
+
_raySeg(o, dir, a, b, out) {
|
|
284
|
+
const vx = b.x - a.x, vy = b.y - a.y, vz = b.z - a.z;
|
|
285
|
+
const wx = o.x - a.x, wy = o.y - a.y, wz = o.z - a.z;
|
|
286
|
+
const bb = dir.x * vx + dir.y * vy + dir.z * vz; // u·v
|
|
287
|
+
const cc = vx * vx + vy * vy + vz * vz; // v·v
|
|
288
|
+
const dd = dir.x * wx + dir.y * wy + dir.z * wz; // u·w
|
|
289
|
+
const ee = vx * wx + vy * wy + vz * wz; // v·w
|
|
290
|
+
const den = cc - bb * bb; // (u·u = 1)
|
|
291
|
+
// segment param = (a·e − b·d)/D with a = u·u = 1. The sign matters: flipped, every hit
|
|
292
|
+
// clamps to the man's SHINS and lands >1m off the ray, so nothing ever tags.
|
|
293
|
+
let s = den > 1e-9 ? (ee - bb * dd) / den : 0;
|
|
294
|
+
s = clamp(s, 0, 1);
|
|
295
|
+
const t = bb * s - dd; // ray param at that s
|
|
296
|
+
out.set(a.x + vx * s, a.y + vy * s, a.z + vz * s);
|
|
297
|
+
return t < 0 ? -1 : t;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Live world points of the current tags (they follow their man). Reused array — this runs
|
|
301
|
+
// every frame while Dead Eye is up.
|
|
302
|
+
_live() {
|
|
303
|
+
const list = this._liveArr ??= [];
|
|
304
|
+
list.length = 0;
|
|
305
|
+
const src = this.active ? this.tags : this.queue;
|
|
306
|
+
for (const t of src) {
|
|
307
|
+
if (t.enemy?.alive) t.point.copy(t.enemy.position).add(t.off); // track the moving man
|
|
308
|
+
list.push(t.point);
|
|
309
|
+
}
|
|
310
|
+
return list;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- the volley ---------------------------------------------------------------------
|
|
314
|
+
// One shot every tagInterval, in paint order, each down player.deadEyeShot → the same
|
|
315
|
+
// combat.fireBullet path as LMB (same damage, blood, impacts, ammo, muzzle-flip, kick).
|
|
316
|
+
_volley(dt) {
|
|
317
|
+
const p = this.game.player;
|
|
318
|
+
this._fireT -= dt;
|
|
319
|
+
while (this._fireT <= 0 && this.queue.length) {
|
|
320
|
+
const tag = this.queue.shift();
|
|
321
|
+
if (tag.enemy?.alive) tag.point.copy(tag.enemy.position).add(tag.off);
|
|
322
|
+
_pt.copy(tag.point);
|
|
323
|
+
if (!p.deadEyeShot(_pt)) { this.queue.length = 0; break; } // dry — drop the rest
|
|
324
|
+
this._fireT += DEAD_EYE.tagInterval;
|
|
325
|
+
}
|
|
326
|
+
// the last shot still gets its beat before time snaps back
|
|
327
|
+
if (!this.queue.length && this._fireT <= 0) {
|
|
328
|
+
this.firing = false;
|
|
329
|
+
this._queueN = 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ENCOUNTERS — the bandit camps, the drover outfits and the Red Hawk riding parties that live in
|
|
2
|
+
// the empty country between the towns. Data in data/encounters.js; this file builds it and owns
|
|
3
|
+
// the one rule that isn't already in the game: an outfit that sees you shoot one of its own.
|
|
4
|
+
//
|
|
5
|
+
// PERF LAW, and it is the whole shape of this file: nothing here is ever spawned, despawned or
|
|
6
|
+
// respawned. Every body — 14 bandits, 14 drovers/riders, 6 outfit guns, 2 loners — is constructed
|
|
7
|
+
// behind the loading screen through the machinery that already exists (combat.spawn for the
|
|
8
|
+
// Enemies, new NPC for the civilians) and stands at its anchor for the life of the run. Waking a
|
|
9
|
+
// camp when you ride up to it is not a thing that needs writing: it already happens, because
|
|
10
|
+
// Enemy.update strides its FSM at a quarter rate beyond the entity cull and NPC.update sleeps
|
|
11
|
+
// outright past AI_RANGE, and the moment you cross that line they simply start thinking again.
|
|
12
|
+
//
|
|
13
|
+
// Two consequences, both deliberate:
|
|
14
|
+
// * `combat.addSpawner` is NOT used. Its respawn tick (combat.js) is the one place in this
|
|
15
|
+
// codebase that builds a skinned mesh during play. A cleared camp stays cleared — the county
|
|
16
|
+
// changes because of you, and the perf hazard is deleted rather than managed.
|
|
17
|
+
// * camp bodies carry `noEvict`, so combat's 6-second corpse eviction never tears their skinned
|
|
18
|
+
// meshes back out of the scene (r184's dispose handler evicts a removed mesh's pipelines with
|
|
19
|
+
// it). The dead lie where they fell in the dirt, which is what a western wants anyway.
|
|
20
|
+
import { NPC } from './npc.js';
|
|
21
|
+
// encounter tables are GAME data: setEncounterTables({ camps, outfits, loners })
|
|
22
|
+
let ET = { camps: [], outfits: [], loners: [] };
|
|
23
|
+
export function setEncounterTables(t) { ET = { ...ET, ...t }; }
|
|
24
|
+
|
|
25
|
+
const T = '/assets/textures';
|
|
26
|
+
const TOWN_TEX = [`${T}/atlas_western.png`, `${T}/atlas_western_b.png`, `${T}/atlas_western_c.png`, `${T}/atlas_western_alt2.png`, `${T}/atlas_western_alt3.png`];
|
|
27
|
+
|
|
28
|
+
export class Encounters {
|
|
29
|
+
constructor(game) {
|
|
30
|
+
this.game = game;
|
|
31
|
+
this.groups = new Map(); // id → { id, name, kind, x, z, members[], guns[] }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async build(clips) {
|
|
35
|
+
const g = this.game;
|
|
36
|
+
g.npcs ??= [];
|
|
37
|
+
|
|
38
|
+
// ── the gangs. Ordinary hostiles: passive:false, so they aggro when you come inside aggroR.
|
|
39
|
+
// A short leash (26 m) means a camp holds its ground rather than following you across the
|
|
40
|
+
// county — enemy.js drops the chase past leashR * 1.6.
|
|
41
|
+
for (const c of ET.camps) {
|
|
42
|
+
const grp = { id: c.id, name: c.name, kind: 'camp', x: c.x, z: c.z, members: [], guns: [] };
|
|
43
|
+
this.groups.set(c.id, grp);
|
|
44
|
+
await Promise.all(c.men.map(async (type, i) => {
|
|
45
|
+
const a = (i / c.men.length) * Math.PI * 2;
|
|
46
|
+
try {
|
|
47
|
+
const e = await g.combat.spawn(type, c.x + Math.sin(a) * 5.5, c.z + Math.cos(a) * 5.5, { leashR: c.leashR });
|
|
48
|
+
e.noEvict = true; // the corpse stays; nothing leaves the scene (see the header)
|
|
49
|
+
e.group = grp;
|
|
50
|
+
grp.members.push(e);
|
|
51
|
+
} catch (err) { console.warn('[encounters] camp body failed', c.id, type, err); }
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── the neutral outfits. The men are real NPCs (civilians: shootable, lootable, witnesses);
|
|
56
|
+
// the one gun in the outfit is a passive Enemy who never starts anything on his own.
|
|
57
|
+
for (const o of [...OUTFITS]) {
|
|
58
|
+
const grp = { id: o.id, name: o.name, kind: 'outfit', x: o.x, z: o.z, members: [], guns: [] };
|
|
59
|
+
this.groups.set(o.id, grp);
|
|
60
|
+
await Promise.all(o.men.map(async (m, i) => {
|
|
61
|
+
const def = {
|
|
62
|
+
id: `${o.id}_${i}`, purse: m.purse, name: m.name, char: m.char,
|
|
63
|
+
roam: { x: o.x, z: o.z, r: o.r }, idles: m.idles,
|
|
64
|
+
...(m.tex != null ? { tex: TOWN_TEX[m.tex % TOWN_TEX.length] } : {}),
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const npc = new NPC(g, def, clips);
|
|
68
|
+
await npc.load();
|
|
69
|
+
npc.settle();
|
|
70
|
+
g.scene.add(npc.root);
|
|
71
|
+
g.npcs.push(npc);
|
|
72
|
+
npc.group = grp;
|
|
73
|
+
grp.members.push(npc);
|
|
74
|
+
} catch (err) { console.warn('[encounters] outfit body failed', o.id, m.name, err); }
|
|
75
|
+
}));
|
|
76
|
+
if (o.gun) {
|
|
77
|
+
try {
|
|
78
|
+
const e = await g.combat.spawn(o.gun, o.x + 3, o.z - 3, { leashR: 22, passive: true });
|
|
79
|
+
e.noEvict = true;
|
|
80
|
+
e.group = grp;
|
|
81
|
+
grp.members.push(e);
|
|
82
|
+
grp.guns.push(e);
|
|
83
|
+
} catch (err) { console.warn('[encounters] outfit gun failed', o.id, err); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── the lone ones
|
|
88
|
+
for (const l of ET.loners) {
|
|
89
|
+
try {
|
|
90
|
+
const npc = new NPC(g, { id: l.id, purse: l.purse, name: l.name, char: l.char, roam: { x: l.x, z: l.z, r: l.r }, idles: l.idles }, clips);
|
|
91
|
+
await npc.load();
|
|
92
|
+
npc.settle();
|
|
93
|
+
g.scene.add(npc.root);
|
|
94
|
+
g.npcs.push(npc);
|
|
95
|
+
} catch (err) { console.warn('[encounters] loner failed', l.id, err); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const bandits = [...this.groups.values()].filter((x) => x.kind === 'camp').reduce((a, x) => a + x.members.length, 0);
|
|
99
|
+
const outfit = [...this.groups.values()].filter((x) => x.kind === 'outfit').reduce((a, x) => a + x.members.length, 0);
|
|
100
|
+
console.log(`[encounters] ${this.groups.size} groups out in the county: ${bandits} bandits (hostile), ${outfit} neutrals, ${ET.loners.length} loners`);
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// THEY TAKE IT BADLY. You shot one of them; his outfit draws. Called from NPC.takeDamage (a
|
|
105
|
+
// drover) and Enemy.takeDamage (the outfit's gun) — both only when the PLAYER did it, so an
|
|
106
|
+
// outlaw's stray round can never start a war with a cattle drive.
|
|
107
|
+
//
|
|
108
|
+
// The men of the outfit who aren't the gun are NPCs and have no fight in them: they run, exactly
|
|
109
|
+
// as a townsman does, and they are the witnesses that make the murder cost you stars. The gun
|
|
110
|
+
// stops being passive and hunts you. He is the ONLY thing that changes.
|
|
111
|
+
provokeGroup(who) {
|
|
112
|
+
const grp = who?.group;
|
|
113
|
+
if (!grp || grp.kind !== 'outfit' || grp.angry) return;
|
|
114
|
+
grp.angry = true;
|
|
115
|
+
for (const gun of grp.guns) {
|
|
116
|
+
if (!gun.alive || gun === who) continue;
|
|
117
|
+
gun.provoke();
|
|
118
|
+
gun.leashR = 40; // he will follow you a way for this — but not across the county
|
|
119
|
+
}
|
|
120
|
+
this.game.ui?.toast?.(`${grp.name} — they saw that.`, 3000);
|
|
121
|
+
this.game.audio?.play('aggro');
|
|
122
|
+
}
|
|
123
|
+
}
|