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.
- package/docs/server.entity.height.md +13 -0
- package/docs/server.entity.md +22 -1
- package/docs/server.entity.opacity.md +1 -1
- package/docs/server.modelregistry.getheight.md +55 -0
- package/docs/server.modelregistry.md +14 -0
- package/docs/server.player.md +21 -0
- package/docs/server.player.profilepictureurl.md +13 -0
- package/examples/pathfinding/index.ts +4 -4
- package/examples/payload-game/index.ts +12 -10
- package/examples/zombies-fps/README.md +5 -0
- package/examples/zombies-fps/assets/audio/music/bg.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/pistol-reload.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/pistol-shoot.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/player-hurt.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/purchase.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/rifle-reload.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/rifle-shoot.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/ripper-idle.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/roulette.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/shotgun-reload.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/shotgun-shoot.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/wave-start.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/zombie-idle.mp3 +0 -0
- package/examples/zombies-fps/assets/icons/ak-47.png +0 -0
- package/examples/zombies-fps/assets/icons/ar-15.png +0 -0
- package/examples/zombies-fps/assets/icons/auto-pistol.png +0 -0
- package/examples/zombies-fps/assets/icons/auto-shotgun.png +0 -0
- package/examples/zombies-fps/assets/icons/heart.png +0 -0
- package/examples/zombies-fps/assets/icons/pistol.png +0 -0
- package/examples/zombies-fps/assets/icons/shotgun.png +0 -0
- package/examples/zombies-fps/assets/models/environment/bombbox.gltf +1 -0
- package/examples/zombies-fps/assets/models/environment/bullet-hole.gltf +1 -0
- package/examples/zombies-fps/assets/models/environment/healthkit.gltf +1 -0
- package/examples/zombies-fps/assets/models/environment/muzzle-flash.gltf +1 -0
- package/examples/zombies-fps/assets/models/items/ak-47.glb +0 -0
- package/examples/zombies-fps/assets/models/items/ar-15.glb +0 -0
- package/examples/zombies-fps/assets/models/items/auto-pistol.glb +0 -0
- package/examples/zombies-fps/assets/models/items/auto-shotgun.glb +0 -0
- package/examples/zombies-fps/assets/models/items/shotgun.glb +0 -0
- package/examples/zombies-fps/assets/models/npcs/ripper-boss.gltf +1 -0
- package/examples/zombies-fps/assets/models/players/soldier-player.gltf +1 -1
- package/examples/zombies-fps/assets/models/projectiles/bullet-trace.gltf +1 -0
- package/examples/zombies-fps/assets/ui/index.html +620 -27
- package/examples/zombies-fps/classes/EnemyEntity.ts +183 -4
- package/examples/zombies-fps/classes/GameManager.ts +165 -0
- package/examples/zombies-fps/classes/GamePlayerEntity.ts +263 -14
- package/examples/zombies-fps/classes/GunEntity.ts +225 -13
- package/examples/zombies-fps/classes/InteractableEntity.ts +9 -0
- package/examples/zombies-fps/classes/PurchaseBarrierEntity.ts +70 -17
- package/examples/zombies-fps/classes/WeaponCrateEntity.ts +173 -0
- package/examples/zombies-fps/classes/enemies/RipperEntity.ts +67 -0
- package/examples/zombies-fps/classes/enemies/ZombieEntity.ts +30 -0
- package/examples/zombies-fps/classes/guns/AK47Entity.ts +43 -0
- package/examples/zombies-fps/classes/guns/AR15Entity.ts +32 -0
- package/examples/zombies-fps/classes/guns/AutoPistolEntity.ts +36 -0
- package/examples/zombies-fps/classes/guns/AutoShotgunEntity.ts +37 -0
- package/examples/zombies-fps/classes/guns/PistolEntity.ts +23 -15
- package/examples/zombies-fps/classes/guns/ShotgunEntity.ts +92 -0
- package/examples/zombies-fps/gameConfig.ts +125 -21
- package/examples/zombies-fps/index.ts +14 -31
- package/package.json +1 -1
- package/server.api.json +126 -18
- package/server.d.ts +17 -5
- package/server.js +98 -90
- package/tsdoc-metadata.json +1 -1
- package/examples/zombies-fps/assets/audio/sfx/pistol-shoot-1.mp3 +0 -0
- package/examples/zombies-fps/assets/audio/sfx/pistol-shoot-2.mp3 +0 -0
- 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
|
-
|
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
|
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
|
-
|
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 = [ '
|
64
|
+
this.playerController.idleLoopedAnimations = [ 'idle_lower' ];
|
47
65
|
this.playerController.interactOneshotAnimations = [];
|
48
|
-
this.playerController.walkLoopedAnimations = [
|
49
|
-
this.playerController.runLoopedAnimations = [ '
|
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 =
|
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.
|
78
|
-
|
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 (
|
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
|
|