470 lines
22 KiB
JavaScript
470 lines
22 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;
|
|
|
|
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 });
|
|
worldRef?.emit?.("fire:ignite", { tarinai: t, source });
|
|
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;
|
|
worldRef?.emit?.("fire:ignite-ant", { ant, source });
|
|
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);
|
|
worldRef?.emit?.("fire:ignite-zunchi", { item: zunchi, source });
|
|
}
|
|
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) 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)) {
|
|
changed = tryGrassExtinguishFire(it, item, worldRef, dt) || changed;
|
|
if ((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 === "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) 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;
|
|
for (const fire of fires) {
|
|
if (!fire || fire.dead) 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;
|
|
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
|
|
worldRef.drawListDirty = true;
|
|
return fire;
|
|
}
|
|
|
|
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 desired = Math.max(5, Math.round(8 * Math.max(0.75, Math.min(1.4, (item.r || 15) / 15))));
|
|
const count = Math.max(3, Math.min(desired, room > 0 ? room : 3));
|
|
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", 28, 118, 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", 10, 18, item, i),
|
|
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i),
|
|
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, 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) * 3.0), life: 0.36, 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,
|
|
igniteTarinaiByFire,
|
|
panicTarinaiFromFire,
|
|
igniteAntByFire,
|
|
igniteGrassByFire,
|
|
igniteZunchiByFire,
|
|
tryGrassExtinguishFire,
|
|
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
|
|
scatterFireFrom,
|
|
compactWorldFires,
|
|
});
|
|
})(typeof window !== "undefined" ? window : globalThis);
|