This commit is contained in:
33333-33333 2026-07-20 14:36:59 +09:00
commit f6d7412744
94 changed files with 3836 additions and 1226 deletions

View file

@ -4,7 +4,7 @@
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"]);
const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind", "ascend_soul"]);
const EFFECT_POOL_LIMIT_PER_TYPE = 96;
const effectPool = new Map();
@ -28,13 +28,15 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
queueEffect(type, x, y, options = {}) {
// The visual-effects toggle is a true simulation gate: when disabled,
// do not allocate, enqueue, update, or draw transient Effect objects.
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null;
const opts = options || {};
const forceVisual = opts.forceVisual === true || type === "ascend_soul";
// Semantic one-shot effects such as grave ascension remain visible even when
// optional ambient effects are disabled.
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null;
const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1))));
const effect = acquireEffect(type, x, y, opts);
effect._effectImportance = importance;
effect._forceVisual = forceVisual;
this.effects.push(effect);
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
this._effectSpawnStats = this._effectSpawnStats || {};
@ -45,8 +47,9 @@
},
spawnEffect(type, x, y, options = {}) {
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null;
const opts = options || {};
const forceVisual = opts.forceVisual === true || type === "ascend_soul";
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null;
const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1))));
if (importance <= 1 && opts.allowOffscreen !== true) {
const margin = Math.max(80, Number(opts.offscreenMargin || 140));
@ -112,6 +115,53 @@
},
applyExplosionExternalForces(x, y, blastRadius, source = null, scale = 1, salt = "explosion-force") {
globalThis.TarinaiItemEnvironmentHazardSystem?.igniteOilNear?.(this, x, y, blastRadius * 1.05);
for (const can of this.itemsOfType?.("poison_gas_can") || []) {
if (!can || can.dead || distXY(x, y, can.x, can.y) > blastRadius) continue;
global.TarinaiStructureLifecycle?.deleteItem?.(this, can, { reason: "explosion", breaker: source });
}
const radius = Math.max(1, Number(blastRadius || 0) || 1);
// Mechanical bodies can have long custom shapes, so do not rely on a spatial
// query that only sees their center/radius. Scan the bounded item list and use
// the mechanical extent for blast reach; this keeps rotators and reciprocators
// consistent even immediately after placement or shape edits.
const affected = this.items || [];
let moved = 0;
for (const item of affected) {
if (!item || item.dead || item === source) continue;
const mechanical = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type);
if (!mechanical && item.type !== "grave") continue;
let dx = (Number(item.x || 0) || 0) - x;
let dy = (Number(item.y || 0) || 0) - y;
let d = Math.hypot(dx, dy);
const bodyExtent = mechanical ? Math.max(18, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 0) || 0) : Math.max(16, Number(item.r || 0) || 0);
if (d > radius + bodyExtent) continue;
if (d < 0.001) {
const a = deterministicAngle(this, `${salt}-overlap`, source || item, item);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const q = clamp(1 - d / Math.max(1, radius), 0, 1);
const force = (430 + q * 960) * Math.max(0.1, Number(scale || 1) || 1);
const nx = dx / d, ny = dy / d;
if (item.type === "grave") {
if (global.TarinaiMemorialBellSystem?.applyExternalForce?.(this, item, force, source, "explosion")) moved += 1;
continue;
}
const contactScale = Math.max(18, Math.min(160, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 40) || 40)) * 0.55;
// Give rotators a small off-centre pressure component as well as translation.
// A purely radial force through the pivot produces zero torque and previously
// made blasts look as though they did nothing to a rotator.
const tangentOffset = item.type === "rotator" ? Math.min(56, contactScale * 0.42) : 0;
const contactX = item.x - nx * contactScale - ny * tangentOffset;
const contactY = item.y - ny * contactScale + nx * tangentOffset;
const hit = global.TarinaiMechanicalSystem?.applyExternalImpulse?.(item, contactX, contactY, nx * force, ny * force, this, item.type === "rotator" ? 1.18 : 1);
if (hit) moved += 1;
}
if (moved) { this.markSpatialDirty?.("explosion-external-force"); this.drawListDirty = true; }
return moved;
},
blastLoosePinsFrom(x, y, blastRadius, source = null, scale = 1, salt = "blast") {
let blastedPins = 0;
const pinSystem = globalThis.TarinaiItemDynamicPinSystem;
@ -221,6 +271,7 @@
if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
}
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it);
this.applyExplosionExternalForces?.(x, y, blastRadius, it, it.blastScale || 1, "firecracker");
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
source: it,
@ -269,6 +320,7 @@
this.spawnEffect("ring", x, y, { size: (30 + r * 24) * blastScale, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" });
}
this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale);
this.applyExplosionExternalForces?.(x, y, blastRadius, t, blastScale, "disease-explosion");
for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
@ -360,15 +412,18 @@
const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1);
const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7);
let cleaned = 0;
let itemTouched = false;
for (const it of this.nearbyItems(x, y, radius + 80)) {
if (!it || it.dead) continue;
const d = distXY(it.x, it.y, x, y);
if (d > radius + (it.r || 12)) continue;
const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1);
if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") {
it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p);
if (it.type === "zunchi") {
itemTouched = true;
it.amount -= dt * 230 * (0.35 + p);
cleaned += p;
} else if (it.type === "grass") {
itemTouched = true;
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p));
it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p));
if (it.amount <= 0.5) {
@ -379,6 +434,7 @@
}
cleaned += p * 0.72;
} else if (it.type === "fire") {
itemTouched = true;
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 540 * (0.35 + p));
it.flameLife = Math.max(0, (Number(it.flameLife) || 0) - dt * 5.2 * (0.35 + p));
it.fireShrinkPulse = Math.max(Number(it.fireShrinkPulse || 0), 0.18);
@ -388,12 +444,31 @@
}
cleaned += p * 1.15;
} else if (it.type === "grass_bed") {
itemTouched = true;
it.amount = 0;
it.hp = 0;
global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "\u6d17\u6d44\u3067\u6d41\u3055\u308c\u305f", wake: true });
global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "洗浄で流された", wake: true });
cleaned += p * 0.95;
} else if (it.type === "grave") {
itemTouched = true;
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, it, {
reason: "water-hose-grave",
userReason: "洗浄で流された",
wake: true,
});
if (removed) cleaned += p * 1.05;
}
}
for (const residue of this.nearbyResidues?.(x, y, radius + 80) || []) {
if (!residue || residue.dead) continue;
const d = distXY(residue.x, residue.y, x, y);
if (d > radius + (residue.r || 12)) continue;
const p = clamp(1 - d / (radius + (residue.r || 12)), 0, 1);
residue.amount = Math.max(0, (Number(residue.amount) || 0) - dt * 180 * (0.35 + p));
cleaned += p;
if (residue.amount <= 0.5) this.removeResidue?.(residue, "water-hose-residue");
else this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "water-hose-residue");
}
for (const ef of this.effects || []) {
if (!ef || ef.dead) continue;
if (ef.type !== "bleed" && ef.type !== "splat") continue;
@ -432,11 +507,13 @@
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.markItemBucketsDirty?.("water-hose-clean");
this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.compactItems?.();
this.updateItemCounts();
if (itemTouched) {
this.markItemBucketsDirty?.("water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.compactItems?.();
this.updateItemCounts();
}
}
},
@ -512,18 +589,29 @@
},
spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") {
if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
const bubbleLimit = Math.max(1, Number(CONFIG.bubbleLimit || 32) || 32);
let bubbleCount = Math.max(0, Number(this.effectCounts.bubble || 0) || 0);
if (bubbleCount >= bubbleLimit) {
bubbleCount = 0;
for (const effect of this.effects || []) if (effect && !effect.dead && effect.type === "bubble") bubbleCount += 1;
this.effectCounts.bubble = bubbleCount;
if (bubbleCount >= bubbleLimit) return;
}
if (text === "z") text = "Zzz...";
audio.bubble(text);
this.spawnEffect("bubble", x, y, {
const effect = this.spawnEffect("bubble", x, y, {
vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
size: 8,
text,
color,
forceVisual: true,
importance: 3,
});
if (!effect) return null;
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
return effect;
},
spawnHeadbuttEffect(x, y) {