@hytopia.com/examples 1.0.8 → 1.0.10

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.
Files changed (45) hide show
  1. package/frontiers-rpg-game/assets/maps/weavers-hollow.json +5448 -5967
  2. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/baseColor.png +0 -0
  3. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver-named-nodes.bin +0 -0
  4. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver-named-nodes.gltf +7586 -0
  5. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.bin +0 -0
  6. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.gltf +3838 -0
  7. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.gltf.md5 +1 -0
  8. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/baseColor.png +0 -0
  9. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling-named-nodes.bin +0 -0
  10. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling-named-nodes.gltf +9726 -0
  11. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.bin +0 -0
  12. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.gltf +9478 -0
  13. package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.gltf.md5 +1 -0
  14. package/frontiers-rpg-game/assets/models/enemies/weaver-broodling.gltf +1 -0
  15. package/frontiers-rpg-game/assets/models/enemies/weaver.gltf +1 -0
  16. package/frontiers-rpg-game/src/GameClock.ts +1 -0
  17. package/frontiers-rpg-game/src/GameManager.ts +4 -5
  18. package/frontiers-rpg-game/src/GamePlayer.ts +18 -13
  19. package/frontiers-rpg-game/src/GamePlayerEntity.ts +8 -0
  20. package/frontiers-rpg-game/src/GameRegion.ts +28 -6
  21. package/frontiers-rpg-game/src/entities/BaseCombatEntity.ts +67 -22
  22. package/frontiers-rpg-game/src/entities/BaseEntity.ts +7 -2
  23. package/frontiers-rpg-game/src/entities/PortalEntity.ts +41 -13
  24. package/frontiers-rpg-game/src/entities/enemies/LesserBlightBloomEntity.ts +3 -4
  25. package/frontiers-rpg-game/src/entities/enemies/QueenWeaverEntity.ts +155 -0
  26. package/frontiers-rpg-game/src/entities/enemies/RatkinBruteEntity.ts +4 -4
  27. package/frontiers-rpg-game/src/entities/enemies/RatkinRangerEntity.ts +3 -3
  28. package/frontiers-rpg-game/src/entities/enemies/RatkinSpellcasterEntity.ts +1 -1
  29. package/frontiers-rpg-game/src/entities/enemies/RatkinWarriorEntity.ts +3 -3
  30. package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinBruteEntity.ts +4 -4
  31. package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinRangerEntity.ts +1 -1
  32. package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinSpellcasterEntity.ts +1 -1
  33. package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinWarriorEntity.ts +3 -3
  34. package/frontiers-rpg-game/src/entities/enemies/WeaverBroodlingEntity.ts +47 -0
  35. package/frontiers-rpg-game/src/entities/environmental/SpiderWebEntity.ts +34 -4
  36. package/frontiers-rpg-game/src/items/BaseWeaponItem.ts +1 -1
  37. package/frontiers-rpg-game/src/items/weapons/DullSwordItem.ts +0 -1
  38. package/frontiers-rpg-game/src/items/weapons/IronLongSwordItem.ts +0 -1
  39. package/frontiers-rpg-game/src/items/weapons/TrainingSwordItem.ts +0 -1
  40. package/frontiers-rpg-game/src/quests/main/WelcomeToStalkhavenQuest.ts +2 -1
  41. package/frontiers-rpg-game/src/regions/ratkin-nest/RatkinNestRegion.ts +31 -0
  42. package/frontiers-rpg-game/src/regions/stalkhaven-port/StalkhavenPortRegion.ts +1 -0
  43. package/frontiers-rpg-game/src/regions/weavers-hollow/WeaversHollowRegion.ts +67 -6
  44. package/frontiers-rpg-game/src/systems/Spawner.ts +2 -2
  45. package/package.json +1 -1
@@ -29,6 +29,7 @@ export default class GameClock {
29
29
 
30
30
  public addRegionClockCycle(region: GameRegion): void {
31
31
  this._regions.add(region);
32
+ this._updateRegionClockCycle(region);
32
33
  }
33
34
 
34
35
  public removeRegionClockCycle(region: GameRegion): void {
@@ -10,7 +10,7 @@ import ChitterForestRegion from './regions/chitter-forest/ChitterForestRegion';
10
10
  import RatkinNestRegion from './regions/ratkin-nest/RatkinNestRegion';
11
11
  import StalkhavenRegion from './regions/stalkhaven/StalkhavenRegion';
12
12
  import StalkhavenPortRegion from './regions/stalkhaven-port/StalkhavenPortRegion';
13
- // import WeaversHollowRegion from './regions/weavers-hollow/WeaversHollowRegion';
13
+ import WeaversHollowRegion from './regions/weavers-hollow/WeaversHollowRegion';
14
14
 
15
15
  // Since globalEventRouter isn't exported in the main index, we'll handle cleanup differently
16
16
  // We can rely on the GamePlayer.remove() method being called manually when needed
@@ -63,10 +63,9 @@ export default class GameManager {
63
63
  this._startRegion = stalkhavenPortRegion;
64
64
 
65
65
  // Weaver's Hollow
66
- // const weaversHollowRegion = new WeaversHollowRegion();
67
- // this._regions.set(weaversHollowRegion.id, weaversHollowRegion);
68
- // // GameClock.instance.addRegionClockCycle(weaversHollowRegion);
69
- // this._startRegion = weaversHollowRegion;
66
+ const weaversHollowRegion = new WeaversHollowRegion();
67
+ this._regions.set(weaversHollowRegion.id, weaversHollowRegion);
68
+ GameClock.instance.addRegionClockCycle(weaversHollowRegion);
70
69
  }
71
70
 
72
71
  private _selectWorldForPlayer = async (player: Player): Promise<World | undefined> => {
@@ -28,12 +28,10 @@ import TrainingSwordItem from './items/weapons/TrainingSwordItem';
28
28
 
29
29
  export enum GamePlayerPlayerEvent {
30
30
  DIED = 'GamePlayer.DIED',
31
- RESPAWNED = 'GamePlayer.RESPAWNED',
32
31
  }
33
32
 
34
33
  export type GamePlayerPlayerEventPayloads = {
35
34
  [GamePlayerPlayerEvent.DIED]: null;
36
- [GamePlayerPlayerEvent.RESPAWNED]: null;
37
35
  }
38
36
 
39
37
  export type NotificationType = 'success' | 'error' | 'warning' | 'complete' | 'new';
@@ -317,6 +315,13 @@ export default class GamePlayer {
317
315
  return this._skillExperience.get(skillId) ?? 0;
318
316
  }
319
317
 
318
+ public joinRegion(region: GameRegion, facingAngle: number, spawnPoint: Vector3Like): void {
319
+ this.setCurrentRegion(region);
320
+ this.setCurrentRegionSpawnFacingAngle(facingAngle);
321
+ this.setCurrentRegionSpawnPoint(spawnPoint);
322
+ this.player.joinWorld(region.world);
323
+ }
324
+
320
325
  public async load(): Promise<void> {
321
326
  const serializedGamePlayerData = await this.player.getPersistedData();
322
327
 
@@ -351,17 +356,17 @@ export default class GamePlayer {
351
356
  // Restore health
352
357
  this.adjustHealth(this.maxHealth);
353
358
 
354
- // Teleport to spawn point if available
355
- this._currentEntity.setPosition(this.respawnPoint);
356
-
357
- // Re enable movement if it was disabled
358
- this._currentEntity.setIsMovementDisabled(false);
359
-
360
- // Show respawn notification
361
- this.showNotification('You have respawned!', 'success');
359
+ if (this._currentRegion?.respawnOverride) {
360
+ const region = GameManager.instance.getRegion(this._currentRegion.respawnOverride.regionId);
362
361
 
363
- // Emit event to GamePlayer to handle respawn
364
- this.eventRouter.emit(GamePlayerPlayerEvent.RESPAWNED, null);
362
+ if (region) {
363
+ this.joinRegion(region, this._currentRegion.respawnOverride.facingAngle, this._currentRegion.respawnOverride.spawnPoint);
364
+ }
365
+ } else { // Same region respawn,
366
+ this._currentEntity.setPosition(this.respawnPoint);
367
+ this._currentEntity.setIsMovementDisabled(false);
368
+ this.showNotification('You have respawned!', 'success');
369
+ }
365
370
  }
366
371
 
367
372
  public setCurrentDialogueEntity(entity: BaseEntity): void {
@@ -436,8 +441,8 @@ export default class GamePlayer {
436
441
 
437
442
  // Restore current region if available
438
443
  if (playerData.currentRegionId) {
439
-
440
444
  const region = GameManager.instance.getRegion(playerData.currentRegionId);
445
+
441
446
  if (region) {
442
447
  this._currentRegion = region;
443
448
  }
@@ -99,6 +99,10 @@ export default class GamePlayerEntity extends DefaultPlayerEntity implements IDa
99
99
  public get isDamaged(): boolean {
100
100
  return this._gamePlayer.health < this._gamePlayer.maxHealth;
101
101
  }
102
+
103
+ public get isDead(): boolean {
104
+ return this._gamePlayer.isDead;
105
+ }
102
106
 
103
107
  public get isDodging(): boolean {
104
108
  const timeSinceDodge = performance.now() - this._lastDodgeTimeMs;
@@ -142,6 +146,10 @@ export default class GamePlayerEntity extends DefaultPlayerEntity implements IDa
142
146
  return this._gamePlayer.getSkillExperience(skillId);
143
147
  }
144
148
 
149
+ public joinRegion(region: GameRegion, facingAngle: number, spawnPoint: Vector3Like): void {
150
+ this._gamePlayer.joinRegion(region, facingAngle, spawnPoint);
151
+ }
152
+
145
153
  public setCurrentDialogueEntity(entity: any): void {
146
154
  this._gamePlayer.setCurrentDialogueEntity(entity);
147
155
  }
@@ -30,6 +30,12 @@ export type GameRegionPlayerEventPayloads = {
30
30
  [GameRegionPlayerEvent.REACHED]: { regionId: string };
31
31
  }
32
32
 
33
+ export type GameRegionRespawnOverride = {
34
+ regionId: string,
35
+ facingAngle: number,
36
+ spawnPoint: Vector3Like,
37
+ }
38
+
33
39
  export type GameRegionOptions = {
34
40
  id: string,
35
41
  ambientAudioUri?: string,
@@ -38,6 +44,7 @@ export type GameRegionOptions = {
38
44
  maxDirectionalLightIntensity?: number,
39
45
  minAmbientLightIntensity?: number,
40
46
  minDirectionalLightIntensity?: number,
47
+ respawnOverride?: GameRegionRespawnOverride,
41
48
  spawnFacingAngle?: number,
42
49
  spawnPoint?: Vector3Like,
43
50
  } & Omit<WorldOptions, 'id'>;
@@ -50,7 +57,8 @@ export default class GameRegion {
50
57
  private _maxDirectionalLightIntensity: number;
51
58
  private _minAmbientLightIntensity: number;
52
59
  private _minDirectionalLightIntensity: number;
53
- private _outOfWorldCollider: Collider | undefined;
60
+ private _playerCount: number = 0;
61
+ private _respawnOverride: GameRegionRespawnOverride | undefined;
54
62
  private _spawnFacingAngle: number;
55
63
  private _spawnPoint: Vector3Like;
56
64
 
@@ -70,10 +78,12 @@ export default class GameRegion {
70
78
  this._maxDirectionalLightIntensity = regionOptions.maxDirectionalLightIntensity ?? DEFAULT_MAX_DIRECTIONAL_LIGHT_INTENSITY;
71
79
  this._minAmbientLightIntensity = regionOptions.minAmbientLightIntensity ?? DEFAULT_MIN_AMBIENT_LIGHT_INTENSITY;
72
80
  this._minDirectionalLightIntensity = regionOptions.minDirectionalLightIntensity ?? DEFAULT_MIN_DIRECTIONAL_LIGHT_INTENSITY;
81
+ this._respawnOverride = regionOptions.respawnOverride;
73
82
  this._spawnFacingAngle = regionOptions.spawnFacingAngle ?? 0;
74
83
  this._spawnPoint = regionOptions.spawnPoint ?? { x: 0, y: 10, z: 0 };
75
84
 
76
85
  this._world = WorldManager.instance.createWorld(regionOptions);
86
+ this._world.stop(); // Keep it in stopped state, when a player joins the world, we'll start it.
77
87
  this._world.on(PlayerEvent.JOINED_WORLD, ({ player }) => this.onPlayerJoin(player));
78
88
  this._world.on(PlayerEvent.LEFT_WORLD, ({ player }) => this.onPlayerLeave(player));
79
89
 
@@ -90,6 +100,7 @@ export default class GameRegion {
90
100
  public get minAmbientLightIntensity(): number { return this._minAmbientLightIntensity; }
91
101
  public get minDirectionalLightIntensity(): number { return this._minDirectionalLightIntensity; }
92
102
  public get name(): string { return this._world.name; }
103
+ public get respawnOverride(): GameRegionRespawnOverride | undefined { return this._respawnOverride; }
93
104
  public get spawnFacingAngle(): number { return this._spawnFacingAngle; }
94
105
  public get spawnPoint(): Vector3Like { return this._spawnPoint; }
95
106
  public get world(): World { return this._world; }
@@ -103,13 +114,13 @@ export default class GameRegion {
103
114
  this._ambientAudio.play(this._world);
104
115
  }
105
116
 
106
- this._outOfWorldCollider = new Collider({
117
+ new Collider({ // Out of world collider
107
118
  shape: ColliderShape.BLOCK,
108
119
  halfExtents: { x: 500, y : 32, z: 500 },
109
120
  isSensor: true,
110
121
  relativePosition: { x: 0, y: -64, z: 0 },
111
122
  onCollision: this.onEntityOutOfWorld,
112
- simulation: this._world.simulation, // setting this auto adds collider to simulation upon creation.
123
+ simulation: this._world.simulation, // setting this auto adds collider to simulation upon instantiation.
113
124
  });
114
125
 
115
126
  this._isSetup = true;
@@ -157,14 +168,25 @@ export default class GameRegion {
157
168
 
158
169
  // Emit the reached event to the player's event router.
159
170
  gamePlayer.eventRouter.emit(GameRegionPlayerEvent.REACHED, { regionId: this._id });
171
+
172
+ this._playerCount++;
173
+
174
+ // Only run the region physics & ticking if a player is in the region.
175
+ if (this._playerCount === 1) {
176
+ this._world.start();
177
+ }
160
178
  }
161
179
 
162
180
  protected onPlayerLeave(player: Player) {
163
181
  this._world.entityManager.getPlayerEntitiesByPlayer(player).forEach(entity => {
164
182
  entity.despawn();
165
183
  });
166
-
167
- // Note: We don't remove the GamePlayer instance here since the player
168
- // might move to another region and we want to preserve their state
184
+
185
+ this._playerCount--;
186
+
187
+ // Stop the region physics & ticking if no players are in the region.
188
+ if (this._playerCount <= 0) {
189
+ this._world.stop();
190
+ }
169
191
  }
170
192
  }
@@ -7,6 +7,7 @@ import {
7
7
  ErrorHandler,
8
8
  EventPayloads,
9
9
  QuaternionLike,
10
+ RawShape,
10
11
  Vector3,
11
12
  Vector3Like,
12
13
  World,
@@ -33,6 +34,7 @@ export type BaseCombatEntityAttack = {
33
34
  simpleAttackDamageVariance?: number; // Percentage variance (0-1), e.g., 0.2 = ±20% damage
34
35
  simpleAttackDamageDelayMs?: number; // When during animation to deal damage (if projectile, delay after hit)
35
36
  simpleAttackReach?: number; // Applies damage if target is < reach after delay
37
+ stopMovingDuringDelay?: boolean;
36
38
  weight: number;
37
39
  onHit?: (target: BaseEntity | GamePlayerEntity, attacker: BaseCombatEntity) => void;
38
40
  }
@@ -69,7 +71,9 @@ export default class BaseCombatEntity extends BaseEntity {
69
71
  private _attackAccumulatorMs: number = 0;
70
72
  private _attackCooldownMs: number = 0;
71
73
  private _attackTotalWeight: number = 0;
74
+ private _diameterSquared: number = 0;
72
75
  private _nextAttack: BaseCombatEntityAttack | null = null;
76
+ private _stopMoving: boolean = false;
73
77
 
74
78
  constructor(options: BaseCombatEntityOptions) {
75
79
  super({
@@ -84,7 +88,7 @@ export default class BaseCombatEntity extends BaseEntity {
84
88
  this._aggroSensorForwardOffset = options.aggroSensorForwardOffset ?? 0;
85
89
  this._attacks = options.attacks ?? [];
86
90
  this._attackTotalWeight = this._attacks.reduce((sum, attack) => sum + attack.weight, 0);
87
-
91
+ this._diameterSquared = this.width > this.depth ? this.width ** 2 : this.depth ** 2;
88
92
  this._nextAttack = this._pickRandomAttack();
89
93
 
90
94
  // Set accumulator to interval to trigger immediate target check on first tick
@@ -106,6 +110,8 @@ export default class BaseCombatEntity extends BaseEntity {
106
110
  this.on(EntityEvent.TICK, this._onTick);
107
111
  }
108
112
 
113
+ public get diameterSquared(): number { return this._diameterSquared; }
114
+
109
115
  public attack() {
110
116
  if (!this._nextAttack || !this._aggroActiveTarget) return;
111
117
 
@@ -113,19 +119,24 @@ export default class BaseCombatEntity extends BaseEntity {
113
119
  const target = this._aggroActiveTarget;
114
120
 
115
121
  this.startModelOneshotAnimations(attack.animations);
122
+
123
+ if (attack.stopMovingDuringDelay) {
124
+ this.stopMoving();
125
+ this._stopMoving = true;
126
+ }
116
127
 
117
128
  if (!attack.complexAttack) { // Simple attack, animation + damage
118
129
  setTimeout(() => {
119
- if (!target || !this._aggroPotentialTargets.has(target) || this.isDying) return;
130
+ if (!target || !this._aggroPotentialTargets.has(target) || this.isDead) return;
120
131
 
121
132
  if (!attack.simpleAttackDamage) {
122
133
  return ErrorHandler.warning(`BaseCombatEntity.attack(): Simple attack has no simple attack damage!`);
123
134
  };
124
135
 
125
- const distanceSquared = this._distanceSquaredToTarget(target);
136
+ const distanceSquared = this.calculateDistanceSquaredToTarget(target);
126
137
  const reachSquared = attack.simpleAttackReach ? attack.simpleAttackReach ** 2 : attack.range ** 2;
127
138
 
128
- if (distanceSquared <= reachSquared) { // make sure target is in reach still
139
+ if (distanceSquared <= reachSquared + this._diameterSquared) { // make sure target is in reach still
129
140
  if (isDamageable(target)) {
130
141
  const damage = this.calculateDamageWithVariance(attack.simpleAttackDamage, attack.simpleAttackDamageVariance);
131
142
  target.takeDamage(damage, this);
@@ -135,15 +146,19 @@ export default class BaseCombatEntity extends BaseEntity {
135
146
  attack.onHit(target, this);
136
147
  }
137
148
  }
149
+
150
+ this._stopMoving = false;
138
151
  }, attack.simpleAttackDamageDelayMs ?? 0);
139
152
  } else { // Complex attack, such as projectile, spell, AoE, etc
140
153
  setTimeout(() => {
141
- if (this.isDying || !attack.complexAttack || !this.world) return;
154
+ if (this.isDead || !attack.complexAttack || !this.world) return;
142
155
 
143
156
  attack.complexAttack({
144
157
  attacker: this,
145
158
  target: target,
146
159
  });
160
+
161
+ this._stopMoving = false;
147
162
  }, attack.complexAttackDelayMs ?? 0);
148
163
  }
149
164
 
@@ -168,6 +183,26 @@ export default class BaseCombatEntity extends BaseEntity {
168
183
  return { x: target.x, y: target.y, z: target.z };
169
184
  }
170
185
 
186
+ public calculateDistanceSquaredToTarget(target: BaseEntity | GamePlayerEntity): number {
187
+ return this._distanceSquaredBetweenPositions(this.position, target.position);
188
+ }
189
+
190
+ public getTargetsByRawShapeIntersection(rawShape: RawShape, position: Vector3Like, rotation: QuaternionLike): Entity[] {
191
+ if (!this.world) return [];
192
+
193
+ const intersectionsResults = this.world.simulation.intersectionsWithRawShape(
194
+ rawShape,
195
+ position,
196
+ rotation,
197
+ {
198
+ filterExcludeRigidBody: this.rawRigidBody, // ignore self (parent/player)
199
+ filterFlags: 8, // Rapier flag to exclude sensor colliders
200
+ },
201
+ );
202
+
203
+ return intersectionsResults.map(result => result.intersectedEntity).filter(Boolean) as Entity[];
204
+ }
205
+
171
206
  public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
172
207
  super.spawn(world, position, rotation);
173
208
 
@@ -214,16 +249,14 @@ export default class BaseCombatEntity extends BaseEntity {
214
249
  return dx * dx + dy * dy + dz * dz;
215
250
  }
216
251
 
217
- private _distanceSquaredToTarget(target: BaseEntity | GamePlayerEntity): number {
218
- return this._distanceSquaredBetweenPositions(this.position, target.position);
219
- }
220
-
221
252
  private _findClosestAggroTarget(): BaseEntity | GamePlayerEntity | null {
222
253
  let closestTarget: BaseEntity | GamePlayerEntity | null = null;
223
254
  let closestDistanceSquared = Infinity;
224
255
 
225
256
  for (const target of this._aggroPotentialTargets) {
226
- const distanceSquared = this._distanceSquaredToTarget(target);
257
+ if (target.isDead) continue;
258
+
259
+ const distanceSquared = this.calculateDistanceSquaredToTarget(target);
227
260
 
228
261
  if (distanceSquared < closestDistanceSquared) {
229
262
  closestDistanceSquared = distanceSquared;
@@ -248,7 +281,7 @@ export default class BaseCombatEntity extends BaseEntity {
248
281
 
249
282
  private _hasAttackInRange(targetDistanceSquared: number): boolean {
250
283
  for (const attack of this._attacks) {
251
- if (targetDistanceSquared <= attack.range ** 2) {
284
+ if (targetDistanceSquared <= attack.range ** 2 + this._diameterSquared) {
252
285
  return true;
253
286
  }
254
287
  }
@@ -269,16 +302,20 @@ export default class BaseCombatEntity extends BaseEntity {
269
302
 
270
303
  if (!this._aggroActiveTarget) return;
271
304
 
272
- const targetDistanceSquared = this._distanceSquaredToTarget(this._aggroActiveTarget);
305
+ const targetDistanceSquared = this.calculateDistanceSquaredToTarget(this._aggroActiveTarget);
306
+
307
+ if (targetDistanceSquared < this._diameterSquared) {
308
+ this.stopMoving(); // Don't push into target when in contact range.
309
+ }
273
310
 
274
311
  // Handle attacks if available
275
312
  if (this._nextAttack) {
276
313
  const attackRangeSquared = this._nextAttack.range ** 2;
277
314
 
278
- if (targetDistanceSquared <= attackRangeSquared && this._attackAccumulatorMs >= this._attackCooldownMs) {
315
+ if (targetDistanceSquared <= attackRangeSquared + this._diameterSquared && this._attackAccumulatorMs >= this._attackCooldownMs) {
279
316
  this._attackAccumulatorMs = 0;
280
317
  this.attack();
281
- } else if (targetDistanceSquared > attackRangeSquared) {
318
+ } else if (targetDistanceSquared > attackRangeSquared + this._diameterSquared) {
282
319
  // Target is out of current attack range, only repick if there's an attack that can reach the target
283
320
  if (this._hasAttackInRange(targetDistanceSquared)) {
284
321
  this._attackAccumulatorMs = Math.max(0, this._attackAccumulatorMs - 500); // Prevent too-frequent repicking every tick
@@ -286,20 +323,24 @@ export default class BaseCombatEntity extends BaseEntity {
286
323
  }
287
324
  }
288
325
 
326
+ if (this._stopMoving) {
327
+ return;
328
+ }
329
+
289
330
  // Update movement strategy
290
331
  if (this.moveSpeed > 0 && this._aggroPathfindAccumulatorMs >= this._aggroPathfindIntervalMs) {
291
332
  this._updateMovementStrategy(targetDistanceSquared);
292
333
  }
293
334
 
294
335
  if (!this._aggroPathfinding) {
295
- // Only move if not within attack range and can move, but always face the target
296
- if (this.moveSpeed > 0 && targetDistanceSquared > attackRangeSquared) {
336
+ // Only move if not within contact range and can move, but always face the target
337
+ if (this.moveSpeed > 0 && targetDistanceSquared > this._diameterSquared + 2) {
297
338
  this.moveTo(this._aggroActiveTarget.position);
298
339
  }
299
340
 
300
341
  this.faceTowards(this._aggroActiveTarget.position, this.faceSpeed);
301
342
  }
302
- } else {
343
+ } else if (!this._stopMoving) {
303
344
  // No attacks - just follow the target
304
345
  if (this.moveSpeed > 0 && this._aggroPathfindAccumulatorMs >= this._aggroPathfindIntervalMs) {
305
346
  this._updateMovementStrategy(targetDistanceSquared);
@@ -340,7 +381,7 @@ export default class BaseCombatEntity extends BaseEntity {
340
381
  if (!this._aggroActiveTarget) return true;
341
382
  if (!this._aggroPathfinding) return true; // Always switch when using simple movement
342
383
 
343
- return this._distanceSquaredToTarget(newTarget) * 2 < this._distanceSquaredToTarget(this._aggroActiveTarget);
384
+ return this.calculateDistanceSquaredToTarget(newTarget) * 2 < this.calculateDistanceSquaredToTarget(this._aggroActiveTarget);
344
385
  }
345
386
 
346
387
  private _updateMovementStrategy(targetDistanceSquared: number): void {
@@ -354,13 +395,17 @@ export default class BaseCombatEntity extends BaseEntity {
354
395
 
355
396
  const distanceMovedSquared = this._distanceSquaredBetweenPositions(this._aggroPathfindLastPosition, this.position);
356
397
  const isStuck = distanceMovedSquared < MOVEMENT_NOT_STUCK_DISTANCE_SQUARED;
357
- const notAtDestination = this._nextAttack ? targetDistanceSquared > this._nextAttack.range ** 2 : false;
398
+ const notAtDestination = this._nextAttack ? targetDistanceSquared > this._nextAttack.range ** 2 + this._diameterSquared : false;
358
399
  const shouldPathfind = isStuck && notAtDestination;
359
400
 
360
401
  if (shouldPathfind !== this._aggroPathfinding) {
361
402
  this._aggroPathfinding = shouldPathfind;
362
403
  if (shouldPathfind && this._aggroActiveTarget) {
363
- this.pathfindTo(this._aggroActiveTarget.position, this.moveSpeed);
404
+ this.pathfindTo(this._aggroActiveTarget.position, this.moveSpeed, {
405
+ ...this.pathfindingOptions,
406
+ pathfindAbortCallback: () => this._aggroPathfinding = false,
407
+ pathfindCompleteCallback: () => this._aggroPathfinding = false,
408
+ });
364
409
  }
365
410
  }
366
411
 
@@ -372,9 +417,9 @@ export default class BaseCombatEntity extends BaseEntity {
372
417
  if (this._aggroPotentialTargets.size > 0) {
373
418
  this._aggroRetargetAccumulatorMs = 0;
374
419
  }
375
-
420
+
376
421
  // Handle lost target
377
- if (this._aggroActiveTarget && !this._aggroPotentialTargets.has(this._aggroActiveTarget)) {
422
+ if (this._aggroActiveTarget && (!this._aggroPotentialTargets.has(this._aggroActiveTarget) || this._aggroActiveTarget.isDead)) {
378
423
  this._handleLostTarget();
379
424
  }
380
425
 
@@ -159,13 +159,14 @@ export default class BaseEntity extends Entity implements IInteractable, IDamage
159
159
  public get idleAnimations(): string[] { return this.pathfindingController.idleLoopedAnimations; }
160
160
  public get idleAnimationsSpeed(): number | undefined { return this.pathfindingController.idleLoopedAnimationsSpeed; }
161
161
  public get interactActionText(): string | undefined { return this._interactActionText; }
162
- public get isDying(): boolean { return this._dying; }
162
+ public get isDead(): boolean { return this._dying; }
163
163
  public get isInteractable(): boolean { return !!this._dialogueRoot || !!this._interactActionText; }
164
164
  public get health(): number { return this._health; }
165
165
  public get maxHealth(): number { return this._maxHealth; }
166
166
  public get moveAnimations(): string[] { return this.pathfindingController.moveLoopedAnimations; }
167
167
  public get moveAnimationsSpeed(): number | undefined { return this.pathfindingController.moveLoopedAnimationsSpeed; }
168
168
  public get moveSpeed(): number { return this._moveSpeed; }
169
+ public get pathfindingOptions(): PathfindingOptions | undefined { return this._pathfindingOptions; }
169
170
  public get pathfindingController(): PathfindingEntityController { return this.controller as PathfindingEntityController; }
170
171
  public get pushable(): boolean { return this._pushable; }
171
172
 
@@ -175,6 +176,7 @@ export default class BaseEntity extends Entity implements IInteractable, IDamage
175
176
  this._dying = true;
176
177
 
177
178
  this.startModelOneshotAnimations(this._deathAnimations);
179
+ this.stopFacing();
178
180
  this.stopMoving();
179
181
  this.dropItems();
180
182
 
@@ -285,8 +287,11 @@ export default class BaseEntity extends Entity implements IInteractable, IDamage
285
287
  });
286
288
  }
287
289
 
288
- public stopMoving() {
290
+ public stopFacing() {
289
291
  this.pathfindingController.stopFace();
292
+ }
293
+
294
+ public stopMoving() {
290
295
  this.pathfindingController.stopMove();
291
296
  }
292
297
 
@@ -5,7 +5,6 @@ import {
5
5
  Entity,
6
6
  ErrorHandler,
7
7
  ModelEntityOptions,
8
- QuaternionLike,
9
8
  RigidBodyType,
10
9
  Vector3Like
11
10
  } from 'hytopia';
@@ -14,15 +13,19 @@ import GameManager from '../GameManager';
14
13
  import GamePlayerEntity from '../GamePlayerEntity';
15
14
 
16
15
  export type PortalEntityOptions = {
16
+ delayS?: number;
17
17
  destinationRegionId: string;
18
18
  destinationRegionFacingAngle?: number;
19
19
  destinationRegionPosition: Vector3Like;
20
+ type?: 'normal' | 'boss';
20
21
  } & ModelEntityOptions;
21
22
 
22
23
  export default class PortalEntity extends Entity {
24
+ public readonly delayS: number;
23
25
  public readonly destinationRegionId: string;
24
26
  public readonly destinationRegionFacingAngle: number;
25
27
  public readonly destinationRegionPosition: Vector3Like;
28
+ private readonly _playerTimeouts = new Map<GamePlayerEntity, NodeJS.Timeout>();
26
29
 
27
30
  public constructor(options: PortalEntityOptions) {
28
31
  const colliderOptions = Collider.optionsFromModelUri('models/environment/portal.gltf', options.modelScale ?? 1, ColliderShape.BLOCK) as BlockColliderOptions;
@@ -37,30 +40,55 @@ export default class PortalEntity extends Entity {
37
40
  colliders: [
38
41
  {
39
42
  ...colliderOptions,
40
- relativePosition: { x: 0, y: 0, z: 0.5 }, // inset the sensor a bit
41
43
  isSensor: true,
42
44
  onCollision: (other, started) => {
43
- if (!started || !(other instanceof GamePlayerEntity)) return;
44
-
45
- const destinationRegion = GameManager.instance.getRegion(this.destinationRegionId);
46
-
47
- if (!destinationRegion) {
48
- ErrorHandler.warning(`PortalEntity: Destination region ${this.destinationRegionId} not found`);
49
- return;
45
+ if (!(other instanceof GamePlayerEntity)) return;
46
+
47
+ if (started) {
48
+ if (this.delayS > 0) {
49
+ other.showNotification(`This is a delayed portal! You'll be teleported in ${this.delayS} seconds. You must stay in the portal area.`, 'warning');
50
+ const timeout = setTimeout(() => this._teleportPlayer(other), this.delayS * 1000);
51
+ this._playerTimeouts.set(other, timeout);
52
+ } else {
53
+ this._teleportPlayer(other);
54
+ }
55
+ } else {
56
+ const timeout = this._playerTimeouts.get(other);
57
+
58
+ if (timeout) {
59
+ clearTimeout(timeout);
60
+ this._playerTimeouts.delete(other);
61
+ other.showNotification('You exited the delayed portal. Please re-enter the portal again to be teleported.', 'warning');
62
+ }
50
63
  }
51
- other.gamePlayer.setCurrentRegion(destinationRegion);
52
- other.gamePlayer.setCurrentRegionSpawnFacingAngle(this.destinationRegionFacingAngle);
53
- other.gamePlayer.setCurrentRegionSpawnPoint(this.destinationRegionPosition);
54
- other.player.joinWorld(destinationRegion.world);
55
64
  },
56
65
  },
57
66
  ],
58
67
  },
68
+ tintColor: options.type === 'boss' ? { r: 255, g: 255, b: 0 } : undefined,
59
69
  ...options,
60
70
  });
61
71
 
72
+ this.delayS = options.delayS ?? 0;
62
73
  this.destinationRegionId = options.destinationRegionId;
63
74
  this.destinationRegionFacingAngle = options.destinationRegionFacingAngle ?? 0;
64
75
  this.destinationRegionPosition = options.destinationRegionPosition;
65
76
  }
77
+
78
+ private _teleportPlayer(player: GamePlayerEntity): void {
79
+ const destinationRegion = GameManager.instance.getRegion(this.destinationRegionId);
80
+
81
+ if (!destinationRegion) {
82
+ ErrorHandler.warning(`PortalEntity: Destination region ${this.destinationRegionId} not found`);
83
+ return;
84
+ }
85
+
86
+ if (player.isDead) {
87
+ return;
88
+ }
89
+
90
+ player.joinRegion(destinationRegion, this.destinationRegionFacingAngle, this.destinationRegionPosition);
91
+
92
+ this._playerTimeouts.delete(player);
93
+ }
66
94
  }
@@ -12,20 +12,19 @@ import GoldItem from '../../items/general/GoldItem';
12
12
 
13
13
  export type LesserBlightBloomEntityOptions = {
14
14
 
15
- } & BaseCombatEntityOptions;
15
+ } & Partial<BaseCombatEntityOptions>;
16
16
 
17
17
  export default class LesserBlightBloomEntity extends BaseCombatEntity {
18
18
  constructor(options?: LesserBlightBloomEntityOptions) {
19
19
  super({
20
20
  aggroRadius: 8,
21
- aggroSensorForwardOffset: 0,
22
21
  attacks: [
23
22
  { // Eat
24
23
  animations: [ 'eat' ],
25
24
  complexAttack: () => this._eatTarget(),
26
25
  complexAttackDelayMs: 750,
27
26
  cooldownMs: 4000,
28
- range: 4,
27
+ range: 2.5,
29
28
  weight: 4,
30
29
  },
31
30
  { // AoEGas
@@ -33,7 +32,7 @@ export default class LesserBlightBloomEntity extends BaseCombatEntity {
33
32
  complexAttack: () => this._emitGasAoE(25, 4),
34
33
  complexAttackDelayMs: 2500,
35
34
  cooldownMs: 4000,
36
- range: 10,
35
+ range: 8,
37
36
  weight: 2,
38
37
  },
39
38
  { // Spray 3 wide gas projectiles