tarinai/js/world_family_social.js

1340 lines
71 KiB
JavaScript
Raw Normal View History

2026-06-21 14:09:35 +09:00
"use strict";
2026-06-27 19:22:38 +09:00
2026-06-21 14:09:35 +09:00
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_family_social.js");
2026-06-30 10:57:06 +09:00
const RESET_PRESETS = Object.freeze({
empty: { label: "空白" },
default: { label: "デフォルト" },
athletic: { label: "アスレチック" },
crowded: { label: "過密" },
family: { label: "家族" },
war: { label: "戦争" },
happy: { label: "幸福" },
});
function normalizeResetPresetId(presetId = "default") {
const id = String(presetId || "default");
return Object.prototype.hasOwnProperty.call(RESET_PRESETS, id) ? id : "default";
}
function presetScaleForField(field) {
const id = field?.id || "garden";
if (id === "cage") return 0.65;
if (id === "park") return 1.45;
return 1;
}
function presetCount(field, base, min = 0, max = Infinity) {
const value = Math.round((Number(base) || 0) * presetScaleForField(field));
return Math.max(min, Math.min(Number.isFinite(max) ? max : value, value));
}
function randPoint(worldRef, pad = 78, zone = null) {
const x0 = Math.max(pad, Number(zone?.x0 ?? pad));
const x1 = Math.min(worldRef.w - pad, Number(zone?.x1 ?? (worldRef.w - pad)));
const y0 = Math.max(pad, Number(zone?.y0 ?? pad));
const y1 = Math.min(worldRef.h - pad, Number(zone?.y1 ?? (worldRef.h - pad)));
return { x: rand(Math.min(x0, x1), Math.max(x0, x1)), y: rand(Math.min(y0, y1), Math.max(y0, y1)) };
}
function randomPresetAngle(step = Math.PI / 4) {
const n = Math.max(1, Math.round((Math.PI * 2) / Math.max(0.001, step)));
return Math.floor(rand(0, n)) * step;
}
function presetShapeExtent(segments) {
let max = 0;
for (const seg of segments || []) {
for (let i = 0; i < 4; i += 2) max = Math.max(max, Math.hypot(Number(seg[i]) || 0, Number(seg[i + 1]) || 0));
}
return max;
}
function configurePresetMechanicalItem(item, opts = {}) {
const body = item?.physicsBody;
if (!body || (item.type !== "rotator" && item.type !== "reciprocator")) return item;
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
body.pose.x = item.x;
body.pose.y = item.y;
body.pose.angle = Number(item.angle) || 0;
body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 };
body.velocity.x = 0;
body.velocity.y = 0;
body.velocity.angular = 0;
body.velocity.linear = 0;
body.shape = body.shape || { model: "segments", thickness: 12, segments: [], version: 0 };
if (item.type === "rotator") {
const segments = opts.segments || [
[-92, 0, 92, 0],
[0, -58, 0, 58],
[-58, -42, 58, 42],
[-58, 42, 58, -42],
];
body.motor = body.motor || { powered: true, speed: Math.PI * 0.18, direction: 1 };
body.motor.powered = opts.motorOn !== false;
body.motor.speed = Number.isFinite(Number(opts.motorSpeed)) ? Number(opts.motorSpeed) : Math.PI * 0.18;
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
body.shape.model = "segments";
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
body.shape.segments = segments.map(seg => seg.slice(0, 4));
body.shape.version = (Number(body.shape.version) || 0) + 1;
item.spin = 0;
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
} else if (item.type === "reciprocator") {
const railAxis = Number.isFinite(Number(opts.railAxis)) ? Number(opts.railAxis) : (Number(item.angle) || 0);
const segments = opts.segments || [
[-92, -24, -36, -24],
[-36, -24, -36, 24],
[-36, 24, 36, 24],
[36, 24, 36, -24],
[36, -24, 92, -24],
];
body.motor = body.motor || { powered: true, speed: 36, direction: 1 };
body.motor.powered = opts.railOn !== false;
body.motor.speed = Number.isFinite(Number(opts.railMotorSpeed)) ? Number(opts.railMotorSpeed) : 36;
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
body.rail = body.rail || { axisAngle: railAxis, travel: 120, phase: 0, anchorX: item.x, anchorY: item.y };
body.rail.axisAngle = railAxis;
body.rail.travel = Number.isFinite(Number(opts.railTravel)) ? Number(opts.railTravel) : 120;
body.rail.phase = Number.isFinite(Number(opts.railPhase)) ? Number(opts.railPhase) : 0;
body.rail.anchorX = item.x;
body.rail.anchorY = item.y;
body.shape.model = "segments";
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
body.shape.segments = segments.map(seg => seg.slice(0, 4));
body.shape.version = (Number(body.shape.version) || 0) + 1;
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
}
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-mechanical-shape");
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
return item;
}
function syncPresetMechanicalPose(item) {
const body = item?.physicsBody;
if (!body) return;
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
body.pose.x = item.x;
body.pose.y = item.y;
body.pose.angle = Number(item.angle) || 0;
if (body.rail) {
body.rail.anchorX = item.x;
body.rail.anchorY = item.y;
}
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-placement-pose");
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
}
function presetItemFootprintRadius(worldRef, item) {
const radius = globalThis.TarinaiCollisionFootprints?.itemFootprintRadius?.(worldRef, item);
if (Number.isFinite(Number(radius))) {
if (item?.type === "ball") return Math.max(34, Number(radius) * 1.35);
return Math.max(14, Number(radius));
}
return Math.max(14, Number(item?.r || 16) || 16);
}
function presetItemPlacementBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
if (typeof worldRef.placementBlocked === "function" && worldRef.placementBlocked(item)) return true;
const itemRects = worldRef.solidObstacleRects?.(item) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, item) || [];
const itemRadius = presetItemFootprintRadius(worldRef, item);
const search = Math.max(120, itemRadius * 3.2);
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || worldRef.items || []) {
if (!other || other === item || other.dead) continue;
const sameType = other.type === item.type;
const otherIsFence = worldRef.isFenceType?.(other.type) || false;
const itemIsFence = worldRef.isFenceType?.(item.type) || false;
if (!opts.avoidAllObstacles && !sameType && !otherIsFence && !itemIsFence) continue;
const otherRects = worldRef.solidObstacleRects?.(other) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, other) || [];
const otherRadius = presetItemFootprintRadius(worldRef, other);
const margin = (itemIsFence || otherIsFence) ? 4 : Math.max(8, Math.min(itemRadius, otherRadius) * 0.28);
if (itemRects.length && otherRects.length) {
if (itemRects.some(a => otherRects.some(b => globalThis.TarinaiCollisionFootprints?.rectsOverlap?.(a, b, margin)))) return true;
continue;
}
if (itemRects.length) {
if (itemRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, other.x, other.y, otherRadius, margin))) return true;
continue;
}
if (otherRects.length) {
if (otherRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, item.x, item.y, itemRadius, margin))) return true;
continue;
}
if (Math.hypot((item.x || 0) - (other.x || 0), (item.y || 0) - (other.y || 0)) < itemRadius + otherRadius + margin) return true;
}
return false;
}
function findPresetItemSpot(worldRef, item, opts = {}) {
const allowOriginal = opts.allowOriginal !== false;
if (allowOriginal && !presetItemPlacementBlocked(worldRef, item, opts)) return { x: item.x, y: item.y };
const solid = Boolean(worldRef?.isFenceType?.(item.type) || item.type === "rotator" || item.type === "reciprocator" || item.type === "nest_box");
const count = Math.max(12, Number(opts.attempts || 0) || (solid ? 96 : 58));
const maxRadius = Math.max(70, Number(opts.maxRadius || 0) || (solid ? 220 : 170));
const golden = Math.PI * (3 - Math.sqrt(5));
let best = null;
let bestD = Infinity;
const seed = `${item.type}:${Math.round(item.x)},${Math.round(item.y)}`;
for (let i = 0; i < count; i++) {
const t = count <= 1 ? 1 : i / (count - 1);
const radius = Math.max(10, maxRadius * Math.sqrt(t));
const angle = i * golden + (globalThis.stableUnit ? globalThis.stableUnit(seed, "preset-place") : Math.random()) * Math.PI * 2;
const rawX = item.x + Math.cos(angle) * radius;
const rawY = item.y + Math.sin(angle) * radius * 0.74;
const p = worldRef.placementClampPointFor?.(item, rawX, rawY) || { x: clamp(rawX, 42, worldRef.w - 42), y: clamp(rawY, 42, worldRef.h - 42) };
const oldX = item.x, oldY = item.y;
item.x = p.x; item.y = p.y; syncPresetMechanicalPose(item);
const blocked = presetItemPlacementBlocked(worldRef, item, opts);
item.x = oldX; item.y = oldY; syncPresetMechanicalPose(item);
if (blocked) continue;
const d = Math.hypot(p.x - oldX, p.y - oldY);
if (d < bestD) { best = p; bestD = d; }
}
return best;
}
function addPresetItem(worldRef, type, x, y, opts = {}) {
const item = new Item(type, x, y);
if (Number.isFinite(Number(opts.r))) item.r = Number(opts.r);
if (Number.isFinite(Number(opts.angle))) item.angle = Number(opts.angle);
if (opts.toolSize) item.toolSize = opts.toolSize;
if (type === "duplicator") {
item.storedFoodType = opts.storedFoodType || "sweet";
item.storedFoodLabel = typeof foodLabel === "function" ? foodLabel(item.storedFoodType) : (typeof toolLabel === "function" ? toolLabel(item.storedFoodType) : item.storedFoodType);
if (item.roles) item.roles.food = true;
}
if (type === "rotator" || type === "reciprocator") configurePresetMechanicalItem(item, opts);
if (opts.avoidOverlap) {
const spot = findPresetItemSpot(worldRef, item, opts);
if (!spot) return null;
item.x = spot.x;
item.y = spot.y;
syncPresetMechanicalPose(item);
}
if (worldRef.addItem) worldRef.addItem(item, opts.reason || `preset-${type}`);
else worldRef.items.push(item);
return item;
}
function addPresetGrass(worldRef, count, zone = null) {
for (let i = 0; i < count; i++) {
const p = randPoint(worldRef, 70, zone);
const grass = addPresetItem(worldRef, "grass", p.x, p.y, { reason: "preset-grass" });
globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true });
}
}
function addPresetTarinai(worldRef, count, zone = null, opts = {}) {
for (let i = 0; i < count; i++) {
const p = randPoint(worldRef, 82, zone);
worldRef.addTarinai({ x: p.x, y: p.y, generation: opts.generation || 1 });
}
}
function addPresetItemsScattered(worldRef, type, count, zone = null, opts = {}) {
let placed = 0;
const n = Math.max(0, Number(count) | 0);
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
for (let i = 0; i < attempts && placed < n; i++) {
const p = randPoint(worldRef, opts.pad || 82, zone);
const item = addPresetItem(worldRef, type, p.x, p.y, opts);
if (item) placed++;
}
}
function addPresetFenceLine(worldRef, count, yRatio, angle = 0, type = "fence_h", opts = {}) {
const n = Math.max(1, Number(count) | 0);
const gap = worldRef.w / (n + 1);
for (let i = 1; i <= n; i++) {
addPresetItem(worldRef, type, gap * i, worldRef.h * yRatio + rand(-24, 24), { angle, reason: opts.reason || "preset-fence", avoidOverlap: !!opts.avoidOverlap, avoidAllObstacles: !!opts.avoidAllObstacles, attempts: opts.attempts || 96, maxRadius: opts.maxRadius || 190 });
}
}
function addPresetFencesScattered(worldRef, count, zone = null, opts = {}) {
const n = Math.max(0, Number(count) | 0);
let placed = 0;
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
for (let i = 0; i < attempts && placed < n; i++) {
const p = randPoint(worldRef, opts.pad || 78, zone);
const vertical = Math.random() < 0.5;
const item = addPresetItem(worldRef, vertical ? "fence_v" : "fence_h", p.x, p.y, { angle: randomPresetAngle(Math.PI / 4), reason: opts.reason || "preset-fence-scatter", avoidOverlap: !!opts.avoidOverlap, attempts: opts.attempts || 80, maxRadius: opts.maxRadius || 180 });
if (item) placed++;
}
}
function generateDefaultPreset(worldRef, field, seedPopulation) {
const population = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
const grassCount = field.grass ?? 18;
addPresetTarinai(worldRef, population);
addPresetGrass(worldRef, grassCount);
addPresetItem(worldRef, "stone", worldRef.w * 0.68, worldRef.h * 0.32, { reason: "reset-stone" });
}
function generateAthleticPreset(worldRef, field) {
const factor = presetScaleForField(field);
const fieldId = field?.id || "garden";
const compact = fieldId === "cage" || fieldId === "garden";
const center = { x0: worldRef.w * 0.10, x1: worldRef.w * 0.90, y0: worldRef.h * 0.14, y1: worldRef.h * 0.86 };
addPresetTarinai(worldRef, presetCount(field, 14, 7, 24), { x0: worldRef.w * 0.08, x1: worldRef.w * 0.30, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 });
// 物理ブロックは柵より先に置く。狭い中小フィールドでは柵側を間引き、ブロックを優先する。
addPresetItemsScattered(worldRef, "stone", presetCount(field, compact ? 4 : 6, 3, 9), center, { pad: compact ? 118 : 132, reason: "preset-athletic-physical-block", avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 260 : 220 });
addPresetItemsScattered(worldRef, "stove", presetCount(field, 2, 1, 3), center, { pad: 118, reason: "preset-athletic-stove", avoidOverlap: true, avoidAllObstacles: true, attempts: 100, maxRadius: 210 });
addPresetItemsScattered(worldRef, "ball", presetCount(field, compact ? 8 : 10, 4, 18), null, { pad: 96, reason: "preset-ball", avoidOverlap: true, avoidAllObstacles: true, attempts: 90, maxRadius: compact ? 230 : 190 });
addPresetFenceLine(worldRef, Math.max(2, Math.round((compact ? 3 : 4) * factor)), 0.36, 0, "fence_h", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
addPresetFenceLine(worldRef, Math.max(1, Math.round((compact ? 2 : 3) * factor)), 0.62, Math.PI / 2, "fence_v", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
addPresetFencesScattered(worldRef, Math.max(1, Math.round((compact ? 2 : 4) * factor)), null, { pad: 108, reason: "preset-athletic-fence", avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210 });
addPresetItemsScattered(worldRef, "rotator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-rotator-slow-custom", motorSpeed: Math.PI * 0.18, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
addPresetItemsScattered(worldRef, "reciprocator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-reciprocator-slow-custom", railMotorSpeed: 36, railTravel: 120, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
}
function generateCrowdedPreset(worldRef, field) {
const center = { x0: worldRef.w * 0.18, x1: worldRef.w * 0.82, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 };
addPresetTarinai(worldRef, presetCount(field, 36, 18, 58), center);
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 13, 7, 22), center, { pad: 96, reason: "preset-nest-box" });
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), center, { pad: 110, storedFoodType: "sweet", reason: "preset-duplicator-zunda" });
addPresetGrass(worldRef, presetCount(field, 18, 8, 34), center);
addPresetItemsScattered(worldRef, "water", presetCount(field, 8, 4, 16), center, { pad: 84, reason: "preset-water" });
}
function generateFamilyPreset(worldRef, field) {
const cx = worldRef.w * 0.5;
const cy = worldRef.h * 0.52;
worldRef.addTarinai({ x: cx - 52, y: cy + 20, generation: 1 });
worldRef.addTarinai({ x: cx + 52, y: cy + 20, generation: 1 });
addPresetItem(worldRef, "nest_box", cx, cy - 58, { reason: "preset-family-nest" });
addPresetItem(worldRef, "duplicator", cx, cy + 112, { storedFoodType: "love_mochi", reason: "preset-family-duplicator" });
}
function generateWarPreset(worldRef, field) {
const left = { x0: worldRef.w * 0.08, x1: worldRef.w * 0.45, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
const right = { x0: worldRef.w * 0.55, x1: worldRef.w * 0.92, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
addPresetTarinai(worldRef, Math.ceil(presetCount(field, 28, 14, 48) / 2), left);
addPresetTarinai(worldRef, Math.floor(presetCount(field, 28, 14, 48) / 2), right);
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), { x0: worldRef.w * 0.38, x1: worldRef.w * 0.62, y0: worldRef.h * 0.22, y1: worldRef.h * 0.78 }, { pad: 96, storedFoodType: "fight_mochi", reason: "preset-war-duplicator" });
addPresetItemsScattered(worldRef, "ball", presetCount(field, 8, 4, 14), null, { pad: 90, reason: "preset-war-ball" });
addPresetItemsScattered(worldRef, "ant_nest", presetCount(field, 1.25, 1, 2), null, { pad: 116, reason: "preset-war-ant-nest", avoidOverlap: true, attempts: 80, maxRadius: 190 });
addPresetItemsScattered(worldRef, "pushpin", presetCount(field, 5, 2, 9), null, { pad: 88, reason: "preset-war-pushpin-reduced" });
addPresetItemsScattered(worldRef, "oshibyo", presetCount(field, 8, 4, 16), null, { pad: 88, reason: "preset-war-oshibyo" });
addPresetFencesScattered(worldRef, presetCount(field, 8, 4, 14), null, { pad: 94, reason: "preset-war-fence" });
}
function generateHappyPreset(worldRef, field) {
const center = { x0: worldRef.w * 0.16, x1: worldRef.w * 0.84, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
addPresetTarinai(worldRef, presetCount(field, 16, 8, 28), center);
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 8, 4, 14), center, { pad: 96, reason: "preset-happy-nest-box" });
addPresetItemsScattered(worldRef, "robot_cleaner", presetCount(field, 2, 1, 3), center, { pad: 110, reason: "preset-happy-robot-cleaner" });
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 2, 1, 4), center, { pad: 110, storedFoodType: "sweet", reason: "preset-happy-duplicator-zunda" });
addPresetItemsScattered(worldRef, "ball", presetCount(field, 6, 3, 10), center, { pad: 88, reason: "preset-happy-ball" });
}
function generatePresetWorld(worldRef, field, presetId, seedPopulation) {
switch (normalizeResetPresetId(presetId)) {
case "empty": return;
case "athletic": return generateAthleticPreset(worldRef, field);
case "crowded": return generateCrowdedPreset(worldRef, field);
case "family": return generateFamilyPreset(worldRef, field);
case "war": return generateWarPreset(worldRef, field);
case "happy": return generateHappyPreset(worldRef, field);
case "default":
default:
return generateDefaultPreset(worldRef, field, seedPopulation);
}
}
function groundForResetPreset(presetId = "default") {
const id = normalizeResetPresetId(presetId);
if (id === "athletic") return "ice";
if (id === "war") return "foot_massage";
if (id === "happy") return "blanket";
2026-06-30 13:40:36 +09:00
return "soil";
2026-06-30 10:57:06 +09:00
}
global.TarinaiResetPresets = Object.freeze({ ids: Object.keys(RESET_PRESETS), meta: RESET_PRESETS, normalize: normalizeResetPresetId });
2026-06-21 14:09:35 +09:00
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
2026-06-30 10:57:06 +09:00
reset(seedPopulation = null, fieldType = this.fieldType || "garden", presetId = "default") {
2026-06-21 14:09:35 +09:00
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
2026-06-30 10:57:06 +09:00
const preset = normalizeResetPresetId(presetId);
2026-06-21 14:09:35 +09:00
this.fieldType = field.id;
this.fieldZoom = 1;
this.setViewportSize(this.viewportW, this.viewportH);
this.time = 0;
this.day = 1;
this.paused = false;
this.speed = 1;
this.toolSize = this.toolSize || "medium";
this.toolSizes = this.toolSizes || {};
this.tarinai = [];
this.items = [];
this.ants = [];
this.effects = [];
2026-06-25 14:51:58 +09:00
this.itemCounts = {};
this.effectCounts = {};
this.itemTypeBuckets = new Map();
this.itemIdMap = new Map();
this.itemBucketsDirty = false;
2026-06-21 14:09:35 +09:00
this.logs = [];
this.events = window.TarinaiEvents || null;
this.eventCounters = {};
this.countsTimer = 0;
this.compactTimer = 0;
this.drawSortTimer = 0;
this.drawListDirty = true;
this.antUpdatePhase = 0;
2026-06-25 14:51:58 +09:00
this.grassGrowthTimer = 0;
this.nextGrassLimitCheckAt = 0;
2026-06-21 14:09:35 +09:00
this.selected = null;
this.deadCount = 0;
this.liveIdNext = 1;
this.liveIdSerial = 0;
this.liveTarinai = new Map();
2026-06-29 16:21:58 +09:00
this.worldSeed = global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
2026-06-25 22:30:35 +09:00
this.birthSerial = 0;
2026-06-21 14:09:35 +09:00
this.lastBirthAt = -999;
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
2026-06-24 13:48:22 +09:00
this.fightPairCooldowns = {};
2026-06-21 14:09:35 +09:00
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
this.shakeTimer = 0;
this.shakeDuration = 0;
this.shakeStrength = 0;
this.clampCamera();
this.lastPhase = this.phaseName();
this.weather = "sunny";
2026-06-29 16:21:58 +09:00
this.groundType = window.TarinaiGround.exists(this.groundType || "soil") ? (this.groundType || "soil") : "soil";
2026-06-30 10:57:06 +09:00
const presetGround = groundForResetPreset(preset);
if (presetGround && window.TarinaiGround.exists(presetGround)) this.groundType = presetGround;
2026-06-21 14:09:35 +09:00
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
2026-06-30 10:57:06 +09:00
generatePresetWorld(this, field, preset, seedPopulation);
2026-06-21 14:09:35 +09:00
this.updateItemCounts();
2026-06-25 14:51:58 +09:00
this.enforceGrassLimit?.("reset-grass-limit");
this.updateItemCounts();
2026-06-21 14:09:35 +09:00
this.updateEffectCounts();
this.markTerrainDirty?.("reset");
this.rebuildSpatial(true);
2026-06-30 10:57:06 +09:00
const label = RESET_PRESETS[preset]?.label || "デフォルト";
showToast(`${label}で新しい観察を開始しました。`);
2026-06-21 14:09:35 +09:00
syncTopButtons();
},
tarinaiFamilyKey(t) {
2026-06-25 14:51:58 +09:00
return t?.familyKey || t?.id || "";
2026-06-21 14:09:35 +09:00
},
assignTarinaiLiveId(t) {
if (!t) return "";
if (!this.liveTarinai) this.liveTarinai = new Map();
// Live IDs are never recycled. Relationships and transient targets are keyed by
// live ID, so reuse can make dead friends/enemies leak onto new individuals.
if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1;
if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0;
const id = `t${this.liveIdNext++}`;
t.id = id;
t.liveToken = ++this.liveIdSerial;
this.liveTarinai.set(id, { token: t.liveToken, target: t });
return id;
},
releaseTarinaiLiveId(t) {
if (!t?.id || !this.liveTarinai) return false;
const current = this.liveTarinai.get(t.id);
if (!current || current.target !== t || current.token !== t.liveToken) return false;
const id = t.id;
this.liveTarinai.delete(id);
// Do not recycle live IDs. Relationships are keyed by live ID; recycling can
// make a dead friend's/enemy's relationship appear on a newly spawned individual.
t.releasedLiveId = id;
t.id = "";
t.liveToken = 0;
return true;
},
liveTarinaiById(id, token = null) {
if (!id || !this.liveTarinai) return null;
const entry = this.liveTarinai.get(id);
if (!entry || (token !== null && entry.token !== token)) return null;
const t = entry.target;
return t && !t.dead ? t : null;
},
recordFamily(t) {
if (!this.family) this.family = {};
const familyKey = this.tarinaiFamilyKey(t);
if (!familyKey) return;
const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean)));
const sameList = (a, b) => {
const aa = uniqueIds(a);
const bb = uniqueIds(b);
return aa.length === bb.length && aa.every((id, i) => id === bb[i]);
};
let changed = false;
const nextParents = uniqueIds(t.parents || []);
const nextChildren = uniqueIds(t.children || []);
const nextPersonalityTags = getPersonalityTraitTags(t);
2026-06-21 22:29:00 +09:00
const nextScale = Number.isFinite(t.scale) ? t.scale : (Number.isFinite(t.adultScale) ? t.adultScale : 0.28);
const nextAdultScale = Number.isFinite(t.adultScale) ? t.adultScale : nextScale;
const nextGrowth = Number.isFinite(t.growth) ? t.growth : 1;
2026-06-28 13:40:41 +09:00
const nextGenetics = typeof normalizeGenetics === "function" ? normalizeGenetics(t.genetics, t.familyKey || t.id || "") : { ...(t.genetics || {}) };
const nextFightWins = Number(t.totalFightWins || 0) || 0;
const nextFightLosses = Number(t.totalFightLosses || 0) || 0;
const nextChampion = !!t.isTarinaiChampion;
2026-06-21 14:09:35 +09:00
if (!this.family[familyKey]) {
this.family[familyKey] = {
id: familyKey,
name: t.name,
type: t.type,
personalityTags: nextPersonalityTags,
generation: t.generation,
hasPaired: !!t.hasPaired,
parents: nextParents,
parentNames: [...(t.parentNames || [])],
children: nextChildren,
birthTime: t.birthTime,
2026-06-21 22:29:00 +09:00
scale: nextScale,
adultScale: nextAdultScale,
growth: nextGrowth,
2026-06-28 13:40:41 +09:00
genetics: nextGenetics,
lifeSpan: Number(t.lifeSpan) || 0,
totalFightWins: nextFightWins,
totalFightLosses: nextFightLosses,
isTarinaiChampion: nextChampion,
2026-06-21 14:09:35 +09:00
alive: !t.dead,
deathReason: t.deathReason || "",
};
changed = true;
} else {
const entry = this.family[familyKey];
const nextAlive = !t.dead;
if (entry.name !== t.name) changed = true;
if (entry.type !== t.type) changed = true;
if (!sameList(entry.personalityTags, nextPersonalityTags)) changed = true;
if (entry.hasPaired !== !!t.hasPaired) changed = true;
if (entry.generation !== t.generation) changed = true;
2026-06-21 22:29:00 +09:00
if (Math.abs((Number(entry.scale) || 0) - nextScale) > 0.002) changed = true;
if (Math.abs((Number(entry.adultScale) || 0) - nextAdultScale) > 0.002) changed = true;
if (Math.abs((Number(entry.growth) || 0) - nextGrowth) > 0.02) changed = true;
2026-06-30 13:40:36 +09:00
for (const key of ["lifeSpanMul", "attackMul", "speedMul", "sizeMul", "temperatureOffset"]) {
const fallback = key === "temperatureOffset" ? 0 : 1;
const prevGene = Number.isFinite(Number(entry.genetics?.[key])) ? Number(entry.genetics?.[key]) : fallback;
const nextGene = Number.isFinite(Number(nextGenetics?.[key])) ? Number(nextGenetics?.[key]) : fallback;
if (Math.abs(prevGene - nextGene) > 0.002) changed = true;
}
2026-06-28 13:40:41 +09:00
if (Math.abs((Number(entry.lifeSpan) || 0) - (Number(t.lifeSpan) || 0)) > 1) changed = true;
if ((Number(entry.totalFightWins) || 0) !== nextFightWins) changed = true;
if ((Number(entry.totalFightLosses) || 0) !== nextFightLosses) changed = true;
if (!!entry.isTarinaiChampion !== nextChampion) changed = true;
2026-06-21 14:09:35 +09:00
if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true;
if (entry.alive !== nextAlive) changed = true;
if (!sameList(entry.parents, nextParents)) changed = true;
if (!sameList(entry.children, nextChildren)) changed = true;
entry.name = t.name;
entry.type = t.type;
delete entry.personality;
entry.personalityTags = nextPersonalityTags;
entry.alive = nextAlive;
entry.generation = t.generation;
2026-06-21 22:29:00 +09:00
entry.scale = nextScale;
entry.adultScale = nextAdultScale;
entry.growth = nextGrowth;
2026-06-28 13:40:41 +09:00
entry.genetics = nextGenetics;
entry.lifeSpan = Number(t.lifeSpan) || entry.lifeSpan || 0;
entry.totalFightWins = nextFightWins;
entry.totalFightLosses = nextFightLosses;
entry.isTarinaiChampion = nextChampion;
2026-06-21 14:09:35 +09:00
entry.hasPaired = !!t.hasPaired;
entry.deathReason = t.deathReason || entry.deathReason || "";
entry.parents = nextParents;
entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
entry.children = nextChildren;
}
if (changed) {
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-record");
}
},
markFamilyTreeDirty(reason = "family") {
this.familyTreeDirty = true;
},
normalizeFamily() {
if (!this.family) this.family = {};
if (this.familyCleanVersion === (this.familyVersion || 0)) return false;
const family = this.family;
const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id));
let changed = false;
const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id)))));
const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
for (const t of this.tarinai || []) {
const familyKey = this.tarinaiFamilyKey(t);
if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue;
const before = this.familyVersion || 0;
this.recordFamily(t);
changed = changed || before !== (this.familyVersion || 0);
}
const known = new Set(Object.keys(family));
for (const id of Object.keys(family)) {
const n = family[id] || {};
const live = liveById.get(id);
const parents = uniqueIds(n.parents, known);
const children = uniqueIds(n.children, known);
if (live) {
const liveParents = uniqueIds(live.parents, known);
const liveChildren = uniqueIds(live.children, known);
for (const p of liveParents) if (!parents.includes(p)) parents.push(p);
for (const c of liveChildren) if (!children.includes(c)) children.push(c);
}
const next = {
id,
name: live?.name || n.name || id,
type: live?.type || n.type || "smile",
personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])],
generation: Math.max(1, live?.generation || n.generation || 1),
hasPaired: !!(live?.hasPaired || n.hasPaired),
parents,
parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])],
children,
birthTime: live?.birthTime ?? n.birthTime ?? 0,
2026-06-21 22:29:00 +09:00
scale: Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : (Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : 0.28))),
adultScale: Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : (Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : 0.28))),
growth: Number.isFinite(live?.growth) ? live.growth : (Number.isFinite(n.growth) ? n.growth : 1),
2026-06-28 13:40:41 +09:00
genetics: live && typeof normalizeGenetics === "function" ? normalizeGenetics(live.genetics, live.familyKey || live.id || "") : (typeof normalizeGenetics === "function" ? normalizeGenetics(n.genetics, id) : { ...(n.genetics || {}) }),
lifeSpan: Number(live?.lifeSpan || n.lifeSpan || 0) || 0,
totalFightWins: Number(live?.totalFightWins ?? n.totalFightWins ?? 0) || 0,
totalFightLosses: Number(live?.totalFightLosses ?? n.totalFightLosses ?? 0) || 0,
isTarinaiChampion: !!(live?.isTarinaiChampion || n.isTarinaiChampion),
2026-06-21 14:09:35 +09:00
alive: live ? !live.dead : n.alive !== false,
deathReason: live?.deathReason || n.deathReason || "",
};
for (const key of Object.keys(next)) {
const a = family[id]?.[key];
const b = next[key];
2026-06-28 13:40:41 +09:00
let equal = false;
if (key === "genetics") {
2026-06-30 13:40:36 +09:00
equal = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul", "temperatureOffset"].every(gk => {
const fallback = gk === "temperatureOffset" ? 0 : 1;
const av = Number.isFinite(Number(a?.[gk])) ? Number(a?.[gk]) : fallback;
const bv = Number.isFinite(Number(b?.[gk])) ? Number(b?.[gk]) : fallback;
return Math.abs(av - bv) <= 0.002;
});
2026-06-28 13:40:41 +09:00
} else {
equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b;
}
2026-06-21 14:09:35 +09:00
if (!equal) changed = true;
}
family[id] = next;
}
for (const child of Object.values(family)) {
for (const parentId of child.parents || []) {
const parent = family[parentId];
if (!parent) continue;
if (!parent.children.includes(child.id)) {
parent.children.push(child.id);
changed = true;
}
}
}
for (const parent of Object.values(family)) {
const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id));
if (!sameList(parent.children || [], nextChildren)) {
parent.children = nextChildren;
changed = true;
}
}
for (const id of Object.keys(family)) {
const n = family[id];
if (!n?.hasPaired && !(n?.parents || []).length && !(n?.children || []).length) {
delete family[id];
changed = true;
}
}
for (const [id, live] of liveById.entries()) {
const n = family[id];
if (!n) continue;
if (!sameList(live.parents || [], n.parents || [])) {
live.parents = [...(n.parents || [])];
changed = true;
}
if (!sameList(live.children || [], n.children || [])) {
live.children = [...(n.children || [])];
changed = true;
}
if (live.hasPaired !== !!n.hasPaired) {
live.hasPaired = !!n.hasPaired;
changed = true;
}
if (live.generation !== n.generation) {
live.generation = n.generation;
changed = true;
}
}
if (changed) {
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-normalize");
}
this.familyCleanVersion = this.familyVersion || 0;
return changed;
},
resetFamilyTree() {
this.family = {};
this.familyCleanVersion = 0;
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-reset");
this.maxGeneration = 1;
for (const t of this.tarinai || []) {
t.parents = [];
t.parentNames = [];
t.children = [];
t.generation = 1;
t.hasPaired = false;
}
},
clearLiveReferencesTo(dead) {
const deadId = dead?.id || dead?.releasedLiveId || "";
if (!deadId) return false;
let changed = false;
for (const other of this.tarinai || []) {
if (!other || other === dead || other.dead) continue;
if (other.target === dead || other.target?.id === deadId) {
other.target = null;
2026-06-24 13:48:22 +09:00
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("\u53c2\u7167\u3057\u3066\u3044\u305f\u76f8\u624b\u304c\u3044\u306a\u304f\u306a\u3063\u305f");
2026-06-21 14:09:35 +09:00
changed = true;
}
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; }
if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) {
other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId);
changed = true;
}
for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) {
if (other[key] === deadId) { other[key] = null; changed = true; }
}
}
return changed;
},
markDead(t, reason) {
this.clearLiveReferencesTo(t);
if (!this.family) this.family = {};
const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason;
const familyKey = this.tarinaiFamilyKey(t);
if (familyKey && this.family[familyKey]) {
if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) {
this.family[familyKey].alive = false;
this.family[familyKey].deathReason = finalReason;
delete this.family[familyKey].personality;
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("death");
}
}
this.familyPrunePending = true;
},
pruneExtinctFamilies(opts = {}) {
if (opts.normalize !== false) this.normalizeFamily();
const family = this.family || {};
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
if (!nodes.length) return;
2026-06-29 16:21:58 +09:00
const components = globalThis.TarinaiFamilyGraph.connectedComponents
2026-06-22 02:03:19 +09:00
? globalThis.TarinaiFamilyGraph.connectedComponents(nodes)
: [];
const remove = components.length
? components.filter(comp => !comp.some(n => family[n.id]?.alive)).flat().map(n => n.id)
: [];
2026-06-21 14:09:35 +09:00
if (!remove.length) return;
for (const id of remove) delete family[id];
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-prune");
this.familyCleanVersion = this.familyVersion || 0;
},
addTarinai(opts = {}) {
2026-06-29 16:21:58 +09:00
opts = global.TarinaiSeedFactory.prepareAddOptions(this, opts) || opts;
2026-06-28 13:40:41 +09:00
const seedParents = Array.isArray(opts.seedParents) ? opts.seedParents.filter(p => p && typeof p === "object") : [];
if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") {
opts.genetics = inheritedGenetics(seedParents[0], seedParents[1], opts.birthSeed || opts.familyKey || "");
}
if (opts.genetics) {
if (!Number.isFinite(opts.adultScale) && typeof adultScaleFromGenetics === "function") opts.adultScale = adultScaleFromGenetics(opts.birthSeed || opts.familyKey || "", opts.genetics);
if (!Number.isFinite(opts.lifeSpan)) {
2026-06-29 16:21:58 +09:00
const profile = global.TarinaiSeedFactory.birthProfile(opts.birthSeed || opts.familyKey || "", opts) || {};
2026-06-28 13:40:41 +09:00
if (Number.isFinite(profile.lifeSpan)) opts.lifeSpan = profile.lifeSpan;
}
}
2026-06-21 14:09:35 +09:00
const t = new Tarinai(this, opts);
this.assignTarinaiLiveId(t);
this.tarinai.push(t);
this.drawListDirty = true;
if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t);
this.maxGeneration = Math.max(this.maxGeneration, t.generation);
2026-06-21 22:29:00 +09:00
this.emit?.("tarinai:born", { tarinai: t, source: opts.parents?.length ? "family" : "spawn" });
2026-06-21 14:09:35 +09:00
return t;
},
2026-06-25 14:51:58 +09:00
nestBoxOccupants(box, limit = 5) {
2026-06-29 16:21:58 +09:00
return globalThis.TarinaiNestSleepSystem.nestBoxOccupants(this, box, limit) || [];
2026-06-25 14:51:58 +09:00
},
pointerNestBoxInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
if (!best) return null;
const occupants = this.nestBoxOccupants(best, Infinity);
const capacity = this.nestBoxCapacity(best);
return { box: best, occupants, capacity };
},
nestBoxTooltipLines(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.nestBoxOccupants(box, Infinity);
const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5);
2026-06-28 13:40:41 +09:00
const more = occupants.length > names.length ? `\u3001\u307b\u304b${occupants.length - names.length}\u5339` : "";
2026-06-30 10:57:06 +09:00
return ["\u5de3\u7bb1", names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "内部気温")].filter(Boolean);
},
temperatureTooltipLineFor(target, label = "体感気温") {
if (!target || !this.temperatureAt) return "";
const temp = this.temperatureAt(target.x, target.y);
if (!Number.isFinite(Number(temp))) return "";
const status = this.temperatureStatusFor?.(temp) || null;
const suffix = status?.label ? `(${status.label})` : "";
return `${label}: ${Number(temp).toFixed(1)}℃${suffix}`;
},
pointerTarinaiTooltipInfo() {
const p = this.pointer;
if (!p?.inside || !Array.isArray(this.tarinai)) return null;
let best = null, bestD = Infinity;
for (const t of this.tarinai) {
if (!t || t.dead) continue;
const d = distXY(p.x, p.y, t.x, t.y);
const limit = Math.max(46, (t.radius || 20) * 1.35);
if (d <= limit && d < bestD) { best = t; bestD = d; }
}
if (!best) return null;
const behavior = (typeof behaviorText === "function" ? behaviorText(best) : "") || window.TEXT_CATALOG?.stateLabel?.(best.state, best) || best.thought || "";
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(best) : (Number(best.maxEnergy) || 100);
const statusLine = `体力: ${Math.round(best.energy || 0)}/${Math.round(maxEnergy)} 空腹: ${Math.round(best.hunger || 0)} ストレス: ${Math.round(best.stress || 0)}`;
const tempLine = this.temperatureTooltipLineFor(best, "体感気温");
return { target: best, lines: [best.name || "たりない", behavior, statusLine, tempLine].filter(Boolean), kind: "tarinai" };
2026-06-25 14:51:58 +09:00
},
pointerOwnedBedInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "grass_bed") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
}
if (!best) return null;
const owner = this.liveTarinaiById?.(best.ownerId) || null;
return { bed: best, owner };
},
ownedBedTooltipLines(bed) {
if (!bed || bed.dead || bed.type !== "grass_bed") return [];
const owner = this.liveTarinaiById?.(bed.ownerId) || null;
2026-06-30 10:57:06 +09:00
return ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `\u6301\u3061\u4e3b: ${owner?.name || "\u4e0d\u660e"}`, this.temperatureTooltipLineFor?.(bed, "周辺気温")].filter(Boolean);
2026-06-25 14:51:58 +09:00
},
pointerItemTooltipInfo() {
2026-06-30 10:57:06 +09:00
const tarinaiInfo = this.pointerTarinaiTooltipInfo?.();
if (tarinaiInfo?.target) return tarinaiInfo;
2026-06-25 14:51:58 +09:00
const nest = this.pointerNestBoxInfo?.();
if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" };
const bedInfo = this.pointerOwnedBedInfo?.();
if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" };
return null;
},
2026-06-21 14:09:35 +09:00
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
2026-06-25 14:51:58 +09:00
if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false;
2026-06-21 22:29:00 +09:00
if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false;
2026-06-21 14:09:35 +09:00
if (this.areParentChild(a, b)) return false;
if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false;
if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false;
const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length);
const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
if (aJoined && !bJoined) b.generation = a.generation;
else if (bJoined && !aJoined) a.generation = b.generation;
else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; }
// Do not mark either parent as part of a family during the ritual.
// The archive should only gain nodes after a child has actually been created;
// otherwise childless ritual participants appear as isolated family trees.
const duration = 10.0;
a.birthRitualTimer = b.birthRitualTimer = duration;
a.birthRitualMax = b.birthRitualMax = duration;
a.birthPartnerId = b.id;
b.birthPartnerId = a.id;
a.birthRitualRole = -1;
b.birthRitualRole = 1;
a.birthRitualLeader = true;
b.birthRitualLeader = false;
2026-06-28 13:40:41 +09:00
const ritualReason = "\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b";
2026-06-25 14:51:58 +09:00
const applyRitualAction = (actor, partner) => {
if (!actor) return false;
if (typeof actor.setActionState === "function") {
actor.setActionState("birth_ritual", { target: partner || null, reason: ritualReason, need: "social", subNeed: "mate", actionId: "birth_ritual", phase: "acting" });
return true;
}
actor.state = "birth_ritual";
actor.target = partner || null;
actor.thought = ritualReason;
actor.sleeping = false;
2026-06-29 16:21:58 +09:00
setBehaviorText(actor, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: ritualReason, reasonText: ritualReason, target: partner || null, phase: "acting", source: "state" });
2026-06-25 14:51:58 +09:00
return true;
};
applyRitualAction(a, b);
applyRitualAction(b, a);
2026-06-29 16:21:58 +09:00
const aBehavior = currentTarinaiBehavior(a);
const bBehavior = currentTarinaiBehavior(b);
const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const aText = buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause });
const bText = buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause });
setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
2026-06-21 14:09:35 +09:00
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
b.surpriseTimer = Math.max(b.surpriseTimer, 0.35);
return true;
},
2026-06-27 19:22:38 +09:00
cancelBirthRitual(actor, options = {}) {
const source = actor || null;
const partnerId = source?.birthPartnerId || null;
const partner = partnerId ? this.liveTarinaiById?.(partnerId) : null;
const participants = [];
if (source) participants.push(source);
if (partner && partner !== source) participants.push(partner);
if (!participants.length) return false;
2026-06-28 13:40:41 +09:00
const reason = options.reason || "\u7e41\u6b96\u304c\u4e2d\u65ad\u3055\u308c\u305f";
2026-06-27 19:22:38 +09:00
let canceled = false;
for (const t of participants) {
if (!t || t.dead) continue;
if ((t.birthRitualTimer || 0) <= 0.04 && t.state !== "birth_ritual") continue;
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
t.birthPartnerId = null;
t.birthRitualLeader = false;
t.birthRitualRole = 0;
t.postBirthPeaceTimer = Math.max(t.postBirthPeaceTimer || 0, 1.4);
t.behaviorLockTimer = Math.max(t.behaviorLockTimer || 0, 0.25);
2026-06-29 16:21:58 +09:00
clearTarinaiBehavior(t, { reason });
clearForcedBehaviorQueue(t, e => e && (e.id === "approach_mate" || e.id === "birth_ritual"));
2026-06-27 19:22:38 +09:00
if (options.panic && t === source) {
if (t.enterPanic) t.enterPanic({ target: options.target || null, reason, fear: options.fear ?? 0.95, wake: true, cause: options.cause || "birth_ritual_interrupted" });
else t.setActionState?.("panic", { target: options.target || null, reason, wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, options.fear ?? 0.95);
} else {
t.goIdle?.(reason);
t.thought = reason;
}
canceled = true;
}
if (canceled && !options.silentLog && this.time - (this.lastBirthCancelLogAt || -999) > 1.2) {
this.log?.(reason, "birth", { participants });
this.lastBirthCancelLogAt = this.time || 0;
}
return canceled;
},
cancelBirthRitualOnForce(tarinai, force = 0, options = {}) {
if (!tarinai || tarinai.dead) return false;
if ((tarinai.birthRitualTimer || 0) <= 0.04 && tarinai.state !== "birth_ritual") return false;
const amount = Number(force) || 0;
const threshold = Number(options.threshold || 180) || 180;
if (amount < threshold) return false;
return this.cancelBirthRitual(tarinai, {
2026-06-28 13:40:41 +09:00
reason: options.reason || "\u5f37\u3044\u885d\u6483\u3067\u7e41\u6b96\u304c\u4e2d\u65ad\u3055\u308c\u305f",
2026-06-27 19:22:38 +09:00
target: options.target || null,
fear: options.fear ?? 1.0,
cause: options.cause || "external_force",
panic: options.panic !== false,
silentLog: !!options.silentLog,
});
},
2026-06-21 14:09:35 +09:00
finishBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return null;
const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease;
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false;
2026-06-24 13:48:22 +09:00
a.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
b.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
2026-06-21 22:29:00 +09:00
if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) {
2026-06-21 14:09:35 +09:00
a.birthRitualRole = b.birthRitualRole = 0;
return null;
}
a.fightTimer = b.fightTimer = 0;
a.fightTargetIds = []; b.fightTargetIds = [];
a.fightTargetId = b.fightTargetId = null;
a.defeatedById = b.defeatedById = null;
a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18;
a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0);
const children = [];
const countRoll = Math.random();
const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1);
for (let i = 0; i < childCount; i++) {
const child = this.spawnChild(a, b, i, childCount);
if (child) { child.postBirthPeaceTimer = 18; children.push(child); }
}
if (children.length > 0) this.lastBirthAt = this.time;
if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] });
return children[0] || null;
},
areParentChild(a, b) {
if (!a || !b) return false;
const aId = this.tarinaiFamilyKey(a) || a;
const bId = this.tarinaiFamilyKey(b) || b;
if (!aId || !bId) return false;
return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId));
},
areCoParents(a, b) {
if (!a || !b) return false;
const aLiveId = a.id || a;
const bLiveId = b.id || b;
if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false;
if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true;
if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true;
const aId = this.tarinaiFamilyKey(a) || aLiveId;
const bId = this.tarinaiFamilyKey(b) || bLiveId;
if (!aId || !bId || aId === bId) return false;
const aChildren = new Set((a.children || []).filter(Boolean));
if (!aChildren.size) return false;
for (const id of (b.children || [])) {
if (aChildren.has(id)) return true;
}
return false;
},
canStartFight(t) {
2026-06-23 22:39:20 +09:00
if (!t || t.dead || typeof t.relationTo !== "function") return false;
2026-06-21 14:09:35 +09:00
if (t.sleepDisease) return false;
// Disease fight rule: explosion, zunchi, and fight disease can lash out;
// sleep disease cannot fight or be challenged, and other diseases cannot start fights.
if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true;
const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value);
const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease);
if (hasOtherDisease) return false;
return true;
},
canBeFightTarget(t) {
2026-06-23 22:39:20 +09:00
return Boolean(t && !t.dead && typeof t.relationTo === "function" && !t.sleepDisease);
2026-06-21 14:09:35 +09:00
},
canFightPair(actor, target) {
return this.canStartFight(actor) && this.canBeFightTarget(target);
},
spawnChild(a, b, index = 0, total = 1) {
2026-06-21 22:29:00 +09:00
const zunchiSlaveChild = !!(a.isZunchiSlave && b.isZunchiSlave);
2026-06-21 14:09:35 +09:00
const child = this.addTarinai({
2026-06-25 22:30:35 +09:00
seedParents: [a, b],
2026-06-21 22:29:00 +09:00
isZunchiSlave: zunchiSlaveChild,
2026-06-21 14:09:35 +09:00
age: 0,
x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18,
y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0),
hunger: rand(20, 40),
loneliness: rand(30, 56),
energy: rand(55, 95),
stress: rand(0, 14),
2026-06-23 22:39:20 +09:00
need: "",
2026-06-21 14:09:35 +09:00
generation: Math.max(a.generation, b.generation) + 1,
parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)],
parentNames: [a.name, b.name],
birthTime: this.time,
2026-06-21 22:29:00 +09:00
powerItemMode: "",
sizeItemMode: "",
lifeItemMode: "",
2026-06-21 14:09:35 +09:00
});
2026-06-21 22:29:00 +09:00
if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true });
2026-06-21 14:09:35 +09:00
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true;
b.hasPaired = true;
a.affection = 0; b.affection = 0;
a.loneliness *= 0.65; b.loneliness *= 0.65;
a.adjustRelation?.(b, 0.65, -0.20, "co_parent");
b.adjustRelation?.(a, 0.65, -0.20, "co_parent");
a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28);
b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28);
const childKey = this.tarinaiFamilyKey(child);
const aKey = this.tarinaiFamilyKey(a);
const bKey = this.tarinaiFamilyKey(b);
a.children.push(childKey);
b.children.push(childKey);
this.recordFamily(a);
this.recordFamily(b);
this.recordFamily(child);
if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey);
if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey);
this.familyCleanVersion = null;
this.lastBirthAt = this.time;
if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] });
return child;
},
2026-06-24 13:48:22 +09:00
fightPairKey(a, b) {
const aid = a?.id || a?.familyKey || "";
const bid = b?.id || b?.familyKey || "";
if (!aid || !bid) return "";
return String(aid) < String(bid) ? `${aid}:${bid}` : `${bid}:${aid}`;
},
fightPairCooldownRemaining(a, b) {
const key = this.fightPairKey?.(a, b);
if (!key) return 0;
const until = Number(this.fightPairCooldowns?.[key] || 0) || 0;
return Math.max(0, until - (this.time || 0));
},
markFightPairCooldown(a, b, seconds = 3.2) {
const key = this.fightPairKey?.(a, b);
if (!key) return;
this.fightPairCooldowns = this.fightPairCooldowns || {};
this.fightPairCooldowns[key] = Math.max(Number(this.fightPairCooldowns[key] || 0) || 0, (this.time || 0) + Math.max(0.2, Number(seconds) || 3.2));
},
isAlreadyFightingPair(a, b) {
if (!a || !b) return false;
return a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id) && ((b.fightTargetIds || []).includes(a.id) || b.fightTargetId === a.id);
},
2026-06-21 14:09:35 +09:00
startForcedFight(a, b) {
if (!a || !b || a === b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b)) return false;
2026-06-24 13:48:22 +09:00
const alreadyFighting = this.isAlreadyFightingPair?.(a, b);
2026-06-21 14:09:35 +09:00
if (alreadyFighting) return true;
2026-06-24 13:48:22 +09:00
if ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
2026-06-25 22:30:35 +09:00
const maxForcedFightDistance = Math.max(142, (a.radius || 20) + (b.radius || 20) + 96);
if (dist(a, b) > maxForcedFightDistance) return false;
if ((a.fightCooldown || 0) > 0.04 || (b.fightCooldown || 0) > 0.04) return false;
2026-06-21 14:09:35 +09:00
for (const t of [a, b]) {
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
t.birthPartnerId = null;
t.birthRitualLeader = false;
t.birthRitualRole = 0;
t.postBirthPeaceTimer = 0;
t.intimidateTimer = 0;
t.intimidateTargetId = null;
t.intimidatedTimer = 0;
t.defeatedTimer = 0;
t.defeatedById = null;
t.fightWinnerId = null;
2026-06-22 02:03:19 +09:00
t.goIdle("");
2026-06-21 14:09:35 +09:00
}
this.startFightMochiIntimidation(a, b);
2026-06-24 13:48:22 +09:00
this.startFight(a, b, { forced: true, initiator: a });
2026-06-21 14:09:35 +09:00
return true;
},
startFightMochiIntimidation(a, b) {
if (!a || !b || a.dead || b.dead) return false;
const aDrug = (a.fightMochiTimer || 0) > 0.04;
const bDrug = (b.fightMochiTimer || 0) > 0.04;
const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b));
const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return false;
2026-06-27 19:22:38 +09:00
actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, deterministicRange(this, "fight-mochi-intimidate-actor-timer", 0.85, 1.35, actor, target));
2026-06-21 14:09:35 +09:00
actor.intimidateTargetId = target.id;
actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28);
2026-06-27 19:22:38 +09:00
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "fight-mochi-intimidate-target-timer", 0.75, 1.25, actor, target));
2026-06-21 14:09:35 +09:00
target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear);
actor.adjustRelation(target, -0.05, 0.06, "intimidate");
target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate");
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.34,
size: actor.radius * 0.62,
color: "rgba(154, 88, 42, 0.74)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)");
return true;
},
startConflict(a, b) {
2026-06-24 13:48:22 +09:00
if (!a || !b || a.dead || b.dead) return false;
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return false;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) return this.startForcedFight(a, b);
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
2026-06-30 13:40:36 +09:00
if ((a.postConflictPeaceTimer || 0) > 0.04 || (b.postConflictPeaceTimer || 0) > 0.04) return false;
2026-06-24 13:48:22 +09:00
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false;
2026-06-21 14:09:35 +09:00
const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
2026-06-27 19:22:38 +09:00
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-a", -5, 5, a, b);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-b", -5, 5, a, b);
2026-06-21 14:09:35 +09:00
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
2026-06-24 13:48:22 +09:00
if (!this.canFightPair(actor, target)) return false;
2026-06-21 14:09:35 +09:00
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
2026-06-30 10:57:06 +09:00
const intimidationChance = 0.30;
2026-06-27 19:22:38 +09:00
if (canIntimidate && deterministicChance(this, "conflict-intimidation-choice", intimidationChance, actor, target)) {
2026-06-24 13:48:22 +09:00
return this.startIntimidation(actor, target, actorScore, targetScore);
2026-06-21 14:09:35 +09:00
}
2026-06-24 13:48:22 +09:00
return this.startFight(actor, target, { initiator: actor });
2026-06-21 14:09:35 +09:00
},
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
if (!actor || !target || actor.dead || target.dead) return false;
2026-06-24 13:48:22 +09:00
if ((actor.fightTimer || 0) > 0.04 || (target.fightTimer || 0) > 0.04) return false;
if ((actor.intimidateTimer || 0) > 0.04 || (target.intimidateTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(actor, target) > 0.04) return false;
2026-06-21 14:09:35 +09:00
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
2026-06-23 22:39:20 +09:00
const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8;
2026-06-21 22:29:00 +09:00
const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0);
const targetAggression = Math.max(0, target.currentPersonality?.aggression || 0);
const actorSize = actor.effectiveScale ? actor.effectiveScale() : (actor.scale || 0.28);
const targetSize = target.effectiveScale ? target.effectiveScale() : (target.scale || 0.28);
const sizeEdge = clamp((actorSize - targetSize) / 0.16, -1, 1);
const aggressionEdge = clamp(actorAggression - targetAggression * 0.55, -1, 1);
2026-06-27 19:22:38 +09:00
const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + deterministicRange(this, "intimidation-resistance", -5, 5, actor, target);
2026-06-21 22:29:00 +09:00
const successChance = clamp(0.56 + (actorScore - targetScore + fearLoad - resistance) / 82 + aggressionEdge * 0.13 + sizeEdge * 0.16, 0.30, 0.96);
2026-06-21 14:09:35 +09:00
actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
2026-06-27 19:22:38 +09:00
actor.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-actor-cooldown", 1, 4, actor, target);
target.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-target-cooldown", 1, 4, actor, target);
actor.intimidateTimer = Math.max(actor.intimidateTimer, deterministicRange(this, "intimidation-actor-timer", 1.05, 1.65, actor, target));
2026-06-21 14:09:35 +09:00
actor.intimidateTargetId = target.id;
2026-06-24 13:48:22 +09:00
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
2026-06-29 16:21:58 +09:00
setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
2026-06-21 14:09:35 +09:00
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42,
size: actor.radius * 0.55,
color: "rgba(145, 106, 55, 0.70)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
2026-06-27 19:22:38 +09:00
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.95, 1.55, actor, target));
2026-06-21 14:09:35 +09:00
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
2026-06-27 19:22:38 +09:00
if (!deterministicChance(this, "intimidation-success", successChance, actor, target)) {
2026-06-21 22:29:00 +09:00
actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation");
2026-06-21 14:09:35 +09:00
target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate");
2026-06-21 22:29:00 +09:00
actor.intimidateTimer = 0;
actor.intimidateTargetId = null;
target.intimidatedTimer = 0;
2026-06-24 13:48:22 +09:00
if (actor.state === "intimidate") actor.goIdle("\u5a01\u5687\u306b\u5931\u6557\u3057\u305f");
if (target.state === "panic") target.goIdle("\u5a01\u5687\u304b\u3089\u623b\u3063\u305f");
2026-06-21 22:29:00 +09:00
actor.spriteLockUntil = 0;
target.spriteLockUntil = 0;
2026-06-24 13:48:22 +09:00
this.startFight(actor, target, { initiator: actor });
2026-06-21 14:09:35 +09:00
return true;
}
2026-06-21 22:29:00 +09:00
actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation");
2026-06-21 14:09:35 +09:00
target.defeatedById = actor.id;
target.fightWinnerId = actor.id;
2026-06-27 19:22:38 +09:00
target.defeatedTimer = Math.max(target.defeatedTimer, deterministicRange(this, "intimidation-defeated-timer", 1.8, 2.8, actor, target));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-success-timer", 1.2, 2.0, actor, target));
2026-06-24 13:48:22 +09:00
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); }
2026-06-21 14:09:35 +09:00
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
2026-06-27 19:22:38 +09:00
target.vx += (target.x < actor.x ? -1 : 1) * deterministicRange(this, "intimidation-knockback-x", 26, 54, actor, target);
target.vy += deterministicRange(this, "intimidation-knockback-y", -18, 18, actor, target);
2026-06-21 14:09:35 +09:00
actor.adjustRelation(target, -0.08, 0.06, "intimidate");
target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate");
if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) {
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] });
actor.lastLog = target.lastLog = this.time;
}
2026-06-24 13:48:22 +09:00
this.markFightPairCooldown?.(actor, target, 2.8);
2026-06-21 14:09:35 +09:00
return true;
},
2026-06-24 13:48:22 +09:00
startFight(a, b, opts = {}) {
if (!a || !b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
2026-06-30 13:40:36 +09:00
if ((a.postConflictPeaceTimer || 0) > 0.04 || (b.postConflictPeaceTimer || 0) > 0.04) return false;
2026-06-24 13:48:22 +09:00
if (this.isAlreadyFightingPair?.(a, b)) return true;
const allowForcedStart = Boolean(opts?.forced);
if (!allowForcedStart && ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04 || (a.intimidateTimer || 0) > 0.04 || (b.intimidateTimer || 0) > 0.04)) return false;
if (!allowForcedStart && this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
2026-06-25 22:30:35 +09:00
const maxStartFightDistance = Math.max(150, (a.radius || 20) + (b.radius || 20) + 104);
if (dist(a, b) > maxStartFightDistance) return false;
this.markFightPairCooldown?.(a, b, CONFIG.fightCooldown + 6);
2026-06-24 13:48:22 +09:00
const initiator = opts?.initiator === b ? b : a;
const receiver = initiator === a ? b : a;
const causeFor = (self, other) => {
2026-06-29 16:21:58 +09:00
if (((currentTarinaiBehavior(self))?.source === "fight_mochi") || (self.fightMochiTimer || 0) > 0.04) return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
2026-06-24 13:48:22 +09:00
if (self === receiver && other === initiator) return `${other.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
return `${other.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
};
2026-06-29 16:21:58 +09:00
const textFor = (self, other) => buildReasonText("social", getTarinaiBehaviorTiedNeeds(self, "social") || ["social"], { id: "fight_rival", need: "social", subNeed: "conflict", label: self === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` }, self, this, { causeText: causeFor(self, other) });
2026-06-24 13:48:22 +09:00
const reasonA = textFor(a, b);
const reasonB = textFor(b, a);
a.counterAttackFromId = a === receiver ? initiator.id : null;
b.counterAttackFromId = b === receiver ? initiator.id : null;
a.setActionState("fight", { target: b, reason: reasonA });
b.setActionState("fight", { target: a, reason: reasonB });
2026-06-29 16:21:58 +09:00
setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
2026-06-27 19:22:38 +09:00
a.fightTimer = Math.max(a.fightTimer, deterministicRange(this, "fight-start-timer-a", 3.2, 4.8, a, b));
b.fightTimer = Math.max(b.fightTimer, deterministicRange(this, "fight-start-timer-b", 3.2, 4.8, a, b));
a.nextHeadbutt = this.time + deterministicRange(this, "fight-start-headbutt", 0.08, 0.18, a, b);
2026-06-21 14:09:35 +09:00
b.nextHeadbutt = a.nextHeadbutt;
2026-06-27 19:22:38 +09:00
a.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-a", 1, 5, a, b);
b.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-b", 1, 5, a, b);
2026-06-21 14:09:35 +09:00
if (!a.fightTargetIds) a.fightTargetIds = [];
if (!b.fightTargetIds) b.fightTargetIds = [];
if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id);
if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id);
a.fightTargetIds = a.fightTargetIds.slice(-4);
b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.fightTargetIds[0];
2026-06-27 19:22:38 +09:00
if (a.addStress) a.addStress(deterministicRange(this, "fight-start-stress-a", 8, 18, a, b), { threshold: 8 });
if (b.addStress) b.addStress(deterministicRange(this, "fight-start-stress-b", 8, 18, a, b), { threshold: 8 });
2026-06-30 13:40:36 +09:00
// Damage is applied by repeated headbutt pulses while the fight is active.
// Do not front-load or end-load a lump sum when the fight state starts/settles.
2026-06-21 14:09:35 +09:00
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1;
2026-06-27 19:22:38 +09:00
a.vx -= dx / d * deterministicRange(this, "fight-pulse-knockback-ax", 32, 62, a, b); a.vy -= dy / d * deterministicRange(this, "fight-pulse-knockback-ay", 32, 62, a, b);
b.vx += dx / d * deterministicRange(this, "fight-pulse-knockback-bx", 32, 62, a, b); b.vy += dy / d * deterministicRange(this, "fight-pulse-knockback-by", 32, 62, a, b);
2026-06-21 14:09:35 +09:00
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
2026-06-27 19:22:38 +09:00
size: deterministicRange(this, "fight-pulse-effect-size", 12, 20, a, b),
life: deterministicRange(this, "fight-pulse-effect-life", 0.32, 0.52, a, b),
2026-06-21 14:09:35 +09:00
color: "rgba(116, 73, 38, 0.82)",
}));
audio.fight();
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
2026-06-25 14:51:58 +09:00
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
2026-06-21 14:09:35 +09:00
a.lastLog = b.lastLog = this.time;
}
2026-06-24 13:48:22 +09:00
return true;
2026-06-21 14:09:35 +09:00
}
}));
})(typeof window !== "undefined" ? window : globalThis);