diff --git a/resources/lang/en.json b/resources/lang/en.json index 05a3c6695..ee02e26c5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -147,6 +147,7 @@ "host_modal": { "title": "Private Lobby", "mode": "Mode", + "team_count": "Number of Teams", "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 599141cd6..5dbe32865 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -23,6 +23,7 @@ export class HostLobbyModal extends LitElement { @state() private selectedDifficulty: Difficulty = Difficulty.Medium; @state() private disableNPCs = false; @state() private gameMode: GameMode = GameMode.FFA; + @state() private teamCount: number = 2; @state() private disableNukes: boolean = false; @state() private bots: number = 400; @state() private infiniteGold: boolean = false; @@ -159,6 +160,33 @@ export class HostLobbyModal extends LitElement { + ${ + this.gameMode === GameMode.FFA + ? "" + : html` + +
+
+ ${translateText("host_modal.team_count")} +
+
+ ${[2, 3, 4, 5, 6, 7].map( + (o) => html` +
this.handleTeamCountSelection(o)} + > +
${o}
+
+ `, + )} +
+
+ ` + } +
@@ -413,6 +441,11 @@ export class HostLobbyModal extends LitElement { this.putGameConfig(); } + private async handleTeamCountSelection(value: number) { + this.teamCount = value; + this.putGameConfig(); + } + private async putGameConfig() { const config = await getServerConfigFromClient(); const response = await fetch( @@ -432,6 +465,7 @@ export class HostLobbyModal extends LitElement { infiniteTroops: this.infiniteTroops, instantBuild: this.instantBuild, gameMode: this.gameMode, + numPlayerTeams: this.teamCount, } as GameConfig), }, ); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 25bf2e525..d7dd675ff 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -30,6 +30,7 @@ export class SinglePlayerModal extends LitElement { @state() private instantBuild: boolean = false; @state() private useRandomMap: boolean = false; @state() private gameMode: GameMode = GameMode.FFA; + @state() private teamCount: number = 2; render() { return html` @@ -136,6 +137,31 @@ export class SinglePlayerModal extends LitElement {
+ ${this.gameMode === GameMode.FFA + ? "" + : html` + +
+
+ ${translateText("host_modal.team_count")} +
+
+ ${[2, 3, 4, 5, 6, 7].map( + (o) => html` +
this.handleTeamCountSelection(o)} + > +
${o}
+
+ `, + )} +
+
+ `} +
@@ -310,6 +336,10 @@ export class SinglePlayerModal extends LitElement { this.gameMode = value; } + private handleTeamCountSelection(value: number) { + this.teamCount = value; + } + private getRandomMap(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); @@ -361,6 +391,7 @@ export class SinglePlayerModal extends LitElement { gameMap: this.selectedMap, gameType: GameType.Singleplayer, gameMode: this.gameMode, + numPlayerTeams: this.teamCount, difficulty: this.selectedDifficulty, disableNPCs: this.disableNPCs, disableNukes: this.disableNukes, diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index 48d241104..42298f1d9 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -1,13 +1,11 @@ -import { blue, red } from "../../../core/configuration/Colors"; import { GameMode, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; export class SpawnTimer implements Layer { - private ratio = 0; - private leftColor = "rgba(0, 128, 255, 0.7)"; - private rightColor = "rgba(0, 0, 0, 0.5)"; + private ratios = [0]; + private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"]; constructor( private game: GameView, @@ -18,27 +16,35 @@ export class SpawnTimer implements Layer { tick() { if (this.game.inSpawnPhase()) { - this.ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns(); + this.ratios[0] = + this.game.ticks() / this.game.config().numSpawnPhaseTurns(); return; } + + this.ratios = []; + this.colors = []; + if (this.game.config().gameConfig().gameMode != GameMode.Team) { - this.ratio = 0; return; } - const numBlueTiles = this.game - .players() - .filter((p) => p.team() == Team.Blue) - .reduce((acc, p) => acc + p.numTilesOwned(), 0); + const teamTiles: Map = new Map(); + for (const player of this.game.players()) { + const team = player.team(); + const tiles = teamTiles.get(team) ?? 0; + const sum = tiles + player.numTilesOwned(); + teamTiles.set(team, sum); + } - const numRedTiles = this.game - .players() - .filter((p) => p.team() == Team.Red) - .reduce((acc, p) => acc + p.numTilesOwned(), 0); - - this.ratio = numBlueTiles / (numBlueTiles + numRedTiles); - this.leftColor = blue.toRgbString(); - this.rightColor = red.toRgbString(); + const theme = this.game.config().theme(); + const total = sumIterator(teamTiles.values()); + if (total === 0) return; + for (const [team, count] of teamTiles) { + const ratio = count / total; + const color = theme.teamColor(team).toRgbString(); + this.ratios.push(ratio); + this.colors.push(color); + } } shouldTransform(): boolean { @@ -46,18 +52,34 @@ export class SpawnTimer implements Layer { } renderLayer(context: CanvasRenderingContext2D) { - if (this.ratio == 0) { - return; - } + if (this.ratios === null) return; + if (this.ratios.length === 0) return; + if (this.colors.length === 0) return; const barHeight = 10; - const barBackgroundWidth = this.transformHandler.width(); + const barWidth = this.transformHandler.width(); - // Draw bar background - context.fillStyle = this.rightColor; - context.fillRect(0, 0, barBackgroundWidth, barHeight); + let x = 0; + let filledRatio = 0; + for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) { + const ratio = this.ratios[i]; + const segmentWidth = barWidth * ratio; - context.fillStyle = this.leftColor; - context.fillRect(0, 0, barBackgroundWidth * this.ratio, barHeight); + context.fillStyle = this.colors[i]; + context.fillRect(x, 0, segmentWidth, barHeight); + + x += segmentWidth; + filledRatio += ratio; + } } } + +function sumIterator(values: MapIterator) { + // To use reduce, we'd need to allocate an array: + // return Array.from(values).reduce((sum, v) => sum + v, 0); + let total = 0; + for (const value of values) { + total += value; + } + return total; +} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index fc5d6fbf9..7fc8e63a5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -121,6 +121,7 @@ const GameConfigSchema = z.object({ infiniteTroops: z.boolean(), instantBuild: z.boolean(), maxPlayers: z.number().optional(), + numPlayerTeams: z.number().optional(), }); const SafeString = z diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts index 26effe285..f2bbfd0fe 100644 --- a/src/core/configuration/Colors.ts +++ b/src/core/configuration/Colors.ts @@ -2,6 +2,11 @@ import { colord, Colord } from "colord"; export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue +export const teal = colord({ h: 172, s: 66, l: 50 }); +export const purple = colord({ h: 271, s: 81, l: 56 }); +export const yellow = colord({ h: 45, s: 93, l: 47 }); +export const orange = colord({ h: 25, s: 95, l: 53 }); +export const green = colord({ h: 128, s: 49, l: 50 }); export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray export const territoryColors: Colord[] = [ diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 42a16dc5a..f53d90aa2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -7,6 +7,7 @@ import { Gold, Player, PlayerInfo, + Team, TerraNullius, Tick, UnitInfo, @@ -65,6 +66,7 @@ export interface Config { instantBuild(): boolean; numSpawnPhaseTurns(): number; userSettings(): UserSettings; + numPlayerTeams(): number; startManpower(playerInfo: PlayerInfo): number; populationIncreaseRate(player: Player | PlayerView): number; @@ -122,6 +124,7 @@ export interface Config { } export interface Theme { + teamColor(team: Team): Colord; territoryColor(playerInfo: PlayerView): Colord; specialBuildingColor(playerInfo: PlayerView): Colord; borderColor(playerInfo: PlayerView): Colord; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 724aea00e..5aa46ca11 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -189,6 +189,9 @@ export class DefaultConfig implements Config { defensePostDefenseBonus(): number { return 5; } + numPlayerTeams(): number { + return this._gameConfig.numPlayerTeams ?? 0; + } spawnNPCs(): boolean { return !this._gameConfig.disableNPCs; } diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 93fbeacc7..83c56c7ea 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -8,9 +8,14 @@ import { blue, botColor, botColors, + green, humanColors, + orange, + purple, red, + teal, territoryColors, + yellow, } from "./Colors"; import { Theme } from "./Config"; @@ -36,15 +41,31 @@ export const pastelTheme = new (class implements Theme { private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 }); + teamColor(team: Team): Colord { + switch (team) { + case Team.Blue: + return blue; + case Team.Red: + return red; + case Team.Teal: + return teal; + case Team.Purple: + return purple; + case Team.Yellow: + return yellow; + case Team.Orange: + return orange; + case Team.Green: + return green; + case Team.Bot: + return botColor; + } + throw new Error(`Missing color for ${team}`); + } + territoryColor(player: PlayerView): Colord { - if (player.team() == Team.Bot) { - return botColor; - } - if (player.team() == Team.Red) { - return red; - } - if (player.team() == Team.Blue) { - return blue; + if (player.team() !== null) { + return this.teamColor(player.team()); } if (player.info().playerType == PlayerType.Human) { return humanColors[simpleHash(player.id()) % humanColors.length]; diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index ad318b5e2..efc6bcd67 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -8,9 +8,14 @@ import { blue, botColor, botColors, + green, humanColors, + orange, + purple, red, + teal, territoryColors, + yellow, } from "./Colors"; import { Theme } from "./Config"; @@ -36,15 +41,31 @@ export const pastelThemeDark = new (class implements Theme { private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 }); + teamColor(team: Team): Colord { + switch (team) { + case Team.Blue: + return blue; + case Team.Red: + return red; + case Team.Teal: + return teal; + case Team.Purple: + return purple; + case Team.Yellow: + return yellow; + case Team.Orange: + return orange; + case Team.Green: + return green; + case Team.Bot: + return botColor; + } + throw new Error(`Missing color for ${team}`); + } + territoryColor(player: PlayerView): Colord { - if (player.team() == Team.Bot) { - return botColor; - } - if (player.team() == Team.Red) { - return red; - } - if (player.team() == Team.Blue) { - return blue; + if (player.team() !== null) { + return this.teamColor(player.team()); } if (player.info().playerType == PlayerType.Human) { return humanColors[simpleHash(player.id()) % humanColors.length]; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 5019e4b15..d0fa0caf2 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -40,6 +40,11 @@ export enum Difficulty { export enum Team { Red = "Red", Blue = "Blue", + Teal = "Teal", + Purple = "Purple", + Yellow = "Yellow", + Orange = "Orange", + Green = "Green", Bot = "Bot", } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 4f56d8da5..8a1051fb3 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -99,6 +99,17 @@ export class GameImpl implements Game { ), ); this.unitGrid = new UnitGrid(this._map); + + if (_config.gameConfig().gameMode === GameMode.Team) { + const numPlayerTeams = _config.numPlayerTeams(); + if (numPlayerTeams < 2) throw new Error("Too few teams!"); + if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal); + if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple); + if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow); + if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange); + if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green); + if (numPlayerTeams >= 8) throw new Error("Too many teams!"); + } } private addHumans() { @@ -106,7 +117,7 @@ export class GameImpl implements Game { this._humans.forEach((p) => this.addPlayer(p)); return; } - const playerToTeam = assignTeams(this._humans); + const playerToTeam = assignTeams(this._humans, this.playerTeams); for (const [playerInfo, team] of playerToTeam.entries()) { if (team == "kicked") { console.warn(`Player ${playerInfo.name} was kicked from team`); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index b63a0949d..d13390db1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -178,7 +178,7 @@ export class PlayerView { return this.data.id; } team(): Team | null { - return this.data.team; + return this.data.team ?? null; } type(): PlayerType { return this.data.playerType; diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index d389f4aef..d12963cd2 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -2,10 +2,10 @@ import { PlayerInfo, Team } from "./Game"; export function assignTeams( players: PlayerInfo[], + teams: Team[], ): Map { const result = new Map(); - let redTeamCount = 0; - let blueTeamCount = 0; + const teamPlayerCount = new Map(); // Group players by clan const clanGroups = new Map(); @@ -23,7 +23,7 @@ export function assignTeams( } } - const maxTeamSize = Math.ceil(players.length / 2); + const maxTeamSize = Math.ceil(players.length / teams.length); // Sort clans by size (largest first) const sortedClans = Array.from(clanGroups.entries()).sort( @@ -33,38 +33,38 @@ export function assignTeams( // First, assign clan players for (const [_, clanPlayers] of sortedClans) { // Try to keep the clan together on the team with fewer players - if (redTeamCount <= blueTeamCount) { - // Assign to red team - for (const player of clanPlayers) { - if (redTeamCount < maxTeamSize) { - redTeamCount++; - result.set(player, Team.Red); - } else { - result.set(player, "kicked"); - } - } - } else { - // Assign to blue team - for (const player of clanPlayers) { - if (blueTeamCount < maxTeamSize) { - blueTeamCount++; - result.set(player, Team.Blue); - } else { - result.set(player, "kicked"); - } + let team: Team | null = null; + let teamSize = 0; + for (const t of teams) { + const p = teamPlayerCount.get(t) ?? 0; + if (team !== null && teamSize <= p) continue; + teamSize = p; + team = t; + } + + for (const player of clanPlayers) { + if (teamSize < maxTeamSize) { + teamSize++; + result.set(player, team); + } else { + result.set(player, "kicked"); } } + teamPlayerCount.set(team, teamSize); } // Then, assign non-clan players to balance teams for (const player of noClanPlayers) { - if (redTeamCount <= blueTeamCount) { - redTeamCount++; - result.set(player, Team.Red); - } else { - blueTeamCount++; - result.set(player, Team.Blue); + let team: Team | null = null; + let teamSize = 0; + for (const t of teams) { + const p = teamPlayerCount.get(t) ?? 0; + if (team !== null && teamSize <= p) continue; + teamSize = p; + team = t; } + teamPlayerCount.set(team, teamSize + 1); + result.set(player, team); } return result; diff --git a/src/server/Master.ts b/src/server/Master.ts index 24a198cdd..bc31cd120 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -237,9 +237,11 @@ async function schedulePublicGame(playlist: MapPlaylist) { } const gameMode = playlist.getNextGameMode(); + const numPlayerTeams = + gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined; // Create the default public game config (from your GameManager) - const defaultGameConfig = { + const defaultGameConfig: GameConfig = { gameMap: map, maxPlayers: config.lobbyMaxPlayers(map), gameType: GameType.Public, @@ -249,9 +251,10 @@ async function schedulePublicGame(playlist: MapPlaylist) { instantBuild: false, disableNPCs: gameMode == GameMode.Team, disableNukes: false, - gameMode: gameMode, + gameMode, + numPlayerTeams, bots: 400, - } as GameConfig; + }; const workerPath = config.workerPath(gameID); diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index 4fb18f8e8..340182e6f 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -1,6 +1,8 @@ import { PlayerInfo, PlayerType, Team } from "../src/core/game/Game"; import { assignTeams } from "../src/core/game/TeamAssignment"; +const teams = [Team.Red, Team.Blue]; + describe("assignTeams", () => { const createPlayer = (id: string, clan?: string): PlayerInfo => { const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; @@ -22,7 +24,7 @@ describe("assignTeams", () => { createPlayer("4"), ]; - const result = assignTeams(players); + const result = assignTeams(players, teams); // Check that players are assigned alternately expect(result.get(players[0])).toEqual(Team.Red); @@ -39,7 +41,7 @@ describe("assignTeams", () => { createPlayer("4", "CLANB"), ]; - const result = assignTeams(players); + const result = assignTeams(players, teams); // Check that clan members are on the same team expect(result.get(players[0])).toEqual(Team.Red); @@ -56,7 +58,7 @@ describe("assignTeams", () => { createPlayer("4"), ]; - const result = assignTeams(players); + const result = assignTeams(players, teams); // Check that clan members are together and non-clan players balance teams expect(result.get(players[0])).toEqual(Team.Red); @@ -75,7 +77,7 @@ describe("assignTeams", () => { createPlayer("6", "CLANB"), ]; - const result = assignTeams(players); + const result = assignTeams(players, teams); // Check that players are kicked when teams are full expect(result.get(players[0])).toEqual(Team.Red); @@ -89,13 +91,13 @@ describe("assignTeams", () => { }); it("should handle empty player list", () => { - const result = assignTeams([]); + const result = assignTeams([], teams); expect(result.size).toBe(0); }); it("should handle single player", () => { const players = [createPlayer("1")]; - const result = assignTeams(players); + const result = assignTeams(players, teams); expect(result.get(players[0])).toEqual(Team.Red); }); @@ -109,7 +111,7 @@ describe("assignTeams", () => { createPlayer("6", "CLANC"), ]; - const result = assignTeams(players); + const result = assignTeams(players, teams); // Check that larger clans are assigned first expect(result.get(players[0])).toEqual(Team.Red); @@ -119,4 +121,48 @@ describe("assignTeams", () => { expect(result.get(players[4])).toEqual(Team.Blue); expect(result.get(players[5])).toEqual(Team.Blue); }); + + it("should distribute players among a larger number of teams", () => { + const players = [ + createPlayer("1", "CLANA"), + createPlayer("2", "CLANA"), + createPlayer("3", "CLANA"), + createPlayer("4", "CLANB"), + createPlayer("5", "CLANB"), + createPlayer("6", "CLANC"), + createPlayer("7"), + createPlayer("8"), + createPlayer("9"), + createPlayer("10"), + createPlayer("11"), + createPlayer("12"), + createPlayer("13"), + createPlayer("14"), + ]; + + const result = assignTeams(players, [ + Team.Red, + Team.Blue, + Team.Teal, + Team.Purple, + Team.Yellow, + Team.Orange, + Team.Green, + ]); + + expect(result.get(players[0])).toEqual(Team.Red); + expect(result.get(players[1])).toEqual(Team.Red); + expect(result.get(players[2])).toEqual("kicked"); + expect(result.get(players[3])).toEqual(Team.Blue); + expect(result.get(players[4])).toEqual(Team.Blue); + expect(result.get(players[5])).toEqual(Team.Teal); + expect(result.get(players[6])).toEqual(Team.Purple); + expect(result.get(players[7])).toEqual(Team.Yellow); + expect(result.get(players[8])).toEqual(Team.Orange); + expect(result.get(players[9])).toEqual(Team.Green); + expect(result.get(players[10])).toEqual(Team.Teal); + expect(result.get(players[11])).toEqual(Team.Purple); + expect(result.get(players[12])).toEqual(Team.Yellow); + expect(result.get(players[13])).toEqual(Team.Orange); + }); });