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