From 7b5fdad378c2a3dd2467be58e980fd455bf064ab Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 12 May 2026 13:03:47 +0900 Subject: [PATCH] Add initial styles for dark theme and layout structure in styles.css --- index.html | 91 ++++ script.js | 1485 ++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 288 ++++++++++ 3 files changed, 1864 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..6434e0a --- /dev/null +++ b/index.html @@ -0,0 +1,91 @@ + + + + + + Emergent Civilization Simulation + + + +
+ + +
+ +
+ +
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..fbdec66 --- /dev/null +++ b/script.js @@ -0,0 +1,1485 @@ +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.8, fertility: 0.1, mineral: 0.12, regen: 0.007 }, + { 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"), + 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"), + 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.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() + })); + + 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.55 - Math.hypot(nx, ny) * 0.72 + this.smoothNoise(x, y, 42) * 0.38 + this.smoothNoise(x + 900, y - 300, 18) * 0.16; + 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; + } + + let t = Terrain.PLAINS; + if (elevation < 0.24) t = Terrain.WATER; + else if (elevation > 0.68) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN; + else if (moisture < 0.22) t = Terrain.DESERT; + else if (moisture > 0.72 && elevation < 0.54) t = Terrain.FERTILE; + else if (moisture > 0.5) t = Terrain.FOREST; + + const info = terrainInfo[t]; + this.terrain[i] = t; + this.fertility[i] = clamp(info.fertility + this.rng.range(-0.07, 0.07), 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.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] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25)); + } + } + } + + noise(x, y, scale) { + const sx = Math.sin((x * 12.9898 + y * 78.233) / scale) * 43758.5453; + const v = sx - Math.floor(sx); + const sx2 = Math.sin(((x + scale) * 93.989 + (y - scale) * 67.345) / (scale * 1.7)) * 24634.6345; + return (v + sx2 - Math.floor(sx2)) * 0.5; + } + + 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); + } +} + +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.tradeLinks = []; + this.nextEthnicity = 1; + this.nextCity = 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))); + for (let e = 0; e < founders; e++) { + const id = this.createEthnicity(0); + const origin = this.findHabitableTile(); + const baseTraits = this.randomTraits(); + for (let n = 0; n < Math.floor(count / founders); n++) { + this.agents.push(this.makeAgent( + clamp(origin.x + this.rng.int(9) - 4, 0, this.world.size - 1), + clamp(origin.y + this.rng.int(9) - 4, 0, this.world.size - 1), + id, + mutateTraits(baseTraits, this.rng, 0.07), + this.rng.range(6, 15) + )); + } + } + } + + createEthnicity(parent) { + const id = this.nextEthnicity++; + this.ethnicities.set(id, { + id, + parent, + born: this.year, + population: 0, + diversity: 0, + 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), + oceanic: this.rng.next() < 0.18 ? this.rng.range(0.48, 0.9) : this.rng.range(0.02, 0.28), + sedentary: this.rng.range(0.08, 0.82) + }; + } + + makeAgent(x, y, ethnicity, traits, resources) { + return { + x, + y, + resources, + ethnicity, + traits, + alive: true, + foreignContact: 0, + contactEthnicity: ethnicity, + settled: 0 + }; + } + + findHabitableTile() { + 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(); + this.updateCities(); + this.updateTradeRoutes(); + this.updateEthnicStats(); + if (this.year % 90 === 0) this.splitDivergentEthnicities(); + 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); + 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 oceanic = a.traits.oceanic ?? 0.12; + const waterAccess = oceanic * 0.42 + a.traits.mobility * 0.08 + (w.tradeRoute[i] ? 0.18 : 0); + if (isWater && this.rng.next() > waterAccess) continue; + + const sameDensity = this.sameEthnicityNear(x, y, a.ethnicity); + const foreignDensity = this.foreignEthnicityNear(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 ? oceanic * 0.65 : 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 ? 2.2 - oceanic * 1.35 : 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 + + sameDensity * ethnocentrism + + cityPull * sedentary + + routePull + + inertia - + terrainPenalty - + crowdPenalty + + pressurePush - + foreignDensity * 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 cityMarket = w.city[i] >= 0 ? 0.28 : 0; + const productivity = 0.45 + w.fertility[i] * 1.15 + w.mineral[i] * 0.45 + w.farmland[i] * 0.32 + cityMarket; + const pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18); + const gathered = Math.min(w.resource[i], productivity * pressurePenalty * this.rng.range(0.45, 1.2)); + w.resource[i] -= gathered; + a.resources += gathered; + const waterCost = w.terrain[i] === Terrain.WATER ? 0.32 - (a.traits.oceanic ?? 0.12) * 0.18 : 0; + a.resources -= 0.72 + w.move[i] * 0.06 + waterCost; + + 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)); + } + } + + sameEthnicityNear(x, y, ethnicity) { + let count = 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; + count += counts.get(ethnicity) || 0; + if (count > 6) return count * 0.22; + } + } + return count * 0.22; + } + + foreignEthnicityNear(x, y, ethnicity) { + let count = 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) count += value; + } + if (count > 6) return count * 0.16; + } + } + return count * 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(44, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 0.55)); + w.pheromone[i] *= 0.992; + w.pressure[i] *= 0.88; + w.farmland[i] *= 0.985; + w.cityPull[i] *= 0.96; + } + } + + updateCities() { + const w = this.world; + if (this.year % 8 === 0) w.city.fill(-1); + + const candidates = new Map(); + for (const a of this.agents) { + if (!a.alive || a.settled < 7) continue; + const local = w.idx(a.x, a.y); + if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.34) continue; + const cx = clamp(Math.round(a.x / 2) * 2, 0, w.size - 1); + const cy = clamp(Math.round(a.y / 2) * 2, 0, w.size - 1); + const i = w.idx(cx, cy); + let group = candidates.get(i); + if (!group) { + group = { count: 0, resources: 0, ethnicities: new Map() }; + candidates.set(i, group); + } + group.count++; + group.resources += Math.max(0, a.resources); + group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1); + } + + for (const [i, group] of candidates) { + if (group.count < 5) continue; + let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 5); + if (!city && this.cities.length < 160) { + city = this.createCity(i % w.size, Math.floor(i / w.size), group); + this.cities.push(city); + } + 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.35) return false; + const radius = Math.min(8, 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 + }; + } + + 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.03 + w.fertility[i] * 0.09 + w.mineral[i] * 0.035) * 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 loss = Math.min(city.population, Math.ceil(Math.abs(city.storedResources) * 1.8)); + city.population -= loss; + city.storedResources = 0; + this.deaths += loss; + removeFromComposition(city.ethnicityComposition, loss); + if (loss > 5 && this.agents.length < this.maxAgents) this.spawnUrbanRefugees(city, Math.min(18, Math.floor(loss / 3))); + } + 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) { + const dominant = 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; + } + + 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.38) 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 / 10)); + const supportedRoutes = new Set(); + for (const candidate of candidates) { + if (this.tradeLinks.length >= maxLinks) break; + const { c1, c2, strength, path } = candidate; + if (c1.tradeLinks.size >= 1 || c2.tradeLinks.size >= 1) 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.population < 80 || e.diversity < 0.42) continue; + const newId = this.createEthnicity(e.id); + let moved = 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.18; + if ((far || spatial) && this.rng.next() < 0.48) { + a.ethnicity = newId; + moved++; + } + } + if (moved < 24) { + for (const a of this.agents) if (a.ethnicity === newId) a.ethnicity = e.id; + this.ethnicities.delete(newId); + this.nextEthnicity--; + } + } + } + + toJSON() { + return { + version: 2, + rngSeed: this.rng.seed, + year: this.year, + deaths: this.deaths, + nextEthnicity: this.nextEthnicity, + nextCity: this.nextCity, + 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), + 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, + color: e.color + })), + cities: this.cities.map(c => ({ + ...c, + ethnicityComposition: [...c.ethnicityComposition], + tradeLinks: [...c.tradeLinks] + })) + }; + } + + 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.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.pheromone.set(unpackArray(state.world.pheromone, Float32Array)); + sim.world.tradeRoute.set(state.world.tradeRoute ? unpackArray(state.world.tradeRoute, Uint8Array) : new Uint8Array(sim.world.count)); + sim.world.farmland.set(state.world.farmland ? unpackArray(state.world.farmland, Float32Array) : new Float32Array(sim.world.count)); + sim.world.cityPull.set(state.world.cityPull ? unpackArray(state.world.cityPull, Float32Array) : new Float32Array(sim.world.count)); + sim.world.city.set(unpackArray(state.world.city, Int32Array)); + sim.world.pressure.set(state.world.pressure ? unpackArray(state.world.pressure, Float32Array) : new Float32Array(sim.world.count)); + + 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, + 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 + })); + sim.tradeLinks = []; + sim.rebuildOccupancy(); + sim.updateTradeRoutes(); + 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([90, 68, 44], [255, 218, 91], clamp(w.tradeRoute[i] / 40, 0, 1)) : 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 { + 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(); + drawAgentsAndCities(mode); + els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; +} + +function drawTradeLinks() { + const w = sim.world; + ctx.save(); + ctx.globalAlpha = 0.62; + 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.22 + link.strength * 0.75, 0.25, 0.9); + ctx.fillStyle = "#ffd75a"; + for (const tile of link.path || []) { + ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); + } + } + ctx.restore(); +} + +function drawAgentsAndCities(mode) { + ctx.save(); + for (const city of sim.cities) { + const radius = cityRenderRadius(city); + ctx.fillStyle = "#f2d786"; + 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) { + ctx.fillStyle = "#fff2b5"; + ctx.fillRect(city.x, city.y, 1, 1); + } + ctx.restore(); +} + +function cityRenderRadius(city) { + return clamp(Math.floor(Math.sqrt(city.population) / 18), 1, 5); +} + +function showTooltip(event) { + const rect = els.canvas.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 - rect.left + 16, + top: event.clientY - rect.top + 16, + width: rect.width, + height: rect.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 habitat = agent ? ((agent.traits.oceanic ?? 0) >= 0.45 ? "Oceanic" : "Continental") : ""; + const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; + const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); + const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; + + els.tooltip.innerHTML = ` + ${agent ? "Agent group" : terrain.name} + Tile${x}, ${y} + Terrain${terrain.name} + Resources${w.resource[i].toFixed(1)} + Fertility${w.fertility[i].toFixed(2)} + Water influence${waterInfluence} + Minerals${w.mineral[i].toFixed(2)} + Pheromone${w.pheromone[i].toFixed(1)} + Pressure${w.pressure[i].toFixed(0)} + ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""} + ${city ? `City#${city.id}` : ""} + ${city ? `Urban pop${city.population.toLocaleString()}` : ""} + ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""} + ${city ? `Farmland radius${city.agriculturalRadius}` : ""} + ${city ? `Trade links${city.tradeLinks.size}` : ""} + ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""} + ${agent ? `EthnicityE${agent.ethnicity}` : ""} + ${agent ? `Habitat${habitat} ${(agent.traits.oceanic ?? 0).toFixed(2)}` : ""} + ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""} + ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""} + ${agent ? `Stored${agent.resources.toFixed(1)}` : ""} + ${ethnicity ? `Lineage pop${ethnicity.population}` : ""} + `; + 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.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 => ( + `
  • ` + + `E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}
  • ` + )).join(""); +} + +function setLegend() { + const mode = els.viewMode.value; + if (mode === "terrain") { + els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join(""); + } else if (mode === "ethnicity") { + els.legend.innerHTML = "Color = inherited ethnicity. New colors appear only by recorded splits or assimilation."; + } else if (mode === "pheromone") { + els.legend.innerHTML = "Pheromone trails and permanent trade routes"; + } else if (mode === "pressure") { + els.legend.innerHTML = "High local population pressure"; + } else if (mode === "cities") { + els.legend.innerHTML = "City nodes, farmland radius, and stored urban population"; + } else { + els.legend.innerHTML = "Regenerating local resource stock"; + } +} + +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 ?? traits.settling ?? 0.35; +} + +function getEthnocentrism(traits) { + if (traits.ethnocentrism !== undefined) return traits.ethnocentrism; + const same = traits.sameAttraction ?? 0.35; + const avoid = traits.foreignAvoidance ?? 0.22; + return clamp((same + avoid * 1.5) * 0.5, 0, 1.2); +} + +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), + oceanic: clamp((traits.oceanic ?? 0.12) + rng.range(-amount, amount), 0, 1), + 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), + oceanic: lerp(a.oceanic ?? 0.12, b.oceanic ?? 0.12, t), + sedentary: lerp(getSedentary(a), getSedentary(b), t) + }; +} + +function emptyTraitSums() { + return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, oceanic: 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.oceanic += traits.oceanic ?? 0.12; + 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, + oceanic: sum.oceanic / count, + 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((a.oceanic ?? 0.12) - (b.oceanic ?? 0.12)) + + 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) { + if (Array.isArray(payload)) return new TypedArray(payload); + 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(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..3c9d1d2 --- /dev/null +++ b/styles.css @@ -0,0 +1,288 @@ +:root { + color-scheme: dark; + --bg: #111315; + --panel: #1b1f22; + --panel-2: #22282c; + --line: #31383d; + --text: #eef1ed; + --muted: #a8b1aa; + --accent: #69b578; + --accent-2: #e3b341; + --danger: #d35f54; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.app { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + width: 100%; + height: 100%; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 14px; + overflow: auto; + border-right: 1px solid var(--line); + background: #16191b; +} + +.brand h1 { + margin: 0 0 6px; + font-size: 24px; + line-height: 1.05; + letter-spacing: 0; +} + +.brand p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.35; +} + +.panel { + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +button, +select { + min-height: 34px; + border: 1px solid #3d464b; + border-radius: 6px; + background: var(--panel-2); + color: var(--text); +} + +button { + cursor: pointer; +} + +button:hover, +select:hover { + border-color: #59656b; +} + +button.primary { + background: #254b30; + border-color: #3d794b; +} + +.controls label { + display: grid; + gap: 6px; + margin-top: 11px; + color: var(--muted); + font-size: 12px; +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +select { + width: 100%; + padding: 0 9px; +} + +.stats dl { + display: grid; + gap: 7px; + margin: 0; +} + +.stats div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + min-height: 22px; +} + +.stats dt { + color: var(--muted); + font-size: 12px; +} + +.stats dd { + margin: 0; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.ledger { + min-height: 0; +} + +.ledger h2 { + margin: 0 0 10px; + font-size: 14px; + letter-spacing: 0; +} + +.ledger ol { + display: grid; + gap: 7px; + max-height: 260px; + margin: 0; + padding-left: 22px; + overflow: auto; +} + +.ledger li { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.lineage-chip { + display: inline-block; + width: 10px; + height: 10px; + margin-right: 7px; + border-radius: 2px; + vertical-align: -1px; +} + +.sim { + position: relative; + display: grid; + place-items: center; + min-width: 0; + min-height: 0; + padding: 16px; + background: #101214; +} + +canvas { + display: block; + width: min(calc(100vh - 32px), calc(100vw - 380px)); + height: min(calc(100vh - 32px), calc(100vw - 380px)); + max-width: 100%; + max-height: 100%; + border: 1px solid #293035; + background: #0b0d0f; + image-rendering: pixelated; +} + +.legend { + position: absolute; + right: 24px; + bottom: 20px; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + max-width: min(560px, calc(100% - 48px)); + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(20, 23, 25, 0.92); + color: var(--muted); + font-size: 11px; +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.legend i { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.tooltip { + position: absolute; + z-index: 3; + min-width: 180px; + max-width: 260px; + padding: 8px 10px; + border: 1px solid #435057; + border-radius: 6px; + background: rgba(15, 18, 20, 0.96); + color: var(--text); + font-size: 11px; + line-height: 1.35; + pointer-events: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); +} + +.tooltip strong { + display: block; + margin-bottom: 4px; + font-size: 12px; +} + +.tooltip span { + display: flex; + justify-content: space-between; + gap: 14px; + color: var(--muted); +} + +@media (max-width: 820px) { + body { + overflow: auto; + } + + .app { + grid-template-columns: 1fr; + height: auto; + min-height: 100%; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .sim { + min-height: 70vh; + padding: 10px; + } + + canvas { + width: min(calc(100vw - 20px), 92vh); + height: min(calc(100vw - 20px), 92vh); + } +}