hytopia 0.2.0 → 0.2.1

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.
@@ -63,7 +63,7 @@ let gameUiState: object = {};
63
63
 
64
64
  // Audio
65
65
  const gameActiveAudio = new Audio({
66
- uri: 'audio/music/hytopia-main.mp3',
66
+ uri: 'audio/music.mp3',
67
67
  loop: true,
68
68
  volume: 0.2,
69
69
  });
@@ -297,9 +297,9 @@ function generateWall(world: World, direction: GameWallDirection, speedModifier:
297
297
  const yOffset = (selectedShape.length - y - 1) * 1 + 0.5;
298
298
 
299
299
  const wallSegment = new Entity({
300
- blockTextureUri: selectedShape[y][x] === 2 ? 'textures/dirt.png' :
301
- selectedShape[y][x] === 3 ? 'textures/sand.png' :
302
- 'textures/oak_leaves.png',
300
+ blockTextureUri: selectedShape[y][x] === 2 ? 'blocks/dirt.png' :
301
+ selectedShape[y][x] === 3 ? 'blocks/sand.png' :
302
+ 'blocks/oak-leaves.png',
303
303
  blockHalfExtents: {
304
304
  x: isNorthSouth ? 0.5 : 0.5,
305
305
  y: 0.5,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Audio,
2
3
  CollisionGroup,
3
4
  ColliderShape,
4
5
  BlockType,
@@ -55,6 +56,12 @@ startServer(world => {
55
56
 
56
57
  setupJoinNPC(world);
57
58
  startBlockSpawner(world);
59
+
60
+ (new Audio({
61
+ uri: 'audio/bgm.mp3',
62
+ loop: true,
63
+ volume: 0.05,
64
+ })).play(world);
58
65
  });
59
66
 
60
67
  /**
@@ -73,6 +73,7 @@
73
73
  <!-- UI Scripts-->
74
74
  <script>
75
75
  const CDN_ASSETS_URL = '{{CDN_ASSETS_URL}}';
76
+ let timerInterval;
76
77
 
77
78
  hytopia.registerSceneUITemplate('downed-player', (id, onState) => {
78
79
  const template = document.getElementById('downed-player-template');
@@ -158,10 +159,15 @@
158
159
  document.querySelector('.game-info').style.display = 'block';
159
160
  document.querySelector('.game-info .timer').textContent = 'Time: 00:00';
160
161
  document.querySelector('.game-info .wave').textContent = 'Wave: 1';
162
+
163
+ // Clear previous timer
164
+ if (timerInterval) {
165
+ clearInterval(timerInterval);
166
+ }
161
167
 
162
168
  // Start game timer
163
169
  const startTime = Date.now();
164
- setInterval(() => {
170
+ timerInterval = setInterval(() => {
165
171
  const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
166
172
  const minutes = Math.floor(elapsedSeconds / 60).toString().padStart(2, '0');
167
173
  const seconds = (elapsedSeconds % 60).toString().padStart(2, '0');
@@ -42,7 +42,7 @@ export default class EnemyEntity extends Entity {
42
42
  private _targetEntity: Entity | undefined;
43
43
 
44
44
  public constructor(options: EnemyEntityOptions) {
45
- super(options);
45
+ super({ ...options, tag: 'enemy' });
46
46
  this.damage = options.damage;
47
47
  this.health = options.health;
48
48
  this.jumpHeight = options.jumpHeight ?? 1;
@@ -84,7 +84,7 @@ export default class EnemyEntity extends Entity {
84
84
  }
85
85
  }
86
86
 
87
- public takeDamage(damage: number, fromPlayer: GamePlayerEntity) {
87
+ public takeDamage(damage: number, fromPlayer?: GamePlayerEntity) {
88
88
  if (!this.world) {
89
89
  return;
90
90
  }
@@ -96,7 +96,9 @@ export default class EnemyEntity extends Entity {
96
96
  }
97
97
 
98
98
  // Give reward based on damage as % of health
99
- fromPlayer.addMoney((this.damage / this.maxHealth) * this.reward);
99
+ if (fromPlayer) {
100
+ fromPlayer.addMoney((this.damage / this.maxHealth) * this.reward);
101
+ }
100
102
 
101
103
  if (this.health <= 0 && this.isSpawned) {
102
104
  // Enemy is dead, give half reward & despawn
@@ -1,16 +1,18 @@
1
1
  import { Audio, Collider, ColliderShape, CollisionGroup, GameServer } from 'hytopia';
2
+ import GamePlayerEntity from './GamePlayerEntity';
2
3
  import PurchaseBarrierEntity from './PurchaseBarrierEntity';
3
4
  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
5
  import RipperEntity from './enemies/RipperEntity';
6
+ import ZombieEntity from './enemies/ZombieEntity';
9
7
  import WeaponCrateEntity from './WeaponCrateEntity';
8
+ import type { World, Vector3Like } from 'hytopia';
9
+ import type EnemyEntity from './EnemyEntity';
10
+ import type { Player } from 'hytopia';
10
11
 
11
12
  const GAME_WAVE_INTERVAL_MS = 30 * 1000; // 30 seconds between waves
12
13
  const SLOWEST_SPAWN_INTERVAL_MS = 4000; // Starting spawn rate
13
14
  const FASTEST_SPAWN_INTERVAL_MS = 750; // Fastest spawn rate
15
+ const GAME_START_COUNTDOWN_S = 45; // 45 seconds delay before game starts
14
16
  const WAVE_SPAWN_INTERVAL_REDUCTION_MS = 300; // Spawn rate reduction per wave
15
17
  const WAVE_DELAY_MS = 10000; // 10s between waves
16
18
 
@@ -24,7 +26,9 @@ export default class GameManager {
24
26
  public world: World | undefined;
25
27
 
26
28
  private _enemySpawnTimeout: NodeJS.Timeout | undefined;
27
- private _startTime: number | undefined;
29
+ private _endGameTimeout: NodeJS.Timeout | undefined;
30
+ private _startCountdown: number = GAME_START_COUNTDOWN_S;
31
+ private _startInterval: NodeJS.Timeout | undefined;
28
32
  private _waveTimeout: NodeJS.Timeout | undefined;
29
33
  private _waveStartAudio: Audio;
30
34
 
@@ -40,6 +44,28 @@ export default class GameManager {
40
44
  this.unlockedIds.add(id);
41
45
  }
42
46
 
47
+ public checkEndGame() {
48
+ clearTimeout(this._endGameTimeout);
49
+
50
+ this._endGameTimeout = setTimeout(() => {
51
+ if (!this.world) return;
52
+
53
+ let allPlayersDowned = true;
54
+
55
+ this.world.entityManager.getAllPlayerEntities().forEach(playerEntity => {
56
+ const gamePlayerEntity = playerEntity as GamePlayerEntity;
57
+
58
+ if (!gamePlayerEntity.downed) {
59
+ allPlayersDowned = false;
60
+ }
61
+ });
62
+
63
+ if (allPlayersDowned) {
64
+ this.endGame();
65
+ }
66
+ }, 1000);
67
+ }
68
+
43
69
  public setupGame(world: World) {
44
70
  this.world = world;
45
71
 
@@ -58,17 +84,8 @@ export default class GameManager {
58
84
  wallCollider.addToSimulation(world.simulation);
59
85
  });
60
86
 
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
- });
87
+ // Spawn initial purchase barriers
88
+ this.spawnPurchaseBarriers();
72
89
 
73
90
  // Setup weapon crates
74
91
  WEAPON_CRATES.forEach(crate => {
@@ -88,14 +105,31 @@ export default class GameManager {
88
105
  volume: 0.4,
89
106
  })).play(world);
90
107
 
91
- world.chatManager.registerCommand('/start', () => this.startGame());
108
+ this.startCountdown();
109
+ }
110
+
111
+ public startCountdown() {
112
+ clearInterval(this._startInterval);
113
+ this._startCountdown = GAME_START_COUNTDOWN_S;
114
+ this._startInterval = setInterval(() => {
115
+ if (!this.world || !this.world.entityManager.getAllPlayerEntities().length) return;
116
+
117
+ this._startCountdown--;
118
+
119
+ if (this._startCountdown <= 0) {
120
+ this.startGame();
121
+ this.world.chatManager.sendBroadcastMessage('Game starting!', 'FF0000');
122
+ } else {
123
+ this.world.chatManager.sendBroadcastMessage(`${this._startCountdown} seconds until the game starts...`, 'FF0000');
124
+ }
125
+ }, 1000);
92
126
  }
93
127
 
94
128
  public startGame() {
95
129
  if (!this.world || this.isStarted) return; // type guard
96
130
 
97
131
  this.isStarted = true;
98
- this._startTime = Date.now();
132
+ clearInterval(this._startInterval);
99
133
 
100
134
  GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => {
101
135
  player.ui.sendData({ type: 'start' });
@@ -105,6 +139,64 @@ export default class GameManager {
105
139
  this._waveLoop();
106
140
  }
107
141
 
142
+ public endGame() {
143
+ if (!this.world) return;
144
+
145
+ this.world.chatManager.sendBroadcastMessage(`Game Over! Your team made it to wave ${this.waveNumber}!`, '00FF00');
146
+
147
+ clearTimeout(this._enemySpawnTimeout);
148
+ clearTimeout(this._waveTimeout);
149
+
150
+ this.isStarted = false;
151
+ this.unlockedIds = new Set([ 'start' ]);
152
+ this.waveNumber = 0;
153
+ this.waveDelay = 0;
154
+
155
+ this.world.entityManager.getEntitiesByTag('enemy').forEach(entity => {
156
+ const enemy = entity as EnemyEntity;
157
+ enemy.takeDamage(enemy.health); // triggers any UI updates when killed via takedamage
158
+ });
159
+
160
+ this.world.entityManager.getAllPlayerEntities().forEach(playerEntity => {
161
+ const player = playerEntity.player;
162
+ playerEntity.despawn();
163
+ });
164
+
165
+ GameServer.instance.playerManager.getConnectedPlayers().forEach(player => {
166
+ this.spawnPlayerEntity(player);
167
+ });
168
+
169
+ this.spawnPurchaseBarriers();
170
+ this.startCountdown();
171
+ }
172
+
173
+ public spawnPlayerEntity(player: Player) {
174
+ if (!this.world) return;
175
+
176
+ const playerEntity = new GamePlayerEntity(player);
177
+ playerEntity.spawn(this.world, { x: 2, y: 10, z: 19 });
178
+ player.camera.setAttachedToEntity(playerEntity);
179
+ }
180
+
181
+ public spawnPurchaseBarriers() {
182
+ if (!this.world) return;
183
+
184
+ this.world.entityManager.getEntitiesByTag('purchase-barrier').forEach(entity => {
185
+ entity.despawn();
186
+ });
187
+
188
+ PURCHASE_BARRIERS.forEach(barrier => {
189
+ const purchaseBarrier = new PurchaseBarrierEntity({
190
+ name: barrier.name,
191
+ removalPrice: barrier.removalPrice,
192
+ unlockIds: barrier.unlockIds,
193
+ width: barrier.width,
194
+ });
195
+
196
+ purchaseBarrier.spawn(this.world!, barrier.position, barrier.rotation);
197
+ });
198
+ }
199
+
108
200
  private _spawnLoop() {
109
201
  if (!this.world) return; // type guard
110
202
 
@@ -21,8 +21,8 @@ import PistolEntity from './guns/PistolEntity';
21
21
 
22
22
  import InteractableEntity from './InteractableEntity';
23
23
  import type GunEntity from './GunEntity';
24
- import type { GunEntityOptions } from './GunEntity';
25
24
  import { INVISIBLE_WALL_COLLISION_GROUP } from '../gameConfig';
25
+ import GameManager from './GameManager';
26
26
 
27
27
  const BASE_HEALTH = 100;
28
28
  const REVIVE_REQUIRED_HEALTH = 50;
@@ -137,6 +137,10 @@ export default class GamePlayerEntity extends PlayerEntity {
137
137
 
138
138
  // Start auto heal ticker
139
139
  this._autoHealTicker();
140
+
141
+ // Reset any prior UI from respawn
142
+ this._updatePlayerUIHealth();
143
+ this._updatePlayerUIMoney();
140
144
  }
141
145
 
142
146
  public addMoney(amount: number) {
@@ -263,10 +267,16 @@ export default class GamePlayerEntity extends PlayerEntity {
263
267
  this.playerController.walkVelocity = downed ? 1 : 4;
264
268
  this.playerController.jumpVelocity = downed ? 0 : 10;
265
269
 
270
+ if (!downed && this._gun) {
271
+ this._gun.setParentAnimations();
272
+ }
273
+
266
274
  if (downed) {
267
275
  this._downedSceneUI.setState({ progress: 0 })
268
276
  this._downedSceneUI.load(this.world);
269
277
  this.world.chatManager.sendPlayerMessage(this.player, 'You are downed! A teammate can still revive you!', 'FF0000');
278
+
279
+ GameManager.instance.checkEndGame();
270
280
  } else {
271
281
  this._downedSceneUI.unload();
272
282
  this.world.chatManager.sendPlayerMessage(this.player, 'You are back up! Thank your team & fight the horde!', '00FF00');
@@ -81,7 +81,7 @@ export default abstract class GunEntity extends Entity {
81
81
  });
82
82
 
83
83
  if (options.parent) {
84
- this._updateParentAnimations();
84
+ this.setParentAnimations();
85
85
  }
86
86
  }
87
87
 
@@ -168,6 +168,18 @@ export default abstract class GunEntity extends Entity {
168
168
  }, this.reloadTimeMs);
169
169
  }
170
170
 
171
+ public setParentAnimations() {
172
+ if (!this.parent || !this.parent.world) {
173
+ return;
174
+ }
175
+
176
+ const playerEntityController = this.parent.controller as PlayerEntityController;
177
+
178
+ playerEntityController.idleLoopedAnimations = [ this.idleAnimation, 'idle_lower' ];
179
+ playerEntityController.walkLoopedAnimations = [ this.idleAnimation, 'walk_lower' ];
180
+ playerEntityController.runLoopedAnimations = [ this.idleAnimation, 'run_lower' ];
181
+ }
182
+
171
183
  // override to create specific gun shoot logic
172
184
  public shoot() {
173
185
  if (!this.parent || !this.parent.world) {
@@ -221,18 +233,6 @@ export default abstract class GunEntity extends Entity {
221
233
  }
222
234
  }
223
235
 
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
236
  private _updatePlayerUIAmmo() {
237
237
  if (!this.parent || !this.parent.world) {
238
238
  return;
@@ -40,6 +40,7 @@ export default class PurchaseBarrierEntity extends InteractableEntity {
40
40
  rigidBodyOptions: {
41
41
  type: RigidBodyType.FIXED,
42
42
  },
43
+ tag: 'purchase-barrier',
43
44
  });
44
45
 
45
46
  this.removalPrice = options.removalPrice;
@@ -1,5 +1,4 @@
1
1
  import { startServer } from 'hytopia';
2
- import GamePlayerEntity from './classes/GamePlayerEntity';
3
2
  import worldMap from './assets/maps/terrain.json';
4
3
 
5
4
  import GameManager from './classes/GameManager';
@@ -8,8 +7,6 @@ startServer(world => {
8
7
  // Load map.
9
8
  world.loadMap(worldMap);
10
9
 
11
- //world.simulation.enableDebugRaycasting(true);
12
-
13
10
  // Setup lighting
14
11
  world.setAmbientLightIntensity(0.0001);
15
12
  world.setAmbientLightColor({ r: 255, g: 192, b: 192 });
@@ -21,12 +18,10 @@ startServer(world => {
21
18
  // Spawn a player entity when a player joins the game.
22
19
  world.onPlayerJoin = player => {
23
20
  if (GameManager.instance.isStarted) {
24
- return world.chatManager.sendPlayerMessage(player, 'This round has already started, please wait for the next round. You can fly around as a spectator.', 'FF0000');
21
+ return world.chatManager.sendPlayerMessage(player, 'This round has already started, you will automatically join when the next round starts. While you wait, you can fly around as a spectator by using W, A, S, D.', 'FF0000');
25
22
  }
26
23
 
27
- const playerEntity = new GamePlayerEntity(player);
28
-
29
- playerEntity.spawn(world, { x: 2, y: 10, z: 19 });
24
+ GameManager.instance.spawnPlayerEntity(player);
30
25
  };
31
26
 
32
27
  // Despawn all player entities when a player leaves the game.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hytopia",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.",
5
5
  "main": "server.js",
6
6
  "bin": {