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
text/typescript
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);
}
}
}