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,20 +1,199 @@
1
1
  import {
2
+ Audio,
2
3
  Entity,
3
4
  EntityOptions,
5
+ PathfindingEntityController,
4
6
  } from 'hytopia';
5
7
 
8
+ import type { QuaternionLike, Vector3Like, World } from 'hytopia';
9
+
10
+ import GamePlayerEntity from './GamePlayerEntity';
11
+
12
+ const RETARGET_ACCUMULATOR_THRESHOLD_MS = 5000;
13
+ const PATHFIND_ACCUMULATOR_THRESHOLD_MS = 3000;
14
+
6
15
  export interface EnemyEntityOptions extends EntityOptions {
7
- health: number;
8
16
  damage: number;
17
+ damageAudioUri?: string;
18
+ health: number;
19
+ idleAudioUri?: string;
20
+ idleAudioReferenceDistance?: number;
21
+ idleAudioVolume?: number;
22
+ jumpHeight?: number
23
+ preferJumping?: boolean;
24
+ reward: number;
25
+ speed: number;
9
26
  }
10
27
 
11
- export class EnemyEntity extends Entity {
12
- public health: number;
28
+ export default class EnemyEntity extends Entity {
13
29
  public damage: number;
30
+ public health: number;
31
+ public jumpHeight: number;
32
+ public maxHealth: number;
33
+ public preferJumping: boolean;
34
+ public reward: number;
35
+ public speed: number;
36
+
37
+ private _damageAudio: Audio | undefined;
38
+ private _idleAudio: Audio | undefined;
39
+ private _isPathfinding = false;
40
+ private _pathfindAccumulatorMs = 0;
41
+ private _retargetAccumulatorMs = 0;
42
+ private _targetEntity: Entity | undefined;
14
43
 
15
44
  public constructor(options: EnemyEntityOptions) {
16
45
  super(options);
17
- this.health = options.health;
18
46
  this.damage = options.damage;
47
+ this.health = options.health;
48
+ this.jumpHeight = options.jumpHeight ?? 1;
49
+ this.maxHealth = options.health;
50
+ this.preferJumping = options.preferJumping ?? false;
51
+ this.reward = options.reward;
52
+ this.speed = options.speed;
53
+
54
+ if (options.damageAudioUri) {
55
+ this._damageAudio = new Audio({
56
+ attachedToEntity: this,
57
+ uri: options.damageAudioUri,
58
+ volume: 1,
59
+ loop: false,
60
+ });
61
+ }
62
+
63
+ if (options.idleAudioUri) {
64
+ this._idleAudio = new Audio({
65
+ attachedToEntity: this,
66
+ uri: options.idleAudioUri,
67
+ volume: options.idleAudioVolume ?? 0.5,
68
+ loop: true,
69
+ referenceDistance: options.idleAudioReferenceDistance ?? 1, // low reference distance so its only heard when the enemy is very near
70
+ });
71
+ }
72
+
73
+ this.onEntityCollision = this._onEntityCollision;
74
+ this.onTick = this._onTick;
75
+
76
+ this.setCcdEnabled(true);
77
+ }
78
+
79
+ public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike) {
80
+ super.spawn(world, position, rotation);
81
+
82
+ if (this._idleAudio) {
83
+ this._idleAudio.play(world, true);
84
+ }
85
+ }
86
+
87
+ public takeDamage(damage: number, fromPlayer: GamePlayerEntity) {
88
+ if (!this.world) {
89
+ return;
90
+ }
91
+
92
+ this.health -= damage;
93
+
94
+ if (this._damageAudio) {
95
+ this._damageAudio.play(this.world, true);
96
+ }
97
+
98
+ // Give reward based on damage as % of health
99
+ fromPlayer.addMoney((this.damage / this.maxHealth) * this.reward);
100
+
101
+ if (this.health <= 0 && this.isSpawned) {
102
+ // Enemy is dead, give half reward & despawn
103
+ this.despawn();
104
+ } else {
105
+ // Apply red tint for 75ms to indicate damage
106
+ this.setTintColor({ r: 255, g: 0, b: 0 });
107
+ // Reset tint after 75ms, make sure to check if the entity is still
108
+ // spawned to prevent setting tint on a despawned entity
109
+ setTimeout(() => this.isSpawned ? this.setTintColor({ r: 255, g: 255, b: 255 }) : undefined, 75);
110
+ }
111
+ }
112
+
113
+ private _onEntityCollision = (entity: Entity, otherEntity: Entity, started: boolean) => {
114
+ if (!started || !(otherEntity instanceof GamePlayerEntity)) {
115
+ return;
116
+ }
117
+
118
+ otherEntity.takeDamage(this.damage);
119
+ }
120
+
121
+ /*
122
+ * Pathfinding is handled on an accumulator basis to prevent excessive pathfinding
123
+ * or movement calculations. It defers to dumb movements
124
+ */
125
+ private _onTick = (entity: Entity, tickDeltaMs: number) => {
126
+ if (!this.isSpawned) {
127
+ return;
128
+ }
129
+
130
+ this._pathfindAccumulatorMs += tickDeltaMs;
131
+ this._retargetAccumulatorMs += tickDeltaMs;
132
+
133
+ // Acquire a target to hunt
134
+ if (!this._targetEntity || !this._targetEntity.isSpawned || this._retargetAccumulatorMs > RETARGET_ACCUMULATOR_THRESHOLD_MS) {
135
+ this._targetEntity = this._getNearestTarget();
136
+ this._retargetAccumulatorMs = 0;
137
+ }
138
+
139
+ // No target, do nothing
140
+ if (!this._targetEntity) {
141
+ return;
142
+ }
143
+
144
+ const targetDistance = this._getTargetDistance(this._targetEntity);
145
+ const pathfindingController = this.controller as PathfindingEntityController;
146
+
147
+ if (targetDistance < 8 || (!this._isPathfinding && this._pathfindAccumulatorMs < PATHFIND_ACCUMULATOR_THRESHOLD_MS)) {
148
+ pathfindingController.move(this._targetEntity.position, this.speed);
149
+ pathfindingController.face(this._targetEntity.position, this.speed * 2);
150
+ } else if (this._pathfindAccumulatorMs > PATHFIND_ACCUMULATOR_THRESHOLD_MS) {
151
+ this._isPathfinding = pathfindingController.pathfind(this._targetEntity.position, this.speed, {
152
+ maxFall: this.jumpHeight,
153
+ maxJump: this.jumpHeight,
154
+ maxOpenSetIterations: 200,
155
+ verticalPenalty: this.preferJumping ? -1 : 1,
156
+ pathfindAbortCallback: () => this._isPathfinding = false,
157
+ pathfindCompleteCallback: () => this._isPathfinding = false,
158
+ waypointMoveSkippedCallback: () => this._isPathfinding = false,
159
+ });
160
+
161
+ this._pathfindAccumulatorMs = 0;
162
+ }
163
+ }
164
+
165
+ private _getNearestTarget(): Entity | undefined {
166
+ if (!this.world) {
167
+ return undefined;
168
+ }
169
+
170
+ let nearestTarget: Entity | undefined;
171
+ let nearestDistance = Infinity;
172
+
173
+ const targetableEntities = this.world.entityManager.getAllPlayerEntities();
174
+
175
+ targetableEntities.forEach(target => {
176
+ if (target instanceof GamePlayerEntity && target.downed) { // skip downed players
177
+ return;
178
+ }
179
+
180
+ const distance = this._getTargetDistance(target);
181
+ if (distance < nearestDistance) {
182
+ nearestTarget = target;
183
+ nearestDistance = distance;
184
+ }
185
+ });
186
+
187
+ return nearestTarget;
188
+ }
189
+
190
+ private _getTargetDistance(target: Entity) {
191
+ const targetDistance = {
192
+ x: target.position.x - this.position.x,
193
+ y: target.position.y - this.position.y,
194
+ z: target.position.z - this.position.z,
195
+ };
196
+
197
+ return Math.sqrt(targetDistance.x * targetDistance.x + targetDistance.y * targetDistance.y + targetDistance.z * targetDistance.z);
19
198
  }
20
199
  }
@@ -0,0 +1,165 @@
1
+ import { Audio, Collider, ColliderShape, CollisionGroup, GameServer } from 'hytopia';
2
+ import PurchaseBarrierEntity from './PurchaseBarrierEntity';
3
+ import { INVISIBLE_WALLS, INVISIBLE_WALL_COLLISION_GROUP, PURCHASE_BARRIERS, ENEMY_SPAWN_POINTS, WEAPON_CRATES } from '../gameConfig';
4
+ import type { World, Vector3Like } from 'hytopia';
5
+
6
+ // temp
7
+ import ZombieEntity from './enemies/ZombieEntity';
8
+ import RipperEntity from './enemies/RipperEntity';
9
+ import WeaponCrateEntity from './WeaponCrateEntity';
10
+
11
+ const GAME_WAVE_INTERVAL_MS = 30 * 1000; // 30 seconds between waves
12
+ const SLOWEST_SPAWN_INTERVAL_MS = 4000; // Starting spawn rate
13
+ const FASTEST_SPAWN_INTERVAL_MS = 750; // Fastest spawn rate
14
+ const WAVE_SPAWN_INTERVAL_REDUCTION_MS = 300; // Spawn rate reduction per wave
15
+ const WAVE_DELAY_MS = 10000; // 10s between waves
16
+
17
+ export default class GameManager {
18
+ public static readonly instance = new GameManager();
19
+
20
+ public isStarted = false;
21
+ public unlockedIds: Set<string> = new Set([ 'start' ]);
22
+ public waveNumber = 0;
23
+ public waveDelay = 0;
24
+ public world: World | undefined;
25
+
26
+ private _enemySpawnTimeout: NodeJS.Timeout | undefined;
27
+ private _startTime: number | undefined;
28
+ private _waveTimeout: NodeJS.Timeout | undefined;
29
+ private _waveStartAudio: Audio;
30
+
31
+ public constructor() {
32
+ this._waveStartAudio = new Audio({
33
+ uri: 'audio/sfx/wave-start.mp3',
34
+ loop: false,
35
+ volume: 1,
36
+ });
37
+ }
38
+
39
+ public addUnlockedId(id: string) {
40
+ this.unlockedIds.add(id);
41
+ }
42
+
43
+ public setupGame(world: World) {
44
+ this.world = world;
45
+
46
+ // Setup invisible walls that only enemies can pass through
47
+ INVISIBLE_WALLS.forEach(wall => {
48
+ const wallCollider = new Collider({
49
+ shape: ColliderShape.BLOCK,
50
+ halfExtents: wall.halfExtents,
51
+ relativePosition: wall.position, // since this is not attached to a rigid body, relative position is relative to the world global coordinate space.
52
+ collisionGroups: {
53
+ belongsTo: [ INVISIBLE_WALL_COLLISION_GROUP ],
54
+ collidesWith: [ CollisionGroup.PLAYER ],
55
+ },
56
+ });
57
+
58
+ wallCollider.addToSimulation(world.simulation);
59
+ });
60
+
61
+ // Setup purchase barriers
62
+ PURCHASE_BARRIERS.forEach(barrier => {
63
+ const purchaseBarrier = new PurchaseBarrierEntity({
64
+ name: barrier.name,
65
+ removalPrice: barrier.removalPrice,
66
+ unlockIds: barrier.unlockIds,
67
+ width: barrier.width,
68
+ });
69
+
70
+ purchaseBarrier.spawn(world, barrier.position, barrier.rotation);
71
+ });
72
+
73
+ // Setup weapon crates
74
+ WEAPON_CRATES.forEach(crate => {
75
+ const weaponCrate = new WeaponCrateEntity({
76
+ name: crate.name,
77
+ price: crate.price,
78
+ rollableWeaponIds: crate.rollableWeaponIds,
79
+ });
80
+
81
+ weaponCrate.spawn(world, crate.position, crate.rotation);
82
+ });
83
+
84
+ // Start ambient music
85
+ (new Audio({
86
+ uri: 'audio/music/bg.mp3',
87
+ loop: true,
88
+ volume: 0.4,
89
+ })).play(world);
90
+
91
+ world.chatManager.registerCommand('/start', () => this.startGame());
92
+ }
93
+
94
+ public startGame() {
95
+ if (!this.world || this.isStarted) return; // type guard
96
+
97
+ this.isStarted = true;
98
+ this._startTime = Date.now();
99
+
100
+ GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => {
101
+ player.ui.sendData({ type: 'start' });
102
+ });
103
+
104
+ this._spawnLoop();
105
+ this._waveLoop();
106
+ }
107
+
108
+ private _spawnLoop() {
109
+ if (!this.world) return; // type guard
110
+
111
+ clearTimeout(this._enemySpawnTimeout);
112
+
113
+ const zombie = new ZombieEntity({
114
+ health: 7 + (this.waveNumber * 0.25),
115
+ speed: Math.min(6, 2 + this.waveNumber * 0.25), // max speed of 6
116
+ });
117
+
118
+ zombie.spawn(this.world, this._getSpawnPoint());
119
+
120
+ const nextSpawn = Math.max(FASTEST_SPAWN_INTERVAL_MS, SLOWEST_SPAWN_INTERVAL_MS - (this.waveNumber * WAVE_SPAWN_INTERVAL_REDUCTION_MS)) + this.waveDelay;
121
+
122
+ this._enemySpawnTimeout = setTimeout(() => this._spawnLoop(), nextSpawn);
123
+ this.waveDelay = 0;
124
+ }
125
+
126
+ private _waveLoop() {
127
+ if (!this.world) return; // type guard
128
+
129
+ clearTimeout(this._waveTimeout);
130
+
131
+ this.waveNumber++;
132
+ this.waveDelay = WAVE_DELAY_MS;
133
+
134
+ this._waveStartAudio.play(this.world, true);
135
+
136
+ GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => {
137
+ player.ui.sendData({
138
+ type: 'wave',
139
+ wave: this.waveNumber,
140
+ });
141
+ });
142
+
143
+ if (this.waveNumber % 5 === 0) { // Spawn a ripper every 5 waves
144
+ const ripper = new RipperEntity({
145
+ health: 50 * this.waveNumber,
146
+ speed: 2 + this.waveNumber * 0.25,
147
+ reward: 50 * this.waveNumber,
148
+ });
149
+ ripper.spawn(this.world, this._getSpawnPoint());
150
+ }
151
+
152
+ this._waveTimeout = setTimeout(() => this._waveLoop(), GAME_WAVE_INTERVAL_MS);
153
+ }
154
+
155
+ private _getSpawnPoint(): Vector3Like {
156
+ const spawnPoints: Vector3Like[] = [];
157
+
158
+ this.unlockedIds.forEach(id => {
159
+ const spawnPoint = ENEMY_SPAWN_POINTS[id];
160
+ if (spawnPoint) spawnPoints.push(...spawnPoint);
161
+ });
162
+
163
+ return spawnPoints[Math.floor(Math.random() * spawnPoints.length)];
164
+ }
165
+ }
@@ -1,28 +1,47 @@
1
1
  import {
2
+ Audio,
2
3
  CollisionGroup,
4
+ Light,
5
+ LightType,
3
6
  Player,
4
7
  PlayerCameraOrientation,
5
8
  PlayerEntity,
6
9
  PlayerCameraMode,
7
10
  PlayerInput,
11
+ SceneUI,
8
12
  Vector3Like,
9
13
  QuaternionLike,
10
14
  World,
11
15
  Quaternion,
12
16
  PlayerEntityController,
17
+ Vector3,
13
18
  } from 'hytopia';
14
19
 
15
20
  import PistolEntity from './guns/PistolEntity';
16
- import type GunEntity from './guns/GunEntity';
21
+
22
+ import InteractableEntity from './InteractableEntity';
23
+ import type GunEntity from './GunEntity';
24
+ import type { GunEntityOptions } from './GunEntity';
25
+ import { INVISIBLE_WALL_COLLISION_GROUP } from '../gameConfig';
17
26
 
18
27
  const BASE_HEALTH = 100;
19
- const BASE_MONEY = 10;
28
+ const REVIVE_REQUIRED_HEALTH = 50;
29
+ const REVIVE_PROGRESS_INTERVAL_MS = 1000;
30
+ const REVIVE_DISTANCE_THRESHOLD = 3;
20
31
 
21
32
  export default class GamePlayerEntity extends PlayerEntity {
22
33
  public health: number;
23
34
  public maxHealth: number;
24
35
  public money: number;
25
- private _gun: GunEntity | null = null;
36
+ public downed = false;
37
+ private _damageAudio: Audio;
38
+ private _downedSceneUI: SceneUI;
39
+ private _purchaseAudio: Audio;
40
+ private _gun: GunEntity | undefined;
41
+ private _light: Light;
42
+ private _reviveInterval: NodeJS.Timeout | undefined;
43
+ private _reviveDistanceVectorA: Vector3;
44
+ private _reviveDistanceVectorB: Vector3;
26
45
 
27
46
  // Player entities always assign a PlayerController to the entity, so we can safely create a convenience getter
28
47
  public get playerController(): PlayerEntityController {
@@ -37,16 +56,15 @@ export default class GamePlayerEntity extends PlayerEntity {
37
56
  modelScale: 0.5,
38
57
  });
39
58
 
40
-
41
59
  // Prevent mouse left click from being cancelled, required
42
60
  // for auto-fire and semi-auto fire mechanics, etc.
43
61
  this.playerController.autoCancelMouseLeftClick = false;
44
62
 
45
63
  // Setup player animations
46
- this.playerController.idleLoopedAnimations = [ 'idle_gun_right', 'idle_lower' ];
64
+ this.playerController.idleLoopedAnimations = [ 'idle_lower' ];
47
65
  this.playerController.interactOneshotAnimations = [];
48
- this.playerController.walkLoopedAnimations = [ 'idle_gun_right', 'walk_lower' ];
49
- this.playerController.runLoopedAnimations = [ 'idle_gun_right', 'run_lower' ];
66
+ this.playerController.walkLoopedAnimations = ['walk_lower' ];
67
+ this.playerController.runLoopedAnimations = [ 'run_lower' ];
50
68
  this.playerController.onTickWithPlayerInput = this._onTickWithPlayerInput;
51
69
 
52
70
  // Setup UI
@@ -54,14 +72,52 @@ export default class GamePlayerEntity extends PlayerEntity {
54
72
 
55
73
  // Setup first person camera
56
74
  this.player.camera.setMode(PlayerCameraMode.FIRST_PERSON);
57
- this.player.camera.setModelHiddenNodes([ 'head', 'neck' ]);
75
+ this.player.camera.setModelHiddenNodes([ 'head', 'neck', 'torso', 'leg_right', 'leg_left' ]);
58
76
  this.player.camera.setOffset({ x: 0, y: 0.5, z: 0 });
59
- this.player.camera.setForwardOffset(0.2);
60
77
 
61
78
  // Set base stats
62
79
  this.health = BASE_HEALTH;
63
80
  this.maxHealth = BASE_HEALTH;
64
- this.money = BASE_MONEY;
81
+ this.money = 0;
82
+
83
+ // Setup damage audio
84
+ this._damageAudio = new Audio({
85
+ attachedToEntity: this,
86
+ uri: 'audio/sfx/player-hurt.mp3',
87
+ loop: false,
88
+ volume: 0.7,
89
+ });
90
+
91
+ // Setup purchase audio
92
+ this._purchaseAudio = new Audio({
93
+ attachedToEntity: this,
94
+ uri: 'audio/sfx/purchase.mp3',
95
+ loop: false,
96
+ volume: 1,
97
+ });
98
+
99
+ // Setup downed scene UI
100
+ this._downedSceneUI = new SceneUI({
101
+ attachedToEntity: this,
102
+ templateId: 'downed-player',
103
+ offset: { x: 0, y: 0.5, z: 0 },
104
+ });
105
+
106
+ // Setup light
107
+ this._light = new Light({
108
+ angle: Math.PI / 4 + 0.1,
109
+ penumbra: 0.03,
110
+ attachedToEntity: this,
111
+ trackedEntity: this,
112
+ type: LightType.SPOTLIGHT,
113
+ intensity: 5,
114
+ offset: { x: 0, y: 0, z: 0.1 },
115
+ color: { r: 255, g: 255, b: 255 },
116
+ });
117
+
118
+ // Create reusable vector3 for revive distance calculations
119
+ this._reviveDistanceVectorA = new Vector3(0, 0, 0);
120
+ this._reviveDistanceVectorB = new Vector3(0, 0, 0);
65
121
  }
66
122
 
67
123
  public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
@@ -70,18 +126,211 @@ export default class GamePlayerEntity extends PlayerEntity {
70
126
  // Prevent players from colliding, setup appropriate collision groups for invisible walls, etc.
71
127
  this.setCollisionGroupsForSolidColliders({
72
128
  belongsTo: [ CollisionGroup.PLAYER ],
73
- collidesWith: [ CollisionGroup.BLOCK, CollisionGroup.ENTITY, CollisionGroup.ENTITY_SENSOR ],
129
+ collidesWith: [ CollisionGroup.BLOCK, CollisionGroup.ENTITY, CollisionGroup.ENTITY_SENSOR, INVISIBLE_WALL_COLLISION_GROUP ],
74
130
  });
75
131
 
76
132
  // Give player a pistol.
77
- this._gun = new PistolEntity({ parent: this });
78
- this._gun.spawn(world, { x: 0, y: 0, z: -0.2 }, Quaternion.fromEuler(-90, 0, 0));
133
+ this.equipGun(new PistolEntity({ parent: this }));
134
+
135
+ // Spawn light
136
+ this._light.spawn(world);
137
+
138
+ // Start auto heal ticker
139
+ this._autoHealTicker();
140
+ }
141
+
142
+ public addMoney(amount: number) {
143
+ this.money += amount;
144
+ this._updatePlayerUIMoney();
145
+ }
146
+
147
+ public equipGun(gun: GunEntity) {
148
+ if (!this.world) {
149
+ return;
150
+ }
151
+
152
+ if (gun.isSpawned) {
153
+ // no support for equipping already spawned guns atm, like pickup up guns etc,
154
+ // but would be easy to add. Not needed for this game though.
155
+ return console.warn('Cannot equip already spawned gun!');
156
+ }
157
+
158
+ if (this._gun) { // despawn old gun
159
+ this._gun.despawn();
160
+ }
161
+
162
+ this._gun = gun;
163
+ this._gun.spawn(this.world, { x: 0, y: 0, z: -0.2 }, Quaternion.fromEuler(-90, 0, 0));
164
+ }
165
+
166
+ public spendMoney(amount: number): boolean {
167
+ if (!this.world || this.money < amount) {
168
+ return false;
169
+ }
170
+
171
+ this.money -= amount;
172
+ this._updatePlayerUIMoney();
173
+ this._purchaseAudio.play(this.world, true);
174
+ return true;
175
+ }
176
+
177
+ public takeDamage(damage: number) {
178
+ if (!this.isSpawned || !this.world || this.downed) {
179
+ return;
180
+ }
181
+
182
+ const healthAfterDamage = this.health - damage;
183
+ if (this.health > 0 && healthAfterDamage <= 0) {
184
+ this._setDowned(true);
185
+ }
186
+
187
+ this.health = Math.max(healthAfterDamage, 0);
188
+
189
+ this._updatePlayerUIHealth();
190
+
191
+ // randomize the detune for variation each hit
192
+ this._damageAudio.setDetune(-200 + Math.random() * 800);
193
+ this._damageAudio.play(this.world, true);
194
+ }
195
+
196
+ public progressRevive(byPlayer: GamePlayerEntity) {
197
+ if (!this.world) {
198
+ return;
199
+ }
200
+
201
+ clearTimeout(this._reviveInterval);
202
+
203
+ this._reviveInterval = setTimeout(() => {
204
+ this._reviveDistanceVectorA.set([ this.position.x, this.position.y, this.position.z ]);
205
+ this._reviveDistanceVectorB.set([ byPlayer.position.x, byPlayer.position.y, byPlayer.position.z ]);
206
+ const distance = this._reviveDistanceVectorA.distance(this._reviveDistanceVectorB);
207
+
208
+ if (distance > REVIVE_DISTANCE_THRESHOLD) {
209
+ return;
210
+ }
211
+
212
+ this.health += 10;
213
+ this._updatePlayerUIHealth();
214
+
215
+ this._downedSceneUI.setState({
216
+ progress: (this.health / REVIVE_REQUIRED_HEALTH) * 100,
217
+ });
218
+
219
+ if (this.health >= REVIVE_REQUIRED_HEALTH) {
220
+ this._setDowned(false);
221
+ } else {
222
+ this.progressRevive(byPlayer);
223
+ }
224
+ }, REVIVE_PROGRESS_INTERVAL_MS);
79
225
  }
80
226
 
81
227
  private _onTickWithPlayerInput = (entity: PlayerEntity, input: PlayerInput, cameraOrientation: PlayerCameraOrientation, deltaTimeMs: number) => {
82
- if (input.ml && this._gun) {
228
+ if (!this._gun) {
229
+ return;
230
+ }
231
+
232
+ if (input.ml && !this.downed) {
83
233
  this._gun.shoot();
84
234
  }
235
+
236
+ if (input.r && !this.downed) {
237
+ this._gun.reload();
238
+ input.r = false;
239
+ }
240
+
241
+ if (input.e) {
242
+ this._interactRaycast();
243
+ input.e = false;
244
+ }
245
+ }
246
+
247
+ private _setDowned(downed: boolean) {
248
+ if (!this.world) {
249
+ return;
250
+ }
251
+
252
+ this.downed = downed;
253
+
254
+ if (downed) {
255
+ this.health = 0;
256
+ this._updatePlayerUIHealth();
257
+ }
258
+
259
+ this.playerController.idleLoopedAnimations = downed ? [ 'sleep' ] : [ 'idle_lower' ];
260
+ this.playerController.walkLoopedAnimations = downed ? [ 'crawling' ] : [ 'walk_lower' ];
261
+ this.playerController.runLoopedAnimations = downed ? [ 'crawling' ] : [ 'run_lower' ];
262
+ this.playerController.runVelocity = downed ? 1 : 8;
263
+ this.playerController.walkVelocity = downed ? 1 : 4;
264
+ this.playerController.jumpVelocity = downed ? 0 : 10;
265
+
266
+ if (downed) {
267
+ this._downedSceneUI.setState({ progress: 0 })
268
+ this._downedSceneUI.load(this.world);
269
+ this.world.chatManager.sendPlayerMessage(this.player, 'You are downed! A teammate can still revive you!', 'FF0000');
270
+ } else {
271
+ this._downedSceneUI.unload();
272
+ this.world.chatManager.sendPlayerMessage(this.player, 'You are back up! Thank your team & fight the horde!', '00FF00');
273
+ }
274
+ }
275
+
276
+ private _interactRaycast() {
277
+ if (!this.world) {
278
+ return;
279
+ }
280
+
281
+ if (this.downed) {
282
+ return this.world.chatManager.sendPlayerMessage(this.player, 'You are downed! You cannot revive others or make purchases!', 'FF0000');
283
+ }
284
+
285
+ // Get raycast direction from player camera
286
+ const origin = {
287
+ x: this.position.x,
288
+ y: this.position.y + this.player.camera.offset.y,
289
+ z: this.position.z,
290
+ };
291
+ const direction = this.player.camera.facingDirection;
292
+ const length = 4;
293
+
294
+ const raycastHit = this.world.simulation.raycast(origin, direction, length, {
295
+ filterExcludeRigidBody: this.rawRigidBody, // prevent raycast from hitting the player
296
+ });
297
+
298
+ const hitEntity = raycastHit?.hitEntity;
299
+
300
+ if (!hitEntity) {
301
+ return;
302
+ }
303
+
304
+ if (hitEntity instanceof InteractableEntity) {
305
+ hitEntity.interact(this);
306
+ }
307
+
308
+ if (hitEntity instanceof GamePlayerEntity && hitEntity.downed) {
309
+ hitEntity.progressRevive(this);
310
+ }
311
+ }
312
+
313
+ private _updatePlayerUIMoney() {
314
+ this.player.ui.sendData({ type: 'money', money: this.money });
315
+ }
316
+
317
+ private _updatePlayerUIHealth() {
318
+ this.player.ui.sendData({ type: 'health', health: this.health, maxHealth: this.maxHealth });
319
+ }
320
+
321
+ private _autoHealTicker() {
322
+ setTimeout(() => {
323
+ if (!this.isSpawned) {
324
+ return;
325
+ }
326
+
327
+ if (!this.downed && this.health < this.maxHealth) {
328
+ this.health += 1;
329
+ this._updatePlayerUIHealth();
330
+ }
331
+
332
+ this._autoHealTicker();
333
+ }, 1000);
85
334
  }
86
335
  }
87
336