Enhance UI layout with history sidebar and state graph; update styles for improved responsiveness
This commit is contained in:
parent
f24ef0a271
commit
9f49359384
3 changed files with 500 additions and 33 deletions
15
index.html
15
index.html
|
|
@ -8,7 +8,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<aside class="sidebar" aria-label="Simulation controls">
|
||||
<aside class="sidebar left-sidebar" aria-label="Simulation controls">
|
||||
<div class="brand">
|
||||
<h1>Civil Emergence Lab</h1>
|
||||
<p>Local rules, genealogical cultures, settlement pressure, migration, and collapse.</p>
|
||||
|
|
@ -54,7 +54,6 @@
|
|||
<option value="ethnicity">Ethnicity</option>
|
||||
<option value="pheromone">Trade trails</option>
|
||||
<option value="pressure">Population pressure</option>
|
||||
<option value="cities">Cities</option>
|
||||
<option value="polities">Polities</option>
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -62,7 +61,7 @@
|
|||
|
||||
<section class="panel stats" aria-live="polite">
|
||||
<dl>
|
||||
<div><dt>Year</dt><dd id="year">0</dd></div>
|
||||
<div><dt>Date</dt><dd id="year">0y 1m</dd></div>
|
||||
<div><dt>Total population</dt><dd id="population">0</dd></div>
|
||||
<div><dt>Active groups</dt><dd id="activeGroups">0</dd></div>
|
||||
<div><dt>Urban population</dt><dd id="urbanPopulation">0</dd></div>
|
||||
|
|
@ -86,6 +85,16 @@
|
|||
<div class="legend" id="legend"></div>
|
||||
<div class="tooltip" id="tooltip" hidden></div>
|
||||
</section>
|
||||
|
||||
<aside class="history-sidebar" aria-label="State history">
|
||||
<section class="panel history-panel">
|
||||
<div class="history-header">
|
||||
<h2>State lifespans</h2>
|
||||
<span id="historyRange">—</span>
|
||||
</div>
|
||||
<canvas id="stateGraph" width="300" height="720" aria-label="State lifespan graph"></canvas>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="script.js"></script>
|
||||
|
|
|
|||
446
script.js
446
script.js
|
|
@ -18,6 +18,12 @@ const terrainInfo = [
|
|||
{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
|
||||
];
|
||||
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
|
||||
function years(value) {
|
||||
return value * MONTHS_PER_YEAR;
|
||||
}
|
||||
|
||||
const els = {
|
||||
canvas: document.getElementById("world"),
|
||||
sim: document.querySelector(".sim"),
|
||||
|
|
@ -43,7 +49,9 @@ const els = {
|
|||
frameCost: document.getElementById("frameCost"),
|
||||
ethnicityList: document.getElementById("ethnicityList"),
|
||||
legend: document.getElementById("legend"),
|
||||
tooltip: document.getElementById("tooltip")
|
||||
tooltip: document.getElementById("tooltip"),
|
||||
stateGraph: document.getElementById("stateGraph"),
|
||||
historyRange: document.getElementById("historyRange")
|
||||
};
|
||||
|
||||
const ctx = els.canvas.getContext("2d", { alpha: false });
|
||||
|
|
@ -51,6 +59,7 @@ let sim;
|
|||
let running = true;
|
||||
let frame = 0;
|
||||
let lastStatsAt = 0;
|
||||
let lastGraphRenderAt = 0;
|
||||
let hoverState = null;
|
||||
|
||||
class Rng {
|
||||
|
|
@ -369,6 +378,8 @@ class Simulation {
|
|||
this.ethnicities = new Map();
|
||||
this.cities = [];
|
||||
this.polities = [];
|
||||
this.polityHistory = new Map();
|
||||
this.deadPolityHistories = [];
|
||||
this.tradeLinks = [];
|
||||
this.nextEthnicity = 1;
|
||||
this.nextCity = 1;
|
||||
|
|
@ -532,13 +543,13 @@ class Simulation {
|
|||
|
||||
this.updateWorldFields();
|
||||
this.rebuildOccupancy();
|
||||
if (this.year % 5 === 0) {
|
||||
if (this.year % years(5) === 0) {
|
||||
this.updateCities();
|
||||
this.updateTradeRoutes();
|
||||
}
|
||||
this.updatePolities();
|
||||
this.updateEthnicStats();
|
||||
if (this.year % 90 === 0) {
|
||||
if (this.year % years(90) === 0) {
|
||||
this.splitDivergentEthnicities();
|
||||
this.updateEthnicStats();
|
||||
}
|
||||
|
|
@ -654,7 +665,9 @@ class Simulation {
|
|||
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;
|
||||
const sedentary = getSedentary(a.traits);
|
||||
const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08;
|
||||
a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
|
||||
|
||||
if (a.resources <= 0) {
|
||||
a.alive = false;
|
||||
|
|
@ -662,10 +675,15 @@ class Simulation {
|
|||
return;
|
||||
}
|
||||
|
||||
if (a.resources > a.traits.reproductionThreshold && offspring.length < 700) {
|
||||
a.resources *= 0.58;
|
||||
const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
|
||||
const mobilityPenalty = (1 - sedentary) * 0.45;
|
||||
const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty - settlementBonus, 0.82, 1.55);
|
||||
if (a.resources > reproductionThreshold && offspring.length < 700) {
|
||||
const childShare = 0.36 + sedentary * 0.08;
|
||||
const childResources = a.resources * childShare;
|
||||
a.resources -= childResources;
|
||||
const childTraits = mutateTraits(a.traits, this.rng, 0.035);
|
||||
offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, a.resources * 0.42));
|
||||
offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -787,15 +805,20 @@ class Simulation {
|
|||
}
|
||||
|
||||
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);
|
||||
const canFoundCities = this.year >= years(180) && this.year % years(30) === 0;
|
||||
const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(1200)) : 0;
|
||||
const candidateEntries = [...candidates].sort((a, b) => {
|
||||
const sedentaryA = a[1].sedentary / Math.max(1, a[1].count);
|
||||
const sedentaryB = b[1].sedentary / Math.max(1, b[1].count);
|
||||
return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02);
|
||||
});
|
||||
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;
|
||||
const foundingChance = clamp((avgSedentary - 0.28) * 1.55, 0, 0.92);
|
||||
if (avgSedentary < 0.34 || this.rng.next() > foundingChance) continue;
|
||||
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
|
||||
this.cities.push(city);
|
||||
foundedThisTick++;
|
||||
|
|
@ -803,8 +826,11 @@ class Simulation {
|
|||
if (city) {
|
||||
city.activeVisitors += group.count;
|
||||
city.storedResources += group.resources * 0.08;
|
||||
city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
|
||||
const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
|
||||
for (const [id, count] of group.ethnicities) {
|
||||
city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + Math.ceil(count * 0.2));
|
||||
const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
|
||||
if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount);
|
||||
}
|
||||
city.strength = city.strength * 0.96 + group.count * 0.05;
|
||||
}
|
||||
|
|
@ -840,9 +866,14 @@ class Simulation {
|
|||
|
||||
createCity(x, y, seedGroup = null) {
|
||||
const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8;
|
||||
const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5;
|
||||
const composition = new Map();
|
||||
if (seedGroup) {
|
||||
for (const [id, count] of seedGroup.ethnicities) composition.set(id, Math.max(1, count * 3));
|
||||
const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1);
|
||||
for (const [id, count] of seedGroup.ethnicities) {
|
||||
const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight);
|
||||
if (urbanCount > 0) composition.set(id, urbanCount);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: this.nextCity++,
|
||||
|
|
@ -857,6 +888,7 @@ class Simulation {
|
|||
activeVisitors: 0,
|
||||
age: 0,
|
||||
strength: 2,
|
||||
sedentaryCulture: seedSedentary,
|
||||
polityId: null,
|
||||
loyalty: 0.5,
|
||||
receivedAid: false
|
||||
|
|
@ -872,19 +904,29 @@ class Simulation {
|
|||
continue;
|
||||
}
|
||||
const city = this.findCityNear(a.x, a.y, 5);
|
||||
if (!city || this.rng.next() > getSedentary(a.traits) * 0.55) {
|
||||
const sedentary = getSedentary(a.traits);
|
||||
if (!city || this.rng.next() > sedentary * sedentary * 0.75) {
|
||||
absorbed.push(a);
|
||||
continue;
|
||||
}
|
||||
const migrants = 1 + Math.floor(Math.min(5, a.resources / 10));
|
||||
const urbanWeight = clamp((sedentary - 0.2) * 1.35, 0.05, 1);
|
||||
city.population += migrants;
|
||||
city.storedResources += Math.max(0, a.resources) * 0.65;
|
||||
city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + migrants);
|
||||
const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
|
||||
if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount);
|
||||
city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015;
|
||||
city.strength += 0.04;
|
||||
}
|
||||
this.agents = absorbed;
|
||||
}
|
||||
|
||||
weightedUrbanContribution(amount, weight) {
|
||||
const value = amount * weight;
|
||||
const whole = Math.floor(value);
|
||||
return whole + (this.rng.next() < value - whole ? 1 : 0);
|
||||
}
|
||||
|
||||
processCityEconomies() {
|
||||
const w = this.world;
|
||||
for (const city of this.cities) {
|
||||
|
|
@ -964,7 +1006,8 @@ class Simulation {
|
|||
|
||||
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));
|
||||
const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
|
||||
return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor;
|
||||
}
|
||||
|
||||
getCityById(id) {
|
||||
|
|
@ -1027,9 +1070,77 @@ class Simulation {
|
|||
centerCity.loyalty = 1;
|
||||
centerCity.receivedAid = false;
|
||||
this.polities.push(polity);
|
||||
this.ensurePolityHistory(polity);
|
||||
this.samplePolityHistory(polity);
|
||||
return polity;
|
||||
}
|
||||
|
||||
ensurePolityHistory(polity) {
|
||||
if (!polity) return null;
|
||||
if (!this.polityHistory.has(polity.id)) {
|
||||
this.polityHistory.set(polity.id, {
|
||||
id: polity.id,
|
||||
color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62),
|
||||
founded: polity.founded ?? this.year,
|
||||
ended: null,
|
||||
active: true,
|
||||
centerCityId: polity.centerCityId ?? null,
|
||||
fate: null,
|
||||
samples: []
|
||||
});
|
||||
}
|
||||
return this.polityHistory.get(polity.id);
|
||||
}
|
||||
|
||||
totalPolityPopulation(polity) {
|
||||
return this.getPolityCities(polity).reduce((sum, city) => sum + city.population, 0);
|
||||
}
|
||||
|
||||
averagePolityLoyalty(polity) {
|
||||
const cities = this.getPolityCities(polity);
|
||||
const subordinates = cities.filter(city => city.id !== polity.centerCityId);
|
||||
if (!subordinates.length) return 1;
|
||||
return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length;
|
||||
}
|
||||
|
||||
samplePolityHistory(polity) {
|
||||
const history = this.ensurePolityHistory(polity);
|
||||
if (!history) return;
|
||||
const cities = this.getPolityCities(polity);
|
||||
history.centerCityId = polity.centerCityId ?? history.centerCityId;
|
||||
history.samples.push({
|
||||
year: this.year,
|
||||
cities: cities.length,
|
||||
population: cities.reduce((sum, city) => sum + city.population, 0),
|
||||
treasury: polity.treasury || 0,
|
||||
avgLoyalty: this.averagePolityLoyalty(polity)
|
||||
});
|
||||
while (history.samples.length > 160) history.samples.shift();
|
||||
}
|
||||
|
||||
samplePolityHistories() {
|
||||
for (const polity of this.polities) this.samplePolityHistory(polity);
|
||||
}
|
||||
|
||||
markPolityEnded(polity, reason = "dissolved") {
|
||||
if (!polity) return;
|
||||
const history = this.ensurePolityHistory(polity);
|
||||
if (!history) return;
|
||||
this.samplePolityHistory(polity);
|
||||
history.ended = this.year;
|
||||
history.active = false;
|
||||
history.fate = reason;
|
||||
history.centerCityId = polity.centerCityId ?? history.centerCityId;
|
||||
this.polityHistory.delete(polity.id);
|
||||
this.deadPolityHistories.push(history);
|
||||
while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift();
|
||||
}
|
||||
|
||||
getAllPolityHistories() {
|
||||
return [...this.deadPolityHistories, ...this.polityHistory.values()]
|
||||
.sort((a, b) => (a.founded - b.founded) || (a.id - b.id));
|
||||
}
|
||||
|
||||
addCityToPolity(city, polity, initialLoyalty = 0.5) {
|
||||
if (!city || !polity) return;
|
||||
if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
|
||||
|
|
@ -1108,6 +1219,21 @@ class Simulation {
|
|||
city.storedResources -= tax;
|
||||
polity.treasury += tax;
|
||||
}
|
||||
const maintenance = Math.pow(cities.length, 1.15) * 0.18;
|
||||
if (maintenance > 0) {
|
||||
const center = this.getCityById(centerId);
|
||||
const treasuryPayment = Math.min(polity.treasury, maintenance);
|
||||
polity.treasury -= treasuryPayment;
|
||||
const unpaid = maintenance - treasuryPayment;
|
||||
if (unpaid > 0 && center) {
|
||||
const paidByCenter = this.drainCityResources(center, unpaid);
|
||||
if (paidByCenter < unpaid) {
|
||||
for (const city of cities) {
|
||||
if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.025, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const poorCount = Math.ceil(cities.length * 0.1);
|
||||
const poorest = [...cities]
|
||||
|
|
@ -1163,14 +1289,20 @@ class Simulation {
|
|||
const survivors = [];
|
||||
for (const polity of this.polities) {
|
||||
const cities = this.getPolityCities(polity);
|
||||
if (!cities.length) continue;
|
||||
if (!cities.length) {
|
||||
this.markPolityEnded(polity, "collapsed");
|
||||
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;
|
||||
const history = this.ensurePolityHistory(polity);
|
||||
if (history) history.centerCityId = center.id;
|
||||
}
|
||||
if (cities.length === 1) {
|
||||
this.markPolityEnded(polity, "dissolved");
|
||||
cities[0].polityId = null;
|
||||
cities[0].loyalty = 0.45;
|
||||
cities[0].receivedAid = false;
|
||||
|
|
@ -1190,14 +1322,43 @@ class Simulation {
|
|||
}
|
||||
|
||||
updatePolities() {
|
||||
if (this.year % 12 !== 0) return;
|
||||
if (this.year % years(10) !== 0) return;
|
||||
this.cleanupPolities();
|
||||
this.foundPolities();
|
||||
this.expandPolities();
|
||||
this.reinforcePolityTradeRoutes();
|
||||
this.collectAndRedistributeResources();
|
||||
this.updateCityLoyalty();
|
||||
this.splitUnloyalCities();
|
||||
this.cleanupPolities();
|
||||
this.samplePolityHistories();
|
||||
}
|
||||
|
||||
reinforcePolityTradeRoutes() {
|
||||
const w = this.world;
|
||||
for (const polity of this.polities) {
|
||||
if (this.rng.next() > 0.32) continue;
|
||||
const center = this.getCityById(polity.centerCityId);
|
||||
if (!center) continue;
|
||||
const candidates = this.getPolityCities(polity)
|
||||
.filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city))
|
||||
.filter(city => this.distanceBetweenCities(center, city) <= 40)
|
||||
.sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b));
|
||||
if (!candidates.length) continue;
|
||||
|
||||
const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))];
|
||||
const path = this.findTerrainRoute(center.x, center.y, target.x, target.y);
|
||||
if (!this.isValidRoutePath(path, target.x, target.y)) continue;
|
||||
if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
|
||||
|
||||
center.tradeLinks.add(target.id);
|
||||
target.tradeLinks.add(center.id);
|
||||
this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path });
|
||||
for (const tile of path) {
|
||||
w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34);
|
||||
w.pheromone[tile] += 2.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTradeRoutes() {
|
||||
|
|
@ -1205,7 +1366,7 @@ class Simulation {
|
|||
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);
|
||||
else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
|
||||
}
|
||||
|
||||
this.tradeLinks = [];
|
||||
|
|
@ -1221,6 +1382,7 @@ class Simulation {
|
|||
if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
|
||||
const strength = this.routeStrengthForPath(path);
|
||||
if (strength < 0.12) continue;
|
||||
if (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
|
||||
candidates.push({ c1, c2, strength, path });
|
||||
}
|
||||
}
|
||||
|
|
@ -1234,6 +1396,7 @@ class Simulation {
|
|||
const c1Limit = c1.population > 90 ? 2 : 1;
|
||||
const c2Limit = c2.population > 90 ? 2 : 1;
|
||||
if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
|
||||
if (!this.payRouteConstructionCost(c1, c2, path)) continue;
|
||||
c1.tradeLinks.add(c2.id);
|
||||
c2.tradeLinks.add(c1.id);
|
||||
this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
|
||||
|
|
@ -1339,6 +1502,51 @@ class Simulation {
|
|||
this.depositRoutePheromone(path, traffic);
|
||||
}
|
||||
|
||||
routeConstructionCost(path, polityBacked = false) {
|
||||
let weakTiles = 0;
|
||||
for (const tile of path) {
|
||||
if (this.world.tradeRoute[tile] < 24) weakTiles++;
|
||||
}
|
||||
return weakTiles * (polityBacked ? 0.22 : 0.16);
|
||||
}
|
||||
|
||||
canPayRouteConstructionCost(cityA, cityB, path, polity = null) {
|
||||
const cost = this.routeConstructionCost(path, !!polity);
|
||||
if (cost <= 0) return true;
|
||||
return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost;
|
||||
}
|
||||
|
||||
payRouteConstructionCost(cityA, cityB, path, polity = null) {
|
||||
const cost = this.routeConstructionCost(path, !!polity);
|
||||
if (cost <= 0) return true;
|
||||
if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false;
|
||||
|
||||
let remaining = cost;
|
||||
if (polity) {
|
||||
const treasuryPayment = Math.min(polity.treasury, cost * 0.65);
|
||||
polity.treasury -= treasuryPayment;
|
||||
remaining -= treasuryPayment;
|
||||
}
|
||||
|
||||
const half = remaining * 0.5;
|
||||
const paidA = this.drainCityResources(cityA, half);
|
||||
const paidB = this.drainCityResources(cityB, half);
|
||||
remaining -= paidA + paidB;
|
||||
if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
|
||||
if (remaining > 0 && polity) {
|
||||
const treasuryPayment = Math.min(polity.treasury, remaining);
|
||||
polity.treasury -= treasuryPayment;
|
||||
remaining -= treasuryPayment;
|
||||
}
|
||||
return remaining <= 0.001;
|
||||
}
|
||||
|
||||
drainCityResources(city, amount) {
|
||||
const paid = Math.min(city.storedResources, amount);
|
||||
city.storedResources -= paid;
|
||||
return paid;
|
||||
}
|
||||
|
||||
depositRoutePheromone(path, amount) {
|
||||
const w = this.world;
|
||||
for (const i of path) {
|
||||
|
|
@ -1470,7 +1678,9 @@ class Simulation {
|
|||
treasury: p.treasury,
|
||||
color: p.color,
|
||||
founded: p.founded
|
||||
}))
|
||||
})),
|
||||
polityHistory: [...this.polityHistory.values()],
|
||||
deadPolityHistories: this.deadPolityHistories
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1525,6 +1735,7 @@ class Simulation {
|
|||
activeVisitors: 0,
|
||||
age: c.age || 0,
|
||||
strength: c.strength || 1,
|
||||
sedentaryCulture: c.sedentaryCulture ?? 0.5,
|
||||
polityId: c.polityId ?? null,
|
||||
loyalty: c.loyalty ?? 0.5,
|
||||
receivedAid: c.receivedAid ?? false
|
||||
|
|
@ -1537,6 +1748,27 @@ class Simulation {
|
|||
color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62),
|
||||
founded: p.founded || 0
|
||||
}));
|
||||
sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, {
|
||||
id: h.id,
|
||||
color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
|
||||
founded: h.founded || 0,
|
||||
ended: h.ended ?? null,
|
||||
active: h.active !== false,
|
||||
centerCityId: h.centerCityId ?? null,
|
||||
fate: h.fate || null,
|
||||
samples: h.samples || []
|
||||
}]));
|
||||
sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({
|
||||
id: h.id,
|
||||
color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
|
||||
founded: h.founded || 0,
|
||||
ended: h.ended ?? null,
|
||||
active: false,
|
||||
centerCityId: h.centerCityId ?? null,
|
||||
fate: h.fate || null,
|
||||
samples: h.samples || []
|
||||
}));
|
||||
for (const polity of sim.polities) sim.ensurePolityHistory(polity);
|
||||
sim.tradeLinks = [];
|
||||
sim.rebuildOccupancy();
|
||||
sim.updateTradeRoutes();
|
||||
|
|
@ -1588,6 +1820,7 @@ function render() {
|
|||
drawTradeLinks();
|
||||
drawFarmlandRings(mode);
|
||||
drawAgentsAndCities(mode);
|
||||
maybeRenderStateGraph();
|
||||
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
|
||||
}
|
||||
|
||||
|
|
@ -1676,9 +1909,12 @@ function cityMajorityColor(city) {
|
|||
}
|
||||
|
||||
function cityDisplayColor(city, mode) {
|
||||
if (mode === "polities" && city.polityId !== null) {
|
||||
const polity = sim.getPolityById(city.polityId);
|
||||
if (polity) return polity.color;
|
||||
if (mode === "polities") {
|
||||
if (city.polityId !== null) {
|
||||
const polity = sim.getPolityById(city.polityId);
|
||||
if (polity) return polity.color;
|
||||
}
|
||||
return [218, 205, 154];
|
||||
}
|
||||
return cityMajorityColor(city);
|
||||
}
|
||||
|
|
@ -1792,7 +2028,7 @@ function updateStats(force = false) {
|
|||
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.year.textContent = formatSimDate(sim.year);
|
||||
els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString();
|
||||
els.activeGroups.textContent = sim.agents.length.toLocaleString();
|
||||
els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString();
|
||||
|
|
@ -1807,6 +2043,7 @@ function updateStats(force = false) {
|
|||
`<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("");
|
||||
maybeRenderStateGraph();
|
||||
}
|
||||
|
||||
function setLegend() {
|
||||
|
|
@ -1828,6 +2065,164 @@ function setLegend() {
|
|||
}
|
||||
}
|
||||
|
||||
function maybeRenderStateGraph() {
|
||||
const now = performance.now();
|
||||
if (now - lastGraphRenderAt < 500) return;
|
||||
lastGraphRenderAt = now;
|
||||
renderStateGraph();
|
||||
}
|
||||
|
||||
function renderStateGraph() {
|
||||
const canvas = els.stateGraph;
|
||||
if (!canvas || !sim) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
const g = canvas.getContext("2d");
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
g.clearRect(0, 0, w, h);
|
||||
g.fillStyle = "#101315";
|
||||
g.fillRect(0, 0, w, h);
|
||||
|
||||
const historyWindowStart = Math.max(0, sim.year - years(10000));
|
||||
const histories = sim.getAllPolityHistories()
|
||||
.filter(history => (history.ended ?? sim.year) >= historyWindowStart)
|
||||
.map(history => ({
|
||||
...history,
|
||||
visibleFounded: Math.max(history.founded ?? 0, historyWindowStart),
|
||||
visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart)
|
||||
}))
|
||||
.filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart);
|
||||
if (!histories.length) {
|
||||
if (els.historyRange) els.historyRange.textContent = "-";
|
||||
g.fillStyle = "#7f8984";
|
||||
g.font = "12px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText("No state history yet", 16, 28);
|
||||
return;
|
||||
}
|
||||
|
||||
let minYear = Infinity;
|
||||
let maxYear = sim.year;
|
||||
for (const history of histories) {
|
||||
minYear = Math.min(minYear, history.visibleFounded);
|
||||
maxYear = Math.max(maxYear, history.ended ?? 0, history.visibleSamples[history.visibleSamples.length - 1]?.year ?? 0);
|
||||
}
|
||||
if (!Number.isFinite(minYear)) minYear = 0;
|
||||
if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`;
|
||||
|
||||
const paddingLeft = 34;
|
||||
const paddingRight = 10;
|
||||
const paddingTop = 18;
|
||||
const paddingBottom = 22;
|
||||
const plotWidth = Math.max(1, w - paddingLeft - paddingRight);
|
||||
const rowHeight = 22;
|
||||
const maxRows = Math.max(1, Math.floor((h - paddingTop - paddingBottom) / rowHeight));
|
||||
const selected = histories
|
||||
.map(history => {
|
||||
const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0 }];
|
||||
const peakPopulation = Math.max(...samples.map(sample => sample.population || 0));
|
||||
const peakCities = Math.max(...samples.map(sample => sample.cities || 0));
|
||||
const lifespan = (history.ended ?? sim.year) - history.visibleFounded;
|
||||
return { history, peakPopulation, peakCities, importance: peakPopulation + peakCities * 100 + lifespan * 2 };
|
||||
})
|
||||
.sort((a, b) => b.importance - a.importance)
|
||||
.slice(0, maxRows)
|
||||
.sort((a, b) => (a.history.founded - b.history.founded) || (a.history.id - b.history.id));
|
||||
|
||||
const yearToX = year => paddingLeft + ((year - minYear) / Math.max(1, maxYear - minYear)) * plotWidth;
|
||||
const axisY = h - paddingBottom + 4;
|
||||
g.strokeStyle = "rgba(168, 177, 170, 0.22)";
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
g.moveTo(paddingLeft, axisY);
|
||||
g.lineTo(w - paddingRight, axisY);
|
||||
g.stroke();
|
||||
g.fillStyle = "#8c9690";
|
||||
g.font = "10px ui-sans-serif, system-ui, sans-serif";
|
||||
g.textAlign = "left";
|
||||
g.fillText(formatGraphYear(minYear), paddingLeft, h - 5);
|
||||
g.textAlign = "center";
|
||||
g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5);
|
||||
g.textAlign = "right";
|
||||
g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5);
|
||||
|
||||
selected.forEach((item, rowIndex) => {
|
||||
const history = item.history;
|
||||
const y = paddingTop + rowIndex * rowHeight + rowHeight * 0.5;
|
||||
const color = history.color || hslToRgb((history.id * 0.38196601125) % 1, 0.58, 0.62);
|
||||
const startX = yearToX(history.visibleFounded);
|
||||
const endX = yearToX(history.ended ?? sim.year);
|
||||
const alpha = history.active ? 0.82 : 0.4;
|
||||
const thickness = clamp(2 + Math.sqrt(item.peakCities) * 1.2, 2, 9);
|
||||
|
||||
g.textAlign = "left";
|
||||
g.fillStyle = "#a8b1aa";
|
||||
g.font = "10px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText(`S${history.id}`, 4, y + 3);
|
||||
|
||||
g.strokeStyle = "rgba(168, 177, 170, 0.12)";
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
g.moveTo(paddingLeft, y);
|
||||
g.lineTo(w - paddingRight, y);
|
||||
g.stroke();
|
||||
|
||||
g.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;
|
||||
g.lineWidth = thickness;
|
||||
g.lineCap = "round";
|
||||
g.beginPath();
|
||||
g.moveTo(startX, y);
|
||||
g.lineTo(endX, y);
|
||||
g.stroke();
|
||||
g.lineCap = "butt";
|
||||
|
||||
for (const sample of history.visibleSamples) {
|
||||
const sx = yearToX(sample.year);
|
||||
const radius = clamp(1 + Math.sqrt(sample.cities || 0) * 0.5, 1.5, 4);
|
||||
const opacity = 0.35 + clamp(sample.avgLoyalty ?? 0.5, 0, 1) * 0.55;
|
||||
g.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`;
|
||||
g.beginPath();
|
||||
g.arc(sx, y, radius, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
|
||||
if (history.ended !== null) {
|
||||
g.strokeStyle = "rgba(224, 105, 94, 0.8)";
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
g.moveTo(endX - 3, y - 3);
|
||||
g.lineTo(endX + 3, y + 3);
|
||||
g.moveTo(endX + 3, y - 3);
|
||||
g.lineTo(endX - 3, y + 3);
|
||||
g.stroke();
|
||||
} else {
|
||||
g.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
||||
g.beginPath();
|
||||
g.arc(endX, y, 3, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatGraphYear(month) {
|
||||
return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`;
|
||||
}
|
||||
|
||||
function formatSimDate(month) {
|
||||
const year = Math.floor(month / MONTHS_PER_YEAR);
|
||||
const monthOfYear = month % MONTHS_PER_YEAR + 1;
|
||||
return `${year.toLocaleString()}y ${monthOfYear}m`;
|
||||
}
|
||||
|
||||
function loop() {
|
||||
const steps = running ? Number(els.speed.value) : 0;
|
||||
for (let i = 0; i < steps; i++) sim.step();
|
||||
|
|
@ -1849,6 +2244,7 @@ function reset() {
|
|||
setLegend();
|
||||
render();
|
||||
updateStats(true);
|
||||
renderStateGraph();
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
|
|
@ -2024,6 +2420,7 @@ function loadWorld() {
|
|||
setLegend();
|
||||
render();
|
||||
updateStats(true);
|
||||
renderStateGraph();
|
||||
} catch (error) {
|
||||
console.warn("Load failed", error);
|
||||
}
|
||||
|
|
@ -2037,6 +2434,7 @@ els.stepOnce.addEventListener("click", () => {
|
|||
sim.step();
|
||||
render();
|
||||
updateStats(true);
|
||||
renderStateGraph();
|
||||
renderTooltip();
|
||||
});
|
||||
els.resetWorld.addEventListener("click", reset);
|
||||
|
|
|
|||
72
styles.css
72
styles.css
|
|
@ -37,7 +37,7 @@ select {
|
|||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(260px, 340px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -53,6 +53,54 @@ select {
|
|||
background: #16191b;
|
||||
}
|
||||
|
||||
.history-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--line);
|
||||
background: #16191b;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-header span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#stateGraph {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #101315;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
|
|
@ -191,12 +239,13 @@ select {
|
|||
background: #101214;
|
||||
}
|
||||
|
||||
canvas {
|
||||
canvas#world {
|
||||
display: block;
|
||||
width: min(calc(100vh - 32px), calc(100vw - 380px));
|
||||
height: min(calc(100vh - 32px), calc(100vw - 380px));
|
||||
width: min(calc(100vh - 32px), 100%);
|
||||
height: min(calc(100vh - 32px), 100%);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid #293035;
|
||||
background: #0b0d0f;
|
||||
image-rendering: pixelated;
|
||||
|
|
@ -260,7 +309,7 @@ canvas {
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@media (max-width: 1100px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -276,13 +325,24 @@ canvas {
|
|||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.history-sidebar {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sim {
|
||||
min-height: 70vh;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
canvas#world {
|
||||
width: min(calc(100vw - 20px), 92vh);
|
||||
height: min(calc(100vw - 20px), 92vh);
|
||||
}
|
||||
|
||||
#stateGraph {
|
||||
height: 420px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue