330 lines
17 KiB
JavaScript
330 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
function colonyMoodTip() {
|
|
const mood = world?.evaluateColonyMood?.() || world?.colonyMood || null;
|
|
return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002");
|
|
}
|
|
|
|
function bindColonyMoodTooltip() {
|
|
const el = ui?.statColonyMood;
|
|
const target = el?.closest?.(".stat") || el;
|
|
if (!target || target.dataset.colonyMoodTipBound === "1") return;
|
|
target.dataset.colonyMoodTipBound = "1";
|
|
target.tabIndex = target.tabIndex >= 0 ? target.tabIndex : 0;
|
|
window.TarinaiTooltips.bind(target, colonyMoodTip);
|
|
}
|
|
|
|
function colonyChartObjectKeys() {
|
|
return ["grass", "zunchi", "trace", "grass_bed", "water"];
|
|
}
|
|
|
|
function renderStats() {
|
|
const alive = world.tarinai;
|
|
const isDiseased = (t) => Boolean(t?.zunchiDisease || t?.sleepDisease || t?.explosionDisease || t?.fightDisease);
|
|
const lifeRatio = (t) => Math.max(0, Math.min(1, (Number(t?.age || 0) || 0) / Math.max(1, Number(t?.lifeSpan || 1) || 1)));
|
|
const sickCount = alive.reduce((n, t) => n + (isDiseased(t) ? 1 : 0), 0);
|
|
const juvenileCount = alive.reduce((n, t) => n + (lifeRatio(t) <= 0.10 ? 1 : 0), 0);
|
|
const elderCount = alive.reduce((n, t) => n + (lifeRatio(t) >= 0.80 ? 1 : 0), 0);
|
|
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
|
|
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
|
const needAverages = Object.fromEntries(needKeys.map(key => [`need_${key}`, avg(t => Number(t.needs?.[key] || 0) || 0)]));
|
|
const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15);
|
|
const values = {
|
|
pop: alive.length,
|
|
sick: sickCount,
|
|
juvenile: juvenileCount,
|
|
elder: elderCount,
|
|
dead: world.deadCount,
|
|
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
|
|
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
|
|
stress: avg(t => t.stress),
|
|
temperature: Number(temp) || 0,
|
|
ants: (world.ants || []).reduce((n, ant) => n + (ant && !ant.dead ? 1 : 0), 0),
|
|
...needAverages,
|
|
objects: { ...(world.itemCounts || {}) },
|
|
};
|
|
setTextIfChanged(ui.statSick, "sick", values.sick);
|
|
setTextIfChanged(ui.statJuvenile, "juvenile", values.juvenile);
|
|
setTextIfChanged(ui.statElder, "elder", values.elder);
|
|
const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "\u5b89\u5b9a" };
|
|
setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "\u5b89\u5b9a");
|
|
const tempLabel = `${Math.round(temp)}\u2103`;
|
|
setTextIfChanged(ui.temperatureValue, "temperature", tempLabel);
|
|
if (ui.temperatureSlider) {
|
|
ui.temperatureSlider.disabled = world.temperatureAuto !== false;
|
|
if (document.activeElement !== ui.temperatureSlider || world.temperatureAuto !== false) ui.temperatureSlider.value = String(Math.round(temp));
|
|
ui.temperatureSlider.setAttribute("aria-valuetext", tempLabel);
|
|
}
|
|
if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false;
|
|
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
|
|
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
|
|
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length;
|
|
if (typeof window.syncColonyLimitControls === "function") {
|
|
window.syncColonyLimitControls("tarinai", tarinaiLimit);
|
|
window.syncColonyLimitControls("object", objectLimit);
|
|
} else {
|
|
if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : "";
|
|
if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
|
|
}
|
|
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `\u305f\u308a\u306a\u3044\u306e\u6570 ${values.pop} / ${tarinaiLimit || "\u221e"}\u3000\u7269\u4f53\u6570 ${objectCount} / ${objectLimit || "\u221e"}`);
|
|
bindColonyMoodTooltip();
|
|
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
|
|
window.TarinaiGroundUI.update(world);
|
|
updateColonyHistory(values);
|
|
drawColonyChart(values);
|
|
renderSelected();
|
|
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
|
}
|
|
|
|
function updateColonyHistory(values) {
|
|
const t = Math.max(0, world.time || 0);
|
|
const history = uiCache.chartHistory;
|
|
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
|
|
const maxPoints = 5 * 12;
|
|
const currentBirthEvents = Math.max(0, Number(values.birthEvents || 0) || 0);
|
|
const currentDeathEvents = Math.max(0, Number(values.deathEvents || 0) || 0);
|
|
const eventCountersRewound = (Number.isFinite(uiCache.chartBirthEventSeen) && currentBirthEvents < uiCache.chartBirthEventSeen)
|
|
|| (Number.isFinite(uiCache.chartDeathEventSeen) && currentDeathEvents < uiCache.chartDeathEventSeen);
|
|
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart) || eventCountersRewound) {
|
|
history.length = 0;
|
|
uiCache.chartBucketStart = -Infinity;
|
|
uiCache.chartBucketEnd = -Infinity;
|
|
uiCache.chartBucketAccum = null;
|
|
uiCache.chartBirthEventSeen = null;
|
|
uiCache.chartDeathEventSeen = null;
|
|
uiCache.lastChartDraw = "";
|
|
}
|
|
const objectKeys = colonyChartObjectKeys();
|
|
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
|
const makeBucket = (start) => ({
|
|
start,
|
|
end: start + interval,
|
|
count: 0,
|
|
sums: {
|
|
pop: 0,
|
|
sick: 0,
|
|
juvenile: 0,
|
|
elder: 0,
|
|
ants: 0,
|
|
stress: 0,
|
|
temperature: 0,
|
|
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
|
|
},
|
|
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
|
|
events: { births: 0, deaths: 0 },
|
|
});
|
|
if (!uiCache.chartBucketAccum) {
|
|
const start = Math.floor(t / interval) * interval;
|
|
uiCache.chartBucketStart = start;
|
|
uiCache.chartBucketEnd = start + interval;
|
|
uiCache.chartBucketAccum = makeBucket(start);
|
|
}
|
|
const pushBucket = (bucket) => {
|
|
if (!bucket || bucket.count <= 0) return;
|
|
const row = {
|
|
t: bucket.end,
|
|
pop: bucket.sums.pop / bucket.count,
|
|
sick: bucket.sums.sick / bucket.count,
|
|
juvenile: bucket.sums.juvenile / bucket.count,
|
|
elder: bucket.sums.elder / bucket.count,
|
|
ants: bucket.sums.ants / bucket.count,
|
|
stress: bucket.sums.stress / bucket.count,
|
|
temperature: bucket.sums.temperature / bucket.count,
|
|
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
|
|
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
|
births: Math.max(0, Number(bucket.events?.births || 0) || 0),
|
|
deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0),
|
|
};
|
|
history.push(row);
|
|
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
|
|
};
|
|
while (t >= uiCache.chartBucketEnd) {
|
|
const b = uiCache.chartBucketAccum;
|
|
pushBucket(b);
|
|
uiCache.chartBucketStart = uiCache.chartBucketEnd;
|
|
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
|
|
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
|
|
}
|
|
const bucket = uiCache.chartBucketAccum;
|
|
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = currentBirthEvents;
|
|
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = currentDeathEvents;
|
|
bucket.events.births += Math.max(0, currentBirthEvents - uiCache.chartBirthEventSeen);
|
|
bucket.events.deaths += Math.max(0, currentDeathEvents - uiCache.chartDeathEventSeen);
|
|
uiCache.chartBirthEventSeen = currentBirthEvents;
|
|
uiCache.chartDeathEventSeen = currentDeathEvents;
|
|
bucket.count += 1;
|
|
bucket.sums.pop += values.pop || 0;
|
|
bucket.sums.sick += values.sick || 0;
|
|
bucket.sums.juvenile += values.juvenile || 0;
|
|
bucket.sums.elder += values.elder || 0;
|
|
bucket.sums.ants += values.ants || 0;
|
|
bucket.sums.stress += values.stress || 0;
|
|
bucket.sums.temperature += Number(values.temperature || 0) || 0;
|
|
for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0;
|
|
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
|
|
}
|
|
|
|
function chartRows(values) {
|
|
// The colony chart is sampled in two-hour buckets. Do not append a live
|
|
// preview row here; it makes the graph redraw every frame even though the
|
|
// recorded statistics have not advanced yet.
|
|
if (uiCache.chartHistory.length) return uiCache.chartHistory.slice();
|
|
const objectKeys = colonyChartObjectKeys();
|
|
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
|
return [{
|
|
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
|
|
pop: 0,
|
|
sick: 0,
|
|
juvenile: 0,
|
|
elder: 0,
|
|
ants: 0,
|
|
stress: 0,
|
|
temperature: 0,
|
|
births: 0,
|
|
deaths: 0,
|
|
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
|
|
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])),
|
|
}];
|
|
}
|
|
|
|
function chartSeasonDayLabelForTime(t = 0) {
|
|
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
|
const dayNumber = Math.floor(Math.max(0, Number(t || 0) || 0) / dayLength) + 1;
|
|
const seasonIndex = Math.floor(((dayNumber - 1) % 20) / 5);
|
|
const season = ["\u6625", "\u590f", "\u79cb", "\u51ac"][seasonIndex] || "\u6625";
|
|
const seasonDay = ((dayNumber - 1) % 5) + 1;
|
|
return `${season}${seasonDay}\u65e5`;
|
|
}
|
|
|
|
function niceChartMax(raw = 0) {
|
|
const value = Math.max(1, Number(raw) || 0);
|
|
const pow = Math.pow(10, Math.floor(Math.log10(value)));
|
|
const unit = value / pow;
|
|
const nice = unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10;
|
|
return nice * pow;
|
|
}
|
|
|
|
function chartScaleForRows(mode, rows, series) {
|
|
if (mode === "life") return { min: 0, max: 100, percent: true };
|
|
const vals = [];
|
|
for (const s of series || []) for (const row of rows || []) vals.push(Number(row?.[s.key] || 0));
|
|
const finite = vals.filter(Number.isFinite);
|
|
const minRaw = Math.min(0, ...finite);
|
|
const maxRaw = Math.max(1, ...finite);
|
|
const max = niceChartMax(maxRaw);
|
|
const min = minRaw < 0 ? -niceChartMax(Math.abs(minRaw)) : 0;
|
|
return { min, max, percent: false };
|
|
}
|
|
|
|
function chartAxisLabel(value, scale) {
|
|
const n = Number(value) || 0;
|
|
if (scale?.percent) return String(Math.round(n));
|
|
if (Math.abs(n) >= 1000) return `${Math.round(n / 100) / 10}k`;
|
|
if (Math.abs(n) >= 10) return String(Math.round(n));
|
|
return String(Math.round(n * 10) / 10);
|
|
}
|
|
|
|
function drawColonyChart(values) {
|
|
const chart = ui.colonyChart;
|
|
if (!chart) return;
|
|
let mode = uiCache.chartMode || "population";
|
|
if (!["population", "life", "objects"].includes(mode)) {
|
|
mode = "population";
|
|
uiCache.chartMode = "population";
|
|
for (const b of ui.colonyChartTabs?.querySelectorAll("button[data-chart]") || []) b.classList.toggle("active", b.dataset.chart === "population");
|
|
}
|
|
const rows = chartRows(values);
|
|
const last = rows[rows.length - 1] || values;
|
|
const allSeries = chartSeries(mode);
|
|
const hiddenSeries = uiCache.chartHiddenSeries instanceof Set ? uiCache.chartHiddenSeries : (uiCache.chartHiddenSeries = new Set());
|
|
const series = allSeries.filter(s => !hiddenSeries.has(s.key));
|
|
const seriesSnapshot = allSeries.map(s => `${s.key}:${hiddenSeries.has(s.key) ? 0 : 1}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
|
|
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
|
const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength);
|
|
const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
|
|
if (uiCache.lastChartDraw === snapshot) return;
|
|
uiCache.lastChartDraw = snapshot;
|
|
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
|
|
const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
|
|
const pad = { l: 30, r: 12, t: 12, b: 24 };
|
|
const w = cssW - pad.l - pad.r;
|
|
const h = cssH - pad.t - pad.b;
|
|
const scale = chartScaleForRows(mode, rows, series);
|
|
const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0));
|
|
const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0));
|
|
const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1);
|
|
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w;
|
|
const xFor = (i) => xForTime(rows[i]?.t ?? maxT);
|
|
const yFor = (row, s) => {
|
|
const raw = Number(row[s.key] || 0);
|
|
if (scale.percent) {
|
|
const v = clamp(raw / 100, 0, 1);
|
|
return pad.t + h - h * v;
|
|
}
|
|
const denom = Math.max(1, scale.max - scale.min);
|
|
const v = clamp((raw - scale.min) / denom, 0, 1);
|
|
return pad.t + h - h * v;
|
|
};
|
|
const grid = [0, 25, 50, 75, 100].map(yv => {
|
|
const y = pad.t + h - h * yv / 100;
|
|
const rawLabel = scale.percent ? yv : scale.min + (scale.max - scale.min) * yv / 100;
|
|
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${chartAxisLabel(rawLabel, scale)}</text>`;
|
|
}).join("");
|
|
const firstDay = Math.ceil(minT / dayLength);
|
|
const lastDay = Math.floor(maxT / dayLength);
|
|
const dayMarkers = [];
|
|
for (let d = firstDay; d <= lastDay; d += 1) {
|
|
const t = d * dayLength;
|
|
if (t < minT - 0.001 || t > maxT + 0.001) continue;
|
|
const x = xForTime(t);
|
|
const label = chartSeasonDayLabelForTime(t);
|
|
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/><text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>`);
|
|
}
|
|
const lines = series.map(s => {
|
|
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
|
|
const last = rows[rows.length - 1];
|
|
const cx = xFor(rows.length - 1).toFixed(1);
|
|
const cy = yFor(last, s).toFixed(1);
|
|
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
|
|
}).join("");
|
|
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
|
|
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = allSeries.map(s => {
|
|
const hidden = hiddenSeries.has(s.key);
|
|
return `<button type="button" class="chart-series-toggle ${hidden ? "series-hidden" : ""}" data-series-key="${s.key}" aria-pressed="${hidden ? "false" : "true"}" title="${hidden ? "\u8868\u793a\u3059\u308b" : "\u975e\u8868\u793a\u306b\u3059\u308b"}"><i aria-hidden="true" style="background:${s.color}"></i><span>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span></button>`;
|
|
}).join("");
|
|
}
|
|
|
|
function chartSeries(mode = "population") {
|
|
if (mode === "life") return [
|
|
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
|
|
{ key: "need_food", label: "\u98df\u6b32", color: "#e4a33f", percent: true },
|
|
{ key: "need_sleep", label: "\u7761\u7720", color: "#6f8edc", percent: true },
|
|
{ key: "need_health", label: "\u5065\u5eb7", color: "#2e9d68", percent: true },
|
|
{ key: "need_safety", label: "\u5b89\u5168", color: "#d65e46", percent: true },
|
|
{ key: "need_social", label: "\u95a2\u4fc2", color: "#9b78d4", percent: true },
|
|
{ key: "need_fulfill", label: "\u5145\u8db3", color: "#0072b2", percent: true },
|
|
];
|
|
if (mode === "objects") return [
|
|
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
|
|
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" },
|
|
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
|
|
{ key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" },
|
|
{ key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" },
|
|
{ key: "temperature", label: "\u6c17\u6e29", color: "#c46f3c" },
|
|
{ key: "ants", label: "\u30a2\u30ea", color: "#36454f" },
|
|
];
|
|
return [
|
|
{ key: "pop", label: "\u7dcf\u6570", color: "#5d8ee6" },
|
|
{ key: "sick", label: "\u75c5\u6c17", color: "#d96262" },
|
|
{ key: "juvenile", label: "\u5e7c\u4f53", color: "#78a642" },
|
|
{ key: "elder", label: "\u8001\u4f53", color: "#8a7054" },
|
|
{ key: "births", label: "\u8a95\u751f\u6570", color: "#d48ac2" },
|
|
{ key: "deaths", label: "\u6b7b\u4ea1\u6570", color: "#4d4d56" },
|
|
];
|
|
}
|
|
|
|
function formatChartValue(key, values) {
|
|
const v = values[key] ?? 0;
|
|
if (key === "temperature") return `${Math.round(Number(v) || 0)}\u2103`;
|
|
return String(Math.round(v));
|
|
}
|