- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling - CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers - Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup - TeamManager: centralized team registry replacing goodGuys/badGuys containers - HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel - Map_Player: wired new subsystems, removed legacy container creation - Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated 47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
11 KiB
11 KiB
TeamManager — Architecture Spec
Motivation
Replace the hardcoded binary goodGuys/badGuys Phaser Containers with a proper multi-team system supporting up to 4 players in free-for-all mode. Each unit, building, and control point tracks teamId instead of being sorted into one of two named buckets.
Design Principles
- Minimal surface area — change only what's necessary. Don't refactor for refactoring's sake.
- Per-team economy —
EconomySystemalready tracks per-player. TeamManager routes team→owner for income attribution. - Combat is team-aware —
isEnemy(unitA, unitB)replaces container-name string matching. - UI follows team color — SelectionSystem, BuildingRenderer, ResourceBar pull from TeamManager not hardcoded constants.
- Backward compatible with Colyseus —
playerIdmaps toteamId1:1 for now. Alliances split this later.
TeamManager API
// src/systems/TeamManager.js
export default class TeamManager {
constructor(scene)
// -- Team lifecycle --
createTeam(teamId, color, name) → Team
getTeam(teamId) → Team | undefined
getTeams() → Map<string, Team>
getTeamCount() → number
// -- Player mapping --
setPlayerTeam(playerId, teamId) → void
getPlayerTeam(playerId) → string // returns teamId
getTeamPlayers(teamId) → Set<string>
// -- Entity ownership --
addUnit(unit, teamId) → void // calls unit.setData('teamId', teamId)
removeUnit(unit, teamId) → void
getUnitTeam(unit) → string | null
getTeamUnits(teamId) → Set<Unit>
getAllUnits() → Array<Unit> // for CombatSystem iteration
getAllUnitsGrouped() → Map<string, Set<Unit>> // for per-team processing
// -- Building ownership --
addBuilding(building, teamId) → void
removeBuilding(building, teamId) → void
getBuildingTeam(building) → string | null
getTeamBuildings(teamId) → Set<Building>
// -- Queries --
isEnemy(entityA, entityB) → boolean // resolves team from entity data
isSameTeam(entityA, entityB) → boolean
getEnemyUnits(teamId) → Set<Unit>
getEntityTeam(entity) → string | null // works for Unit, Building, anything with getData('teamId')
// -- Team info --
getTeamColor(teamId) → number
getTeamName(teamId) → string
// -- Cleanup --
destroy() → void
}
// Team value object
class Team {
constructor(id, color, name)
id: string
color: number
name: string
players: Set<string>
units: Set<Unit>
buildings: Set<Building>
}
Migration Table
| Touchpoint | Current | New |
|---|---|---|
| Map_Player.js L109-114, 155-158 | Creates this.goodGuys/this.badGuys Phaser Containers |
Creates this.teamManager = new TeamManager(this) then createTeam('team-A', 0x1d7196, 'Alpha') and createTeam('team-B', 0xd94f4f, 'Bravo') |
| Map_Player.js L158 | this.unitFactory = new UnitFactory(this) |
this.unitFactory = new UnitFactory(this, this.teamManager) |
| Map_Player.js L160-164 | this.orchestrator.systems.combat.registerUnitContainers(this.goodGuys, this.badGuys) |
Remove — TeamManager is passed via constructor injection |
| Map_Player.js L191 | this.orchestrator.systems.controlPointManager.registerUnitContainers(this.goodGuys, this.badGuys) |
Remove — CPs query TeamManager directly |
| Map_Player.js L207-211 | orchestrator.systems.combat.registerUnitContainers(this.goodGuys, this.badGuys) |
Remove |
| Map_Player.js L244-265 | Camera centers on this.goodGuys.list[0] |
this.teamManager.getAllUnits()[0] |
| UnitFactory.js | new UnitFactory(scene) → spawnInfantry(tile, team="player") → scene.goodGuys.add() or scene.badGuys.add() |
Takes teamManager in constructor. spawnInfantry(tile, teamId) → this.teamManager.addUnit(unit, teamId). teamId replaces binary team string. |
| Unit.js L142-168 | getEnemyContainer() / getFriendlyContainer() — string match on container name |
Removed. Replaced by getEnemyUnits() / getFriendlyUnits() delegating to TeamManager |
| Unit.js L322-343 | select() — binary team === 'enemy' red/green tint |
Uses this.getData('teamId') → this.scene.teamManager.getTeamColor(teamId). isSelf = teamId === this.scene.localPlayerTeam |
| CombatSystem.js | registerUnitContainers(goodGuys, enemies) → this._goodGuys/this._enemies |
Constructor takes teamManager. _processCombatGroup iterates teamManager.getAllUnitsGrouped(). acquireTarget uses teamManager.isEnemy(). |
| SystemOrchestrator.js L191-196 | this.scene.goodGuys && this.scene.badGuys → passes to CP manager |
Removed. TeamManager handles it. |
| ControlPointManager.js | registerUnitContainers(goodGuys, enemies) |
Removed. Uses scene.teamManager.getAllUnitsGrouped() for counting. |
| ControlPointStateMachine.js | Counts units in goodGuys/enemies containers by name |
Counts per-team using teamManager.getTeamUnits(teamId) for each team. Owner becomes first team to reach majority. |
| OwnerComponent.js | `team: 'good' | 'enemy', isEnemy(otherTeam)` |
| EconomySystem.js | Per-player Map. | No API change needed. initPlayer(playerId, teamId?) — optional teamId stored. TeamManager routes income to correct team. |
| WinCondition.js | Per-player victory threshold | No direct change — queries economy by playerId which maps to team via TeamManager |
| BuildingStateMachine.js | playerId field |
teamId added alongside playerId |
| ProductionPanel.js | Creates units via UnitFactory | Needs TeamManager injection to pass teamId through production queue |
| ResourceBar.js | Shows economy per-player | Per-player display unchanged. Could show team summary later. |
| SelectionSystem.js | Binary red/green selection | Uses TeamManager color map |
UnitFactory Migration
// Old
spawnInfantry(tile, team = "player") {
const Skin = team === "player" ? Ukrainian_Rifle : Russian_Rifle;
const unit = new Skin(this.scene, tile);
if (team === "player") this.scene.goodGuys.add(unit);
else this.scene.badGuys.add(unit);
return unit;
}
// New
spawnInfantry(tile, teamId) {
const team = this.teamManager.getTeam(teamId);
// Pick skin based on team index: team 0 = Ukrainian, team 1+ = Russian
const skinIndex = Array.from(this.teamManager.getTeams().keys()).indexOf(teamId);
const Skin = skinIndex === 0 ? Ukrainian_Rifle : Russian_Rifle;
const unit = new Skin(this.scene, tile);
this.teamManager.addUnit(unit, teamId);
return unit;
}
Test Plan (TDD)
T1: TeamManager unit tests
File: test/systems/TeamManager.test.js
describe('TeamManager', () => {
describe('createTeam', () => {
test('creates team with id, color, name')
test('returns same Team object on duplicate createTeam()')
test('getTeam returns undefined for unknown teamId')
})
describe('setPlayerTeam / getPlayerTeam', () => {
test('maps playerId to teamId')
test('getPlayerTeam returns undefined for unmapped player')
test('getTeamPlayers returns set of players in team')
})
describe('addUnit / removeUnit / getUnitTeam', () => {
test('addUnit sets unit data and adds to Team.units')
test('removeUnit removes from one team, re-add to another')
test('getUnitTeam returns null for unregistered unit')
test('getAllUnits returns flat array of all units')
test('getAllUnitsGrouped returns Map<teamId, Set<Unit>>')
})
describe('addBuilding / removeBuilding / getBuildingTeam', () => {
test('addBuilding adds to team.buildings')
test('getTeamBuildings returns set')
test('getEntityTeam works for buildings')
})
describe('isEnemy / isSameTeam', () => {
test('same team → false')
test('different team → true')
test('unit without team → null, not enemy')
})
describe('getEnemyUnits', () => {
test('returns all units NOT in given team')
})
describe('serialization', () => {
test('serialize/deserialize round-trip')
test('toJSON/fromJSON preserves teams and player mappings')
})
})
T2: UnitFactory tests (updated)
File: test/systems/UnitFactory.test.js
describe('UnitFactory (with TeamManager)', () => {
test('spawnInfantry adds unit to correct team via TeamManager')
test('spawnTank adds unit to correct team via TeamManager')
test('no longer touches scene.goodGuys or scene.badGuys')
test('remapped enemy skins: team index 0 = Ukrainian, index 1 = Russian, index 2+ = Russian (fallback)')
})
T3: CombatSystem tests (multi-team)
File: test/systems/CombatSystem.test.js
describe('CombatSystem (multi-team)', () => {
test('constructor accepts TeamManager, not containers')
test('_processCombatGroup iterates all team groups')
test('_checkOverlap checks all teams')
test('acquireTarget returns enemy unit from any team')
test('friendly fire prevented by team check')
test('projectile fired by team-A unit hits team-B and team-C units')
test('projectile from team-A unit does NOT hit team-A units')
})
T4: Unit tests (updated)
File: test/entities/Unit.test.js
describe('Unit (team-aware)', () => {
test('getEnemyContainer removed — no longer exists')
test('getFriendlyContainer removed — no longer exists')
test('select() uses teamId for tint color')
test('isEnemy/isSameTeam delegates to TeamManager')
test('Unit created without team returns null team from getUnitTeam')
})
T5: ControlPoint tests (updated)
File: test/systems/ControlPointStateMachine.test.js
describe('ControlPointStateMachine (multi-team)', () => {
test('counts units per team instead of goodGuys/badGuys')
test('NEUTRAL → CONTESTED when units from multiple teams present')
test('CONTESTED → CAPTURED when one team has majority')
test('registerUnitContainers removed from public API')
})
T6: Map_Player E2E (updated)
File: test/scenes/Map_Player.test.js or Playwright
describe('Map_Player (multi-team)', () => {
test('creates TeamManager, not goodGuys/badGuys containers')
test('spawns 3-team FFA: each team gets different color')
test('combat resolves correctly across all 3 teams')
test('control point captures correctly with 3 teams')
test('UI shows correct team colors for all 3 teams')
})
T7: Integration — 3-team FFA smoke test (Playwright)
describe('3-team FFA E2E', () => {
test('create game, connect 3 players')
test('each player is on different team')
test('economy tracked per-team')
test('buildings render in team color')
test('units spawn in team color')
test('combat resolves: team-A kills team-B, team-C unaffected')
test('control point captures correctly')
})
Migration order
- Create
src/systems/TeamManager.js+ tests - Update
UnitFactoryto accept TeamManager - Rewire
Map_Playerto create TeamManager instead of containers - Rewire
CombatSystemfor multi-team - Rewire
ControlPointManager/ControlPointStateMachine - Update
Unit.js(remove binary checks) - Update
OwnerComponent(teamId replaces team string) - Integration + E2E