sindicate 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +234 -0
- package/src/audio/synth.js +228 -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/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// Combat orchestration: outlaw spawning, fist melee resolution, and bullets.
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
import { segmentCapsule, BODY } from './aim.js';
|
|
4
|
+
import { Enemy, foes } from './enemy.js';
|
|
5
|
+
import { alarmTown } from './npc.js';
|
|
6
|
+
import { dist2D, tmpV1 } from '../core/utils.js';
|
|
7
|
+
|
|
8
|
+
const _prevPos = new THREE.Vector3(); // projectile pre-move position for the wall sweep
|
|
9
|
+
|
|
10
|
+
// Bare-fist boxing combo — real MoCap Online "PUNCH Starter" clips (retargeted to the Synty rig).
|
|
11
|
+
// Light combo cycles jab → cross → hook; heavy is an uppercut. Idle guard is played from player.js.
|
|
12
|
+
const UNARMED_LIGHT = ['punchJab', 'punchCross', 'punchHook'];
|
|
13
|
+
const UNARMED_HEAVY = ['punchUpper'];
|
|
14
|
+
|
|
15
|
+
// FINISHERS: a staggered foe this weakened can be executed with F (fists only in the west —
|
|
16
|
+
// gun kills don't need the ceremony). More lenient when it's the last foe standing nearby.
|
|
17
|
+
const FINISH_HP = 0.25; // health fraction gate
|
|
18
|
+
// A headshot is a hit landing above the collar: metres above the FEET (an entity's position is
|
|
19
|
+
// at ground level). These bodies stand ~1.8m with the shoulders at ~1.4, so 1.5 is the neck.
|
|
20
|
+
const HEAD_Y = 1.5;
|
|
21
|
+
// The body, as the bullets see it: a vertical capsule from the ankles to the crown. Radius is a
|
|
22
|
+
// man's shoulders halved, and generous by a hair — a western is a game about hitting people.
|
|
23
|
+
const BOUNTY = 14; // the last outlaw's purse, multiplied: clearing the gang is worth doing
|
|
24
|
+
const BODY_LO = 0.25;
|
|
25
|
+
const BODY_HI = 1.78;
|
|
26
|
+
const BODY_R = 0.42;
|
|
27
|
+
const _seg = new THREE.Vector3(); // fallback segment start, when a caller has no previous position
|
|
28
|
+
const FINISH_HP_LAST = 0.35; // gate when no other hostile is within 10m
|
|
29
|
+
const FINISH_RANGE = 3.0; // metres, and roughly in front:
|
|
30
|
+
const FINISH_ARC = 1.2; // radians off player heading
|
|
31
|
+
// A shot fired ANYWHERE is heard: the street empties. Nobody in a western stands in the open
|
|
32
|
+
// looking mildly interested while a gunfight happens — panic, not obliviousness.
|
|
33
|
+
const GUNSHOT_R = 22;
|
|
34
|
+
|
|
35
|
+
export class Combat {
|
|
36
|
+
constructor(game) {
|
|
37
|
+
this.game = game;
|
|
38
|
+
this.enemies = [];
|
|
39
|
+
this.projectiles = [];
|
|
40
|
+
this.spawners = [];
|
|
41
|
+
this.clips = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------- spawning ----------
|
|
45
|
+
|
|
46
|
+
addSpawner({ type, x, z, count, respawn = 90, leashR = 26, passive = false, tag = '' }) {
|
|
47
|
+
this.spawners.push({ type, x, z, count, respawn, leashR, passive, tag, alive: [], timer: 0 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async spawn(type, x, z, opts = {}) {
|
|
51
|
+
const e = new Enemy(this.game, type, this.clips, { x, z, ...opts });
|
|
52
|
+
await e.load();
|
|
53
|
+
this.game.scene.add(e.root);
|
|
54
|
+
this.enemies.push(e);
|
|
55
|
+
// VAT-back ordinary humanoid enemies (vatRoster.js) IF a roster exists — v1 ships
|
|
56
|
+
// without VAT, so this nil-guards to "always real". Bosses stay always-real regardless.
|
|
57
|
+
const def = e.def ?? {};
|
|
58
|
+
if (!def.boss && this.game.vatRoster) {
|
|
59
|
+
const bake = {};
|
|
60
|
+
for (const n of ['idle', 'walk', 'run']) if (e.clipSet?.[n]) bake[n] = e.clipSet[n];
|
|
61
|
+
if (bake.idle) e.vatProxy = this.game.vatRoster.register(e, { bakeClips: bake, promoteR: 25, demoteR: 30 }) ?? undefined;
|
|
62
|
+
}
|
|
63
|
+
return e;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async populate(clips) {
|
|
67
|
+
this.clips = clips;
|
|
68
|
+
// ONE tracer master, cloned per shot: a thin emissive streak, no texture, no lights —
|
|
69
|
+
// the same preload-at-boot discipline the arrow master had (no mid-fight geometry builds).
|
|
70
|
+
const geo = new THREE.BoxGeometry(0.02, 0.02, 0.7); // long axis = +Z (flight direction)
|
|
71
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0xffe8b0, fog: false });
|
|
72
|
+
this.tracerMaster = new THREE.Mesh(geo, mat);
|
|
73
|
+
|
|
74
|
+
// Outlaw spawners are registered by main.js (the "clear the outlaws" objective owns
|
|
75
|
+
// placement); anything queued before populate spawns now, on the loading screen —
|
|
76
|
+
// deferring spawns into live gameplay steals frames (skinned-mesh builds mid-play).
|
|
77
|
+
for (const s of this.spawners) await this._spawnFrom(s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async _spawnFrom(s) {
|
|
81
|
+
for (let i = 0; i < s.count; i++) {
|
|
82
|
+
const a = (i / s.count) * Math.PI * 2;
|
|
83
|
+
try {
|
|
84
|
+
const e = await this.spawn(s.type, s.x + Math.sin(a) * 6, s.z + Math.cos(a) * 6, { leashR: s.leashR, passive: s.passive });
|
|
85
|
+
e.spawner = s; s.alive.push(e);
|
|
86
|
+
} catch (err) { console.warn('[spawn] failed', s.type, err); }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
nearestEnemy(pos, maxDist = 18, exclude = null) {
|
|
91
|
+
let best = null, bestD = maxDist;
|
|
92
|
+
for (const e of this.enemies) {
|
|
93
|
+
if (!e.alive || e === exclude || e.faction === 'ally') continue;
|
|
94
|
+
const d = dist2D(pos.x, pos.z, e.position.x, e.position.z);
|
|
95
|
+
if (d < bestD) { best = e; bestD = d; }
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// objective API (WESTERN_CONTRACT): main.js polls this / listens for onOutlawsCleared
|
|
101
|
+
outlawsAlive() {
|
|
102
|
+
let n = 0;
|
|
103
|
+
for (const e of this.enemies) if (e.alive && e.type?.startsWith('outlaw')) n++;
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- player attacks ----------
|
|
108
|
+
|
|
109
|
+
// FINISHER: the cinematic execute on a staggered, weakened foe. The player is locked
|
|
110
|
+
// (busy) and invulnerable (dodgeT i-frames) for the move; the victim is held in its
|
|
111
|
+
// stagger with any pending swing cancelled; the killing blow lands on the uppercut's
|
|
112
|
+
// impact beat and routes through takeDamage so the death-anim fires.
|
|
113
|
+
executeFinisher() {
|
|
114
|
+
const e = this.finishTarget, p = this.game.player;
|
|
115
|
+
if (!e || !e.alive || !p.alive || p.busy) return false;
|
|
116
|
+
this.finishTarget = null;
|
|
117
|
+
// square the two up
|
|
118
|
+
p.heading = Math.atan2(e.position.x - p.position.x, e.position.z - p.position.z);
|
|
119
|
+
p.root.rotation.y = p.heading;
|
|
120
|
+
e.faceToward?.(p.position.x, p.position.z);
|
|
121
|
+
// hold the victim through the blow: stagger persists, pending swing dies
|
|
122
|
+
e.staggerT = Math.max(e.staggerT, 1.2);
|
|
123
|
+
e._swing = null; // cancel a pending swing (game-time, enemy.js)
|
|
124
|
+
e._hitBusyT = 0; // the idle-alias failsafe must not clear the deliberate victim hold
|
|
125
|
+
// hold the pose too: busy stops the FSM's idle branches overwriting the reel, and the
|
|
126
|
+
// clamped hit clip keeps the victim reeling until the death anim replaces it
|
|
127
|
+
e.busy = true;
|
|
128
|
+
e.animator?.play('hit', { once: true, fade: 0.08 });
|
|
129
|
+
p.busy = true;
|
|
130
|
+
p.dodgeT = 1.6; // i-frames — nothing interrupts an execution
|
|
131
|
+
p.comboStep = 0; p.comboWindow = 0;
|
|
132
|
+
p.attackCooldown = 0.3;
|
|
133
|
+
this.game.audio?.play('swingHeavy');
|
|
134
|
+
p.animator.play('punchUpper', {
|
|
135
|
+
once: true, fade: 0.08, timeScale: 0.9, // slower = weightier than the combo upper
|
|
136
|
+
onDone: () => { p.busy = false; p.dodgeT = Math.min(p.dodgeT, 0.1); },
|
|
137
|
+
});
|
|
138
|
+
this._finishHit = { t: 0.45, e }; // impact beat ticks in update() on GAME time
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// the killing blow — runs from update() so pause/dialogue can never race it
|
|
143
|
+
_finishImpact(e) {
|
|
144
|
+
const p = this.game.player;
|
|
145
|
+
if (!p.alive || !e.alive || !this.enemies.includes(e)) return; // despawn evicted it
|
|
146
|
+
const shown = Math.ceil(e.health);
|
|
147
|
+
e.takeDamage(e.health + 1, p.position, { softHit: true }); // no shove — die where you stand
|
|
148
|
+
this.game.vfx.hit(e.position.clone().setY(e.position.y + 1.1));
|
|
149
|
+
const bdir = p.position.clone().sub(e.position).setY(0).normalize().multiplyScalar(0.5); bdir.y = 1.1;
|
|
150
|
+
this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 2);
|
|
151
|
+
this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, 0.22); // the big crunch (kills are 0.13)
|
|
152
|
+
this.game.shake(0.75);
|
|
153
|
+
this.game.audio?.play('hit');
|
|
154
|
+
this.game.ui?.showDamage(e, shown);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ONE fist-vs-body test. `e` is anything with the SHOOTABLE-PEOPLE contract — an outlaw, or a
|
|
158
|
+
// townsman who was standing in the wrong place. Returns true if the punch landed.
|
|
159
|
+
// (opts.attacker is what tells a civilian whose crime this was: npc.js reads it.)
|
|
160
|
+
_meleeHit(e, dmg, heavy, range, arc) {
|
|
161
|
+
const p = this.game.player;
|
|
162
|
+
const d = dist2D(p.position.x, p.position.z, e.position.x, e.position.z);
|
|
163
|
+
if (d > range) return false;
|
|
164
|
+
const ang = Math.atan2(e.position.x - p.position.x, e.position.z - p.position.z);
|
|
165
|
+
let da = Math.abs(ang - p.heading) % (Math.PI * 2);
|
|
166
|
+
if (da > Math.PI) da = Math.PI * 2 - da;
|
|
167
|
+
if (da > arc) return false;
|
|
168
|
+
if (this.game.world.losBlocked(p.position, e.position)) return false; // no hitting through walls
|
|
169
|
+
e.provoke?.(); // an outlaw turns on you; a townsman has no fight in him
|
|
170
|
+
// light hits barely displace (perpetual bounce-out was half of "they never hit me");
|
|
171
|
+
// heavies still bowl them
|
|
172
|
+
e.takeDamage(dmg, p.position, { knockback: heavy ? 0.4 : 0.05, power: heavy ? 1 : 0, attacker: p });
|
|
173
|
+
this.game.vfx.hit(e.position.clone().setY(e.position.y + 1.1));
|
|
174
|
+
// blood squirts UP and back toward the attacker (the camera) so it's visible, not
|
|
175
|
+
// hidden behind the foe
|
|
176
|
+
const bdir = p.position.clone().sub(e.position).setY(0).normalize().multiplyScalar(0.5); bdir.y = 1.0;
|
|
177
|
+
this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 1);
|
|
178
|
+
// hit-stop: a breath on connect, a longer one on the killing blow
|
|
179
|
+
this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, !e.alive ? 0.13 : heavy ? 0.09 : 0.045);
|
|
180
|
+
this.game.ui?.showDamage(e, Math.round(dmg));
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
playerMelee(heavy) {
|
|
185
|
+
const p = this.game.player;
|
|
186
|
+
// Combat magnetism: face the nearest enemy in reach so a stationary combo
|
|
187
|
+
// keeps landing. Without this, the first swing connects but knockback nudges
|
|
188
|
+
// the target off the fixed swing arc and the follow-ups whiff.
|
|
189
|
+
// ENEMIES ONLY — magnetism must never snap a punch onto a bystander mid-gunfight. If you want
|
|
190
|
+
// to hit a townsman you have to actually be facing him; the arc test below does the rest.
|
|
191
|
+
let aim = p.lockTarget?.alive ? p.lockTarget : null;
|
|
192
|
+
if (!aim) {
|
|
193
|
+
let bestD = 3.2;
|
|
194
|
+
for (const e of this.enemies) {
|
|
195
|
+
if (!e.alive || e.faction === 'ally') continue;
|
|
196
|
+
const d = dist2D(p.position.x, p.position.z, e.position.x, e.position.z);
|
|
197
|
+
if (d < bestD) { bestD = d; aim = e; }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (aim) {
|
|
201
|
+
p.heading = Math.atan2(aim.position.x - p.position.x, aim.position.z - p.position.z);
|
|
202
|
+
p.root.rotation.y = p.heading;
|
|
203
|
+
}
|
|
204
|
+
const combo = heavy ? UNARMED_HEAVY : UNARMED_LIGHT;
|
|
205
|
+
const step = Math.min(p.comboStep, combo.length - 1);
|
|
206
|
+
const anim = combo[step];
|
|
207
|
+
|
|
208
|
+
p.busy = true;
|
|
209
|
+
p.comboStep = (step + 1) % combo.length;
|
|
210
|
+
p.comboWindow = 1.1;
|
|
211
|
+
p.attackCooldown = heavy ? 0.25 : 0.12;
|
|
212
|
+
this.game.audio?.play(heavy ? 'swingHeavy' : 'swing');
|
|
213
|
+
|
|
214
|
+
p.animator.play(anim, {
|
|
215
|
+
once: true, fade: 0.07, timeScale: heavy ? 1.2 : 1.5,
|
|
216
|
+
onDone: () => { p.busy = false; }, // start-trim comes from CLIP_TIMING
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// damage window mid-swing — combo steps B/C start mid-clip, so their window comes
|
|
220
|
+
// sooner. GAME time: the wall-clock version silently ATE the hit if a menu opened
|
|
221
|
+
// mid-swing, and desynced from the animation under hit-stop (audit m45)
|
|
222
|
+
const delay = heavy ? 450 : (step === 0 ? 280 : 200);
|
|
223
|
+
this.game.after(delay / 1000, () => {
|
|
224
|
+
if (!p.alive) return;
|
|
225
|
+
const dmg = (p.fistDamage ?? 8) * (heavy ? 1.6 : 1);
|
|
226
|
+
const range = 1.7, arc = 1.5;
|
|
227
|
+
let hitAny = false;
|
|
228
|
+
for (const e of this.enemies) {
|
|
229
|
+
if (!e.alive || e.faction === 'ally') continue;
|
|
230
|
+
if (this._meleeHit(e, dmg, heavy, range, arc)) hitAny = true;
|
|
231
|
+
}
|
|
232
|
+
// ...AND THE TOWNSFOLK. A fist that passes through a barman is not a fist. They take the
|
|
233
|
+
// punch on the same terms as an outlaw (npc.js: he cries out and he runs), and the arc keeps
|
|
234
|
+
// its full sweep — a heavy swing that catches two men catches two men.
|
|
235
|
+
// The coach crew are NOT in here: a man on the box is 1.6m up and a fare is behind a wooden
|
|
236
|
+
// wall, and the coach is a COLLIDER, not world BVH — so losBlocked can't stop the punch and
|
|
237
|
+
// you'd be knocking fares out through the side of a moving stagecoach. Shoot them.
|
|
238
|
+
// ...EXCEPT THE CHILDREN. `noTarget` is a property of the entity (npc.js), honoured at every
|
|
239
|
+
// place a body is gathered. The fist passes through.
|
|
240
|
+
for (const n of this.game.npcs ?? []) {
|
|
241
|
+
if (!n.alive || n.noTarget) continue;
|
|
242
|
+
if (this._meleeHit(n, dmg, heavy, range, arc)) hitAny = true;
|
|
243
|
+
}
|
|
244
|
+
// smashables share the swing arc — crates/barrels/bottles break on any hit
|
|
245
|
+
if (this.game.smashables?.tryMelee(p.position, p.heading, range, arc)) hitAny = true;
|
|
246
|
+
if (hitAny) {
|
|
247
|
+
this.game.audio?.play('hit');
|
|
248
|
+
this.game.shake(0.3);
|
|
249
|
+
} else {
|
|
250
|
+
this.game.audio?.play('whoosh');
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------- bullets ----------
|
|
256
|
+
|
|
257
|
+
// The pinned gunfire API (WESTERN_CONTRACT): from/dir are Vector3 (dir normalized,
|
|
258
|
+
// INCLUDES pitch — the player fires down the camera ray). Straight flight, no gravity;
|
|
259
|
+
// lifetime = range/speed; per-frame world BVH sweep (tunnel-proof at 90m/s); flesh hits
|
|
260
|
+
// bleed + provoke, world hits kick dirt. `shooter` is skipped by its own bullet.
|
|
261
|
+
fireBullet({ from, dir, damage, speed = 90, range = 80, friendly = true, shooter = null, alarm = true }) {
|
|
262
|
+
const obj = this.tracerMaster
|
|
263
|
+
? this.tracerMaster.clone()
|
|
264
|
+
: new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.02, 0.7), new THREE.MeshBasicMaterial({ color: 0xffe8b0, fog: false }));
|
|
265
|
+
obj.position.copy(from);
|
|
266
|
+
obj.lookAt(from.clone().add(dir));
|
|
267
|
+
this.game.scene.add(obj);
|
|
268
|
+
this.projectiles.push({
|
|
269
|
+
obj, dir: dir.clone().normalize(), speed, damage, friendly, shooter,
|
|
270
|
+
kind: 'bullet', age: 0, ttl: range / Math.max(speed, 1),
|
|
271
|
+
});
|
|
272
|
+
// THE TOWN HEARS IT. Whoever fired — you, an outlaw — a gunshot scatters the street (npc.js).
|
|
273
|
+
// An EVENT, not a per-frame cost: ~13 townsfolk once per round fired.
|
|
274
|
+
//
|
|
275
|
+
// ...BUT NOT THE WARM-UP SHOT. main.js's warmUpFX fires one round from (0, -800, 0) to compile
|
|
276
|
+
// the projectile pipeline behind the loading screen — and alarmTown measures in 2D (dist2D, y
|
|
277
|
+
// ignored), so a bullet EIGHT HUNDRED METRES BELOW THE MAP still reads as x=0, z=0: the middle
|
|
278
|
+
// of Dustwater's crossroads. Seven townsfolk heard a gunshot during the loading screen and were
|
|
279
|
+
// still running when the game handed you the reins. `alarm: false` is the honest fix — moving
|
|
280
|
+
// the warm-up shot sideways would only hide it until someone moved it back.
|
|
281
|
+
if (alarm) alarmTown(this.game, from.x, from.z, GUNSHOT_R, 3);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// legacy entry point — the gun state machine (player.js) calls fireBullet with the
|
|
285
|
+
// camera ray; this flat-fire wrapper survives for anything still wired to it.
|
|
286
|
+
playerShoot() {
|
|
287
|
+
const p = this.game.player;
|
|
288
|
+
const dir = new THREE.Vector3(Math.sin(p.heading), 0, Math.cos(p.heading));
|
|
289
|
+
this.fireBullet({
|
|
290
|
+
from: p.position.clone().add(new THREE.Vector3(0, 1.45, 0)).addScaledVector(dir, 0.3),
|
|
291
|
+
dir, damage: 16,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// bullet met the world: dirt/wood/stone all share the dust kick v1; the whine is rationed
|
|
296
|
+
_worldImpact(pr) {
|
|
297
|
+
this.game.vfx?.playFx?.('impact_dirt', { pos: pr.obj.position, scale: 1 });
|
|
298
|
+
if (Math.random() < 0.3) this.game.audio?.play('ricochet');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ONE BULLET-VS-BODY TEST, for every kind of body there is: the outlaws (combat.enemies), the
|
|
302
|
+
// townsfolk (game.npcs) and the men on the coach (coach.crew). They all keep the same contract —
|
|
303
|
+
// a .position AT THE FEET, .alive, .maxHealth, .takeDamage — so there is no reason for this to
|
|
304
|
+
// exist three times, and every reason for it not to: the headshot branch lived inside the enemy
|
|
305
|
+
// loop, so the first pass at shootable civilians gave the town bodies you could not shoot in the
|
|
306
|
+
// head. One helper, three lists. Returns true if the round landed and the bullet dies here.
|
|
307
|
+
//
|
|
308
|
+
// opts.attacker is who fired: a civilian reads it to know whose crime this was (npc.js), because
|
|
309
|
+
// an outlaw's stray round through a barman is not the player's murder.
|
|
310
|
+
_bulletHit(pr, e, from = null) {
|
|
311
|
+
// WHERE THE ROUND CROSSES HIM — one shared body model and one intersection test (aim.js), the
|
|
312
|
+
// same one the reticle and the aim use. When these were separate copies they disagreed, and the
|
|
313
|
+
// disagreement was invisible: green light, bang, nothing falls.
|
|
314
|
+
const p1 = from ?? _seg.copy(pr.obj.position).addScaledVector(pr.dir, -pr.speed * (1 / 60));
|
|
315
|
+
const cross = segmentCapsule(p1, pr.obj.position, e.position);
|
|
316
|
+
if (!cross) return false;
|
|
317
|
+
const impactY = e.position.y + cross.hitY;
|
|
318
|
+
const by = pr.shooter ?? this.game.player;
|
|
319
|
+
e.provoke?.(); // an outlaw turns on you; a townsman has no provoke
|
|
320
|
+
// WHERE you hit him matters. The body was ONE sphere at chest height, so a round through the
|
|
321
|
+
// hat and a round through the belly did exactly the same thing. A bullet in the head ends a
|
|
322
|
+
// man: no damage arithmetic, no health bar, no second shot.
|
|
323
|
+
// HEAD_Y is measured from the FEET (e.position is at ground) — these bodies stand ~1.8m,
|
|
324
|
+
// shoulders ~1.4, so anything landing above 1.5 went in above the collar. A SEATED man is
|
|
325
|
+
// folded up and has no feet on any ground, so the coach publishes the datum his feet WOULD be
|
|
326
|
+
// on if he stood up (seatedRider.js) — which is the only reason this same line works on him.
|
|
327
|
+
const hitY = cross.hitY;
|
|
328
|
+
if (hitY >= BODY.head) {
|
|
329
|
+
const at = e.position.clone().setY(e.position.y + 1.62);
|
|
330
|
+
e.takeDamage(e.maxHealth * 10, by.position, { knockback: 0.35, attacker: by });
|
|
331
|
+
this.game.ui?.showDamage(e, 'HEADSHOT');
|
|
332
|
+
this.game.vfx.hit(at);
|
|
333
|
+
this.game.vfx.blood(at, pr.dir.clone().setY(pr.dir.y + 0.5), 2.6); // it makes a mess
|
|
334
|
+
this.game.audio?.play('hit');
|
|
335
|
+
this.game.shakeT = Math.max(this.game.shakeT ?? 0, 0.12);
|
|
336
|
+
} else {
|
|
337
|
+
e.takeDamage(pr.damage, by.position, { knockback: 0.15, attacker: by });
|
|
338
|
+
this.game.ui?.showDamage(e, Math.round(pr.damage));
|
|
339
|
+
this.game.vfx.hit(e.position.clone().setY(e.position.y + 1.1));
|
|
340
|
+
this._fleshImpact(pr, e);
|
|
341
|
+
this.game.audio?.play('hit');
|
|
342
|
+
}
|
|
343
|
+
// THE RETICLE GOES RED — but only for the man holding the gun. An outlaw's round finding a
|
|
344
|
+
// townsman is not YOUR hit and must not be reported as one; at 40m in bright desert the blood
|
|
345
|
+
// spray is four pixels and the mark is the only honest answer to "did I connect?".
|
|
346
|
+
if (by === this.game.player) {
|
|
347
|
+
this.game.ui?.hitMark(!e.alive);
|
|
348
|
+
// WHAT THE COUNTY KNOWS ABOUT YOU. Every man you put down is one the next one has heard about,
|
|
349
|
+
// and the men they send after that are better shots (enemy.js, AIM). It is the only progression
|
|
350
|
+
// this game keeps, and it is kept HERE because this is the one place a death by your hand is
|
|
351
|
+
// certain — the loot, the crime and the objective systems all watch different halves of it.
|
|
352
|
+
if (!e.alive) this.playerKills = (this.playerKills ?? 0) + 1;
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// bullet met flesh — blood jet along the bullet's line, pool sometimes
|
|
358
|
+
_fleshImpact(pr, victim) {
|
|
359
|
+
const at = victim.position.clone().setY(victim.position.y + 1.1);
|
|
360
|
+
this.game.vfx.blood(at, pr.dir.clone().setY(pr.dir.y + 0.4), 1.5);
|
|
361
|
+
if (Math.random() < 0.4) {
|
|
362
|
+
const gy = this.game.world.groundAt(victim.position.x, victim.position.z);
|
|
363
|
+
this.game.vfx.bloodDecal({ x: victim.position.x, y: gy, z: victim.position.z });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------- frame update ----------
|
|
368
|
+
|
|
369
|
+
update(dt) {
|
|
370
|
+
const p = this.game.player;
|
|
371
|
+
|
|
372
|
+
// ATTACK DIRECTOR (AC ring): of the melee enemies engaging the player, only the
|
|
373
|
+
// closest TWO hold an attack token — they close in and swing; the rest prowl the
|
|
374
|
+
// ring (enemy.js _circle) until a token frees up. Turns "mob pile-on" into the
|
|
375
|
+
// circling pressure fight. Re-dealt 4x/sec — cheap, and tokens naturally rotate as
|
|
376
|
+
// staggers/kills reshuffle distance order.
|
|
377
|
+
this._tokenT = (this._tokenT ?? 0) - dt;
|
|
378
|
+
if (this._tokenT <= 0) {
|
|
379
|
+
this._tokenT = 0.25;
|
|
380
|
+
const engaged = [];
|
|
381
|
+
for (const e of this.enemies) {
|
|
382
|
+
if (!e.alive || e.def.ranged) continue;
|
|
383
|
+
if (e.state !== 'chase' && e.state !== 'attack') continue;
|
|
384
|
+
const d = Math.hypot(e.position.x - p.position.x, e.position.z - p.position.z);
|
|
385
|
+
if (d < 9) engaged.push([d, e]);
|
|
386
|
+
else e._token = undefined; // out of the fight — no ring behaviour
|
|
387
|
+
}
|
|
388
|
+
engaged.sort((a, b) => a[0] - b[0]);
|
|
389
|
+
engaged.forEach(([, e], i) => { e._token = i < 2; });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// FINISHER TARGET: the nearest staggered, weakened, un-bossy foe close in front of
|
|
393
|
+
// the player, fists only. main.js shows 'F — Finish'; player.js consumes F. Lock-on
|
|
394
|
+
// wins ties; a short grace latch stops the prompt strobing when staggerT expires
|
|
395
|
+
// between the glance and the press.
|
|
396
|
+
this.finishTarget = null;
|
|
397
|
+
const pFree = p.alive && !p.busy && p.weapon === 'fists' && !p.riding?.mounted;
|
|
398
|
+
if (pFree) {
|
|
399
|
+
let hostiles = 0;
|
|
400
|
+
for (const e of this.enemies) {
|
|
401
|
+
if (e.alive && foes(e.faction, 'player')
|
|
402
|
+
&& Math.hypot(e.position.x - p.position.x, e.position.z - p.position.z) < 10) hostiles++;
|
|
403
|
+
}
|
|
404
|
+
const hpGate = hostiles <= 1 ? FINISH_HP_LAST : FINISH_HP;
|
|
405
|
+
const geom = (e, slack = 0) => { // close + in front + visible
|
|
406
|
+
const d = dist2D(p.position.x, p.position.z, e.position.x, e.position.z);
|
|
407
|
+
if (d > FINISH_RANGE + slack) return Infinity;
|
|
408
|
+
const ang = Math.atan2(e.position.x - p.position.x, e.position.z - p.position.z);
|
|
409
|
+
let da = Math.abs(ang - p.heading) % (Math.PI * 2);
|
|
410
|
+
if (da > Math.PI) da = Math.PI * 2 - da;
|
|
411
|
+
if (da > FINISH_ARC + slack * 0.15) return Infinity;
|
|
412
|
+
if (this.game.world.losBlocked(p.position, e.position)) return Infinity;
|
|
413
|
+
return d;
|
|
414
|
+
};
|
|
415
|
+
const eligible = (e) => e.alive && foes(e.faction, 'player') && !e.def.boss && !e.def.phase2
|
|
416
|
+
&& e.staggerT > 0 && e.health / e.maxHealth < hpGate;
|
|
417
|
+
let bestD = Infinity;
|
|
418
|
+
for (const e of this.enemies) {
|
|
419
|
+
if (!eligible(e)) continue;
|
|
420
|
+
const d = geom(e);
|
|
421
|
+
if (d < bestD) { bestD = d; this.finishTarget = e; }
|
|
422
|
+
}
|
|
423
|
+
// the locked-on foe outranks nearest-eligible (matches every other attack here)
|
|
424
|
+
const lk = p.lockTarget;
|
|
425
|
+
if (lk && lk !== this.finishTarget && eligible(lk) && geom(lk) < Infinity) this.finishTarget = lk;
|
|
426
|
+
if (this.finishTarget) {
|
|
427
|
+
this._finishGrace = { e: this.finishTarget, t: 0.4 };
|
|
428
|
+
this.game.hint?.('finisher', 'F — finish a staggered, weakened foe.');
|
|
429
|
+
} else if (this._finishGrace) {
|
|
430
|
+
// stagger just lapsed but the foe's still dying on its feet — honour the prompt briefly
|
|
431
|
+
const g = this._finishGrace; g.t -= dt;
|
|
432
|
+
const e = g.e;
|
|
433
|
+
if (g.t > 0 && e.alive && foes(e.faction, 'player') && !e.def.boss && !e.def.phase2
|
|
434
|
+
&& e.health / e.maxHealth < hpGate && geom(e, 0.5) < Infinity) this.finishTarget = e;
|
|
435
|
+
else this._finishGrace = null;
|
|
436
|
+
}
|
|
437
|
+
} else { this._finishGrace = null; }
|
|
438
|
+
|
|
439
|
+
// finisher impact beat: game-time, not wall-clock — it pauses with the menu and can
|
|
440
|
+
// never land after the victim was evicted (setTimeout could do both)
|
|
441
|
+
if (this._finishHit) {
|
|
442
|
+
this._finishHit.t -= dt;
|
|
443
|
+
if (this._finishHit.t <= 0) { const fh = this._finishHit; this._finishHit = null; this._finishImpact(fh.e); }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// enemies
|
|
447
|
+
for (let i = this.enemies.length - 1; i >= 0; i--) {
|
|
448
|
+
const e = this.enemies[i];
|
|
449
|
+
e.update(dt);
|
|
450
|
+
e.applyDescentSmoothing(dt);
|
|
451
|
+
// HE HAD MONEY ON HIM. Dropped ONCE, the moment he goes down — not when the body is evicted
|
|
452
|
+
// (that is 6 seconds later and, for a pooled lawman, never happens at all), and not every
|
|
453
|
+
// frame he is lying there. The civilians and the coach crew were paying out from the day the
|
|
454
|
+
// system landed; the outlaws — the men the game actually asks you to shoot — never did,
|
|
455
|
+
// because nobody wired the one list that matters.
|
|
456
|
+
if (!e.alive && !e._looted) {
|
|
457
|
+
e._looted = true;
|
|
458
|
+
// THE LAST MAN CARRIES THE TAKINGS. Kill the fourth of the four and he is holding what the
|
|
459
|
+
// gang stole — so the job pays, and it pays AT the moment the job is finished rather than
|
|
460
|
+
// as a number that appears in a corner of the screen. You have to walk over and pick it up,
|
|
461
|
+
// standing where you shot him, which is the difference between being paid and being
|
|
462
|
+
// congratulated. (Guarded on `objective` so it can't fire for the ordinary hostiles.)
|
|
463
|
+
const last = e.objective && !this.enemies.some((o) => o !== e && o.alive && o.objective);
|
|
464
|
+
this.game.loot?.dropFor?.(e.type ?? 'outlaw', e.position, last ? BOUNTY : 1);
|
|
465
|
+
if (last) this.game.ui?.toast?.('The gang is finished. He was carrying their takings.');
|
|
466
|
+
}
|
|
467
|
+
// EVICTION, AND WHO IS EXEMPT FROM IT. Pulling a skinned mesh out of the scene evicts its
|
|
468
|
+
// render objects AND their pipelines from r184's cache, and the four objective outlaws have
|
|
469
|
+
// always paid that (once, in town, where you are standing over them). A wilderness camp
|
|
470
|
+
// must not: `noEvict` bodies lie in the dirt where they fell, resident for life, costing one
|
|
471
|
+
// distanceTo a frame past the cull. It is also better fiction — a cleared camp looks cleared.
|
|
472
|
+
if (e.dead && !e.noEvict) {
|
|
473
|
+
this.game.vatRoster?.release(e.vatProxy);
|
|
474
|
+
this.game.scene.remove(e.root);
|
|
475
|
+
e.spawner?.alive.splice(e.spawner.alive.indexOf(e), 1);
|
|
476
|
+
this.enemies.splice(i, 1);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// respawns. spawn() is async and only pushes to s.alive in .then(), so s.alive.length
|
|
481
|
+
// lags behind in-flight spawns — without counting pending ones, several respawns fire
|
|
482
|
+
// before any resolves and the spawner overshoots s.count (a slow enemy leak).
|
|
483
|
+
for (const s of this.spawners) {
|
|
484
|
+
if (!s.respawn) continue;
|
|
485
|
+
if (s.alive.length + (s.pending ?? 0) < s.count) {
|
|
486
|
+
s.timer += dt;
|
|
487
|
+
if (s.timer > s.respawn && dist2D(p.position.x, p.position.z, s.x, s.z) > 50) {
|
|
488
|
+
s.timer = 0;
|
|
489
|
+
s.pending = (s.pending ?? 0) + 1;
|
|
490
|
+
const a = Math.random() * Math.PI * 2;
|
|
491
|
+
this.spawn(s.type, s.x + Math.sin(a) * 5, s.z + Math.cos(a) * 5, { leashR: s.leashR }).then((e) => {
|
|
492
|
+
e.spawner = s;
|
|
493
|
+
s.alive.push(e);
|
|
494
|
+
s.pending--;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The coach crew are children of a MOVING coach, and main.js ticks combat BEFORE the coaches —
|
|
501
|
+
// so without this every round is tested against where the driver was last frame. Only when
|
|
502
|
+
// there is actually a round in the air; two sines and twelve position writes.
|
|
503
|
+
if (this.projectiles.length) this.game.coaches?.syncRiders?.();
|
|
504
|
+
|
|
505
|
+
// bullets — straight flight, no gravity; expiry from range/speed
|
|
506
|
+
for (let i = this.projectiles.length - 1; i >= 0; i--) {
|
|
507
|
+
const pr = this.projectiles[i];
|
|
508
|
+
pr.age += dt;
|
|
509
|
+
_prevPos.copy(pr.obj.position);
|
|
510
|
+
pr.obj.position.addScaledVector(pr.dir, pr.speed * dt);
|
|
511
|
+
let dead = pr.age > pr.ttl; // ranged out — vanish quietly, no impact
|
|
512
|
+
let struck = false; // world hit → dust + ricochet
|
|
513
|
+
|
|
514
|
+
const ground = this.game.world.groundAt(pr.obj.position.x, pr.obj.position.z);
|
|
515
|
+
if (!dead && pr.obj.position.y < ground + 0.05) { dead = struck = true; }
|
|
516
|
+
|
|
517
|
+
// buildings/rocks stop shots: sweep this frame's movement segment against the world
|
|
518
|
+
// BVH (the same geometry moveCapsule collides with) — tunnel-proof at bullet speed,
|
|
519
|
+
// and genuinely 3D so a shot over a roof misses. Trees/fences live in the 2D collider
|
|
520
|
+
// list instead (alt-gate so high shots clear them).
|
|
521
|
+
if (!dead && this.game.world.segmentBlocked(_prevPos, pr.obj.position)) dead = struck = true;
|
|
522
|
+
const alt = pr.obj.position.y - ground;
|
|
523
|
+
if (!dead && alt < 7) {
|
|
524
|
+
const c = this.game.world.collide(pr.obj.position.x, pr.obj.position.z, 0.25, alt, pr.obj.position.y);
|
|
525
|
+
if (Math.abs(c.x - pr.obj.position.x) + Math.abs(c.z - pr.obj.position.z) > 1e-6) dead = struck = true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!dead && pr.friendly) {
|
|
529
|
+
// THE OUTLAWS.
|
|
530
|
+
for (const e of this.enemies) {
|
|
531
|
+
if (!e.alive || e === pr.shooter || e.faction === 'ally') continue;
|
|
532
|
+
if (this._bulletHit(pr, e, _prevPos)) { dead = true; break; }
|
|
533
|
+
}
|
|
534
|
+
// THE TOWNSFOLK. Bullets used to pass straight through them — you could empty a revolver
|
|
535
|
+
// into a barman and he carried on polishing the glass. They live in game.npcs, not in
|
|
536
|
+
// this.enemies, which is exactly why they were never tested.
|
|
537
|
+
// ...AND NOT THE CHILDREN. `noTarget` (npc.js): the round passes clean through. There is
|
|
538
|
+
// no damage arithmetic here to get wrong, because there is no code path from a bullet to a
|
|
539
|
+
// child's health at all.
|
|
540
|
+
if (!dead) {
|
|
541
|
+
for (const n of this.game.npcs ?? []) {
|
|
542
|
+
if (!n.alive || n === pr.shooter || n.noTarget) continue;
|
|
543
|
+
if (this._bulletHit(pr, n, _prevPos)) { dead = true; break; }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// THE MEN ON THE COACH — driver, guard, and whoever is actually riding today (coach.crew
|
|
547
|
+
// is only the visible ones: a fare who isn't aboard must not stop a bullet).
|
|
548
|
+
if (!dead) {
|
|
549
|
+
for (const c of this.game.coaches?.coaches ?? []) {
|
|
550
|
+
for (const r of c.crew) {
|
|
551
|
+
if (!r.alive) continue;
|
|
552
|
+
if (this._bulletHit(pr, r, _prevPos)) { dead = true; break; }
|
|
553
|
+
}
|
|
554
|
+
if (dead) break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// bullets smash props they fly into (bottles on the saloon rail)
|
|
558
|
+
if (!dead && this.game.smashables?.tryPoint(pr.obj.position, 0.75, pr.dir)) dead = true;
|
|
559
|
+
// THE COUNTY'S GAME. Deer and wolf are VAT herd instances, not entities — hunting.js owns
|
|
560
|
+
// the sphere test, the death take, and the carcass the round leaves behind.
|
|
561
|
+
if (!dead && this.game.hunting?.bulletHit(pr, _prevPos)) dead = true;
|
|
562
|
+
} else if (!dead && p.alive && pr.obj.position.distanceTo(tmpV1.copy(p.position).setY(p.position.y + 1.2)) < 0.9) {
|
|
563
|
+
p.takeDamage(pr.damage, pr.shooter?.position ?? pr.obj.position);
|
|
564
|
+
this._fleshImpact(pr, p);
|
|
565
|
+
dead = true;
|
|
566
|
+
} else if (!dead && !pr.friendly) {
|
|
567
|
+
// hostile shots also strike allies, should any ever exist
|
|
568
|
+
for (const e of this.enemies) {
|
|
569
|
+
if (!e.alive || e.faction !== 'ally') continue;
|
|
570
|
+
if (pr.obj.position.distanceTo(tmpV1.copy(e.position).setY(e.position.y + 1.1)) < 0.9) {
|
|
571
|
+
e.takeDamage(pr.damage, pr.obj.position);
|
|
572
|
+
this._fleshImpact(pr, e);
|
|
573
|
+
dead = true;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (dead) {
|
|
580
|
+
if (struck) this._worldImpact(pr);
|
|
581
|
+
this.game.scene.remove(pr.obj);
|
|
582
|
+
this.projectiles.splice(i, 1);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|