hytopia 0.1.18 → 0.1.19

Sign up to get free protection for your applications and to get access to all the features.
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.18",
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