@hytopia.com/examples 1.0.48 → 1.0.50

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.
@@ -0,0 +1,1455 @@
1
+ import {
2
+ EventPayloads,
3
+ EventRouter,
4
+ Player,
5
+ PlayerCameraMode,
6
+ PlayerInput,
7
+ PlayerUIEvent,
8
+ QuaternionLike,
9
+ Vector3Like,
10
+ World,
11
+ WorldLoopEvent,
12
+ } from 'hytopia';
13
+
14
+ import ChestEntity from './ChestEntity';
15
+ import GamePlayerEntity from './GamePlayerEntity';
16
+ import GunEntity from './GunEntity';
17
+ import ItemEntity from './ItemEntity';
18
+ import { SPAWN_REGION_AABB } from '../gameConfig';
19
+
20
+ const AIM_JITTER_RADIANS = 0.035;
21
+ const AIM_JITTER_RANDOM_MIN_SCALE = 0.75;
22
+ const AIM_JITTER_RANDOM_MAX_SCALE = 2.1;
23
+ const MELEE_ATTACK_RANGE = 2.4;
24
+ const PICKAXE_SLOT_INDEX = 0;
25
+ const LOOT_INTERACT_RANGE = 1.5;
26
+ const NAVIGATION_PROGRESS_INTERVAL_MS = 600;
27
+ const NAVIGATION_MIN_PROGRESS = 0.35;
28
+ const MAX_VERTICAL_TARGET_DELTA = 12;
29
+ const MELEE_LOOT_OPPORTUNITY_RANGE = 8;
30
+ const BEHAVIOR_LOCK_MS = 450;
31
+ const ENEMY_RETARGET_COOLDOWN_MS = 750;
32
+ const ENEMY_LOST_GRACE_MS = 1400;
33
+ const LOOT_RETARGET_COOLDOWN_MS = 550;
34
+ const LOOT_LOST_GRACE_MS = 2200;
35
+
36
+ enum BotBehaviorState {
37
+ IDLE = 'IDLE',
38
+ LOOT = 'LOOT',
39
+ COMBAT = 'COMBAT',
40
+ }
41
+
42
+ type PlayerUiListener = (payload: { data: Record<string, unknown> }) => void;
43
+
44
+ class BotPlayerUI {
45
+ private _listeners: Map<PlayerUIEvent, Set<PlayerUiListener>> = new Map();
46
+
47
+ public load(_: string): void {}
48
+
49
+ public sendData(_: object): void {}
50
+
51
+ public on(event: PlayerUIEvent, callback: PlayerUiListener): void {
52
+ if (!this._listeners.has(event)) {
53
+ this._listeners.set(event, new Set());
54
+ }
55
+
56
+ this._listeners.get(event)!.add(callback);
57
+ }
58
+
59
+ public emitData(data: Record<string, unknown>): void {
60
+ const listeners = this._listeners.get(PlayerUIEvent.DATA);
61
+ listeners?.forEach(listener => listener({ data }));
62
+ }
63
+ }
64
+
65
+ class BotPlayerCamera {
66
+ public mode: PlayerCameraMode = PlayerCameraMode.FIRST_PERSON;
67
+ public offset: Vector3Like = { x: 0, y: 0.5, z: 0 };
68
+ public zoom: number = 1;
69
+ private _orientation = { pitch: 0, yaw: 0 };
70
+ private _attachedEntity: GamePlayerEntity | undefined;
71
+
72
+ public get orientation(): { pitch: number; yaw: number } {
73
+ return this._orientation;
74
+ }
75
+
76
+ public get facingDirection(): Vector3Like {
77
+ return {
78
+ x: -Math.sin(this._orientation.yaw) * Math.cos(this._orientation.pitch),
79
+ y: Math.sin(this._orientation.pitch),
80
+ z: -Math.cos(this._orientation.yaw) * Math.cos(this._orientation.pitch),
81
+ };
82
+ }
83
+
84
+ public get facingQuaternion(): QuaternionLike {
85
+ const hp = this._orientation.pitch * 0.5;
86
+ const hy = this._orientation.yaw * 0.5;
87
+ const cp = Math.cos(hp);
88
+ const sp = Math.sin(hp);
89
+ const cy = Math.cos(hy);
90
+ const sy = Math.sin(hy);
91
+
92
+ return {
93
+ x: sp * cy,
94
+ y: cp * sy,
95
+ z: -sp * sy,
96
+ w: cp * cy,
97
+ };
98
+ }
99
+
100
+ public setMode(mode: PlayerCameraMode): void {
101
+ this.mode = mode;
102
+ }
103
+
104
+ public setAttachedToEntity(entity: GamePlayerEntity): void {
105
+ this._attachedEntity = entity;
106
+ }
107
+
108
+ public setModelHiddenNodes(_: string[]): void {}
109
+
110
+ public setModelShownNodes(_: string[]): void {}
111
+
112
+ public setOffset(offset: Vector3Like): void {
113
+ this.offset = { ...offset };
114
+ }
115
+
116
+ public setOrientationYaw(yaw: number): void {
117
+ this._orientation.yaw = yaw;
118
+ }
119
+
120
+ public setOrientationPitch(pitch: number): void {
121
+ this._orientation.pitch = pitch;
122
+ }
123
+
124
+ public lookAtPosition(position: Vector3Like): void {
125
+ if (!this._attachedEntity) {
126
+ return;
127
+ }
128
+
129
+ const origin = this._attachedEntity.position;
130
+ const dir = {
131
+ x: position.x - origin.x,
132
+ y: position.y - origin.y,
133
+ z: position.z - origin.z,
134
+ };
135
+ const flat = Math.hypot(dir.x, dir.z) || 1;
136
+
137
+ this._orientation.yaw = Math.atan2(-dir.x, -dir.z);
138
+ this._orientation.pitch = Math.atan2(dir.y, flat);
139
+ }
140
+
141
+ public setZoom(zoom: number): void {
142
+ this.zoom = zoom;
143
+ }
144
+ }
145
+
146
+ class BotStubPlayer extends EventRouter {
147
+ private static _idCounter = 1;
148
+
149
+ public readonly id: string;
150
+ public readonly camera: BotPlayerCamera;
151
+ public readonly cosmetics: Promise<void>;
152
+ public readonly ui: BotPlayerUI;
153
+ public input: PlayerInput = {};
154
+ public profilePictureUrl: string | undefined;
155
+ public username: string;
156
+ public world: World | undefined;
157
+ private _persistedData: Record<string, unknown> | undefined;
158
+
159
+ public constructor(username: string) {
160
+ super();
161
+
162
+ this.id = `bot-${BotStubPlayer._idCounter++}`;
163
+ this.username = username;
164
+ this.camera = new BotPlayerCamera();
165
+ this.cosmetics = Promise.resolve(undefined);
166
+ this.ui = new BotPlayerUI();
167
+ }
168
+
169
+ public joinWorld(world: World): void {
170
+ this.world = world;
171
+ }
172
+
173
+ public getPersistedData(): Record<string, unknown> | undefined {
174
+ return this._persistedData;
175
+ }
176
+
177
+ public setPersistedData(data: Record<string, unknown>): void {
178
+ this._persistedData = { ...(this._persistedData ?? {}), ...data };
179
+ }
180
+
181
+ public scheduleNotification(): Promise<string | void> {
182
+ return Promise.resolve();
183
+ }
184
+
185
+ public unscheduleNotification(): Promise<boolean> {
186
+ return Promise.resolve(false);
187
+ }
188
+
189
+ public resetInputs(): void {
190
+ this.input = {};
191
+ }
192
+ }
193
+
194
+ export default class BotPlayerEntity extends GamePlayerEntity {
195
+ private static readonly _botsByWorld: Map<number, Set<BotPlayerEntity>> = new Map();
196
+ private static readonly _activeWorlds: Set<number> = new Set();
197
+ private static readonly _maxBots = 5 + Math.floor(Math.random() * 4); // 5-8 inclusive
198
+
199
+ public static ensureForWorld(world: World): void {
200
+ const bots = this._botsByWorld.get(world.id) ?? new Set<BotPlayerEntity>();
201
+
202
+ if (!this._botsByWorld.has(world.id)) {
203
+ this._botsByWorld.set(world.id, bots);
204
+ }
205
+
206
+ const desiredBots = this._maxBots;
207
+
208
+ while (bots.size < desiredBots) {
209
+ const botName = this._generateRandomBotName();
210
+ const driver = new BotStubPlayer(botName);
211
+ const bot = new BotPlayerEntity(driver);
212
+ bot.spawn(world, BotPlayerEntity._randomSpawnPosition());
213
+ bots.add(bot);
214
+ }
215
+
216
+ while (bots.size > desiredBots) {
217
+ const iterator = bots.values().next();
218
+ if (iterator.done) {
219
+ break;
220
+ }
221
+
222
+ const bot = iterator.value;
223
+ bot.despawn();
224
+ bots.delete(bot);
225
+ }
226
+ }
227
+
228
+ public static despawnAll(world: World | undefined): void {
229
+ if (!world) {
230
+ return;
231
+ }
232
+
233
+ const bots = this._botsByWorld.get(world.id);
234
+ bots?.forEach(bot => bot.despawn());
235
+ this._botsByWorld.delete(world.id);
236
+ }
237
+
238
+ public static setWorldActive(world: World | undefined, active: boolean): void {
239
+ if (!world) {
240
+ return;
241
+ }
242
+
243
+ if (active) {
244
+ this._activeWorlds.add(world.id);
245
+ } else {
246
+ this._activeWorlds.delete(world.id);
247
+ }
248
+ }
249
+
250
+ private static _randomSpawnPosition(): Vector3Like {
251
+ return {
252
+ x: SPAWN_REGION_AABB.min.x + Math.random() * (SPAWN_REGION_AABB.max.x - SPAWN_REGION_AABB.min.x),
253
+ y: SPAWN_REGION_AABB.min.y + Math.random() * (SPAWN_REGION_AABB.max.y - SPAWN_REGION_AABB.min.y),
254
+ z: SPAWN_REGION_AABB.min.z + Math.random() * (SPAWN_REGION_AABB.max.z - SPAWN_REGION_AABB.min.z),
255
+ };
256
+ }
257
+
258
+ private static _generateRandomBotName(): string {
259
+ const digits = Math.floor(Math.random() * 1_000_000_000)
260
+ .toString()
261
+ .padStart(9, '0');
262
+ return `guest-${digits}`;
263
+ }
264
+
265
+ private readonly _driver: BotStubPlayer;
266
+ private _behaviorState: BotBehaviorState = BotBehaviorState.IDLE;
267
+ private _behaviorLockUntil = 0;
268
+ private _targetEnemy: GamePlayerEntity | undefined;
269
+ private _targetLootEntity: ItemEntity | ChestEntity | undefined;
270
+ private _idleDestination: Vector3Like | undefined;
271
+ private _nextSenseAt = 0;
272
+ private _strafeDirection: 1 | -1 = 1;
273
+ private _strafeSwitchAt = 0;
274
+ private _loopHandler: ((payload: EventPayloads[WorldLoopEvent.TICK_START]) => void) | undefined;
275
+ private _loopWorld: World | undefined;
276
+ private _lastWorldId: number | undefined;
277
+ private _navLastTarget: Vector3Like | undefined;
278
+ private _navLastDistance: number = Number.POSITIVE_INFINITY;
279
+ private _navLastCheckAt = 0;
280
+ private _navLastShortProgressAt = 0;
281
+ private _jumpRetryDebounceAt = 0;
282
+ private _unstickLastPosition: Vector3Like | undefined;
283
+ private _unstickNextCheckAt = 0;
284
+ private _blockBreakTarget: Vector3Like | undefined;
285
+ private _blockBreakExpiresAt = 0;
286
+ private _spentWeaponIds: Set<number> = new Set();
287
+ private _targetReevalAt = 0;
288
+ private _spentWeapons = new WeakSet<GunEntity>();
289
+ private _enemyRetargetCooldownUntil = 0;
290
+ private _enemyForgetAt = 0;
291
+ private _lootRetargetCooldownUntil = 0;
292
+ private _lootForgetAt = 0;
293
+ private _activeLootTargetId: number | undefined;
294
+ private _lootOverrideUntil = 0;
295
+
296
+ private constructor(driver: BotStubPlayer) {
297
+ super(driver as unknown as Player);
298
+ this._driver = driver;
299
+ }
300
+
301
+ public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
302
+ this._driver.joinWorld(world);
303
+ super.spawn(world, position, rotation);
304
+ this._lastWorldId = world.id;
305
+ this._bindLoop(world);
306
+ }
307
+
308
+ public override despawn(): void {
309
+ this._unbindLoop();
310
+ const worldId = this._lastWorldId;
311
+ super.despawn();
312
+
313
+ if (worldId !== undefined) {
314
+ BotPlayerEntity._botsByWorld.get(worldId)?.delete(this);
315
+ }
316
+ }
317
+
318
+ public override setupPlayerUI(): void {
319
+ // Bots do not need UI.
320
+ }
321
+
322
+ public override async loadPersistedData(): Promise<void> {
323
+ // No-op for bots.
324
+ }
325
+
326
+ public override savePersistedData(): void {
327
+ // No-op for bots.
328
+ }
329
+
330
+ public override respawn(): void {
331
+ super.respawn();
332
+ this._idleDestination = undefined;
333
+ this._blockBreakTarget = undefined;
334
+ this._targetLootEntity = undefined;
335
+ this._activeLootTargetId = undefined;
336
+ this._blockBreakTarget = undefined;
337
+ this._blockBreakExpiresAt = 0;
338
+ this._activeLootTargetId = undefined;
339
+ this._activeLootTargetId = undefined;
340
+ this._spentWeaponIds.clear();
341
+ this._spentWeapons = new WeakSet<GunEntity>();
342
+ }
343
+
344
+ private _bindLoop(world: World): void {
345
+ this._loopHandler = ({ tickDeltaMs }) => this._updateBehavior(tickDeltaMs);
346
+ this._loopWorld = world;
347
+ world.loop.on(WorldLoopEvent.TICK_START, this._loopHandler);
348
+ }
349
+
350
+ private _unbindLoop(): void {
351
+ if (this._loopHandler && this._loopWorld) {
352
+ this._loopWorld.loop.off(WorldLoopEvent.TICK_START, this._loopHandler);
353
+ }
354
+
355
+ this._loopHandler = undefined;
356
+ this._loopWorld = undefined;
357
+ }
358
+
359
+ private _updateBehavior(deltaTimeMs: number): void {
360
+ if (!this.world || !BotPlayerEntity._activeWorlds.has(this.world.id) || this.isDead) {
361
+ this._resetInput();
362
+ return;
363
+ }
364
+
365
+ this._resetInput();
366
+ this._navLastCheckAt = Math.min(this._navLastCheckAt, performance.now());
367
+ this._senseEnvironment();
368
+ this._monitorAndUnstick();
369
+
370
+ const hasEnemy = Boolean(this._targetEnemy?.isSpawned);
371
+ const now = performance.now();
372
+ const lootOverrideActive = now < this._lootOverrideUntil;
373
+ let desiredState = BotBehaviorState.IDLE;
374
+ if (hasEnemy && !lootOverrideActive) {
375
+ desiredState = BotBehaviorState.COMBAT;
376
+ } else if (this._targetLootEntity?.isSpawned) {
377
+ desiredState = BotBehaviorState.LOOT;
378
+ }
379
+
380
+ const forceCombat = desiredState === BotBehaviorState.COMBAT && this._behaviorState !== BotBehaviorState.COMBAT;
381
+ this._setBehaviorState(desiredState, forceCombat);
382
+
383
+ switch (this._behaviorState) {
384
+ case BotBehaviorState.COMBAT:
385
+ this._driveCombat(deltaTimeMs);
386
+ break;
387
+ case BotBehaviorState.LOOT:
388
+ this._driveLoot();
389
+ break;
390
+ default:
391
+ this._driveIdle();
392
+ break;
393
+ }
394
+ }
395
+
396
+ private _setBehaviorState(state: BotBehaviorState, force: boolean = false): void {
397
+ if (state === this._behaviorState) {
398
+ return;
399
+ }
400
+
401
+ const now = performance.now();
402
+ if (!force && now < this._behaviorLockUntil) {
403
+ return;
404
+ }
405
+
406
+ this._behaviorState = state;
407
+ this._behaviorLockUntil = now + BEHAVIOR_LOCK_MS;
408
+ this._blockBreakTarget = undefined;
409
+ this._blockBreakExpiresAt = 0;
410
+ this._navLastTarget = undefined;
411
+ this._navLastDistance = Number.POSITIVE_INFINITY;
412
+ if (state === BotBehaviorState.LOOT) {
413
+ this._targetReevalAt = now + 4000;
414
+ } else {
415
+ this._targetReevalAt = 0;
416
+ if (state === BotBehaviorState.COMBAT) {
417
+ this._lootOverrideUntil = 0;
418
+ }
419
+ }
420
+ }
421
+
422
+ private _senseEnvironment(): void {
423
+ if (!this.world) {
424
+ return;
425
+ }
426
+
427
+ if (performance.now() < this._nextSenseAt) {
428
+ return;
429
+ }
430
+
431
+ this._nextSenseAt = performance.now() + 200;
432
+ const position = this.position;
433
+
434
+ let closestEnemy: GamePlayerEntity | undefined;
435
+ let closestEnemyDist = Number.POSITIVE_INFINITY;
436
+
437
+ for (const entity of this.world.entityManager.getAllPlayerEntities()) {
438
+ if (!(entity instanceof GamePlayerEntity)) {
439
+ continue;
440
+ }
441
+
442
+ if (entity === (this as GamePlayerEntity)) {
443
+ continue;
444
+ }
445
+
446
+ if (Math.abs(entity.position.y - position.y) > MAX_VERTICAL_TARGET_DELTA) {
447
+ continue;
448
+ }
449
+
450
+ const dist = this._distanceSq(position, entity.position);
451
+ if (dist < closestEnemyDist) {
452
+ closestEnemy = entity;
453
+ closestEnemyDist = dist;
454
+ }
455
+ }
456
+
457
+ this._updateEnemyTarget(closestEnemy);
458
+
459
+ let closestGun: GunEntity | undefined;
460
+ let closestGunDist = Number.POSITIVE_INFINITY;
461
+ let closestChest: ChestEntity | undefined;
462
+ let closestChestDist = Number.POSITIVE_INFINITY;
463
+ let closestItem: ItemEntity | undefined;
464
+ let closestItemDist = Number.POSITIVE_INFINITY;
465
+
466
+ for (const entity of this.world.entityManager.getAllEntities()) {
467
+ if (!entity.isSpawned) {
468
+ continue;
469
+ }
470
+
471
+ if (Math.abs(entity.position.y - position.y) > MAX_VERTICAL_TARGET_DELTA) {
472
+ continue;
473
+ }
474
+
475
+ if (entity instanceof ChestEntity) {
476
+ if (entity.isOpened) {
477
+ continue;
478
+ }
479
+
480
+ const dist = this._distanceSq(position, entity.position);
481
+ if (dist < closestChestDist) {
482
+ closestChestDist = dist;
483
+ closestChest = entity;
484
+ }
485
+
486
+ continue;
487
+ }
488
+
489
+ if (!(entity instanceof ItemEntity)) {
490
+ continue;
491
+ }
492
+
493
+ if (entity.parent) {
494
+ continue;
495
+ }
496
+
497
+ if (entity instanceof GunEntity && this._isSpentGun(entity)) {
498
+ continue;
499
+ }
500
+
501
+ const dist = this._distanceSq(position, entity.position);
502
+ if (entity instanceof GunEntity) {
503
+ if (dist < closestGunDist) {
504
+ closestGunDist = dist;
505
+ closestGun = entity;
506
+ }
507
+ } else if (dist < closestItemDist) {
508
+ closestItemDist = dist;
509
+ closestItem = entity;
510
+ }
511
+ }
512
+
513
+ const weaponNeeded = !this._hasUsableGun();
514
+ const lowOnAmmo = this._shouldSeekAmmo();
515
+ const prioritizeWeapons = weaponNeeded || lowOnAmmo;
516
+
517
+ const desiredLoot = prioritizeWeapons
518
+ ? closestGun ?? closestChest ?? closestItem
519
+ : closestChest ?? closestGun ?? closestItem;
520
+
521
+ this._updateLootTarget(desiredLoot);
522
+
523
+ if (!this._targetEnemy && !this._targetLootEntity) {
524
+ if (!this._idleDestination || this._distanceSq(this._idleDestination, position) < 1) {
525
+ this._idleDestination = BotPlayerEntity._randomSpawnPosition();
526
+ }
527
+ }
528
+ }
529
+
530
+ private _updateEnemyTarget(candidate: GamePlayerEntity | undefined): void {
531
+ const now = performance.now();
532
+ const current = this._targetEnemy;
533
+ const candidateValid = Boolean(
534
+ candidate && candidate.isSpawned && !candidate.isDead,
535
+ );
536
+
537
+ if (candidateValid && candidate) {
538
+ if (current === candidate) {
539
+ this._enemyForgetAt = now + ENEMY_LOST_GRACE_MS;
540
+ return;
541
+ }
542
+
543
+ const canRetarget =
544
+ !current ||
545
+ !current.isSpawned ||
546
+ current.isDead ||
547
+ now >= this._enemyRetargetCooldownUntil;
548
+
549
+ if (canRetarget) {
550
+ this._targetEnemy = candidate;
551
+ this._enemyRetargetCooldownUntil = now + ENEMY_RETARGET_COOLDOWN_MS;
552
+ this._enemyForgetAt = now + ENEMY_LOST_GRACE_MS;
553
+ this._blockBreakTarget = undefined;
554
+ this._blockBreakExpiresAt = 0;
555
+ }
556
+
557
+ return;
558
+ }
559
+
560
+ if (
561
+ current &&
562
+ (!current.isSpawned || current.isDead || now > this._enemyForgetAt)
563
+ ) {
564
+ this._targetEnemy = undefined;
565
+ }
566
+ }
567
+
568
+ private _updateLootTarget(candidate: ItemEntity | ChestEntity | undefined): void {
569
+ const now = performance.now();
570
+ const current = this._targetLootEntity;
571
+
572
+ if (this._isValidLootTarget(candidate)) {
573
+ if (current === candidate) {
574
+ this._lootForgetAt = now + LOOT_LOST_GRACE_MS;
575
+ return;
576
+ }
577
+
578
+ const canRetarget =
579
+ !this._isValidLootTarget(current) ||
580
+ now >= this._lootRetargetCooldownUntil;
581
+
582
+ if (canRetarget) {
583
+ this._targetLootEntity = candidate;
584
+ this._activeLootTargetId = candidate.id;
585
+ this._lootRetargetCooldownUntil = now + LOOT_RETARGET_COOLDOWN_MS;
586
+ this._lootForgetAt = now + LOOT_LOST_GRACE_MS;
587
+ this._blockBreakTarget = undefined;
588
+ this._blockBreakExpiresAt = 0;
589
+ this._targetReevalAt = now + 4000;
590
+ }
591
+
592
+ return;
593
+ }
594
+
595
+ if (
596
+ current &&
597
+ (!this._isValidLootTarget(current) || now > this._lootForgetAt)
598
+ ) {
599
+ this._targetLootEntity = undefined;
600
+ this._activeLootTargetId = undefined;
601
+ }
602
+ }
603
+
604
+ private _isValidLootTarget(entity: ItemEntity | ChestEntity | undefined): entity is ItemEntity | ChestEntity {
605
+ if (!entity || !entity.isSpawned) {
606
+ return false;
607
+ }
608
+
609
+ if (entity instanceof ItemEntity) {
610
+ return !entity.parent;
611
+ }
612
+
613
+ if (entity instanceof ChestEntity) {
614
+ return !entity.isOpened;
615
+ }
616
+
617
+ return true;
618
+ }
619
+
620
+ private _findNearbyLootOpportunity(radius: number): ItemEntity | ChestEntity | undefined {
621
+ if (!this.world) {
622
+ return undefined;
623
+ }
624
+
625
+ const radiusSq = radius * radius;
626
+ let closestGun: GunEntity | undefined;
627
+ let closestGunDist = Number.POSITIVE_INFINITY;
628
+ let closestChest: ChestEntity | undefined;
629
+ let closestChestDist = Number.POSITIVE_INFINITY;
630
+
631
+ for (const entity of this.world.entityManager.getAllEntities()) {
632
+ if (!entity.isSpawned) {
633
+ continue;
634
+ }
635
+
636
+ const distSq = this._distanceSq(this.position, entity.position);
637
+ if (distSq > radiusSq) {
638
+ continue;
639
+ }
640
+
641
+ if (entity instanceof GunEntity) {
642
+ if (entity.parent || this._isSpentGun(entity)) {
643
+ continue;
644
+ }
645
+ if (distSq < closestGunDist) {
646
+ closestGun = entity;
647
+ closestGunDist = distSq;
648
+ }
649
+ continue;
650
+ }
651
+
652
+ if (entity instanceof ChestEntity) {
653
+ if (entity.isOpened) {
654
+ continue;
655
+ }
656
+ if (distSq < closestChestDist) {
657
+ closestChest = entity;
658
+ closestChestDist = distSq;
659
+ }
660
+ }
661
+ }
662
+
663
+ return closestGun ?? closestChest;
664
+ }
665
+
666
+ private _driveCombat(deltaTimeMs: number): void {
667
+ if (
668
+ !this.world ||
669
+ !this._targetEnemy ||
670
+ !this._targetEnemy.isSpawned ||
671
+ this._targetEnemy.isDead
672
+ ) {
673
+ this._targetEnemy = undefined;
674
+ return;
675
+ }
676
+
677
+ const enemyPosition = this._targetEnemy.position;
678
+ const distance = Math.sqrt(this._distanceSq(this.position, enemyPosition));
679
+ const gun = this._ensureBestWeaponEquipped();
680
+
681
+ if (gun && gun.hasUsableAmmo()) {
682
+ this._handleGunCombat(gun, enemyPosition, distance, deltaTimeMs);
683
+ return;
684
+ }
685
+
686
+ const lootOpportunity = this._findNearbyLootOpportunity(MELEE_LOOT_OPPORTUNITY_RANGE);
687
+ if (lootOpportunity) {
688
+ this._targetLootEntity = lootOpportunity;
689
+ this._activeLootTargetId = lootOpportunity.id;
690
+ this._targetReevalAt = performance.now() + 4000;
691
+ this._lootOverrideUntil = performance.now() + 3000;
692
+ this._setBehaviorState(BotBehaviorState.LOOT, true);
693
+ return;
694
+ }
695
+
696
+ // No usable gun available and no nearby loot; engage with melee.
697
+ this._handleMeleeCombat(enemyPosition, distance, deltaTimeMs);
698
+ return;
699
+ }
700
+
701
+ private _driveLoot(): void {
702
+ const target = this._targetLootEntity;
703
+ const now = performance.now();
704
+
705
+ if (!target) {
706
+ this._activeLootTargetId = undefined;
707
+ this._lootOverrideUntil = 0;
708
+ return;
709
+ }
710
+
711
+ if (target.id !== undefined && target.id !== this._activeLootTargetId) {
712
+ this._targetReevalAt = now + 4000;
713
+ this._activeLootTargetId = target.id;
714
+ }
715
+
716
+ if (!this._isValidLootTarget(target)) {
717
+ this._targetLootEntity = undefined;
718
+ this._activeLootTargetId = undefined;
719
+ return;
720
+ }
721
+
722
+ if (now > this._targetReevalAt) {
723
+ this._targetLootEntity = undefined;
724
+ this._activeLootTargetId = undefined;
725
+ this._blockBreakTarget = undefined;
726
+ this._blockBreakExpiresAt = 0;
727
+ this._targetReevalAt = now + 4000;
728
+ this._lootOverrideUntil = 0;
729
+ return;
730
+ }
731
+
732
+ const targetPosition = { ...target.position };
733
+ const distance = Math.sqrt(this._distanceSq(this.position, targetPosition));
734
+ this._facePosition(targetPosition, false, AIM_JITTER_RADIANS * 0.25);
735
+ if (this._blockBreakTarget && this._continueBlockBreaking(targetPosition)) {
736
+ return;
737
+ }
738
+
739
+ if (this._maybeBreakBlockForTarget(targetPosition)) {
740
+ return;
741
+ }
742
+ if (this._recordNavigationProgress(targetPosition)) {
743
+ this._moveTowards(targetPosition);
744
+ } else {
745
+ this._targetLootEntity = undefined;
746
+ this._activeLootTargetId = undefined;
747
+ return;
748
+ }
749
+
750
+ if (distance < LOOT_INTERACT_RANGE) {
751
+ const input = this.player.input as PlayerInput;
752
+ input.e = true;
753
+
754
+ if (target instanceof ItemEntity) {
755
+ target.pickup(this);
756
+ } else if (target instanceof ChestEntity) {
757
+ target.open();
758
+ }
759
+
760
+ this._targetLootEntity = undefined;
761
+ this._activeLootTargetId = undefined;
762
+ }
763
+ }
764
+
765
+ private _driveIdle(): void {
766
+ if (!this._idleDestination) {
767
+ this._idleDestination = BotPlayerEntity._randomSpawnPosition();
768
+ }
769
+
770
+ this._facePosition(this._idleDestination);
771
+ this._moveTowards(this._idleDestination);
772
+
773
+ if (this._distanceSq(this.position, this._idleDestination) < 4) {
774
+ this._idleDestination = BotPlayerEntity._randomSpawnPosition();
775
+ }
776
+ }
777
+
778
+ private _moveTowards(target: Vector3Like): void {
779
+ const direction = this._directionTo(target);
780
+ this._faceDirection(direction);
781
+
782
+ const input = this.player.input as PlayerInput;
783
+ input.w = true;
784
+ input.sh = true;
785
+
786
+ const shouldJump = this._shouldAutoJump(target);
787
+ if (shouldJump) {
788
+ input.sp = true;
789
+ this._jumpRetryDebounceAt = performance.now() + 400;
790
+ } else if (!this._hasHeadroom() && this.playerController.isGrounded) {
791
+ input.sp = true;
792
+ }
793
+
794
+ if (this._isPathBlocked()) {
795
+ this._strafe(0);
796
+ if (this.playerController.isGrounded && performance.now() > this._jumpRetryDebounceAt) {
797
+ input.sp = true;
798
+ this._jumpRetryDebounceAt = performance.now() + 400;
799
+ }
800
+ }
801
+
802
+ this._recordNavigationProgress(target);
803
+ }
804
+
805
+ private _strafe(deltaTimeMs: number): void {
806
+ const now = performance.now();
807
+ if (now > this._strafeSwitchAt) {
808
+ this._strafeDirection = this._strafeDirection === 1 ? -1 : 1;
809
+ this._strafeSwitchAt = now + 1000 + Math.random() * 750;
810
+ }
811
+
812
+ const input = this.player.input as PlayerInput;
813
+ if (this._strafeDirection > 0) {
814
+ input.d = true;
815
+ } else {
816
+ input.a = true;
817
+ }
818
+
819
+ if (deltaTimeMs > 0 && Math.random() < 0.02 && this.playerController.isGrounded) {
820
+ input.sp = true;
821
+ }
822
+ }
823
+
824
+ private _resetInput(): void {
825
+ const input = this.player.input as PlayerInput;
826
+
827
+ Object.keys(input).forEach(key => {
828
+ delete (input as Record<string, unknown>)[key];
829
+ });
830
+ }
831
+
832
+ private _directionTo(target: Vector3Like): Vector3Like {
833
+ const dir = {
834
+ x: target.x - this.position.x,
835
+ y: target.y - this.position.y,
836
+ z: target.z - this.position.z,
837
+ };
838
+
839
+ const length = Math.hypot(dir.x, dir.y, dir.z) || 1;
840
+
841
+ return {
842
+ x: dir.x / length,
843
+ y: dir.y / length,
844
+ z: dir.z / length,
845
+ };
846
+ }
847
+
848
+ private _distanceSq(a: Vector3Like, b: Vector3Like): number {
849
+ const dx = a.x - b.x;
850
+ const dy = a.y - b.y;
851
+ const dz = a.z - b.z;
852
+
853
+ return dx * dx + dy * dy + dz * dz;
854
+ }
855
+
856
+ private _faceDirection(direction: Vector3Like, jitterRadians: number = 0): void {
857
+ let yaw = Math.atan2(-direction.x, -direction.z);
858
+ let pitch = Math.atan2(direction.y, Math.hypot(direction.x, direction.z));
859
+
860
+ if (jitterRadians > 0) {
861
+ const jitterScale =
862
+ AIM_JITTER_RANDOM_MIN_SCALE +
863
+ Math.random() * (AIM_JITTER_RANDOM_MAX_SCALE - AIM_JITTER_RANDOM_MIN_SCALE);
864
+ const yawJitter = (Math.random() - 0.5) * 2 * jitterRadians * jitterScale;
865
+ const pitchJitter = (Math.random() - 0.5) * jitterRadians * jitterScale;
866
+ yaw += yawJitter;
867
+ pitch += pitchJitter;
868
+ }
869
+
870
+ const camera = this._botCamera;
871
+ camera.setOrientationYaw(yaw);
872
+ camera.setOrientationPitch(pitch);
873
+ }
874
+
875
+ private _facePosition(position: Vector3Like, flatten: boolean = false, jitterRadians: number = 0): void {
876
+ const dir = this._directionTo(position);
877
+ if (flatten) {
878
+ dir.y = 0;
879
+ }
880
+
881
+ this._faceDirection(dir, jitterRadians);
882
+ }
883
+
884
+ private _isPathBlocked(): boolean {
885
+ if (!this.world) {
886
+ return false;
887
+ }
888
+
889
+ const origin = {
890
+ x: this.position.x,
891
+ y: this.position.y + this._botCamera.offset.y,
892
+ z: this.position.z,
893
+ };
894
+
895
+ const raycast = this.world.simulation.raycast(
896
+ origin,
897
+ this._botCamera.facingDirection,
898
+ 0.75,
899
+ {
900
+ filterExcludeRigidBody: this.rawRigidBody,
901
+ },
902
+ );
903
+
904
+ return Boolean(raycast?.hitBlock);
905
+ }
906
+
907
+ private get _botCamera(): BotPlayerCamera {
908
+ return this.player.camera as unknown as BotPlayerCamera;
909
+ }
910
+
911
+ private _recordNavigationProgress(target: Vector3Like): boolean {
912
+ const now = performance.now();
913
+ const distance = Math.sqrt(this._distanceSq(this.position, target));
914
+
915
+ if (
916
+ !this._navLastTarget ||
917
+ this._distanceSq(this._navLastTarget, target) > 1
918
+ ) {
919
+ this._navLastTarget = { ...target };
920
+ this._navLastDistance = Number.POSITIVE_INFINITY;
921
+ this._navLastCheckAt = now;
922
+ return true;
923
+ }
924
+
925
+ if (now - this._navLastCheckAt < NAVIGATION_PROGRESS_INTERVAL_MS) {
926
+ return true;
927
+ }
928
+
929
+ const progress = this._navLastDistance - distance;
930
+ this._navLastCheckAt = now;
931
+ this._navLastDistance = distance;
932
+
933
+ if (progress < NAVIGATION_MIN_PROGRESS) {
934
+ this._handleNavigationFailure();
935
+ return false;
936
+ }
937
+
938
+ return true;
939
+ }
940
+
941
+ private _handleNavigationFailure(): void {
942
+ const now = performance.now();
943
+ if (now > this._jumpRetryDebounceAt && this.playerController.isGrounded) {
944
+ (this.player.input as PlayerInput).sp = true;
945
+ this._jumpRetryDebounceAt = now + 400;
946
+ }
947
+
948
+ if (!this._blockBreakTarget) {
949
+ const forward = this._blockAheadCoordinate();
950
+ if (forward && this._assignBlockBreak(forward)) {
951
+ return;
952
+ }
953
+ }
954
+
955
+ this._targetLootEntity = undefined;
956
+ this._idleDestination = BotPlayerEntity._randomSpawnPosition();
957
+ this._navLastTarget = undefined;
958
+ this._navLastDistance = Number.POSITIVE_INFINITY;
959
+ }
960
+
961
+ private _hasHeadroom(): boolean {
962
+ if (!this.world) {
963
+ return true;
964
+ }
965
+
966
+ const origin = {
967
+ x: this.position.x,
968
+ y: this.position.y + 0.5,
969
+ z: this.position.z,
970
+ };
971
+
972
+ const hit = this.world.simulation.raycast(origin, { x: 0, y: 1, z: 0 }, 1.8, {
973
+ filterExcludeRigidBody: this.rawRigidBody,
974
+ });
975
+
976
+ return !hit?.hitBlock;
977
+ }
978
+
979
+ private _shouldAutoJump(target: Vector3Like): boolean {
980
+ if (!this.world || performance.now() < this._jumpRetryDebounceAt) {
981
+ return false;
982
+ }
983
+
984
+ const direction = this._directionTo(target);
985
+ const horizontal = Math.hypot(direction.x, direction.z) || 1;
986
+ const forward = { x: direction.x / horizontal, z: direction.z / horizontal };
987
+
988
+ const footOrigin = {
989
+ x: this.position.x + forward.x * 0.4,
990
+ y: this.position.y - this.height * 0.5 + 0.1,
991
+ z: this.position.z + forward.z * 0.4,
992
+ };
993
+
994
+ const blockAhead = this._castBlock(footOrigin, { x: forward.x, y: 0, z: forward.z }, 0.9);
995
+ if (!blockAhead) {
996
+ return false;
997
+ }
998
+
999
+ const headOrigin = {
1000
+ x: footOrigin.x,
1001
+ y: this.position.y + this.height * 0.4,
1002
+ z: footOrigin.z,
1003
+ };
1004
+
1005
+ const headClear = !this._castBlock(headOrigin, { x: forward.x, y: 0, z: forward.z }, 0.75);
1006
+ return headClear;
1007
+ }
1008
+
1009
+ private _castBlock(origin: Vector3Like, direction: Vector3Like, length: number): boolean {
1010
+ if (!this.world) {
1011
+ return false;
1012
+ }
1013
+
1014
+ const hit = this.world.simulation.raycast(origin, direction, length, {
1015
+ filterExcludeRigidBody: this.rawRigidBody,
1016
+ });
1017
+
1018
+ return Boolean(hit?.hitBlock);
1019
+ }
1020
+
1021
+ private _monitorAndUnstick(): void {
1022
+ if (!this.world) {
1023
+ return;
1024
+ }
1025
+
1026
+ const now = performance.now();
1027
+
1028
+ if (!this._unstickLastPosition) {
1029
+ this._unstickLastPosition = { ...this.position };
1030
+ this._unstickNextCheckAt = now + 650;
1031
+ return;
1032
+ }
1033
+
1034
+ if (now < this._unstickNextCheckAt) {
1035
+ return;
1036
+ }
1037
+
1038
+ const moved = Math.sqrt(this._distanceSq(this.position, this._unstickLastPosition));
1039
+ this._unstickLastPosition = { ...this.position };
1040
+ this._unstickNextCheckAt = now + 650;
1041
+
1042
+ if (moved > 0.25) {
1043
+ return;
1044
+ }
1045
+
1046
+ this._forceUnstick();
1047
+ }
1048
+
1049
+ private _forceUnstick(): void {
1050
+ const mass = Math.max(1, this.mass);
1051
+ const impulseMagnitude = 4 * mass;
1052
+ const angle = Math.random() * Math.PI * 2;
1053
+ const impulse = {
1054
+ x: Math.cos(angle) * impulseMagnitude,
1055
+ y: impulseMagnitude * 0.6,
1056
+ z: Math.sin(angle) * impulseMagnitude,
1057
+ };
1058
+
1059
+ this.applyImpulse(impulse);
1060
+ (this.player.input as PlayerInput).sp = true;
1061
+ this._jumpRetryDebounceAt = performance.now() + 450;
1062
+
1063
+ if (this._behaviorState === BotBehaviorState.LOOT) {
1064
+ this._targetLootEntity = undefined;
1065
+ this._activeLootTargetId = undefined;
1066
+ }
1067
+
1068
+ this._blockBreakTarget = undefined;
1069
+ this._blockBreakExpiresAt = 0;
1070
+ this._idleDestination = BotPlayerEntity._randomSpawnPosition();
1071
+ }
1072
+
1073
+ private _continueBlockBreaking(desiredPosition?: Vector3Like): boolean {
1074
+ if (!this.world || !this._blockBreakTarget) {
1075
+ return false;
1076
+ }
1077
+
1078
+ if (this._blockBreakExpiresAt && performance.now() > this._blockBreakExpiresAt) {
1079
+ this._blockBreakTarget = undefined;
1080
+ this._blockBreakExpiresAt = 0;
1081
+ return false;
1082
+ }
1083
+
1084
+ const blockType = this.world.chunkLattice.getBlockType(this._blockBreakTarget);
1085
+ if (!blockType) {
1086
+ this._blockBreakTarget = undefined;
1087
+ this._blockBreakExpiresAt = 0;
1088
+ return false;
1089
+ }
1090
+
1091
+ const blockCenter = {
1092
+ x: this._blockBreakTarget.x + 0.5,
1093
+ y: this._blockBreakTarget.y + 0.5,
1094
+ z: this._blockBreakTarget.z + 0.5,
1095
+ };
1096
+
1097
+ const distance = Math.sqrt(this._distanceSq(this.position, blockCenter));
1098
+ if (desiredPosition && distance > 2.5) {
1099
+ this._moveTowards(desiredPosition);
1100
+ return true;
1101
+ }
1102
+
1103
+ this.setActiveInventorySlotIndex(PICKAXE_SLOT_INDEX);
1104
+ const input = this.player.input as PlayerInput;
1105
+ input.ml = true;
1106
+
1107
+ this._facePosition(blockCenter);
1108
+
1109
+ return true;
1110
+ }
1111
+
1112
+ private _maybeBreakBlockForTarget(targetPosition: Vector3Like, allowAbove: boolean = false): boolean {
1113
+ if (!this.world) {
1114
+ return false;
1115
+ }
1116
+
1117
+ if (this._blockBreakTarget) {
1118
+ return this._continueBlockBreaking(targetPosition);
1119
+ }
1120
+
1121
+ const verticalDelta = this.position.y - targetPosition.y;
1122
+ const horizontalSq = this._distanceSq(
1123
+ { x: this.position.x, y: 0, z: this.position.z },
1124
+ { x: targetPosition.x, y: 0, z: targetPosition.z },
1125
+ );
1126
+
1127
+ const botAboveTarget = verticalDelta > 0.5;
1128
+ const botBelowTarget = verticalDelta < -0.5;
1129
+
1130
+ if (botAboveTarget && horizontalSq < 4) {
1131
+ const below = this._blockBelowCoordinate();
1132
+ if (below && this._assignBlockBreak(below)) {
1133
+ return true;
1134
+ }
1135
+ }
1136
+
1137
+ const ahead = this._blockAheadCoordinate();
1138
+ if (
1139
+ ahead &&
1140
+ (allowAbove || !botAboveTarget || targetPosition.y <= this.position.y + 0.25) &&
1141
+ this._assignBlockBreak(ahead)
1142
+ ) {
1143
+ return true;
1144
+ }
1145
+
1146
+ if (allowAbove && botBelowTarget && horizontalSq < 4) {
1147
+ const above = this._blockAboveCoordinate();
1148
+ if (above && this._assignBlockBreak(above)) {
1149
+ return true;
1150
+ }
1151
+ }
1152
+
1153
+ return false;
1154
+ }
1155
+
1156
+ private _blockBelowCoordinate(): Vector3Like | undefined {
1157
+ if (!this.world) {
1158
+ return undefined;
1159
+ }
1160
+
1161
+ const coord = {
1162
+ x: Math.floor(this.position.x),
1163
+ y: Math.floor(this.position.y - this.height * 0.5),
1164
+ z: Math.floor(this.position.z),
1165
+ };
1166
+
1167
+ return this.world.chunkLattice.getBlockType(coord) ? coord : undefined;
1168
+ }
1169
+
1170
+ private _blockAheadCoordinate(): Vector3Like | undefined {
1171
+ if (!this.world) {
1172
+ return undefined;
1173
+ }
1174
+
1175
+ const forward = this._botCamera.facingDirection;
1176
+ const horizontal = Math.hypot(forward.x, forward.z) || 1;
1177
+ const coord = {
1178
+ x: Math.floor(this.position.x + forward.x / horizontal),
1179
+ y: Math.floor(this.position.y - this.height * 0.25),
1180
+ z: Math.floor(this.position.z + forward.z / horizontal),
1181
+ };
1182
+
1183
+ return this.world.chunkLattice.getBlockType(coord) ? coord : undefined;
1184
+ }
1185
+
1186
+ private _blockAboveCoordinate(): Vector3Like | undefined {
1187
+ if (!this.world) {
1188
+ return undefined;
1189
+ }
1190
+
1191
+ const coord = {
1192
+ x: Math.floor(this.position.x),
1193
+ y: Math.floor(this.position.y + this.height * 0.5),
1194
+ z: Math.floor(this.position.z),
1195
+ };
1196
+
1197
+ return this.world.chunkLattice.getBlockType(coord) ? coord : undefined;
1198
+ }
1199
+
1200
+ private _assignBlockBreak(coord: Vector3Like): boolean {
1201
+ if (!this.world) {
1202
+ return false;
1203
+ }
1204
+
1205
+ const block = this.world.chunkLattice.getBlockType(coord);
1206
+ if (!block) {
1207
+ return false;
1208
+ }
1209
+
1210
+ this._blockBreakTarget = { ...coord };
1211
+ this._blockBreakExpiresAt = performance.now() + 3000;
1212
+ return true;
1213
+ }
1214
+
1215
+ private _hasLineOfSight(target: Vector3Like, distance: number): boolean {
1216
+ if (!this.world) {
1217
+ return false;
1218
+ }
1219
+
1220
+ const origin = {
1221
+ x: this.position.x,
1222
+ y: this.position.y + this._botCamera.offset.y,
1223
+ z: this.position.z,
1224
+ };
1225
+
1226
+ const direction = {
1227
+ x: target.x - origin.x,
1228
+ y: target.y - origin.y,
1229
+ z: target.z - origin.z,
1230
+ };
1231
+
1232
+ const length = Math.hypot(direction.x, direction.y, direction.z) || 1;
1233
+ direction.x /= length;
1234
+ direction.y /= length;
1235
+ direction.z /= length;
1236
+
1237
+ const hit = this.world.simulation.raycast(origin, direction, distance, {
1238
+ filterExcludeRigidBody: this.rawRigidBody,
1239
+ });
1240
+
1241
+ if (!hit) {
1242
+ return true;
1243
+ }
1244
+
1245
+ if (hit.hitEntity && hit.hitEntity === this._targetEnemy) {
1246
+ return true;
1247
+ }
1248
+
1249
+ return false;
1250
+ }
1251
+
1252
+ private _hasUsableGun(): boolean {
1253
+ return !!this._findBestGun();
1254
+ }
1255
+
1256
+ private _shouldSeekAmmo(): boolean {
1257
+ const bestGun = this._findBestGun(true);
1258
+ if (!bestGun) { return true; }
1259
+ const ammoTotal = bestGun.gun.getClipAmmo() + bestGun.gun.getReserveAmmo();
1260
+ return ammoTotal < 5;
1261
+ }
1262
+
1263
+ private _findBestGun(requireAmmo: boolean = true): { gun: GunEntity; slot: number } | undefined {
1264
+ const items = this.inventoryItems;
1265
+ let best: { gun: GunEntity; slot: number; score: number } | undefined;
1266
+
1267
+ for (let i = 0; i < items.length; i++) {
1268
+ const item = items[i];
1269
+ if (!(item instanceof GunEntity)) continue;
1270
+
1271
+ if (this._isSpentGun(item)) continue;
1272
+
1273
+ if (!item.hasUsableAmmo()) {
1274
+ this._markWeaponSpent(item);
1275
+ continue;
1276
+ }
1277
+
1278
+ const ammoScore = item.getClipAmmo() + item.getReserveAmmo();
1279
+ if (requireAmmo && ammoScore <= 0) continue;
1280
+
1281
+ if (!best || ammoScore > best.score) {
1282
+ best = { gun: item, slot: i, score: ammoScore };
1283
+ }
1284
+ }
1285
+
1286
+ return best ? { gun: best.gun, slot: best.slot } : undefined;
1287
+ }
1288
+
1289
+ private _isSpentGun(gun: GunEntity): boolean {
1290
+ if (this._spentWeapons.has(gun)) {
1291
+ return true;
1292
+ }
1293
+
1294
+ const id = typeof gun.id === 'number' ? gun.id : undefined;
1295
+ return id !== undefined && this._spentWeaponIds.has(id);
1296
+ }
1297
+
1298
+ private _markWeaponSpent(gun: GunEntity): void {
1299
+ this._spentWeapons.add(gun);
1300
+ if (typeof gun.id === 'number') {
1301
+ this._spentWeaponIds.add(gun.id);
1302
+ }
1303
+ }
1304
+
1305
+ private _reloadUntilFull(gun: GunEntity, attempt: number = 0): void {
1306
+ if (gun.getReserveAmmo() <= 0) {
1307
+ return;
1308
+ }
1309
+
1310
+ if (gun.getClipAmmo() > 0) {
1311
+ return;
1312
+ }
1313
+
1314
+ const activeItem = this.activeInventoryItem;
1315
+ if (activeItem !== gun) {
1316
+ const slot = this.getItemInventorySlot(gun);
1317
+ if (slot !== -1) {
1318
+ this.setActiveInventorySlotIndex(slot);
1319
+ }
1320
+ }
1321
+
1322
+ if (!gun.isReloading()) {
1323
+ gun.reload();
1324
+ }
1325
+
1326
+ if (attempt > 6) {
1327
+ return;
1328
+ }
1329
+
1330
+ const retryDelay = Math.max(100, gun.getReloadTimeMs());
1331
+ setTimeout(() => this._reloadUntilFull(gun, attempt + 1), retryDelay);
1332
+ }
1333
+
1334
+ private _ensureBestWeaponEquipped(): GunEntity | undefined {
1335
+ const activeItem = this.activeInventoryItem;
1336
+ const activeGun = activeItem instanceof GunEntity ? activeItem : undefined;
1337
+ const bestGunInfo = this._findBestGun();
1338
+
1339
+ if (!bestGunInfo) {
1340
+ if (!activeGun) {
1341
+ this.setActiveInventorySlotIndex(PICKAXE_SLOT_INDEX);
1342
+ }
1343
+ return undefined;
1344
+ }
1345
+
1346
+ if (activeGun === bestGunInfo.gun) {
1347
+ return activeGun;
1348
+ }
1349
+
1350
+ this.setActiveInventorySlotIndex(bestGunInfo.slot);
1351
+ return bestGunInfo.gun;
1352
+ }
1353
+
1354
+ private _handleGunCombat(gun: GunEntity, enemyPosition: Vector3Like, distance: number, deltaTimeMs: number): void {
1355
+ const hasLineOfSight = this._hasLineOfSight(enemyPosition, distance);
1356
+ this._facePosition(enemyPosition, true, AIM_JITTER_RADIANS);
1357
+
1358
+ if (!gun.hasUsableAmmo()) {
1359
+ const alternate = this._findBestGun(true);
1360
+ if (gun.getClipAmmo() <= 0 && gun.getReserveAmmo() <= 0 && this.activeInventoryItem === gun) {
1361
+ this._markWeaponSpent(gun);
1362
+ this.dropActiveInventoryItem();
1363
+ }
1364
+
1365
+ if (alternate) {
1366
+ this.setActiveInventorySlotIndex(alternate.slot);
1367
+ return;
1368
+ }
1369
+
1370
+ this._handleMeleeCombat(enemyPosition, distance, deltaTimeMs);
1371
+ return;
1372
+ }
1373
+
1374
+ if (gun.getClipAmmo() <= 0) {
1375
+ if (gun.getReserveAmmo() > 0) {
1376
+ if (!gun.isReloading()) {
1377
+ this._reloadUntilFull(gun);
1378
+ }
1379
+ return;
1380
+ }
1381
+
1382
+ // Gun is empty; drop and flag it as spent.
1383
+ this._markWeaponSpent(gun);
1384
+ if (this.isItemActiveInInventory(gun)) {
1385
+ this.dropActiveInventoryItem();
1386
+ }
1387
+
1388
+ const fallback = this._findBestGun(false);
1389
+ if (fallback && fallback.gun.hasUsableAmmo()) {
1390
+ this.setActiveInventorySlotIndex(fallback.slot);
1391
+ return;
1392
+ }
1393
+
1394
+ this._targetLootEntity = undefined;
1395
+ this._activeLootTargetId = undefined;
1396
+ this._handleMeleeCombat(enemyPosition, distance, deltaTimeMs);
1397
+ return;
1398
+ }
1399
+
1400
+ const input = this.player.input as PlayerInput;
1401
+ const preferredRange = Math.max(6, gun.getEffectiveRange() * 0.6);
1402
+
1403
+ if (!hasLineOfSight) {
1404
+ if (this._maybeBreakBlockForTarget(enemyPosition, true)) {
1405
+ return;
1406
+ }
1407
+ this._moveTowards(enemyPosition);
1408
+ return;
1409
+ }
1410
+
1411
+ if (this._blockBreakTarget) {
1412
+ this._blockBreakTarget = undefined;
1413
+ this._blockBreakExpiresAt = 0;
1414
+ }
1415
+
1416
+ if (distance > preferredRange) {
1417
+ this._moveTowards(enemyPosition);
1418
+ } else if (distance < preferredRange * 0.4) {
1419
+ input.s = true;
1420
+ } else {
1421
+ this._strafe(deltaTimeMs);
1422
+ }
1423
+
1424
+ input.ml = true;
1425
+ input.sh = true;
1426
+
1427
+ if (gun.getClipAmmo() <= 1 && gun.getReserveAmmo() > 0 && !gun.isReloading()) {
1428
+ gun.reload();
1429
+ }
1430
+
1431
+ if (!gun.hasUsableAmmo()) {
1432
+ this._activeLootTargetId = undefined;
1433
+ }
1434
+ }
1435
+
1436
+ private _handleMeleeCombat(enemyPosition: Vector3Like, distance: number, deltaTimeMs: number): void {
1437
+ this.setActiveInventorySlotIndex(PICKAXE_SLOT_INDEX);
1438
+ this._facePosition(enemyPosition, true, AIM_JITTER_RADIANS * 0.5);
1439
+
1440
+ const input = this.player.input as PlayerInput;
1441
+
1442
+ if (distance > MELEE_ATTACK_RANGE || !this._hasLineOfSight(enemyPosition, distance)) {
1443
+ this._moveTowards(enemyPosition);
1444
+ return;
1445
+ }
1446
+
1447
+ input.ml = true;
1448
+ this._strafe(deltaTimeMs);
1449
+
1450
+ if (Math.random() < 0.05 && this.playerController.isGrounded) {
1451
+ input.sp = true;
1452
+ }
1453
+ }
1454
+ }
1455
+