UNPKG

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
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); }); }