population mechanic test

This commit is contained in:
Restart2008
2026-02-19 16:48:00 -08:00
parent 040766d417
commit b81d442030
17 changed files with 482 additions and 30 deletions
+5 -1
View File
@@ -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",
+27
View File
@@ -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 {
+5 -1
View File
@@ -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
@@ -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`
<div class="p-2">
<div class="p-2 border-t border-gray-700/50">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().name()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
<div class="text-sm opacity-80">
${translateText("unit_type." + this.getUnitTypeKey(unit.type()))}
</div>
${isCity
? html`
<div class="text-xs text-blue-300">
${translateText("player_panel.population")}:
${renderNumber(population)}
</div>
<div class="text-xs text-blue-200">
${translateText("player_panel.density")}:
${Math.round(unit.density() * 100)}%
</div>
${unit.owner().isMe() && !unit.isUnderConstruction()
? html`<div class="text-[10px] text-gray-400 mt-1 italic">
${translateText(
"events_display.double_click_expand_hint",
)}
</div>`
: ""}
`
: ""}
${unit.hasHealth()
? html` <div class="text-sm">Health: ${unit.health()}</div> `
? html`
<div class="text-sm">
${translateText("player_stats_table.hits")}: ${unit.health()}
</div>
`
: ""}
${unit.type() === UnitType.TransportShip
? html`
@@ -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],
@@ -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<number, number>();
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());
}
}
+42 -1
View File
@@ -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) => {
+4
View File
@@ -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)");
+52 -11
View File
@@ -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 {
+1
View File
@@ -21,6 +21,7 @@ export class CityExecution implements Execution {
this.active = false;
return;
}
this.city.incrementAge();
}
isActive(): boolean {
@@ -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());
+7 -1
View File
@@ -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 }>;
+1 -1
View File
@@ -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 }> {
+2
View File
@@ -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 {
+114 -1
View File
@@ -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;
}
}
+9
View File
@@ -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());
}
+43 -1
View File
@@ -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;
}
}