hytopia
Version:
The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.
571 lines (473 loc) • 20.1 kB
text/typescript
/**
* payload-game is a simple game that encompasses a number of core HYTOPIA SDK systems.
* This example utilizes entities, spawning, rigid body, colliders and sensors,
* collision groups, audio, entity controller hooks, and more.
*
* This example is a quick and dirty implementation of an overwatch style push the payload
* in a multiplayer PvE style. Players start the game around the payload and must stay near it
* for it to move towards the next waypoint. Enemies spawn near the next arget waypoint of
* the payload and swarm towards the players. Players can left click to shoot spiders with
* bullets.
*
* This example is not meant to be a polished game, but rather a demonstration of how to use
* the SDK to build your own games.
*
* In a polished implementation, we'd be using multiple files and not just index.ts to
* break out and properly organize game behavior and mechanics.
*/
import {
Audio,
BaseEntityControllerEvent,
BlockType,
PlayerCameraMode,
ColliderShape,
CollisionGroup,
PlayerEntityController,
Entity,
EntityEvent,
PlayerEntity,
PlayerEvent,
RigidBodyType,
SimpleEntityController,
Vector3,
World,
startServer,
Collider,
Quaternion,
} from 'hytopia';
import type {
PlayerInput,
PlayerCameraOrientation,
QuaternionLike,
Vector3Like,
EventPayloads,
} from 'hytopia';
import map from './assets/map.json';
// Constants
const BULLET_SPEED = 50;
const PATHFIND_ACCUMULATOR_THRESHOLD = 60;
const PAYLOAD_SPAWN_COORDINATE = { x: 1.5, y: 2.6, z: 69.5 };
const PAYLOAD_PER_PLAYER_SPEED = 1;
const PAYLOAD_MAX_SPEED = 5;
const PAYLOAD_WAYPOINT_COORDINATES = [
{ x: 1.5, y: 0, z: 1.5 },
{ x: 60.5, y: 0, z: 1.5 },
{ x: 60.5, y: 0, z: -62.5 },
];
const PLAYER_SPAWN_COORDINATES = [
{ x: 4, y: 3, z: 71 },
{ x: -2, y: 3, z: 68 },
{ x: 0, y: 3, z: 66 },
{ x: 3, y: 3, z: 66 },
{ x: 5, y: 3, z: 68 },
];
const PAYLOAD_WAYPOINT_ENEMY_SPAWNS = [
[
{ x: -16, y: 2, z: 1 },
{ x: 9, y: 2, z: -18 },
],
[
{ x: 61, y: 2, z: 19 },
{ x: 75, y: 2, z: -7 },
],
[
{ x: 44, y: 2, z: -62 },
{ x: 60, y: 2, z: -76 },
{ x: 76, y: 2, z: -76 },
],
];
// Simple game state tracking via globals.
const enemyHealth: Record<number, number> = {}; // Entity id -> health
const enemyPathfindAccumulators: Record<number, number> = {}; // Entity id -> accumulator, so we don't pathfind each tick
const enemyPathfindingTargets: Record<number, Vector3Like> = {}; // Entity id -> target coordinate
const playerEntityHealth: Record<number, number> = {}; // Player entity id -> health
let started = false; // Game started flag
let payloadEntity: Entity | null = null; // Payload entity
let payloadPlayerEntityCount = 0; // Number of player entities within the payload sensor, minus number of enemies
let playerCount = 0; // Number of players in the game
let targetWaypointCoordinateIndex = 0; // Current waypoint coordinate index for the payload
// Run
startServer(world => { // Perform our game setup logic in the startServer init callback here.
const chatManager = world.chatManager;
// Uncomment this to visualize physics vertices, will cause noticable lag.
// world.simulation.enableDebugRendering(true);
// Load Map
world.loadMap(map);
// Setup Player Join & Spawn Controlled Entity
world.on(PlayerEvent.JOINED_WORLD, ({ player }) => {
const playerEntity = new PlayerEntity({ // Create an entity our newly joined player controls
player,
name: 'Player',
modelUri: 'models/soldier-player.gltf',
modelLoopedAnimations: [ 'idle_lower', 'idle_gun_right' ],
modelScale: 0.5,
});
player.ui.load('ui/index.html');
// Setup a first person camera for the player
player.camera.setMode(PlayerCameraMode.FIRST_PERSON); // set first person mode
player.camera.setOffset({ x: 0, y: 0.4, z: 0 }); // shift camrea up on Y axis so we see from "head" perspective.
player.camera.setModelHiddenNodes([ 'head', 'neck' ]); // hide the head node from the model so we don't see it in the camera, this is just hidden for the controlling player.
player.camera.setForwardOffset(0.3); // Shift the camera forward so we are looking slightly in front of where the player is looking.
// Spawn the player entity at a random coordinate
const randomSpawnCoordinate = PLAYER_SPAWN_COORDINATES[Math.floor(Math.random() * PLAYER_SPAWN_COORDINATES.length)];
playerEntity.spawn(world, randomSpawnCoordinate);
// Override default model animations
const playerController = playerEntity.controller as PlayerEntityController;
playerController.idleLoopedAnimations = [ 'idle_lower', 'idle_gun_right' ];
playerController.interactOneshotAnimations = [];
playerController.walkLoopedAnimations = ['walk_lower', 'idle_gun_right' ];
playerController.runLoopedAnimations = [ 'run_lower', 'idle_gun_right' ];
// We need to do some custom logic for player inputs, so let's assign custom onTick handler to the default player controller.
playerController.on(BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT, onTickWithPlayerInput);
// Set custom collision groups for the player entity, this is so we can reference the PLAYER collision group
// specifically in enemy collision sensors.
playerEntity.setCollisionGroupsForSolidColliders({
belongsTo: [ CollisionGroup.ENTITY, CollisionGroup.PLAYER ],
collidesWith: [ CollisionGroup.ALL ],
});
// Spawn gun as child entity
const gun = new Entity({
modelUri: 'models/raygun.gltf',
modelScale: 0.04,
parent: playerEntity,
parentNodeName: 'hand_right_anchor',
});
gun.spawn(world, { x: 0, y: 0, z: 0.1 }, Quaternion.fromEuler(-90, 0, 0));
// Initialize player health
playerEntityHealth[playerEntity.id!] = 20;
// Increment player count
playerCount++;
// Send a message to all players informing them that a new player has joined
chatManager.sendBroadcastMessage(`Player ${player.username} has joined the game!`, 'FFFFFF');
// If the game hasn't started yet, send a message to all players to start the game
if (!started) {
chatManager.sendBroadcastMessage('Enter command /start to start the game!', 'FFFFFF');
}
});
// Setup Player Leave & Despawn Controlled Entity
world.on(PlayerEvent.LEFT_WORLD, ({ player }) => {
world.entityManager.getPlayerEntitiesByPlayer(player).forEach(entity => {
entity.despawn();
});
playerCount--;
chatManager.sendBroadcastMessage(`Player ${player.username} has left the game!`, 'FFFFFF');
});
// Spawn Payload
spawnPayloadEntity(world);
// Start spawning enemies
startEnemySpawnLoop(world);
// Game Commands
chatManager.registerCommand('/start', () => {
chatManager.sendBroadcastMessage('Game started!');
started = true;
});
chatManager.registerCommand('/stop', () => {
chatManager.sendBroadcastMessage('Game stopped!');
started = false;
});
// Start ambient music for all players
(new Audio({
uri: 'audio/music/game.mp3',
loop: true,
volume: 0.2,
})).play(world);
});
// Helper Functions
function startEnemySpawnLoop(world: World) {
let spawnInterval;
const spawn = () => { // Simple spawn loop that spawns enemies relative to the payload's current waypoint
const possibleSpawnCoordinate = PAYLOAD_WAYPOINT_ENEMY_SPAWNS[targetWaypointCoordinateIndex];
if (!possibleSpawnCoordinate) {
return console.warn('No possible spawn coordinate found!');
}
if (started) {
for (let i = 0; i < playerCount; i++) {
spawnSpider(world, possibleSpawnCoordinate[Math.floor(Math.random() * possibleSpawnCoordinate.length)]);
}
}
spawnInterval = 3500 - targetWaypointCoordinateIndex * 1000;
setTimeout(spawn, spawnInterval);
};
spawn();
}
function spawnBullet(world: World, coordinate: Vector3Like, direction: Vector3Like) {
// Spawn a bullet when the player shoots.
const bullet = new Entity({
name: 'Bullet',
modelUri: 'models/bullet.gltf',
modelScale: 0.3,
rigidBodyOptions: {
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.
linearVelocity: {
x: direction.x * BULLET_SPEED,
y: direction.y * BULLET_SPEED,
z: direction.z * BULLET_SPEED,
},
rotation: getRotationFromDirection(direction), // Get the rotation from the direction vector so it's facing the right way we shot it
colliders: [
{
...Collider.optionsFromModelUri('models/bullet.gltf', 0.3),
isSensor: true,
}
]
},
});
bullet.on(EntityEvent.BLOCK_COLLISION, ({ started }) => { // If the bullet hits a block, despawn it
if (started) {
bullet.despawn();
}
});
bullet.on(EntityEvent.ENTITY_COLLISION, ({ otherEntity, started }) => { // If the bullet hits an enemy, deal damage if it is a Spider
if (!started || otherEntity.name !== 'Spider') {
return;
}
enemyHealth[otherEntity.id!]--;
// Apply knockback, the knockback effect is less if the spider is larger, and more if it is smaller
// because of how the physics simulation applies forces relative to automatically calculated mass from the spider's
// size
const bulletDirection = bullet.directionFromRotation;
const mass = otherEntity.mass;
const knockback = 14 * mass;
otherEntity.applyImpulse({
x: -bulletDirection.x * knockback,
y: 0,
z: -bulletDirection.z * knockback,
});
if (enemyHealth[otherEntity.id!] <= 0) {
console.log('bullet killed spider, despawning spider..');
otherEntity.despawn();
}
bullet.despawn();
});
bullet.spawn(world, coordinate);
// Play a bullet noise that follows the bullet spatially
(new Audio({
uri: 'audio/sfx/shoot.mp3',
playbackRate: 2,
volume: 1,
attachedToEntity: bullet,
referenceDistance: 20,
})).play(world);
return bullet;
}
function spawnPayloadEntity(world: World) {
if (payloadEntity) {
return console.warn('Payload entity already exists!');
}
payloadEntity = new Entity({
controller: new SimpleEntityController(),
name: 'Payload',
modelUri: 'models/payload.gltf',
modelScale: 0.7,
modelLoopedAnimations: [ 'idle' ],
rigidBodyOptions: {
type: RigidBodyType.KINEMATIC_POSITION,
colliders: [
// Get hitbox collider as collider options based on the model & scale.
Collider.optionsFromModelUri('models/payload.gltf', 0.7),
{
shape: ColliderShape.BLOCK, // Create a proximity sensor for movement when players are near.
halfExtents: { x: 3.75, y: 2, z: 6 },
isSensor: true,
collisionGroups: {
belongsTo: [ CollisionGroup.ENTITY_SENSOR ],
collidesWith: [ CollisionGroup.PLAYER, CollisionGroup.ENTITY ],
},
// We use a onCollision handler specific to this sensor, and
// not the whole entity, so we can track the number of players in the payload sensor.
onCollision: (other: BlockType | Entity, started: boolean) => {
if (other instanceof PlayerEntity) {
started ? payloadPlayerEntityCount++ : payloadPlayerEntityCount--;
console.log('player count change', payloadPlayerEntityCount, started, other.name);
} else if (other instanceof Entity && other.name === 'Spider') {
started ? payloadPlayerEntityCount-- : payloadPlayerEntityCount++;
console.log('ENTITY count change', payloadPlayerEntityCount, started, other.name);
}
},
},
],
},
});
payloadEntity.on(EntityEvent.TICK, onTickPathfindPayload);
payloadEntity.spawn(world, PAYLOAD_SPAWN_COORDINATE); // Spawn the payload at the designated spawn coordinate
(new Audio({ // Play a looped idle sound that follows the payload spatially
uri: 'audio/sfx/payload-idle.mp3',
loop: true,
attachedToEntity: payloadEntity,
volume: 0.25,
referenceDistance: 5, // Reference distance affects how loud the audio is relative to a player's proximity to the entity
})).play(world);
}
function spawnSpider(world: World, coordinate: Vector3Like) {
const baseScale = 0.5;
const baseSpeed = 3;
const randomScaleMultiplier = Math.random() * 2 + 1; // Random value between 1 and 3 // Random scale multiplier to make each spider a different size
const targetPlayers = new Set<PlayerEntity>();
const spider = new Entity({
controller: new SimpleEntityController(),
name: 'Spider',
modelUri: 'models/npcs/spider.gltf',
modelLoopedAnimations: [ 'walk' ],
modelScale: baseScale * randomScaleMultiplier,
rigidBodyOptions: {
type: RigidBodyType.DYNAMIC,
enabledRotations: { x: false, y: true, z: false },
colliders: [
// Get hitbox collider as collider options based on the model & scale.
Collider.optionsFromModelUri('models/npcs/spider.gltf', baseScale * randomScaleMultiplier),
{
shape: ColliderShape.CYLINDER,
radius: 20,
halfHeight: 1,
isSensor: true,
tag: 'aggro-sensor', // unecessary but for clarity
collisionGroups: {
belongsTo: [ CollisionGroup.ENTITY_SENSOR ],
collidesWith: [ CollisionGroup.PLAYER ],
},
onCollision: (other: BlockType | Entity, started: boolean) => { // If a player enters or exits the aggro sensor, add or remove them from the target players set
if (other instanceof PlayerEntity) {
started ? targetPlayers.add(other) : targetPlayers.delete(other);
}
},
},
],
},
});
spider.on(EntityEvent.TICK, ({ entity, tickDeltaMs }) => {
onTickPathfindEnemy(entity, targetPlayers, baseSpeed * randomScaleMultiplier, tickDeltaMs);
});
spider.on(EntityEvent.ENTITY_COLLISION, ({ otherEntity, started }) => {
if (started && otherEntity instanceof PlayerEntity && otherEntity.isSpawned) {
const spiderDirection = spider.directionFromRotation;
const knockback = 4 * randomScaleMultiplier;
otherEntity.applyImpulse({
x: -spiderDirection.x * knockback,
y: 4,
z: -spiderDirection.z * knockback,
});
(new Audio({
uri: 'audio/sfx/damage.wav',
volume: 0.2,
attachedToEntity: spider,
referenceDistance: 8,
})).play(world);
damagePlayer(otherEntity);
}
});
spider.spawn(world, coordinate);
// Give the spider a health value relative to its size, bigger = more health
enemyHealth[spider.id!] = 2 * Math.round(randomScaleMultiplier);
}
function onTickPathfindPayload(payload: EventPayloads[EntityEvent.TICK]) { // Movement logic for the payload
const entity = payload.entity;
const speed = started // Set the payload speed relative to the number of players in the payload sensor
? Math.max(Math.min(PAYLOAD_PER_PLAYER_SPEED * payloadPlayerEntityCount, PAYLOAD_MAX_SPEED), 0)
: 0;
if (!speed) { // Play animations based on if its moving or not
entity.stopModelAnimations(Array.from(entity.modelLoopedAnimations).filter(v => v !== 'idle'));
entity.startModelLoopedAnimations([ 'idle' ]);
} else {
entity.stopModelAnimations(Array.from(entity.modelLoopedAnimations).filter(v => v !== 'walk'));
entity.startModelLoopedAnimations([ 'walk' ]);
}
const targetWaypointCoordinate = PAYLOAD_WAYPOINT_COORDINATES[targetWaypointCoordinateIndex];
if (!targetWaypointCoordinate) {
return console.warn('Payload destination reached!! Game won!!');
}
if (!(entity.controller instanceof SimpleEntityController)) { // type guard
return console.warn('Payload entity does not have a SimpleEntityController!');
}
entity.controller.move(targetWaypointCoordinate, speed, {
moveCompleteCallback: () => targetWaypointCoordinateIndex++,
moveIgnoreAxes: { y: true },
});
entity.controller.face(targetWaypointCoordinate, speed / 2);
}
function onTickPathfindEnemy(entity: Entity, targetPlayers: Set<PlayerEntity>, speed: number, _tickDeltaMs: number) {
if (!entity.isSpawned || !payloadEntity) return;
const entityId = entity.id!;
enemyPathfindAccumulators[entityId] ??= 0; // Initialize the accumulator for this enemy if it isn't initialized yet
if (!enemyPathfindingTargets[entityId] || enemyPathfindAccumulators[entityId] >= PATHFIND_ACCUMULATOR_THRESHOLD) {
const targetPlayer = targetPlayers.values().next().value as PlayerEntity | undefined;
enemyPathfindingTargets[entityId] = targetPlayer?.isSpawned
? targetPlayer.position
: payloadEntity.position;
enemyPathfindAccumulators[entityId] -= PATHFIND_ACCUMULATOR_THRESHOLD;
// Make the spider jump if its close to the target player
const currentPosition = entity.position;
const dx = enemyPathfindingTargets[entityId].x - currentPosition.x;
const dz = enemyPathfindingTargets[entityId].z - currentPosition.z;
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance < 10) {
const mass = entity.mass;
entity.applyImpulse({ x: 0, y: (10 * Math.random() + 5) * mass, z: 0 });
}
// Handle Movement
if (!(entity.controller instanceof SimpleEntityController)) {
return console.warn('Enemy entity does not have a SimpleEntityController!');
}
const targetPosition = enemyPathfindingTargets[entityId];
entity.controller.move(targetPosition, speed, { moveIgnoreAxes: { y: true } });
entity.controller.face(targetPosition, speed / 2);
}
enemyPathfindAccumulators[entityId]++;
}
function onTickWithPlayerInput(payload: EventPayloads[BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT]) {
const { entity, input, cameraOrientation } = payload;
if (!entity.world) return;
if (input.ml) {
const world = entity.world;
const direction = Vector3.fromVector3Like(entity.directionFromRotation);
direction.y = Math.sin(cameraOrientation.pitch);
// Adjust horizontal components based on pitch
const cosP = Math.cos(cameraOrientation.pitch);
direction.x = -direction.x * cosP;
direction.z = -direction.z * cosP;
// Normalize the direction vector to unit length
direction.normalize();
entity.startModelOneshotAnimations([ 'shoot_gun_right' ]);
// Adjust bullet origin roughly for camera offset so crosshair is accurate
const bulletOrigin = entity.position;
bulletOrigin.y += 0.65;
const bullet = spawnBullet(world, bulletOrigin, direction);
setTimeout(() => bullet.isSpawned && bullet.despawn(), 2000);
}
}
function damagePlayer(playerEntity: PlayerEntity) {
const chatManager = playerEntity.world!.chatManager;
playerEntityHealth[playerEntity.id!]--;
chatManager.sendPlayerMessage(
playerEntity.player,
`You have been hit! You have ${playerEntityHealth[playerEntity.id!]} health remaining.`,
'FF0000',
);
if (playerEntityHealth[playerEntity.id!] <= 0) {
chatManager.sendPlayerMessage( // Alert the player they've been damaged, since we don't have UI support yet, we just use chat
playerEntity.player,
'You have died!',
'FF0000',
);
playerEntity.despawn();
}
}
function getRotationFromDirection(direction: Vector3Like): QuaternionLike {
// Calculate yaw (rotation around Y-axis)
const yaw = Math.atan2(-direction.x, -direction.z);
// Calculate pitch (rotation around X-axis)
const pitch = Math.asin(direction.y);
// Pre-calculate common terms
const halfYaw = yaw * 0.5;
const halfPitch = pitch * 0.5;
const cosHalfYaw = Math.cos(halfYaw);
const sinHalfYaw = Math.sin(halfYaw);
const cosHalfPitch = Math.cos(halfPitch);
const sinHalfPitch = Math.sin(halfPitch);
// Convert to quaternion
return {
x: sinHalfPitch * cosHalfYaw,
y: sinHalfYaw * cosHalfPitch,
z: sinHalfYaw * sinHalfPitch,
w: cosHalfPitch * cosHalfYaw,
};
}