@hytopia.com/examples 1.0.8 → 1.0.9
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/frontiers-rpg-game/assets/maps/weavers-hollow.json +5448 -5967
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/baseColor.png +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver-named-nodes.bin +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver-named-nodes.gltf +7586 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.bin +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.gltf +3838 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver/weaver.gltf.md5 +1 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/baseColor.png +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling-named-nodes.bin +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling-named-nodes.gltf +9726 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.bin +0 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.gltf +9478 -0
- package/frontiers-rpg-game/assets/models/enemies/.optimized/weaver-broodling/weaver-broodling.gltf.md5 +1 -0
- package/frontiers-rpg-game/assets/models/enemies/weaver-broodling.gltf +1 -0
- package/frontiers-rpg-game/assets/models/enemies/weaver.gltf +1 -0
- package/frontiers-rpg-game/src/GameClock.ts +1 -0
- package/frontiers-rpg-game/src/GameManager.ts +4 -5
- package/frontiers-rpg-game/src/GamePlayer.ts +18 -13
- package/frontiers-rpg-game/src/GamePlayerEntity.ts +8 -0
- package/frontiers-rpg-game/src/GameRegion.ts +28 -6
- package/frontiers-rpg-game/src/entities/BaseCombatEntity.ts +67 -22
- package/frontiers-rpg-game/src/entities/BaseEntity.ts +7 -2
- package/frontiers-rpg-game/src/entities/PortalEntity.ts +41 -13
- package/frontiers-rpg-game/src/entities/enemies/LesserBlightBloomEntity.ts +1 -2
- package/frontiers-rpg-game/src/entities/enemies/QueenWeaverEntity.ts +155 -0
- package/frontiers-rpg-game/src/entities/enemies/RatkinBruteEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/RatkinRangerEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/RatkinSpellcasterEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/RatkinWarriorEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinBruteEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinRangerEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinSpellcasterEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/TaintedRatkinWarriorEntity.ts +1 -1
- package/frontiers-rpg-game/src/entities/enemies/WeaverBroodlingEntity.ts +47 -0
- package/frontiers-rpg-game/src/entities/environmental/SpiderWebEntity.ts +34 -4
- package/frontiers-rpg-game/src/items/BaseWeaponItem.ts +1 -1
- package/frontiers-rpg-game/src/items/weapons/DullSwordItem.ts +0 -1
- package/frontiers-rpg-game/src/items/weapons/IronLongSwordItem.ts +0 -1
- package/frontiers-rpg-game/src/items/weapons/TrainingSwordItem.ts +0 -1
- package/frontiers-rpg-game/src/quests/main/WelcomeToStalkhavenQuest.ts +2 -1
- package/frontiers-rpg-game/src/regions/ratkin-nest/RatkinNestRegion.ts +31 -0
- package/frontiers-rpg-game/src/regions/stalkhaven-port/StalkhavenPortRegion.ts +1 -0
- package/frontiers-rpg-game/src/regions/weavers-hollow/WeaversHollowRegion.ts +67 -6
- package/frontiers-rpg-game/src/systems/Spawner.ts +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
296
|
-
if (this.moveSpeed > 0 && targetDistanceSquared >
|
|
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.
|
|
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
|
|
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
|
|
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 (!
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,13 +12,12 @@ 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' ],
|