2026-06-21 14:09:35 +09:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
const World = global.World;
|
|
|
|
|
if (!World) throw new Error("World is not available for mixin: world_environment.js");
|
2026-06-25 22:30:35 +09:00
|
|
|
|
|
|
|
|
function angleForItemLocal(it) {
|
2026-06-29 16:21:58 +09:00
|
|
|
return itemAngleFor(it);
|
2026-06-25 22:30:35 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:04:32 +09:00
|
|
|
|
2026-06-25 22:30:35 +09:00
|
|
|
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
|
|
|
|
const c = Math.cos(angle);
|
|
|
|
|
const s = Math.sin(angle);
|
|
|
|
|
const ex = Math.abs(c) * halfW + Math.abs(s) * halfH;
|
|
|
|
|
const ey = Math.abs(s) * halfW + Math.abs(c) * halfH;
|
|
|
|
|
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
function obstacleQueryDistanceLocal(x, y, it) {
|
|
|
|
|
if (!it) return Infinity;
|
|
|
|
|
const type = String(it.type || "");
|
|
|
|
|
let reach = Math.max(32, Number(it.r || it.radius || 32) || 32);
|
2026-06-30 10:57:06 +09:00
|
|
|
if (type === "fence" || type === "fence_v" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9);
|
2026-06-27 19:22:38 +09:00
|
|
|
else if (type === "nest_box") reach = Math.max(92, reach * 1.9);
|
2026-06-29 16:21:58 +09:00
|
|
|
else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6);
|
2026-06-27 19:22:38 +09:00
|
|
|
return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 21:50:05 +09:00
|
|
|
function rectDistanceToPointLocal(rect, x, y) {
|
|
|
|
|
if (!rect) return Infinity;
|
|
|
|
|
const px = Math.max(Number(rect.left || 0), Math.min(Number(rect.right || 0), Number(x) || 0));
|
|
|
|
|
const py = Math.max(Number(rect.top || 0), Math.min(Number(rect.bottom || 0), Number(y) || 0));
|
|
|
|
|
return Math.hypot((Number(x) || 0) - px, (Number(y) || 0) - py);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) {
|
|
|
|
|
if (!candidates || typeof visitor !== "function") return 0;
|
|
|
|
|
const source = candidates;
|
|
|
|
|
const n = Number(source.length || 0) || 0;
|
|
|
|
|
if (n <= 0) return 0;
|
|
|
|
|
const keepMax = Math.max(1, Math.min(n, Math.max(limit + 6, limit * 3)));
|
|
|
|
|
const kept = [];
|
|
|
|
|
const dists = [];
|
|
|
|
|
const include = opts.include || null;
|
|
|
|
|
const exclude = opts.exclude || null;
|
|
|
|
|
const stamp = opts.stamp;
|
|
|
|
|
|
|
|
|
|
// Hot path: candidate counts are usually small. Keep a bounded sorted
|
|
|
|
|
// prefix without allocating intermediate Array.from()/sort() output. For
|
|
|
|
|
// large candidate sets, insertion is limited to the top keepMax entries.
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
const it = source[i];
|
|
|
|
|
if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) continue;
|
|
|
|
|
if (include && !include(it)) continue;
|
|
|
|
|
const d = obstacleQueryDistanceLocal(x, y, it);
|
|
|
|
|
if (kept.length >= keepMax && d >= dists[dists.length - 1]) continue;
|
|
|
|
|
let pos = kept.length;
|
|
|
|
|
while (pos > 0 && d < dists[pos - 1]) pos -= 1;
|
|
|
|
|
if (pos >= keepMax) continue;
|
|
|
|
|
kept.splice(pos, 0, it);
|
|
|
|
|
dists.splice(pos, 0, d);
|
|
|
|
|
if (kept.length > keepMax) {
|
|
|
|
|
kept.length = keepMax;
|
|
|
|
|
dists.length = keepMax;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let visited = 0;
|
|
|
|
|
for (let i = 0; i < kept.length; i++) {
|
|
|
|
|
visited += 1;
|
|
|
|
|
if (visitor(kept[i]) === true) break;
|
|
|
|
|
}
|
|
|
|
|
return visited;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 12:46:32 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeRotatorSegmentsLocal(item) {
|
2026-06-29 16:21:58 +09:00
|
|
|
const raw = global.TarinaiPhysicsBodySystem.segments(item) || [];
|
2026-06-26 12:46:32 +09:00
|
|
|
const out = [];
|
|
|
|
|
const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0));
|
|
|
|
|
for (const seg of raw) {
|
|
|
|
|
if (!Array.isArray(seg) || seg.length < 4) continue;
|
|
|
|
|
const x1 = clampCoord(seg[0]);
|
|
|
|
|
const y1 = clampCoord(seg[1]);
|
|
|
|
|
const x2 = clampCoord(seg[2]);
|
|
|
|
|
const y2 = clampCoord(seg[3]);
|
|
|
|
|
if (Math.hypot(x2 - x1, y2 - y1) < 4) continue;
|
|
|
|
|
out.push([x1, y1, x2, y2]);
|
|
|
|
|
if (out.length >= 96) break;
|
|
|
|
|
}
|
|
|
|
|
if (!out.length) out.push([-78, 0, 78, 0], [0, -52, 0, 52]);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rotatorExtentLocal(item) {
|
|
|
|
|
let maxD = 48;
|
|
|
|
|
for (const seg of sanitizeRotatorSegmentsLocal(item)) {
|
|
|
|
|
maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3]));
|
|
|
|
|
}
|
2026-06-29 16:21:58 +09:00
|
|
|
return Math.min(460, maxD + Math.max(8, global.TarinaiPhysicsBodySystem.scalar(item, "thickness", 12) || 12) + 8);
|
2026-06-26 12:46:32 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 19:04:32 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 22:30:35 +09:00
|
|
|
function rectLocalPoint(r, x, y) {
|
2026-06-29 16:21:58 +09:00
|
|
|
return global.TarinaiCollisionFootprints.rectLocalPoint(r, x, y);
|
2026-06-25 22:30:35 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rectWorldNormal(r, nx, ny) {
|
|
|
|
|
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
|
|
|
|
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
|
|
|
|
return { x: nx * c - ny * s, y: nx * s + ny * c };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
function segmentAabbHitLocal(x1, y1, x2, y2, left, top, right, bottom) {
|
|
|
|
|
let t0 = 0;
|
|
|
|
|
let t1 = 1;
|
|
|
|
|
const dx = x2 - x1;
|
|
|
|
|
const dy = y2 - y1;
|
|
|
|
|
const clip = (p, q) => {
|
|
|
|
|
if (Math.abs(p) < 1e-9) return q >= 0;
|
|
|
|
|
const t = q / p;
|
|
|
|
|
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
|
|
|
|
|
else { if (t < t0) return false; if (t < t1) t1 = t; }
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
if (!clip(-dx, x1 - left)) return null;
|
|
|
|
|
if (!clip(dx, right - x1)) return null;
|
|
|
|
|
if (!clip(-dy, y1 - top)) return null;
|
|
|
|
|
if (!clip(dy, bottom - y1)) return null;
|
|
|
|
|
if (t1 < 0 || t0 > 1) return null;
|
|
|
|
|
const t = clamp(t0, 0, 1);
|
|
|
|
|
const hx = x1 + dx * t;
|
|
|
|
|
const hy = y1 + dy * t;
|
|
|
|
|
const dl = Math.abs(hx - left);
|
|
|
|
|
const dr = Math.abs(right - hx);
|
|
|
|
|
const dt = Math.abs(hy - top);
|
|
|
|
|
const db = Math.abs(bottom - hy);
|
|
|
|
|
const m = Math.min(dl, dr, dt, db);
|
|
|
|
|
let nx = 0, ny = 0;
|
|
|
|
|
if (m === dl) nx = -1;
|
|
|
|
|
else if (m === dr) nx = 1;
|
|
|
|
|
else if (m === dt) ny = -1;
|
|
|
|
|
else ny = 1;
|
|
|
|
|
return { t, nx, ny };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sweptCircleRectHitLocal(x1, y1, x2, y2, radius, r, padding = 0) {
|
|
|
|
|
if (!r) return null;
|
|
|
|
|
const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0);
|
|
|
|
|
if (r.oriented) {
|
|
|
|
|
const a = rectLocalPoint(r, x1, y1);
|
|
|
|
|
const b = rectLocalPoint(r, x2, y2);
|
|
|
|
|
const hit = segmentAabbHitLocal(a.x, a.y, b.x, b.y, -(r.halfW || 0) - rr, -(r.halfH || 0) - rr, (r.halfW || 0) + rr, (r.halfH || 0) + rr);
|
|
|
|
|
if (!hit) return null;
|
|
|
|
|
const n = rectWorldNormal(r, hit.nx, hit.ny);
|
|
|
|
|
return { t: hit.t, nx: n.x, ny: n.y, rect: r };
|
|
|
|
|
}
|
|
|
|
|
const hit = segmentAabbHitLocal(x1, y1, x2, y2, (r.left || 0) - rr, (r.top || 0) - rr, (r.right || 0) + rr, (r.bottom || 0) + rr);
|
|
|
|
|
return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) {
|
|
|
|
|
if (!worldRef?.nearbySolidObstacleRects) return null;
|
|
|
|
|
const midX = (x1 + x2) * 0.5;
|
|
|
|
|
const midY = (y1 + y2) * 0.5;
|
|
|
|
|
const travel = Math.hypot(x2 - x1, y2 - y1);
|
|
|
|
|
const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180);
|
|
|
|
|
const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48);
|
|
|
|
|
let best = null;
|
|
|
|
|
for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks })) {
|
|
|
|
|
if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) continue;
|
|
|
|
|
const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, 0.5);
|
|
|
|
|
if (!hit) continue;
|
|
|
|
|
if (!best || hit.t < best.t) best = hit;
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 22:30:35 +09:00
|
|
|
function segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, padding = 0) {
|
|
|
|
|
const a = rectLocalPoint(r, x1, y1);
|
|
|
|
|
const b = rectLocalPoint(r, x2, y2);
|
|
|
|
|
const minX = -(r.halfW || 0) - padding;
|
|
|
|
|
const maxX = (r.halfW || 0) + padding;
|
|
|
|
|
const minY = -(r.halfH || 0) - padding;
|
|
|
|
|
const maxY = (r.halfH || 0) + padding;
|
|
|
|
|
let t0 = 0, t1 = 1;
|
|
|
|
|
const clip = (p, q) => {
|
|
|
|
|
if (Math.abs(p) < 1e-9) return q >= 0;
|
|
|
|
|
const t = q / p;
|
|
|
|
|
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
|
|
|
|
|
else { if (t < t0) return false; if (t < t1) t1 = t; }
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
const dx = b.x - a.x;
|
|
|
|
|
const dy = b.y - a.y;
|
|
|
|
|
return clip(-dx, a.x - minX) && clip(dx, maxX - a.x) && clip(-dy, a.y - minY) && clip(dy, maxY - a.y);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
|
|
|
|
nearest(entity, types, maxDist = Infinity) {
|
|
|
|
|
let best = null, bestD = maxDist;
|
|
|
|
|
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
|
|
|
|
|
for (const it of candidates) {
|
|
|
|
|
if (it.dead || !types.includes(it.type)) continue;
|
|
|
|
|
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
|
|
|
|
|
const d = dist(entity, it);
|
|
|
|
|
if (d < bestD) { best = it; bestD = d; }
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nearestOther(entity, maxDist = Infinity, predicate = null) {
|
|
|
|
|
let best = null, bestD = maxDist;
|
|
|
|
|
const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
|
|
|
|
|
for (const o of candidates) {
|
|
|
|
|
if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue;
|
|
|
|
|
if (predicate && !predicate(o)) continue;
|
|
|
|
|
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
|
|
|
|
|
const d = dist(entity, o);
|
|
|
|
|
if (d < bestD) { best = o; bestD = d; }
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-30 10:57:06 +09:00
|
|
|
temperatureNumber(value, fallback = CONFIG.standardTemperature ?? 15) {
|
|
|
|
|
const v = Number(value);
|
|
|
|
|
const fb = Number(fallback);
|
|
|
|
|
return Number.isFinite(v) ? v : (Number.isFinite(fb) ? fb : 15);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clampTemperature(value) {
|
|
|
|
|
// v10: 気温の上限/下限は撤廃。ドライアイス・ストーブの重ね掛けや手動値は制限しない。
|
|
|
|
|
return this.temperatureNumber(value);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
climateTemperature(value) {
|
|
|
|
|
// 自然気候だけは仕様どおり -20℃〜50℃ の範囲に収める。
|
|
|
|
|
const v = this.temperatureNumber(value);
|
|
|
|
|
return clamp(v, CONFIG.climateTemperatureMin ?? -20, CONFIG.climateTemperatureMax ?? 50);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
seasonIndex() {
|
|
|
|
|
return Math.floor((((Number(this.day) || 1) - 1) % 20) / 5);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
seasonLabel() {
|
|
|
|
|
return ["春", "夏", "秋", "冬"][this.seasonIndex()] || "春";
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
seasonDay() {
|
|
|
|
|
return ((((Number(this.day) || 1) - 1) % 5) + 1);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
seasonDayString() {
|
|
|
|
|
return `${this.seasonLabel()}${this.seasonDay()}日`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
temperatureStableUnit(salt = "") {
|
|
|
|
|
const key = `${this.worldSeed || "world"}:temperature:${salt}`;
|
|
|
|
|
if (typeof stableUnit === "function") return stableUnit(key, "temperature");
|
|
|
|
|
let h = 2166136261;
|
|
|
|
|
for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 16777619);
|
|
|
|
|
return ((h >>> 0) % 1000000) / 1000000;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
weatherDailyRangeFactor(weather = this.weather) {
|
|
|
|
|
if (weather === "sunny") return 0.68;
|
|
|
|
|
if (weather === "light_rain") return 0.24;
|
|
|
|
|
return 0.36;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dailyTemperatureRange(day = this.day, weather = this.weather) {
|
|
|
|
|
const raw = this.temperatureStableUnit(`daily-range:${day}`) * 15;
|
|
|
|
|
if (weather === "sunny") return clamp(6 + raw * 0.62, 0, 15);
|
|
|
|
|
if (weather === "light_rain") return clamp(raw * 0.32, 0, 5);
|
|
|
|
|
return clamp(raw * 0.45, 0, 7);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
annualTemperatureOffset() {
|
|
|
|
|
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
|
|
|
|
const totalDays = Math.max(0, Number(this.time || 0) / dayLength);
|
|
|
|
|
const phase = ((totalDays % 20) + 20) % 20 / 20;
|
|
|
|
|
const range = Number.isFinite(Number(this.temperatureAnnualRange)) ? Number(this.temperatureAnnualRange) : 30;
|
|
|
|
|
const wave = Math.sin(phase * Math.PI * 2 - Math.PI * 0.25);
|
|
|
|
|
return wave * range * 0.5;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dailyTemperatureWave(progress = 0) {
|
|
|
|
|
const hour = ((((Number(progress) || 0) % 1) + 1) % 1) * 24;
|
|
|
|
|
const minHour = 5.0; // 朝方に最低気温
|
|
|
|
|
const maxHour = 14.0; // 昼過ぎに最高気温
|
|
|
|
|
const smooth = (x) => {
|
|
|
|
|
const t = clamp(x, 0, 1);
|
|
|
|
|
return t * t * (3 - 2 * t);
|
|
|
|
|
};
|
|
|
|
|
let warmth;
|
|
|
|
|
if (hour >= minHour && hour <= maxHour) {
|
|
|
|
|
warmth = smooth((hour - minHour) / Math.max(0.001, maxHour - minHour));
|
|
|
|
|
} else {
|
|
|
|
|
const downHour = hour > maxHour ? hour - maxHour : hour + 24 - maxHour;
|
|
|
|
|
const downSpan = minHour + 24 - maxHour;
|
|
|
|
|
warmth = 1 - smooth(downHour / Math.max(0.001, downSpan));
|
|
|
|
|
}
|
|
|
|
|
return warmth * 2 - 1;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
naturalTemperature() {
|
|
|
|
|
// 初期ロード直後は 10℃ を保持し、初回フレームのdt揺れで0℃近くへ落ちないようにする。
|
|
|
|
|
if ((Number(this.time) || 0) <= 6.0 && (Number(this.day) || 1) === 1) {
|
|
|
|
|
this.temperatureDailyRange = 0;
|
|
|
|
|
return 10;
|
|
|
|
|
}
|
|
|
|
|
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
|
|
|
|
const progress = ((Number(this.time || 0) % dayLength) + dayLength) % dayLength / dayLength;
|
|
|
|
|
const dailyRange = this.dailyTemperatureRange(this.day, this.weather);
|
|
|
|
|
const dailyWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(progress) : Math.sin(progress * Math.PI * 2 - Math.PI * 0.5);
|
|
|
|
|
let temp;
|
|
|
|
|
if ((Number(this.day) || 1) === 1) {
|
|
|
|
|
// 春1日目は開始時10℃を基準にしつつ、日内の最低/最高時刻だけは維持する。
|
|
|
|
|
const initialWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(0) : Math.sin(-Math.PI * 0.5);
|
|
|
|
|
temp = 10 + (dailyWave - initialWave) * dailyRange * 0.5;
|
|
|
|
|
} else {
|
|
|
|
|
temp = (CONFIG.standardTemperature ?? 15) + this.annualTemperatureOffset() + dailyWave * dailyRange * 0.5;
|
|
|
|
|
}
|
|
|
|
|
this.temperatureDailyRange = dailyRange;
|
|
|
|
|
return this.climateTemperature(temp);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateTemperature(dt = 0) {
|
|
|
|
|
if (!Number.isFinite(Number(this.temperatureAnnualRange))) {
|
|
|
|
|
this.temperatureAnnualRange = 25 + this.temperatureStableUnit("annual-range") * 10;
|
|
|
|
|
}
|
|
|
|
|
if (!Number.isFinite(Number(this.manualTemperature))) this.manualTemperature = CONFIG.standardTemperature ?? 15;
|
|
|
|
|
this.currentTemperature = this.temperatureAuto === false
|
|
|
|
|
? this.temperatureNumber(this.manualTemperature)
|
|
|
|
|
: this.naturalTemperature();
|
|
|
|
|
this.temperatureLastUpdateAt = this.time || 0;
|
|
|
|
|
return this.currentTemperature;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setTemperatureAuto(enabled = true) {
|
|
|
|
|
this.temperatureAuto = Boolean(enabled);
|
|
|
|
|
if (this.temperatureAuto === false) this.manualTemperature = this.temperatureNumber(this.currentTemperature ?? this.manualTemperature ?? CONFIG.standardTemperature ?? 15);
|
|
|
|
|
return this.updateTemperature?.(0) ?? this.currentTemperature;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setManualTemperature(value = 15) {
|
|
|
|
|
this.manualTemperature = this.temperatureNumber(value);
|
|
|
|
|
if (this.temperatureAuto === false) this.currentTemperature = this.manualTemperature;
|
|
|
|
|
return this.manualTemperature;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
temperatureAt(x, y) {
|
2026-06-30 10:57:06 +09:00
|
|
|
let temp = Number.isFinite(Number(this.currentTemperature)) ? Number(this.currentTemperature) : this.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15);
|
|
|
|
|
if (this.temperatureAuto === false) temp = this.temperatureNumber(this.manualTemperature);
|
|
|
|
|
if ((this.groundType || "soil") === "ice") temp -= 5;
|
|
|
|
|
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
|
|
|
|
for (const it of this.nearbyItems(x, y, range + 8)) {
|
|
|
|
|
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
|
|
|
|
const d = distXY(x, y, it.x, it.y);
|
|
|
|
|
if (d > range) continue;
|
|
|
|
|
const edge = d <= range - 24 ? 1 : clamp((range - d) / 24, 0, 1);
|
|
|
|
|
temp += (it.type === "stove" ? 10 : -10) * edge;
|
|
|
|
|
}
|
|
|
|
|
const nestWarmthLimit = 5;
|
|
|
|
|
for (const box of this.nearbyItems?.(x, y, 86) || []) {
|
|
|
|
|
if (!box || box.dead || box.type !== "nest_box") continue;
|
|
|
|
|
const r = Math.max(36, Number(box.r || 42) || 42);
|
|
|
|
|
const base = this.nestBoxBaseRect?.(box) || null;
|
|
|
|
|
const inBase = base && x >= base.left - 4 && x <= base.right + 4 && y >= base.top - 4 && y <= base.bottom + 4;
|
|
|
|
|
const inBody = distXY(x, y, box.x, box.y) <= r * 1.08;
|
|
|
|
|
if (!inBase && !inBody) continue;
|
|
|
|
|
const toward = 20 - temp;
|
|
|
|
|
temp += clamp(toward, -nestWarmthLimit, nestWarmthLimit);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
return this.temperatureNumber(temp);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
temperatureStatusFor(temp = CONFIG.standardTemperature ?? 15) {
|
|
|
|
|
const value = this.temperatureNumber(temp);
|
|
|
|
|
const min = CONFIG.temperatureComfortMin ?? 10;
|
|
|
|
|
const max = CONFIG.temperatureComfortMax ?? 25;
|
|
|
|
|
const discomfort = value < min ? min - value : (value > max ? value - max : 0);
|
|
|
|
|
const direction = value < min ? "cold" : (value > max ? "hot" : "comfort");
|
|
|
|
|
const stressExcess = Math.max(0, discomfort - (CONFIG.temperatureStressMargin ?? 5));
|
|
|
|
|
const harmExcess = Math.max(0, discomfort - (CONFIG.temperatureEnergyMargin ?? 10));
|
|
|
|
|
return {
|
|
|
|
|
temp: value,
|
|
|
|
|
direction,
|
|
|
|
|
discomfort,
|
|
|
|
|
comfortable: discomfort <= 0,
|
|
|
|
|
stressExcess,
|
|
|
|
|
harmExcess,
|
|
|
|
|
harmful: harmExcess > 0,
|
|
|
|
|
label: discomfort <= 0 ? "快適" : (direction === "cold" ? (harmExcess > 0 ? "危険な寒さ" : "寒い") : (harmExcess > 0 ? "危険な暑さ" : "暑い")),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
findComfortTemperatureSpot(actor, opts = {}) {
|
|
|
|
|
if (!actor || actor.dead) return null;
|
|
|
|
|
const current = this.temperatureAt(actor.x, actor.y);
|
|
|
|
|
const currentStatus = this.temperatureStatusFor(current);
|
|
|
|
|
if (!opts.force && currentStatus.comfortable) return null;
|
|
|
|
|
const targetTemp = ((CONFIG.temperatureComfortMin ?? 10) + (CONFIG.temperatureComfortMax ?? 25)) * 0.5;
|
|
|
|
|
const radius = actor.radius || actor.r || 20;
|
|
|
|
|
const rings = [72, 128, 196, 288, 392];
|
|
|
|
|
const dirs = 18;
|
|
|
|
|
const candidates = [];
|
|
|
|
|
const pushCandidate = (x, y, extra = 0) => candidates.push({
|
|
|
|
|
x: clamp(x, radius + 8, (this.w || 1000) - radius - 8),
|
|
|
|
|
y: clamp(y, radius + 8, (this.h || 720) - radius - 8),
|
|
|
|
|
extra,
|
|
|
|
|
});
|
|
|
|
|
for (const ring of rings) {
|
|
|
|
|
for (let i = 0; i < dirs; i++) {
|
|
|
|
|
const a = (Math.PI * 2 * i / dirs) + ring * 0.017;
|
|
|
|
|
pushCandidate(actor.x + Math.cos(a) * ring, actor.y + Math.sin(a) * ring);
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-30 10:57:06 +09:00
|
|
|
const itemRange = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
|
|
|
|
for (const it of this.items || []) {
|
|
|
|
|
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
|
|
|
|
if (currentStatus.direction === "cold" && it.type !== "stove") continue;
|
|
|
|
|
if (currentStatus.direction === "hot" && it.type !== "dry_ice") continue;
|
|
|
|
|
const toward = Math.atan2(actor.y - it.y, actor.x - it.x);
|
|
|
|
|
pushCandidate(it.x + Math.cos(toward) * itemRange * 0.45, it.y + Math.sin(toward) * itemRange * 0.45, -60);
|
|
|
|
|
pushCandidate(it.x + Math.cos(toward + 0.75) * itemRange * 0.62, it.y + Math.sin(toward + 0.75) * itemRange * 0.62, -32);
|
|
|
|
|
pushCandidate(it.x + Math.cos(toward - 0.75) * itemRange * 0.62, it.y + Math.sin(toward - 0.75) * itemRange * 0.62, -32);
|
|
|
|
|
}
|
|
|
|
|
let best = null;
|
|
|
|
|
let bestScore = Infinity;
|
|
|
|
|
for (const c of candidates) {
|
|
|
|
|
if (this.pointBlockedByObstacle?.(c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor })) continue;
|
|
|
|
|
const temp = this.temperatureAt(c.x, c.y);
|
|
|
|
|
const status = this.temperatureStatusFor(temp);
|
|
|
|
|
if (currentStatus.direction === "cold" && temp <= current + 0.6 && !status.comfortable) continue;
|
|
|
|
|
if (currentStatus.direction === "hot" && temp >= current - 0.6 && !status.comfortable) continue;
|
|
|
|
|
const d = distXY(actor.x, actor.y, c.x, c.y);
|
|
|
|
|
const blockedPenalty = this.pathBlockedByFence?.(actor.x, actor.y, c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor }) ? 480 : 0;
|
|
|
|
|
const score = status.discomfort * 110 + Math.abs(temp - targetTemp) * 2.8 + d * 0.22 + blockedPenalty + (c.extra || 0);
|
|
|
|
|
if (score < bestScore) {
|
|
|
|
|
bestScore = score;
|
|
|
|
|
best = { x: c.x, y: c.y, dead: false, detour: true, temperatureTarget: true, temperature: temp, label: "快適な温度帯" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!best) return null;
|
|
|
|
|
const bestStatus = this.temperatureStatusFor(best.temperature);
|
|
|
|
|
if (opts.requireComfort && !bestStatus.comfortable) return null;
|
|
|
|
|
if (!bestStatus.comfortable && bestStatus.discomfort >= currentStatus.discomfort - 0.65) return null;
|
|
|
|
|
best.temperatureStatus = bestStatus;
|
|
|
|
|
return best;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
hasReachableComfortTemperatureSpot(actor) {
|
|
|
|
|
return !!this.findComfortTemperatureSpot?.(actor, { force: true, requireComfort: true });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
sleepTargetTemperatureBlocked(actor, item) {
|
|
|
|
|
if (!actor || !item || item.dead) return false;
|
|
|
|
|
if (!(item.type === "grass_bed" || item.type === "bed" || item.type === "nest_box")) return false;
|
|
|
|
|
const current = this.temperatureAt?.(actor.x, actor.y);
|
|
|
|
|
const currentStatus = this.temperatureStatusFor?.(current);
|
|
|
|
|
if (!currentStatus || currentStatus.comfortable) return false;
|
|
|
|
|
if (!this.hasReachableComfortTemperatureSpot?.(actor)) return false;
|
|
|
|
|
const temp = this.temperatureAt?.(item.x, item.y);
|
|
|
|
|
const status = this.temperatureStatusFor?.(temp);
|
|
|
|
|
return Boolean(status && !status.comfortable);
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
fenceRect(it) {
|
2026-06-25 22:30:35 +09:00
|
|
|
const type = it?.type || "";
|
|
|
|
|
if (!this.isFenceType(type)) return null;
|
|
|
|
|
const bounce = type === "bounce_fence" || type === "bounce_fence_v";
|
|
|
|
|
const gate = type === "gate_fence";
|
2026-06-30 10:57:06 +09:00
|
|
|
const glassWall = type === "glass_wall";
|
2026-06-21 14:09:35 +09:00
|
|
|
const len = Math.max(112, (it.r || 42) * 3.55);
|
|
|
|
|
const thick = Math.max(10, (it.r || 42) * 0.31);
|
2026-06-25 22:30:35 +09:00
|
|
|
const angle = angleForItemLocal(it);
|
|
|
|
|
const halfW = len / 2;
|
|
|
|
|
const halfH = thick / 2;
|
|
|
|
|
const aabb = orientedRectAabb(it.x || 0, it.y || 0, halfW, halfH, angle);
|
|
|
|
|
return {
|
|
|
|
|
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
|
|
|
|
|
cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
|
|
|
|
|
vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)),
|
2026-06-30 10:57:06 +09:00
|
|
|
bounce, gate, glassWall, gateOpen: Boolean(it.gateOpen),
|
2026-06-25 22:30:35 +09:00
|
|
|
restitution: bounce ? 1.08 : undefined, minBounceSpeed: bounce ? 260 : undefined
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
nestBoxCapacity() {
|
|
|
|
|
return 5;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nestBoxBaseRect(it) {
|
|
|
|
|
if (!it || it.dead || it.type !== "nest_box") return null;
|
|
|
|
|
const r = it.r || 42;
|
|
|
|
|
return {
|
|
|
|
|
left: it.x - r * 1.28,
|
|
|
|
|
right: it.x + r * 1.28,
|
|
|
|
|
top: it.y - r * 0.80,
|
|
|
|
|
bottom: it.y + r * 0.72,
|
|
|
|
|
type: "nest_box",
|
|
|
|
|
item: it,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nestBoxSolidRects(it) {
|
|
|
|
|
const base = this.nestBoxBaseRect(it);
|
|
|
|
|
if (!base) return [];
|
|
|
|
|
const w = base.right - base.left;
|
|
|
|
|
const h = base.bottom - base.top;
|
|
|
|
|
const colW = w / 3;
|
|
|
|
|
const rowH = h / 3;
|
|
|
|
|
const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it });
|
|
|
|
|
return [
|
|
|
|
|
mk(base.left, base.right, base.top, base.top + rowH, "top"),
|
|
|
|
|
mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"),
|
|
|
|
|
mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"),
|
|
|
|
|
];
|
|
|
|
|
},
|
|
|
|
|
nestBoxEntryPoint(box) {
|
|
|
|
|
const base = this.nestBoxBaseRect(box);
|
|
|
|
|
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
|
|
|
|
|
const r = box.r || 42;
|
|
|
|
|
// Move the approach point to the center of the open middle cell. The
|
|
|
|
|
// collision resolver also ignores this nest box for active sleepers near
|
|
|
|
|
// the door, so tarinai can cross the threshold instead of sliding off posts.
|
|
|
|
|
return {
|
|
|
|
|
x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
|
|
|
|
|
y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nestBoxExitPoint(box, occupant = null) {
|
|
|
|
|
const base = this.nestBoxBaseRect(box);
|
|
|
|
|
if (!base) return this.nestBoxEntryPoint(box);
|
|
|
|
|
const r = box.r || 42;
|
|
|
|
|
const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0;
|
|
|
|
|
return {
|
|
|
|
|
x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
|
|
|
|
|
y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nestBoxInnerPoint(box, occupant = null) {
|
|
|
|
|
const base = this.nestBoxBaseRect(box);
|
|
|
|
|
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
|
|
|
|
|
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : [];
|
2026-06-28 13:40:41 +09:00
|
|
|
let index = Number.isInteger(occupant?.nestBoxSlotIndex) && occupant.nestBoxSlotIndex >= 0 ? occupant.nestBoxSlotIndex : occupants.indexOf(occupant);
|
2026-06-21 14:09:35 +09:00
|
|
|
if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1);
|
2026-06-28 13:40:41 +09:00
|
|
|
index = clamp(index, 0, Math.max(0, this.nestBoxCapacity(box) - 1));
|
2026-06-21 14:09:35 +09:00
|
|
|
const r = box.r || 42;
|
|
|
|
|
const slots = [
|
|
|
|
|
[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16],
|
|
|
|
|
];
|
|
|
|
|
const slot = slots[index % slots.length];
|
|
|
|
|
return {
|
|
|
|
|
x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46),
|
|
|
|
|
y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-26 12:46:32 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rotatorExtent(it) {
|
2026-06-29 16:21:58 +09:00
|
|
|
return global.TarinaiMechanicalShapeSystem.extent(it) || rotatorExtentLocal(it);
|
2026-06-26 12:46:32 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-26 19:04:32 +09:00
|
|
|
|
2026-06-26 12:46:32 +09:00
|
|
|
|
|
|
|
|
|
2026-06-21 14:09:35 +09:00
|
|
|
solidObstacleRects(it) {
|
|
|
|
|
if (!it || it.dead) return [];
|
|
|
|
|
if (this.isFenceType(it.type)) {
|
2026-06-25 22:30:35 +09:00
|
|
|
if (it.type === "gate_fence" && it.gateOpen) return [];
|
2026-06-21 14:09:35 +09:00
|
|
|
const rect = this.fenceRect(it);
|
|
|
|
|
return rect ? [{ ...rect, type: it.type, item: it }] : [];
|
|
|
|
|
}
|
2026-06-29 16:21:58 +09:00
|
|
|
if (global.TarinaiMechanicalShapeSystem.isMechanicalType(it.type)) return global.TarinaiMechanicalShapeSystem.obstacleRects(it) || [];
|
2026-06-21 14:09:35 +09:00
|
|
|
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
|
|
|
|
|
return [];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
shouldIgnoreNestBoxCollisionFor(t, box) {
|
|
|
|
|
if (!t || !box || box.dead || box.type !== "nest_box") return false;
|
|
|
|
|
if (t.insideNestBoxId) return true;
|
|
|
|
|
if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false;
|
|
|
|
|
const entry = this.nestBoxEntryPoint(box);
|
|
|
|
|
const base = this.nestBoxBaseRect(box);
|
|
|
|
|
const r = box.r || 42;
|
|
|
|
|
const entryD = distXY(t.x, t.y, entry.x, entry.y);
|
|
|
|
|
const centerD = distXY(t.x, t.y, box.x, box.y);
|
2026-06-28 23:07:40 +09:00
|
|
|
const aroundDoor = entryD <= Math.max(68, r * 1.32) || centerD <= Math.max(66, r * 1.18);
|
|
|
|
|
const inDoorColumn = base && t.x >= base.left + r * 0.30 && t.x <= base.right - r * 0.30 && t.y >= base.top + r * 0.12 && t.y <= base.bottom + r * 0.42;
|
2026-06-21 14:09:35 +09:00
|
|
|
return Boolean(aroundDoor || inDoorColumn);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-27 21:50:05 +09:00
|
|
|
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24, maxRects: requestedMaxRects = 0 } = {}) {
|
2026-06-27 19:22:38 +09:00
|
|
|
const limit = Math.max(1, Number(maxChecks || 0) || 24);
|
2026-06-27 21:50:05 +09:00
|
|
|
const maxRects = Math.max(12, Math.min(72, Number(requestedMaxRects || 0) || Math.ceil(limit * 2.25)));
|
2026-06-27 19:22:38 +09:00
|
|
|
const cacheable = !include && !exclude;
|
|
|
|
|
const stats = this.nearbyQueryStatsThisFrame || null;
|
2026-06-28 23:07:40 +09:00
|
|
|
// Quantize cacheable probes so smooth movement reuses obstacle rect lists
|
|
|
|
|
// across nearby frames while the inflated radius keeps collisions conservative.
|
2026-06-27 19:22:38 +09:00
|
|
|
const q = 48;
|
|
|
|
|
const qx = Math.floor((Number(x) || 0) / q);
|
|
|
|
|
const qy = Math.floor((Number(y) || 0) / q);
|
|
|
|
|
const qcx = (qx + 0.5) * q;
|
|
|
|
|
const qcy = (qy + 0.5) * q;
|
|
|
|
|
const inflatedRadius = Math.max(1, Number(radius || 0) || 1) + q * 0.82;
|
|
|
|
|
const qr = Math.ceil(inflatedRadius / q);
|
2026-06-27 21:50:05 +09:00
|
|
|
const cacheLimit = Math.min(72, Math.max(limit + 6, Math.ceil(limit * 1.35)));
|
2026-06-27 19:22:38 +09:00
|
|
|
const cacheKey = cacheable
|
2026-06-27 21:50:05 +09:00
|
|
|
? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`
|
2026-06-27 19:22:38 +09:00
|
|
|
: "";
|
|
|
|
|
if (cacheable) {
|
|
|
|
|
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
|
|
|
|
const cached = this._solidObstacleRectQueryCache.get(cacheKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
if (stats) stats.obstacleRectHits = (stats.obstacleRectHits || 0) + 1;
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
} else if (stats) {
|
|
|
|
|
stats.obstacleRectBypass = (stats.obstacleRectBypass || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const queryX = cacheable ? qcx : x;
|
|
|
|
|
const queryY = cacheable ? qcy : y;
|
|
|
|
|
const queryRadius = cacheable ? qr * q : radius;
|
|
|
|
|
const queryLimit = cacheable ? cacheLimit : limit;
|
2026-06-21 14:09:35 +09:00
|
|
|
const rects = [];
|
2026-06-27 19:22:38 +09:00
|
|
|
const stamp = (this._obstacleRectQueryStamp = (this._obstacleRectQueryStamp || 0) + 1);
|
2026-06-21 14:09:35 +09:00
|
|
|
let checked = 0;
|
2026-06-26 12:46:32 +09:00
|
|
|
const addRectsFor = (it) => {
|
2026-06-27 19:22:38 +09:00
|
|
|
if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) return false;
|
2026-06-26 12:46:32 +09:00
|
|
|
if (include && !include(it)) return false;
|
2026-06-21 14:09:35 +09:00
|
|
|
const partRects = this.solidObstacleRects(it);
|
2026-06-26 12:46:32 +09:00
|
|
|
if (!partRects.length) return false;
|
2026-06-27 19:22:38 +09:00
|
|
|
it._obstacleRectQueryStamp = stamp;
|
2026-06-21 14:09:35 +09:00
|
|
|
checked += 1;
|
2026-06-27 21:50:05 +09:00
|
|
|
const localLimit = it.type === "rotator" || it.type === "poison_block" || it.type === "reciprocator" ? maxRects : maxRects + 8;
|
|
|
|
|
for (const rect of partRects) {
|
|
|
|
|
if (rects.length >= localLimit) break;
|
|
|
|
|
// Dense custom mechanical shapes can produce dozens of segment rects.
|
|
|
|
|
// Keep only rects that can plausibly interact with this query point;
|
|
|
|
|
// otherwise one smooth-motion collision probe spends most of its time
|
|
|
|
|
// iterating far-away segments of the same object.
|
|
|
|
|
if (rectDistanceToPointLocal(rect, queryX, queryY) > queryRadius + 42) continue;
|
|
|
|
|
rects.push(rect);
|
|
|
|
|
}
|
2026-06-26 12:46:32 +09:00
|
|
|
return true;
|
|
|
|
|
};
|
2026-06-27 19:22:38 +09:00
|
|
|
const candidates = this.nearbyObstacles?.(queryX, queryY, queryRadius, false) || this.nearbyItems?.(queryX, queryY, queryRadius, false) || [];
|
|
|
|
|
visitNearestObstaclesLocal(queryX, queryY, candidates, queryLimit, (it) => {
|
2026-06-26 12:46:32 +09:00
|
|
|
addRectsFor(it);
|
2026-06-27 19:22:38 +09:00
|
|
|
return checked >= queryLimit;
|
|
|
|
|
}, { include, exclude, stamp });
|
|
|
|
|
if (checked < queryLimit && !this.nearbyObstacles) {
|
|
|
|
|
const mech = global.TarinaiMechanicalSystem;
|
|
|
|
|
const fallback = [];
|
|
|
|
|
for (const type of ["rotator", "poison_block", "reciprocator"]) {
|
|
|
|
|
for (const it of this.itemsOfType?.(type) || []) {
|
|
|
|
|
if (!it || it.dead || it._obstacleRectQueryStamp === stamp) continue;
|
|
|
|
|
const reach = mech?.reach?.(it) || Math.max(60, it.r || 64);
|
|
|
|
|
if (distXY(queryX, queryY, it.x, it.y) > queryRadius + reach + 36) continue;
|
|
|
|
|
fallback.push(it);
|
|
|
|
|
}
|
2026-06-26 12:46:32 +09:00
|
|
|
}
|
2026-06-27 19:22:38 +09:00
|
|
|
visitNearestObstaclesLocal(queryX, queryY, fallback, queryLimit - checked, (it) => {
|
|
|
|
|
addRectsFor(it);
|
|
|
|
|
return checked >= queryLimit;
|
|
|
|
|
}, { include, exclude, stamp });
|
|
|
|
|
}
|
|
|
|
|
if (stats) {
|
|
|
|
|
stats.obstacleRectMisses = (stats.obstacleRectMisses || 0) + 1;
|
|
|
|
|
stats.obstacleRectBuilt = (stats.obstacleRectBuilt || 0) + rects.length;
|
|
|
|
|
}
|
|
|
|
|
if (cacheable) {
|
|
|
|
|
const cache = this._solidObstacleRectQueryCache || (this._solidObstacleRectQueryCache = new Map());
|
|
|
|
|
if (cache.size > 160) cache.clear();
|
|
|
|
|
cache.set(cacheKey, rects);
|
2026-06-26 12:46:32 +09:00
|
|
|
}
|
2026-06-21 14:09:35 +09:00
|
|
|
return rects;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
pointInRect(x, y, r, padding = 0) {
|
2026-06-29 16:21:58 +09:00
|
|
|
return global.TarinaiCollisionFootprints.pointInRect(x, y, r, padding) ?? false;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
|
|
|
|
|
nearbyPoisonBlockHazardRects(x, y, radius, { maxChecks = 24 } = {}) {
|
|
|
|
|
if ((this.itemCounts?.poison_block || 0) <= 0) return [];
|
|
|
|
|
const rects = [];
|
|
|
|
|
const stamp = (this._poisonHazardQueryStamp = (this._poisonHazardQueryStamp || 0) + 1);
|
|
|
|
|
let checked = 0;
|
|
|
|
|
const addRectsFor = (it) => {
|
|
|
|
|
if (!it || it.dead || it.type !== "poison_block" || it._poisonHazardQueryStamp === stamp) return false;
|
2026-06-29 16:21:58 +09:00
|
|
|
const reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, it.r || 64);
|
2026-06-27 19:22:38 +09:00
|
|
|
if (distXY(x, y, it.x, it.y) > radius + reach + 36) return false;
|
2026-06-29 16:21:58 +09:00
|
|
|
const partRects = global.TarinaiMechanicalSystem.poisonHazardRects(it) || [];
|
2026-06-27 19:22:38 +09:00
|
|
|
if (!partRects.length) return false;
|
|
|
|
|
it._poisonHazardQueryStamp = stamp;
|
|
|
|
|
checked += 1;
|
|
|
|
|
for (const rect of partRects) rects.push(rect);
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
const candidates = this.nearbyHazards?.(x, y, radius + 48, false) || [];
|
|
|
|
|
for (const it of candidates) {
|
|
|
|
|
addRectsFor(it);
|
|
|
|
|
if (checked >= maxChecks) break;
|
|
|
|
|
}
|
|
|
|
|
if (checked < maxChecks && !this.nearbyHazards) {
|
|
|
|
|
for (const it of this.itemsOfType?.("poison_block") || []) {
|
|
|
|
|
if (addRectsFor(it) && checked >= maxChecks) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return rects;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
circleOverlapsObstacleRect(x, y, radius, r, padding = 0) {
|
|
|
|
|
if (!r) return false;
|
|
|
|
|
const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0);
|
|
|
|
|
if (r.oriented) {
|
|
|
|
|
const local = rectLocalPoint(r, x, y);
|
|
|
|
|
const clx = clamp(local.x, -(r.halfW || 0), r.halfW || 0);
|
|
|
|
|
const cly = clamp(local.y, -(r.halfH || 0), r.halfH || 0);
|
|
|
|
|
return Math.hypot(local.x - clx, local.y - cly) < rr;
|
|
|
|
|
}
|
|
|
|
|
const cx = clamp(x, r.left, r.right);
|
|
|
|
|
const cy = clamp(y, r.top, r.bottom);
|
|
|
|
|
return Math.hypot(x - cx, y - cy) < rr;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
applyPoisonBlockContactDamage(t, rect, rr) {
|
|
|
|
|
if (!t || t.dead || !rect?.poisonBlock || !rect.item || rect.item.dead) return false;
|
|
|
|
|
if (!this.circleOverlapsObstacleRect(t.x, t.y, rr, rect, 1.0)) return false;
|
|
|
|
|
const now = this.time || 0;
|
|
|
|
|
const key = rect.item.id || "poison";
|
|
|
|
|
t._poisonBlockHitAt = t._poisonBlockHitAt || Object.create(null);
|
2026-06-29 16:21:58 +09:00
|
|
|
const body = global.TarinaiPhysicsBodySystem.ensureBody(rect.item, this, { syncFromLegacy: false });
|
2026-06-27 19:22:38 +09:00
|
|
|
const solid = body?.collision ? body.collision.solid !== false : true;
|
|
|
|
|
const cooldown = solid ? 0.30 : 0.42;
|
|
|
|
|
if ((t._poisonBlockHitAt[key] || -999) + cooldown > now) return false;
|
|
|
|
|
t._poisonBlockHitAt[key] = now;
|
|
|
|
|
const velocity = body?.velocity || {};
|
|
|
|
|
const vx = Number((velocity.x ?? rect.item.vx) || 0) || 0;
|
|
|
|
|
const vy = Number((velocity.y ?? rect.item.vy) || 0) || 0;
|
2026-06-29 16:21:58 +09:00
|
|
|
const omega = Number((velocity.angular ?? global.TarinaiPhysicsBodySystem.scalar(rect.item, "spin", 0)) || 0) || 0;
|
2026-06-27 19:22:38 +09:00
|
|
|
const speed = Math.hypot(vx, vy) + Math.abs(omega) * Math.max(28, rect.item.r || 64) * 0.35;
|
2026-06-29 16:21:58 +09:00
|
|
|
const base = Math.max(1, Number((body?.hazard?.damage ?? global.TarinaiPhysicsBodySystem.scalar(rect.item, "damage", 7)) || 7) || 7);
|
2026-06-27 19:22:38 +09:00
|
|
|
const damage = clamp(base * (solid ? 1.0 : 0.72) + speed * 0.018, 2.0, 18.0);
|
2026-06-28 13:40:41 +09:00
|
|
|
if (typeof this.applyImpactDamage === "function") this.applyImpactDamage(t, damage, "\u6bd2\u30d6\u30ed\u30c3\u30af", { source: rect.item, x: t.x, y: t.y });
|
|
|
|
|
else if (typeof t.damage === "function") t.damage(damage, "\u6bd2\u30d6\u30ed\u30c3\u30af");
|
|
|
|
|
// HP\u30d0\u30fc\u306f\u63cf\u753b\u5074\u3067\u300c\u30c0\u30e1\u30fc\u30b8\u4e2d\u300d\u306e\u8868\u793a\u6761\u4ef6\u3092\u898b\u308b\u305f\u3081\u3001\u6bd2\u30d6\u30ed\u30c3\u30af\u3067\u3082\u660e\u793a\u7684\u306b\u50b7\u307f\u72b6\u614b\u3092\u7acb\u3066\u308b\u3002
|
2026-06-27 19:22:38 +09:00
|
|
|
t.showHpBar?.(4.8);
|
|
|
|
|
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.82);
|
|
|
|
|
t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.28);
|
|
|
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
|
|
|
|
|
if (typeof t.bubble === "function" && now >= (t.lastPoisonBlockBubbleAt || -999) + 0.85) {
|
|
|
|
|
t.lastPoisonBlockBubbleAt = now;
|
|
|
|
|
t.bubble("!", 0.48, "rgba(94,166,76,0.78)");
|
|
|
|
|
}
|
2026-06-28 23:07:40 +09:00
|
|
|
// 物理ブロック衝突の視覚エフェクトは出さない。接触時の状態変化はHPバーと吹き出しだけに限定する。
|
2026-06-27 19:22:38 +09:00
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resolvePoisonBlockContactDamage(t, rr) {
|
|
|
|
|
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false;
|
|
|
|
|
if ((this.itemCounts?.poison_block || 0) <= 0) return false;
|
|
|
|
|
const now = this.time || 0;
|
|
|
|
|
const interval = Math.max(0.045, Math.min(0.10, Number(CONFIG?.poisonBlockProbeInterval || 0.075) || 0.075));
|
|
|
|
|
if ((t._nextPoisonBlockProbeAt || 0) > now) return false;
|
|
|
|
|
t._nextPoisonBlockProbeAt = now + interval;
|
|
|
|
|
let hit = false;
|
|
|
|
|
for (const rect of this.nearbyPoisonBlockHazardRects(t.x, t.y, rr + 150, { maxChecks: 32 })) {
|
|
|
|
|
hit = this.applyPoisonBlockContactDamage(t, rect, rr) || hit;
|
|
|
|
|
}
|
|
|
|
|
return hit;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-28 23:07:40 +09:00
|
|
|
|
|
|
|
|
resolvePoisonBlockItemContacts(opts = {}) {
|
|
|
|
|
if ((this.itemCounts?.poison_block || 0) <= 0) return 0;
|
|
|
|
|
const meltTypes = new Set(["grass_bed", "zunchi", "water", "grass"]);
|
|
|
|
|
let possible = 0;
|
|
|
|
|
for (const type of meltTypes) possible += this.itemCounts?.[type] || 0;
|
|
|
|
|
if (possible <= 0) return 0;
|
|
|
|
|
const maxPoison = Math.max(1, Math.min(48, Number(opts.maxPoison || 24) || 24));
|
|
|
|
|
const maxTargets = Math.max(1, Math.min(160, Number(opts.maxTargets || 80) || 80));
|
|
|
|
|
const poisonBlocks = this.itemsOfType?.("poison_block") || [];
|
|
|
|
|
let removed = 0;
|
|
|
|
|
let checkedPoison = 0;
|
|
|
|
|
const lifecycle = global.TarinaiStructureLifecycle;
|
|
|
|
|
for (const poison of poisonBlocks) {
|
|
|
|
|
if (!poison || poison.dead || poison.type !== "poison_block") continue;
|
|
|
|
|
checkedPoison += 1;
|
|
|
|
|
if (checkedPoison > maxPoison) break;
|
2026-06-29 16:21:58 +09:00
|
|
|
const rects = global.TarinaiMechanicalSystem.poisonHazardRects(poison) || [];
|
2026-06-28 23:07:40 +09:00
|
|
|
if (!rects.length) continue;
|
2026-06-29 16:21:58 +09:00
|
|
|
const reach = global.TarinaiMechanicalSystem.reach(poison) || Math.max(72, poison.r || 64);
|
2026-06-28 23:07:40 +09:00
|
|
|
const source = this.nearbyItems?.(poison.x, poison.y, reach + 96, true) || this.items || [];
|
|
|
|
|
let checkedTargets = 0;
|
|
|
|
|
for (const target of source) {
|
|
|
|
|
if (!target || target.dead || target === poison || !meltTypes.has(target.type)) continue;
|
|
|
|
|
checkedTargets += 1;
|
|
|
|
|
if (checkedTargets > maxTargets) break;
|
|
|
|
|
const rr = Math.max(5, Number(target.r || target.radius || itemRadiusFor?.(target.type, 10) || 10) || 10) * (target.type === "grass_bed" ? 1.15 : 0.92);
|
|
|
|
|
let touching = false;
|
|
|
|
|
for (const rect of rects) {
|
|
|
|
|
if (this.circleOverlapsObstacleRect(target.x, target.y, rr, rect, 1.5)) { touching = true; break; }
|
|
|
|
|
}
|
|
|
|
|
if (!touching) continue;
|
|
|
|
|
const wasLive = !target.dead;
|
2026-06-29 16:21:58 +09:00
|
|
|
const destroyed = (lifecycle)?.deleteItem?.(this, target, {
|
2026-06-28 23:07:40 +09:00
|
|
|
reason: "poison-block-contact",
|
|
|
|
|
userReason: "毒ブロックに触れて消えた",
|
|
|
|
|
wake: true,
|
|
|
|
|
breaker: poison,
|
|
|
|
|
panicOwner: false,
|
|
|
|
|
});
|
|
|
|
|
if (wasLive && destroyed) {
|
|
|
|
|
removed += 1;
|
|
|
|
|
this.markTerrainDirtyAt?.(target.x, target.y, Math.max(36, rr + 16), "poison-block-contact");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (removed > 0) {
|
|
|
|
|
this.compactItems?.();
|
|
|
|
|
this.updateItemCounts?.("poison-block-contact");
|
|
|
|
|
this.markSpatialDirty?.("poison-block-contact");
|
|
|
|
|
this.drawListDirty = true;
|
|
|
|
|
}
|
|
|
|
|
return removed;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
pushTarinaiOutOfRect(t, r, rr, opts = {}) {
|
2026-06-21 14:09:35 +09:00
|
|
|
if (!t || !r) return false;
|
2026-06-26 19:04:32 +09:00
|
|
|
const preVx = Number(t.vx || 0) || 0;
|
|
|
|
|
const preVy = Number(t.vy || 0) || 0;
|
2026-06-25 22:30:35 +09:00
|
|
|
let cx, cy, dx, dy, d, nx, ny;
|
|
|
|
|
let insidePenetration = 0;
|
|
|
|
|
if (r.oriented) {
|
|
|
|
|
const local = rectLocalPoint(r, t.x, t.y);
|
|
|
|
|
const clx = clamp(local.x, -(r.halfW || 0), r.halfW || 0);
|
|
|
|
|
const cly = clamp(local.y, -(r.halfH || 0), r.halfH || 0);
|
|
|
|
|
dx = local.x - clx;
|
|
|
|
|
dy = local.y - cly;
|
|
|
|
|
d = Math.hypot(dx, dy);
|
|
|
|
|
if (d >= rr) return false;
|
|
|
|
|
if (d < 0.001) {
|
|
|
|
|
const left = Math.abs(local.x + (r.halfW || 0));
|
|
|
|
|
const right = Math.abs((r.halfW || 0) - local.x);
|
|
|
|
|
const top = Math.abs(local.y + (r.halfH || 0));
|
|
|
|
|
const bottom = Math.abs((r.halfH || 0) - local.y);
|
|
|
|
|
const m = Math.min(left, right, top, bottom);
|
|
|
|
|
insidePenetration = m;
|
|
|
|
|
if (m === left) { dx = -1; dy = 0; d = 1; }
|
|
|
|
|
else if (m === right) { dx = 1; dy = 0; d = 1; }
|
|
|
|
|
else if (m === top) { dx = 0; dy = -1; d = 1; }
|
|
|
|
|
else { dx = 0; dy = 1; d = 1; }
|
|
|
|
|
}
|
|
|
|
|
const n = rectWorldNormal(r, dx / d, dy / d);
|
|
|
|
|
nx = n.x; ny = n.y;
|
|
|
|
|
cx = t.x - dx; cy = t.y - dy;
|
|
|
|
|
} else {
|
|
|
|
|
cx = clamp(t.x, r.left, r.right);
|
|
|
|
|
cy = clamp(t.y, r.top, r.bottom);
|
|
|
|
|
dx = t.x - cx;
|
|
|
|
|
dy = t.y - cy;
|
|
|
|
|
d = Math.hypot(dx, dy);
|
|
|
|
|
if (d >= rr) return false;
|
|
|
|
|
if (d < 0.001) {
|
|
|
|
|
const left = Math.abs(t.x - r.left);
|
|
|
|
|
const right = Math.abs(r.right - t.x);
|
|
|
|
|
const top = Math.abs(t.y - r.top);
|
|
|
|
|
const bottom = Math.abs(r.bottom - t.y);
|
|
|
|
|
const m = Math.min(left, right, top, bottom);
|
|
|
|
|
if (m === left) { dx = -1; dy = 0; d = 1; }
|
|
|
|
|
else if (m === right) { dx = 1; dy = 0; d = 1; }
|
|
|
|
|
else if (m === top) { dx = 0; dy = -1; d = 1; }
|
|
|
|
|
else { dx = 0; dy = 1; d = 1; }
|
|
|
|
|
}
|
|
|
|
|
nx = dx / d;
|
|
|
|
|
ny = dy / d;
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
2026-06-27 19:22:38 +09:00
|
|
|
const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8;
|
|
|
|
|
const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, rr * 2.8) : Math.max(10, rr * 0.92);
|
|
|
|
|
const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush);
|
2026-06-25 22:30:35 +09:00
|
|
|
const push = r.oriented && insidePenetration > 0
|
2026-06-27 19:22:38 +09:00
|
|
|
? Math.min(insidePenetration + rr + slop, maxPush)
|
|
|
|
|
: Math.min(rr - d + slop, maxPush);
|
2026-06-25 22:30:35 +09:00
|
|
|
t.x += nx * push;
|
|
|
|
|
t.y += ny * push;
|
2026-06-28 23:07:40 +09:00
|
|
|
t.lastSolidObstacleCollisionSource = r.item || null;
|
|
|
|
|
t.lastSolidObstacleCollisionAt = this.time || 0;
|
2026-06-25 22:30:35 +09:00
|
|
|
if (r.bounce) {
|
|
|
|
|
const restitution = Number.isFinite(r.restitution) ? r.restitution : 1.08;
|
|
|
|
|
const vx = t.vx || 0;
|
|
|
|
|
const vy = t.vy || 0;
|
|
|
|
|
const toward = vx * nx + vy * ny;
|
|
|
|
|
if (toward < 0) {
|
|
|
|
|
t.vx = vx - (1 + restitution) * toward * nx;
|
|
|
|
|
t.vy = vy - (1 + restitution) * toward * ny;
|
|
|
|
|
} else {
|
|
|
|
|
const kick = Math.max(46, Math.min(120, rr * 3.2));
|
|
|
|
|
t.vx = vx + nx * kick;
|
|
|
|
|
t.vy = vy + ny * kick;
|
|
|
|
|
}
|
|
|
|
|
const minBounceSpeed = Math.max(0, Number(r.minBounceSpeed || 0) || 0);
|
|
|
|
|
if (minBounceSpeed > 0) {
|
|
|
|
|
const currentOut = (t.vx || 0) * nx + (t.vy || 0) * ny + (t.impulseVx || 0) * nx + (t.impulseVy || 0) * ny;
|
|
|
|
|
const now = this.time || 0;
|
|
|
|
|
if (currentOut < minBounceSpeed && ((t.lastBounceFenceImpulseAt || -999) + 0.16 <= now || toward < 0)) {
|
|
|
|
|
const add = minBounceSpeed - currentOut;
|
|
|
|
|
t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -520, 520);
|
|
|
|
|
t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -520, 520);
|
|
|
|
|
t.lastBounceFenceImpulseAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.vx = clamp(t.vx || 0, -520, 520);
|
|
|
|
|
t.vy = clamp(t.vy || 0, -520, 520);
|
|
|
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.18);
|
|
|
|
|
if (t.bubble && (this.time || 0) >= (t.lastBounceFenceBubbleAt || -999) + 0.55) {
|
|
|
|
|
t.lastBounceFenceBubbleAt = this.time || 0;
|
|
|
|
|
t.bubble("!", 0.45, "rgba(80,130,210,0.72)");
|
|
|
|
|
}
|
|
|
|
|
if ((this.time || 0) >= (t.lastBounceFenceEffectAt || -999) + 0.08) {
|
|
|
|
|
t.lastBounceFenceEffectAt = this.time || 0;
|
|
|
|
|
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }));
|
|
|
|
|
}
|
2026-06-26 19:04:32 +09:00
|
|
|
} else if (r.mechanical) {
|
2026-06-29 16:21:58 +09:00
|
|
|
global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(t, r, nx, ny, preVx, preVy, {
|
2026-06-27 19:22:38 +09:00
|
|
|
damping: r.rotator ? 0.78 : (r.poisonBlock ? 0.74 : 0.80),
|
|
|
|
|
surfaceScale: r.rotator ? 0.52 : (r.poisonBlock ? 0.38 : 0.64),
|
|
|
|
|
normalBoost: r.rotator ? 42 : (r.poisonBlock ? 30 : 36),
|
|
|
|
|
maxSpeed: r.rotator ? 680 : (r.poisonBlock ? 560 : 620),
|
|
|
|
|
impulseVScale: r.rotator ? 0.10 : (r.poisonBlock ? 0.05 : 0.08),
|
|
|
|
|
impulseMax: r.rotator ? 360 : (r.poisonBlock ? 260 : 320),
|
2026-06-26 19:04:32 +09:00
|
|
|
passiveImpulseScale: 0.82,
|
|
|
|
|
spinKick: 0,
|
|
|
|
|
});
|
|
|
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10);
|
2026-06-25 22:30:35 +09:00
|
|
|
} else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
2026-06-21 14:09:35 +09:00
|
|
|
else t.vy *= -0.18;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-27 19:22:38 +09:00
|
|
|
moveTarinaiWithCollision(t, dx, dy, opts = {}) {
|
|
|
|
|
if (!t || t.dead) return false;
|
|
|
|
|
const moveX = Number(dx || 0) || 0;
|
|
|
|
|
const moveY = Number(dy || 0) || 0;
|
|
|
|
|
const dist = Math.hypot(moveX, moveY);
|
|
|
|
|
if (dist <= 0.0001) return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: opts.reason || "tarinai-stationary" });
|
|
|
|
|
if (this.isTarinaiHiddenInNestBox?.(t)) {
|
|
|
|
|
t.x += moveX;
|
|
|
|
|
t.y += moveY;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
|
|
|
|
|
const maxStep = Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8));
|
|
|
|
|
const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep)));
|
|
|
|
|
const stepX = moveX / steps;
|
|
|
|
|
const stepY = moveY / steps;
|
|
|
|
|
let collided = false;
|
|
|
|
|
for (let i = 0; i < steps; i++) {
|
|
|
|
|
const sx = t.x;
|
|
|
|
|
const sy = t.y;
|
|
|
|
|
const ex = sx + stepX;
|
|
|
|
|
const ey = sy + stepY;
|
|
|
|
|
const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, {
|
|
|
|
|
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
|
|
|
|
|
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
|
|
|
|
|
});
|
|
|
|
|
if (hit && hit.t <= 1) {
|
|
|
|
|
const safeT = Math.max(0, hit.t - 0.035);
|
|
|
|
|
t.x = sx + stepX * safeT;
|
|
|
|
|
t.y = sy + stepY * safeT;
|
|
|
|
|
const toward = (t.vx || 0) * hit.nx + (t.vy || 0) * hit.ny;
|
|
|
|
|
if (toward < 0) {
|
|
|
|
|
t.vx -= toward * hit.nx;
|
|
|
|
|
t.vy -= toward * hit.ny;
|
|
|
|
|
}
|
|
|
|
|
collided = this.resolveSolidObstacleCollision(t, {
|
|
|
|
|
maxPasses: 3,
|
|
|
|
|
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
|
|
|
|
|
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
|
|
|
|
|
slop: 0.30,
|
|
|
|
|
reason: opts.reason || "tarinai-swept-hit",
|
|
|
|
|
}) || true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
t.x = ex;
|
|
|
|
|
t.y = ey;
|
|
|
|
|
const passHit = this.resolveSolidObstacleCollision(t, {
|
|
|
|
|
maxPasses: 2,
|
|
|
|
|
searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140),
|
|
|
|
|
maxChecks: Math.max(opts.light ? 10 : 28, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
|
|
|
|
|
slop: 0.35,
|
|
|
|
|
reason: opts.reason || "tarinai-swept-move",
|
|
|
|
|
});
|
|
|
|
|
collided = passHit || collided;
|
|
|
|
|
}
|
|
|
|
|
if (collided) {
|
|
|
|
|
t.lastSolidObstacleCollisionAt = this.time || 0;
|
|
|
|
|
this.markSpatialDirty?.("tarinai-moved-collision");
|
|
|
|
|
}
|
|
|
|
|
return collided;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resolveSolidObstacleCollision(t, opts = {}) {
|
|
|
|
|
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false;
|
|
|
|
|
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
|
|
|
|
|
this.resolvePoisonBlockContactDamage(t, rr);
|
2026-06-21 14:09:35 +09:00
|
|
|
let pushed = false;
|
2026-06-25 22:30:35 +09:00
|
|
|
let bounced = false;
|
2026-06-27 19:22:38 +09:00
|
|
|
const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2));
|
|
|
|
|
const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150));
|
|
|
|
|
const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24);
|
|
|
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
|
|
|
let passPushed = false;
|
|
|
|
|
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks })) {
|
|
|
|
|
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue;
|
|
|
|
|
if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) {
|
|
|
|
|
pushed = true;
|
|
|
|
|
passPushed = true;
|
|
|
|
|
if (rect?.bounce) bounced = true;
|
|
|
|
|
}
|
2026-06-25 22:30:35 +09:00
|
|
|
}
|
2026-06-27 19:22:38 +09:00
|
|
|
if (!passPushed) break;
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
if (pushed) {
|
2026-06-27 19:22:38 +09:00
|
|
|
const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18);
|
2026-06-21 14:09:35 +09:00
|
|
|
t.x = clamp(t.x, pad, this.w - pad);
|
|
|
|
|
t.y = clamp(t.y, pad, this.h - pad);
|
2026-06-25 22:30:35 +09:00
|
|
|
const maxV = bounced ? 520 : 130;
|
|
|
|
|
t.vx = clamp(t.vx || 0, -maxV, maxV);
|
|
|
|
|
t.vy = clamp(t.vy || 0, -maxV, maxV);
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
2026-06-27 19:22:38 +09:00
|
|
|
return pushed;
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resolveFenceCollision(t) {
|
2026-06-27 19:22:38 +09:00
|
|
|
return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: "resolve-fence" });
|
2026-06-21 14:09:35 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
segmentIntersectsRect(x1, y1, x2, y2, r) {
|
|
|
|
|
if (!r) return false;
|
2026-06-25 22:30:35 +09:00
|
|
|
if (r.oriented) return segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, 0);
|
2026-06-21 14:09:35 +09:00
|
|
|
if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true;
|
|
|
|
|
const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => {
|
|
|
|
|
const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px);
|
|
|
|
|
return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy);
|
|
|
|
|
};
|
|
|
|
|
return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top)
|
|
|
|
|
|| intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom)
|
|
|
|
|
|| intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom)
|
|
|
|
|
|| intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-30 10:57:06 +09:00
|
|
|
isFenceRoutingRect(rect) {
|
|
|
|
|
const type = rect?.item?.type || rect?.type || "";
|
|
|
|
|
if (!type || type === "glass_wall") return false;
|
|
|
|
|
if (type === "gate_fence" && rect?.item?.gateOpen) return false;
|
|
|
|
|
return Boolean(this.isFenceType?.(type));
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 21:02:04 +09:00
|
|
|
pathBlockedByFence(x1, y1, x2, y2, padding = 16, opts = {}) {
|
2026-06-21 14:09:35 +09:00
|
|
|
const cx = (x1 + x2) / 2;
|
|
|
|
|
const cy = (y1 + y2) / 2;
|
|
|
|
|
const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120;
|
2026-06-29 21:02:04 +09:00
|
|
|
const exclude = opts?.exclude || null;
|
|
|
|
|
for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) {
|
|
|
|
|
if (exclude && rect?.item === exclude) continue;
|
2026-06-30 10:57:06 +09:00
|
|
|
if (!this.isFenceRoutingRect?.(rect)) continue;
|
2026-06-25 22:30:35 +09:00
|
|
|
if (rect?.oriented) {
|
|
|
|
|
const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding };
|
|
|
|
|
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
|
|
|
|
|
} else {
|
|
|
|
|
const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding };
|
|
|
|
|
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
|
|
|
|
|
}
|
2026-06-21 14:09:35 +09:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 21:02:04 +09:00
|
|
|
targetBlockedByObstacle(actor, target, padding = 14) {
|
|
|
|
|
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return false;
|
|
|
|
|
return Boolean(this.pathBlockedByFence?.(actor.x, actor.y, target.x, target.y, padding, { exclude: target }));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pointBlockedByObstacle(x, y, padding = 14, opts = {}) {
|
|
|
|
|
const exclude = opts?.exclude || null;
|
|
|
|
|
const searchRadius = Math.max(48, Number(padding || 0) + 72);
|
|
|
|
|
for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) {
|
|
|
|
|
if (exclude && rect?.item === exclude) continue;
|
|
|
|
|
if (this.pointInRect?.(x, y, rect, padding)) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
findTarinaiPathWaypoint(actor, target, opts = {}) {
|
|
|
|
|
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
|
|
|
|
const tx = Number(target.x);
|
|
|
|
|
const ty = Number(target.y);
|
|
|
|
|
const ax = Number(actor.x);
|
|
|
|
|
const ay = Number(actor.y);
|
|
|
|
|
const targetItem = opts?.targetItem || target?.hostItem || (target?.isStructure || target?.type ? target : null);
|
|
|
|
|
const exclude = opts?.exclude || targetItem || null;
|
|
|
|
|
const rr = Math.max(8, Number(actor.radius || 20) || 20);
|
|
|
|
|
const padding = Math.max(16, Number(opts.padding || 0) || rr * 0.78);
|
|
|
|
|
const directClear = !this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude });
|
|
|
|
|
if (directClear) {
|
|
|
|
|
actor._pathWaypoint = null;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetId = targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
|
|
|
|
const cached = actor._pathWaypoint || null;
|
|
|
|
|
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
|
|
|
|
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
|
|
|
|
&& !this.pointBlockedByObstacle(cached.x, cached.y, Math.max(6, padding * 0.45), { exclude, maxChecks: 18 })
|
|
|
|
|
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
|
|
|
|
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const midX = (ax + tx) * 0.5;
|
|
|
|
|
const midY = (ay + ty) * 0.5;
|
|
|
|
|
const totalD = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
|
|
|
|
const rects = this.nearbySolidObstacleRects?.(midX, midY, totalD * 0.5 + padding + 190, { exclude, maxChecks: Math.max(32, Number(opts.maxChecks || 0) || 46), maxRects: 80 }) || [];
|
|
|
|
|
const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45;
|
|
|
|
|
const candidates = [];
|
|
|
|
|
const add = (x, y, sourceRect, grade = 0) => {
|
|
|
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
|
|
|
if (x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return;
|
|
|
|
|
candidates.push({ x, y, sourceRect, grade });
|
|
|
|
|
};
|
|
|
|
|
const worldPoint = (r, lx, ly) => {
|
|
|
|
|
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
|
|
|
|
const sn = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
|
|
|
|
const cx = Number(r.cx ?? ((Number(r.left || 0) + Number(r.right || 0)) * 0.5)) || 0;
|
|
|
|
|
const cy = Number(r.cy ?? ((Number(r.top || 0) + Number(r.bottom || 0)) * 0.5)) || 0;
|
|
|
|
|
return { x: cx + lx * c - ly * sn, y: cy + lx * sn + ly * c };
|
|
|
|
|
};
|
|
|
|
|
const addRectCandidates = (r, index = 0) => {
|
|
|
|
|
const pad = Math.max(34, padding + rr * 1.45 + 18 + Math.min(18, index * 3));
|
|
|
|
|
if (r.oriented) {
|
|
|
|
|
const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad;
|
|
|
|
|
const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad;
|
|
|
|
|
for (const [lx, ly, grade] of [[-hw, -hh, 0], [hw, -hh, 0], [hw, hh, 0], [-hw, hh, 0], [0, -hh, 1], [hw, 0, 1], [0, hh, 1], [-hw, 0, 1]]) {
|
|
|
|
|
const p = worldPoint(r, lx, ly);
|
|
|
|
|
add(p.x, p.y, r, grade);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const left = Number(r.left || 0) - pad;
|
|
|
|
|
const right = Number(r.right || 0) + pad;
|
|
|
|
|
const top = Number(r.top || 0) - pad;
|
|
|
|
|
const bottom = Number(r.bottom || 0) + pad;
|
|
|
|
|
const cx = (left + right) * 0.5;
|
|
|
|
|
const cy = (top + bottom) * 0.5;
|
|
|
|
|
for (const [x, y, grade] of [[left, top, 0], [right, top, 0], [right, bottom, 0], [left, bottom, 0], [cx, top, 1], [right, cy, 1], [cx, bottom, 1], [left, cy, 1]]) add(x, y, r, grade);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let blockers = 0;
|
|
|
|
|
for (const rect of rects) {
|
2026-06-30 10:57:06 +09:00
|
|
|
if (!this.isFenceRoutingRect?.(rect)) continue;
|
2026-06-29 21:02:04 +09:00
|
|
|
const inflated = rect?.oriented
|
|
|
|
|
? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }
|
|
|
|
|
: { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding };
|
|
|
|
|
if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue;
|
|
|
|
|
addRectCandidates(rect, blockers);
|
|
|
|
|
blockers += 1;
|
|
|
|
|
if (blockers >= 4) break;
|
|
|
|
|
}
|
|
|
|
|
if (!candidates.length) return null;
|
|
|
|
|
|
|
|
|
|
let best = null;
|
|
|
|
|
let bestScore = Infinity;
|
|
|
|
|
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
|
|
|
|
|
for (const c of candidates) {
|
|
|
|
|
if (this.pointBlockedByObstacle(c.x, c.y, Math.max(5, padding * 0.42), { exclude, maxChecks: 20 })) continue;
|
|
|
|
|
const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude });
|
|
|
|
|
if (firstBlocked) continue;
|
|
|
|
|
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
|
|
|
|
|
const d1 = Math.hypot(c.x - ax, c.y - ay);
|
|
|
|
|
const d2 = Math.hypot(tx - c.x, ty - c.y);
|
|
|
|
|
const progress = totalD - d2;
|
|
|
|
|
const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0;
|
|
|
|
|
const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias;
|
2026-06-30 10:57:06 +09:00
|
|
|
if (score < bestScore) { best = { ...c, secondBlocked }; bestScore = score; }
|
|
|
|
|
}
|
|
|
|
|
if (!best || (best.secondBlocked && opts.preferFullPath)) {
|
|
|
|
|
const grid = this.findGridPathWaypoint?.(actor, target, { ...opts, targetItem, exclude, padding, targetId });
|
|
|
|
|
if (grid) return grid;
|
|
|
|
|
if (!best) return null;
|
2026-06-29 21:02:04 +09:00
|
|
|
}
|
|
|
|
|
actor._pathWaypoint = { x: best.x, y: best.y, targetId, until: (this.time || 0) + 0.95 };
|
|
|
|
|
return { x: best.x, y: best.y, dead: false, detour: true, routeTargetId: targetId };
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-30 10:57:06 +09:00
|
|
|
findGridPathWaypoint(actor, target, opts = {}) {
|
|
|
|
|
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
|
|
|
|
const ax = Number(actor.x), ay = Number(actor.y), tx = Number(target.x), ty = Number(target.y);
|
|
|
|
|
const targetId = opts.targetId || opts.targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
|
|
|
|
const cached = actor._gridPathWaypoint || null;
|
|
|
|
|
const padding = Math.max(16, Number(opts.padding || 0) || (Number(actor.radius || 20) * 0.78));
|
|
|
|
|
const exclude = opts.exclude || opts.targetItem || target || null;
|
|
|
|
|
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
|
|
|
|
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
|
|
|
|
&& !this.pointBlockedByObstacle?.(cached.x, cached.y, Math.max(6, padding * 0.42), { exclude, maxChecks: 20 })
|
|
|
|
|
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
|
|
|
|
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dTotal = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
|
|
|
|
const step = Math.max(42, Math.min(72, Number(opts.gridStep || 0) || dTotal / 7));
|
|
|
|
|
const padWorld = (CONFIG.worldPadding || 28) + Math.max(8, Number(actor.radius || 20) * 0.45);
|
|
|
|
|
const margin = Math.max(190, step * 3.2);
|
|
|
|
|
const minX = Math.max(padWorld, Math.min(ax, tx) - margin);
|
|
|
|
|
const maxX = Math.min(this.w - padWorld, Math.max(ax, tx) + margin);
|
|
|
|
|
const minY = Math.max(padWorld, Math.min(ay, ty) - margin);
|
|
|
|
|
const maxY = Math.min(this.h - padWorld, Math.max(ay, ty) + margin);
|
|
|
|
|
const cols = Math.max(2, Math.min(18, Math.ceil((maxX - minX) / step) + 1));
|
|
|
|
|
const rows = Math.max(2, Math.min(18, Math.ceil((maxY - minY) / step) + 1));
|
|
|
|
|
const px = (ix) => cols <= 1 ? minX : minX + (maxX - minX) * ix / (cols - 1);
|
|
|
|
|
const py = (iy) => rows <= 1 ? minY : minY + (maxY - minY) * iy / (rows - 1);
|
|
|
|
|
const nearest = (x, y) => ({ ix: clamp(Math.round((x - minX) / Math.max(1, maxX - minX) * (cols - 1)), 0, cols - 1), iy: clamp(Math.round((y - minY) / Math.max(1, maxY - minY) * (rows - 1)), 0, rows - 1) });
|
|
|
|
|
const start = nearest(ax, ay);
|
|
|
|
|
const goal = nearest(tx, ty);
|
|
|
|
|
const key = (ix, iy) => `${ix},${iy}`;
|
|
|
|
|
const inBounds = (ix, iy) => ix >= 0 && iy >= 0 && ix < cols && iy < rows;
|
|
|
|
|
const pointOpen = (ix, iy) => {
|
|
|
|
|
if (!inBounds(ix, iy)) return false;
|
|
|
|
|
if (ix === start.ix && iy === start.iy) return true;
|
|
|
|
|
if (ix === goal.ix && iy === goal.iy) return true;
|
|
|
|
|
return !this.pointBlockedByObstacle?.(px(ix), py(iy), Math.max(6, padding * 0.45), { exclude, maxChecks: 18 });
|
|
|
|
|
};
|
|
|
|
|
const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude });
|
|
|
|
|
const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);
|
|
|
|
|
const open = [{ ...start, g: 0, f: h(start.ix, start.iy), parent: null }];
|
|
|
|
|
const bestByKey = new Map([[key(start.ix, start.iy), open[0]]]);
|
|
|
|
|
let found = null;
|
|
|
|
|
let guard = 0;
|
|
|
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
|
|
|
while (open.length && guard++ < 420) {
|
|
|
|
|
open.sort((a, b) => a.f - b.f);
|
|
|
|
|
const cur = open.shift();
|
|
|
|
|
if (cur.ix === goal.ix && cur.iy === goal.iy) { found = cur; break; }
|
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
|
|
|
const nx = cur.ix + dx, ny = cur.iy + dy;
|
|
|
|
|
if (!pointOpen(nx, ny)) continue;
|
|
|
|
|
const nb = { ix: nx, iy: ny };
|
|
|
|
|
if (!edgeOpen(cur, nb)) continue;
|
|
|
|
|
const diagonal = dx && dy;
|
|
|
|
|
const ng = cur.g + (diagonal ? 1.42 : 1) * step;
|
|
|
|
|
const k = key(nx, ny);
|
|
|
|
|
const oldNode = bestByKey.get(k);
|
|
|
|
|
if (oldNode && oldNode.g <= ng) continue;
|
|
|
|
|
const node = { ix: nx, iy: ny, g: ng, f: ng + h(nx, ny), parent: cur };
|
|
|
|
|
bestByKey.set(k, node);
|
|
|
|
|
open.push(node);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!found) return null;
|
|
|
|
|
const path = [];
|
|
|
|
|
for (let n = found; n; n = n.parent) path.push(n);
|
|
|
|
|
path.reverse();
|
|
|
|
|
const chosen = path[Math.min(path.length - 1, Math.max(1, path.length > 3 ? 2 : 1))];
|
|
|
|
|
if (!chosen) return null;
|
|
|
|
|
const wx = px(chosen.ix), wy = py(chosen.iy);
|
|
|
|
|
actor._gridPathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
|
|
|
|
actor._pathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
|
|
|
|
return { x: wx, y: wy, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-28 23:07:40 +09:00
|
|
|
grassBlockedAt(x, y, self = null, opts = {}) {
|
|
|
|
|
const ignoreSoft = Boolean(opts?.ignoreSoftPlacement);
|
2026-06-21 14:09:35 +09:00
|
|
|
for (const it of this.nearbyItems(x, y, 96)) {
|
|
|
|
|
if (it === self || it.dead) continue;
|
2026-06-29 16:21:58 +09:00
|
|
|
if (ignoreSoft && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
|
2026-06-21 14:09:35 +09:00
|
|
|
for (const r of this.solidObstacleRects(it)) {
|
|
|
|
|
if (this.pointInRect(x, y, r, 10)) return true;
|
|
|
|
|
}
|
|
|
|
|
if (it.type !== "zunchi") continue;
|
|
|
|
|
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
|
|
|
|
|
const dy = (y - it.y) / Math.max(12, it.r * 0.82);
|
|
|
|
|
if (dx * dx + dy * dy < 1) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
|
|
|
|
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
|
|
|
|
|
if (it === self || it.dead || it.type !== "grass") continue;
|
|
|
|
|
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
grassOnTarinaiAt(x, y, minGap = 24) {
|
|
|
|
|
for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
|
|
|
|
|
if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
|
|
|
|
|
if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
|
|
|
|
|
if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
|
|
|
|
|
if (this.grassBlockedAt(x, y, self)) return false;
|
|
|
|
|
if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
|
|
|
|
|
if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
|
|
|
|
|
const cx = clamp(x, 44, this.w - 44);
|
|
|
|
|
const cy = clamp(y, 44, this.h - 44);
|
|
|
|
|
if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
|
|
|
|
|
const blockedAtCenter = this.grassBlockedAt(cx, cy);
|
|
|
|
|
const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
|
|
|
|
|
const outer = Math.max(maxRadius, inner + 42);
|
|
|
|
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
|
|
|
for (let i = 0; i < attempts; i++) {
|
|
|
|
|
const t = attempts <= 1 ? 1 : i / (attempts - 1);
|
|
|
|
|
const radius = lerp(inner, outer, Math.sqrt(t));
|
|
|
|
|
const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
|
|
|
|
|
const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
|
|
|
|
|
const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
|
|
|
|
if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
})(typeof window !== "undefined" ? window : globalThis);
|