hytopia
Version:
The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.
372 lines (307 loc) • 10.9 kB
text/typescript
import {
Audio,
BlockType,
Collider,
ColliderShape,
Entity,
EntityEvent,
GameServer,
RigidBodyType,
startServer,
Player,
PlayerEntity,
PlayerEvent,
SceneUI,
World,
CollisionGroup,
} from 'hytopia';
import GAME_WALL_SHAPES from './wall-shapes';
import worldMap from './assets/map.json';
// Game constants
enum GameWallDirection {
NORTH = 'NORTH',
SOUTH = 'SOUTH',
WEST = 'WEST',
EAST = 'EAST',
}
const GAME_CONFIG = {
LEVEL_DURATION: 10,
START_DELAY: 30,
WALL_COLLISION_GROUP: CollisionGroup.GROUP_1,
WALL_DESPAWN_DISTANCE: 18,
POSITIONS: {
START: { x: 1, y: 22, z: 1 },
JOIN_NPC: { x: 1, y: 35, z: -7 },
PLAYER_SPAWN: { x: 1, y: 35, z: 2 },
WALLS: {
[GameWallDirection.NORTH]: { x: 1, y: 20, z: -10 },
[GameWallDirection.SOUTH]: { x: 1, y: 20, z: 12 },
[GameWallDirection.WEST]: { x: -10, y: 20, z: 1 },
[GameWallDirection.EAST]: { x: 12, y: 20, z: 1 },
}
},
WALL_VELOCITIES: {
[GameWallDirection.NORTH]: { x: 0, y: 0, z: 1 },
[GameWallDirection.SOUTH]: { x: 0, y: 0, z: -1 },
[GameWallDirection.WEST]: { x: 1, y: 0, z: 0 },
[GameWallDirection.EAST]: { x: -1, y: 0, z: 0 },
}
};
// Game state
const QUEUED_PLAYER_ENTITIES = new Set<PlayerEntity>();
const GAME_PLAYER_ENTITIES = new Set<PlayerEntity>();
let gameLevel = 1;
let gameState: 'awaitingPlayers' | 'starting' | 'inProgress' = 'awaitingPlayers';
let gameCountdownStartTime: number | null = null;
let gameInterval: NodeJS.Timeout;
let gameStartTime: number | null = null;
let gameUiState: object = {};
// Audio
const gameActiveAudio = new Audio({
uri: 'audio/music.mp3',
loop: true,
volume: 0.2,
});
const gameInactiveAudio = new Audio({
uri: 'audio/music/hytopia-main.mp3',
loop: true,
volume: 0.2,
});
startServer(world => {
world.loadMap(worldMap);
world.on(PlayerEvent.JOINED_WORLD, ({ player }) => onPlayerJoin(world, player));
world.on(PlayerEvent.LEFT_WORLD, ({ player }) => onPlayerLeave(world, player));
spawnJoinNpc(world);
gameInactiveAudio.play(world);
});
function onPlayerJoin(world: World, player: Player) {
player.ui.load('ui/index.html');
player.ui.sendData(gameUiState);
const playerEntity = new PlayerEntity({
player,
name: 'Player',
modelUri: 'models/players/player.gltf',
modelLoopedAnimations: ['idle'],
modelScale: 0.5,
});
playerEntity.spawn(world, GAME_CONFIG.POSITIONS.PLAYER_SPAWN);
playerEntity.setCollisionGroupsForSolidColliders({
belongsTo: [CollisionGroup.PLAYER],
collidesWith: [CollisionGroup.BLOCK, CollisionGroup.ENTITY, CollisionGroup.ENTITY_SENSOR, GAME_CONFIG.WALL_COLLISION_GROUP],
});
playerEntity.setCollisionGroupsForSensorColliders({
belongsTo: [CollisionGroup.ENTITY_SENSOR],
collidesWith: [CollisionGroup.BLOCK, GAME_CONFIG.WALL_COLLISION_GROUP],
});
playerEntity.on(EntityEvent.TICK, () => {
if (playerEntity.position.y < 5) {
killPlayer(playerEntity);
}
});
}
function onPlayerLeave(world: World, player: Player) {
world.entityManager.getPlayerEntitiesByPlayer(player).forEach(entity => {
removePlayerFromQueue(entity);
killPlayer(entity);
entity.despawn();
});
}
function spawnJoinNpc(world: World) {
const joinNpc = new Entity({
name: 'Join NPC',
modelUri: 'models/npcs/mindflayer.gltf',
modelLoopedAnimations: ['idle'],
modelScale: 0.4,
rigidBodyOptions: {
rotation: { x: 0, y: 1, z: 0, w: 0 },
enabledPositions: { x: false, y: true, z: false },
enabledRotations: { x: false, y: true, z: false },
colliders: [
Collider.optionsFromModelUri('models/npcs/mindflayer.gltf', 0.4),
{
shape: ColliderShape.CYLINDER,
radius: 2,
halfHeight: 2,
isSensor: true,
onCollision: (other: BlockType | Entity, started: boolean) => {
if (other instanceof PlayerEntity && started) {
addPlayerEntityToQueue(world, other);
}
}
}
],
},
});
joinNpc.spawn(world, GAME_CONFIG.POSITIONS.JOIN_NPC);
const sceneUi = new SceneUI({
templateId: 'join-npc-ui',
attachedToEntity: joinNpc,
offset: { x: 0, y: 2.5, z: 0 },
});
sceneUi.load(world);
}
function addPlayerEntityToQueue(world: World, playerEntity: PlayerEntity) {
if (QUEUED_PLAYER_ENTITIES.has(playerEntity)) return;
QUEUED_PLAYER_ENTITIES.add(playerEntity);
world.chatManager.sendPlayerMessage(playerEntity.player, 'You have joined the next game queue!', '00FF00');
uiUpdate({ queueCount: QUEUED_PLAYER_ENTITIES.size });
if (gameState === 'awaitingPlayers' && QUEUED_PLAYER_ENTITIES.size > 0) {
queueGame(world);
}
const queuedSceneUi = new SceneUI({
templateId: 'player-queued',
attachedToEntity: playerEntity,
offset: { x: 0, y: 1, z: 0 },
});
queuedSceneUi.load(world);
}
function queueGame(world: World) {
gameState = 'starting';
gameCountdownStartTime = Date.now();
uiUpdate({
gameState,
gameCountdownStartTime,
countdown: GAME_CONFIG.START_DELAY,
});
setTimeout(() => {
QUEUED_PLAYER_ENTITIES.forEach(playerEntity => {
playerEntity.setPosition(GAME_CONFIG.POSITIONS.START);
GAME_PLAYER_ENTITIES.add(playerEntity);
world.sceneUIManager.getAllEntityAttachedSceneUIs(playerEntity).forEach(sceneUi => {
sceneUi.unload();
});
});
QUEUED_PLAYER_ENTITIES.clear();
uiUpdate({ queueCount: 0 });
startGame(world);
}, GAME_CONFIG.START_DELAY * 1000);
}
function startGame(world: World) {
gameState = 'inProgress';
gameStartTime = Date.now();
gameInactiveAudio.pause();
gameActiveAudio.play(world, true);
const gameLoop = () => {
if (!gameStartTime) return clearTimeout(gameInterval);
const elapsedTime = Date.now() - gameStartTime;
const level = Math.floor(elapsedTime / (GAME_CONFIG.LEVEL_DURATION * 1000)) + 1;
const speedModifier = 1 + (level * 0.15);
const spawnInterval = Math.max(2000, 10000 - (level * 1000));
const directions = Object.values(GameWallDirection);
const randomDirection = directions[Math.floor(Math.random() * directions.length)];
generateWall(world, randomDirection, speedModifier);
clearTimeout(gameInterval);
gameInterval = setTimeout(gameLoop, spawnInterval);
if (level > gameLevel) {
gameLevel = level;
uiUpdate({ gameLevel });
}
}
gameLoop();
uiUpdate({
gameState: 'inProgress',
gameStartTime,
playersRemaining: Array.from(GAME_PLAYER_ENTITIES).map(playerEntity => playerEntity.player.username),
});
}
function endGame() {
clearTimeout(gameInterval);
gameState = 'awaitingPlayers';
gameLevel = 1;
const winner = Array.from(GAME_PLAYER_ENTITIES)[0];
if (winner) {
winner.setPosition(GAME_CONFIG.POSITIONS.PLAYER_SPAWN);
winner.world!.chatManager.sendBroadcastMessage(`${winner.player.username} wins!`, '00FF00');
}
GAME_PLAYER_ENTITIES.clear();
gameActiveAudio.pause();
gameInactiveAudio.play(gameInactiveAudio.world!, true);
uiUpdate({ gameState: 'awaitingPlayers', gameLevel: 1 });
}
function killPlayer(playerEntity: PlayerEntity) {
if (!GAME_PLAYER_ENTITIES.has(playerEntity)) return;
playerEntity.setPosition(GAME_CONFIG.POSITIONS.PLAYER_SPAWN);
GAME_PLAYER_ENTITIES.delete(playerEntity);
if (GAME_PLAYER_ENTITIES.size <= 0) {
endGame();
}
uiUpdate({
playersRemaining: Array.from(GAME_PLAYER_ENTITIES).map(playerEntity => playerEntity.player.username),
});
}
function removePlayerFromQueue(playerEntity: PlayerEntity) {
if (!QUEUED_PLAYER_ENTITIES.has(playerEntity)) return;
QUEUED_PLAYER_ENTITIES.delete(playerEntity);
uiUpdate({ queueCount: QUEUED_PLAYER_ENTITIES.size });
}
function generateWall(world: World, direction: GameWallDirection, speedModifier: number = 1) {
const selectedShape = GAME_WALL_SHAPES[Math.floor(Math.random() * GAME_WALL_SHAPES.length)];
const isNorthSouth = direction === GameWallDirection.NORTH || direction === GameWallDirection.SOUTH;
for (let y = 0; y < selectedShape.length; y++) {
for (let x = 0; x < selectedShape[y].length; x++) {
if (selectedShape[y][x] === 0) continue;
const xOffset = (x - selectedShape[y].length / 2) * 1 + 0.5;
const yOffset = (selectedShape.length - y - 1) * 1 + 0.5;
const wallSegment = new Entity({
blockTextureUri: selectedShape[y][x] === 2 ? 'blocks/dirt.png' :
selectedShape[y][x] === 3 ? 'blocks/sand.png' :
'blocks/oak-leaves.png',
blockHalfExtents: {
x: isNorthSouth ? 0.5 : 0.5,
y: 0.5,
z: isNorthSouth ? 0.5 : 0.5
},
rigidBodyOptions: {
type: RigidBodyType.KINEMATIC_VELOCITY,
}
});
const spawnPosition = { ...GAME_CONFIG.POSITIONS.WALLS[direction] };
if (isNorthSouth) {
spawnPosition.x += xOffset;
spawnPosition.y += yOffset;
if (selectedShape[y][x] === 2) {
spawnPosition.z += direction === GameWallDirection.NORTH ? 1 : -1;
} else if (selectedShape[y][x] === 3) {
spawnPosition.z += direction === GameWallDirection.NORTH ? 2 : -2;
}
} else {
spawnPosition.z += xOffset;
spawnPosition.y += yOffset;
if (selectedShape[y][x] === 2) {
spawnPosition.x += direction === GameWallDirection.WEST ? 1 : -1;
} else if (selectedShape[y][x] === 3) {
spawnPosition.x += direction === GameWallDirection.WEST ? 2 : -2;
}
}
wallSegment.spawn(world, spawnPosition);
wallSegment.setCollisionGroupsForSolidColliders({
belongsTo: [GAME_CONFIG.WALL_COLLISION_GROUP],
collidesWith: [CollisionGroup.PLAYER, CollisionGroup.ENTITY_SENSOR],
});
wallSegment.setLinearVelocity({
x: GAME_CONFIG.WALL_VELOCITIES[direction].x * speedModifier,
y: GAME_CONFIG.WALL_VELOCITIES[direction].y * speedModifier,
z: GAME_CONFIG.WALL_VELOCITIES[direction].z * speedModifier,
});
wallSegment.on(EntityEvent.TICK, () => {
if (!wallSegment.isSpawned) return;
const position = wallSegment.position;
if (Math.abs(position.x - spawnPosition.x) > GAME_CONFIG.WALL_DESPAWN_DISTANCE ||
Math.abs(position.z - spawnPosition.z) > GAME_CONFIG.WALL_DESPAWN_DISTANCE) {
wallSegment.setLinearVelocity({ x: 0, y: -32, z: 0 });
setTimeout(() => {
if (wallSegment.isSpawned) {
wallSegment.despawn();
}
}, 1000);
}
});
}
}
}
function uiUpdate(data: object) {
gameUiState = { ...gameUiState, ...data };
GameServer.instance.playerManager.getConnectedPlayers().forEach(player => {
player.ui.sendData(data);
});
}