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,442 @@
|
|
|
1
|
+
// Enemies: the outlaws of Dustwater — simple but lively AI:
|
|
2
|
+
// wander -> aggro -> chase -> attack/shoot -> stagger/death.
|
|
3
|
+
// Brawlers box with the punch set; gunmen hang back in a ranged band and fire
|
|
4
|
+
// real bullets through combat.fireBullet (tracer + muzzle flash + gunshot).
|
|
5
|
+
import { Character } from './entity.js';
|
|
6
|
+
// fighter clip list + subset helper are game data: setEnemyContent({ clipSubset, fighterClips })
|
|
7
|
+
let EC = { clipSubset: (c) => c, fighterClips: [] };
|
|
8
|
+
export function setEnemyContent(c) { EC = { ...EC, ...c }; }
|
|
9
|
+
import { steerCarrot } from './pathFollow.js';
|
|
10
|
+
import { dist2D, tmpV1 } from '../core/utils.js';
|
|
11
|
+
import { collectFistJoints, applyFist } from './fistCurl.js';
|
|
12
|
+
|
|
13
|
+
const _echase = { x: 0, z: 0 }; // scratch: the point steerCarrot hands a chasing enemy to aim at
|
|
14
|
+
|
|
15
|
+
// Faction enmity: 'hostile' outlaws fight the player (and any 'ally', if one ever exists);
|
|
16
|
+
// allies NEVER target the player. An undefined team is a non-combatant — no foe of anyone.
|
|
17
|
+
const TEAM = { hostile: 'evil', player: 'good', ally: 'good' };
|
|
18
|
+
export function foes(a, b) { const ta = TEAM[a], tb = TEAM[b]; return !!ta && !!tb && ta !== tb; }
|
|
19
|
+
|
|
20
|
+
// ── HOW WELL THEY SHOOT, AND HOW THAT CHANGES ────────────────────────────────────────────────
|
|
21
|
+
// Every round an outlaw ever fired used to LAND: _landSwing drew a straight line from his chest to
|
|
22
|
+
// yours and let go. The only thing keeping you alive was his rate of fire, which is a countdown,
|
|
23
|
+
// not a gunfight.
|
|
24
|
+
//
|
|
25
|
+
// So he rolls. A shot is decided HIT or MISS first, and then AIMED to match — which is the only way
|
|
26
|
+
// to say "one in five" and mean it. Fake it with a cone of spread instead and the hit rate becomes a
|
|
27
|
+
// function of range and of how wide the man is, i.e. of nothing you can tune.
|
|
28
|
+
//
|
|
29
|
+
// The county learns. The first men who come at you are drunks with a pistol who have never been shot
|
|
30
|
+
// at; the ones who come later have heard what you did to the others. combat.playerKills is the count,
|
|
31
|
+
// because a death by your hand is the one thing every other system only sees half of.
|
|
32
|
+
export const AIM = {
|
|
33
|
+
green: 0.20, // one shot in five, at the start. He is nervous and you are moving.
|
|
34
|
+
master: 0.90, // ...and by the end he does not miss much. 1.0 would be a cheat, not a duel.
|
|
35
|
+
seasonedAt: 45, // kills. The ramp is smoothstepped, so it steepens in the middle of the game.
|
|
36
|
+
// A MISS MUST BE A MISS YOU CAN SEE. Deflect enough to whistle past — 0.5m to 1.4m off at the
|
|
37
|
+
// player's range — and no further: a shot that lands ten metres wide reads as a bug, not a near
|
|
38
|
+
// thing. (The player's capsule is 0.42m; anything past ~0.6m of offset is clean air.)
|
|
39
|
+
missNear: 0.55,
|
|
40
|
+
missFar: 1.40,
|
|
41
|
+
// and the good ones do not all shoot alike
|
|
42
|
+
skill: { rifle: 1.15, boss: 1.35, brawler: 0.75 }, // multipliers on the hit chance
|
|
43
|
+
// Distance still matters. At the far end of his band he is worse, whatever he has learnt.
|
|
44
|
+
falloffFrom: 12, // metres — inside this, his practised chance stands
|
|
45
|
+
falloffTo: 40, // ...and by here it is halved
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const smooth = (t) => t * t * (3 - 2 * t);
|
|
49
|
+
|
|
50
|
+
// THE LAW SHOOTS BY THE STARS, NOT BY YOUR KILL COUNT. An outlaw gets better because he has heard
|
|
51
|
+
// what you did to the last one; a deputy is not a better shot for that reason — a BETTER MAN is
|
|
52
|
+
// sent. At one and two stars it is whoever the sheriff could find: a shopkeeper with a badge, and he
|
|
53
|
+
// shoots like one. Past that they send men who do this for a living, and at five they have sent the
|
|
54
|
+
// best they have. Indexed BY STAR (0..5), so index 0 is the peacetime deputy nobody called for.
|
|
55
|
+
const LAW_AIM = [0.20, 0.20, 0.25, 0.45, 0.70, 0.92];
|
|
56
|
+
|
|
57
|
+
// The chance THIS man lands THIS shot, right now.
|
|
58
|
+
export function hitChance(e, dist, game) {
|
|
59
|
+
const d = e.def ?? {};
|
|
60
|
+
let c;
|
|
61
|
+
if (d.law || /^law_/.test(e.type ?? '')) {
|
|
62
|
+
const stars = Math.max(0, Math.min(5, game?.crime?.level ?? 0));
|
|
63
|
+
c = LAW_AIM[stars];
|
|
64
|
+
} else {
|
|
65
|
+
const kills = game?.combat?.playerKills ?? 0;
|
|
66
|
+
const learn = smooth(Math.min(1, kills / AIM.seasonedAt));
|
|
67
|
+
c = AIM.green + (AIM.master - AIM.green) * learn;
|
|
68
|
+
}
|
|
69
|
+
if (d.boss) c *= AIM.skill.boss;
|
|
70
|
+
else if (d.aimClip === 'rifleAim') c *= AIM.skill.rifle;
|
|
71
|
+
else if (d.brawler) c *= AIM.skill.brawler;
|
|
72
|
+
const far = smooth(Math.min(1, Math.max(0, (dist - AIM.falloffFrom) / (AIM.falloffTo - AIM.falloffFrom))));
|
|
73
|
+
c *= 1 - 0.5 * far;
|
|
74
|
+
return Math.max(0.02, Math.min(0.97, c));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Roll the shot, and bend the barrel to match the answer. `dir` is a unit vector, modified in place.
|
|
78
|
+
export function aimError(dir, e, dist, game) {
|
|
79
|
+
if (Math.random() < hitChance(e, dist, game)) return false; // he has you: the line stands
|
|
80
|
+
// A miss goes WIDE, or high, or into the dirt at your feet — but it goes somewhere you can read.
|
|
81
|
+
const off = AIM.missNear + Math.random() * (AIM.missFar - AIM.missNear);
|
|
82
|
+
const ang = Math.random() * Math.PI * 2;
|
|
83
|
+
// build a basis square to the shot: right = dir x up, and up' = right x dir
|
|
84
|
+
const rx = dir.z, rz = -dir.x; // dir cross (0,1,0), un-normalised is fine at |dir|=1
|
|
85
|
+
const rl = Math.hypot(rx, rz) || 1;
|
|
86
|
+
const t = off / Math.max(dist, 1); // the angle that puts him `off` metres wide AT the player
|
|
87
|
+
dir.x += (rx / rl) * Math.cos(ang) * t;
|
|
88
|
+
dir.z += (rz / rl) * Math.cos(ang) * t;
|
|
89
|
+
dir.y += Math.sin(ang) * t * 0.7; // less vertical: a shot over his hat reads better than one at his boots
|
|
90
|
+
dir.normalize();
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// POLYGON Western characters: one multi-char FBX on the standard Synty ANIM rig →
|
|
95
|
+
// keep-filter one body each + the western char atlas; they retarget on the shared
|
|
96
|
+
// FIGHTER_CLIPS like every other Synty character. FBX is cm → default 0.01 scale.
|
|
97
|
+
// The enemy roster is GAME data — register at boot: registerEnemyTypes(defs).
|
|
98
|
+
// `??=` merge semantics: a hot-reload re-registration must not stamp a second def
|
|
99
|
+
// over live enemies holding the first one by reference.
|
|
100
|
+
export const ENEMY_TYPES = {};
|
|
101
|
+
export function registerEnemyTypes(defs) { for (const k in defs) ENEMY_TYPES[k] ??= defs[k]; }
|
|
102
|
+
|
|
103
|
+
export class Enemy extends Character {
|
|
104
|
+
constructor(game, type, clips, { x, z, y = null, leashR = 26, passive = false } = {}) {
|
|
105
|
+
const def = ENEMY_TYPES[type];
|
|
106
|
+
const cs = EC.clipSubset(clips, EC.fighterClips, { strip: true });
|
|
107
|
+
super(game, {
|
|
108
|
+
model: def.model,
|
|
109
|
+
texture: def.texture,
|
|
110
|
+
clips: cs,
|
|
111
|
+
scale: def.scale, // undefined → Character picks by URL (.glb metres=1, .fbx cm=0.01)
|
|
112
|
+
speed: def.speed,
|
|
113
|
+
health: def.health,
|
|
114
|
+
faction: def.faction ?? 'hostile',
|
|
115
|
+
name: def.name,
|
|
116
|
+
keep: def.keep, // multi-body western FBX → base load() keep-filters + atlases
|
|
117
|
+
attach: def.attach,
|
|
118
|
+
texFlipY: def.texFlipY,
|
|
119
|
+
});
|
|
120
|
+
this.type = type;
|
|
121
|
+
this.def = def;
|
|
122
|
+
this.home = { x, z };
|
|
123
|
+
this.leashR = leashR;
|
|
124
|
+
this.passive = passive; // won't aggro until provoked
|
|
125
|
+
this.state = 'wander';
|
|
126
|
+
this.wanderTarget = null;
|
|
127
|
+
this.thinkT = Math.random();
|
|
128
|
+
this.attackT = 0;
|
|
129
|
+
this.civilian = !!def.civilian; // a NEUTRAL outfit's gun: an Enemy by mechanism, a civilian by law
|
|
130
|
+
this.setPosition(x, y ?? game.world.groundAt(x, z), z); // y override: seat on a building floor
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async load() {
|
|
134
|
+
await super.load();
|
|
135
|
+
// Brawlers box bare-fisted (ENEMY_TYPES … brawler:true). Collect the finger joints at REST here — the
|
|
136
|
+
// mixer hasn't posed the fingers yet — so update() can close them into fists on the punch clips, the
|
|
137
|
+
// same procedural curl the player uses (fistCurl.js). Gunmen skip this: their fingers hold the gun.
|
|
138
|
+
if (this.def.brawler) this._fistBones = collectFistJoints(this.model);
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
provoke() { this.passive = false; this.state = 'chase'; }
|
|
143
|
+
|
|
144
|
+
// A NEUTRAL MAN WHO IS SHOT AT. He is an Enemy only so that he can shoot back — by law he is a
|
|
145
|
+
// civilian, so hurting him is an assault and killing him is a murder, weighted by whoever saw it
|
|
146
|
+
// (his outfit are real NPCs standing right there, and crime.js counts them). And it brings his
|
|
147
|
+
// whole outfit down on you, which is the point: they take it badly.
|
|
148
|
+
//
|
|
149
|
+
// Plain hostiles fall straight through to Character.takeDamage, unchanged — a bandit who drew
|
|
150
|
+
// on you still costs nothing, and that must never change.
|
|
151
|
+
takeDamage(amount, fromPos = null, opts = {}) {
|
|
152
|
+
if (!this.alive || !this.civilian) return super.takeDamage(amount, fromPos, opts);
|
|
153
|
+
const g = this.game;
|
|
154
|
+
this._killer = opts.attacker ?? null; // die() runs INSIDE super.takeDamage — stash it first
|
|
155
|
+
const byPlayer = this._killer == null || this._killer === g.player;
|
|
156
|
+
super.takeDamage(amount, fromPos, opts);
|
|
157
|
+
if (byPlayer) {
|
|
158
|
+
// AND HE DRAWS HIS OWN GUN. encounters.provokeGroup wakes his OUTFIT and deliberately skips
|
|
159
|
+
// the man who was hit (it is iterating the group's guns and he is one of them) — so without
|
|
160
|
+
// this line the only body in the outfit who can shoot back was the only one who didn't, and
|
|
161
|
+
// putting the first round into the drover's gun-hand was a free kill with no answer.
|
|
162
|
+
// 40 m of leash: he will follow you off the trail for it, but not across the county.
|
|
163
|
+
if (this.alive) { this.provoke(); this.leashR = Math.max(this.leashR, 40); }
|
|
164
|
+
g.encounters?.provokeGroup(this);
|
|
165
|
+
// ...but only if he's someone it's a CRIME to hit — a civilian (the drover fights back but is
|
|
166
|
+
// still a townsman) or a badge. Landing a round on a HOSTILE OUTLAW is self-defence, not
|
|
167
|
+
// assault — the same factional rule die() uses for murder. Without this, clearing the deputy's
|
|
168
|
+
// four outlaws earned a wanted star (Nick). (dead? die() handles the murder/no-crime call.)
|
|
169
|
+
if (this.alive && (this.civilian || this.lawman)) g.crime?.report('assault', this.position.clone());
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
die() {
|
|
174
|
+
// A neutral man killed by the player is a MURDER, and it is scored by his outfit as witnesses.
|
|
175
|
+
// A hostile is not: killing an Enemy reports nothing, which is the self-defence rule.
|
|
176
|
+
const murder = this.civilian && (this._killer == null || this._killer === this.game.player);
|
|
177
|
+
super.die();
|
|
178
|
+
if (murder) this.game.crime?.report('murder', this.position.clone());
|
|
179
|
+
if (this.faction === 'ally') return;
|
|
180
|
+
// outlaws bleed where they fall
|
|
181
|
+
const gy = this.game.world.groundAt(this.position.x, this.position.z);
|
|
182
|
+
this.game.vfx?.bloodDecal({ x: this.position.x, y: gy, z: this.position.z });
|
|
183
|
+
// tell the mission engine — a kill stage tallies hostiles by the mission tag stamped on them
|
|
184
|
+
// when they spawned (game/missions.js), so it advances only on the RIGHT deaths.
|
|
185
|
+
this.game.missions?.enemyKilled?.(this);
|
|
186
|
+
// ...and the bounty board (main.js): a poster's men are tagged 'bounty'
|
|
187
|
+
this.game._bountyKill?.(this);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Pick the nearest FOE: outlaws weigh the player + any allies; allies can NEVER return the
|
|
191
|
+
// player. No range limit — the FSM's aggroR gates whether it actually engages, so a lone
|
|
192
|
+
// enemy with a far foe just stays in wander (unchanged behaviour).
|
|
193
|
+
acquireTarget() {
|
|
194
|
+
let best = null, bestD = Infinity;
|
|
195
|
+
const player = this.game.player;
|
|
196
|
+
if (player?.alive && foes(this.faction, 'player')) { best = player; bestD = dist2D(this.position.x, this.position.z, player.position.x, player.position.z); }
|
|
197
|
+
for (const e of this.game.combat.enemies) {
|
|
198
|
+
if (e === this || !e.alive || !foes(this.faction, e.faction)) continue;
|
|
199
|
+
const d = dist2D(this.position.x, this.position.z, e.position.x, e.position.z);
|
|
200
|
+
if (d < bestD) { bestD = d; best = e; }
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// the deferred blow, ticked from update() on game time (menu/map-paused, Load-evicted).
|
|
206
|
+
// alive checks: the tick only runs while this enemy lives; the victim is re-checked here.
|
|
207
|
+
_landSwing(sw) {
|
|
208
|
+
const player = sw.target ?? this.game.player; // the FSM's victim — NOT necessarily the human
|
|
209
|
+
if (!player?.alive) return;
|
|
210
|
+
if (sw.kind === 'ranged') {
|
|
211
|
+
const rg = sw.rg;
|
|
212
|
+
const from = this.position.clone().setY(this.position.y + 1.3);
|
|
213
|
+
const to = player.position.clone().setY(player.position.y + 1.0);
|
|
214
|
+
const dir = to.sub(from).normalize();
|
|
215
|
+
// ...AND HE MIGHT MISS. He never did: this line drew a straight line from his chest to yours
|
|
216
|
+
// and every round he ever fired landed. The only thing between you and death was how slowly he
|
|
217
|
+
// pulled the trigger, which is not a gunfight, it is a countdown.
|
|
218
|
+
if (player === this.game.player) aimError(dir, this, from.distanceTo(to), this.game);
|
|
219
|
+
this.game.combat.fireBullet({
|
|
220
|
+
from, dir, damage: Math.round(this.def.damage * (this._dmgMul ?? 1)),
|
|
221
|
+
speed: rg.projSpeed, range: rg.max + 12, friendly: false, shooter: this,
|
|
222
|
+
});
|
|
223
|
+
this.game.vfx?.muzzleFlash?.(from.addScaledVector(dir, 0.35), dir);
|
|
224
|
+
this.game.audio?.play('gunshot');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const dd = dist2D(this.position.x, this.position.z, player.position.x, player.position.z);
|
|
228
|
+
if (dd < 2.3 && !this.game.world.losBlocked(this.position, player.position)) { // not through a wall
|
|
229
|
+
player.takeDamage(Math.round(this.def.damage * (this._dmgMul ?? 1)), this.position, { attacker: this });
|
|
230
|
+
if (player === this.game.player) this.game.audio?.play('hurt'); // YOU took it — the full body thud
|
|
231
|
+
else { // a brawl you're only near — impact, distance-faded
|
|
232
|
+
const dL = dist2D(this.position.x, this.position.z, this.game.player.position.x, this.game.player.position.z);
|
|
233
|
+
if (dL < 20) this.game.audio?.play('hit', 1 - dL / 20);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
update(dt) {
|
|
239
|
+
super.update(dt);
|
|
240
|
+
if (!this.alive) return;
|
|
241
|
+
// Bare-knuckle brawlers: curl the fingers into fists on the punch clips, open otherwise. AFTER the
|
|
242
|
+
// mixer (super.update ran it), and only while on-screen (super.update hides off-screen bodies).
|
|
243
|
+
if (this._fistBones && this.model.visible) applyFist(this._fistBones, (this.animator?.currentName || '').startsWith('punch'));
|
|
244
|
+
// pending swing lands on GAME time (was setTimeout): pausing (menu) freezes it with
|
|
245
|
+
// the world instead of silently voiding the blow, hit-stop slows it in sync with the
|
|
246
|
+
// animation, and a Load evicts it with the enemy — the finisher-timer bug class (audit C12)
|
|
247
|
+
if (this._swing) {
|
|
248
|
+
this._swing.t -= dt;
|
|
249
|
+
if (this._swing.t <= 0) { const sw = this._swing; this._swing = null; this._landSwing(sw); }
|
|
250
|
+
}
|
|
251
|
+
// Far-enemy AI stride: beyond 1.5× the entity cull the whole FSM ticks every 4th frame with
|
|
252
|
+
// accumulated dt (a leashed wander doesn't need 60Hz; combat always happens well inside
|
|
253
|
+
// this radius). dt accumulation keeps every FSM timer exact — moves just step 4× as far.
|
|
254
|
+
if (this.game.player && this.game.quality) {
|
|
255
|
+
const dp = dist2D(this.position.x, this.position.z, this.game.player.position.x, this.game.player.position.z);
|
|
256
|
+
if (dp > (this.cullDist ?? this.game.quality.entityCull) * 1.5) {
|
|
257
|
+
this._aiPhase = ((this._aiPhase ?? 0) + 1) % 4;
|
|
258
|
+
if (this._aiPhase) { this._aiDt = (this._aiDt ?? 0) + dt; return; }
|
|
259
|
+
dt += this._aiDt ?? 0; this._aiDt = 0;
|
|
260
|
+
} else if (this._aiDt) { dt += this._aiDt; this._aiDt = 0; }
|
|
261
|
+
}
|
|
262
|
+
// Resolve the current foe (throttled + re-acquired on target death). Keeping the local named
|
|
263
|
+
// `player` lets the whole FSM below drive at `this.target` unchanged.
|
|
264
|
+
this._acqT = (this._acqT ?? 0) - dt;
|
|
265
|
+
if (!this.target || !this.target.alive || this._acqT <= 0) { this.target = this.acquireTarget(); this._acqT = 0.4; }
|
|
266
|
+
const player = this.target;
|
|
267
|
+
if (!player?.alive) { this.idleBehaviour(dt); return; }
|
|
268
|
+
|
|
269
|
+
// boss phase two: below half health the leaders ENRAGE — faster, harder, announced
|
|
270
|
+
if (this.def.phase2 && !this._enraged && this.health <= this.maxHealth * 0.5) {
|
|
271
|
+
this._enraged = true;
|
|
272
|
+
this.speed *= 1.3;
|
|
273
|
+
this._dmgMul = 1.3;
|
|
274
|
+
this.game.ui?.toast(`${this.name} is enraged!`);
|
|
275
|
+
this.game.shake?.(0.25);
|
|
276
|
+
this.game.audio?.play('aggro');
|
|
277
|
+
this.game.vfx?.spawn?.({ pos: this.position.clone().setY(this.position.y + 1.2), count: 16, size: 0.5, life: 0.7, color: 0xff3020, spread: 2.0 });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const d = dist2D(this.position.x, this.position.z, player.position.x, player.position.z);
|
|
281
|
+
const homeD = dist2D(this.position.x, this.position.z, this.home.x, this.home.z);
|
|
282
|
+
|
|
283
|
+
if (this.state === 'wander') {
|
|
284
|
+
if (!this.passive && d < this.def.aggroR) {
|
|
285
|
+
this.state = 'chase';
|
|
286
|
+
if (player === this.game.player) { // don't announce a scrap the player isn't in
|
|
287
|
+
this.game.audio?.play('aggro');
|
|
288
|
+
this.game.hint?.('combat', 'Tab locks on. Ctrl dodge-rolls — time it as the blow lands.');
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
this.idleBehaviour(dt);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (this.state === 'chase') {
|
|
297
|
+
if (homeD > this.leashR * 1.6 || d > this.def.aggroR * 2.5) {
|
|
298
|
+
this.state = 'wander';
|
|
299
|
+
this.wanderTarget = { x: this.home.x, z: this.home.z };
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// gunmen stop and fire once the player is inside their band (LOS permitting)
|
|
303
|
+
const rg = this.def.ranged;
|
|
304
|
+
if (rg && d > rg.min && d < rg.max && !this.game.world.losBlocked(this.position, player.position)) {
|
|
305
|
+
this.state = 'shoot';
|
|
306
|
+
} else if (d < 1.7 && this._token !== false) {
|
|
307
|
+
this.state = 'attack';
|
|
308
|
+
} else if (this._token === false && d < 6 && !this.busy && this.staggerT <= 0) {
|
|
309
|
+
// no attack token (AC ring, phase 2): prowl the circle around the player instead
|
|
310
|
+
// of piling in — the director (combat.js) hands tokens to the closest two only
|
|
311
|
+
this._circle(player, d, dt);
|
|
312
|
+
return;
|
|
313
|
+
} else if (!this.busy && this.staggerT <= 0) {
|
|
314
|
+
// When the line to the player is blocked by static geometry (a building corner, a fence), route
|
|
315
|
+
// AROUND it via the camp's grid instead of grinding the wall; when it's clear (the common case)
|
|
316
|
+
// this is free and charges straight. Whiskers below still dodge crates/troughs/each other.
|
|
317
|
+
let ax = player.position.x, az = player.position.z;
|
|
318
|
+
if (steerCarrot(this, this.game, this.home, this.position.x, this.position.z, player.position.x, player.position.z, _echase, { dt, arrive: 1.2 })) { ax = _echase.x; az = _echase.z; }
|
|
319
|
+
const dir = tmpV1.set(ax - this.position.x, 0, az - this.position.z).normalize();
|
|
320
|
+
const sd = this.walkDir(dir, dt); // whiskers: chase AROUND crates/troughs/each other, not into them
|
|
321
|
+
this.move(sd, this.speed, dt);
|
|
322
|
+
this.faceToward(this.position.x + sd.x, this.position.z + sd.z, dt);
|
|
323
|
+
this.animator.play('run');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this.state === 'shoot') {
|
|
329
|
+
const rg = this.def.ranged;
|
|
330
|
+
if (!rg || d <= rg.min || d >= rg.max || this.game.world.losBlocked(this.position, player.position)) { this.state = 'chase'; return; }
|
|
331
|
+
this.faceToward(player.position.x, player.position.z, dt);
|
|
332
|
+
this.attackT -= dt;
|
|
333
|
+
if (!this.busy && this.attackT <= 0 && this.staggerT <= 0) {
|
|
334
|
+
this.attackT = rg.cooldown + Math.random() * 0.6;
|
|
335
|
+
this.busy = true;
|
|
336
|
+
// gun clip when the retarget has landed; punch cross keeps a readable telegraph until then
|
|
337
|
+
const shot = this.animator.actions.gunFire ? 'gunFire' : 'punchCross';
|
|
338
|
+
this.animator.play(shot, { once: true, fade: 0.08, timeScale: 1.2, onDone: () => { this.busy = false; } });
|
|
339
|
+
this._telegraph();
|
|
340
|
+
this._swing = { t: 0.38, kind: 'ranged', rg, target: player }; // lands via _landSwing (game-time)
|
|
341
|
+
} else if (!this.busy) {
|
|
342
|
+
const aim = this.def.aimClip && this.animator.actions[this.def.aimClip] ? this.def.aimClip : 'idle';
|
|
343
|
+
this.animator.play(aim);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (this.state === 'attack') {
|
|
349
|
+
if (d > 2.2) { this.state = 'chase'; return; }
|
|
350
|
+
if (this._token === false && !this.busy && this.staggerT <= 0) {
|
|
351
|
+
// token lost mid-melee: back out to the ring instead of swinging
|
|
352
|
+
this._circle(player, d, dt);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.faceToward(player.position.x, player.position.z, dt);
|
|
356
|
+
this.attackT -= dt;
|
|
357
|
+
if (!this.busy && this.attackT <= 0 && this.staggerT <= 0) {
|
|
358
|
+
this.attackT = 1.2 + Math.random() * 0.8;
|
|
359
|
+
this.busy = true;
|
|
360
|
+
const swing = Math.random() < 0.25 ? 'punchUpper' : (Math.random() < 0.5 ? 'punchJab' : 'punchCross');
|
|
361
|
+
this.animator.play(swing, {
|
|
362
|
+
once: true, fade: 0.08, timeScale: 1.3,
|
|
363
|
+
onDone: () => { this.busy = false; },
|
|
364
|
+
});
|
|
365
|
+
this._telegraph(); // readable wind-up: a glint at the fist before the blow lands
|
|
366
|
+
// the WHIFF of the blow — attenuated by the listener's (player's) distance so a brawl across
|
|
367
|
+
// the street is faint, not full-blast. The uppercut gets the beefier swing.
|
|
368
|
+
const dL = dist2D(this.position.x, this.position.z, this.game.player.position.x, this.game.player.position.z);
|
|
369
|
+
if (dL < 20) this.game.audio?.play(swing === 'punchUpper' ? 'swingHeavy' : 'swing', 1 - dL / 20);
|
|
370
|
+
// damage lands mid-swing — stored so a hit on this enemy can cancel it (interrupt)
|
|
371
|
+
this._swing = { t: 0.42, kind: 'melee', target: player }; // lands via _landSwing (game-time)
|
|
372
|
+
} else if (!this.busy) {
|
|
373
|
+
this.animator.play('punchIdle'); // bob in a boxing guard between swings
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// AC ring: prowl a circle around the player while another enemy holds the attack
|
|
379
|
+
// token — sideways drift onto a ~3.4m radius, facing the player the whole time
|
|
380
|
+
_circle(player, d, dt) {
|
|
381
|
+
this._orbitDir ??= Math.random() < 0.5 ? 1 : -1; // each enemy picks a lifelong orbit direction
|
|
382
|
+
const rx = (this.position.x - player.position.x) / (d || 1);
|
|
383
|
+
const rz = (this.position.z - player.position.z) / (d || 1);
|
|
384
|
+
const inward = (3.4 - d) * 0.6; // radial drift toward the ring radius
|
|
385
|
+
const dir = tmpV1.set(rx * inward - rz * this._orbitDir, 0, rz * inward + rx * this._orbitDir).normalize();
|
|
386
|
+
const sd = this.walkDir(dir, dt); // whiskers still steer around props/each other
|
|
387
|
+
this.move(sd, this.speed * 0.45, dt);
|
|
388
|
+
this.faceToward(player.position.x, player.position.z, dt); // guard stays ON the player
|
|
389
|
+
this.animator.play('walk');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// the attack TELEGRAPH: a bright glint at the raised hand as the wind-up starts,
|
|
393
|
+
// giving the dodge window a readable cue (diegetic — no floating UI)
|
|
394
|
+
_telegraph() {
|
|
395
|
+
const p = this.position.clone().setY(this.position.y + 1.5);
|
|
396
|
+
this.game.vfx?.spark?.(p, 0.55);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Nav home (Character._navRegion): camp wanderers grid around their leash home.
|
|
400
|
+
_navHome() { return this.home ?? null; }
|
|
401
|
+
|
|
402
|
+
idleBehaviour(dt) {
|
|
403
|
+
this.thinkT -= dt;
|
|
404
|
+
if (this.thinkT <= 0) {
|
|
405
|
+
this.thinkT = 2 + Math.random() * 4;
|
|
406
|
+
if (Math.random() < 0.6) {
|
|
407
|
+
const a = Math.random() * Math.PI * 2;
|
|
408
|
+
const r = Math.random() * this.leashR * 0.5;
|
|
409
|
+
const cand = { x: this.home.x + Math.sin(a) * r, z: this.home.z + Math.cos(a) * r };
|
|
410
|
+
// route on the navgrid — but ONLY when the player is near enough to see it: every
|
|
411
|
+
// spawner in the world requesting its grid at boot ran dozens of concurrent bakes
|
|
412
|
+
// over the load. Far camps wander blind like they always did — invisible.
|
|
413
|
+
const pp = this.game.player?.position;
|
|
414
|
+
const near = pp && dist2D(this.position.x, this.position.z, pp.x, pp.z) < 90;
|
|
415
|
+
const grd = near ? this._navGrid() : null;
|
|
416
|
+
if (grd && grd.walkable(cand.x, cand.z) !== true) { this.wanderTarget = null; }
|
|
417
|
+
else {
|
|
418
|
+
this.wanderTarget = cand;
|
|
419
|
+
this._navPath = grd ? this._requestPath(cand) : null; this._navI = 0;
|
|
420
|
+
if (!this._navPath && grd) this.wanderTarget = null;
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
this.wanderTarget = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (this.wanderTarget && !this.busy) {
|
|
427
|
+
const d = dist2D(this.position.x, this.position.z, this.wanderTarget.x, this.wanderTarget.z);
|
|
428
|
+
if (d > 1) {
|
|
429
|
+
const wp = this._pathWaypoint(this.wanderTarget);
|
|
430
|
+
const dir = tmpV1.set(wp.x - this.position.x, 0, wp.z - this.position.z).normalize();
|
|
431
|
+
const sd = this.walkDir(dir, dt); // whiskers: dynamic avoidance on top of the path
|
|
432
|
+
this.move(sd, this.speed * 0.45 * (this._steerSlow ? 0.6 : 1), dt);
|
|
433
|
+
this.faceToward(this.position.x + sd.x, this.position.z + sd.z, dt);
|
|
434
|
+
this.animator.play('walk');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.wanderTarget = null;
|
|
438
|
+
}
|
|
439
|
+
if (!this.busy) this.animator.play('idle');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
}
|