diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index fd3f520ed..55a57e61f 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -259,13 +259,12 @@ export class TerritoryLayer implements Layer { if (this.game.isBorder(tile)) { const playerIsFocused = owner && this.game.focusedPlayer() == owner; if ( - this.game - .nearbyUnits( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - ) - .filter((u) => u.unit.owner() == owner).length > 0 + this.game.hasUnitNearby( + tile, + this.game.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ) ) { const borderColors = this.theme.defendedBorderColors(owner); const x = this.game.x(tile); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 877ec19cf..cc9b6f6d8 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -391,6 +391,15 @@ export class GameView implements GameMap { }>; } + hasUnitNearby( + tile: TileRef, + searchRange: number, + type: UnitType, + playerId: PlayerID, + ) { + return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId); + } + myClientID(): ClientID { return this._myClientID; } diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index 79b7b0b32..e75753d3c 100644 --- a/src/core/game/UnitGrid.ts +++ b/src/core/game/UnitGrid.ts @@ -1,4 +1,4 @@ -import { Unit, UnitType } from "./Game"; +import { PlayerID, Unit, UnitType } from "./Game"; import { GameMap, TileRef } from "./GameMap"; import { UnitView } from "./GameView"; @@ -50,6 +50,46 @@ export class UnitGrid { ); } + // Compute the exact cells in range of tile + private getCellsInRange(tile: TileRef, range: number) { + const x = this.gm.x(tile); + const y = this.gm.y(tile); + const cellSize = this.cellSize; + const [gridX, gridY] = this.getGridCoords(x, y); + const startGridX = Math.max( + 0, + gridX - Math.ceil((range - (x % cellSize)) / cellSize), + ); + const endGridX = Math.min( + this.grid[0].length - 1, + gridX + Math.ceil((range - (cellSize - (x % cellSize))) / cellSize), + ); + const startGridY = Math.max( + 0, + gridY - Math.ceil((range - (y % cellSize)) / cellSize), + ); + const endGridY = Math.min( + this.grid.length - 1, + gridY + Math.ceil((range - (cellSize - (y % cellSize))) / cellSize), + ); + + return { startGridX, endGridX, startGridY, endGridY }; + } + + private squaredDistanceFromTile( + unit: Unit | UnitView, + tile: TileRef, + ): number { + const x = this.gm.x(tile); + const y = this.gm.y(tile); + const tileX = this.gm.x(unit.tile()); + const tileY = this.gm.y(unit.tile()); + const dx = tileX - x; + const dy = tileY - y; + const distSquared = dx * dx + dy * dy; + return distSquared; + } + // Get all units within range of a point // Returns [unit, distanceSquared] pairs for efficient filtering nearbyUnits( @@ -57,38 +97,56 @@ export class UnitGrid { searchRange: number, types: UnitType | UnitType[], ): Array<{ unit: Unit | UnitView; distSquared: number }> { - const x = this.gm.x(tile); - const y = this.gm.y(tile); - const [gridX, gridY] = this.getGridCoords(x, y); - const cellsToCheck = Math.ceil(searchRange / this.cellSize); const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = []; - - const startGridX = Math.max(0, gridX - cellsToCheck); - const endGridX = Math.min(this.grid[0].length - 1, gridX + cellsToCheck); - const startGridY = Math.max(0, gridY - cellsToCheck); - const endGridY = Math.min(this.grid.length - 1, gridY + cellsToCheck); - + const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange( + tile, + searchRange, + ); const rangeSquared = searchRange * searchRange; const typeSet = Array.isArray(types) ? new Set(types) : new Set([types]); - for (let cy = startGridY; cy <= endGridY; cy++) { for (let cx = startGridX; cx <= endGridX; cx++) { for (const unit of this.grid[cy][cx]) { - if (unit.isActive()) { - const tileX = this.gm.x(unit.tile()); - const tileY = this.gm.y(unit.tile()); - const dx = tileX - x; - const dy = tileY - y; - const distSquared = dx * dx + dy * dy; - - if (distSquared <= rangeSquared && typeSet.has(unit.type())) { + if (typeSet.has(unit.type()) && unit.isActive()) { + const distSquared = this.squaredDistanceFromTile(unit, tile); + if (distSquared <= rangeSquared) { nearby.push({ unit, distSquared }); } } } } } - return nearby; } + + // Return true if it finds an owned specific unit in range + hasUnitNearby( + tile: TileRef, + searchRange: number, + type: UnitType, + playerId: PlayerID, + ): boolean { + const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange( + tile, + searchRange, + ); + const rangeSquared = searchRange * searchRange; + for (let cy = startGridY; cy <= endGridY; cy++) { + for (let cx = startGridX; cx <= endGridX; cx++) { + for (const unit of this.grid[cy][cx]) { + if ( + unit.type() == type && + unit.owner().id() == playerId && + unit.isActive() + ) { + const distSquared = this.squaredDistanceFromTile(unit, tile); + if (distSquared <= rangeSquared) { + return true; + } + } + } + } + } + return false; + } } diff --git a/tests/UnitGrid.test.ts b/tests/UnitGrid.test.ts new file mode 100644 index 000000000..0fcfbf11f --- /dev/null +++ b/tests/UnitGrid.test.ts @@ -0,0 +1,139 @@ +import { PlayerInfo, PlayerType, UnitType } from "../src/core/game/Game"; +import { UnitGrid } from "../src/core/game/UnitGrid"; +import { setup } from "./util/Setup"; + +async function checkRange( + mapName: string, + unitPosX: number, + rangeCheck: number, + range: number, +) { + const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); + const grid = new UnitGrid(game.map()); + const player = game.addPlayer( + new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + ); + const unitTile = game.map().ref(unitPosX, 0); + grid.addUnit(player.buildUnit(UnitType.DefensePost, unitTile, {})); + const tileToCheck = game.map().ref(rangeCheck, 0); + return grid.hasUnitNearby( + tileToCheck, + range, + UnitType.DefensePost, + "test_id", + ); +} + +async function nearbyUnits( + mapName: string, + unitPosX: number, + rangeCheck: number, + range: number, + unitTypes: UnitType[], +) { + const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); + const grid = new UnitGrid(game.map()); + const player = game.addPlayer( + new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + ); + const unitTile = game.map().ref(unitPosX, 0); + for (const unitType of unitTypes) { + grid.addUnit(player.buildUnit(unitType, unitTile, {})); + } + const tileToCheck = game.map().ref(rangeCheck, 0); + return grid.nearbyUnits(tileToCheck, range, unitTypes); +} + +describe("Unit Grid range tests", () => { + const hasUnitCases = [ + ["Plains", 0, 10, 0, true], // Same spot + ["Plains", 0, 10, 10, true], // Exactly on the range + ["Plains", 0, 10, 11, false], // Exactly 1px outside + ["BigPlains", 0, 198, 42, true], // Inside huge range + ["BigPlains", 0, 198, 199, false], // Exactly 1px outside huge range + ]; + + describe("Is unit in range", () => { + test.each(hasUnitCases)( + "on %p map, look if unit at position %p with a range of %p is in range of %p position, returns %p", + async ( + mapName: string, + unitPosX: number, + range: number, + rangeCheck: number, + expectedResult: boolean, + ) => { + const result = await checkRange(mapName, unitPosX, rangeCheck, range); + expect(result).toBe(expectedResult); + }, + ); + }); + + const unitsInRangeCases = [ + ["Plains", 0, 10, 0, [UnitType.Warship], 1], // Same spot + ["Plains", 0, 10, 0, [UnitType.City, UnitType.Port], 2], // 2 in range + ["Plains", 0, 10, 0, [], 0], // no unit + ["Plains", 0, 10, 10, [UnitType.City], 1], // Exactly on the range + ["Plains", 0, 10, 11, [UnitType.DefensePost], 0], // 1px outside + ["BigPlains", 0, 198, 42, [UnitType.TradeShip], 1], // Inside huge range + ["BigPlains", 0, 198, 199, [UnitType.TransportShip], 0], // 1px outside + ]; + + describe("Retrieve all units in range", () => { + test.each(unitsInRangeCases)( + "on %p map, look if unit at position %p with a range of %p is in range of %p position, returns %p", + async ( + mapName: string, + unitPosX: number, + range: number, + rangeCheck: number, + units: UnitType[], + expectedResult: number, + ) => { + const result = await nearbyUnits( + mapName, + unitPosX, + rangeCheck, + range, + units, + ); + expect(result.length).toBe(expectedResult); + }, + ); + + test("Wrong unit type in range", async () => { + const game = await setup("Plains", { + infiniteGold: true, + instantBuild: true, + }); + const grid = new UnitGrid(game.map()); + const player = game.addPlayer( + new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + ); + const unitTile = game.map().ref(0, 0); + grid.addUnit(player.buildUnit(UnitType.City, unitTile, {})); + const tileToCheck = game.map().ref(0, 0); + expect(grid.nearbyUnits(tileToCheck, 10, [UnitType.Port])).toHaveLength( + 0, + ); + }); + + test("One inside, one outside of range", async () => { + const game = await setup("Plains", { + infiniteGold: true, + instantBuild: true, + }); + const grid = new UnitGrid(game.map()); + const player = game.addPlayer( + new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + ); + const unitType = UnitType.City; + const unitTile = game.map().ref(0, 0); + grid.addUnit(player.buildUnit(unitType, unitTile, {})); + const outsideTile = game.map().ref(99, 0); + grid.addUnit(player.buildUnit(unitType, outsideTile, {})); + const tileToCheck = game.map().ref(0, 0); + expect(grid.nearbyUnits(tileToCheck, 10, [unitType])).toHaveLength(1); + }); + }); +}); diff --git a/tests/testdata/BigPlains.png b/tests/testdata/BigPlains.png new file mode 100644 index 000000000..12cf92f1e Binary files /dev/null and b/tests/testdata/BigPlains.png differ