-
${unit.type()}
+
+ ${translateText("unit_type." + this.getUnitTypeKey(unit.type()))}
+
+ ${isCity
+ ? html`
+
+ ${translateText("player_panel.population")}:
+ ${renderNumber(population)}
+
+
+ ${translateText("player_panel.density")}:
+ ${Math.round(unit.density() * 100)}%
+
+ ${unit.owner().isMe() && !unit.isUnderConstruction()
+ ? html`
+ ${translateText(
+ "events_display.double_click_expand_hint",
+ )}
+
`
+ : ""}
+ `
+ : ""}
${unit.hasHealth()
- ? html`
Health: ${unit.health()}
`
+ ? html`
+
+ ${translateText("player_stats_table.hits")}: ${unit.health()}
+
+ `
: ""}
${unit.type() === UnitType.TransportShip
? html`
diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts
index 9fe6a940b..452f496a7 100644
--- a/src/client/graphics/layers/StructureDrawingUtils.ts
+++ b/src/client/graphics/layers/StructureDrawingUtils.ts
@@ -88,6 +88,8 @@ export class SpriteFactory {
image.onload = () => {
unitInfo.image = image;
this.invalidateTextureCache(unitType);
+ // We don't have direct access to layers to call redraw,
+ // but the next tick will pick up the new texture.
};
image.onerror = () => {
console.error(
@@ -411,7 +413,7 @@ export class SpriteFactory {
const structureInfo = this.structuresInfos.get(structureType);
- if (structureInfo?.image && renderIcon) {
+ if (structureInfo?.image && structureInfo.image.width > 0 && renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
index 72d7ef7f9..c8b752f45 100644
--- a/src/client/graphics/layers/StructureIconsLayer.ts
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -16,6 +16,8 @@ import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
+ CityUpdateEvent,
+ DoubleClickEvent,
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
@@ -172,6 +174,7 @@ export class StructureIconsLayer implements Layer {
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));
this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e));
+ this.eventBus.on(DoubleClickEvent, (e) => this.onDoubleClick(e));
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
@@ -419,6 +422,33 @@ export class StructureIconsLayer implements Layer {
this.removeGhostStructure();
}
+ private onDoubleClick(e: DoubleClickEvent) {
+ const rect = this.transformHandler.boundingRect();
+ if (!rect) return;
+ const x = e.x - rect.left;
+ const y = e.y - rect.top;
+ const tile = this.transformHandler.screenToWorldCoordinates(x, y);
+ if (!this.game.isValidCoord(tile.x, tile.y)) return;
+ const tileRef = this.game.ref(tile.x, tile.y);
+
+ // Find city at or near this tile
+ const radius = 10; // tolerance for clicking
+ const nearby = this.game.nearbyUnits(tileRef, radius, UnitType.City);
+ if (nearby.length > 0) {
+ // Sort by distance to find the closest city
+ nearby.sort((a, b) => a.distSquared - b.distSquared);
+ const city = nearby[0].unit;
+ if (
+ city.owner() === this.game.myPlayer() &&
+ !city.isUnderConstruction()
+ ) {
+ this.eventBus.emit(
+ new SendUpgradeStructureIntentEvent(city.id(), UnitType.City),
+ );
+ }
+ }
+ }
+
private moveGhost(e: MouseMoveEvent) {
this.mousePos.x = e.x;
this.mousePos.y = e.y;
@@ -558,13 +588,26 @@ export class StructureIconsLayer implements Layer {
if (this.seenUnits.has(unitView)) {
const render = this.findRenderByUnit(unitView);
if (render) {
- this.checkForConstructionState(render, unitView);
- this.checkForDeletionState(render, unitView);
- this.checkForOwnershipChange(render, unitView);
- this.checkForLevelChange(render, unitView);
+ let changed = false;
+ if (this.checkForConstructionState(render, unitView)) changed = true;
+ if (this.checkForDeletionState(render, unitView)) changed = true;
+ if (this.checkForOwnershipChange(render, unitView)) changed = true;
+ if (this.checkForLevelChange(render, unitView)) changed = true;
+ if (this.checkForDensityChange(render, unitView)) changed = true;
+
+ if (changed) {
+ this.computeNewLocation(render);
+ if (unitView.type() === UnitType.City) {
+ this.eventBus.emit(new CityUpdateEvent(unitView));
+ }
+ }
}
} else if (this.structures.has(unitView.type())) {
+ const city = unitView.type() === UnitType.City;
this.addNewStructure(unitView);
+ if (city) {
+ this.eventBus.emit(new CityUpdateEvent(unitView));
+ }
}
}
@@ -603,20 +646,25 @@ export class StructureIconsLayer implements Layer {
}
}
- private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) {
+ private checkForDeletionState(
+ render: StructureRenderInfo,
+ unit: UnitView,
+ ): boolean {
if (unit.markedForDeletion() !== false) {
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
+ return true;
}
+ return false;
}
private checkForConstructionState(
render: StructureRenderInfo,
unit: UnitView,
- ) {
+ ): boolean {
if (render.underConstruction && !unit.isUnderConstruction()) {
render.underConstruction = false;
render.iconContainer?.destroy();
@@ -624,10 +672,15 @@ export class StructureIconsLayer implements Layer {
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
+ return true;
}
+ return false;
}
- private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) {
+ private checkForOwnershipChange(
+ render: StructureRenderInfo,
+ unit: UnitView,
+ ): boolean {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
@@ -635,10 +688,15 @@ export class StructureIconsLayer implements Layer {
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
+ return true;
}
+ return false;
}
- private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) {
+ private checkForLevelChange(
+ render: StructureRenderInfo,
+ unit: UnitView,
+ ): boolean {
if (render.level !== unit.level()) {
render.level = unit.level();
render.iconContainer?.destroy();
@@ -648,7 +706,32 @@ export class StructureIconsLayer implements Layer {
render.levelContainer = this.createLevelSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
+ return true;
}
+ return false;
+ }
+
+ private lastDensity = new Map
();
+
+ private checkForDensityChange(
+ render: StructureRenderInfo,
+ unit: UnitView,
+ ): boolean {
+ if (unit.type() !== UnitType.City || !unit.isActive() || !unit.owner())
+ return false;
+ const currentDensity = Math.round(unit.density() * 10) / 10;
+ const prevDensity = this.lastDensity.get(unit.id()) ?? 0;
+
+ if (currentDensity !== prevDensity) {
+ this.lastDensity.set(unit.id(), currentDensity);
+ render.iconContainer?.destroy();
+ render.dotContainer?.destroy();
+ render.iconContainer = this.createIconSprite(unit);
+ render.dotContainer = this.createDotSprite(unit);
+ this.modifyVisibility(render);
+ return true;
+ }
+ return false;
}
private computeNewLocation(render: StructureRenderInfo) {
@@ -753,5 +836,6 @@ export class StructureIconsLayer implements Layer {
render.dotContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
+ this.lastDensity.delete(render.unit.id());
}
}
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 08d3f5a9c..bcefbe8e7 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -16,6 +16,7 @@ import { UserSettings } from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
+ CityUpdateEvent,
DragEvent,
MouseOverEvent,
} from "../../InputHandler";
@@ -330,6 +331,9 @@ export class TerritoryLayer implements Layer {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
// this.lastDragTime = Date.now();
});
+ this.eventBus.on(CityUpdateEvent, (e) => {
+ this.enqueueCityArea(e.city);
+ });
this.redraw();
}
@@ -586,7 +590,29 @@ export class TerritoryLayer implements Layer {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);
- this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
+ if (!owner) {
+ this.clearTile(tile);
+ return;
+ }
+
+ const urbanization = this.game.getUrbanization(tile);
+ let color = owner.territoryColor(tile);
+ let alpha = 150;
+
+ if (urbanization.density > 0.05) {
+ // Alpha increases with density, making urban centers feel more "heavy" and established
+ alpha = 160 + Math.min(85, urbanization.density * 70);
+
+ // Lightness Gradient: Rural (Low density) is lighter, Urban (High density) is darker/richer.
+ // We interpolate between a "rural" shade and an "urban" shade of the player's color.
+ const ruralShade = color.lighten(0.15).desaturate(0.05);
+ const urbanShade = color.darken(0.15).saturate(0.2);
+
+ const mixRatio = Math.min(1, urbanization.density);
+ color = ruralShade.mix(urbanShade, mixRatio);
+ }
+
+ this.paintTile(this.imageData, tile, color, alpha);
}
}
@@ -638,6 +664,21 @@ export class TerritoryLayer implements Layer {
});
}
+ enqueueCityArea(city: UnitView) {
+ const radius = Math.ceil(city.areaRadius() * 1.4 + 2);
+ const tile = city.tile();
+ const cx = this.game.x(tile);
+ const cy = this.game.y(tile);
+
+ for (let x = cx - radius; x <= cx + radius; x++) {
+ for (let y = cy - radius; y <= cy + radius; y++) {
+ if (this.game.isValidCoord(x, y)) {
+ this.enqueueTile(this.game.ref(x, y));
+ }
+ }
+ }
+ }
+
async enqueuePlayerBorder(player: PlayerView) {
const playerBorderTiles = await player.borderTiles();
playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts
index 37caba0f3..62cce3953 100644
--- a/src/core/configuration/Colors.ts
+++ b/src/core/configuration/Colors.ts
@@ -1,9 +1,13 @@
import { colord, Colord, extend } from "colord";
+import a11yPlugin from "colord/plugins/a11y";
import labPlugin from "colord/plugins/lab";
import lchPlugin from "colord/plugins/lch";
+import mixPlugin from "colord/plugins/mix";
extend([lchPlugin]);
extend([labPlugin]);
+extend([mixPlugin]);
+extend([a11yPlugin]);
export const red = colord("rgb(235,51,51)");
export const blue = colord("rgb(41,98,255)");
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 5a672f296..8800b0cf9 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -590,6 +590,27 @@ export class DefaultConfig implements Config {
throw new Error(`terrain type ${type} not supported`);
}
if (defender.isPlayer()) {
+ // City defense bonus based on density and area
+ const maxCitySearchRadius = 50;
+ const nearbyCities = gm.nearbyUnits(
+ tileToConquer,
+ maxCitySearchRadius,
+ UnitType.City,
+ ({ unit: u }) => u.owner() === defender && !u.isUnderConstruction(),
+ );
+ for (const { unit: city, distSquared } of nearbyCities) {
+ const areaRadius = city.areaRadius() || 0;
+ if (areaRadius > 0 && Math.sqrt(distSquared) <= areaRadius) {
+ // Denser cities are harder to capture
+ // Density bonus scales from 1.5x up to ~3x or more based on age and area
+ const density = city.density() || 0;
+ const densityBonus = 1.5 + density * 2;
+ mag *= densityBonus;
+ speed *= 0.7; // Moving through a city is slower
+ break;
+ }
+ }
+
for (const dp of gm.nearbyUnits(
tileToConquer,
gm.config().defensePostRange(),
@@ -657,7 +678,8 @@ export class DefaultConfig implements Config {
largeDefenderAttackDebuff *
largeAttackBonus *
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
- defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
+ defenderTroopLoss:
+ defender.troops() / Math.max(1, defender.numTilesOwned()),
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
@@ -752,27 +774,31 @@ export class DefaultConfig implements Config {
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player
.units(UnitType.City)
- .map((city) => city.level())
- .reduce((a, b) => a + b, 0) *
- this.cityTroopIncrease();
+ .filter((city) => !city.isUnderConstruction())
+ .reduce((acc, city) => {
+ const area = Math.PI * Math.pow(city.areaRadius() || 1, 2);
+ const density = city.density() || 0;
+ const population = area * 1500 * (1 + density);
+ return acc + (isNaN(population) ? 0 : population);
+ }, 0);
if (player.type() === PlayerType.Bot) {
- return maxTroops / 3;
+ return Math.max(1, maxTroops / 3);
}
if (player.type() === PlayerType.Human) {
- return maxTroops;
+ return Math.max(1, maxTroops);
}
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
- return maxTroops * 0.5;
+ return Math.max(1, maxTroops * 0.5);
case Difficulty.Medium:
- return maxTroops * 0.75;
+ return Math.max(1, maxTroops * 0.75);
case Difficulty.Hard:
- return maxTroops * 1; // Like humans
+ return Math.max(1, maxTroops * 1); // Like humans
case Difficulty.Impossible:
- return maxTroops * 1.25;
+ return Math.max(1, maxTroops * 1.25);
default:
assertNever(this._gameConfig.difficulty);
}
@@ -820,7 +846,22 @@ export class DefaultConfig implements Config {
} else {
baseRate = 100n;
}
- return BigInt(Math.floor(Number(baseRate) * multiplier));
+
+ // Cities generate extra gold based on area and density
+ let cityIncome = 0;
+ for (const city of player.units(UnitType.City)) {
+ if (!city.isUnderConstruction()) {
+ const area = Math.PI * Math.pow(city.areaRadius() || 1, 2);
+ const density = city.density() || 0;
+ cityIncome += area * 0.1 * (1 + density);
+ }
+ }
+
+ if (isNaN(cityIncome)) {
+ cityIncome = 0;
+ }
+
+ return BigInt(Math.floor((Number(baseRate) + cityIncome) * multiplier));
}
nukeMagnitudes(unitType: UnitType): NukeMagnitude {
diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts
index fd31c940e..d42143cdb 100644
--- a/src/core/execution/CityExecution.ts
+++ b/src/core/execution/CityExecution.ts
@@ -21,6 +21,7 @@ export class CityExecution implements Execution {
this.active = false;
return;
}
+ this.city.incrementAge();
}
isActive(): boolean {
diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts
index c6e937985..7297b8b2a 100644
--- a/src/core/execution/nation/NationStructureBehavior.ts
+++ b/src/core/execution/nation/NationStructureBehavior.ts
@@ -401,6 +401,15 @@ export class NationStructureBehavior {
for (const structure of upgradable) {
let score = 0;
+ // Cities are more valuable now, so prioritize them
+ if (structure.type() === UnitType.City) {
+ score += 20;
+ // Higher priority for smaller cities to help them reach density quicker
+ if (structure.level() < 5) {
+ score += 15;
+ }
+ }
+
// Check if protected by any SAM, using per-SAM level-based range
for (const sam of samLaunchers) {
const samRange = this.game.config().samRange(sam.level());
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 3897da573..f9a0a97e5 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -564,6 +564,12 @@ export interface Unit {
// Warships
setPatrolTile(tile: TileRef): void;
patrolTile(): TileRef | undefined;
+
+ // Cities
+ areaRadius(): number;
+ age(): number;
+ density(): number;
+ incrementAge(): void;
}
export interface TerraNullius {
@@ -792,7 +798,7 @@ export interface Game extends GameMap {
nearbyUnits(
tile: TileRef,
searchRange: number,
- types: UnitType | UnitType[],
+ types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
includeUnderConstruction?: boolean,
): Array<{ unit: Unit; distSquared: number }>;
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 45d77878f..417128f57 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -893,7 +893,7 @@ export class GameImpl implements Game {
nearbyUnits(
tile: TileRef,
searchRange: number,
- types: UnitType | UnitType[],
+ types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
includeUnderConstruction?: boolean,
): Array<{ unit: Unit; distSquared: number }> {
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index b8786e14b..3b225b3a5 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -140,6 +140,8 @@ export interface UnitUpdate {
hasTrainStation: boolean;
trainType?: TrainType; // Only for trains
loaded?: boolean; // Only for trains
+ areaRadius?: number; // Only for cities
+ age?: number; // Only for cities
}
export interface AttackUpdate {
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 07d2841e8..d706f70ce 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -171,6 +171,31 @@ export class UnitView {
level(): number {
return this.data.level;
}
+ areaRadius(): number {
+ return this.data.areaRadius ?? 7;
+ }
+ age(): number {
+ return this.data.age ?? 0;
+ }
+
+ private _density: number | null = null;
+ private _lastDensityTick = -1;
+
+ density(): number {
+ if (this.type() !== UnitType.City) return 0;
+ const tick = this.gameView.ticks();
+ if (tick === this._lastDensityTick && this._density !== null) {
+ return this._density;
+ }
+
+ const timeConstant = 200 * this.level();
+ const maxDensity = 0.5 + (this.level() - 1) * 0.5;
+ const density = (1 - Math.exp(-this.age() / timeConstant)) * maxDensity;
+
+ this._density = Math.max(0, density) || 0;
+ this._lastDensityTick = tick;
+ return this._density;
+ }
hasTrainStation(): boolean {
return this.data.hasTrainStation;
}
@@ -199,6 +224,9 @@ export class PlayerView {
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
+ private _urbanizationBuildingColor: Colord;
+ private _urbanizationCoreColor: Colord;
+
constructor(
private game: GameView,
public data: PlayerUpdate,
@@ -312,12 +340,23 @@ export class PlayerView {
this._borderColorEmbargo,
);
+ this._urbanizationBuildingColor = this._borderColor;
+ this._urbanizationCoreColor = this._borderColor.lighten(0.2);
+
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
+ urbanizationBuildingColor(): Colord {
+ return this._urbanizationBuildingColor;
+ }
+
+ urbanizationCoreColor(): Colord {
+ return this._urbanizationCoreColor;
+ }
+
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
@@ -707,14 +746,16 @@ export class GameView implements GameMap {
nearbyUnits(
tile: TileRef,
searchRange: number,
- types: UnitType | UnitType[],
+ types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
+ includeUnderConstruction?: boolean,
): Array<{ unit: UnitView; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
searchRange,
types,
predicate,
+ includeUnderConstruction,
) as Array<{
unit: UnitView;
distSquared: number;
@@ -954,4 +995,76 @@ export class GameView implements GameMap {
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
+
+ private urbanizationCache: Map<
+ TileRef,
+ { density: number; unit?: UnitView }
+ > = new Map();
+ private lastUrbanizationUpdate = -1;
+
+ getUrbanization(tile: TileRef): { density: number; unit?: UnitView } {
+ if (!this.isLand(tile)) return { density: 0 };
+
+ if (this.ticks() !== this.lastUrbanizationUpdate) {
+ this.urbanizationCache.clear();
+ this.lastUrbanizationUpdate = this.ticks();
+ }
+
+ const cached = this.urbanizationCache.get(tile);
+ if (cached) return cached;
+
+ const nearby = this.nearbyUnits(tile, 40, UnitType.City);
+ if (nearby.length === 0) {
+ const result = { density: 0 };
+ this.urbanizationCache.set(tile, result);
+ return result;
+ }
+
+ let maxDensity = 0;
+ let maxUnit: UnitView | undefined = undefined;
+
+ const tileOwnerID = this.ownerID(tile);
+ const tx = this.x(tile);
+ const ty = this.y(tile);
+
+ for (let i = 0; i < nearby.length; i++) {
+ const { unit, distSquared } = nearby[i];
+ if (unit.isUnderConstruction()) continue;
+
+ const owner = unit.owner();
+ if (!owner || owner.smallID() !== tileOwnerID) continue;
+
+ const dist = Math.sqrt(distSquared);
+ const radius = unit.areaRadius();
+
+ const ux = this.x(unit.tile());
+ const uy = this.y(unit.tile());
+ const dx = tx - ux;
+ const dy = ty - uy;
+
+ // Faster angle approximation or just use atan2 (it's called once per nearby city)
+ const angle = Math.atan2(dy, dx);
+ const uid = unit.id();
+
+ // Simplified noise with fewer sin calls
+ const noise =
+ 1 + 0.18 * Math.sin(angle * 3 + uid) + 0.12 * Math.sin(angle * 5 - uid);
+
+ const irregularRadius = radius * noise;
+
+ if (dist <= irregularRadius) {
+ // Linear fade is faster than Math.pow
+ const fade = 1 - dist / irregularRadius;
+ const d = unit.density() * fade;
+
+ if (d > maxDensity) {
+ maxDensity = d;
+ maxUnit = unit;
+ }
+ }
+ }
+ const result = { density: maxDensity, unit: maxUnit };
+ this.urbanizationCache.set(tile, result);
+ return result;
+ }
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index e2005f248..225463c7a 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -948,6 +948,12 @@ export class PlayerImpl implements Player {
) {
return false;
}
+ if (
+ unit.type() === UnitType.City &&
+ this.troops() < 25_000 * unit.level()
+ ) {
+ return false;
+ }
if (unit.owner() !== this) {
return false;
}
@@ -957,6 +963,9 @@ export class PlayerImpl implements Player {
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this.mg, this);
this.removeGold(cost);
+ if (unit.type() === UnitType.City) {
+ this.removeTroops(25_000 * unit.level());
+ }
unit.increaseLevel();
this.recordUnitConstructed(unit.type());
}
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 67791b458..42ef9fa3e 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -42,6 +42,8 @@ export class UnitImpl implements Unit {
private _trajectoryIndex: number = 0;
private _trajectory: TrajectoryTile[];
private _deletionAt: number | null = null;
+ private _areaRadius: number = 0;
+ private _age: number = 0;
constructor(
private _type: UnitType,
@@ -69,6 +71,10 @@ export class UnitImpl implements Unit {
"loaded" in params ? (params.loaded ?? undefined) : undefined;
this._trainType = "trainType" in params ? params.trainType : undefined;
+ if (this._type === UnitType.City) {
+ this._areaRadius = 7;
+ }
+
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
@@ -142,6 +148,8 @@ export class UnitImpl implements Unit {
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
+ areaRadius: this.areaRadius() || undefined,
+ age: this.age() || undefined,
};
}
@@ -164,7 +172,7 @@ export class UnitImpl implements Unit {
}
setTroops(troops: number): void {
- this._troops = troops;
+ this._troops = Math.max(0, troops);
}
troops(): number {
return this._troops;
@@ -451,6 +459,9 @@ export class UnitImpl implements Unit {
increaseLevel(): void {
this._level++;
+ if (this.type() === UnitType.City) {
+ this._areaRadius += 2; // Expand city area with level
+ }
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.push(this.mg.ticks());
}
@@ -459,6 +470,9 @@ export class UnitImpl implements Unit {
decreaseLevel(destroyer?: Player): void {
this._level--;
+ if (this.type() === UnitType.City) {
+ this._areaRadius = Math.max(7, this._areaRadius - 2);
+ }
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.pop();
}
@@ -483,4 +497,32 @@ export class UnitImpl implements Unit {
this.mg.addUpdate(this.toUpdate());
}
}
+
+ areaRadius(): number {
+ return Math.max(1, this._areaRadius);
+ }
+
+ age(): number {
+ return this._age;
+ }
+
+ incrementAge(): void {
+ this._age++;
+ // We update the unit every 10 ticks to avoid excessive network traffic
+ if (this._age % 10 === 0) {
+ this.mg.addUpdate(this.toUpdate());
+ }
+ }
+
+ density(): number {
+ if (this.type() !== UnitType.City) return 0;
+ // Growth rate: smaller cities fill up faster (lower time constant)
+ // One tick is 100ms, so 200 * Level is 20 * Level seconds to reach ~63% of potential
+ const timeConstant = 200 * this._level;
+ // Potential density: larger cities can become much denser (higher cap)
+ const maxDensity = 0.5 + (this._level - 1) * 0.5;
+
+ const density = (1 - Math.exp(-this._age / timeConstant)) * maxDensity;
+ return Math.max(0, density) || 0;
+ }
}