hytopia 0.1.98 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/docs/server.entity.height.md +13 -0
  2. package/docs/server.entity.md +22 -1
  3. package/docs/server.entity.opacity.md +1 -1
  4. package/docs/server.modelregistry.getheight.md +55 -0
  5. package/docs/server.modelregistry.md +14 -0
  6. package/docs/server.player.md +21 -0
  7. package/docs/server.player.profilepictureurl.md +13 -0
  8. package/examples/pathfinding/index.ts +4 -4
  9. package/examples/payload-game/index.ts +12 -10
  10. package/examples/zombies-fps/README.md +5 -0
  11. package/examples/zombies-fps/assets/audio/music/bg.mp3 +0 -0
  12. package/examples/zombies-fps/assets/audio/sfx/pistol-reload.mp3 +0 -0
  13. package/examples/zombies-fps/assets/audio/sfx/pistol-shoot.mp3 +0 -0
  14. package/examples/zombies-fps/assets/audio/sfx/player-hurt.mp3 +0 -0
  15. package/examples/zombies-fps/assets/audio/sfx/purchase.mp3 +0 -0
  16. package/examples/zombies-fps/assets/audio/sfx/rifle-reload.mp3 +0 -0
  17. package/examples/zombies-fps/assets/audio/sfx/rifle-shoot.mp3 +0 -0
  18. package/examples/zombies-fps/assets/audio/sfx/ripper-idle.mp3 +0 -0
  19. package/examples/zombies-fps/assets/audio/sfx/roulette.mp3 +0 -0
  20. package/examples/zombies-fps/assets/audio/sfx/shotgun-reload.mp3 +0 -0
  21. package/examples/zombies-fps/assets/audio/sfx/shotgun-shoot.mp3 +0 -0
  22. package/examples/zombies-fps/assets/audio/sfx/wave-start.mp3 +0 -0
  23. package/examples/zombies-fps/assets/audio/sfx/zombie-idle.mp3 +0 -0
  24. package/examples/zombies-fps/assets/icons/ak-47.png +0 -0
  25. package/examples/zombies-fps/assets/icons/ar-15.png +0 -0
  26. package/examples/zombies-fps/assets/icons/auto-pistol.png +0 -0
  27. package/examples/zombies-fps/assets/icons/auto-shotgun.png +0 -0
  28. package/examples/zombies-fps/assets/icons/heart.png +0 -0
  29. package/examples/zombies-fps/assets/icons/pistol.png +0 -0
  30. package/examples/zombies-fps/assets/icons/shotgun.png +0 -0
  31. package/examples/zombies-fps/assets/models/environment/bombbox.gltf +1 -0
  32. package/examples/zombies-fps/assets/models/environment/bullet-hole.gltf +1 -0
  33. package/examples/zombies-fps/assets/models/environment/healthkit.gltf +1 -0
  34. package/examples/zombies-fps/assets/models/environment/muzzle-flash.gltf +1 -0
  35. package/examples/zombies-fps/assets/models/items/ak-47.glb +0 -0
  36. package/examples/zombies-fps/assets/models/items/ar-15.glb +0 -0
  37. package/examples/zombies-fps/assets/models/items/auto-pistol.glb +0 -0
  38. package/examples/zombies-fps/assets/models/items/auto-shotgun.glb +0 -0
  39. package/examples/zombies-fps/assets/models/items/shotgun.glb +0 -0
  40. package/examples/zombies-fps/assets/models/npcs/ripper-boss.gltf +1 -0
  41. package/examples/zombies-fps/assets/models/players/soldier-player.gltf +1 -1
  42. package/examples/zombies-fps/assets/models/projectiles/bullet-trace.gltf +1 -0
  43. package/examples/zombies-fps/assets/ui/index.html +620 -27
  44. package/examples/zombies-fps/classes/EnemyEntity.ts +183 -4
  45. package/examples/zombies-fps/classes/GameManager.ts +165 -0
  46. package/examples/zombies-fps/classes/GamePlayerEntity.ts +263 -14
  47. package/examples/zombies-fps/classes/GunEntity.ts +225 -13
  48. package/examples/zombies-fps/classes/InteractableEntity.ts +9 -0
  49. package/examples/zombies-fps/classes/PurchaseBarrierEntity.ts +70 -17
  50. package/examples/zombies-fps/classes/WeaponCrateEntity.ts +173 -0
  51. package/examples/zombies-fps/classes/enemies/RipperEntity.ts +67 -0
  52. package/examples/zombies-fps/classes/enemies/ZombieEntity.ts +30 -0
  53. package/examples/zombies-fps/classes/guns/AK47Entity.ts +43 -0
  54. package/examples/zombies-fps/classes/guns/AR15Entity.ts +32 -0
  55. package/examples/zombies-fps/classes/guns/AutoPistolEntity.ts +36 -0
  56. package/examples/zombies-fps/classes/guns/AutoShotgunEntity.ts +37 -0
  57. package/examples/zombies-fps/classes/guns/PistolEntity.ts +23 -15
  58. package/examples/zombies-fps/classes/guns/ShotgunEntity.ts +92 -0
  59. package/examples/zombies-fps/gameConfig.ts +125 -21
  60. package/examples/zombies-fps/index.ts +14 -31
  61. package/package.json +1 -1
  62. package/server.api.json +126 -18
  63. package/server.d.ts +17 -5
  64. package/server.js +98 -90
  65. package/tsdoc-metadata.json +1 -1
  66. package/examples/zombies-fps/assets/audio/sfx/pistol-shoot-1.mp3 +0 -0
  67. package/examples/zombies-fps/assets/audio/sfx/pistol-shoot-2.mp3 +0 -0
  68. package/examples/zombies-fps/classes/guns/BulletEntity.ts +0 -0
@@ -1,27 +1,53 @@
1
1
  import {
2
+ Audio,
3
+ CollisionGroup,
4
+ CollisionGroupsBuilder,
2
5
  Entity,
3
6
  EntityOptions,
7
+ PlayerEntity,
8
+ Vector3Like,
9
+ QuaternionLike,
10
+ World,
11
+ PlayerEntityController,
4
12
  } from 'hytopia';
5
13
 
14
+ import EnemyEntity from './EnemyEntity';
15
+ import type GamePlayerEntity from './GamePlayerEntity';
16
+
6
17
  export type GunHand = 'left' | 'right' | 'both';
7
18
 
8
19
  export interface GunEntityOptions extends EntityOptions {
9
- ammo: number; // The amount of ammo in the clip.
10
- damage: number; // The damage of the gun.
11
- fireRate: number; // Bullets shot per second.
12
- reloadTime: number; // Seconds to reload.
13
- hand: GunHand; // The hand the weapon is held in.
14
- maxAmmo: number; // The amount of ammo the clip can hold.
20
+ ammo: number; // The amount of ammo in the clip.
21
+ damage: number; // The damage of the gun.
22
+ fireRate: number; // Bullets shot per second.
23
+ hand: GunHand; // The hand the weapon is held in.
24
+ iconImageUri: string; // The image uri of the weapon icon.
25
+ idleAnimation: string; // The animation played when the gun is idle.
26
+ maxAmmo: number; // The amount of ammo the clip can hold.
27
+ parent?: GamePlayerEntity; // The parent player entity.
28
+ range: number; // The max range bullets travel for raycast hits
29
+ reloadAudioUri: string; // The audio played when reloading
30
+ reloadTimeMs: number; // Seconds to reload.
31
+ shootAnimation: string; // The animation played when the gun is shooting.
32
+ shootAudioUri: string; // The audio played when shooting
15
33
  }
16
34
 
17
- export default class GunEntity extends Entity {
35
+ export default abstract class GunEntity extends Entity {
18
36
  public ammo: number;
19
37
  public damage: number;
20
38
  public fireRate: number;
21
39
  public hand: GunHand;
40
+ public iconImageUri: string;
41
+ public idleAnimation: string;
22
42
  public maxAmmo: number;
23
- public reloadTime: number;
43
+ public range: number;
44
+ public reloadTimeMs: number;
45
+ public shootAnimation: string;
24
46
  private _lastFireTime: number = 0;
47
+ private _muzzleFlashChildEntity: Entity | undefined;
48
+ private _reloadAudio: Audio;
49
+ private _reloading: boolean = false;
50
+ private _shootAudio: Audio;
25
51
 
26
52
  public constructor(options: GunEntityOptions) {
27
53
  super({
@@ -30,12 +56,76 @@ export default class GunEntity extends Entity {
30
56
  parentNodeName: options.parent ? GunEntity._getParentNodeName(options.hand) : undefined,
31
57
  });
32
58
 
33
- this.fireRate = options.fireRate;
34
- this.damage = options.damage;
35
59
  this.ammo = options.ammo;
60
+ this.damage = options.damage;
61
+ this.fireRate = options.fireRate;
36
62
  this.hand = options.hand;
63
+ this.iconImageUri = options.iconImageUri;
64
+ this.idleAnimation = options.idleAnimation;
37
65
  this.maxAmmo = options.maxAmmo;
38
- this.reloadTime = options.reloadTime;
66
+ this.range = options.range;
67
+ this.reloadTimeMs = options.reloadTimeMs;
68
+ this.shootAnimation = options.shootAnimation;
69
+
70
+ // Create reusable audio instances
71
+ this._reloadAudio = new Audio({
72
+ attachedToEntity: this,
73
+ uri: options.reloadAudioUri,
74
+ });
75
+
76
+ this._shootAudio = new Audio({
77
+ attachedToEntity: this,
78
+ uri: options.shootAudioUri,
79
+ volume: 0.3,
80
+ referenceDistance: 8,
81
+ });
82
+
83
+ if (options.parent) {
84
+ this._updateParentAnimations();
85
+ }
86
+ }
87
+
88
+ public get isEquipped(): boolean { return !!this.parent; }
89
+
90
+ public override spawn(world: World, position: Vector3Like, rotation: QuaternionLike) {
91
+ super.spawn(world, position, rotation);
92
+ this.createMuzzleFlashChildEntity();
93
+ this._updatePlayerUIAmmo();
94
+ this._updatePlayerUIWeapon();
95
+ }
96
+
97
+ public createMuzzleFlashChildEntity() {
98
+ if (!this.isSpawned || !this.world) {
99
+ return;
100
+ }
101
+
102
+ this._muzzleFlashChildEntity = new Entity({
103
+ parent: this,
104
+ modelUri: 'models/environment/muzzle-flash.gltf',
105
+ modelScale: 0.5,
106
+ opacity: 0,
107
+ });
108
+
109
+ // pistol specific atm
110
+ const { position, rotation } = this.getMuzzleFlashPositionRotation();
111
+ this._muzzleFlashChildEntity.spawn(this.world, position, rotation);
112
+ }
113
+
114
+ public abstract getMuzzleFlashPositionRotation(): { position: Vector3Like, rotation: QuaternionLike };
115
+
116
+ public getShootOriginDirection(): { origin: Vector3Like, direction: Vector3Like } {
117
+ const parentPlayerEntity = this.parent as GamePlayerEntity;
118
+
119
+ const { x, y, z } = parentPlayerEntity.position;
120
+ const cameraYOffset = parentPlayerEntity.player.camera.offset.y;
121
+ const direction = parentPlayerEntity.player.camera.facingDirection;
122
+ const origin = {
123
+ x: x + (direction.x * 0.5),
124
+ y: y + (direction.y * 0.5) + cameraYOffset,
125
+ z: z + (direction.z * 0.5),
126
+ };
127
+
128
+ return { origin, direction };
39
129
  }
40
130
 
41
131
  // simple convenience helper for handling ammo and fire rate in shoot() overrides.
@@ -47,7 +137,8 @@ export default class GunEntity extends Entity {
47
137
  }
48
138
 
49
139
  if (this.ammo <= 0) {
50
- //return false;
140
+ this.reload();
141
+ return false;
51
142
  }
52
143
 
53
144
  this.ammo--;
@@ -56,8 +147,129 @@ export default class GunEntity extends Entity {
56
147
  return true;
57
148
  }
58
149
 
150
+ public reload() {
151
+ if (!this.parent || !this.parent.world || this._reloading) {
152
+ return;
153
+ }
154
+
155
+ this.ammo = 0; // set the ammo to 0 to prevent fire while reloading if clip wasn't empty.
156
+ this._reloading = true;
157
+ this._reloadAudio.play(this.parent.world, true);
158
+ this._updatePlayerUIReload();
159
+
160
+ setTimeout(() => {
161
+ if (!this.isEquipped) {
162
+ return;
163
+ }
164
+
165
+ this.ammo = this.maxAmmo;
166
+ this._reloading = false;
167
+ this._updatePlayerUIAmmo();
168
+ }, this.reloadTimeMs);
169
+ }
170
+
59
171
  // override to create specific gun shoot logic
60
- public shoot() {}
172
+ public shoot() {
173
+ if (!this.parent || !this.parent.world) {
174
+ return;
175
+ }
176
+
177
+ const parentPlayerEntity = this.parent as GamePlayerEntity;
178
+
179
+ // Deal damage and raycast
180
+ const { origin, direction } = this.getShootOriginDirection();
181
+ this.shootRaycast(origin, direction, this.range);
182
+
183
+ // Play shoot animation
184
+ parentPlayerEntity.startModelOneshotAnimations([ this.shootAnimation ]);
185
+
186
+ // Show Muzzle Flash
187
+ if (this._muzzleFlashChildEntity) {
188
+ this._muzzleFlashChildEntity.setOpacity(1);
189
+ setTimeout(() => {
190
+ if (this.isSpawned && this._muzzleFlashChildEntity?.isSpawned) {
191
+ this._muzzleFlashChildEntity.setOpacity(0);
192
+ }
193
+ }, 35);
194
+ }
195
+
196
+ // Update player ammo
197
+ this._updatePlayerUIAmmo();
198
+
199
+ // Play shoot audio
200
+ this._shootAudio.play(this.parent.world, true);
201
+ }
202
+
203
+ public shootRaycast(origin: Vector3Like, direction: Vector3Like, length: number) {
204
+ if (!this.parent || !this.parent.world) {
205
+ return;
206
+ }
207
+
208
+ const parentPlayerEntity = this.parent as GamePlayerEntity;
209
+
210
+ const raycastHit = this.parent.world.simulation.raycast(origin, direction, length, {
211
+ filterGroups: CollisionGroupsBuilder.buildRawCollisionGroups({ // filter group is the group the raycast belongs to.
212
+ belongsTo: [ CollisionGroup.ALL ],
213
+ collidesWith: [ CollisionGroup.BLOCK, CollisionGroup.ENTITY ],
214
+ }),
215
+ });
216
+
217
+ const hitEntity = raycastHit?.hitEntity;
218
+
219
+ if (hitEntity && hitEntity instanceof EnemyEntity) {
220
+ hitEntity.takeDamage(this.damage, parentPlayerEntity);
221
+ }
222
+ }
223
+
224
+ private _updateParentAnimations() {
225
+ if (!this.parent || !this.parent.world) {
226
+ return;
227
+ }
228
+
229
+ const playerEntityController = this.parent.controller as PlayerEntityController;
230
+
231
+ playerEntityController.idleLoopedAnimations = [ this.idleAnimation, 'idle_lower' ];
232
+ playerEntityController.walkLoopedAnimations = [ this.idleAnimation, 'walk_lower' ];
233
+ playerEntityController.runLoopedAnimations = [ this.idleAnimation, 'run_lower' ];
234
+ }
235
+
236
+ private _updatePlayerUIAmmo() {
237
+ if (!this.parent || !this.parent.world) {
238
+ return;
239
+ }
240
+
241
+ const parentPlayerEntity = this.parent as PlayerEntity;
242
+
243
+ parentPlayerEntity.player.ui.sendData({
244
+ type: 'ammo',
245
+ ammo: this.ammo,
246
+ maxAmmo: this.maxAmmo,
247
+ });
248
+ }
249
+
250
+ private _updatePlayerUIReload() {
251
+ if (!this.parent || !this.parent.world) {
252
+ return;
253
+ }
254
+
255
+ const parentPlayerEntity = this.parent as PlayerEntity;
256
+
257
+ parentPlayerEntity.player.ui.sendData({ type: 'reload' });
258
+ }
259
+
260
+ private _updatePlayerUIWeapon() {
261
+ if (!this.parent || !this.parent.world) {
262
+ return;
263
+ }
264
+
265
+ const parentPlayerEntity = this.parent as PlayerEntity;
266
+
267
+ parentPlayerEntity.player.ui.sendData({
268
+ type: 'weapon',
269
+ name: this.name,
270
+ iconImageUri: this.iconImageUri,
271
+ });
272
+ }
61
273
 
62
274
  // convenience helper for getting the node name of the hand the gun is held in.
63
275
  private static _getParentNodeName(hand: GunHand): string {
@@ -0,0 +1,9 @@
1
+ import {
2
+ Entity,
3
+ } from 'hytopia';
4
+
5
+ import GamePlayerEntity from './GamePlayerEntity';
6
+
7
+ export default abstract class InteractableEntity extends Entity {
8
+ public abstract interact(interactingPlayer: GamePlayerEntity): void;
9
+ }
@@ -1,19 +1,24 @@
1
1
  import {
2
2
  ColliderOptions,
3
3
  ColliderShape,
4
+ CollisionGroup,
4
5
  Entity,
5
- RigidBodyType,
6
6
  QuaternionLike,
7
+ RigidBodyType,
8
+ SceneUI,
7
9
  Vector3Like,
8
10
  World,
9
11
  } from 'hytopia';
10
12
 
13
+ import GameManager from './GameManager';
14
+ import GamePlayerEntity from './GamePlayerEntity';
15
+ import InteractableEntity from './InteractableEntity';
16
+
11
17
  const WALL_COLLIDER_OPTIONS: ColliderOptions = {
12
18
  shape: ColliderShape.BLOCK,
13
- halfExtents: {
14
- x: 0.5,
15
- y: 5,
16
- z: 0.5,
19
+ collisionGroups: {
20
+ belongsTo: [ CollisionGroup.BLOCK ],
21
+ collidesWith: [ CollisionGroup.PLAYER ],
17
22
  },
18
23
  };
19
24
 
@@ -21,33 +26,66 @@ export interface PurchaseBarrierEntityOptions {
21
26
  name: string;
22
27
  removalPrice: number;
23
28
  width: number;
29
+ unlockIds: string[];
24
30
  }
25
31
 
26
- export default class PurchaseBarrierEntity extends Entity {
32
+ export default class PurchaseBarrierEntity extends InteractableEntity {
27
33
  public removalPrice: number;
34
+ private _unlockIds: string[];
28
35
  private _width: number;
29
-
30
36
  public constructor(options: PurchaseBarrierEntityOptions) {
31
37
  super({
32
38
  name: options.name,
33
39
  modelUri: 'models/environment/barbedfence.gltf',
34
40
  rigidBodyOptions: {
35
41
  type: RigidBodyType.FIXED,
36
- colliders: [ WALL_COLLIDER_OPTIONS ],
37
42
  },
38
43
  });
39
44
 
40
45
  this.removalPrice = options.removalPrice;
46
+ this._unlockIds = options.unlockIds;
41
47
  this._width = options.width;
42
48
  }
43
49
 
44
50
  public get width(): number {
45
51
  return this._width;
46
52
  }
53
+
54
+ public override interact(interactingPlayer: GamePlayerEntity) {
55
+ if (!this.isSpawned || !this.world) {
56
+ return;
57
+ }
58
+
59
+ if (!interactingPlayer.spendMoney(this.removalPrice)) {
60
+ this.world.chatManager.sendPlayerMessage(interactingPlayer.player, `You don't have enough money to unlock this barrier!`, 'FF0000');
61
+ return;
62
+ }
63
+
64
+ this.world.chatManager.sendBroadcastMessage(`The ${this.name} barrier has been unlocked!`, '00FF00');
65
+
66
+ // Add unlocked ids to game state, so zombies can spawn in the new areas
67
+ this._unlockIds.forEach(id => {
68
+ GameManager.instance.addUnlockedId(id);
69
+ });
70
+
71
+ this.despawn();
72
+ }
47
73
 
48
74
  public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
75
+ // Add the barrier collider based on spawn position and width before spawning the barrier.
76
+ this.createAndAddChildCollider({
77
+ ...WALL_COLLIDER_OPTIONS,
78
+ halfExtents: {
79
+ x: rotation?.w === 1 ? (this._width * 0.5) : 0.5,
80
+ y: 5,
81
+ z: rotation?.w === 1 ? 0.5 : (this._width * 0.5),
82
+ },
83
+ relativeRotation: rotation,
84
+ });
85
+
49
86
  super.spawn(world, position, rotation);
50
87
 
88
+ // Add children barriers for visual barrier width
51
89
  if (this._width > 1) {
52
90
  const offset = Math.floor((this._width - 1) / 2);
53
91
  for (let i = -offset; i <= offset; i++) {
@@ -56,19 +94,34 @@ export default class PurchaseBarrierEntity extends Entity {
56
94
  const barrier = new Entity({
57
95
  name: `${this.name} (${Math.abs(i)})`,
58
96
  modelUri: 'models/environment/barbedfence.gltf',
59
- rigidBodyOptions: {
60
- type: RigidBodyType.FIXED,
61
- colliders: [ WALL_COLLIDER_OPTIONS ],
62
- },
97
+ parent: this,
63
98
  });
64
99
 
100
+ // Because of the anchor point of the barbedfence model
101
+ // being in the lower corner and not centered, we need
102
+ // to offset the child barriers by half the height to
103
+ // center them, this is just a unique for to this model.
104
+ const halfHeight = this.height / 2;
105
+
65
106
  barrier.spawn(world, {
66
- x: position.x + (rotation?.w === 1 ? i : 0),
67
- y: position.y,
68
- z: position.z + (rotation?.w === 1 ? 0 : i),
69
- }, rotation);
107
+ x: i - halfHeight,
108
+ y: -halfHeight,
109
+ z: -halfHeight,
110
+ });
70
111
  }
71
- };
112
+ }
113
+
114
+ // Spawn Scene UI that shows barrier removal price
115
+ (new SceneUI({
116
+ attachedToEntity: this,
117
+ offset: { x: 0, y: 1, z: 0 },
118
+ templateId: 'purchase-label',
119
+ viewDistance: 4,
120
+ state: {
121
+ name: this.name,
122
+ cost: this.removalPrice,
123
+ },
124
+ })).load(world);
72
125
  }
73
126
  }
74
127
 
@@ -0,0 +1,173 @@
1
+ import {
2
+ Audio,
3
+ Collider,
4
+ RigidBodyType,
5
+ QuaternionLike,
6
+ Vector3Like,
7
+ World,
8
+ SceneUI,
9
+ } from 'hytopia';
10
+
11
+ import GamePlayerEntity from './GamePlayerEntity';
12
+ import InteractableEntity from './InteractableEntity';
13
+
14
+ import AK47Entity from './guns/AK47Entity';
15
+ import AR15Entity from './guns/AR15Entity';
16
+ import AutoPistolEntity from './guns/AutoPistolEntity';
17
+ import AutoShotgunEntity from './guns/AutoShotgunEntity';
18
+ import PistolEntity from './guns/PistolEntity';
19
+ import ShotgunEntity from './guns/ShotgunEntity';
20
+ import type { GunEntityOptions } from './GunEntity';
21
+ import type GunEntity from './GunEntity';
22
+
23
+ const POSSIBLE_WEAPONS = [
24
+ {
25
+ id: 'ak47',
26
+ name: 'AK-47',
27
+ iconUri: 'icons/ak-47.png',
28
+ },
29
+ {
30
+ id: 'ar15',
31
+ name: 'AR-15',
32
+ iconUri: 'icons/ar-15.png',
33
+ },
34
+ {
35
+ id: 'auto-pistol',
36
+ name: 'Auto Pistol',
37
+ iconUri: 'icons/auto-pistol.png',
38
+ },
39
+ {
40
+ id: 'auto-shotgun',
41
+ name: 'Auto Shotgun',
42
+ iconUri: 'icons/auto-shotgun.png',
43
+ },
44
+ {
45
+ id: 'pistol',
46
+ name: 'Pistol',
47
+ iconUri: 'icons/pistol.png',
48
+ },
49
+ {
50
+ id: 'shotgun',
51
+ name: 'Shotgun',
52
+ iconUri: 'icons/shotgun.png',
53
+ },
54
+ ]
55
+
56
+ export interface WeaponCrateEntityOptions {
57
+ name: string,
58
+ price: number,
59
+ rollableWeaponIds: string[],
60
+ };
61
+
62
+ export default class WeaponCrateEntity extends InteractableEntity {
63
+ public purchasePrice: number;
64
+ private _purchaseSceneUI: SceneUI;
65
+ private _rouletteAudio: Audio;
66
+ private _rouletteSceneUI: SceneUI;
67
+ private _rollableWeaponIds: string[];
68
+ private _rolledWeaponId: string | undefined;
69
+
70
+ public constructor(options: WeaponCrateEntityOptions) {
71
+ const colliderOptions = Collider.optionsFromModelUri('models/environment/weaponbox.gltf');
72
+
73
+ if (colliderOptions.halfExtents) { // make it taller for better interact area
74
+ colliderOptions.halfExtents.y = 3;
75
+ }
76
+
77
+ super({
78
+ name: options.name,
79
+ modelUri: 'models/environment/weaponbox.gltf',
80
+ rigidBodyOptions: {
81
+ type: RigidBodyType.FIXED,
82
+ colliders: [ colliderOptions ]
83
+ },
84
+ tintColor: { r: 255, g: 255, b: 255 },
85
+ });
86
+
87
+ this.purchasePrice = options.price;
88
+ this._rollableWeaponIds = options.rollableWeaponIds;
89
+
90
+ this._purchaseSceneUI = new SceneUI({
91
+ attachedToEntity: this,
92
+ offset: { x: 0, y: 1, z: 0 },
93
+ templateId: 'purchase-label',
94
+ viewDistance: 4,
95
+ state: {
96
+ name: this.name,
97
+ cost: this.purchasePrice,
98
+ },
99
+ });
100
+
101
+ this._rouletteAudio = new Audio({
102
+ attachedToEntity: this,
103
+ uri: 'audio/sfx/roulette.mp3',
104
+ volume: 0.3,
105
+ referenceDistance: 4,
106
+ });
107
+
108
+ this._rouletteSceneUI = new SceneUI({
109
+ attachedToEntity: this,
110
+ offset: { x: 0, y: 1, z: 0 },
111
+ templateId: 'weapon-roulette',
112
+ viewDistance: 4,
113
+ });
114
+ }
115
+
116
+ public override interact(interactingPlayer: GamePlayerEntity) {
117
+ if (!this.isSpawned || !this.world) {
118
+ return;
119
+ }
120
+
121
+ // If interacting and a weapon is rolled, equip it.
122
+ if (this._rolledWeaponId) {
123
+ this._rouletteSceneUI.unload();
124
+ this._purchaseSceneUI.load(this.world);
125
+
126
+ const GunClass = this._weaponIdToGunClass(this._rolledWeaponId);
127
+ if (GunClass) {
128
+ interactingPlayer.equipGun(new GunClass({ parent: interactingPlayer }));
129
+ }
130
+
131
+ this._rolledWeaponId = undefined;
132
+ return;
133
+ }
134
+
135
+ // If interacting and no weapon is rolled, spend $ to roll a weapon.
136
+ if (!interactingPlayer.spendMoney(this.purchasePrice)) {
137
+ this.world.chatManager.sendPlayerMessage(interactingPlayer.player, `You don't have enough money to purchase this weapon crate!`, 'FF0000');
138
+ return;
139
+ }
140
+
141
+ // Unload purchase scene UI
142
+ this._purchaseSceneUI.unload();
143
+
144
+ // Roll a weapon and show roll UI
145
+ this._rolledWeaponId = this._rollableWeaponIds[Math.floor(Math.random() * this._rollableWeaponIds.length)];
146
+ this._rouletteSceneUI.setState({
147
+ selectedWeaponId: this._rolledWeaponId,
148
+ possibleWeapons: POSSIBLE_WEAPONS,
149
+ });
150
+ this._rouletteSceneUI.load(this.world);
151
+
152
+ this._rouletteAudio.play(this.world, true);
153
+ }
154
+
155
+ public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
156
+ super.spawn(world, position, rotation);
157
+
158
+ // Spawn Scene UI that shows purchase price
159
+ this._purchaseSceneUI.load(world);
160
+ }
161
+
162
+ private _weaponIdToGunClass(weaponId: string): (new (options: Partial<GunEntityOptions>) => GunEntity) | undefined {
163
+ switch (weaponId) {
164
+ case 'ak47': return AK47Entity;
165
+ case 'ar15': return AR15Entity;
166
+ case 'auto-pistol': return AutoPistolEntity;
167
+ case 'auto-shotgun': return AutoShotgunEntity;
168
+ case 'pistol': return PistolEntity;
169
+ case 'shotgun': return ShotgunEntity;
170
+ default: return undefined;
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,67 @@
1
+ import { GameServer, PathfindingEntityController } from 'hytopia';
2
+ import type { GamePlayerEntity, QuaternionLike, Vector3Like, World } from 'hytopia';
3
+
4
+ import EnemyEntity from '../EnemyEntity';
5
+ import type { EnemyEntityOptions } from '../EnemyEntity';
6
+
7
+ export default class RipperEntity extends EnemyEntity {
8
+ public constructor(options: Partial<EnemyEntityOptions> = {}) {
9
+ const speed = options.speed ?? 2;
10
+ const animation = speed > 6 ? 'animation.ripper_zombie.sprint' : 'animation.ripper_zombie.walk';
11
+
12
+ super({
13
+ damage: options.damage ?? 6,
14
+ damageAudioUri: options.damageAudioUri ?? 'audio/sfx/entity/zombie/zombie-hurt.mp3',
15
+ health: options.health ?? 300,
16
+ idleAudioUri: options.idleAudioUri ?? 'audio/sfx/ripper-idle.mp3',
17
+ idleAudioVolume: 1,
18
+ idleAudioReferenceDistance: 8,
19
+ jumpHeight: options.jumpHeight ?? 2,
20
+ preferJumping: true,
21
+ reward: options.reward ?? 300,
22
+ speed,
23
+ controller: new PathfindingEntityController(),
24
+ modelUri: 'models/npcs/ripper-boss.gltf',
25
+ modelLoopedAnimations: [ animation ],
26
+ modelScale: 0.5,
27
+ rigidBodyOptions: {
28
+ enabledRotations: { x: false, y: true, z: false },
29
+ ccdEnabled: true,
30
+ },
31
+ });
32
+ }
33
+
34
+ public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike) {
35
+ super.spawn(world, position, rotation);
36
+
37
+ this._updateBossUI({
38
+ type: 'boss',
39
+ name: 'BOSS: RIPPER',
40
+ health: this.health,
41
+ maxHealth: this.maxHealth,
42
+ show: true,
43
+ });
44
+ }
45
+
46
+ public override takeDamage(damage: number, fromPlayer: GamePlayerEntity) {
47
+ // Do the UI check first, because otherwise
48
+ // takeDamage can trigger a despawn if health < 0
49
+ this._updateBossUI({
50
+ type: 'boss',
51
+ show: this.health - damage > 0,
52
+ healthPercent: ((this.health - damage) / this.maxHealth) * 100,
53
+ });
54
+
55
+ super.takeDamage(damage, fromPlayer);
56
+ }
57
+
58
+ private _updateBossUI(data = {}) {
59
+ if (!this.world) {
60
+ return;
61
+ }
62
+
63
+ GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => {
64
+ player.ui.sendData(data);
65
+ });
66
+ }
67
+ }
@@ -0,0 +1,30 @@
1
+ import { PathfindingEntityController } from 'hytopia';
2
+
3
+ import EnemyEntity from '../EnemyEntity';
4
+ import type { EnemyEntityOptions } from '../EnemyEntity';
5
+
6
+ export default class ZombieEntity extends EnemyEntity {
7
+ public constructor(options: Partial<EnemyEntityOptions> = {}) {
8
+ const speed = options.speed ?? 1 + Math.random() * 4;
9
+ const animation = speed > 5 ? 'run' : speed > 3 ? 'walk' : 'crawling';
10
+
11
+ super({
12
+ damage: options.damage ?? 2,
13
+ damageAudioUri: options.damageAudioUri ?? 'audio/sfx/entity/zombie/zombie-hurt.mp3',
14
+ health: options.health ?? 7,
15
+ idleAudioUri: options.idleAudioUri ?? 'audio/sfx/zombie-idle.mp3',
16
+ jumpHeight: options.jumpHeight ?? 2,
17
+ reward: options.reward ?? 20,
18
+ speed: options.speed ?? speed,
19
+
20
+ controller: new PathfindingEntityController(),
21
+ modelUri: 'models/npcs/zombie.gltf',
22
+ modelLoopedAnimations: [ animation ],
23
+ modelScale: 0.5 + Math.random() * 0.2,
24
+ rigidBodyOptions: {
25
+ enabledRotations: { x: false, y: true, z: false },
26
+ ccdEnabled: true,
27
+ },
28
+ });
29
+ }
30
+ }