tarinai/js/world_spatial_budget.js
2026-06-27 19:22:38 +09:00

406 lines
16 KiB
JavaScript

"use strict";
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js");
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
beginFramePerformanceBudgets() {
this.workBudget = {
ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)),
env: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 3)),
aiUsed: 0,
envUsed: 0,
};
this.workStats = {
aiRuns: 0,
aiSkips: 0,
envRuns: 0,
envSkips: 0,
collisionRuns: 0,
collisionSkips: 0,
bedConflictRuns: 0,
bedConflictSkips: 0,
};
this.spatialDirtyReasonCountsThisFrame = {};
this.spatialRebuildReasonCountsThisFrame = {};
this.deferredSpatialReadsThisFrame = 0;
this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
this.nearbyQueryStatsThisFrame = { obstacleRectHits: 0, obstacleRectMisses: 0, obstacleRectBypass: 0, obstacleRectBuilt: 0 };
this.constraintDirtyStatsThisFrame = { marked: 0, coalesced: 0 };
this._solidObstacleRectQueryCache = new Map();
},
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;
},
spatialDirtySummary() {
return {
staticItems: Boolean(this.spatialStaticItemsDirty),
dynamicItems: Boolean(this.spatialDynamicItemsDirty),
tarinai: Boolean(this.spatialTarinaiDirty),
ants: Boolean(this.spatialAntDirty),
};
},
runtimeDiagnostics() {
const behaviorCounts = {};
let forcedBehaviors = 0;
let lockedBehaviors = 0;
let liveTarinai = 0;
for (const t of this.tarinai || []) {
if (!t || t.dead) continue;
liveTarinai += 1;
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const id = behavior?.actionId || t.state || "none";
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
if (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest)) forcedBehaviors += 1;
if ((t.behaviorLockTimer || 0) > 0.01) lockedBehaviors += 1;
}
return {
spatial: {
version: this.spatialVersion || 0,
dirty: Boolean(this.spatialDirty),
dirtyParts: this.spatialDirtySummary?.() || null,
dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "",
dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0,
dirtyMarksTotal: this.spatialDirtyMarksTotal || 0,
rebuildsThisFrame: this.spatialRebuildsThisFrame || 0,
rebuildsTotal: this.spatialRebuildsTotal || 0,
partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0,
lastRebuildReason: this.lastSpatialRebuildReason || "",
dirtyReasons: this.spatialDirtyReasonCountsThisFrame || {},
rebuildReasons: this.spatialRebuildReasonCountsThisFrame || {},
deferredReads: this.deferredSpatialReadsThisFrame || 0,
},
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,
},
runtime: {
liveTarinai,
fps: window.__tarinaiFps || 0,
dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0,
},
work: {
budget: this.workBudget || null,
stats: this.workStats || null,
},
scheduler: this._itemUpdateSchedulerStats || null,
creatures: this._creatureUpdateStats || null,
terrain: this.terrainDirtyStatsThisFrame || null,
nearby: this.nearbyQueryStatsThisFrame || null,
constraintDirty: this.constraintDirtyStatsThisFrame || null,
perf: window.TarinaiPerf?.snapshot?.() || null,
};
},
grassLimit() {
return window.TarinaiGround?.grassLimit?.(this.groundType || "soil", this.fieldType || "garden") ?? (CONFIG.grassLimit ?? 99);
},
canAddGrass(extra = 1) {
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
if (!Number.isFinite(limit)) return true;
const current = Math.max(0, Math.floor(Number(this.itemCounts?.grass || 0)));
return current + Math.max(0, Number(extra) || 0) <= limit;
},
enforceGrassLimit(reason = "grass-limit") {
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
if (!Number.isFinite(limit)) return 0;
this.updateItemCounts?.(`${reason}:count`);
let kept = 0;
let removed = 0;
for (const it of this.items || []) {
if (!it || it.dead || it.type !== "grass") continue;
if (kept < limit) {
kept += 1;
continue;
}
it.amount = 0;
it.growth = 0;
it.grassStage = 0;
removed += 1;
}
if (removed > 0) {
this.markItemBucketsDirty?.(reason);
this.markSpatialDirty?.(reason);
this.markTerrainDirty?.(reason);
}
return removed;
},
ensureSpatial(reason = "read") {
if (!this.spatial) return false;
if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false;
// During the creature pass, Tarinai movement marks only the Tarinai bucket
// dirty many times. Obstacle/static item cells are still valid, so spatial
// reads can use the frame-start Tarinai grid and collapse those invalidations
// into one post-creature rebuild. Do not defer static/dynamic/ant dirtiness:
// those buckets are used by collision and contact queries and need fresh data.
const inDeferredPhase = (this.deferSpatialRebuildDepth || 0) > 0 && (this.spatialVersion || 0) > 0;
const canDeferTarinaiOnly = inDeferredPhase
&& this.spatialTarinaiDirty
&& !this.spatialStaticItemsDirty
&& !this.spatialDynamicItemsDirty
&& !this.spatialAntDirty;
const canDeferMobileOnly = inDeferredPhase
&& String(this.deferSpatialRebuildMode || "") === "mobile"
&& !this.spatialStaticItemsDirty
&& (this.spatialDynamicItemsDirty || this.spatialTarinaiDirty || this.spatialAntDirty);
if (canDeferTarinaiOnly || canDeferMobileOnly) {
this.deferredSpatialDirtyReason = reason;
this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1;
return false;
}
// Collision/contact queries must not read a previous-grid position after
// moving static/dynamic obstacles in the same frame. Partial rebuilds are
// already split by static/dynamic/tarinai/ant buckets, so keep those reads
// correct here and let dirty flags decide how much work is required.
return this.rebuildSpatial(true, reason);
},
nearbyItems(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-items");
const out = this.spatialItemScratch || [];
out.length = 0;
if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) {
this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise);
this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise);
return out;
}
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
},
nearbyTarinai(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-tarinai");
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
},
nearbyAnts(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-ants");
return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise);
},
nearbyFood(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-food");
if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) {
return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise);
}
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
},
nearbyObstacles(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-obstacles");
if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) {
return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
}
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
},
nearbyHazards(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-hazards");
if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) {
return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise);
}
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
},
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;
const profiler = window.TarinaiPerf;
const end = profiler?.begin?.("update.spatial") || null;
try {
const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems;
if (full) {
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
} else {
const staticDirty = Boolean(this.spatialStaticItemsDirty);
const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty);
const tarinaiDirty = Boolean(this.spatialTarinaiDirty);
const antDirty = Boolean(this.spatialAntDirty);
if (staticDirty) this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false });
if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false });
if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai);
if (antDirty) this.spatial.rebuildAnts(this.ants || []);
this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1;
}
} finally {
if (end) end();
}
this.spatialDirty = false;
this.spatialStaticItemsDirty = false;
this.spatialDynamicItemsDirty = false;
this.spatialTarinaiDirty = false;
this.spatialAntDirty = false;
this.spatialDirtyReason = "";
this.spatialVersion = (this.spatialVersion || 0) + 1;
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
this.lastSpatialRebuildReason = reason;
if (!this.spatialRebuildReasonCountsThisFrame) this.spatialRebuildReasonCountsThisFrame = {};
this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] = (this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
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;
if (item.type === "grass" && !this.canAddGrass?.(1)) return null;
item.world = this;
this.items.push(item);
this.markItemBucketsDirty?.(reason);
this.markSpatialDirty?.(reason);
if (opts.terrain !== false) {
if (item.type === "grass" || item.type === "trace" || item.type === "splat") this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason);
else 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;
},
itemsOfType(type) {
this.ensureItemBuckets?.(`items-of-type:${type}`);
const bucket = this.itemTypeBuckets?.get?.(type);
return bucket || [];
},
updateItemCounts(reason = "manual") {
const counts = this.itemCounts;
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
if (!this.itemIdMap) this.itemIdMap = new Map();
for (const key of Object.keys(counts)) counts[key] = 0;
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;
},
liveItemsOfType(type, predicate = null) {
const list = this.itemsOfType ? this.itemsOfType(type) : (this.items || []).filter(it => it && !it.dead && it.type === type);
return predicate ? list.filter(predicate) : list;
},
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");
this.markItemBucketsDirty?.("compact-items");
}
return changed;
},
compactEffects() {
const limit = CONFIG.effectLimit ?? 96;
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);