2056 lines
72 KiB
JavaScript
2056 lines
72 KiB
JavaScript
const Terrain = Object.freeze({
|
|
PLAINS: 0,
|
|
FOREST: 1,
|
|
MOUNTAIN: 2,
|
|
WATER: 3,
|
|
DESERT: 4,
|
|
FERTILE: 5,
|
|
MINERAL: 6
|
|
});
|
|
|
|
const terrainInfo = [
|
|
{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
|
|
{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
|
|
{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
|
|
{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
|
|
{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 },
|
|
{ name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 },
|
|
{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
|
|
];
|
|
|
|
const els = {
|
|
canvas: document.getElementById("world"),
|
|
sim: document.querySelector(".sim"),
|
|
toggleRun: document.getElementById("toggleRun"),
|
|
stepOnce: document.getElementById("stepOnce"),
|
|
resetWorld: document.getElementById("resetWorld"),
|
|
saveWorld: document.getElementById("saveWorld"),
|
|
loadWorld: document.getElementById("loadWorld"),
|
|
clearSave: document.getElementById("clearSave"),
|
|
speed: document.getElementById("speed"),
|
|
agentCount: document.getElementById("agentCount"),
|
|
worldSize: document.getElementById("worldSize"),
|
|
viewMode: document.getElementById("viewMode"),
|
|
year: document.getElementById("year"),
|
|
population: document.getElementById("population"),
|
|
activeGroups: document.getElementById("activeGroups"),
|
|
urbanPopulation: document.getElementById("urbanPopulation"),
|
|
ethnicities: document.getElementById("ethnicities"),
|
|
cities: document.getElementById("cities"),
|
|
polities: document.getElementById("polities"),
|
|
routes: document.getElementById("routes"),
|
|
deaths: document.getElementById("deaths"),
|
|
frameCost: document.getElementById("frameCost"),
|
|
ethnicityList: document.getElementById("ethnicityList"),
|
|
legend: document.getElementById("legend"),
|
|
tooltip: document.getElementById("tooltip")
|
|
};
|
|
|
|
const ctx = els.canvas.getContext("2d", { alpha: false });
|
|
let sim;
|
|
let running = true;
|
|
let frame = 0;
|
|
let lastStatsAt = 0;
|
|
let hoverState = null;
|
|
|
|
class Rng {
|
|
constructor(seed) {
|
|
this.seed = seed >>> 0;
|
|
}
|
|
|
|
next() {
|
|
this.seed = (1664525 * this.seed + 1013904223) >>> 0;
|
|
return this.seed / 4294967296;
|
|
}
|
|
|
|
range(min, max) {
|
|
return min + (max - min) * this.next();
|
|
}
|
|
|
|
int(max) {
|
|
return Math.floor(this.next() * max);
|
|
}
|
|
}
|
|
|
|
class World {
|
|
constructor(size, rng) {
|
|
this.size = size;
|
|
this.count = size * size;
|
|
this.rng = rng;
|
|
this.terrain = new Uint8Array(this.count);
|
|
this.resource = new Float32Array(this.count);
|
|
this.regen = new Float32Array(this.count);
|
|
this.move = new Float32Array(this.count);
|
|
this.fertility = new Float32Array(this.count);
|
|
this.mineral = new Float32Array(this.count);
|
|
this.temperature = new Float32Array(this.count);
|
|
this.humidity = new Float32Array(this.count);
|
|
this.pheromone = new Float32Array(this.count);
|
|
this.tradeRoute = new Uint8Array(this.count);
|
|
this.farmland = new Float32Array(this.count);
|
|
this.cityPull = new Float32Array(this.count);
|
|
this.city = new Int32Array(this.count);
|
|
this.pressure = new Float32Array(this.count);
|
|
this.city.fill(-1);
|
|
this.generate();
|
|
}
|
|
|
|
idx(x, y) {
|
|
return y * this.size + x;
|
|
}
|
|
|
|
generate() {
|
|
const s = this.size;
|
|
const centers = Array.from({ length: 18 }, () => ({
|
|
x: this.rng.range(0, s),
|
|
y: this.rng.range(0, s),
|
|
kind: this.rng.next()
|
|
}));
|
|
const height = new Float32Array(this.count);
|
|
const moistureMap = new Float32Array(this.count);
|
|
const mineralMap = new Float32Array(this.count);
|
|
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
const i = this.idx(x, y);
|
|
const nx = x / s - 0.5;
|
|
const ny = y / s - 0.5;
|
|
const latitude = Math.abs(ny) * 0.18;
|
|
let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12;
|
|
let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
|
|
let minerals = this.smoothNoise(x - 290, y + 120, 20);
|
|
|
|
for (const c of centers) {
|
|
const d = Math.hypot(x - c.x, y - c.y) / s;
|
|
if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
|
|
if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
|
|
}
|
|
height[i] = elevation;
|
|
moistureMap[i] = moisture;
|
|
mineralMap[i] = minerals;
|
|
}
|
|
}
|
|
|
|
this.smoothField(height, 2);
|
|
this.smoothField(moistureMap, 1);
|
|
const seaLevel = this.percentile(height, 0.34);
|
|
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
const i = this.idx(x, y);
|
|
const latitude = Math.abs((y / (s - 1)) * 2 - 1);
|
|
const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18);
|
|
this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1);
|
|
this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS;
|
|
}
|
|
}
|
|
|
|
this.computeHumidityFromWater(moistureMap);
|
|
|
|
for (let i = 0; i < this.count; i++) {
|
|
const elevation = height[i];
|
|
const minerals = mineralMap[i];
|
|
const temp = this.temperature[i];
|
|
const humid = this.humidity[i];
|
|
let t = this.terrain[i];
|
|
if (t !== Terrain.WATER) {
|
|
if (elevation > seaLevel + 0.42) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN;
|
|
else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT;
|
|
else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE;
|
|
else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST;
|
|
else t = Terrain.PLAINS;
|
|
}
|
|
|
|
const info = terrainInfo[t];
|
|
this.terrain[i] = t;
|
|
this.fertility[i] = clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
|
|
this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
|
|
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
|
|
this.move[i] = info.move;
|
|
this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
|
|
}
|
|
this.smoothTerrainTypes(2);
|
|
this.ensureDesertPatches();
|
|
this.enrichWaterMargins();
|
|
}
|
|
|
|
enrichWaterMargins() {
|
|
const s = this.size;
|
|
const nextFertility = new Float32Array(this.fertility);
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
const i = this.idx(x, y);
|
|
if (this.terrain[i] === Terrain.WATER) continue;
|
|
let waterScore = 0;
|
|
for (let dy = -3; dy <= 3; dy++) {
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
if (!d || d > 3) continue;
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
|
|
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
|
|
}
|
|
}
|
|
if (waterScore <= 0) continue;
|
|
const boost = Math.min(0.32, waterScore * 0.08);
|
|
nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
|
|
if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
|
|
if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < this.count; i++) {
|
|
const info = terrainInfo[this.terrain[i]];
|
|
this.fertility[i] = nextFertility[i];
|
|
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
|
|
this.move[i] = info.move;
|
|
if (this.terrain[i] === Terrain.WATER) {
|
|
this.resource[i] = 0;
|
|
} else {
|
|
this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
|
|
}
|
|
}
|
|
}
|
|
|
|
smoothNoise(x, y, scale) {
|
|
const x0 = Math.floor(x / scale);
|
|
const y0 = Math.floor(y / scale);
|
|
const fx = smoothstep((x / scale) - x0);
|
|
const fy = smoothstep((y / scale) - y0);
|
|
const a = this.gridNoise(x0, y0);
|
|
const b = this.gridNoise(x0 + 1, y0);
|
|
const c = this.gridNoise(x0, y0 + 1);
|
|
const d = this.gridNoise(x0 + 1, y0 + 1);
|
|
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
|
|
}
|
|
|
|
gridNoise(x, y) {
|
|
const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
|
|
return v - Math.floor(v);
|
|
}
|
|
|
|
computeHumidityFromWater(moistureMap) {
|
|
const s = this.size;
|
|
const radius = 10;
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
const i = this.idx(x, y);
|
|
if (this.terrain[i] === Terrain.WATER) {
|
|
this.humidity[i] = 1;
|
|
continue;
|
|
}
|
|
let best = 0;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
if (!d || d > radius) continue;
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
|
|
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1));
|
|
}
|
|
}
|
|
this.humidity[i] = clamp(best * 0.78 + moistureMap[i] * 0.22, 0, 1);
|
|
}
|
|
}
|
|
this.smoothField(this.humidity, 1);
|
|
}
|
|
|
|
smoothTerrainTypes(passes) {
|
|
const s = this.size;
|
|
let source = new Uint8Array(this.terrain);
|
|
let target = new Uint8Array(this.terrain.length);
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
const counts = new Uint8Array(terrainInfo.length);
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
|
|
counts[source[this.idx(tx, ty)]]++;
|
|
}
|
|
}
|
|
let bestTerrain = source[this.idx(x, y)];
|
|
let bestCount = counts[bestTerrain];
|
|
for (let t = 0; t < counts.length; t++) {
|
|
if (counts[t] > bestCount) {
|
|
bestTerrain = t;
|
|
bestCount = counts[t];
|
|
}
|
|
}
|
|
target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)];
|
|
}
|
|
}
|
|
const swap = source;
|
|
source = target;
|
|
target = swap;
|
|
}
|
|
this.terrain.set(source);
|
|
}
|
|
|
|
ensureDesertPatches() {
|
|
const candidates = [];
|
|
let desertCount = 0;
|
|
for (let i = 0; i < this.count; i++) {
|
|
if (this.terrain[i] === Terrain.DESERT) desertCount++;
|
|
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) {
|
|
candidates.push(i);
|
|
}
|
|
}
|
|
const target = Math.max(12, Math.floor(this.count * 0.012));
|
|
if (desertCount >= target || !candidates.length) return;
|
|
candidates.sort((a, b) => {
|
|
const dryA = this.temperature[a] * (1 - this.humidity[a]);
|
|
const dryB = this.temperature[b] * (1 - this.humidity[b]);
|
|
return dryB - dryA;
|
|
});
|
|
const needed = Math.min(target - desertCount, candidates.length);
|
|
for (let n = 0; n < needed; n++) {
|
|
const center = candidates[n];
|
|
const cx = center % this.size;
|
|
const cy = Math.floor(center / this.size);
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
|
|
const x = cx + dx;
|
|
const y = cy + dy;
|
|
if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;
|
|
const i = this.idx(x, y);
|
|
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) {
|
|
this.terrain[i] = Terrain.DESERT;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
smoothField(field, passes) {
|
|
const s = this.size;
|
|
let source = field;
|
|
let target = new Float32Array(field.length);
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
for (let y = 0; y < s; y++) {
|
|
for (let x = 0; x < s; x++) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
|
|
sum += source[this.idx(tx, ty)];
|
|
count++;
|
|
}
|
|
}
|
|
target[this.idx(x, y)] = sum / count;
|
|
}
|
|
}
|
|
const nextSource = target;
|
|
target = source === field ? new Float32Array(field.length) : field;
|
|
source = nextSource;
|
|
}
|
|
if (source !== field) field.set(source);
|
|
}
|
|
|
|
percentile(field, ratio) {
|
|
const values = Array.from(field).sort((a, b) => a - b);
|
|
return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
|
|
}
|
|
}
|
|
|
|
class Simulation {
|
|
constructor(size, initialAgents) {
|
|
this.rng = new Rng(Date.now());
|
|
this.world = new World(size, this.rng);
|
|
this.agents = [];
|
|
this.ethnicities = new Map();
|
|
this.cities = [];
|
|
this.polities = [];
|
|
this.tradeLinks = [];
|
|
this.nextEthnicity = 1;
|
|
this.nextCity = 1;
|
|
this.nextPolity = 1;
|
|
this.year = 0;
|
|
this.deaths = 0;
|
|
this.maxAgents = Math.max(initialAgents * 1.25, 30000);
|
|
this.tileEthnicities = new Map();
|
|
this.spawnInitialAgents(initialAgents);
|
|
this.rebuildOccupancy();
|
|
this.updateEthnicStats();
|
|
}
|
|
|
|
spawnInitialAgents(count) {
|
|
const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
|
|
const desertFounders = Math.max(2, Math.floor(founders * 0.25));
|
|
for (let e = 0; e < founders; e++) {
|
|
const desertFounder = e < desertFounders;
|
|
const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
|
|
const originTile = this.world.idx(origin.x, origin.y);
|
|
const id = this.createEthnicity(0, {
|
|
temperature: this.world.temperature[originTile],
|
|
humidity: this.world.humidity[originTile]
|
|
});
|
|
const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
|
|
for (let n = 0; n < Math.floor(count / founders); n++) {
|
|
const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null);
|
|
this.agents.push(this.makeAgent(
|
|
spawn.x,
|
|
spawn.y,
|
|
id,
|
|
mutateTraits(baseTraits, this.rng, 0.07),
|
|
desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
createEthnicity(parent, climate = null) {
|
|
const id = this.nextEthnicity++;
|
|
this.ethnicities.set(id, {
|
|
id,
|
|
parent,
|
|
born: this.year,
|
|
population: 0,
|
|
diversity: 0,
|
|
climateTemp: climate?.temperature ?? 0.55,
|
|
climateHumidity: climate?.humidity ?? 0.45,
|
|
color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
|
|
centroidX: 0,
|
|
centroidY: 0
|
|
});
|
|
return id;
|
|
}
|
|
|
|
randomTraits() {
|
|
return {
|
|
mobility: this.rng.range(0.18, 0.85),
|
|
resourceAttraction: this.rng.range(0.55, 1),
|
|
assimilation: this.rng.range(0.03, 0.34),
|
|
ethnocentrism: this.rng.range(0.12, 0.78),
|
|
reproductionThreshold: this.rng.range(19, 34),
|
|
sedentary: this.rng.range(0.08, 0.82)
|
|
};
|
|
}
|
|
|
|
desertFounderTraits() {
|
|
const traits = this.randomTraits();
|
|
traits.mobility = this.rng.range(0.48, 0.9);
|
|
traits.resourceAttraction = this.rng.range(0.82, 1.12);
|
|
traits.reproductionThreshold = this.rng.range(24, 38);
|
|
traits.sedentary = this.rng.range(0.18, 0.58);
|
|
return traits;
|
|
}
|
|
|
|
makeAgent(x, y, ethnicity, traits, resources) {
|
|
return {
|
|
x,
|
|
y,
|
|
resources,
|
|
ethnicity,
|
|
traits,
|
|
alive: true,
|
|
foreignContact: 0,
|
|
contactEthnicity: ethnicity,
|
|
settled: 0
|
|
};
|
|
}
|
|
|
|
findNearbySpawn(originX, originY, preferredTerrain = null) {
|
|
const w = this.world;
|
|
let best = { x: originX, y: originY };
|
|
let bestScore = -Infinity;
|
|
for (let tries = 0; tries < 24; tries++) {
|
|
const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1);
|
|
const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1);
|
|
const i = w.idx(x, y);
|
|
if (w.terrain[i] === Terrain.WATER) continue;
|
|
const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0;
|
|
const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
findHabitableTile(preferredTerrain = null) {
|
|
if (preferredTerrain !== null) {
|
|
const exact = [];
|
|
let best = null;
|
|
let bestScore = -Infinity;
|
|
for (let i = 0; i < this.world.count; i++) {
|
|
const x = i % this.world.size;
|
|
const y = Math.floor(i / this.world.size);
|
|
if (this.world.terrain[i] === preferredTerrain) {
|
|
exact.push({ x, y });
|
|
continue;
|
|
}
|
|
if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) {
|
|
const score = this.world.temperature[i] * (1 - this.world.humidity[i]);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y };
|
|
}
|
|
}
|
|
}
|
|
if (exact.length) return exact[this.rng.int(exact.length)];
|
|
if (best) return best;
|
|
}
|
|
for (let tries = 0; tries < 5000; tries++) {
|
|
const x = this.rng.int(this.world.size);
|
|
const y = this.rng.int(this.world.size);
|
|
const i = this.world.idx(x, y);
|
|
if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
|
|
return { x, y };
|
|
}
|
|
}
|
|
return { x: this.world.size >> 1, y: this.world.size >> 1 };
|
|
}
|
|
|
|
step() {
|
|
for (const city of this.cities) city.activeVisitors = 0;
|
|
this.rebuildOccupancy();
|
|
|
|
const offspring = [];
|
|
for (const a of this.agents) {
|
|
if (!a.alive) continue;
|
|
this.moveAgent(a);
|
|
this.gatherConsumeReproduce(a, offspring);
|
|
this.resolveAssimilation(a);
|
|
}
|
|
|
|
this.agents = this.agents.filter(a => a.alive);
|
|
if (this.agents.length + offspring.length < this.maxAgents) {
|
|
this.agents.push(...offspring);
|
|
} else {
|
|
this.agents.push(...offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)));
|
|
}
|
|
|
|
this.updateWorldFields();
|
|
this.rebuildOccupancy();
|
|
if (this.year % 5 === 0) {
|
|
this.updateCities();
|
|
this.updateTradeRoutes();
|
|
}
|
|
this.updatePolities();
|
|
this.updateEthnicStats();
|
|
if (this.year % 90 === 0) {
|
|
this.splitDivergentEthnicities();
|
|
this.updateEthnicStats();
|
|
}
|
|
this.year++;
|
|
}
|
|
|
|
rebuildOccupancy() {
|
|
const w = this.world;
|
|
w.pressure.fill(0);
|
|
this.tileEthnicities.clear();
|
|
for (const a of this.agents) {
|
|
if (!a.alive) continue;
|
|
const i = w.idx(a.x, a.y);
|
|
w.pressure[i]++;
|
|
let counts = this.tileEthnicities.get(i);
|
|
if (!counts) {
|
|
counts = new Map();
|
|
this.tileEthnicities.set(i, counts);
|
|
}
|
|
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
moveAgent(a) {
|
|
const w = this.world;
|
|
const s = w.size;
|
|
const current = w.idx(a.x, a.y);
|
|
const localPressure = w.pressure[current];
|
|
let bestX = a.x;
|
|
let bestY = a.y;
|
|
let bestScore = -Infinity;
|
|
const sedentary = getSedentary(a.traits);
|
|
const ethnocentrism = getEthnocentrism(a.traits);
|
|
const ethnicClimate = this.ethnicities.get(a.ethnicity);
|
|
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
|
|
if (localPressure < 7 && this.rng.next() < sedentary * 0.58) {
|
|
a.settled++;
|
|
return;
|
|
}
|
|
const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const x = clamp(a.x + dx, 0, s - 1);
|
|
const y = clamp(a.y + dy, 0, s - 1);
|
|
const i = w.idx(x, y);
|
|
const isWater = w.terrain[i] === Terrain.WATER;
|
|
const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45;
|
|
const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
|
|
if (isWater && this.rng.next() > waterAccess) continue;
|
|
|
|
const ethnicDensity = this.ethnicDensityNear(x, y, a.ethnicity);
|
|
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
|
|
const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
|
|
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
|
|
const crowdPenalty = Math.max(0, w.pressure[i] - 3) * (0.35 + a.traits.mobility);
|
|
const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
|
|
const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
|
|
const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
|
|
const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
|
|
const pressurePush = localPressure > 8 ? a.traits.mobility * (1.65 - sedentary * 0.7) : 0;
|
|
|
|
const score =
|
|
resourceScore * a.traits.resourceAttraction +
|
|
w.pheromone[i] * 0.021 +
|
|
ethnicDensity.same * ethnocentrism +
|
|
cityPull * sedentary +
|
|
routePull +
|
|
inertia -
|
|
terrainPenalty -
|
|
crowdPenalty +
|
|
pressurePush -
|
|
ethnicDensity.foreign * ethnocentrism * 0.72 +
|
|
this.rng.range(-0.55, 0.55);
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestX = x;
|
|
bestY = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
const from = w.idx(a.x, a.y);
|
|
a.x = bestX;
|
|
a.y = bestY;
|
|
const to = w.idx(a.x, a.y);
|
|
if (from !== to) {
|
|
const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
|
|
w.pheromone[from] += (w.tradeRoute[from] ? 0.08 : 0.22) * depositScale;
|
|
w.pheromone[to] += (w.tradeRoute[to] ? 0.06 : 0.15) * depositScale;
|
|
a.settled = Math.max(0, a.settled - 1);
|
|
} else {
|
|
a.settled++;
|
|
}
|
|
}
|
|
|
|
gatherConsumeReproduce(a, offspring) {
|
|
const w = this.world;
|
|
const i = w.idx(a.x, a.y);
|
|
const ethnicity = this.ethnicities.get(a.ethnicity);
|
|
const climateMismatch = this.climateMismatch(a.ethnicity, i);
|
|
const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1);
|
|
const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
|
|
const drylandAdapted = this.isDrylandAdapted(ethnicity);
|
|
const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
|
|
const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
|
|
const pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18);
|
|
const gathered = Math.min(w.resource[i], productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2));
|
|
w.resource[i] -= gathered;
|
|
a.resources += gathered;
|
|
const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
|
|
const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
|
|
const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
|
|
const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
|
|
a.resources -= (0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep;
|
|
|
|
if (a.resources <= 0) {
|
|
a.alive = false;
|
|
this.deaths++;
|
|
return;
|
|
}
|
|
|
|
if (a.resources > a.traits.reproductionThreshold && offspring.length < 700) {
|
|
a.resources *= 0.58;
|
|
const childTraits = mutateTraits(a.traits, this.rng, 0.035);
|
|
offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, a.resources * 0.42));
|
|
}
|
|
}
|
|
|
|
climateMismatch(ethnicityId, tile) {
|
|
const ethnicity = this.ethnicities.get(ethnicityId);
|
|
if (!ethnicity) return 0;
|
|
const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
|
|
const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
|
|
return tempDiff * 0.58 + humidDiff * 0.42;
|
|
}
|
|
|
|
isDrylandAdapted(ethnicity) {
|
|
return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38;
|
|
}
|
|
|
|
ethnicDensityNear(x, y, ethnicity) {
|
|
let same = 0;
|
|
let foreign = 0;
|
|
const w = this.world;
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
const counts = this.tileEthnicities.get(w.idx(tx, ty));
|
|
if (!counts) continue;
|
|
for (const [id, value] of counts) {
|
|
if (id === ethnicity) same += value;
|
|
else foreign += value;
|
|
}
|
|
if (same > 6 && foreign > 6) return { same: same * 0.22, foreign: foreign * 0.16 };
|
|
}
|
|
}
|
|
return { same: same * 0.22, foreign: foreign * 0.16 };
|
|
}
|
|
|
|
resolveAssimilation(a) {
|
|
const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
|
|
if (!dominant || dominant.id === a.ethnicity) {
|
|
a.foreignContact = Math.max(0, a.foreignContact - 1);
|
|
return;
|
|
}
|
|
|
|
if (a.contactEthnicity !== dominant.id) {
|
|
a.contactEthnicity = dominant.id;
|
|
a.foreignContact = 0;
|
|
}
|
|
a.foreignContact++;
|
|
|
|
const pressure = dominant.count / Math.max(1, dominant.total);
|
|
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 120);
|
|
if (this.rng.next() < chance * 0.035) {
|
|
a.ethnicity = dominant.id;
|
|
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
|
|
a.foreignContact = 0;
|
|
}
|
|
}
|
|
|
|
dominantEthnicityNear(x, y, self) {
|
|
const counts = new Map();
|
|
let total = 0;
|
|
const w = this.world;
|
|
for (let dy = -3; dy <= 3; dy++) {
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
if (Math.abs(dx) + Math.abs(dy) > 3) continue;
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
const tileCounts = this.tileEthnicities.get(w.idx(tx, ty));
|
|
if (!tileCounts) continue;
|
|
for (const [id, count] of tileCounts) {
|
|
total += count;
|
|
counts.set(id, (counts.get(id) || 0) + count);
|
|
}
|
|
}
|
|
}
|
|
let best = null;
|
|
for (const [id, count] of counts) {
|
|
if (id !== self && (!best || count > best.count)) best = { id, count, total };
|
|
}
|
|
if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
|
|
return best;
|
|
}
|
|
|
|
updateWorldFields() {
|
|
const w = this.world;
|
|
for (let i = 0; i < w.count; i++) {
|
|
w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 1.15));
|
|
w.pheromone[i] *= 0.992;
|
|
w.pressure[i] *= 0.88;
|
|
}
|
|
}
|
|
|
|
updateCities() {
|
|
const w = this.world;
|
|
w.city.fill(-1);
|
|
w.farmland.fill(0);
|
|
w.cityPull.fill(0);
|
|
|
|
const candidates = new Map();
|
|
for (const a of this.agents) {
|
|
if (!a.alive) continue;
|
|
const local = w.idx(a.x, a.y);
|
|
if (a.settled < 3 && w.pressure[local] < 2) continue;
|
|
if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue;
|
|
const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1);
|
|
const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1);
|
|
const i = w.idx(cx, cy);
|
|
let group = candidates.get(i);
|
|
if (!group) {
|
|
group = { count: 0, resources: 0, sedentary: 0, ethnicities: new Map() };
|
|
candidates.set(i, group);
|
|
}
|
|
group.count++;
|
|
group.resources += Math.max(0, a.resources);
|
|
group.sedentary += getSedentary(a.traits);
|
|
group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
|
|
}
|
|
|
|
let foundedThisTick = 0;
|
|
const canFoundCities = this.year >= 220 && this.year % 40 === 0;
|
|
const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / 1500) : 0;
|
|
const candidateEntries = [...candidates].sort((a, b) => b[1].count - a[1].count || b[1].resources - a[1].resources);
|
|
for (const [i, group] of candidateEntries) {
|
|
if (group.count < 6) continue;
|
|
const avgSedentary = group.sedentary / group.count;
|
|
let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7);
|
|
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 50) {
|
|
if (avgSedentary < 0.42 || this.rng.next() > avgSedentary * avgSedentary) continue;
|
|
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
|
|
this.cities.push(city);
|
|
foundedThisTick++;
|
|
}
|
|
if (city) {
|
|
city.activeVisitors += group.count;
|
|
city.storedResources += group.resources * 0.08;
|
|
for (const [id, count] of group.ethnicities) {
|
|
city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + Math.ceil(count * 0.2));
|
|
}
|
|
city.strength = city.strength * 0.96 + group.count * 0.05;
|
|
}
|
|
}
|
|
|
|
this.absorbUrbanPopulation();
|
|
this.processCityEconomies();
|
|
|
|
this.cities = this.cities.filter(c => {
|
|
c.age++;
|
|
c.strength *= 0.996;
|
|
if (c.population < 10) c.strength -= 0.02;
|
|
if (c.population <= 0 || c.strength <= 0.12) return false;
|
|
const radius = c.agriculturalRadius;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const x = clamp(c.x + dx, 0, w.size - 1);
|
|
const y = clamp(c.y + dy, 0, w.size - 1);
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
if (d <= radius) {
|
|
const tile = w.idx(x, y);
|
|
w.city[tile] = c.id;
|
|
if (w.terrain[tile] !== Terrain.WATER) {
|
|
w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
|
|
w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
createCity(x, y, seedGroup = null) {
|
|
const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8;
|
|
const composition = new Map();
|
|
if (seedGroup) {
|
|
for (const [id, count] of seedGroup.ethnicities) composition.set(id, Math.max(1, count * 3));
|
|
}
|
|
return {
|
|
id: this.nextCity++,
|
|
x,
|
|
y,
|
|
population: seedPopulation,
|
|
storedResources: 24 + (seedGroup?.resources || 0) * 0.4,
|
|
ethnicityComposition: composition,
|
|
pheromoneOutput: 0,
|
|
agriculturalRadius: 2,
|
|
tradeLinks: new Set(),
|
|
activeVisitors: 0,
|
|
age: 0,
|
|
strength: 2,
|
|
polityId: null,
|
|
loyalty: 0.5,
|
|
receivedAid: false
|
|
};
|
|
}
|
|
|
|
absorbUrbanPopulation() {
|
|
if (!this.cities.length) return;
|
|
const absorbed = [];
|
|
for (const a of this.agents) {
|
|
if (!a.alive || a.settled < 9) {
|
|
absorbed.push(a);
|
|
continue;
|
|
}
|
|
const city = this.findCityNear(a.x, a.y, 5);
|
|
if (!city || this.rng.next() > getSedentary(a.traits) * 0.55) {
|
|
absorbed.push(a);
|
|
continue;
|
|
}
|
|
const migrants = 1 + Math.floor(Math.min(5, a.resources / 10));
|
|
city.population += migrants;
|
|
city.storedResources += Math.max(0, a.resources) * 0.65;
|
|
city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + migrants);
|
|
city.strength += 0.04;
|
|
}
|
|
this.agents = absorbed;
|
|
}
|
|
|
|
processCityEconomies() {
|
|
const w = this.world;
|
|
for (const city of this.cities) {
|
|
city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
|
|
city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
|
|
let harvested = 0;
|
|
const radius = city.agriculturalRadius;
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
if (Math.abs(dx) + Math.abs(dy) > radius) continue;
|
|
const x = clamp(city.x + dx, 0, w.size - 1);
|
|
const y = clamp(city.y + dy, 0, w.size - 1);
|
|
const i = w.idx(x, y);
|
|
if (w.terrain[i] === Terrain.WATER) continue;
|
|
const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
|
|
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 + w.mineral[i] * 0.045) * pull);
|
|
w.resource[i] -= extraction;
|
|
w.farmland[i] = Math.max(w.farmland[i], pull);
|
|
w.pheromone[i] += city.pheromoneOutput * pull * 0.04;
|
|
harvested += extraction;
|
|
}
|
|
}
|
|
|
|
city.storedResources += harvested;
|
|
const upkeep = city.population * 0.012;
|
|
city.storedResources -= upkeep;
|
|
if (city.storedResources > city.population * 0.24 && city.population > 0) {
|
|
const births = Math.max(1, Math.floor(city.population * 0.004));
|
|
city.population += births;
|
|
city.storedResources -= births * 0.7;
|
|
addBirthsToComposition(city.ethnicityComposition, births);
|
|
}
|
|
if (city.storedResources < 0) {
|
|
const deficit = Math.abs(city.storedResources);
|
|
const dominant = dominantComposition(city.ethnicityComposition);
|
|
const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035));
|
|
city.population -= loss;
|
|
city.storedResources = 0;
|
|
removeFromComposition(city.ethnicityComposition, loss);
|
|
city.strength -= Math.min(0.4, 0.03 + deficit * 0.01);
|
|
if (loss > 0 && this.agents.length < this.maxAgents) {
|
|
this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant);
|
|
}
|
|
}
|
|
city.population = Math.max(0, Math.floor(city.population));
|
|
city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
|
|
}
|
|
}
|
|
|
|
spawnUrbanRefugees(city, count, ethnicity = null) {
|
|
const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
|
|
const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
|
|
for (let i = 0; i < count; i++) {
|
|
this.agents.push(this.makeAgent(
|
|
clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
|
|
clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
|
|
dominant,
|
|
mutateTraits(template, this.rng, 0.05),
|
|
this.rng.range(4, 11)
|
|
));
|
|
}
|
|
}
|
|
|
|
findCityNear(x, y, radius) {
|
|
let best = null;
|
|
let bestDistance = Infinity;
|
|
for (const c of this.cities) {
|
|
const d = Math.abs(c.x - x) + Math.abs(c.y - y);
|
|
if (d <= radius && d < bestDistance) {
|
|
best = c;
|
|
bestDistance = d;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
cityInfluence(city) {
|
|
if (!city || city.population <= 0 || city.strength <= 0) return 0;
|
|
return Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources));
|
|
}
|
|
|
|
getCityById(id) {
|
|
return this.cities.find(c => c.id === id) || null;
|
|
}
|
|
|
|
getPolityById(id) {
|
|
return this.polities.find(p => p.id === id) || null;
|
|
}
|
|
|
|
getPolityCities(polity) {
|
|
const living = [];
|
|
for (const id of [...polity.cityIds]) {
|
|
const city = this.getCityById(id);
|
|
if (!city || city.population <= 0) {
|
|
polity.cityIds.delete(id);
|
|
} else {
|
|
living.push(city);
|
|
}
|
|
}
|
|
return living;
|
|
}
|
|
|
|
dominantCityEthnicity(city) {
|
|
if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
|
|
return dominantComposition(city.ethnicityComposition) || null;
|
|
}
|
|
|
|
sameDominantEthnicity(cityA, cityB) {
|
|
const a = this.dominantCityEthnicity(cityA);
|
|
const b = this.dominantCityEthnicity(cityB);
|
|
return a !== null && b !== null && a === b;
|
|
}
|
|
|
|
distanceBetweenCities(cityA, cityB) {
|
|
return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
|
|
}
|
|
|
|
hasDirectTradeConnection(cityA, cityB) {
|
|
return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id);
|
|
}
|
|
|
|
effectiveDistance(cityA, cityB) {
|
|
let distance = this.distanceBetweenCities(cityA, cityB);
|
|
if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55;
|
|
return distance;
|
|
}
|
|
|
|
createPolity(centerCity) {
|
|
const id = this.nextPolity++;
|
|
const polity = {
|
|
id,
|
|
centerCityId: centerCity.id,
|
|
cityIds: new Set([centerCity.id]),
|
|
treasury: 0,
|
|
color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
|
|
founded: this.year
|
|
};
|
|
centerCity.polityId = id;
|
|
centerCity.loyalty = 1;
|
|
centerCity.receivedAid = false;
|
|
this.polities.push(polity);
|
|
return polity;
|
|
}
|
|
|
|
addCityToPolity(city, polity, initialLoyalty = 0.5) {
|
|
if (!city || !polity) return;
|
|
if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
|
|
city.polityId = polity.id;
|
|
city.loyalty = clamp(initialLoyalty, 0, 1);
|
|
city.receivedAid = false;
|
|
polity.cityIds.add(city.id);
|
|
}
|
|
|
|
removeCityFromPolity(city) {
|
|
if (!city || city.polityId === null) return;
|
|
const oldPolity = this.getPolityById(city.polityId);
|
|
if (oldPolity) oldPolity.cityIds.delete(city.id);
|
|
city.polityId = null;
|
|
city.loyalty = 0.45;
|
|
city.receivedAid = false;
|
|
}
|
|
|
|
foundPolities() {
|
|
for (const city of this.cities) {
|
|
if (city.polityId !== null || city.population < 80 || city.storedResources < 30) continue;
|
|
const centerInfluence = this.cityInfluence(city);
|
|
let absorbed = 0;
|
|
for (const other of this.cities) {
|
|
if (absorbed >= 2) break;
|
|
if (other === city || other.polityId !== null) continue;
|
|
if (this.distanceBetweenCities(city, other) > 28) continue;
|
|
const targetInfluence = this.cityInfluence(other);
|
|
if (centerInfluence <= targetInfluence * 1.25) continue;
|
|
|
|
const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08);
|
|
const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0;
|
|
const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus;
|
|
if (dominanceScore > 0.9 && this.rng.next() < 0.18) {
|
|
const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId);
|
|
this.addCityToPolity(other, polity, 0.55);
|
|
absorbed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expandPolities() {
|
|
for (const polity of this.polities) {
|
|
const center = this.getCityById(polity.centerCityId);
|
|
if (!center) continue;
|
|
const centerInfluence = this.cityInfluence(center);
|
|
let absorbed = 0;
|
|
for (const city of this.cities) {
|
|
if (absorbed >= 1) break;
|
|
if (city.polityId !== null || city.id === center.id) continue;
|
|
const distance = this.effectiveDistance(center, city);
|
|
if (distance > 32) continue;
|
|
const targetInfluence = this.cityInfluence(city);
|
|
if (centerInfluence <= targetInfluence) continue;
|
|
const dominanceScore =
|
|
(centerInfluence / (targetInfluence + 1)) *
|
|
(1 / (1 + distance * 0.08)) *
|
|
(this.hasDirectTradeConnection(center, city) ? 1.5 : 1.0);
|
|
if (dominanceScore > 0.85 && this.rng.next() < 0.12) {
|
|
this.addCityToPolity(city, polity, 0.48);
|
|
absorbed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
collectAndRedistributeResources() {
|
|
for (const polity of this.polities) {
|
|
const cities = this.getPolityCities(polity);
|
|
const centerId = polity.centerCityId;
|
|
for (const city of cities) city.receivedAid = false;
|
|
for (const city of cities) {
|
|
if (city.id === centerId) continue;
|
|
const tax = city.storedResources * 0.04;
|
|
city.storedResources -= tax;
|
|
polity.treasury += tax;
|
|
}
|
|
|
|
const poorCount = Math.ceil(cities.length * 0.1);
|
|
const poorest = [...cities]
|
|
.sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population)))
|
|
.slice(0, poorCount);
|
|
for (const city of poorest) {
|
|
const target = city.population * 0.08;
|
|
const need = target - city.storedResources;
|
|
if (need > 0 && polity.treasury > 0) {
|
|
const aid = Math.min(need, polity.treasury);
|
|
city.storedResources += aid;
|
|
polity.treasury -= aid;
|
|
city.receivedAid = true;
|
|
city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateCityLoyalty() {
|
|
for (const polity of this.polities) {
|
|
const center = this.getCityById(polity.centerCityId);
|
|
if (!center) continue;
|
|
center.loyalty = 1;
|
|
for (const city of this.getPolityCities(polity)) {
|
|
if (city.id === center.id) continue;
|
|
const perCapita = city.storedResources / Math.max(1, city.population);
|
|
const distance = this.effectiveDistance(city, center);
|
|
let delta = 0;
|
|
delta += clamp((perCapita - 0.12) * 0.08, -0.04, 0.04);
|
|
delta += this.sameDominantEthnicity(city, center) ? 0.025 : -0.025;
|
|
delta += clamp(0.035 - distance * 0.0015, -0.045, 0.035);
|
|
if (this.hasDirectTradeConnection(city, center)) delta += 0.015;
|
|
if (city.receivedAid) delta += 0.04;
|
|
delta -= 0.01;
|
|
if (city.storedResources < city.population * 0.05) delta -= 0.06;
|
|
city.loyalty = clamp(city.loyalty + delta, 0, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
splitUnloyalCities() {
|
|
for (const polity of this.polities) {
|
|
for (const city of this.getPolityCities(polity)) {
|
|
if (city.id === polity.centerCityId || city.loyalty >= 0.2) continue;
|
|
const chance = (0.2 - city.loyalty) * 0.4;
|
|
if (this.rng.next() < chance) this.removeCityFromPolity(city);
|
|
}
|
|
}
|
|
}
|
|
|
|
cleanupPolities() {
|
|
const survivors = [];
|
|
for (const polity of this.polities) {
|
|
const cities = this.getPolityCities(polity);
|
|
if (!cities.length) continue;
|
|
let center = this.getCityById(polity.centerCityId);
|
|
if (!center) {
|
|
center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
|
|
polity.centerCityId = center.id;
|
|
center.loyalty = 1;
|
|
}
|
|
if (cities.length === 1) {
|
|
cities[0].polityId = null;
|
|
cities[0].loyalty = 0.45;
|
|
cities[0].receivedAid = false;
|
|
continue;
|
|
}
|
|
survivors.push(polity);
|
|
}
|
|
this.polities = survivors;
|
|
const validPolities = new Set(this.polities.map(p => p.id));
|
|
for (const city of this.cities) {
|
|
if (city.polityId !== null && !validPolities.has(city.polityId)) {
|
|
city.polityId = null;
|
|
city.loyalty = 0.45;
|
|
city.receivedAid = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
updatePolities() {
|
|
if (this.year % 12 !== 0) return;
|
|
this.cleanupPolities();
|
|
this.foundPolities();
|
|
this.expandPolities();
|
|
this.collectAndRedistributeResources();
|
|
this.updateCityLoyalty();
|
|
this.splitUnloyalCities();
|
|
this.cleanupPolities();
|
|
}
|
|
|
|
updateTradeRoutes() {
|
|
const w = this.world;
|
|
for (let i = 0; i < w.count; i++) {
|
|
const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
|
|
if (invalidTerrain) w.tradeRoute[i] = 0;
|
|
else if (w.tradeRoute[i] > 0 && this.year % 4 === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
|
|
}
|
|
|
|
this.tradeLinks = [];
|
|
for (const city of this.cities) city.tradeLinks.clear();
|
|
const candidates = [];
|
|
for (let a = 0; a < this.cities.length; a++) {
|
|
for (let b = a + 1; b < this.cities.length; b++) {
|
|
const c1 = this.cities[a];
|
|
const c2 = this.cities[b];
|
|
const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
|
|
if (d > 34) continue;
|
|
const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
|
|
if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
|
|
const strength = this.routeStrengthForPath(path);
|
|
if (strength < 0.12) continue;
|
|
candidates.push({ c1, c2, strength, path });
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) => b.strength - a.strength);
|
|
const maxLinks = Math.max(1, Math.floor(this.cities.length / 3));
|
|
const supportedRoutes = new Set();
|
|
for (const candidate of candidates) {
|
|
if (this.tradeLinks.length >= maxLinks) break;
|
|
const { c1, c2, strength, path } = candidate;
|
|
const c1Limit = c1.population > 90 ? 2 : 1;
|
|
const c2Limit = c2.population > 90 ? 2 : 1;
|
|
if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
|
|
c1.tradeLinks.add(c2.id);
|
|
c2.tradeLinks.add(c1.id);
|
|
this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
|
|
this.exchangeCityResources(c1, c2, strength, path);
|
|
for (const tile of path) {
|
|
supportedRoutes.add(tile);
|
|
w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 12);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 10);
|
|
}
|
|
}
|
|
|
|
findTerrainRoute(x1, y1, x2, y2) {
|
|
const w = this.world;
|
|
const path = [];
|
|
const visited = new Set();
|
|
let x = x1;
|
|
let y = y1;
|
|
const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60);
|
|
const directions = [
|
|
[1, 0],
|
|
[-1, 0],
|
|
[0, 1],
|
|
[0, -1]
|
|
];
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const current = w.idx(x, y);
|
|
path.push(current);
|
|
visited.add(current);
|
|
if (x === x2 && y === y2) break;
|
|
|
|
let bestX = x;
|
|
let bestY = y;
|
|
let bestScore = Infinity;
|
|
for (const [dx, dy] of directions) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
|
|
const i = w.idx(nx, ny);
|
|
const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
|
|
const revisitCost = visited.has(i) ? 5 : 0;
|
|
const routeEase = w.tradeRoute[i] ? -2 : 0;
|
|
const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
|
|
const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
|
|
const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
bestX = nx;
|
|
bestY = ny;
|
|
}
|
|
}
|
|
|
|
if (bestX === x && bestY === y) break;
|
|
x = bestX;
|
|
y = bestY;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
routeStrengthForPath(path) {
|
|
const w = this.world;
|
|
if (!path.length) return 0;
|
|
let route = 0;
|
|
let pheromone = 0;
|
|
let terrainEase = 0;
|
|
for (const i of path) {
|
|
route += w.tradeRoute[i] > 0 ? 1 : 0;
|
|
pheromone += clamp(w.pheromone[i] / 9, 0, 1);
|
|
terrainEase += 1 / Math.max(1, w.move[i]);
|
|
}
|
|
return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
|
|
}
|
|
|
|
isValidRoutePath(path, targetX, targetY) {
|
|
const w = this.world;
|
|
if (!path.length) return false;
|
|
const last = path[path.length - 1];
|
|
if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
|
|
if (path.length > 42) return false;
|
|
|
|
let totalCost = 0;
|
|
let hardTiles = 0;
|
|
const seen = new Set();
|
|
for (const tile of path) {
|
|
if (seen.has(tile)) return false;
|
|
seen.add(tile);
|
|
if (w.terrain[tile] === Terrain.WATER) return false;
|
|
totalCost += w.move[tile];
|
|
if (w.move[tile] > 1.8) hardTiles++;
|
|
}
|
|
return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
|
|
}
|
|
|
|
exchangeCityResources(a, b, strength, path) {
|
|
const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
|
|
a.storedResources -= delta;
|
|
b.storedResources += delta;
|
|
const traffic = Math.min(0.8, strength * 0.26);
|
|
this.depositRoutePheromone(path, traffic);
|
|
}
|
|
|
|
depositRoutePheromone(path, amount) {
|
|
const w = this.world;
|
|
for (const i of path) {
|
|
w.pheromone[i] += amount;
|
|
}
|
|
}
|
|
|
|
updateEthnicStats() {
|
|
for (const e of this.ethnicities.values()) {
|
|
e.population = 0;
|
|
e.diversity = 0;
|
|
e.centroidX = 0;
|
|
e.centroidY = 0;
|
|
e.activeTraitPopulation = 0;
|
|
e.traitSums = emptyTraitSums();
|
|
}
|
|
|
|
for (const a of this.agents) {
|
|
const e = this.ethnicities.get(a.ethnicity);
|
|
if (!e) continue;
|
|
e.population++;
|
|
e.centroidX += a.x;
|
|
e.centroidY += a.y;
|
|
e.activeTraitPopulation++;
|
|
addTraits(e.traitSums, a.traits);
|
|
}
|
|
|
|
for (const city of this.cities) {
|
|
for (const [id, count] of city.ethnicityComposition) {
|
|
const e = this.ethnicities.get(id);
|
|
if (!e) continue;
|
|
e.population += count;
|
|
e.centroidX += city.x * count;
|
|
e.centroidY += city.y * count;
|
|
}
|
|
}
|
|
|
|
for (const e of this.ethnicities.values()) {
|
|
if (!e.population) continue;
|
|
e.centroidX /= e.population;
|
|
e.centroidY /= e.population;
|
|
const activeTraitCount = Math.max(1, e.activeTraitPopulation);
|
|
e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
|
|
}
|
|
|
|
for (const a of this.agents) {
|
|
const e = this.ethnicities.get(a.ethnicity);
|
|
if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
|
|
}
|
|
|
|
for (const e of this.ethnicities.values()) {
|
|
if (e.population) e.diversity /= e.population;
|
|
}
|
|
}
|
|
|
|
splitDivergentEthnicities() {
|
|
for (const e of this.ethnicities.values()) {
|
|
if (e.activeTraitPopulation < 35 || e.population < 55 || e.diversity < 0.22) continue;
|
|
const candidates = [];
|
|
let tempSum = 0;
|
|
let humidSum = 0;
|
|
for (const a of this.agents) {
|
|
if (a.ethnicity !== e.id) continue;
|
|
const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 1.12;
|
|
const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.12;
|
|
const tile = this.world.idx(a.x, a.y);
|
|
const climate = this.climateMismatch(e.id, tile) > 0.26;
|
|
if ((far || spatial || climate) && this.rng.next() < 0.58) {
|
|
candidates.push(a);
|
|
tempSum += this.world.temperature[tile];
|
|
humidSum += this.world.humidity[tile];
|
|
}
|
|
}
|
|
if (candidates.length >= 10) {
|
|
const newId = this.createEthnicity(e.id, {
|
|
temperature: tempSum / candidates.length,
|
|
humidity: humidSum / candidates.length
|
|
});
|
|
for (const a of candidates) a.ethnicity = newId;
|
|
}
|
|
}
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
version: 2,
|
|
rngSeed: this.rng.seed,
|
|
year: this.year,
|
|
deaths: this.deaths,
|
|
nextEthnicity: this.nextEthnicity,
|
|
nextCity: this.nextCity,
|
|
nextPolity: this.nextPolity,
|
|
maxAgents: this.maxAgents,
|
|
world: {
|
|
size: this.world.size,
|
|
terrain: packArray(this.world.terrain),
|
|
resource: packArray(this.world.resource),
|
|
regen: packArray(this.world.regen),
|
|
move: packArray(this.world.move),
|
|
fertility: packArray(this.world.fertility),
|
|
mineral: packArray(this.world.mineral),
|
|
temperature: packArray(this.world.temperature),
|
|
humidity: packArray(this.world.humidity),
|
|
pheromone: packArray(this.world.pheromone),
|
|
tradeRoute: packArray(this.world.tradeRoute),
|
|
farmland: packArray(this.world.farmland),
|
|
cityPull: packArray(this.world.cityPull),
|
|
city: packArray(this.world.city),
|
|
pressure: packArray(this.world.pressure)
|
|
},
|
|
agents: this.agents,
|
|
ethnicities: [...this.ethnicities.values()].map(e => ({
|
|
id: e.id,
|
|
parent: e.parent,
|
|
born: e.born,
|
|
climateTemp: e.climateTemp,
|
|
climateHumidity: e.climateHumidity,
|
|
color: e.color
|
|
})),
|
|
cities: this.cities.map(c => ({
|
|
...c,
|
|
ethnicityComposition: [...c.ethnicityComposition],
|
|
tradeLinks: [...c.tradeLinks]
|
|
})),
|
|
polities: this.polities.map(p => ({
|
|
id: p.id,
|
|
centerCityId: p.centerCityId,
|
|
cityIds: [...p.cityIds],
|
|
treasury: p.treasury,
|
|
color: p.color,
|
|
founded: p.founded
|
|
}))
|
|
};
|
|
}
|
|
|
|
static fromJSON(state) {
|
|
const sim = new Simulation(state.world.size, 0);
|
|
sim.rng.seed = state.rngSeed >>> 0;
|
|
sim.year = state.year || 0;
|
|
sim.deaths = state.deaths || 0;
|
|
sim.nextEthnicity = state.nextEthnicity || 1;
|
|
sim.nextCity = state.nextCity || 1;
|
|
sim.nextPolity = state.nextPolity || 1;
|
|
sim.maxAgents = state.maxAgents || 30000;
|
|
|
|
sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
|
|
sim.world.resource.set(unpackArray(state.world.resource, Float32Array));
|
|
sim.world.regen.set(unpackArray(state.world.regen, Float32Array));
|
|
sim.world.move.set(unpackArray(state.world.move, Float32Array));
|
|
sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array));
|
|
sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array));
|
|
sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array));
|
|
sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array));
|
|
sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array));
|
|
sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array));
|
|
sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
|
|
sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
|
|
sim.world.city.set(unpackArray(state.world.city, Int32Array));
|
|
sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
|
|
|
|
sim.agents = state.agents || [];
|
|
sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
|
|
id: e.id,
|
|
parent: e.parent,
|
|
born: e.born,
|
|
population: 0,
|
|
diversity: 0,
|
|
climateTemp: e.climateTemp,
|
|
climateHumidity: e.climateHumidity,
|
|
color: e.color,
|
|
centroidX: 0,
|
|
centroidY: 0
|
|
}]));
|
|
sim.cities = (state.cities || []).map(c => ({
|
|
id: c.id,
|
|
x: c.x,
|
|
y: c.y,
|
|
population: c.population || 0,
|
|
storedResources: c.storedResources || 0,
|
|
ethnicityComposition: new Map(c.ethnicityComposition || []),
|
|
pheromoneOutput: c.pheromoneOutput || 0,
|
|
agriculturalRadius: c.agriculturalRadius || 2,
|
|
tradeLinks: new Set(c.tradeLinks || []),
|
|
activeVisitors: 0,
|
|
age: c.age || 0,
|
|
strength: c.strength || 1,
|
|
polityId: c.polityId ?? null,
|
|
loyalty: c.loyalty ?? 0.5,
|
|
receivedAid: c.receivedAid ?? false
|
|
}));
|
|
sim.polities = (state.polities || []).map(p => ({
|
|
id: p.id,
|
|
centerCityId: p.centerCityId,
|
|
cityIds: new Set(p.cityIds || []),
|
|
treasury: p.treasury || 0,
|
|
color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62),
|
|
founded: p.founded || 0
|
|
}));
|
|
sim.tradeLinks = [];
|
|
sim.rebuildOccupancy();
|
|
sim.updateTradeRoutes();
|
|
sim.cleanupPolities();
|
|
sim.updateEthnicStats();
|
|
return sim;
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const start = performance.now();
|
|
const w = sim.world;
|
|
const size = w.size;
|
|
const image = ctx.createImageData(size, size);
|
|
const data = image.data;
|
|
const mode = els.viewMode.value;
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
let color;
|
|
if (mode === "resources") {
|
|
const v = clamp(w.resource[i] / 30, 0, 1);
|
|
color = mix([28, 36, 40], [107, 188, 85], v);
|
|
} else if (mode === "pheromone") {
|
|
const v = clamp(w.pheromone[i] / 9, 0, 1);
|
|
color = w.tradeRoute[i] ? mix(terrainInfo[w.terrain[i]].color, [255, 218, 91], 0.38) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v);
|
|
} else if (mode === "pressure") {
|
|
const v = clamp(w.pressure[i] / 12, 0, 1);
|
|
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
|
|
} else if (mode === "cities") {
|
|
color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color;
|
|
} else if (mode === "polities") {
|
|
color = terrainInfo[w.terrain[i]].color;
|
|
if (w.city[i] >= 0) {
|
|
const city = sim.getCityById(w.city[i]);
|
|
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
|
|
if (polity) color = mix(color, polity.color, 0.5);
|
|
}
|
|
} else {
|
|
color = terrainInfo[w.terrain[i]].color;
|
|
}
|
|
const p = i * 4;
|
|
data[p] = color[0];
|
|
data[p + 1] = color[1];
|
|
data[p + 2] = color[2];
|
|
data[p + 3] = 255;
|
|
}
|
|
|
|
ctx.putImageData(image, 0, 0);
|
|
drawTradeLinks();
|
|
drawFarmlandRings(mode);
|
|
drawAgentsAndCities(mode);
|
|
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
|
|
}
|
|
|
|
function drawTradeLinks() {
|
|
const w = sim.world;
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.24;
|
|
ctx.fillStyle = "#d9b650";
|
|
for (let i = 0; i < w.count; i++) {
|
|
if (w.tradeRoute[i]) ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
|
|
}
|
|
if (!sim.tradeLinks.length) {
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
for (const link of sim.tradeLinks) {
|
|
ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
|
|
ctx.fillStyle = "#ffd75a";
|
|
for (const tile of link.path || []) {
|
|
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFarmlandRings(mode) {
|
|
if (mode !== "cities") return;
|
|
const w = sim.world;
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.78;
|
|
ctx.fillStyle = "#d9c45f";
|
|
for (const city of sim.cities) {
|
|
const radius = Math.max(1, city.agriculturalRadius);
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
if (d !== radius) continue;
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
|
|
if (w.terrain[w.idx(x, y)] !== Terrain.WATER) ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawAgentsAndCities(mode) {
|
|
ctx.save();
|
|
for (const city of sim.cities) {
|
|
const radius = cityRenderRadius(city);
|
|
const color = cityDisplayColor(city, mode);
|
|
ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`;
|
|
ctx.globalAlpha = 0.88;
|
|
ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
for (const a of sim.agents) {
|
|
if (mode === "ethnicity") {
|
|
const e = sim.ethnicities.get(a.ethnicity);
|
|
if (!e) continue;
|
|
ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
|
|
ctx.fillRect(a.x, a.y, 1, 1);
|
|
} else {
|
|
ctx.fillStyle = "#eeeccf";
|
|
ctx.fillRect(a.x, a.y, 1, 1);
|
|
}
|
|
}
|
|
|
|
for (const city of sim.cities) {
|
|
const color = cityDisplayColor(city, mode);
|
|
ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`;
|
|
ctx.fillRect(city.x, city.y, 1, 1);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function cityRenderRadius(city) {
|
|
return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7);
|
|
}
|
|
|
|
function cityMajorityColor(city) {
|
|
const id = dominantComposition(city.ethnicityComposition);
|
|
return sim.ethnicities.get(id)?.color || [242, 215, 134];
|
|
}
|
|
|
|
function cityDisplayColor(city, mode) {
|
|
if (mode === "polities" && city.polityId !== null) {
|
|
const polity = sim.getPolityById(city.polityId);
|
|
if (polity) return polity.color;
|
|
}
|
|
return cityMajorityColor(city);
|
|
}
|
|
|
|
function showTooltip(event) {
|
|
const rect = els.canvas.getBoundingClientRect();
|
|
const simRect = els.sim.getBoundingClientRect();
|
|
const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
|
|
const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
|
|
hoverState = {
|
|
x,
|
|
y,
|
|
left: event.clientX - simRect.left + 16,
|
|
top: event.clientY - simRect.top + 16,
|
|
width: simRect.width,
|
|
height: simRect.height
|
|
};
|
|
renderTooltip();
|
|
}
|
|
|
|
function renderTooltip() {
|
|
if (!hoverState) return;
|
|
const x = hoverState.x;
|
|
const y = hoverState.y;
|
|
if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
|
|
hideTooltip();
|
|
return;
|
|
}
|
|
|
|
const w = sim.world;
|
|
const i = w.idx(x, y);
|
|
const agent = findAgentAt(x, y);
|
|
const city = w.city[i] >= 0 ? sim.cities.find(c => c.id === w.city[i]) : null;
|
|
const terrain = terrainInfo[w.terrain[i]];
|
|
const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
|
|
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
|
|
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
|
|
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
|
|
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
|
|
|
|
els.tooltip.innerHTML = `
|
|
<strong>${agent ? "Agent group" : terrain.name}</strong>
|
|
<span><b>Tile</b><em>${x}, ${y}</em></span>
|
|
<span><b>Terrain</b><em>${terrain.name}</em></span>
|
|
<span><b>Resources</b><em>${w.resource[i].toFixed(1)}</em></span>
|
|
<span><b>Fertility</b><em>${w.fertility[i].toFixed(2)}</em></span>
|
|
<span><b>Temp / humid</b><em>${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}</em></span>
|
|
<span><b>Water influence</b><em>${waterInfluence}</em></span>
|
|
<span><b>Minerals</b><em>${w.mineral[i].toFixed(2)}</em></span>
|
|
<span><b>Pheromone</b><em>${w.pheromone[i].toFixed(1)}</em></span>
|
|
<span><b>Pressure</b><em>${w.pressure[i].toFixed(0)}</em></span>
|
|
${w.tradeRoute[i] ? `<span><b>Route</b><em>${w.tradeRoute[i]}</em></span>` : ""}
|
|
${city ? `<span><b>City</b><em>#${city.id}</em></span>` : ""}
|
|
${city ? `<span><b>Urban pop</b><em>${city.population.toLocaleString()}</em></span>` : ""}
|
|
${city ? `<span><b>Food stock</b><em>${city.storedResources.toFixed(1)}</em></span>` : ""}
|
|
${city ? `<span><b>Farmland radius</b><em>${city.agriculturalRadius}</em></span>` : ""}
|
|
${city ? `<span><b>Trade links</b><em>${city.tradeLinks.size}</em></span>` : ""}
|
|
${cityEthnicity ? `<span><b>City majority</b><em>E${cityEthnicity}</em></span>` : ""}
|
|
${city ? `<span><b>State</b><em>${polity ? `#${polity.id}` : "Independent"}</em></span>` : ""}
|
|
${city ? `<span><b>Loyalty</b><em>${city.loyalty.toFixed(2)}</em></span>` : ""}
|
|
${polity ? `<span><b>Treasury</b><em>${polity.treasury.toFixed(1)}</em></span>` : ""}
|
|
${polity ? `<span><b>Center</b><em>${polity.centerCityId === city.id ? "yes" : "no"}</em></span>` : ""}
|
|
${agent ? `<span><b>Ethnicity</b><em>E${agent.ethnicity}</em></span>` : ""}
|
|
${ethnicity ? `<span><b>Climate pref</b><em>${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}</em></span>` : ""}
|
|
${agent ? `<span><b>Climate mismatch</b><em>${mismatch.toFixed(2)}</em></span>` : ""}
|
|
${agent ? `<span><b>Sedentary</b><em>${getSedentary(agent.traits).toFixed(2)}</em></span>` : ""}
|
|
${agent ? `<span><b>Ethnocentrism</b><em>${getEthnocentrism(agent.traits).toFixed(2)}</em></span>` : ""}
|
|
${agent ? `<span><b>Stored</b><em>${agent.resources.toFixed(1)}</em></span>` : ""}
|
|
${ethnicity ? `<span><b>Lineage pop</b><em>${ethnicity.population}</em></span>` : ""}
|
|
`;
|
|
els.tooltip.hidden = false;
|
|
const left = Math.min(hoverState.left, hoverState.width - 280);
|
|
const top = Math.min(hoverState.top, hoverState.height - 230);
|
|
els.tooltip.style.left = `${Math.max(8, left)}px`;
|
|
els.tooltip.style.top = `${Math.max(8, top)}px`;
|
|
}
|
|
|
|
function hideTooltip() {
|
|
hoverState = null;
|
|
els.tooltip.hidden = true;
|
|
}
|
|
|
|
function findAgentAt(x, y) {
|
|
const directIndex = sim.world.idx(x, y);
|
|
const directCounts = sim.tileEthnicities.get(directIndex);
|
|
if (!directCounts) return null;
|
|
for (const a of sim.agents) {
|
|
if (a.x === x && a.y === y) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function waterInfluenceAt(world, x, y) {
|
|
let score = 0;
|
|
for (let dy = -3; dy <= 3; dy++) {
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
if (!d || d > 3) continue;
|
|
const tx = x + dx;
|
|
const ty = y + dy;
|
|
if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue;
|
|
if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4;
|
|
}
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function updateStats(force = false) {
|
|
const now = performance.now();
|
|
if (!force && now - lastStatsAt < 300) return;
|
|
lastStatsAt = now;
|
|
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
|
|
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
|
|
els.year.textContent = sim.year.toLocaleString();
|
|
els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString();
|
|
els.activeGroups.textContent = sim.agents.length.toLocaleString();
|
|
els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString();
|
|
els.ethnicities.textContent = livingEthnicities.length.toLocaleString();
|
|
els.cities.textContent = sim.cities.length.toLocaleString();
|
|
els.polities.textContent = sim.polities.length.toLocaleString();
|
|
els.routes.textContent = sim.tradeLinks.length.toLocaleString();
|
|
els.deaths.textContent = sim.deaths.toLocaleString();
|
|
|
|
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
|
|
els.ethnicityList.innerHTML = top.map(e => (
|
|
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
|
|
`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}</li>`
|
|
)).join("");
|
|
}
|
|
|
|
function setLegend() {
|
|
const mode = els.viewMode.value;
|
|
if (mode === "terrain") {
|
|
els.legend.innerHTML = terrainInfo.map(t => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join("");
|
|
} else if (mode === "ethnicity") {
|
|
els.legend.innerHTML = "<span>Color = inherited ethnicity. New colors appear only by recorded splits or assimilation.</span>";
|
|
} else if (mode === "pheromone") {
|
|
els.legend.innerHTML = "<span><i style=\"background:#e8c14b\"></i>Pheromone trails and permanent trade routes</span>";
|
|
} else if (mode === "pressure") {
|
|
els.legend.innerHTML = "<span><i style=\"background:#d24943\"></i>High local population pressure</span>";
|
|
} else if (mode === "cities") {
|
|
els.legend.innerHTML = "<span><i style=\"background:#e2c679\"></i>City nodes, farmland radius, and stored urban population</span>";
|
|
} else if (mode === "polities") {
|
|
els.legend.innerHTML = "<span>Color = city-centered state. Uncolored cities are independent.</span>";
|
|
} else {
|
|
els.legend.innerHTML = "<span><i style=\"background:#6bbc55\"></i>Regenerating local resource stock</span>";
|
|
}
|
|
}
|
|
|
|
function loop() {
|
|
const steps = running ? Number(els.speed.value) : 0;
|
|
for (let i = 0; i < steps; i++) sim.step();
|
|
if (running || frame % 8 === 0) {
|
|
render();
|
|
updateStats();
|
|
renderTooltip();
|
|
}
|
|
frame++;
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
function reset() {
|
|
const size = Number(els.worldSize.value);
|
|
const count = Number(els.agentCount.value);
|
|
els.canvas.width = size;
|
|
els.canvas.height = size;
|
|
sim = new Simulation(size, count);
|
|
setLegend();
|
|
render();
|
|
updateStats(true);
|
|
}
|
|
|
|
function clamp(v, min, max) {
|
|
return Math.max(min, Math.min(max, v));
|
|
}
|
|
|
|
function smoothstep(t) {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function getSedentary(traits) {
|
|
return traits.sedentary;
|
|
}
|
|
|
|
function getEthnocentrism(traits) {
|
|
return traits.ethnocentrism;
|
|
}
|
|
|
|
function mutateTraits(traits, rng, amount) {
|
|
return {
|
|
mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1),
|
|
resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2),
|
|
assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75),
|
|
ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2),
|
|
reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48),
|
|
sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1)
|
|
};
|
|
}
|
|
|
|
function blendTraits(a, b, t) {
|
|
return {
|
|
mobility: lerp(a.mobility, b.mobility, t),
|
|
resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t),
|
|
assimilation: lerp(a.assimilation, b.assimilation, t),
|
|
ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t),
|
|
reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t),
|
|
sedentary: lerp(getSedentary(a), getSedentary(b), t)
|
|
};
|
|
}
|
|
|
|
function emptyTraitSums() {
|
|
return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 };
|
|
}
|
|
|
|
function addTraits(sum, traits) {
|
|
sum.mobility += traits.mobility;
|
|
sum.resourceAttraction += traits.resourceAttraction;
|
|
sum.assimilation += traits.assimilation;
|
|
sum.ethnocentrism += getEthnocentrism(traits);
|
|
sum.reproductionThreshold += traits.reproductionThreshold / 48;
|
|
sum.sedentary += getSedentary(traits);
|
|
}
|
|
|
|
function averageTraits(sum, count) {
|
|
return {
|
|
mobility: sum.mobility / count,
|
|
resourceAttraction: sum.resourceAttraction / count,
|
|
assimilation: sum.assimilation / count,
|
|
ethnocentrism: sum.ethnocentrism / count,
|
|
reproductionThreshold: (sum.reproductionThreshold / count) * 48,
|
|
sedentary: sum.sedentary / count
|
|
};
|
|
}
|
|
|
|
function addBirthsToComposition(composition, births) {
|
|
const dominant = dominantComposition(composition);
|
|
if (!dominant) return;
|
|
composition.set(dominant, (composition.get(dominant) || 0) + births);
|
|
}
|
|
|
|
function removeFromComposition(composition, loss) {
|
|
let remaining = loss;
|
|
const total = [...composition.values()].reduce((sum, value) => sum + value, 0);
|
|
if (!total) return;
|
|
for (const [id, count] of [...composition]) {
|
|
const removed = Math.min(count, Math.ceil(loss * (count / total)));
|
|
composition.set(id, Math.max(0, count - removed));
|
|
remaining -= removed;
|
|
if (composition.get(id) <= 0) composition.delete(id);
|
|
if (remaining <= 0) break;
|
|
}
|
|
}
|
|
|
|
function dominantComposition(composition) {
|
|
let bestId = null;
|
|
let bestCount = 0;
|
|
for (const [id, count] of composition) {
|
|
if (count > bestCount) {
|
|
bestId = id;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
return bestId;
|
|
}
|
|
|
|
function traitDistance(a, b) {
|
|
return Math.abs(a.mobility - b.mobility) +
|
|
Math.abs(a.resourceAttraction - b.resourceAttraction) +
|
|
Math.abs(a.assimilation - b.assimilation) +
|
|
Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) +
|
|
Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 +
|
|
Math.abs(getSedentary(a) - getSedentary(b));
|
|
}
|
|
|
|
function hslToRgb(h, s, l) {
|
|
const hue = (p, q, t) => {
|
|
if (t < 0) t += 1;
|
|
if (t > 1) t -= 1;
|
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
if (t < 1 / 2) return q;
|
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
return p;
|
|
};
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
const p = 2 * l - q;
|
|
return [
|
|
Math.round(hue(p, q, h + 1 / 3) * 255),
|
|
Math.round(hue(p, q, h) * 255),
|
|
Math.round(hue(p, q, h - 1 / 3) * 255)
|
|
];
|
|
}
|
|
|
|
function mix(a, b, t) {
|
|
return [
|
|
Math.round(lerp(a[0], b[0], t)),
|
|
Math.round(lerp(a[1], b[1], t)),
|
|
Math.round(lerp(a[2], b[2], t))
|
|
];
|
|
}
|
|
|
|
function lerp(a, b, t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
function packArray(typedArray) {
|
|
const bytes = new Uint8Array(typedArray.buffer);
|
|
let binary = "";
|
|
const chunkSize = 8192;
|
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
}
|
|
return {
|
|
type: typedArray.constructor.name,
|
|
length: typedArray.length,
|
|
data: btoa(binary)
|
|
};
|
|
}
|
|
|
|
function unpackArray(payload, TypedArray) {
|
|
const binary = atob(payload.data);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return new TypedArray(bytes.buffer, 0, payload.length);
|
|
}
|
|
|
|
function saveWorld() {
|
|
try {
|
|
localStorage.setItem("civil-emergence-save", JSON.stringify(sim));
|
|
} catch (error) {
|
|
console.warn("Save failed", error);
|
|
}
|
|
}
|
|
|
|
function loadWorld() {
|
|
const raw = localStorage.getItem("civil-emergence-save");
|
|
if (!raw) return;
|
|
try {
|
|
const state = JSON.parse(raw);
|
|
sim = Simulation.fromJSON(state);
|
|
els.worldSize.value = String(sim.world.size);
|
|
els.canvas.width = sim.world.size;
|
|
els.canvas.height = sim.world.size;
|
|
setLegend();
|
|
render();
|
|
updateStats(true);
|
|
} catch (error) {
|
|
console.warn("Load failed", error);
|
|
}
|
|
}
|
|
|
|
els.toggleRun.addEventListener("click", () => {
|
|
running = !running;
|
|
els.toggleRun.textContent = running ? "Pause" : "Run";
|
|
});
|
|
els.stepOnce.addEventListener("click", () => {
|
|
sim.step();
|
|
render();
|
|
updateStats(true);
|
|
renderTooltip();
|
|
});
|
|
els.resetWorld.addEventListener("click", reset);
|
|
els.saveWorld.addEventListener("click", saveWorld);
|
|
els.loadWorld.addEventListener("click", loadWorld);
|
|
els.clearSave.addEventListener("click", () => localStorage.removeItem("civil-emergence-save"));
|
|
els.worldSize.addEventListener("change", reset);
|
|
els.agentCount.addEventListener("change", reset);
|
|
els.viewMode.addEventListener("change", () => {
|
|
setLegend();
|
|
render();
|
|
});
|
|
els.canvas.addEventListener("mousemove", showTooltip);
|
|
els.canvas.addEventListener("mouseleave", hideTooltip);
|
|
|
|
reset();
|
|
loop();
|