tarinai/js/fire_runtime_system.js
2026-07-09 16:08:11 +09:00

620 lines
29 KiB
JavaScript

"use strict";
// Runtime behavior for fire, firecracker, flame spread, and fire contact effects.
(function (global) {
function updateFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
else item.amount = 0;
}
}
const FIRE_BURNING_TEXT = "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
const FIRE_SEEN_TEXT = "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
const FIRE_CONTACT_INTERVAL = 0.35;
const FIRE_PANIC_INTERVAL = 1.20;
const FIRE_WATER_INTERVAL = 0.16;
const FIRE_CLUSTER_SCAN_INTERVAL = 0.45;
const FIRE_INTERACTION_BUDGET_DESKTOP = 18;
const FIRE_INTERACTION_BUDGET_MOBILE = 10;
const FIRE_MAX_ACTIVE_DESKTOP = 72;
const FIRE_MAX_ACTIVE_MOBILE = 44;
const GRASS_FIRE_EXTINGUISH_RATE = 0.56;
const NEST_BOX_FIRE_EVACUATE_AFTER = 10.0;
const NEST_BOX_FIRE_MIN_LIFE = 14.0;
const NEST_BOX_FIRE_WET_REIGNITE_DELAY = 1.65;
const NEST_BOX_FIRE_WATER_INTERVAL = 0.16;
const NEST_BOX_FIRE_EFFECT_INTERVAL = 0.28;
function fireTempMultiplier(worldRef, x, y, entity = null) {
const weather = String(worldRef?.weather || "");
const temp = Number(worldRef?.temperatureAt?.(x, y, entity) ?? worldRef?.feltTemperatureFor?.(entity) ?? worldRef?.temperature ?? global.CONFIG?.standardTemperature ?? 15);
let mul = 1;
if (weather === "light_rain") mul *= 2;
if (Number.isFinite(temp) && temp <= 10) mul *= 2;
return mul;
}
function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
if (!worldRef?.effects || typeof Effect !== "function") return;
if ((worldRef.effects.length || 0) > 180) return;
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
worldRef.effects.push(new Effect("flame", x, y, {
vx: Math.cos(a) * speed * 0.35,
vy: -Math.max(4, speed * 0.65),
size: Math.max(4, size * 0.58),
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
color: "rgba(236,46,24,0.78)",
}));
}
function fireInteractionBudget(worldRef) {
const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60);
if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
worldRef._fireInteractionBudget = {
frame,
remaining: mobile ? FIRE_INTERACTION_BUDGET_MOBILE : FIRE_INTERACTION_BUDGET_DESKTOP,
};
}
return worldRef._fireInteractionBudget;
}
function useFireInteractionBudget(worldRef, cost = 1) {
const budget = fireInteractionBudget(worldRef);
if (!budget || budget.remaining < cost) return false;
budget.remaining -= cost;
return true;
}
function contactWaterDrop(worldRef, x, y, radius = 16) {
if (!worldRef) return null;
const list = typeof worldRef.nearbyItems === "function" ? [...(worldRef.nearbyItems(x, y, radius + 28, true) || [])] : (worldRef.items || []);
for (const it of list) {
if (!it || it.dead || it.type !== "water") continue;
const rr = Math.max(radius, 10) + Math.max(it.r || 10, 10);
if (distXY(x, y, it.x, it.y) <= rr) return it;
}
return null;
}
function consumeWaterForFire(worldRef, water, amount = 8) {
if (!water) return false;
water.amount = Math.max(0, Number(water.amount || 0) - amount);
worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }));
worldRef?.markItemBucketsDirty?.("fire-water");
worldRef?.markSpatialDirty?.("fire-water");
return true;
}
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
if (!t || t.dead) return false;
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
t.burning = true;
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
t.thought = FIRE_BURNING_TEXT;
t.lastPanicCause = "burning";
t.lastPanicDetail = FIRE_BURNING_TEXT;
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
t.sleeping = false;
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
return true;
}
function panicTarinaiFromFire(t, source = null, worldRef = null) {
if (!t || t.dead || t.burning || (t.burnTimer || 0) > 0.02) return false;
const now = Number(worldRef?.time || t.world?.time || 0) || 0;
if (now < Number(t.firePanicCooldownUntil || 0)) return false;
t.firePanicCooldownUntil = now + 0.85;
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
t.lastPanicCause = "fire_seen";
t.lastPanicDetail = FIRE_SEEN_TEXT;
t.thought = FIRE_SEEN_TEXT;
if (typeof t.enterPanic === "function") t.enterPanic({ threat: source, target: source, reason: FIRE_SEEN_TEXT, fear: 1.25, wake: true, cause: "fire_seen", surpriseTimer: 0.55, awakeLockTimer: 0.35 });
else { t.fearTimer = Math.max(t.fearTimer || 0, 1.25); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_SEEN_TEXT, wake: true }); }
return true;
}
function igniteAntByFire(ant, source = null, worldRef = null) {
if (!ant || ant.dead) return false;
if (typeof ant.igniteFire === "function") return ant.igniteFire(source);
ant.burning = true;
ant.burnTimer = Math.max(ant.burnTimer || 0, 5.0);
ant.state = "panic";
ant.targetId = "";
ant.targetRef = null;
return true;
}
function isIgnitableNestBox(item) {
return Boolean(item && !item.dead && item.type === "nest_box");
}
function nestBoxFireRadius(box) {
return Math.max(30, (Number(box?.r || 42) || 42) * 1.36);
}
function nestBoxFireActive(box) {
return Boolean(isIgnitableNestBox(box) && (box.burning || (box.burnTimer || 0) > 0.02 || (box.nestFireEvacuationTimer || 0) > 0.02));
}
function igniteNestBoxByFire(box, source = null, worldRef = null) {
if (!isIgnitableNestBox(box)) return false;
const now = Number(worldRef?.time || box.world?.time || 0) || 0;
if (now < Number(box.nestFireWetUntil || 0)) return false;
const already = nestBoxFireActive(box);
box.burning = true;
box.burnTimer = Math.max(Number(box.burnTimer || 0) || 0, NEST_BOX_FIRE_MIN_LIFE);
if (!already) {
box.nestFireStartedAt = now;
box.nestFireEvacuationTimer = NEST_BOX_FIRE_EVACUATE_AFTER;
box.nestFireEvacuated = false;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
worldRef?.log?.("巣箱に火が燃え移った。", "danger");
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" }));
}
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire");
worldRef?.markItemBucketsDirty?.("nest-box-fire");
worldRef?.markSpatialDirty?.("nest-box-fire");
if (worldRef) worldRef.drawListDirty = true;
return !already;
}
function extinguishNestBoxFire(box, worldRef = null, reason = "water") {
if (!isIgnitableNestBox(box) || !nestBoxFireActive(box)) return false;
box.burning = false;
box.burnTimer = 0;
box.nestFireEvacuationTimer = 0;
box.nestFireEvacuated = false;
box.nestFireStartedAt = 0;
box.nestFireWetUntil = reason === "water" ? ((Number(worldRef?.time || box.world?.time || 0) || 0) + NEST_BOX_FIRE_WET_REIGNITE_DELAY) : 0;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" }));
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire-extinguished");
worldRef?.markItemBucketsDirty?.("nest-box-fire-extinguished");
worldRef?.markSpatialDirty?.("nest-box-fire-extinguished");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function evacuateBurningNestBox(box, worldRef = null, reason = "nest-box-fire") {
if (!isIgnitableNestBox(box) || !worldRef || box.nestFireEvacuated) return 0;
const occupants = global.TarinaiNestSleepSystem?.nestBoxOccupants
? global.TarinaiNestSleepSystem.nestBoxOccupants(worldRef, box, Infinity)
: (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
box.nestFireEvacuated = true;
let count = 0;
const now = Number(worldRef?.time || box.world?.time || 0) || 0;
for (const t of occupants || []) {
if (!t || t.dead) continue;
t._burningNestExitBoxId = box.id || t._burningNestExitBoxId || "";
t._burningNestExitUntil = Math.max(Number(t._burningNestExitUntil || 0) || 0, now + 1.8);
t.leaveNestBox?.(box);
igniteTarinaiByFire(t, box, worldRef, true);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 2.4);
count += 1;
}
if (count) {
worldRef.log?.(`炎上した巣箱から${count}匹のたりないが追い出された。`, "danger", { participants: occupants });
worldRef.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" }));
worldRef.markSpatialDirty?.(reason);
worldRef.drawListDirty = true;
}
return count;
}
function burnOutNestBox(box, worldRef = null, reason = "burnout") {
if (!isIgnitableNestBox(box)) return false;
if (!box.nestFireEvacuated) evacuateBurningNestBox(box, worldRef, "nest-box-fire-burnout-evacuate");
box.burning = false;
box.burnTimer = 0;
box.nestFireEvacuationTimer = 0;
box.nestFireEvacuated = true;
box.nestFireStartedAt = 0;
box.nestFireWetUntil = 0;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
box.amount = 0;
if (box.isStructure) {
box.hp = 0;
box.dead = true;
}
worldRef?.log?.("巣箱が焼失した。", "danger");
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" }));
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-burnout");
worldRef?.markItemBucketsDirty?.("nest-box-burnout");
worldRef?.markSpatialDirty?.("nest-box-burnout");
worldRef?.compactItems?.();
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function updateNestBoxFire(box, dt, worldRef) {
if (!isIgnitableNestBox(box) || !nestBoxFireActive(box) || !worldRef) return false;
dt = Math.max(0, Math.min(0.24, Number(dt || 0) || 0));
box.burning = true;
box.burnTimer = Math.max(0, Number(box.burnTimer || 0) - dt);
box.nestFireEvacuationTimer = Math.max(0, Number(box.nestFireEvacuationTimer || 0) - dt);
box.flameWaterCooldown = Math.max(0, Number(box.flameWaterCooldown || 0) - dt);
box.nestFireEffectCooldown = Math.max(0, Number(box.nestFireEffectCooldown || 0) - dt);
if (box.flameWaterCooldown <= 0) {
box.flameWaterCooldown = NEST_BOX_FIRE_WATER_INTERVAL;
const water = contactWaterDrop(worldRef, box.x, box.y, nestBoxFireRadius(box));
if (water) {
consumeWaterForFire(worldRef, water, Math.max(12, (box.r || 42) * 0.34));
extinguishNestBoxFire(box, worldRef, "water");
return true;
}
}
if (!box.nestFireEvacuated && box.nestFireEvacuationTimer <= 0) {
evacuateBurningNestBox(box, worldRef, "nest-box-fire-evacuate");
}
if (box.nestFireEffectCooldown <= 0) {
box.nestFireEffectCooldown = NEST_BOX_FIRE_EFFECT_INTERVAL;
pushFireEffect(worldRef, box.x, box.y - (box.r || 42) * 0.35, Math.max(13, (box.r || 42) * 0.40), box);
}
if (box.burnTimer <= 0.01) {
burnOutNestBox(box, worldRef, "burnout");
return true;
}
worldRef.drawListDirty = true;
return true;
}
function grassFireExtinguishChance(worldRef, grass, dt = 0.016) {
const baseDt = Math.max(Number(dt || 0) || 0, FIRE_CONTACT_INTERVAL);
const weather = String(worldRef?.weather || "");
const fresh = clamp(Number(grass?.growth ?? grass?.health ?? 0.65) || 0, 0, 1);
const rainMul = weather === "light_rain" ? 1.75 : 1;
return clamp(baseDt * GRASS_FIRE_EXTINGUISH_RATE * rainMul * (1 + fresh * 0.45), 0, 0.58);
}
function tryGrassExtinguishFire(grass, fire, worldRef = null, dt = 0.016) {
if (!grass || grass.dead || grass.type !== "grass" || !fire || fire.dead || fire.type !== "fire") return false;
const chance = grassFireExtinguishChance(worldRef, grass, dt);
if (!global.deterministicChance(worldRef, "grass-fire-extinguish", chance, grass, fire)) return false;
const reduction = Math.max(18, Math.max(grass.r || 16, 12) * 0.72 + Math.max(fire.r || 14, 12) * 0.52);
fire.amount = Math.max(0, Number(fire.amount || 0) - reduction);
fire.flameLife = Math.max(0, Number(fire.flameLife || 0) - 0.85);
grass.health = clamp((grass.health ?? 1) - 0.035, 0, 1);
if ((fire.amount || 0) <= 24 || (fire.flameLife || 0) <= 0.35) fire.amount = 0;
worldRef?.effects?.push(new Effect("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" }));
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 28), "grass-extinguish-fire");
worldRef?.markItemBucketsDirty?.("grass-extinguish-fire");
worldRef?.markSpatialDirty?.("grass-extinguish-fire");
worldRef.drawListDirty = true;
return true;
}
function igniteGrassByFire(grass, source = null, worldRef = null, dt = 0.016) {
if (!grass || grass.dead || grass.type !== "grass") return false;
grass.burning = true;
grass.burnTimer = Math.max(grass.burnTimer || 0, 2.8);
grass.health = clamp((grass.health ?? 1) - dt * 0.30, 0, 1);
grass.amount = Math.max(0, Number(grass.amount || 0) - dt * 18);
if (grass.health <= 0.02 || grass.amount <= 0.5) grass.amount = 0;
if (worldRef && (worldRef.time || 0) > Number(grass.nextFireSpreadAt || -Infinity)) {
grass.nextFireSpreadAt = (worldRef.time || 0) + 1.6;
if (typeof worldRef.spawnFireAt === "function") {
const a = global.deterministicAngle(worldRef, "grass-fire-spread", grass, source);
worldRef.spawnFireAt(grass.x + Math.cos(a) * (grass.r || 14) * 0.55, grass.y + Math.sin(a) * (grass.r || 14) * 0.35, { amount: 60, radius: 12, life: 4.2, source: grass });
}
}
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 26), "grass-fire");
return true;
}
function igniteZunchiByFire(zunchi, source = null, worldRef = null, dt = 0.016) {
if (!zunchi || zunchi.dead || zunchi.type !== "zunchi") return false;
const wasBurning = Boolean(zunchi.burning || (zunchi.burnTimer || 0) > 0.02);
zunchi.burning = true;
zunchi.burnTimer = Math.max(Number(zunchi.burnTimer || 0) || 0, 4.6);
zunchi.spawnGrace = 0;
zunchi.freshness = clamp((zunchi.freshness ?? 1) - dt * 0.55, 0, 1);
zunchi.amount = Math.max(0, Number(zunchi.amount || 0) - dt * 34);
zunchi.stageTimer = Math.max(0, Number(zunchi.stageTimer || 0) + dt * 2.4);
if (!wasBurning) {
pushFireEffect(worldRef, zunchi.x, zunchi.y - Math.max(5, (zunchi.r || 14) * 0.2), Math.max(10, zunchi.r || 14), zunchi);
}
if ((zunchi.amount || 0) <= 0.5) zunchi.amount = 0;
worldRef?.markTerrainDirtyAt?.(zunchi.x, zunchi.y, Math.max(zunchi.r || 15, 28), "zunchi-fire");
worldRef?.markItemBucketsDirty?.("zunchi-fire");
worldRef?.markSpatialDirty?.("zunchi-fire");
worldRef.drawListDirty = true;
return true;
}
function updateFireContacts(item, dt, worldRef) {
if (!item || !worldRef) return false;
const r = Math.max(14, Number(item.r || 0) || 14);
const spreadRadius = r + 16;
let changed = false;
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, spreadRadius + 32, true) || []) {
if (!t || t.dead || t.insideNestBoxId || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(item.x, item.y, t.x, t.y);
if (d <= spreadRadius + Math.max(t.radius || 18, 12) * 0.72) changed = igniteTarinaiByFire(t, item, worldRef, false) || changed;
}
for (const ant of worldRef.nearbyAnts?.(item.x, item.y, spreadRadius + 20, true) || []) {
if (!ant || ant.dead) continue;
const d = distXY(item.x, item.y, ant.x, ant.y);
if (d <= spreadRadius + Math.max(ant.r || 5, 4)) changed = igniteAntByFire(ant, item, worldRef) || changed;
}
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(item.x, item.y, spreadRadius + 38, true) || []) : (worldRef.items || []);
for (const it of nearby) {
if (!it || it.dead || it === item) continue;
if (it.type === "grass" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 16, 12)) {
const extinguished = tryGrassExtinguishFire(it, item, worldRef, dt);
changed = extinguished || changed;
if (!extinguished && (item.amount || 0) > 0) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
}
if (it.type === "zunchi" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 15, 12)) changed = igniteZunchiByFire(it, item, worldRef, dt) || changed;
if (it.type === "nest_box" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 42, 28) * 1.15) changed = igniteNestBoxByFire(it, item, worldRef) || changed;
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
consumeWaterForFire(worldRef, it, Math.max(6, r * 0.45));
item.amount = 0;
changed = true;
}
}
return changed;
}
function panicFromNearbyFire(item, worldRef) {
if (!item || !worldRef) return;
const r = Math.max(170, (item.r || 14) + 170);
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, r, true) || []) {
if (!t || t.dead || t.insideNestBoxId || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
panicTarinaiFromFire(t, item, worldRef);
}
}
function updateFire(item, dt, worldRef) {
if (!item || item.dead || !worldRef) return false;
dt = Math.max(0, Math.min(0.08, Number(dt || 0) || 0));
item.amount = Math.max(0, Number(item.amount || 0) - dt * 4.2);
item.flameLife = Math.max(0, Number(item.flameLife ?? 8.0) - dt);
item.flameSpreadCooldown = Math.max(0, Number(item.flameSpreadCooldown || 0) - dt);
item.flamePanicCooldown = Math.max(0, Number(item.flamePanicCooldown || 0) - dt);
item.flameWaterCooldown = Math.max(0, Number(item.flameWaterCooldown || 0) - dt);
item.flameClusterCooldown = Math.max(0, Number(item.flameClusterCooldown || 0) - dt);
item.vx = 0;
item.vy = 0;
const lowFire = (item.amount || 0) < 24 || item.flameLife < 1.2;
if (item.flameWaterCooldown <= 0) {
item.flameWaterCooldown = FIRE_WATER_INTERVAL;
const water = contactWaterDrop(worldRef, item.x, item.y, Math.max(item.r || 14, 12));
if (water) {
consumeWaterForFire(worldRef, water, Math.max(8, (item.r || 14) * 0.6));
item.amount = 0;
return true;
}
}
const chance = dt * 0.012 * fireTempMultiplier(worldRef, item.x, item.y, item);
if (global.deterministicChance(worldRef, "fire-random-extinguish", chance, item)) {
item.amount = 0;
return true;
}
if (item.flameLife <= 0) item.amount = Math.min(item.amount, 1);
if (item.flameClusterCooldown <= 0) {
mergeNearbyFires(worldRef, item, Math.max(24, (item.r || 14) * 2.4), 4);
item.flameClusterCooldown = FIRE_CLUSTER_SCAN_INTERVAL;
}
if (!lowFire && item.flameSpreadCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
updateFireContacts(item, dt, worldRef);
item.flameSpreadCooldown = FIRE_CONTACT_INTERVAL;
}
if (!lowFire && item.flamePanicCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
panicFromNearbyFire(item, worldRef);
item.flamePanicCooldown = FIRE_PANIC_INTERVAL;
}
if (!lowFire && global.deterministicChance(worldRef, "fire-effect", dt * 0.72, item)) pushFireEffect(worldRef, item.x, item.y - (item.r || 14) * 0.2, Math.max(8, (item.r || 14) * 0.9), item);
return true;
}
function mergeNearbyFires(worldRef, fire, radius = 36, maxMerges = 4) {
if (!worldRef || !fire || fire.dead) return 0;
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(fire.x, fire.y, radius, true) || []) : (worldRef.items || []);
let merged = 0;
for (const other of nearby) {
if (!other || other === fire || other.dead || other.type !== "fire") continue;
if (distXY(fire.x, fire.y, other.x, other.y) > radius + Math.max(other.r || 14, 12)) continue;
fire.amount = Math.min(260, Math.max(fire.amount || 0, other.amount || 0) + Math.min(34, Math.max(0, Number(other.amount || 0) * 0.35)));
fire.flameLife = Math.max(fire.flameLife || 0, other.flameLife || 0);
fire.r = Math.min(42, Math.max(fire.r || 14, other.r || 14));
other.amount = 0;
merged += 1;
if (merged >= maxMerges) break;
}
if (merged) {
worldRef.markItemBucketsDirty?.("fire-merge");
worldRef.markSpatialDirty?.("fire-merge");
worldRef.drawListDirty = true;
}
return merged;
}
function activeFireLimit() {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
return mobile ? FIRE_MAX_ACTIVE_MOBILE : FIRE_MAX_ACTIVE_DESKTOP;
}
function activeFires(worldRef) {
return worldRef?.itemsOfType?.("fire") || (worldRef?.items || []).filter(it => it && !it.dead && it.type === "fire");
}
function foldIntoExistingFire(worldRef, x, y, opts = {}) {
const fires = activeFires(worldRef);
if (!fires.length) return null;
let best = null;
let bestD = Infinity;
const exclude = opts.exclude || null;
const searchRadius = Math.max(96, Number(opts.mergeRadius || opts.radius || 48) * 5.0);
const candidates = typeof worldRef?.nearbyItems === "function" ? (worldRef.nearbyItems(x, y, searchRadius, true) || []) : fires;
const scan = candidates.length ? candidates : fires;
for (const fire of scan) {
if (!fire || fire.dead || fire.type !== "fire") continue;
if (exclude && fire === exclude) continue;
const d = distXY(x, y, fire.x, fire.y);
if (d < bestD) { best = fire; bestD = d; }
}
if (!best) return null;
best.amount = Math.min(280, Math.max(Number(best.amount || 0), Number(opts.amount || 0) * 0.45) + 14);
best.flameLife = Math.max(Number(best.flameLife || 0), Math.max(1.0, Number(opts.life || 3.5) * 0.75));
best.r = Math.min(44, Math.max(best.r || 14, Number(opts.radius || 14)));
best.vx = 0;
best.vy = 0;
worldRef.drawListDirty = true;
return best;
}
function compactWorldFires(worldRef, opts = {}) {
const fires = activeFires(worldRef).filter(fire => fire && !fire.dead);
const limit = Math.max(8, Number(opts.limit || activeFireLimit()) || activeFireLimit());
if (fires.length <= limit) return 0;
const cellSize = Math.max(46, Number(opts.cellSize || 72) || 72);
const cells = new Map();
for (const fire of fires) {
const key = `${Math.floor((fire.x || 0) / cellSize)}:${Math.floor((fire.y || 0) / cellSize)}`;
let group = cells.get(key);
if (!group) {
group = [];
cells.set(key, group);
}
group.push(fire);
}
let removed = 0;
for (const group of cells.values()) {
if (group.length <= 1) continue;
group.sort((a, b) => ((b.amount || 0) + (b.flameLife || 0) * 8 + (b.r || 0)) - ((a.amount || 0) + (a.flameLife || 0) * 8 + (a.r || 0)));
const keep = group[0];
for (let i = 1; i < group.length; i += 1) {
const fire = group[i];
keep.amount = Math.min(300, Math.max(keep.amount || 0, fire.amount || 0) + Math.min(18, Math.max(0, Number(fire.amount || 0) * 0.20)));
keep.flameLife = Math.max(keep.flameLife || 0, fire.flameLife || 0);
keep.r = Math.min(46, Math.max(keep.r || 14, fire.r || 14));
fire.amount = 0;
removed += 1;
}
}
if (fires.length - removed > limit) {
const survivors = fires.filter(fire => !fire.dead).sort((a, b) => ((a.amount || 0) + (a.flameLife || 0) * 8) - ((b.amount || 0) + (b.flameLife || 0) * 8));
let remaining = survivors.length;
for (const fire of survivors) {
if (remaining <= limit) break;
if (fire.amount <= 0) continue;
foldIntoExistingFire(worldRef, fire.x, fire.y, { ...fire, exclude: fire });
fire.amount = 0;
removed += 1;
remaining -= 1;
}
}
if (removed) {
worldRef.compactItems?.();
worldRef.markItemBucketsDirty?.("fire-compact");
worldRef.markSpatialDirty?.("fire-compact");
worldRef.drawListDirty = true;
}
return removed;
}
function spawnFireAt(worldRef, x, y, opts = {}) {
if (!worldRef || typeof Item !== "function") return null;
const currentFires = activeFires(worldRef);
if (currentFires.length >= activeFireLimit()) return foldIntoExistingFire(worldRef, x, y, opts);
const mergeRadius = Math.max(70, Number(opts.mergeRadius || opts.radius || 16) * 4.5);
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(x, y, mergeRadius, true) || []) : (worldRef.items || []);
for (const existing of nearby) {
if (!existing || existing.dead || existing.type !== "fire") continue;
if (distXY(x, y, existing.x, existing.y) > mergeRadius + Math.max(existing.r || 14, 12)) continue;
existing.amount = Math.min(260, Math.max(Number(existing.amount || 0), Number(opts.amount || 0) * 0.55) + 18);
existing.flameLife = Math.max(Number(existing.flameLife || 0), Math.max(1.2, Number(opts.life || 4.0) * 0.85));
existing.r = Math.min(42, Math.max(existing.r || 14, Number(opts.radius || 14)));
existing.vx = 0;
existing.vy = 0;
worldRef.drawListDirty = true;
return existing;
}
const fire = new Item("fire", x, y);
const scale = Math.max(0.45, Math.min(2.0, Number(opts.scale || 1) || 1));
fire.r = Math.max(9, Number(opts.radius || fire.r || 14) * scale);
fire.amount = Math.max(12, Number(opts.amount || fire.amount || 100) * scale);
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
fire.vx = 0;
fire.vy = 0;
const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null;
if (!addedFire) return null;
worldRef.drawListDirty = true;
return addedFire;
}
function scatterFireFrom(item, worldRef) {
if (!item || !worldRef) return false;
const existing = activeFires(worldRef).length;
const limit = activeFireLimit();
const room = Math.max(0, limit - existing);
const sizeScale = clamp((Number(item.r || 15) || 15) / 15, 0.45, 3.0);
const flameMass = Math.pow(sizeScale, 1.28);
const desired = Math.max(2, Math.round(5 + 5.5 * flameMass));
const count = Math.max(2, Math.min(desired, room > 0 ? room : Math.max(2, Math.round(2 + 2.5 * Math.min(flameMass, 1.6)))));
for (let i = 0; i < count; i++) {
const a = (Math.PI * 2 * i / count) + global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i);
const d = global.deterministicRange(worldRef, "flame-cracker-radius", 18 + 10 * sizeScale, 68 + 56 * flameMass, item, i);
const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, {
radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 6 + 3 * sizeScale, 10 + 6.5 * flameMass, item, i),
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 22 + 34 * flameMass, 46 + 84 * flameMass, item, i),
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 2.4 + 1.0 * sizeScale, 4.6 + 3.2 * flameMass, item, i),
source: item,
});
if (fire) {
fire.vx = 0;
fire.vy = 0;
updateFireContacts(fire, FIRE_CONTACT_INTERVAL, worldRef);
}
pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item);
}
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" }));
item.amount = 0;
worldRef.markItemBucketsDirty?.("flame-firecracker");
worldRef.markSpatialDirty?.("flame-firecracker");
return true;
}
function updateFlameFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef) scatterFireFrom(item, worldRef);
else item.amount = 0;
}
return true;
}
global.TarinaiFireRuntimeSystem = Object.freeze({
updateFirecracker,
updateFlameFirecracker,
updateFire,
fireBurningText: FIRE_BURNING_TEXT,
fireSeenText: FIRE_SEEN_TEXT,
fireTempMultiplier,
contactWaterDrop,
consumeWaterForFire,
panicTarinaiFromFire,
igniteAntByFire,
igniteGrassByFire,
igniteNestBoxByFire,
extinguishNestBoxFire,
updateNestBoxFire,
burnOutNestBox,
nestBoxFireActive,
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
scatterFireFrom,
compactWorldFires,
});
})(typeof window !== "undefined" ? window : globalThis);