2026-06-21 14:09:35 +09:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
const World = global.World;
|
|
|
|
|
if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js");
|
2026-06-21 22:29:00 +09:00
|
|
|
|
|
|
|
|
function liveCount(list) {
|
|
|
|
|
let count = 0;
|
|
|
|
|
for (const item of list || []) if (item && !item.dead) count += 1;
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
|
|
|
|
performanceLevel() {
|
|
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
performanceQuality() {
|
2026-06-24 21:20:53 +09:00
|
|
|
return { level: 0, pressure: 0, effectDensity: 1, antStride: 2, effectLimit: CONFIG.effectLimit ?? 96, itemScanLimit: 16, socialScanLimit: 8, collisionInterval: 1.8, bedConflictInterval: 3.2, statsInterval: 1.2, maxDpr: 2 };
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-24 21:20:53 +09:00
|
|
|
beginFramePerformanceBudgets() {
|
2026-06-24 17:39:44 +09:00
|
|
|
this.workBudget = {
|
2026-06-24 21:20:53 +09:00
|
|
|
ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)),
|
|
|
|
|
env: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 3)),
|
2026-06-24 17:39:44 +09:00
|
|
|
aiUsed: 0,
|
|
|
|
|
envUsed: 0,
|
|
|
|
|
};
|
|
|
|
|
this.workStats = {
|
|
|
|
|
aiRuns: 0,
|
|
|
|
|
aiSkips: 0,
|
|
|
|
|
envRuns: 0,
|
|
|
|
|
envSkips: 0,
|
|
|
|
|
collisionRuns: 0,
|
|
|
|
|
collisionSkips: 0,
|
|
|
|
|
bedConflictRuns: 0,
|
|
|
|
|
bedConflictSkips: 0,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
spendScheduledWork(kind = "ai", urgent = false) {
|
|
|
|
|
if (!this.workBudget) this.beginFramePerformanceBudgets?.();
|
|
|
|
|
const stats = this.workStats || {};
|
|
|
|
|
const budget = this.workBudget || {};
|
|
|
|
|
const usedKey = `${kind}Used`;
|
|
|
|
|
const runKey = `${kind}Runs`;
|
|
|
|
|
const skipKey = `${kind}Skips`;
|
|
|
|
|
if (urgent || (budget[usedKey] || 0) < (budget[kind] || 0)) {
|
|
|
|
|
budget[usedKey] = (budget[usedKey] || 0) + 1;
|
|
|
|
|
stats[runKey] = (stats[runKey] || 0) + 1;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
stats[skipKey] = (stats[skipKey] || 0) + 1;
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
runtimeDiagnostics() {
|
|
|
|
|
const behaviorCounts = {};
|
|
|
|
|
let forcedBehaviors = 0;
|
|
|
|
|
let lockedBehaviors = 0;
|
2026-06-24 17:39:44 +09:00
|
|
|
let liveTarinai = 0;
|
2026-06-24 13:48:22 +09:00
|
|
|
for (const t of this.tarinai || []) {
|
|
|
|
|
if (!t || t.dead) continue;
|
2026-06-24 17:39:44 +09:00
|
|
|
liveTarinai += 1;
|
2026-06-24 13:48:22 +09:00
|
|
|
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
|
|
|
|
|
const id = behavior?.id || t.state || "none";
|
|
|
|
|
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
|
|
|
|
|
if (behavior?.forced) forcedBehaviors += 1;
|
|
|
|
|
if ((t.intentLockTimer || 0) > 0.01) lockedBehaviors += 1;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
spatial: {
|
|
|
|
|
version: this.spatialVersion || 0,
|
|
|
|
|
dirty: Boolean(this.spatialDirty),
|
|
|
|
|
dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "",
|
|
|
|
|
dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0,
|
|
|
|
|
dirtyMarksTotal: this.spatialDirtyMarksTotal || 0,
|
|
|
|
|
rebuildsThisFrame: this.spatialRebuildsThisFrame || 0,
|
|
|
|
|
rebuildsTotal: this.spatialRebuildsTotal || 0,
|
|
|
|
|
lastRebuildReason: this.lastSpatialRebuildReason || "",
|
|
|
|
|
},
|
|
|
|
|
render: {
|
|
|
|
|
drawList: (this.drawList || []).length,
|
|
|
|
|
drawListDirty: Boolean(this.drawListDirty),
|
|
|
|
|
drawSortTimer: Number(this.drawSortTimer || 0),
|
|
|
|
|
},
|
|
|
|
|
items: {
|
|
|
|
|
total: (this.items || []).length,
|
|
|
|
|
bucketTypes: this.itemTypeBuckets?.size || 0,
|
|
|
|
|
bucketsDirty: Boolean(this.itemBucketsDirty),
|
|
|
|
|
idMapSize: this.itemIdMap?.size || 0,
|
|
|
|
|
bucketRebuildsTotal: this.itemBucketRebuildsTotal || 0,
|
|
|
|
|
},
|
|
|
|
|
behavior: {
|
|
|
|
|
forced: forcedBehaviors,
|
|
|
|
|
locked: lockedBehaviors,
|
|
|
|
|
counts: behaviorCounts,
|
|
|
|
|
},
|
2026-06-24 17:39:44 +09:00
|
|
|
performance: {
|
|
|
|
|
liveTarinai,
|
2026-06-24 21:20:53 +09:00
|
|
|
fps: window.__tarinaiFps || window.TarinaiPerformance?.fps || 0,
|
|
|
|
|
pressure: 0,
|
|
|
|
|
level: 0,
|
2026-06-24 17:39:44 +09:00
|
|
|
dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0,
|
2026-06-24 21:20:53 +09:00
|
|
|
quality: null,
|
2026-06-24 17:39:44 +09:00
|
|
|
},
|
|
|
|
|
work: {
|
|
|
|
|
budget: this.workBudget || null,
|
|
|
|
|
stats: this.workStats || null,
|
|
|
|
|
},
|
2026-06-24 13:48:22 +09:00
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
grassLoadLevel() {
|
|
|
|
|
const grass = this.itemCounts?.grass || 0;
|
2026-06-24 21:20:53 +09:00
|
|
|
if (grass >= 180) return 3;
|
|
|
|
|
if (grass >= 120) return 2;
|
|
|
|
|
if (grass >= 72) return 1;
|
2026-06-21 14:09:35 +09:00
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
grassUpdateFactor() {
|
|
|
|
|
return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 21:20:53 +09:00
|
|
|
grassLimit() {
|
|
|
|
|
const limit = Number(CONFIG.grassLimit ?? 360);
|
|
|
|
|
return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isGrassLimitExempt(item) {
|
|
|
|
|
return Boolean(item && item.type === "grass" && (item.grassLimitExempt || item.manualGrass || item.placedByPlayer));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
liveGrassCount() {
|
|
|
|
|
let total = 0;
|
|
|
|
|
for (const it of this.items || []) {
|
|
|
|
|
if (it && !it.dead && it.type === "grass" && !this.isGrassLimitExempt?.(it)) total += 1;
|
|
|
|
|
}
|
|
|
|
|
return total;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
canAddGrass(extra = 1) {
|
|
|
|
|
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 360);
|
|
|
|
|
if (!Number.isFinite(limit)) return true;
|
|
|
|
|
return (this.liveGrassCount ? this.liveGrassCount() : 0) + Math.max(0, Number(extra) || 0) <= limit;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
sortedDrawList() {
|
|
|
|
|
if (!this.drawList) this.drawList = [];
|
2026-06-21 22:29:00 +09:00
|
|
|
const antCount = liveCount(this.ants);
|
|
|
|
|
const tarinaiCount = liveCount(this.tarinai);
|
2026-06-21 14:09:35 +09:00
|
|
|
if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) {
|
|
|
|
|
this.drawList.length = 0;
|
|
|
|
|
for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t);
|
|
|
|
|
for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a);
|
|
|
|
|
this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0));
|
|
|
|
|
this.drawListDirty = false;
|
|
|
|
|
}
|
|
|
|
|
return this.drawList;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
ensureSpatial(reason = "read") {
|
|
|
|
|
if (!this.spatial) return false;
|
|
|
|
|
if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false;
|
2026-06-24 21:20:53 +09:00
|
|
|
// Rebuild at most once per frame. Queries after movement may use the previous
|
|
|
|
|
// frame's grid until the next update-start; this is acceptable for this game
|
|
|
|
|
// and avoids repeated full-grid rebuilds under dense scenes.
|
|
|
|
|
if ((this.spatialRebuildsThisFrame || 0) > 0 && (this.spatialVersion || 0) > 0) return false;
|
2026-06-24 13:48:22 +09:00
|
|
|
return this.rebuildSpatial(true, reason);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
nearbyItems(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-items");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyTarinai(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-tarinai");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyAnts(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-ants");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyObstacles(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-obstacles");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyFood(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-food");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearbyHazards(x, y, radius, precise = false) {
|
2026-06-24 13:48:22 +09:00
|
|
|
this.ensureSpatial?.("nearby-hazards");
|
2026-06-21 14:09:35 +09:00
|
|
|
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
rebuildSpatial(force = false, reason = "manual") {
|
|
|
|
|
// Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial
|
|
|
|
|
// so repeated rebuild requests in the same phase are collapsed to one rebuild.
|
|
|
|
|
if (!force && this.spatialDirty === false && reason !== "manual") return false;
|
2026-06-21 14:09:35 +09:00
|
|
|
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
|
|
|
|
|
this.spatialDirty = false;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.spatialDirtyReason = "";
|
2026-06-21 14:09:35 +09:00
|
|
|
this.spatialVersion = (this.spatialVersion || 0) + 1;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
|
|
|
|
|
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
|
|
|
|
|
this.lastSpatialRebuildReason = reason;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markItemBucketsDirty(reason = "manual") {
|
|
|
|
|
this.itemBucketsDirty = true;
|
|
|
|
|
this.itemBucketsDirtyReason = reason;
|
|
|
|
|
this.lastItemBucketsDirtyReason = reason;
|
|
|
|
|
this.itemBucketDirtyMarksTotal = (this.itemBucketDirtyMarksTotal || 0) + 1;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
ensureItemBuckets(reason = "read") {
|
|
|
|
|
if (!this.itemTypeBuckets || !this.itemIdMap || this.itemBucketsDirty) {
|
|
|
|
|
this.updateItemCounts(reason);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addItem(item, reason = "add-item", opts = {}) {
|
|
|
|
|
if (!item) return null;
|
2026-06-24 21:20:53 +09:00
|
|
|
const ignoreGrassLimit = Boolean(opts.ignoreGrassLimit || opts.manualGrass || item.grassLimitExempt || item.manualGrass || item.placedByPlayer);
|
|
|
|
|
if (item.type === "grass" && !ignoreGrassLimit && !this.canAddGrass?.(1)) return null;
|
|
|
|
|
if (item.type === "grass" && ignoreGrassLimit) item.grassLimitExempt = true;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.items.push(item);
|
|
|
|
|
this.markItemBucketsDirty?.(reason);
|
|
|
|
|
this.markSpatialDirty?.(reason);
|
|
|
|
|
if (opts.terrain !== false) this.markTerrainDirty?.(reason);
|
|
|
|
|
if (opts.countNow !== false && item.type) this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
|
|
|
|
return item;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
itemById(id) {
|
|
|
|
|
if (id == null || id === "") return null;
|
|
|
|
|
this.ensureItemBuckets?.("item-by-id");
|
|
|
|
|
const item = this.itemIdMap?.get?.(id) || null;
|
|
|
|
|
return item && !item.dead ? item : null;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-24 13:48:22 +09:00
|
|
|
itemsOfType(type) {
|
|
|
|
|
this.ensureItemBuckets?.(`items-of-type:${type}`);
|
|
|
|
|
const bucket = this.itemTypeBuckets?.get?.(type);
|
|
|
|
|
return bucket || [];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
liveItemsOfType(type, predicate = null) {
|
|
|
|
|
const bucket = this.itemsOfType(type);
|
|
|
|
|
if (typeof predicate !== "function") return bucket;
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const item of bucket) if (item && !item.dead && predicate(item)) out.push(item);
|
|
|
|
|
return out;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateItemCounts(reason = "manual") {
|
2026-06-21 14:09:35 +09:00
|
|
|
const counts = this.itemCounts;
|
2026-06-24 13:48:22 +09:00
|
|
|
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
|
|
|
|
|
if (!this.itemIdMap) this.itemIdMap = new Map();
|
2026-06-21 14:09:35 +09:00
|
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
2026-06-24 13:48:22 +09:00
|
|
|
this.itemTypeBuckets.clear();
|
|
|
|
|
this.itemIdMap.clear();
|
|
|
|
|
for (const it of this.items) {
|
|
|
|
|
if (!it || it.dead) continue;
|
|
|
|
|
counts[it.type] = (counts[it.type] || 0) + 1;
|
|
|
|
|
if (it.id != null) this.itemIdMap.set(it.id, it);
|
|
|
|
|
let bucket = this.itemTypeBuckets.get(it.type);
|
|
|
|
|
if (!bucket) {
|
|
|
|
|
bucket = [];
|
|
|
|
|
this.itemTypeBuckets.set(it.type, bucket);
|
|
|
|
|
}
|
|
|
|
|
bucket.push(it);
|
|
|
|
|
}
|
|
|
|
|
this.itemBucketsDirty = false;
|
|
|
|
|
this.itemBucketsDirtyReason = "";
|
|
|
|
|
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
|
|
|
|
|
this.lastItemBucketRebuildReason = reason;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateEffectCounts() {
|
|
|
|
|
const counts = this.effectCounts;
|
|
|
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
|
|
|
|
for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactItems() {
|
|
|
|
|
const before = this.items.length;
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.items.length; read++) {
|
|
|
|
|
const it = this.items[read];
|
|
|
|
|
if (it.type === "mirror") continue;
|
|
|
|
|
if (!it.dead) this.items[write++] = it;
|
|
|
|
|
}
|
|
|
|
|
this.items.length = write;
|
|
|
|
|
const changed = before !== this.items.length;
|
|
|
|
|
if (changed) {
|
|
|
|
|
this.markSpatialDirty?.("compact-items");
|
|
|
|
|
this.markTerrainDirty?.("compact-items");
|
2026-06-24 13:48:22 +09:00
|
|
|
this.markItemBucketsDirty?.("compact-items");
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactEffects() {
|
2026-06-24 21:20:53 +09:00
|
|
|
const limit = CONFIG.effectLimit ?? 96;
|
2026-06-21 14:09:35 +09:00
|
|
|
if (Number.isFinite(limit) && this.effects.length > limit) {
|
|
|
|
|
this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001)));
|
|
|
|
|
const remove = this.effects.length - limit;
|
|
|
|
|
for (let i = 0; i < remove; i++) this.effects[i].life = 0;
|
|
|
|
|
}
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.effects.length; read++) {
|
|
|
|
|
const ef = this.effects[read];
|
|
|
|
|
if (!ef.dead) this.effects[write++] = ef;
|
|
|
|
|
}
|
|
|
|
|
this.effects.length = write;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactTarinai() {
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.tarinai.length; read++) {
|
|
|
|
|
const t = this.tarinai[read];
|
|
|
|
|
if (!t.dead) this.tarinai[write++] = t;
|
|
|
|
|
}
|
|
|
|
|
if (this.tarinai.length !== write) this.drawListDirty = true;
|
|
|
|
|
this.tarinai.length = write;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
compactAnts() {
|
|
|
|
|
if (!this.ants) this.ants = [];
|
|
|
|
|
let write = 0;
|
|
|
|
|
for (let read = 0; read < this.ants.length; read++) {
|
|
|
|
|
const a = this.ants[read];
|
|
|
|
|
if (a && !a.dead) this.ants[write++] = a;
|
|
|
|
|
}
|
|
|
|
|
if (this.ants.length !== write) this.drawListDirty = true;
|
|
|
|
|
this.ants.length = write;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
})(typeof window !== "undefined" ? window : globalThis);
|