hytopia 0.1.17 → 0.1.19

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/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
 
6
6
  ## What is HYTOPIA?
7
7
 
8
- ![HYTOPIA Banner](./readme/assets/banner.png)
8
+ ![HYTOPIA Demo](./readme/assets/demo.gif)
9
+
9
10
  HYTOPIA is a modern games platform inspired by Minecraft, Roblox, and Rec Room.
10
11
 
11
- HYTOPIA allows you to create your own highly-sharable, immersive, massively multiplayer games in a voxel-like style. All playable in a web browser on any device!
12
+ HYTOPIA allows you to create your own highly-sharable, immersive, massively multiplayer games in a voxel-like style by writing TypeScript or JavaScript. All playable in a web browser on any device!
12
13
 
13
14
  ## What is this SDK?
14
15
 
@@ -19,11 +20,11 @@ The HYTOPIA SDK makes it easy for developers to create multiplayer games on the
19
20
  Available as a simple NPM package, this SDK provides everything you need to get started:
20
21
 
21
22
  - Compiled HYTOPIA Server: The ready-to-use server software.
22
- - Game Client & Debugger: For rapidly testing and developing your games.
23
+ - Game Client & Debugger: Accessible at https://play.hytopia.com
23
24
  - TypeScript Definitions: For strong typing and code completion.
24
- - Documentation: Detailed guides and references.
25
- - Default Assets: Textures, models and audio you can use in your games.
26
- - Game Examples: Sample projects & scripts showing how to build different types of games.
25
+ - Documentation: Detailed guides and API reference.
26
+ - Default Assets: Textures, models, audio and more you can use in your games.
27
+ - Examples: Sample projects & scripts showing how to build different types of games.
27
28
 
28
29
  With these resources, you can quickly build and share immersive, voxel-style multiplayer games on HYTOPIA.
29
30
 
@@ -36,9 +37,13 @@ With these resources, you can quickly build and share immersive, voxel-style mul
36
37
  mkdir my-project-directory && cd my-project-directory
37
38
  ```
38
39
 
39
- 3. Initialize a hytopia project. Sets up package.json and all dependencies, copies assets and an index.ts game script into your project.
40
+ 3. Initialize a hytopia project from boilerplate or an existing example. Sets up package.json and all dependencies, copies assets and an index.ts game script into your project.
40
41
  ```bash
42
+ # Option 1: Initialize a boilerplate project
41
43
  bunx hytopia init
44
+
45
+ # Option 2: Initialize a project from any of the examples in the examples directory like so:
46
+ bunx hytopia init --template payload-game
42
47
  ```
43
48
 
44
49
  3. Start the server, use --watch for hot reloads as you make changes.
@@ -48,6 +53,8 @@ bun --watch index.ts
48
53
 
49
54
  4. Visit https://play.hytopia.com - when prompted, enter `localhost:8080` - this is the hostname of the local server you started in the previous step.
50
55
 
56
+ **Note: If you'd prefer to use JavaScript instead of TypeScript, simply change the file extension of index.ts to index.js - Your editor will highlight the TypeScript syntax errors, simple delete the type annotations and everything should work the same without any TypeScript usage.**
57
+
51
58
  Once you're up and running, here's some other resources to go further:
52
59
  - [Game Examples](./examples)
53
60
  - [API Reference](./docs/server.md)
@@ -1,3 +1,7 @@
1
+ /**
2
+ *
3
+ */
4
+
1
5
  import {
2
6
  Audio,
3
7
  BlockType,
@@ -5,7 +9,6 @@ import {
5
9
  CollisionGroup,
6
10
  DefaultCharacterController,
7
11
  Entity,
8
- EventRouter,
9
12
  GameServer,
10
13
  PlayerEntity,
11
14
  RigidBodyType,
@@ -20,7 +23,7 @@ import type {
20
23
  Vector3,
21
24
  } from 'hytopia';
22
25
 
23
- import worldJson from './assets/map.json';
26
+ import map from './assets/map.json';
24
27
 
25
28
  // Constants
26
29
  const BULLET_SPEED = 50;
@@ -57,35 +60,32 @@ const PAYLOAD_WAYPOINT_ENEMY_SPAWNS = [
57
60
  ],
58
61
  ];
59
62
 
60
- // Globals, yuck
61
- const enemyHealth: Record<number, number> = {};
62
- const enemyPathfindAccumulators: Record<number, number> = {};
63
- const enemyPathfindingTargets: Record<number, Vector3> = {};
64
- const playerEntityHealth: Record<number, number> = {};
65
- let started = false;
66
- let payloadEntity: Entity | null = null;
67
- let payloadPlayerEntityCount = 0;
68
- let playerCount = 0;
69
- let targetWaypointCoordinateIndex = 0;
70
-
71
- // Configure Global Event Router
72
- EventRouter.serverInstance.logIgnoreEvents = [ 'CONNECTION.PACKET_SENT' ];
73
- EventRouter.serverInstance.logIgnoreEventPrefixes = [ 'CONNECTION.PACKET_SENT:' ];
74
- EventRouter.serverInstance.logAllEvents = false;
63
+ // Simple game state tracking via globals.
64
+ const enemyHealth: Record<number, number> = {}; // Entity id -> health
65
+ const enemyPathfindAccumulators: Record<number, number> = {}; // Entity id -> accumulator, so we don't pathfind each tick
66
+ const enemyPathfindingTargets: Record<number, Vector3> = {}; // Entity id -> target coordinate
67
+ const playerEntityHealth: Record<number, number> = {}; // Player entity id -> health
68
+ let started = false; // Game started flag
69
+ let payloadEntity: Entity | null = null; // Payload entity
70
+ let payloadPlayerEntityCount = 0; // Number of player entities within the payload sensor, minus number of enemies
71
+ let playerCount = 0; // Number of players in the game
72
+ let targetWaypointCoordinateIndex = 0; // Current waypoint coordinate index for the payload
75
73
 
76
74
  // Run
77
- void startServer(world => {
75
+ startServer(world => { // Perform our game setup logic in the startServer init callback here.
78
76
  const chatManager = world.chatManager;
79
77
 
80
- // Enable local ssl
78
+ // Enable local ssl, so we can connect to https://localhost:8080 from play.hytopia.com for testing
79
+ // If using NGROK or a reverse proxy that handles SSL, you need to comment this out to be able to
80
+ // connect to the server from the client using the reverse proxy URL.
81
81
  GameServer.instance.webServer.enableLocalSSL();
82
82
 
83
83
  // Load Map
84
- world.loadMap(worldJson);
84
+ world.loadMap(map);
85
85
 
86
86
  // Setup Player Join & Spawn Controlled Entity
87
87
  world.onPlayerJoin = player => {
88
- const playerEntity = new PlayerEntity({
88
+ const playerEntity = new PlayerEntity({ // Create an entity our newly joined player controls
89
89
  player,
90
90
  name: 'Player',
91
91
  modelUri: 'models/player-with-gun.gltf',
@@ -93,21 +93,30 @@ void startServer(world => {
93
93
  modelScale: 0.5,
94
94
  });
95
95
 
96
+ // Spawn the player entity at a random coordinate
96
97
  const randomSpawnCoordinate = PLAYER_SPAWN_COORDINATES[Math.floor(Math.random() * PLAYER_SPAWN_COORDINATES.length)];
97
98
  playerEntity.spawn(world, randomSpawnCoordinate);
98
99
 
100
+ // We need to do some custom logic for player inputs, so let's assign custom onTick handler to the default player controller.
99
101
  playerEntity.characterController!.onTickPlayerMovement = onTickPlayerMovement;
100
102
 
103
+ // Set custom collision groups for the player entity, this is so we can reference the PLAYER collision group
104
+ // specifically in enemy collision sensors.
101
105
  playerEntity.setCollisionGroupsForSolidColliders({
102
106
  belongsTo: [ CollisionGroup.ENTITY, CollisionGroup.PLAYER ],
103
107
  collidesWith: [ CollisionGroup.ALL ],
104
108
  });
105
109
 
110
+ // Initialize player health
106
111
  playerEntityHealth[playerEntity.id!] = 20;
112
+
113
+ // Increment player count
107
114
  playerCount++;
108
115
 
116
+ // Send a message to all players informing them that a new player has joined
109
117
  chatManager.sendBroadcastMessage(`Player ${player.username} has joined the game!`, 'FFFFFF');
110
118
 
119
+ // If the game hasn't started yet, send a message to all players to start the game
111
120
  if (!started) {
112
121
  chatManager.sendBroadcastMessage('Enter command /start to start the game!', 'FFFFFF');
113
122
  }
@@ -115,9 +124,13 @@ void startServer(world => {
115
124
 
116
125
  // Setup Player Leave & Despawn Controlled Entity
117
126
  world.onPlayerLeave = player => {
127
+ // Despawn all player entities for the player that left
128
+ // We apply a translation prior to despawn because of a bug in the RAPIER
129
+ // physics engine we use where entities despawned to not trigger a collision
130
+ // event for leaving a sensor. This is a workaround till a better solution is found.
118
131
  world.entityManager.getAllPlayerEntities(player).forEach(entity => {
119
132
  entity.setTranslation({ x: 0, y: 100, z: 0 });
120
- setTimeout(() => entity.despawn(), 50);
133
+ setTimeout(() => entity.despawn(), 50); // Despawn after a short delay so we step the physics after translating it so leaving the sensor registers.
121
134
  });
122
135
 
123
136
  playerCount--;
@@ -128,7 +141,6 @@ void startServer(world => {
128
141
  // Spawn Payload
129
142
  spawnPayloadEntity(world);
130
143
 
131
-
132
144
  // Start spawning enemies
133
145
  startEnemySpawnLoop(world);
134
146
 
@@ -143,7 +155,7 @@ void startServer(world => {
143
155
  started = false;
144
156
  });
145
157
 
146
- // Start Ambient Music
158
+ // Start ambient music for all players
147
159
  (new Audio({
148
160
  uri: 'audio/music/game.mp3',
149
161
  loop: true,
@@ -155,7 +167,7 @@ void startServer(world => {
155
167
  function startEnemySpawnLoop(world: World) {
156
168
  let spawnInterval;
157
169
 
158
- const spawn = () => {
170
+ const spawn = () => { // Simple spawn loop that spawns enemies relative to the payload's current waypoint
159
171
  const possibleSpawnCoordinate = PAYLOAD_WAYPOINT_ENEMY_SPAWNS[targetWaypointCoordinateIndex];
160
172
 
161
173
  if (!possibleSpawnCoordinate) {
@@ -177,18 +189,19 @@ function startEnemySpawnLoop(world: World) {
177
189
  }
178
190
 
179
191
  function spawnBullet(world: World, coordinate: Vector3, direction: Vector3) {
192
+ // Spawn a bullet when the player shoots.
180
193
  const bullet = new Entity({
181
194
  name: 'Bullet',
182
195
  modelUri: 'models/bullet.gltf',
183
196
  modelScale: 0.3,
184
197
  rigidBodyOptions: {
185
- type: RigidBodyType.KINEMATIC_VELOCITY,
198
+ type: RigidBodyType.KINEMATIC_VELOCITY, // Kinematic means entity's rigid body will not be affected by physics. KINEMATIC_VELOCITY means the entity is moved by setting velocity.
186
199
  linearVelocity: {
187
200
  x: direction.x * BULLET_SPEED,
188
201
  y: direction.y * BULLET_SPEED,
189
202
  z: direction.z * BULLET_SPEED,
190
203
  },
191
- rotation: getRotationFromDirection(direction),
204
+ rotation: getRotationFromDirection(direction), // Get the rotation from the direction vector so it's facing the right way we shot it
192
205
  colliders: [
193
206
  {
194
207
  shape: ColliderShape.BALL,
@@ -203,19 +216,22 @@ function spawnBullet(world: World, coordinate: Vector3, direction: Vector3) {
203
216
  },
204
217
  });
205
218
 
206
- bullet.onBlockCollision = (block: BlockType, started: boolean) => {
219
+ bullet.onBlockCollision = (block: BlockType, started: boolean) => { // If the bullet hits a block, despawn it
207
220
  if (started) {
208
221
  bullet.despawn();
209
222
  }
210
223
  };
211
224
 
212
- bullet.onEntityCollision = (entity: Entity, started: boolean) => {
213
- if (!started || ![ 'Spider', 'Zombie' ].includes(entity.name)) {
225
+ bullet.onEntityCollision = (entity: Entity, started: boolean) => { // If the bullet hits an enemy, deal damage if it is a Spider
226
+ if (!started || entity.name !== 'Spider') {
214
227
  return;
215
228
  }
216
229
 
217
230
  enemyHealth[entity.id!]--;
218
231
 
232
+ // Apply knockback, the knockback effect is less if the spider is larger, and more if it is smaller
233
+ // because of how the physics simulation applies forces relative to automatically calculated mass from the spider's
234
+ // size
219
235
  const bulletDirection = bullet.getDirectionFromRotation();
220
236
  const mass = entity.getMass();
221
237
  const knockback = 14 * mass;
@@ -227,9 +243,9 @@ function spawnBullet(world: World, coordinate: Vector3, direction: Vector3) {
227
243
  });
228
244
 
229
245
  if (enemyHealth[entity.id!] <= 0) {
230
- // have to YEET the spider to register it leaving the sensor
246
+ // YEET the spider before despawning it so it registers leaving the sensor
231
247
  entity.setTranslation({ x: 0, y: 100, z: 0 });
232
- setTimeout(() => { entity.despawn(); }, 50);
248
+ setTimeout(() => { entity.despawn(); }, 50); // Despawn after a short delay so we step the physics after translating it so leaving the sensor registers.
233
249
  }
234
250
 
235
251
  bullet.despawn();
@@ -237,6 +253,7 @@ function spawnBullet(world: World, coordinate: Vector3, direction: Vector3) {
237
253
 
238
254
  bullet.spawn(world, coordinate);
239
255
 
256
+ // Play a bullet noise that follows the bullet spatially
240
257
  (new Audio({
241
258
  uri: 'audio/sfx/shoot.mp3',
242
259
  playbackRate: 2,
@@ -263,21 +280,23 @@ function spawnPayloadEntity(world: World) {
263
280
  colliders: [
264
281
  {
265
282
  shape: ColliderShape.BLOCK,
266
- halfExtents: { x: 0.9, y: 1.6, z: 2.5 },
283
+ halfExtents: { x: 0.9, y: 1.6, z: 2.5 }, // Note: We manually set the collider size, the SDK currently does not support automatic sizing of colliders to a model.
267
284
  collisionGroups: {
268
285
  belongsTo: [ CollisionGroup.ALL ],
269
286
  collidesWith: [ CollisionGroup.ENTITY, CollisionGroup.ENTITY_SENSOR, CollisionGroup.PLAYER ],
270
287
  },
271
288
  },
272
289
  {
273
- shape: ColliderShape.BLOCK,
290
+ shape: ColliderShape.BLOCK, // Create a proximity sensor for movement when players are near.
274
291
  halfExtents: { x: 3.75, y: 2, z: 6 },
275
292
  isSensor: true,
276
293
  collisionGroups: {
277
294
  belongsTo: [ CollisionGroup.ENTITY_SENSOR ],
278
295
  collidesWith: [ CollisionGroup.PLAYER, CollisionGroup.ENTITY ],
279
296
  },
280
- onCollision: (other: BlockType | Entity, started: boolean) => {
297
+ // We use a onCollision handler specific to this sensor, and
298
+ // not the whole entity, so we can track the number of players in the payload sensor.
299
+ onCollision: (other: BlockType | Entity, started: boolean) => {
281
300
  if (other instanceof PlayerEntity) {
282
301
  started ? payloadPlayerEntityCount++ : payloadPlayerEntityCount--;
283
302
  } else if (other instanceof Entity) {
@@ -289,22 +308,23 @@ function spawnPayloadEntity(world: World) {
289
308
  },
290
309
  });
291
310
 
292
- payloadEntity.onTick = onTickPathfindPayload;
293
- payloadEntity.spawn(world, PAYLOAD_SPAWN_COORDINATE);
311
+ payloadEntity.onTick = onTickPathfindPayload; // Use our own basic pathfinding function each tick of the game for the payload.
312
+ payloadEntity.spawn(world, PAYLOAD_SPAWN_COORDINATE); // Spawn the payload at the designated spawn coordinate
294
313
 
295
- (new Audio({
314
+ (new Audio({ // Play a looped idle sound that follows the payload spatially
296
315
  uri: 'audio/sfx/payload-idle.mp3',
297
316
  loop: true,
298
317
  attachedToEntity: payloadEntity,
299
318
  volume: 0.25,
300
- referenceDistance: 5,
319
+ referenceDistance: 5, // Reference distance affects how loud the audio is relative to a player's proximity to the entity
301
320
  })).play(world);
302
321
  }
303
322
 
304
323
  function spawnSpider(world: World, coordinate: Vector3) {
305
324
  const baseScale = 0.5;
306
325
  const baseSpeed = 3;
307
- const randomScaleMultiplier = Math.random() * 2 + 1; // Random value between 1 and 3
326
+ const randomScaleMultiplier = Math.random() * 2 + 1; // Random value between 1 and 3 // Random scale multiplier to make each spider a different size
327
+ const targetPlayers = new Set<PlayerEntity>();
308
328
 
309
329
  const spider = new Entity({
310
330
  name: 'Spider',
@@ -320,7 +340,7 @@ function spawnSpider(world: World, coordinate: Vector3) {
320
340
  borderRadius: 0.1 * randomScaleMultiplier,
321
341
  halfHeight: 0.225 * randomScaleMultiplier,
322
342
  radius: 0.5 * randomScaleMultiplier,
323
- tag: 'body',
343
+ tag: 'body', // Note we use tags here, they don't really serve a purpose in this example other than showing that they can be used.
324
344
  collisionGroups: {
325
345
  belongsTo: [ CollisionGroup.ENTITY ],
326
346
  collidesWith: [ CollisionGroup.BLOCK, CollisionGroup.ENTITY_SENSOR, CollisionGroup.PLAYER ],
@@ -336,7 +356,7 @@ function spawnSpider(world: World, coordinate: Vector3) {
336
356
  belongsTo: [ CollisionGroup.ENTITY_SENSOR ],
337
357
  collidesWith: [ CollisionGroup.PLAYER ],
338
358
  },
339
- onCollision: (other: BlockType | Entity, started: boolean) => {
359
+ onCollision: (other: BlockType | Entity, started: boolean) => { // If a player enters or exits the aggro sensor, add or remove them from the target players set
340
360
  if (other instanceof PlayerEntity) {
341
361
  started ? targetPlayers.add(other) : targetPlayers.delete(other);
342
362
  }
@@ -346,16 +366,14 @@ function spawnSpider(world: World, coordinate: Vector3) {
346
366
  },
347
367
  });
348
368
 
349
- const targetPlayers = new Set<PlayerEntity>();
350
-
351
- spider.onTick = (tickDeltaMs: number) => onTickPathfindEnemy(
369
+ spider.onTick = (tickDeltaMs: number) => onTickPathfindEnemy( // Use our own basic pathfinding function each tick of the game for the enemy
352
370
  spider,
353
371
  targetPlayers,
354
372
  baseSpeed * randomScaleMultiplier,
355
373
  tickDeltaMs,
356
374
  );
357
375
 
358
- spider.onEntityCollision = (entity: Entity, started: boolean) => {
376
+ spider.onEntityCollision = (entity: Entity, started: boolean) => { // If the spider hits a player, deal damage and apply knockback
359
377
  if (started && entity instanceof PlayerEntity && entity.isSpawned) {
360
378
  const spiderDirection = spider.getDirectionFromRotation();
361
379
  const knockback = 4 * randomScaleMultiplier;
@@ -379,15 +397,16 @@ function spawnSpider(world: World, coordinate: Vector3) {
379
397
 
380
398
  spider.spawn(world, coordinate);
381
399
 
400
+ // Give the spider a health value relative to its size, bigger = more health
382
401
  enemyHealth[spider.id!] = 2 * Math.round(randomScaleMultiplier);
383
402
  }
384
403
 
385
- function onTickPathfindPayload(this: Entity, tickDeltaMs: number) {
386
- const speed = started
404
+ function onTickPathfindPayload(this: Entity, tickDeltaMs: number) { // Movement logic for the payload
405
+ const speed = started // Set the payload speed relative to the number of players in the payload sensor
387
406
  ? Math.max(Math.min(PAYLOAD_PER_PLAYER_SPEED * payloadPlayerEntityCount, PAYLOAD_MAX_SPEED), 0)
388
407
  : 0;
389
408
 
390
- if (!speed) {
409
+ if (!speed) { // Play animations based on if its moving or not
391
410
  this.stopModelAnimations(Array.from(this.modelLoopedAnimations).filter(v => v !== 'idle'));
392
411
  this.startModelLoopedAnimations([ 'idle' ]);
393
412
  } else {
@@ -395,7 +414,7 @@ function onTickPathfindPayload(this: Entity, tickDeltaMs: number) {
395
414
  this.startModelLoopedAnimations([ 'walk' ]);
396
415
  }
397
416
 
398
- // Calculate direction to target
417
+ // Calculate direction to target waypoint
399
418
  const targetWaypointCoordinate = PAYLOAD_WAYPOINT_COORDINATES[targetWaypointCoordinateIndex];
400
419
  const currentPosition = this.getTranslation();
401
420
  const deltaX = targetWaypointCoordinate.x - currentPosition.x;
@@ -406,7 +425,7 @@ function onTickPathfindPayload(this: Entity, tickDeltaMs: number) {
406
425
  z: Math.abs(deltaZ) > 0.1 ? Math.sign(deltaZ) : 0,
407
426
  };
408
427
 
409
- // Apply rotation to face direction if necessary
428
+ // Apply rotation to face direction if necessary based on the current target waypoint
410
429
  const rotation = this.getRotation();
411
430
  const currentAngle = 2 * Math.atan2(rotation.y, rotation.w);
412
431
  const targetAngle = Math.atan2(direction.x, direction.z) + Math.PI; // Add PI to face forward
@@ -452,7 +471,7 @@ function onTickPathfindEnemy(entity: Entity, targetPlayers: Set<PlayerEntity>, s
452
471
  if (!entity.isSpawned || !payloadEntity) return;
453
472
 
454
473
  const entityId = entity.id!;
455
- enemyPathfindAccumulators[entityId] ??= 0;
474
+ enemyPathfindAccumulators[entityId] ??= 0; // Initialize the accumulator for this enemy if it isn't initialized yet
456
475
 
457
476
  // Handle pathfinding
458
477
  if (!enemyPathfindingTargets[entityId] || enemyPathfindAccumulators[entityId] >= PATHFIND_ACCUMULATOR_THRESHOLD) {
@@ -542,7 +561,7 @@ function damagePlayer(playerEntity: PlayerEntity) {
542
561
  );
543
562
 
544
563
  if (playerEntityHealth[playerEntity.id!] <= 0) {
545
- chatManager.sendPlayerMessage(
564
+ chatManager.sendPlayerMessage( // Alert the player they've been damaged, since we don't have UI support yet, we just use chat
546
565
  playerEntity.player,
547
566
  'You have died!',
548
567
  'FF0000',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hytopia",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "The HYTOPIA SDK makes it easy for developers to create multiplayer games on the HYTOPIA platform using JavaScript or TypeScript.",
5
5
  "main": "server.js",
6
6
  "bin": {
Binary file