mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 07:14:55 +00:00
population mechanic test
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user