This commit is contained in:
33333-33333 2026-07-18 13:06:02 +09:00
commit f657b6a4a4
94 changed files with 3970 additions and 1241 deletions

View file

@ -22,6 +22,11 @@
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
const ROUTE_STATES = new Set([
"seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent",
"seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature",
]);
function isNestContainerItem(item) {
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item));
}
@ -47,7 +52,7 @@
function nearestKingFleeNestBox(tarinai, maxDist = 460) {
const worldRef = tarinai?.world;
if (!tarinai || !worldRef) return null;
const source = typeof worldRef.nearbyItems === "function" ? worldRef.nearbyItems(tarinai.x, tarinai.y, maxDist) : (worldRef.items || []);
const source = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyNonGrassItems?.(tarinai.x, tarinai.y, maxDist) || worldRef.nearbyItems(tarinai.x, tarinai.y, maxDist)) : (worldRef.items || []);
let best = null;
let bestScore = Infinity;
for (const item of source || []) {
@ -78,6 +83,7 @@
};
const now = avoider.world?.time || 0;
avoider.lastNeedShockBreaker = champion;
avoider.lastPanicThreat = champion;
avoider.lastPanicCause = "tarinai_champion";
avoider.lastPanicDetail = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u3044";
avoider.panicStartedAt = now;
@ -103,23 +109,125 @@
return true;
}
const CROWD_ESCAPE_REASON = "\u4ed6\u306e\u305f\u308a\u306a\u3044\u304c\u591a\u3059\u304e\u308b\u306e\u3067\u3001\u9003\u3052\u3066\u3044\u308b\u3002";
const LONELY_REASON = "\u5bc2\u3057\u3055\u3092\u611f\u3058\u3066\u3044\u308b\u3002";
function crowdThresholds(t) {
const cur = t?.currentPersonality || {};
const social = clamp(Number(cur.sociability || 0) || 0, -1, 1);
const open = clamp(Number(cur.openness || 0) || 0, -1, 1);
const social01 = (social + 1) * 0.5;
const open01 = (open + 1) * 0.5;
// Sociability raises preferred group size. Openness broadens the comfort band,
// especially on the crowded side, without turning ordinary colonies into panic.
const preferred = 4 + social01 * 6;
const lower = Math.floor(clamp(preferred - (3 + open01 * 3), 0, 7));
const upper = Math.ceil(clamp(preferred + 5 + open01 * 7, Math.max(8, lower + 4), 24));
return { lower, upper };
}
function nearestLonelyCompanion(t, local = [], maxDist = 640) {
if (!t?.world) return null;
let best = null;
let bestD2 = Infinity;
const consider = (o) => {
if (!o || o === t || o.dead) return;
const dx = o.x - t.x, dy = o.y - t.y;
const d2 = dx * dx + dy * dy;
if (d2 < 0.0001 || d2 > maxDist * maxDist || d2 >= bestD2) return;
best = o;
bestD2 = d2;
};
for (const entry of local || []) consider(entry?.o || entry);
if (!best) {
const source = typeof t.world.nearbyTarinai === "function"
? (t.world.nearbyTarinai(t.x, t.y, maxDist, true) || [])
: (t.world.tarinai || []);
// This fallback runs only while lonely. Stop once a good nearby companion is
// found instead of sorting or retaining the full wide-radius population.
for (const o of source) consider(o);
}
return best;
}
function findCrowdEscapeTarget(t, nearby) {
const world = t?.world;
if (!t || !world) return null;
let cx = 0, cy = 0, count = 0;
for (const o of nearby || []) {
if (!o || o === t || o.dead) continue;
const dx = o.x - t.x, dy = o.y - t.y;
const d2 = dx * dx + dy * dy;
if (d2 > 118 * 118 || d2 < 0.0001) continue;
const w = 1 / Math.max(24, Math.sqrt(d2));
cx += dx * w;
cy += dy * w;
count++;
}
const seed = typeof stableUnit === "function" ? stableUnit(t.familyKey || t.id || "crowd", "crowd-escape-angle") : 0.5;
let base = Math.atan2(-cy, -cx);
if (!count || !Number.isFinite(base)) base = seed * Math.PI * 2;
const pad = Math.max(30, (CONFIG.worldPadding || 30) + (t.radius || 22));
const radius = Math.max(14, (t.radius || 22) * 0.72);
const offsets = [0, 0.42, -0.42, 0.84, -0.84];
for (const dist of [190, 270]) {
for (const off of offsets) {
const a = base + off + (seed - 0.5) * 0.18;
const x = clamp(t.x + Math.cos(a) * dist, pad, world.w - pad);
const y = clamp(t.y + Math.sin(a) * dist, pad, world.h - pad);
if (world.pointBlockedByObstacle?.(x, y, radius, { maxChecks: 14, directionalOneWay: true })) continue;
return { x, y, dead: false, detour: true, crowdEscape: true };
}
}
return null;
}
function trySpreadZunchiDisease(source, target, dt, d, salt) {
if (!source?.zunchiDisease || !target || target.zunchiDisease || (target.zunchiDiseaseCooldown || 0) > 0 || d >= 54) return false;
const tempMul = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(target) : 1;
const base = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * tempMul;
const chance = target.diseaseChance ? target.diseaseChance(base) : base;
if (!deterministicChance(source.world, `social-zunchi-spread-${salt}`, chance, source, target)) return false;
if (!target.infectZunchiDisease(source)) return false;
source.world.log(`${source.name}\u306e\u305a\u3093\u3061\u75c5\u304c${target.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [target, source] });
return true;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
interactWithOthers(dt) {
let closeCount = 0;
const scanLimit = 8;
const scanLimit = (this.fightTimer > 0.04 || this.birthRitualTimer > 0.04) ? 12 : 8;
const selfRadius = Math.max(12, Number(this.radius || 22) || 22);
const scanRadius = Math.max(96, selfRadius * 2.4 + 88);
let scanned = 0;
const scanRadius = Math.max(92, selfRadius * 2.0 + 52);
const selfProfile = this.personalityProfile();
const detailed = [];
// nearbyTarinai() has already paid the spatial lookup cost and returns only
// candidates inside the requested radius when precise=true. Do not stop at
// an arbitrary iteration count here: the incremental grid intentionally does
// not preserve bucket insertion order, so an early break can miss the actual
// nearest mate/contact candidates after enough cell crossings. Keep only the
// nearest scanLimit entries with a tiny bounded insertion list instead.
for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) {
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);
const d2 = dx * dx + dy * dy;
if (d2 > scanRadius * scanRadius || d2 < 0.0001) continue;
const entry = { o, dx, dy, d2 };
let pos = detailed.length;
while (pos > 0 && detailed[pos - 1].d2 > d2) pos--;
if (pos < scanLimit) {
detailed.splice(pos, 0, entry);
if (detailed.length > scanLimit) detailed.pop();
}
}
for (const entry of detailed) {
const o = entry.o;
const pairLeader = String(this.id) < String(o.id);
const dx = entry.dx, dy = entry.dy;
const otherRadius = Math.max(12, Number(o.radius || 22) || 22);
const bodyContactRange = Math.max(74, selfRadius + otherRadius + 18);
if (d < 0.01 || d > bodyContactRange) continue;
closeCount += 1;
if (entry.d2 > bodyContactRange * bodyContactRange) continue;
const d = Math.sqrt(entry.d2);
const champion = this.isTarinaiChampion ? this : (o.isTarinaiChampion ? o : null);
if (champion && this.isTarinaiChampion !== o.isTarinaiChampion) {
@ -153,7 +261,7 @@
}
}
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
const fightMochiContact = pairLeader && this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
if (fightMochiContact && (this.world.fightPairCooldownRemaining?.(this, o) || 0) <= 0.04 && this.fightCooldown <= 0.04 && o.fightCooldown <= 0.04 && d <= Math.max(42, this.radius + o.radius + 16)) {
this.conflictTargetId = o.id;
o.conflictTargetId = this.id;
@ -167,53 +275,57 @@
}
}
const zunchiTempMulA = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(o) : 1;
const zunchiSpreadBaseA = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulA;
const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(zunchiSpreadBaseA) : zunchiSpreadBaseA;
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-a", zunchiSpreadChanceA, this, o)) {
if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
}
const zunchiTempMulB = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(this) : 1;
const zunchiSpreadBaseB = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulB;
const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(zunchiSpreadBaseB) : zunchiSpreadBaseB;
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-b", zunchiSpreadChanceB, this, o)) {
if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
if (pairLeader) {
trySpreadZunchiDisease(this, o, dt, d, "a");
trySpreadZunchiDisease(o, this, dt, d, "b");
}
const pairBond = this.world.areCoParents?.(this, o);
const mixedZunchiSlave = !!this.isZunchiSlave !== !!o.isZunchiSlave;
if (mixedZunchiSlave && d < 86) {
const nonSlave = this.isZunchiSlave ? o : this;
const slave = this.isZunchiSlave ? this : o;
const nx = (nonSlave.x - slave.x) / d;
const ny = (nonSlave.y - slave.y) / d;
nonSlave.vx += nx * dt * 34;
nonSlave.vy += ny * dt * 34;
if (typeof applyNeedShock === "function") applyNeedShock(nonSlave, { safety: dt * 24, social: dt * 5 }, slave);
nonSlave.lastNeedShockBreaker = slave;
nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.22 * nonSlave.personalityProfile().fear);
nonSlave.target = slave;
nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044";
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid");
if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18);
if (pairLeader) {
const nonSlave = this.isZunchiSlave ? o : this;
const slave = this.isZunchiSlave ? this : o;
const nx = (nonSlave.x - slave.x) / d;
const ny = (nonSlave.y - slave.y) / d;
nonSlave.vx += nx * dt * 34;
nonSlave.vy += ny * dt * 34;
if (typeof applyNeedShock === "function") applyNeedShock(nonSlave, { safety: dt * 24, social: dt * 5 }, slave);
nonSlave.lastNeedShockBreaker = slave;
nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.22 * nonSlave.personalityProfile().fear);
nonSlave.target = slave;
nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044";
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid");
if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18);
}
continue;
}
const overlap = this.radius + o.radius - d;
if (overlap > -2) {
if (pairLeader && overlap > -2) {
const nx = dx / d, ny = dy / d;
const push = (overlap + 6) * (pairBond ? 0.08 : 0.17);
this.vx -= (dx / d) * push;
this.vy -= (dy / d) * push;
if (!pairBond && deterministicChance(this.world, "body-overlap-surprise", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
this.vx -= nx * push;
this.vy -= ny * push;
o.vx += nx * push;
o.vy += ny * push;
if (!pairBond) {
if (deterministicChance(this.world, "body-overlap-surprise-a", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
if (deterministicChance(this.world, "body-overlap-surprise-b", dt * 1.2, o, this)) o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.12);
}
}
if (pairBond && d > 26 && d < 120) {
this.vx += (dx / d) * dt * 4.8;
this.vy += (dy / d) * dt * 4.8;
if (pairLeader && pairBond && d > 26 && d < 120) {
const nx = dx / d, ny = dy / d;
const pull = dt * 4.8;
this.vx += nx * pull;
this.vy += ny * pull;
o.vx -= nx * pull;
o.vy -= ny * pull;
}
if (d < Math.max(60, selfRadius + otherRadius + 8)) {
const rel = this.relationTo(o.id);
const pdef = this.personalityProfile();
const pdef = selfProfile;
this.loneliness -= dt * 1.65 * this.trait.social;
this.affection += dt * 0.28 * this.trait.social;
this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby");
@ -269,8 +381,11 @@
}
const weights = socialFightMateWeights(this, o, this.world, { mode: "contact", requireBirthReady: true, distance: d, fightRange: 58 });
const combinedStartChance = dt * 0.30 * clamp((weights.totalWeight || 0) / 10, 0, 2.6);
if ((weights.totalWeight || 0) > 0 && deterministicChance(this.world, "social-weighted-fight-mate-start", combinedStartChance, this, o)) {
const oneDirectionChance = clamp(dt * 0.30 * clamp((weights.totalWeight || 0) / 10, 0, 2.6), 0, 0.95);
// Pair processing now runs once instead of once from each participant. Preserve
// the old two-attempt probability: 1 - (1 - p)^2.
const combinedStartChance = 1 - (1 - oneDirectionChance) * (1 - oneDirectionChance);
if (pairLeader && (weights.totalWeight || 0) > 0 && deterministicChance(this.world, "social-weighted-fight-mate-start", combinedStartChance, this, o)) {
const choice = pickSocialFightMate(this.world, "social-weighted-fight-mate-pick", weights, this, o);
if (choice === "fight") {
this.conflictTargetId = o.id;
@ -291,12 +406,6 @@
}
}
if (closeCount > 8) {
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 5, safety: dt * 3 * this.personalityProfile().fear });
this.energy -= dt * 0.25;
this.fearTimer = Math.max(this.fearTimer, 0.18);
}
if (this.loneliness < 12 && deterministicChance(this.world, "nearby-calm-log", dt * 0.002, this)) {
this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] });
}
@ -474,12 +583,11 @@
if (this.target && !this.target.dead) {
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : (this.state === "play_seesaw" ? (globalThis.TarinaiSeesawSystem?.seatWorld?.(this.target, this.seesawSeatIndex) || this.target) : this.target);
const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature"]);
const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
const zundaFoodTarget = this.state === "seek_food" && (targetFoodType === "sweet" || targetFoodType === "zunda_juice");
const waypoint = routeStates.has(this.state) && this.world?.findTarinaiPathWaypoint
const waypoint = ROUTE_STATES.has(this.state) && this.world?.findTarinaiPathWaypoint
? this.world.findTarinaiPathWaypoint(this, baseMoveTarget, { targetItem: routeTargetItem, padding: pathPadding, state: this.state, preferFullPath: zundaFoodTarget })
: null;
if (this.state === "seek_bed" && this.target?.type === "grass_bed") {
@ -707,7 +815,9 @@
const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason, opts) : (reason || "\u4e8b\u6545");
this.dead = true;
this.deathReason = finalReason;
this.world.syncTarinaiCountEntry?.(this);
this.world.deadCount += 1;
globalThis.TarinaiAchievements?.evaluateEvent?.(this.world, "population", { tarinai: this, delta: -1, reason: finalReason });
globalThis.TarinaiAchievements?.recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) });
this.world.lastDeathAt = this.world.time || 0;
if (!Array.isArray(this.world.recentDeathTimes)) this.world.recentDeathTimes = [];
@ -736,6 +846,7 @@
this.world.markDead(this, finalReason);
this.releasedLiveToken = this.liveToken || 0;
this.world.releaseTarinaiLiveId?.(this);
this.world.noteTarinaiDeathForRuntimeCleanup?.(this.id);
audio.death();
this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] });
if (this.world.selected === this) this.world.selected = null;