UNPKG

hytopia

Version:

The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.

422 lines (349 loc) 12.4 kB
import { GameServer, Player, Quaternion, Vector3Like, World, } from 'hytopia'; import worldMap from '../assets/map.json'; import { BEDROCK_BLOCK_ID, CHEST_SPAWNS, CHEST_SPAWNS_AT_START, CHEST_DROP_INTERVAL_MS, CHEST_DROP_REGION_AABB, GAME_DURATION_MS, ITEM_SPAWNS, ITEM_SPAWNS_AT_START, ITEM_SPAWN_ITEMS, MINIMUM_PLAYERS_TO_START, SPAWN_REGION_AABB, } from '../gameConfig'; import GamePlayerEntity from './GamePlayerEntity'; import ChestEntity from './ChestEntity'; import ItemFactory from './ItemFactory'; export default class GameManager { public static readonly instance = new GameManager(); public world: World | undefined; private _chestDropInterval: NodeJS.Timeout | undefined; private _gameStartAt: number = 0; private _gameTimer: NodeJS.Timeout | undefined; private _playerCount: number = 0; private _restartTimer: NodeJS.Timeout | undefined; private _killCounter: Map<string, number> = new Map(); private _gameActive: boolean = false; public get isGameActive(): boolean { return this._gameActive; } public get playerCount(): number { return this._playerCount; } public set playerCount(value: number) { this._playerCount = value; this._updatePlayerCountUI(); } /** * Sets up the game world and waits for players to join */ public setupGame(world: World) { this.world = world; this._spawnBedrock(world); this._waitForPlayersToStart(); } /** * Starts a new game round */ public startGame() { if (!this.world) return; // Clean up any previous game state this._cleanup(); // Set game as active this._gameActive = true; this._gameStartAt = Date.now(); // Spawn initial game elements this._spawnStartingChests(); this._spawnStartingItems(); this._startChestDropInterval(); // Move all players to random spawn positions this.world.entityManager.getAllPlayerEntities().forEach(playerEntity => { playerEntity.setPosition(this.getRandomSpawnPosition()); playerEntity.player.ui.sendData({ type: 'game-start' }); this._sendGameStartAnnouncements(playerEntity.player); }); // Set game timer this._gameTimer = setTimeout(() => this.endGame(), GAME_DURATION_MS); // Sync UI for all players this._syncAllPlayersUI(); } /** * Ends the current game round and schedules the next one */ public endGame() { if (!this.world || !this._gameActive) return; this._gameActive = false; this.world.chatManager.sendBroadcastMessage('Game over! Starting the next round in 10 seconds...', 'FF0000'); this._focusWinningPlayer(); // Clear any existing restart timer if (this._restartTimer) { clearTimeout(this._restartTimer); } this._restartTimer = setTimeout(() => this.startGame(), 10 * 1000); } /** * Spawns a player entity in the world */ public spawnPlayerEntity(player: Player) { if (!this.world) return; const playerEntity = new GamePlayerEntity(player); playerEntity.spawn(this.world, this.getRandomSpawnPosition()); // Sync UI for the new player this.syncTimer(player); this.syncLeaderboard(player); // Send start announcement if game is active if (this._gameActive) { player.ui.sendData({ type: 'game-start' }); this._sendGameStartAnnouncements(player); } } /** * Increments kill count for a player and updates the leaderboard */ public addKill(playerId: string): void { const killCount = this._killCounter.get(playerId) ?? 0; const newKillCount = killCount + 1; this._killCounter.set(playerId, newKillCount); this._updateLeaderboardUI(playerId, newKillCount); } /** * Gets a random spawn position within the defined spawn region */ public getRandomSpawnPosition(): Vector3Like { return { x: SPAWN_REGION_AABB.min.x + Math.random() * (SPAWN_REGION_AABB.max.x - SPAWN_REGION_AABB.min.x), y: SPAWN_REGION_AABB.min.y + Math.random() * (SPAWN_REGION_AABB.max.y - SPAWN_REGION_AABB.min.y), z: SPAWN_REGION_AABB.min.z + Math.random() * (SPAWN_REGION_AABB.max.z - SPAWN_REGION_AABB.min.z), }; } /** * Returns the current kill counts for all players */ public getKillCounts(): Record<string, number> { return Object.fromEntries(this._killCounter); } /** * Syncs the leaderboard UI for a specific player */ public syncLeaderboard(player: Player) { if (!this.world) return; player.ui.sendData({ type: 'leaderboard-sync', killCounts: this.getKillCounts(), }); } /** * Syncs the game timer UI for a specific player */ public syncTimer(player: Player) { if (!this.world || !this._gameStartAt) return; player.ui.sendData({ type: 'timer-sync', startedAt: this._gameStartAt, endsAt: this._gameStartAt + GAME_DURATION_MS, }); } /** * Resets the leaderboard and syncs it for all players */ public resetLeaderboard() { if (!this.world) return; this._killCounter.clear(); GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => { this.syncLeaderboard(player); }); } /** * Cleans up the game state for a new round */ private _cleanup() { if (!this.world) return; // Reset map to initial state this.world.loadMap(worldMap); // Reset player state this.world.entityManager.getAllPlayerEntities().forEach(playerEntity => { if (playerEntity instanceof GamePlayerEntity) { playerEntity.setActiveInventorySlotIndex(0); // reset to pickaxe at slot 0 playerEntity.dropAllInventoryItems(); playerEntity.resetCamera(); playerEntity.resetMaterials(); playerEntity.health = 100; playerEntity.shield = 0; } }); // Remove non-player entities except pickaxes this.world.entityManager.getAllEntities().forEach(entity => { if (!(entity instanceof GamePlayerEntity) && entity.tag !== 'pickaxe') { // allow 1 event loop for drop to resolve, there's some // weird bug here otherwise we need to investigate later. setTimeout(() => { if (entity.isSpawned) { entity.despawn(); } }, 0); } }); // Clear timers if (this._gameTimer) { clearTimeout(this._gameTimer); this._gameTimer = undefined; } if (this._chestDropInterval) { clearInterval(this._chestDropInterval); this._chestDropInterval = undefined; } // Reset leaderboard this.resetLeaderboard(); } public _focusWinningPlayer() { if (!this.world) return; // Find player with most kills let highestKills = 0; let winningPlayer = ''; this._killCounter.forEach((kills, player) => { if (kills > highestKills) { highestKills = kills; winningPlayer = player; } }); // Get winning player entity const winningPlayerEntity = this.world.entityManager .getAllPlayerEntities() .find(entity => entity.player.username === winningPlayer); if (!winningPlayerEntity) return; this.world.entityManager.getAllPlayerEntities().forEach(playerEntity => { if (playerEntity instanceof GamePlayerEntity) { if (playerEntity.player.username !== winningPlayer) { // don't change camera for the winner playerEntity.focusCameraOnPlayer(winningPlayerEntity as GamePlayerEntity); } playerEntity.player.ui.sendData({ type: 'announce-winner', username: winningPlayer, }); } }); } /** * Syncs UI for all connected players */ private _syncAllPlayersUI() { if (!this.world) return; const players = GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world); players.forEach(player => { this.syncTimer(player); this.syncLeaderboard(player); }); } /** * Sends game start announcements to a specific player */ private _sendGameStartAnnouncements(player: Player) { if (!this.world) return; this.world.chatManager.sendPlayerMessage(player, 'Game started - most kills wins!', '00FF00'); this.world.chatManager.sendPlayerMessage(player, '- Search for chests and weapons to survive'); this.world.chatManager.sendPlayerMessage(player, '- Break blocks with your pickaxe to gain materials'); this.world.chatManager.sendPlayerMessage(player, '- Right click to spend 3 materials to place a block'); this.world.chatManager.sendPlayerMessage(player, '- Some weapons zoom with "Z". Drop items with "Q"'); } /** * Creates bedrock floor for the game world */ private _spawnBedrock(world: World) { for (let x = -50; x <= 50; x++) { for (let z = -50; z <= 50; z++) { world.chunkLattice.setBlock({ x, y: -1, z }, BEDROCK_BLOCK_ID); } } } /** * Spawns initial chests at random positions */ private _spawnStartingChests() { if (!this.world) return; const shuffledChestSpawns = [...CHEST_SPAWNS].sort(() => Math.random() - 0.5); const selectedChestSpawns = shuffledChestSpawns.slice(0, CHEST_SPAWNS_AT_START); for (const spawn of selectedChestSpawns) { const chest = new ChestEntity(); chest.spawn(this.world, spawn.position, spawn.rotation); } } /** * Spawns initial items at random positions */ private _spawnStartingItems() { if (!this.world) return; const shuffledItemSpawns = [...ITEM_SPAWNS].sort(() => Math.random() - 0.5); const selectedItemSpawns = shuffledItemSpawns.slice(0, ITEM_SPAWNS_AT_START); const totalWeight = ITEM_SPAWN_ITEMS.reduce((sum, item) => sum + item.pickWeight, 0); selectedItemSpawns.forEach(async spawn => { // Select random item based on weight let random = Math.random() * totalWeight; let selectedItem = ITEM_SPAWN_ITEMS[0]; for (const item of ITEM_SPAWN_ITEMS) { random -= item.pickWeight; if (random <= 0) { selectedItem = item; break; } } const item = await ItemFactory.createItem(selectedItem.itemId); item.spawn(this.world!, spawn.position, Quaternion.fromEuler(0, Math.random() * 360 - 180, 0)); }); } /** * Starts the interval for dropping chests during the game */ private _startChestDropInterval() { if (this._chestDropInterval) { clearInterval(this._chestDropInterval); } this._chestDropInterval = setInterval(() => { if (!this.world || !this._gameActive) return; const randomPosition = { x: Math.floor(Math.random() * (CHEST_DROP_REGION_AABB.max.x - CHEST_DROP_REGION_AABB.min.x + 1)) + CHEST_DROP_REGION_AABB.min.x, y: CHEST_DROP_REGION_AABB.min.y, z: Math.floor(Math.random() * (CHEST_DROP_REGION_AABB.max.z - CHEST_DROP_REGION_AABB.min.z + 1)) + CHEST_DROP_REGION_AABB.min.z, }; const chest = new ChestEntity(); const randomRotation = Quaternion.fromEuler(0, [0, 90, -90, 180][Math.floor(Math.random() * 4)], 0); chest.spawn(this.world, randomPosition, randomRotation); }, CHEST_DROP_INTERVAL_MS); } /** * Updates the leaderboard UI for all players */ private _updateLeaderboardUI(username: string, killCount: number) { if (!this.world) return; GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => { player.ui.sendData({ type: 'leaderboard-update', username, killCount, }); }); } private _updatePlayerCountUI() { setTimeout(() => { // have to wait 1 tick, we need to figure out this race condition later if (!this.world) return; GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).forEach(player => { player.ui.sendData({ type: 'players-count', count: this._playerCount }); }); }, 25); } /** * Waits for enough players to join before starting the game */ private _waitForPlayersToStart() { if (!this.world) return; const connectedPlayers = GameServer.instance.playerManager.getConnectedPlayersByWorld(this.world).length; if (connectedPlayers >= MINIMUM_PLAYERS_TO_START) { this.startGame(); } else { setTimeout(() => this._waitForPlayersToStart(), 1000); } } }