diff --git a/resources/lang/en.json b/resources/lang/en.json index 5947ad4e2..be506ca04 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -146,7 +146,7 @@ "build_icon": "Icon", "build_desc": "Description", "build_city": "City", - "build_city_desc": "Increases your max population. Useful when you can't expand your territory or you're about to hit your population limit.", + "build_city_desc": "A city that can be expanded by double-clicking (costs gold and troops). Its population and gold generation increase with area and density. Denser areas are harder to capture.", "build_factory": "Factory", "build_factory_desc": "Automatically builds railroads to nearby cities, ports and other factories, and can also link up with friendly neighbors. Trains spawn regularly and give you a fixed amount of gold for each building they visit along the route, with extra gold for visiting your neighbors' buildings.", "build_defense": "Defense Post", @@ -752,6 +752,8 @@ "received_gold_from_player": "Received {gold} gold from {name}", "unit_captured_by_enemy": "Your {unit} was captured by {name}", "captured_enemy_unit": "Captured {unit} from {name}", + "unit_expanded": "Expanded {unit} to level {level}", + "double_click_expand_hint": "Double-click to expand (costs gold & troops)", "unit_destroyed": "Your {unit} was destroyed", "no_boats_available": "No boats available, max {max}" }, @@ -773,6 +775,8 @@ "betrayals": "Betrayals", "traitor": "Traitor", "trading": "Trading", + "population": "Population", + "density": "Density", "active": "Active", "stopped": "Stopped", "alliance_time_remaining": "Alliance Expires In", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 45d1188a3..775abf0bc 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -12,6 +12,13 @@ export class MouseUpEvent implements GameEvent { ) {} } +export class DoubleClickEvent implements GameEvent { + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + export class MouseOverEvent implements GameEvent { constructor( public readonly x: number, @@ -75,6 +82,10 @@ export class AlternateViewEvent implements GameEvent { constructor(public readonly alternateView: boolean) {} } +export class CityUpdateEvent implements GameEvent { + constructor(public readonly city: UnitView) {} +} + export class CloseViewEvent implements GameEvent {} export class RefreshGraphicsEvent implements GameEvent {} @@ -148,6 +159,9 @@ export class InputHandler { private lastPinchDistance: number = 0; private pointerDown: boolean = false; + private lastClickTime: number = 0; + private lastClickX: number = 0; + private lastClickY: number = 0; private alternateView = false; @@ -517,6 +531,19 @@ export class InputHandler { return; } + const now = Date.now(); + const clickDist = + Math.abs(event.x - this.lastClickX) + + Math.abs(event.y - this.lastClickY); + if (now - this.lastClickTime < 300 && clickDist < 20) { + this.eventBus.emit(new DoubleClickEvent(event.x, event.y)); + this.lastClickTime = 0; // reset to prevent triple-click double events + return; + } + this.lastClickTime = now; + this.lastClickX = event.x; + this.lastClickY = event.y; + if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) { this.eventBus.emit(new MouseUpEvent(event.x, event.y)); } else { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 5f4ef4a37..e9f96df79 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -428,7 +428,11 @@ export class GameRenderer { ); const layerStart = FrameProfiler.start(); - layer.renderLayer?.(this.context); + try { + layer.renderLayer?.(this.context); + } catch (e) { + console.error(`Error rendering layer ${layer.constructor.name}:`, e); + } FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart); } handleTransformState(false, isTransformActive); // Ensure context is clean after rendering diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index b62a22d3e..4e0470045 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -141,6 +141,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.player.profile().then((p) => { this.playerProfile = p; }); + + // Also check if we are hovering a city within this player's territory + const urbanization = this.game.getUrbanization(tile); + if (urbanization.unit) { + this.unit = urbanization.unit; + } + this.setVisible(true); } else if (!this.game.isLand(tile)) { const units = this.game @@ -172,6 +179,33 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.requestUpdate(); } + private getUnitTypeKey(type: UnitType): string { + switch (type) { + case UnitType.City: + return "city"; + case UnitType.DefensePost: + return "defense_post"; + case UnitType.Port: + return "port"; + case UnitType.Warship: + return "warship"; + case UnitType.MissileSilo: + return "missile_silo"; + case UnitType.SAMLauncher: + return "sam_launcher"; + case UnitType.AtomBomb: + return "atom_bomb"; + case UnitType.HydrogenBomb: + return "hydrogen_bomb"; + case UnitType.MIRV: + return "mirv"; + case UnitType.Factory: + return "factory"; + default: + return type.toLowerCase().replace(/ /g, "_"); + } + } + private getPlayerNameColor( player: PlayerView, myPlayer: PlayerView | null | undefined, @@ -456,15 +490,44 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.game.myPlayer()?.isFriendly(unit.owner())) ?? false; + const isCity = unit.type() === UnitType.City; + const area = Math.PI * Math.pow(unit.areaRadius(), 2); + const population = Math.floor(area * 1500 * (1 + unit.density())); + return html` -
+
${unit.owner().name()}
-
${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; + } }