317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
(function (global) {
|
|
const World = global.World;
|
|
if (!World) throw new Error("World is not available for mixin: world_environment.js");
|
|
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;
|
|
},
|
|
|
|
temperatureAt(x, y) {
|
|
let temp = 0.54 + Math.sin(this.time / 55) * 0.05;
|
|
for (const it of this.nearbyItems(x, y, 190)) {
|
|
if (it.type === "stone") {
|
|
const d = distXY(x, y, it.x, it.y);
|
|
temp += clamp(1 - d / 190, 0, 1) * 0.35;
|
|
}
|
|
}
|
|
return clamp(temp, 0, 1);
|
|
},
|
|
|
|
fenceRect(it) {
|
|
const vertical = it?.type === "fence_v";
|
|
const horizontal = it?.type === "fence_h";
|
|
if (!vertical && !horizontal) return null;
|
|
const len = Math.max(112, (it.r || 42) * 3.55);
|
|
const thick = Math.max(10, (it.r || 42) * 0.31);
|
|
const halfW = vertical ? thick / 2 : len / 2;
|
|
const halfH = vertical ? len / 2 : thick / 2;
|
|
return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
|
|
},
|
|
|
|
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"),
|
|
];
|
|
},
|
|
|
|
nestBoxTopRect(it) {
|
|
return this.nestBoxSolidRects(it)[0] || null;
|
|
},
|
|
|
|
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) : [];
|
|
let index = occupants.indexOf(occupant);
|
|
if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1);
|
|
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),
|
|
};
|
|
},
|
|
|
|
solidObstacleRects(it) {
|
|
if (!it || it.dead) return [];
|
|
if (this.isFenceType(it.type)) {
|
|
const rect = this.fenceRect(it);
|
|
return rect ? [{ ...rect, type: it.type, item: it }] : [];
|
|
}
|
|
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);
|
|
const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0);
|
|
const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58;
|
|
return Boolean(aroundDoor || inDoorColumn);
|
|
},
|
|
|
|
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
|
|
const rects = [];
|
|
let checked = 0;
|
|
for (const it of this.nearbyItems(x, y, radius)) {
|
|
if (!it || it === exclude || it.dead) continue;
|
|
if (include && !include(it)) continue;
|
|
const partRects = this.solidObstacleRects(it);
|
|
if (!partRects.length) continue;
|
|
checked += 1;
|
|
for (const rect of partRects) rects.push(rect);
|
|
if (checked >= maxChecks) break;
|
|
}
|
|
return rects;
|
|
},
|
|
|
|
pointInRect(x, y, r, padding = 0) {
|
|
return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding);
|
|
},
|
|
|
|
pushTarinaiOutOfRect(t, r, rr) {
|
|
if (!t || !r) return false;
|
|
const cx = clamp(t.x, r.left, r.right);
|
|
const cy = clamp(t.y, r.top, r.bottom);
|
|
let dx = t.x - cx;
|
|
let dy = t.y - cy;
|
|
let 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; }
|
|
}
|
|
const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92));
|
|
t.x += dx / d * push;
|
|
t.y += dy / d * push;
|
|
if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
|
else t.vy *= -0.18;
|
|
return true;
|
|
},
|
|
|
|
resolveSolidObstacleCollision(t) {
|
|
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return;
|
|
const rr = Math.max(8, t.radius * 0.74);
|
|
let pushed = false;
|
|
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) {
|
|
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue;
|
|
if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true;
|
|
}
|
|
if (pushed) {
|
|
const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18);
|
|
t.x = clamp(t.x, pad, this.w - pad);
|
|
t.y = clamp(t.y, pad, this.h - pad);
|
|
t.vx = clamp(t.vx || 0, -130, 130);
|
|
t.vy = clamp(t.vy || 0, -130, 130);
|
|
}
|
|
},
|
|
|
|
resolveFenceCollision(t) {
|
|
this.resolveSolidObstacleCollision(t);
|
|
},
|
|
|
|
segmentIntersectsRect(x1, y1, x2, y2, r) {
|
|
if (!r) return false;
|
|
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);
|
|
},
|
|
|
|
pathBlockedByFence(x1, y1, x2, y2, padding = 16) {
|
|
const cx = (x1 + x2) / 2;
|
|
const cy = (y1 + y2) / 2;
|
|
const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120;
|
|
for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) {
|
|
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;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
grassBlockedAt(x, y, self = null) {
|
|
for (const it of this.nearbyItems(x, y, 96)) {
|
|
if (it === self || it.dead) continue;
|
|
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;
|
|
},
|
|
|
|
findGrassSproutSpot(source, fertile = false) {
|
|
const attempts = fertile ? 24 : 10;
|
|
const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
|
|
const maxR = fertile ? 125 : 72;
|
|
for (let i = 0; i < attempts; i++) {
|
|
const angle = rand(0, Math.PI * 2);
|
|
const radius = rand(minR, maxR);
|
|
const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
|
|
const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
|
if (this.grassSpotOpen(x, y, { minGrassGap: fertile ? 24 : 22, avoidTarinai: true })) return { x, y };
|
|
}
|
|
return null;
|
|
}
|
|
}));
|
|
})(typeof window !== "undefined" ? window : globalThis);
|