This commit is contained in:
33333-33333 2026-06-24 17:39:44 +09:00
commit 9ef1b8dbb1
23 changed files with 329 additions and 96 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.tmp-edge-profile/
__pycache__/
*.pyc
# Local browser smoke/probe artifacts.
*probe*.html
*probe*.png
*smoke*.html
*smoke*.png

View file

@ -1 +0,0 @@
level=none expiry=0

View file

@ -1 +0,0 @@
{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

View file

@ -442,7 +442,7 @@ h1 {
}
.signboard-editor.hidden { display: none; }
.signboard-editor-panel {
width: min(430px, calc(100vw - 28px));
width: min(220px, calc(100vw - 28px));
border-radius: 18px;
padding: 14px;
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96));
@ -476,8 +476,11 @@ h1 {
}
.signboard-editor-close:hover { background: rgba(255,248,232,0.96); }
#signboardEditorText {
width: 100%;
width: 7.4em;
max-width: 100%;
box-sizing: border-box;
display: block;
margin: 0 auto;
resize: none;
min-height: 88px;
max-height: 88px;
@ -498,6 +501,8 @@ h1 {
}
.signboard-editor-meta {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
@ -505,8 +510,8 @@ h1 {
}
.signboard-editor-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
}

View file

@ -44,7 +44,6 @@ const DECOR_ASSETS = [
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" },
{ id: "oshibyo", path: "assets/objects/oshibyo.webp" },
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
{ id: "plushie", path: "assets/objects/plushie.webp" },
];
const TOOL_DEFINITIONS = Object.freeze({

View file

@ -24,15 +24,18 @@
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
const behaviorCounts = diag.behavior?.counts || {};
const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", ");
const quality = diag.performance?.quality || perf.quality || {};
panel.textContent = [
`tarinai v${window.TARINAI_VERSION || "?"}`,
`fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`,
`live ${diag.performance?.liveTarinai ?? quality.tarinaiCount ?? (w?.tarinai || []).length} qlevel ${quality.level ?? diag.performance?.level ?? "?"} pressure ${Number(diag.performance?.pressure ?? quality.pressure ?? 0).toFixed(2)} dpr ${diag.performance?.dpr || uiCache?.canvasDpr || "?"} maxDpr ${Number(quality.maxDpr ?? 0).toFixed ? Number(quality.maxDpr ?? 0).toFixed(2) : "?"}`,
`items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`,
`effects ${(w?.effects || []).length}`,
`terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`,
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
`behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`,
`dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`,
`sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`,

View file

@ -5,6 +5,7 @@ let fpsTimer = 0;
let fpsFrames = 0;
let perfResizeTimer = 0;
let lastPerfLevel = 0;
let lastPerfMaxDpr = 0;
window.__tarinaiFps = 0;
const LOADING_SPRITES = [
@ -91,11 +92,14 @@ function loop() {
world.update(dt);
render();
statsTimer += dt;
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const quality = world.performanceQuality?.() || {};
const perf = Number(quality.level ?? (world.performanceLevel ? world.performanceLevel() : 0)) || 0;
perfResizeTimer += rawDt;
if (perfResizeTimer > 1.5) {
if (perf !== lastPerfLevel) {
const maxDpr = Number(quality.maxDpr || 0) || 0;
if (perf !== lastPerfLevel || Math.abs(maxDpr - lastPerfMaxDpr) > 0.08) {
lastPerfLevel = perf;
lastPerfMaxDpr = maxDpr;
resizeCanvas();
}
perfResizeTimer = 0;
@ -139,4 +143,3 @@ if ("serviceWorker" in navigator) {
});
}

View file

@ -7,6 +7,7 @@ class TarinaiPerformanceManager {
this.lastLevel = 0;
this.samples = [];
this.sampleLimit = 20;
this.pressure = 0;
}
updateFps(fps, worldRef = null) {
@ -16,7 +17,8 @@ class TarinaiPerformanceManager {
if (this.samples.length > this.sampleLimit) this.samples.shift();
const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
this.fps = Math.round(avg);
const next = this.computeLevel(avg);
this.pressure = this.computePressure(avg, worldRef);
const next = this.computeLevel(avg, worldRef);
if (next !== this.level) {
this.lastLevel = this.level;
this.level = next;
@ -25,33 +27,68 @@ class TarinaiPerformanceManager {
return this.level;
}
computeLevel(fps = this.fps) {
if (!fps || !Number.isFinite(fps)) return 0;
if (fps < 22) return 3;
if (fps < 34) return 2;
if (fps < 48) return 1;
computeLevel(fps = this.fps, worldRef = null) {
const pressure = this.computePressure(fps, worldRef);
if (pressure >= 0.78) return 3;
if (pressure >= 0.54) return 2;
if (pressure >= 0.30) return 1;
return 0;
}
quality(worldRef = null) {
const level = this.level;
computePressure(fps = this.fps, worldRef = null) {
const q = this.countWorkload(worldRef);
const n = Number(fps);
const fpsPressure = Number.isFinite(n) && n > 0 ? clamp((58 - n) / 38, 0, 1) : 0;
const workPressure = clamp(Math.log1p(q.workUnits / 42) / Math.log1p(260 / 42), 0, 1);
const sampleBoost = this.samples.length < 4 ? 0.84 : 1.0;
return clamp(Math.max(fpsPressure, workPressure * 0.78) * sampleBoost, 0, 1);
}
countWorkload(worldRef = null) {
let tarinaiCount = 0;
for (const t of worldRef?.tarinai || []) if (t && !t.dead) tarinaiCount += 1;
let antCount = 0;
for (const ant of worldRef?.ants || []) if (ant && !ant.dead) antCount += 1;
const itemCount = (worldRef?.items || []).length || 0;
const effectCount = (worldRef?.effects || []).length || 0;
const antStride = antCount >= 95 || level >= 3 ? 3 : (antCount >= 35 || level >= 2 ? 2 : 1);
const workUnits = tarinaiCount + antCount * 0.35 + itemCount * 0.08 + effectCount * 0.12;
return { tarinaiCount, antCount, itemCount, effectCount, workUnits };
}
quality(worldRef = null) {
const counts = this.countWorkload(worldRef);
const pressure = this.computePressure(this.fps, worldRef);
const level = Math.max(this.level, this.computeLevel(this.fps, worldRef));
const density = clamp(1 - pressure * 0.82, 0.18, 1);
const antStride = Math.max(1, Math.round(1 + pressure * 2.4));
const aiIntervalScale = 1 + pressure * 5.2;
const envIntervalScale = 1 + pressure * 4.8;
const socialScanLimit = Math.max(3, Math.round(14 - pressure * 10));
const itemScanLimit = Math.max(5, Math.round(28 - pressure * 18));
const collisionInterval = 0.04 + pressure * 0.24;
return {
fps: this.fps,
pressure,
level,
effectDensity: density,
tarinaiCount: counts.tarinaiCount,
antStride,
itemCount,
antCount,
effectCount,
effectLimit: level >= 3 ? 34 : level >= 2 ? 52 : level >= 1 ? 68 : 88,
statsInterval: level >= 3 ? 1.20 : level === 2 ? 0.92 : level === 1 ? 0.68 : 0.48,
terrainRedrawInterval: level >= 3 ? 1.30 : level === 2 ? 1.05 : level === 1 ? 0.82 : 0.62,
backgroundLightStep: level >= 3 ? 64 : level === 2 ? 96 : 180,
uiUpdateInterval: level >= 3 ? 1.2 : level === 2 ? 0.9 : level === 1 ? 0.65 : 0.45,
itemCount: counts.itemCount,
antCount: counts.antCount,
effectCount: counts.effectCount,
workUnits: counts.workUnits,
maxDpr: clamp(2.0 - pressure * 1.35, 0.65, 2.0),
effectLimit: Math.round(30 + density * 58),
aiIntervalScale,
envIntervalScale,
socialScanLimit,
itemScanLimit,
collisionInterval,
bedConflictInterval: 0.12 + pressure * 0.72,
statsInterval: 0.42 + pressure * 0.86,
terrainRedrawInterval: 0.62 + pressure * 0.72,
backgroundLightStep: Math.round(180 - pressure * 116),
uiUpdateInterval: 0.45 + pressure * 0.78,
};
}

View file

@ -704,8 +704,9 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
function drawRain(ctx, w, h, t) {
if (world.weather !== "light_rain") return;
ctx.save();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const count = perf >= 2 ? 64 : 138;
const quality = world.performanceQuality?.() || {};
const density = clamp(Number(quality.effectDensity || 1) || 1, 0.12, 1);
const count = Math.max(20, Math.round(138 * density));
const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites.
const slant = Math.tan(angle);
for (let i = 0; i < count; i++) {
@ -729,7 +730,7 @@ function drawRain(ctx, w, h, t) {
ctx.moveTo(x - dx * 0.5, y - dy * 0.5);
ctx.lineTo(x + dx * 0.5, y + dy * 0.5);
ctx.stroke();
if (perf < 3 && i % 4 === 0) {
if (density > 0.25 && i % 4 === 0) {
ctx.globalAlpha = 0.13 * alpha;
ctx.strokeStyle = "rgba(255,255,255,0.70)";
ctx.lineWidth = 0.7;
@ -795,8 +796,9 @@ function render() {
}
ctx.restore();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
const quality = world.performanceQuality?.() || {};
const density = clamp(Number(quality.effectDensity || 1) || 1, 0.12, 1);
if (density > 0.45) drawLightRays(ctx, screenW, screenH, lighting);
const visibleRect = visibleWorldRect(world, 180);
const terrainLayer = ensureTerrainCache(world, lighting);
@ -812,21 +814,23 @@ function render() {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
ctx.save();
ctx.lineWidth = 1.15;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
for (const t of world.tarinai) {
if (!isEntityVisibleInRect(t, visibleRect, 80)) continue;
const p = t.parentToFollow?.();
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.stroke();
if (density > 0.45) {
ctx.save();
ctx.lineWidth = 1.15;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
for (const t of world.tarinai) {
if (!isEntityVisibleInRect(t, visibleRect, 80)) continue;
const p = t.parentToFollow?.();
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
}
ctx.setLineDash([]);
ctx.restore();
for (const entry of renderStack.layered) {
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);

View file

@ -263,7 +263,7 @@
function itemExtra(item, tarinaiIndex = new Map()) {
const type = item?.type || "";
if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0];
if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0, item.plushieSpriteId || ""];
if (type === "grass") return [q(item.growth, 1000), q(item.health, 1000), q(item.seedTimer, 10), q(item.eatenAmount, 10), q(item.fertilityBoost, 1000), q(item.lifeSpan, 10), q(item.wither, 1000)];
if (type === "bed") return [q(item.comfort, 1000), q(item.wear, 1000)];
if (type === "signboard") return [item.text || ""];
@ -296,6 +296,7 @@
item.ownerId = tarinaiList[extra[5]]?.id || "";
item.carriedById = tarinaiList[extra[6]]?.id || "";
item.onHead = !!extra[7];
item.plushieSpriteId = extra[8] || item.plushieSpriteId || "";
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;

View file

@ -29,6 +29,10 @@
this.needEffects = clonePlain(definition.needEffects);
this.carriedById = definition.type === "plushie" ? this.ownerId : "";
this.onHead = definition.type === "plushie";
if (definition.type === "plushie") {
const sprites = typeof spawnableSprites === "function" ? spawnableSprites().map(s => s.id).filter(Boolean) : [];
this.plushieSpriteId = sprites.length ? sprites[Math.floor(Math.random() * sprites.length)] : (owner?.type || "smile");
}
this.dead = false;
this.world = world || null;
}
@ -106,10 +110,11 @@
ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "plushie") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("plushie", "plushie") : global.images?.get?.("plushie");
const spriteId = this.plushieSpriteId || "smile";
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, "smile") : (global.images?.get?.(spriteId) || global.images?.get?.("smile"));
ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05);
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("plushie") : null;
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(spriteId) || getImageMetrics("smile") : null;
const w = this.r * 3.0;
const h = w * (metrics?.ratio || 0.74);
ctx.drawImage(img, -w * 0.58, -h * 0.60, w, h);

View file

@ -166,14 +166,14 @@
rel.fightsWon = rel.fightsWon || 0;
rel.fightsLost = rel.fightsLost || 0;
if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
this.relationCache = null;
},
strongestRelation(kind = "friend", liveOnly = true) {
let bestId = null;
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null;
for (const [id, rel] of Object.entries(this.relationships || {})) {
if (liveIds && !liveIds.has(id)) continue;
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
if (score > bestScore) { bestId = id; bestScore = score; }
}
@ -187,13 +187,16 @@
},
currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) {
const now = this.world?.time || 0;
const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`;
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
let count = 0;
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null;
for (const [id, rel] of Object.entries(this.relationships || {})) {
if ((rel.affinity || 0) <= minScore) continue;
if (liveIds && !liveIds.has(id)) continue;
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
count += 1;
}
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
return count;
},
@ -203,16 +206,23 @@
},
bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) {
const now = this.world?.time || 0;
const cacheKey = `${minScore}:${Number.isFinite(maxDist) ? Math.round(maxDist) : "inf"}`;
if (this.relationCache?.bestFriendKey === cacheKey && now < (this.relationCache.bestFriendUntil || 0)) {
const cached = this.world?.liveTarinaiById?.(this.relationCache.bestFriendId);
if (cached && (!Number.isFinite(maxDist) || dist(this, cached) <= maxDist)) return cached;
}
let best = null, bestScore = minScore;
for (const [id, rel] of Object.entries(this.relationships || {})) {
const score = rel.affinity || 0;
if (score <= bestScore) continue;
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
const t = this.world.liveTarinaiById?.(id) || null;
if (!t) continue;
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
best = t;
bestScore = score;
}
this.relationCache = { ...(this.relationCache || {}), bestFriendKey: cacheKey, bestFriendId: best?.id || "", bestFriendUntil: now + 1.0 };
return best;
},
@ -223,7 +233,7 @@
const when = rel.lastTime || -Infinity;
if (this.world.time - when > maxAge) continue;
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
const t = this.world.liveTarinaiById?.(id) || null;
if (!t) continue;
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
if (when > bestT) { best = t; bestT = when; }

View file

@ -122,13 +122,26 @@ function needPressure(value) {
}
function calculateStressFromNeeds(needs) {
return clamp(
const weightedLinear =
(Number(needs.food) || 0) * 0.09 +
(Number(needs.sleep) || 0) * 0.11 +
(Number(needs.health) || 0) * 0.22 +
(Number(needs.safety) || 0) * 0.26 +
(Number(needs.social) || 0) * 0.07 +
(Number(needs.fulfill) || 0) * 0.13;
const peakNeed = Math.max(0, ...TARINAI_NEED_KEYS.map(key => Number(needs?.[key] || 0) || 0));
const peakPressure = Math.max(0, peakNeed - 62) * 0.10;
const quadraticPressure =
needPressure(needs.food) * 0.10 +
needPressure(needs.sleep) * 0.12 +
needPressure(needs.health) * 0.25 +
needPressure(needs.safety) * 0.30 +
needPressure(needs.social) * 0.08 +
needPressure(needs.fulfill) * 0.15,
needPressure(needs.fulfill) * 0.15;
return clamp(
weightedLinear +
peakPressure +
quadraticPressure * 0.35,
0,
130
);
@ -1392,7 +1405,8 @@ function createSleepBuildGrassBedActionSpec() {
timerBacked: true,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) {
return !findNearestSleepPlace(world, t, 760)
return canBuildOwnedStructureByAge(t)
&& !findNearestSleepPlace(world, t, 760)
&& !findOwnedStructure(world, t, "grass_bed")
&& !!(ctx.target ?? this.selectTarget(t, world, ctx))
&& !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state);
@ -1780,7 +1794,7 @@ const TARINAI_ACTION_DEFINITIONS = [
phrase: "かんたんベッドを作った",
weight: 34,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); },
},
{
@ -1791,7 +1805,7 @@ const TARINAI_ACTION_DEFINITIONS = [
phrase: "ぬいぐるみを作った",
weight: 28,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); },
},
{
@ -2065,6 +2079,32 @@ function structureBuildProbe(type, x, y) {
return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" };
}
function canBuildOwnedStructureByAge(tarinai) {
if (!tarinai || tarinai.dead) return false;
if (tarinai.generation <= 1) return true;
const growth = Number.isFinite(tarinai.growth)
? tarinai.growth
: clamp(((tarinai.world?.time || 0) - (tarinai.birthTime || 0)) / Math.max(1, CONFIG.childGrowTime || 58), 0, 1);
return growth >= 0.55;
}
function stopFailedBuildPlan(t, world, type = "", reason = "") {
if (!t) return false;
t.buildPlan = null;
t.actionCooldowns = t.actionCooldowns || {};
t.actionIdCooldowns = t.actionIdCooldowns || {};
const actionId = type === "grass_bed" ? "build_grass_bed" : (type === "plushie" ? "build_plushie" : "build");
t.actionCooldowns.fulfill = Math.max(t.actionCooldowns.fulfill || 0, 5.0);
t.actionCooldowns.sleep = Math.max(t.actionCooldowns.sleep || 0, type === "grass_bed" ? 3.8 : 0);
t.actionIdCooldowns[actionId] = Math.max(t.actionIdCooldowns[actionId] || 0, 9.0);
if (type === "grass_bed") t.actionIdCooldowns.sleep_build_grass_bed = Math.max(t.actionIdCooldowns.sleep_build_grass_bed || 0, 9.0);
t.behaviorSwitchCooldownUntil = Math.max(t.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 1.2);
const text = reason || "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044";
if (typeof clearActiveBehavior === "function") clearActiveBehavior(t, text);
t.goIdle?.(text);
return false;
}
function findStructureBuildSpot(t, world, type) {
const baseX = Number.isFinite(t?.x) ? t.x : 0;
const baseY = Number.isFinite(t?.y) ? t.y : 0;
@ -2117,8 +2157,7 @@ function continueBuildPlan(t, world, dt) {
if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null;
if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520);
if (!material) {
t.buildPlan = null;
return false;
return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
plan.materialId = material.id;
const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269";
@ -2133,8 +2172,7 @@ function continueBuildPlan(t, world, dt) {
const spot = findStructureBuildSpot(t, world, plan.type);
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
t.buildPlan = null;
return false;
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure);
world.drawListDirty = true;
@ -2859,9 +2897,12 @@ function protectSleepSession(tarinai, world, needs) {
this.intentLockTimer = Math.max(0, (this.intentLockTimer || 0) - dt);
this.aiTimer += dt;
const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02;
const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0;
const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055);
if (this.aiTimer >= aiInterval) {
const quality = this.world.performanceQuality ? this.world.performanceQuality() : {};
const pressure = clamp(Number(quality.pressure || 0) || 0, 0, 1);
const intervalScale = Number(quality.aiIntervalScale || 1) || 1;
const phaseOffset = stableUnit(this.familyKey || this.id, "ai-phase") * 0.12 * intervalScale;
const aiInterval = urgentAi ? (0.065 + pressure * 0.045) : ((0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09) * intervalScale + phaseOffset);
if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) {
const aiDt = Math.min(this.aiTimer, 0.48);
this.aiTimer = 0;
this.resolveNeeds(aiDt);
@ -2878,8 +2919,8 @@ function protectSleepSession(tarinai, world, needs) {
this.updateFacing();
}
this.envTimer += dt;
const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48;
if (this.envTimer >= envInterval) {
const envInterval = (0.48 + stableUnit(this.familyKey || this.id, "env-phase") * 0.18) * (Number(quality.envIntervalScale || 1) || 1);
if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) {
const envDt = Math.min(this.envTimer, 1.2);
this.envTimer = 0;
this.applyEnvironment(envDt);
@ -3210,9 +3251,19 @@ function protectSleepSession(tarinai, world, needs) {
const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18;
const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7;
const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger > 88 || foodNeed > 82));
const foodCandidates = this.world.nearbyItems(this.x, this.y, this.radius + 46).slice();
if (this.target && !this.target.dead && !foodCandidates.includes(this.target) && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.unshift(this.target);
foodCandidates.sort((a, b) => foodPriorityScore(this, b, b?.type === "duplicator" ? foodInteractionDistance(this, b) : dist(this, b)) - foodPriorityScore(this, a, a?.type === "duplicator" ? foodInteractionDistance(this, a) : dist(this, a)));
const quality = this.world?.performanceQuality?.() || {};
const scanLimit = Math.max(3, Number(quality.itemScanLimit || 28) || 28);
const nearbyItems = this.world.nearbyItems(this.x, this.y, this.radius + 46);
const foodCandidates = [];
if (this.target && !this.target.dead && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.push(this.target);
for (const it of nearbyItems) {
if (!it || it.dead || foodCandidates.includes(it)) continue;
foodCandidates.push(it);
if (foodCandidates.length >= scanLimit) break;
}
if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) {
foodCandidates.sort((a, b) => foodPriorityScore(this, b, b?.type === "duplicator" ? foodInteractionDistance(this, b) : dist(this, b)) - foodPriorityScore(this, a, a?.type === "duplicator" ? foodInteractionDistance(this, a) : dist(this, a)));
}
for (const it of foodCandidates) {
const d = it?.type === "duplicator" ? foodInteractionDistance(this, it) : dist(this, it);
const contactReach = foodInteractionReach(this, it, "food") + (it === this.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0);

View file

@ -7,6 +7,9 @@
draw(ctx, t, lighting = null) {
if (this.dead) return;
const lightState = lighting || getLightingState(this.world);
const quality = this.world.performanceQuality?.() || {};
const effectDensity = clamp(Number(quality.effectDensity || 1) || 1, 0.12, 1);
const showDecor = effectDensity > 0.45 || this.world.selected === this;
const sprite = this.spriteId();
const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type));
const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile");
@ -63,18 +66,18 @@
const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8);
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
if (img) {
if (showDecor && img) {
drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
faceRight,
alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha,
widthScale: this.state === "sleep" ? 1.12 : 1,
heightScale: this.state === "sleep" ? 0.88 : 1,
});
} else {
} else if (showDecor) {
drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha });
}
drawCreatureGlow(ctx, this, drawW, drawH, lightState);
if (this.powerItemMode === "protein" || this.powerItemMode === "niteropu") {
if (showDecor) drawCreatureGlow(ctx, this, drawW, drawH, lightState);
if (showDecor && (this.powerItemMode === "protein" || this.powerItemMode === "niteropu")) {
ctx.save();
const up = this.powerItemMode === "protein";
const baseColor = up ? "rgba(224,54,40,0.92)" : "rgba(116,80,190,0.88)";
@ -194,7 +197,7 @@
ctx.textBaseline = "middle";
ctx.fillText(focusLabel, 0, ly);
ctx.restore();
} else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
} else if (showDecor && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
ctx.save();
ctx.globalAlpha = 0.28;
ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)";
@ -231,7 +234,7 @@
ctx.restore();
}
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
ctx.save();
const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0;
ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
@ -312,8 +315,10 @@
ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(255, 198, 28, 0.82)";
ctx.shadowBlur = 14;
if (showDecor) {
ctx.shadowColor = "rgba(255, 198, 28, 0.82)";
ctx.shadowBlur = 14;
}
ctx.lineWidth = Math.max(2, starSize * 0.12);
ctx.strokeStyle = "rgba(92, 62, 0, 0.86)";
ctx.fillStyle = "rgba(255, 218, 46, 1)";
@ -324,7 +329,7 @@
const hpActive = (this.hpBarTimer || 0) > 0;
const stressActive = (this.stressBarTimer || 0) > 0;
if (hpActive || stressActive) {
if ((hpActive || stressActive) && (showDecor || this.world.selected === this || this.hurtTimer > 0.04)) {
const bw = Math.max(28, this.radius * 2.35);
const bh = 5;
const x = this.x - bw / 2;

View file

@ -6,8 +6,13 @@
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
interactWithOthers(dt) {
let closeCount = 0;
const quality = this.world?.performanceQuality?.() || {};
const scanLimit = Math.max(1, Number(quality.socialScanLimit || 14) || 14);
let scanned = 0;
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
if (o === this || o.dead) continue;
if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break;
scanned += 1;
const dx = o.x - this.x, dy = o.y - this.y;
const d = Math.hypot(dx, dy);
if (d < 0.01 || d > 74) continue;

View file

@ -2,9 +2,10 @@
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
const quality = world?.performanceQuality?.() || {};
const perf = Number(quality.level ?? (world?.performanceLevel ? world.performanceLevel() : 0)) || 0;
const maxDpr = Number(quality.maxDpr || 0) || (perf >= 2 ? 1 : (perf === 1 ? 1.25 : 2));
const dpr = Math.max(0.6, Math.min(window.devicePixelRatio || 1, maxDpr));
const nextW = Math.max(1, Math.floor(rect.width * dpr));
const nextH = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;

View file

@ -38,7 +38,7 @@
return chars.slice(0, Math.max(0, limit)).join("");
}
function sanitizeSignboardText(value = "") {
function sanitizeSignboardText(value = "", opts = {}) {
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
const out = [];
@ -47,7 +47,8 @@
if (rawLine == null) break;
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
}
return out.join("\n").replace(/\n+$/g, "");
const next = out.join("\n");
return opts.trimTrailing === false ? next : next.replace(/\n+$/g, "");
}
function signboardVisibleLength(text = "") {
@ -95,7 +96,8 @@
const currentLine = lines[lineIndex] || "";
const replacing = selected.length > 0;
if (e.key === "Enter") {
if (lines.length >= SIGNBOARD_LINES) e.preventDefault();
const selectedLineBreaks = (selected.match(/\n/g) || []).length;
if (lines.length - selectedLineBreaks >= SIGNBOARD_LINES) e.preventDefault();
return;
}
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
@ -110,12 +112,12 @@
const text = e.clipboardData?.getData?.("text") || "";
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const next = sanitizeSignboardText((area.value || "").slice(0, start) + text + (area.value || "").slice(end));
const next = sanitizeSignboardText((area.value || "").slice(0, start) + text + (area.value || "").slice(end), { trimTrailing: false });
area.value = next;
syncCount();
};
area.oninput = () => {
const clean = sanitizeSignboardText(area.value || "");
const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false });
if (area.value !== clean) area.value = clean;
syncCount();
};

View file

@ -12,7 +12,7 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
performanceLevel() {
if (window.TarinaiPerformance) return window.TarinaiPerformance.level || 0;
if (window.TarinaiPerformance) return window.TarinaiPerformance.quality?.(this)?.level || window.TarinaiPerformance.level || 0;
const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60;
if (!fps || !Number.isFinite(fps)) return 0;
if (fps < 22) return 3;
@ -25,12 +25,51 @@
return window.TarinaiPerformance?.quality?.(this) || { level: this.performanceLevel(), antStride: 1, effectLimit: CONFIG.effectLimit ?? 96 };
},
beginFramePerformanceBudgets(quality = this.performanceQuality?.()) {
const pressure = clamp(Number(quality?.pressure || 0) || 0, 0, 1);
const density = clamp(Number(quality?.effectDensity || (1 - pressure)) || 0, 0.12, 1);
this.workBudget = {
ai: Math.max(4, Math.round(4 + density * 24)),
env: Math.max(3, Math.round(3 + density * 15)),
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;
},
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?.id || t.state || "none";
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
@ -65,6 +104,18 @@
locked: lockedBehaviors,
counts: behaviorCounts,
},
performance: {
liveTarinai,
fps: window.TarinaiPerformance?.fps || window.__tarinaiFps || 0,
pressure: this.performanceQuality?.().pressure || 0,
level: this.performanceLevel?.() || 0,
dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0,
quality: this.performanceQuality?.() || null,
},
work: {
budget: this.workBudget || null,
stats: this.workStats || null,
},
};
},

View file

@ -3,6 +3,32 @@
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_update.js");
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
if (!worldRef || !Array.isArray(list) || !list.length) return false;
const thresholdSq = threshold * threshold;
let moved = false;
for (const entity of list) {
if (!entity || entity.dead) continue;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
continue;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) continue;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
environmentScores() {
const alive = this.tarinai || [];
@ -227,29 +253,47 @@
this.spatialDirtyMarksThisFrame = 0;
this.ensureSpatial?.("update-start");
const perf = this.performanceLevel();
const quality = this.performanceQuality?.() || {};
const perf = Number(quality.level ?? this.performanceLevel?.() ?? 0) || 0;
this.beginFramePerformanceBudgets?.(quality);
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
if (this.items.length) this.markSpatialDirty?.("items-updated");
const itemsMoved = markSpatialDirtyIfMoved(this, this.items, "items-moved", 0.25);
if (this.itemCounts?.ball) {
this.ensureSpatial?.("ball-ball-collisions");
this.resolveBallBallCollisions(dt);
this.markSpatialDirty?.("ball-ball-collisions");
}
this.updateAnts?.(dt);
if ((this.ants || []).length) this.markSpatialDirty?.("ants-updated");
const antsMoved = markSpatialDirtyIfMoved(this, this.ants || [], "ants-moved", 0.25);
if (itemsMoved || antsMoved || this.spatialDirty) this.ensureSpatial?.("post-mobile-item-update");
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
this.resolveOwnedBedSleepConflicts?.();
if (this.tarinai.length) this.markSpatialDirty?.("tarinai-updated");
const now = this.time || 0;
const bedConflictInterval = Number(quality.bedConflictInterval || 0.12) || 0.12;
if (now >= (this.nextBedConflictCheckAt || 0)) {
this.nextBedConflictCheckAt = now + bedConflictInterval;
this.workStats && (this.workStats.bedConflictRuns = (this.workStats.bedConflictRuns || 0) + 1);
this.resolveOwnedBedSleepConflicts?.();
} else {
this.workStats && (this.workStats.bedConflictSkips = (this.workStats.bedConflictSkips || 0) + 1);
}
markSpatialDirtyIfMoved(this, this.tarinai, "tarinai-moved", 0.25);
this.ensureSpatial?.("post-tarinai-update");
this.limitBallChasers();
this.resolveBallInteractions(dt);
this.resolveTarinaiHighSpeedCollisions(dt);
const collisionInterval = Number(quality.collisionInterval || 0.04) || 0.04;
if (now >= (this.nextTarinaiCollisionCheckAt || 0)) {
this.nextTarinaiCollisionCheckAt = now + collisionInterval;
this.workStats && (this.workStats.collisionRuns = (this.workStats.collisionRuns || 0) + 1);
this.resolveTarinaiHighSpeedCollisions(dt);
} else {
this.workStats && (this.workStats.collisionSkips = (this.workStats.collisionSkips || 0) + 1);
}
this.compactTimer += dt;
const compactInterval = perf >= 2 ? 0.95 : 0.58;