626 lines
31 KiB
JavaScript
626 lines
31 KiB
JavaScript
"use strict";
|
|
|
|
// Item update/lifecycle and type-specific runtime methods.
|
|
|
|
const DUPLICATOR_STORABLE_TYPES = new Set([
|
|
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug",
|
|
"laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug",
|
|
"zunda_juice", "grass"
|
|
]);
|
|
|
|
function duplicatorLoadTypeForItem(item) {
|
|
if (!item || item.dead || item.type === "duplicator") return "";
|
|
const type = String(item.type || "");
|
|
if (!type || type === "water_bowl" || type === "water") return "";
|
|
// Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が
|
|
// serving-food 判定や medicine role の揺れに左右されず複製機へ入る。
|
|
if (DUPLICATOR_STORABLE_TYPES.has(type)) return type;
|
|
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type;
|
|
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type;
|
|
const foodDef = window.TarinaiItemRegistry?.food?.get?.(type);
|
|
if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type;
|
|
if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type;
|
|
return "";
|
|
}
|
|
|
|
function syncDuplicatorRoles(item) {
|
|
if (!item || item.type !== "duplicator") return;
|
|
const type = String(item.storedFoodType || "");
|
|
item.roles.food = Boolean(type);
|
|
item.roles.medicine = Boolean(type && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))));
|
|
item.roles.drink = Boolean(type === "water" || type === "zunda_juice");
|
|
}
|
|
|
|
Object.assign(Item.prototype, {
|
|
update(dt, worldRef = world) {
|
|
this.age += dt;
|
|
if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt);
|
|
if (this.dropTimer > 0) {
|
|
const beforeDrop = this.dropTimer;
|
|
this.dropTimer = Math.max(0, this.dropTimer - dt);
|
|
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
|
|
this.dropImpactDone = true;
|
|
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
|
|
}
|
|
}
|
|
if (this.type === "water") this.amount -= dt * 0.9;
|
|
if (isServingFoodType(this.type)) {
|
|
const interval = passiveFoodDecayInterval(worldRef);
|
|
this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt;
|
|
let ticks = 0;
|
|
while (this.passiveFoodDecayTimer >= interval && ticks < 3) {
|
|
this.passiveFoodDecayTimer -= interval;
|
|
ticks++;
|
|
if (Number.isFinite(this.foodServingsRemaining)) {
|
|
const before = Math.max(0, this.foodServingsRemaining || 0);
|
|
const lost = Math.min(before, interval * passiveFoodDecayRate(this));
|
|
if (lost > 0) {
|
|
this.foodServingsRemaining = Math.max(0, before - lost);
|
|
this.amount = this.foodServingsRemaining;
|
|
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
|
|
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
|
|
worldRef?.markTerrainDirty?.("food-passive-decay");
|
|
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
|
|
if (this.foodServingsRemaining <= 0.015) this.amount = 0;
|
|
}
|
|
} else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) {
|
|
const before = this.amount || 0;
|
|
const lost = Math.min(before, interval * 0.12);
|
|
this.amount -= lost;
|
|
if (lost > 0) {
|
|
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? 0.002;
|
|
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
|
|
worldRef?.markTerrainDirty?.("food-passive-decay");
|
|
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (this.type === "trace") this.amount -= dt * 1.35;
|
|
if (this.type === "splat") this.amount -= dt * 1.05;
|
|
if (this.type === "ant_corpse") this.amount -= dt * 0.018;
|
|
if (this.type === "firecracker") {
|
|
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
|
|
if (this.fuseTimer <= 0) {
|
|
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
|
|
else this.amount = 0;
|
|
}
|
|
}
|
|
if (this.type === "genkotsu") {
|
|
this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt);
|
|
if (this.dropImpactDone) {
|
|
this.lingerTimer = Math.max(0, (this.lingerTimer || 0) - dt);
|
|
if ((this.lingerTimer || 0) <= 0) this.amount = 0;
|
|
} else {
|
|
this.amount = Math.max(this.amount || 0, 100);
|
|
}
|
|
}
|
|
if (this.type === "ball") this.updateBall(dt, worldRef);
|
|
if (this.type === "duplicator") this.updateDuplicator(dt, worldRef);
|
|
if (isPinType(this.type)) this.updatePushpin(dt, worldRef);
|
|
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
|
|
if (this.type === "grass") {
|
|
// Grass is intentionally not simulated per item. World.update randomly
|
|
// advances one grass by one discrete stage every few seconds.
|
|
normalizeGrassStage(this);
|
|
} else if (this.type === "zunchi") {
|
|
this.lifecycleTimer += dt;
|
|
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
|
|
if (this.lifecycleTimer >= interval) {
|
|
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
|
|
this.lifecycleTimer = 0;
|
|
this.updateZunchi(lifecycleDt, worldRef);
|
|
}
|
|
}
|
|
},
|
|
|
|
updateDuplicator(dt, worldRef) {
|
|
this.amount = 999;
|
|
syncDuplicatorRoles(this);
|
|
if (!worldRef?.nearbyItems) return;
|
|
const pickupRadius = Math.max(96, (this.r || 34) * 3.0);
|
|
const maxContact = item => Math.max(68, (this.r || 34) + (item?.r || 14) + 24);
|
|
const candidates = Array.from(worldRef.nearbyItems(this.x, this.y, pickupRadius, true) || []);
|
|
// SpatialGrid の分類漏れがあっても、複製機の近接投入だけは取りこぼさない。
|
|
// 全件を「候補にする」のではなく、投入半径付近にあるものだけ補完する。
|
|
if (worldRef.items) {
|
|
for (const item of worldRef.items) {
|
|
if (!item || candidates.includes(item) || item === this || item.dead) continue;
|
|
const approxReach = pickupRadius + Math.max(18, item.r || 12);
|
|
if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item);
|
|
}
|
|
}
|
|
let best = null, bestD = Infinity, bestType = "";
|
|
for (const it of candidates) {
|
|
if (!it || it === this || it.dead) continue;
|
|
const loadType = duplicatorLoadTypeForItem(it);
|
|
if (!loadType) continue;
|
|
if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue;
|
|
const d = distXY(this.x, this.y, it.x, it.y);
|
|
if (d < bestD && d <= maxContact(it)) { best = it; bestD = d; bestType = loadType; }
|
|
}
|
|
if (!best || !bestType) return;
|
|
const def = typeof itemDefinition === "function" ? itemDefinition(bestType) : null;
|
|
const newLabel = def?.label || bestType;
|
|
const changed = this.storedFoodType !== bestType;
|
|
if (!changed && this.storedFoodType) return;
|
|
this.storedFoodType = bestType;
|
|
this.storedFoodLabel = newLabel;
|
|
syncDuplicatorRoles(this);
|
|
this.loadedAt = worldRef.time || 0;
|
|
best.amount = 0;
|
|
best.foodServingsRemaining = 0;
|
|
// Item.dead is a getter based on amount. Do not assign to it.
|
|
worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
|
worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
|
worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
|
worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed });
|
|
worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food");
|
|
},
|
|
|
|
updateZunchiMotion(dt, worldRef) {
|
|
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
|
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
|
|
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
|
|
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
|
|
} else {
|
|
this.prevX = this.x;
|
|
this.prevY = this.y;
|
|
this.x += (this.vx || 0) * dt;
|
|
this.y += (this.vy || 0) * dt;
|
|
const p = Math.max(28, CONFIG.worldPadding || 30);
|
|
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; }
|
|
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
|
|
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
|
|
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
|
|
const slow = Math.pow(0.68, dt * 2.4);
|
|
this.vx *= slow;
|
|
this.vy *= slow;
|
|
worldRef.drawListDirty = true;
|
|
}
|
|
this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed);
|
|
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
|
|
},
|
|
|
|
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) {
|
|
if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return;
|
|
const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
|
|
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) {
|
|
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
|
const d = distXY(this.x, this.y, t.x, t.y);
|
|
if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue;
|
|
const now = worldRef.time || 0;
|
|
if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
|
|
this.lastHitTarinaiAt = this.lastHitTarinaiAt || {};
|
|
this.lastHitTarinaiAt[t.id] = now;
|
|
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
|
|
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
|
|
const impulse = clamp(speed * 1.15, 150, 620);
|
|
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
|
|
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
|
|
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
|
|
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
|
|
t.fallDir = nx < 0 ? -1 : 1;
|
|
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
|
|
else {
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
|
|
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
|
|
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true });
|
|
if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 });
|
|
}
|
|
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65);
|
|
worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }));
|
|
this.vx *= -0.18;
|
|
this.vy *= -0.18;
|
|
break;
|
|
}
|
|
},
|
|
|
|
updateBall(dt, worldRef) {
|
|
this.prevX = this.x;
|
|
this.prevY = this.y;
|
|
const moving = Math.hypot(this.vx || 0, this.vy || 0);
|
|
if (moving > 0.04) {
|
|
this.x += (this.vx || 0) * dt;
|
|
this.y += (this.vy || 0) * dt;
|
|
const p = Math.max(28, CONFIG.worldPadding || 30);
|
|
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
|
|
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
|
|
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
|
|
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
|
|
this.resolveBallObstacleCollisions(dt, worldRef);
|
|
this.x = clamp(this.x, p, worldRef.w - p);
|
|
this.y = clamp(this.y, p, worldRef.h - p);
|
|
const groundFriction = Math.pow(0.72, dt);
|
|
this.vx *= groundFriction;
|
|
this.vy *= groundFriction;
|
|
}
|
|
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18);
|
|
this.spinVelocity *= Math.pow(0.65, dt);
|
|
if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; }
|
|
},
|
|
|
|
resolveBallObstacleCollisions(dt, worldRef) {
|
|
if (!worldRef?.nearbyItems) return;
|
|
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
|
const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
|
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
|
|
for (const it of items) {
|
|
if (!it || it === this || it.dead) continue;
|
|
if (it.type === "bed") {
|
|
this.applyBallHayDrag(it, dt, worldRef);
|
|
} else if (it.type === "stone") {
|
|
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
|
|
}
|
|
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
|
|
if (!rects.length) continue;
|
|
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
|
|
}
|
|
},
|
|
|
|
applyBallHayDrag(bed, dt, worldRef) {
|
|
const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40);
|
|
const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34);
|
|
const dx = this.x - bed.x;
|
|
const dy = this.y - bed.y;
|
|
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
|
|
if (!inside) return;
|
|
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
|
|
this.vx *= slow;
|
|
this.vy *= slow;
|
|
this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
|
|
if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) {
|
|
this.lastHaySlowLogAt = worldRef.time || 0;
|
|
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
|
|
}
|
|
},
|
|
|
|
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) {
|
|
let dx = this.x - obstacle.x;
|
|
let dy = this.y - obstacle.y;
|
|
let d = Math.hypot(dx, dy);
|
|
if (d >= hitRadius) {
|
|
const px = this.prevX;
|
|
const py = this.prevY;
|
|
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
|
|
const sx = this.x - px;
|
|
const sy = this.y - py;
|
|
const len2 = sx * sx + sy * sy;
|
|
if (len2 <= 0.0001) return;
|
|
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
|
|
const cx = px + sx * t;
|
|
const cy = py + sy * t;
|
|
dx = cx - obstacle.x;
|
|
dy = cy - obstacle.y;
|
|
d = Math.hypot(dx, dy);
|
|
if (d >= hitRadius) return;
|
|
if (d < 0.001) {
|
|
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
|
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
|
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
|
|
}
|
|
}
|
|
if (d < 0.001) {
|
|
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
|
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
|
else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; }
|
|
}
|
|
const nx = dx / d;
|
|
const ny = dy / d;
|
|
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
|
|
this.x = obstacle.x + nx * (hitRadius + 0.5);
|
|
this.y = obstacle.y + ny * (hitRadius + 0.5);
|
|
if (toward < 0) {
|
|
this.vx = (this.vx || 0) - (1 + restitution) * toward * nx;
|
|
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
|
|
} else {
|
|
this.vx = (this.vx || 0) + nx * 24;
|
|
this.vy = (this.vy || 0) + ny * 24;
|
|
}
|
|
this.vx *= 0.96;
|
|
this.vy *= 0.96;
|
|
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
|
|
this.emitBallBounce(worldRef, obstacle);
|
|
},
|
|
|
|
resolveBallRectBounce(rect, worldRef, source = null) {
|
|
if (!rect) return;
|
|
const hitRadius = (this.r || 18) + 2;
|
|
const cx = clamp(this.x, rect.left, rect.right);
|
|
const cy = clamp(this.y, rect.top, rect.bottom);
|
|
let dx = this.x - cx;
|
|
let dy = this.y - cy;
|
|
let d = Math.hypot(dx, dy);
|
|
if (d >= hitRadius) {
|
|
const px = this.prevX;
|
|
const py = this.prevY;
|
|
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
|
|
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
|
|
let nx = 0;
|
|
let ny = 0;
|
|
const vx = this.vx || 0;
|
|
const vy = this.vy || 0;
|
|
const candidates = [];
|
|
const sx = this.x - px;
|
|
const sy = this.y - py;
|
|
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
|
|
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
|
|
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
|
|
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
|
|
if (candidates.length) {
|
|
candidates.sort((a, b) => a.t - b.t);
|
|
nx = candidates[0].nx;
|
|
ny = candidates[0].ny;
|
|
} else if (Math.abs(vx) >= Math.abs(vy)) {
|
|
nx = vx >= 0 ? -1 : 1;
|
|
} else {
|
|
ny = vy >= 0 ? -1 : 1;
|
|
}
|
|
const toward = vx * nx + vy * ny;
|
|
if (nx < 0) this.x = expanded.left - 0.5;
|
|
else if (nx > 0) this.x = expanded.right + 0.5;
|
|
if (ny < 0) this.y = expanded.top - 0.5;
|
|
else if (ny > 0) this.y = expanded.bottom + 0.5;
|
|
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
|
|
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
|
|
if (toward < 0) {
|
|
this.vx = vx - 1.78 * toward * nx;
|
|
this.vy = vy - 1.78 * toward * ny;
|
|
} else {
|
|
this.vx = vx + nx * 18;
|
|
this.vy = vy + ny * 18;
|
|
}
|
|
this.vx *= 0.94;
|
|
this.vy *= 0.94;
|
|
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
|
this.emitBallBounce(worldRef, source || rect.item || rect);
|
|
return;
|
|
}
|
|
if (d < 0.001) {
|
|
const left = Math.abs(this.x - rect.left);
|
|
const right = Math.abs(rect.right - this.x);
|
|
const top = Math.abs(this.y - rect.top);
|
|
const bottom = Math.abs(rect.bottom - this.y);
|
|
const m = Math.min(left, right, top, bottom);
|
|
if (m === left) { dx = -1; dy = 0; }
|
|
else if (m === right) { dx = 1; dy = 0; }
|
|
else if (m === top) { dx = 0; dy = -1; }
|
|
else { dx = 0; dy = 1; }
|
|
d = 1;
|
|
}
|
|
const nx = dx / d;
|
|
const ny = dy / d;
|
|
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
|
|
this.x = cx + nx * (hitRadius + 0.5);
|
|
this.y = cy + ny * (hitRadius + 0.5);
|
|
if (toward < 0) {
|
|
this.vx = (this.vx || 0) - 1.78 * toward * nx;
|
|
this.vy = (this.vy || 0) - 1.78 * toward * ny;
|
|
} else {
|
|
this.vx = (this.vx || 0) + nx * 18;
|
|
this.vy = (this.vy || 0) + ny * 18;
|
|
}
|
|
this.vx *= 0.94;
|
|
this.vy *= 0.94;
|
|
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
|
this.emitBallBounce(worldRef, source || rect.item || rect);
|
|
},
|
|
|
|
emitBallBounce(worldRef, obstacle) {
|
|
const now = worldRef?.time || 0;
|
|
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
|
|
this.lastObstacleBounceAt = now;
|
|
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" }));
|
|
},
|
|
|
|
updatePushpin(dt, worldRef) {
|
|
if (!worldRef) return;
|
|
if (this.pinState === "lodged") {
|
|
this.updateLodgedPushpin(dt, worldRef);
|
|
return;
|
|
}
|
|
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
|
|
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
|
|
} else {
|
|
this.prevX = this.x;
|
|
this.prevY = this.y;
|
|
this.x += (this.vx || 0) * dt;
|
|
this.y += (this.vy || 0) * dt;
|
|
}
|
|
this.tryStickPushpin(worldRef);
|
|
},
|
|
|
|
tryStickPushpin(worldRef) {
|
|
if (!worldRef?.tarinai?.length) return false;
|
|
if (this.pinState === "lodged") return false;
|
|
const now = worldRef.time || 0;
|
|
if ((this.noStickUntil || 0) > now) return false;
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
const hitRange = Math.max(10, (this.r || 8) * 1.25);
|
|
const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai;
|
|
for (const t of candidates) {
|
|
if (!t || t.dead) continue;
|
|
if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue;
|
|
const d = distXY(this.x, this.y, t.x, t.y);
|
|
const hit = (t.radius || 16) * 0.86 + hitRange;
|
|
if (d <= hit && d < bestD) { best = t; bestD = d; }
|
|
}
|
|
if (!best) return false;
|
|
return this.attachPushpin(best, worldRef, bestD);
|
|
},
|
|
|
|
attachPushpin(t, worldRef, d = null) {
|
|
if (!t || t.dead) return false;
|
|
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false;
|
|
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
|
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
|
const dx = this.x - t.x;
|
|
const dy = this.y - t.y;
|
|
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
|
|
this.pinState = "lodged";
|
|
this.deletable = false;
|
|
this.pinTargetId = t.id;
|
|
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
|
const visualSide = isOshibyo ? -faceDir : faceDir;
|
|
this.pinVisualSide = visualSide;
|
|
this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
|
|
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
|
|
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
|
|
this.pinDamageTick = 0;
|
|
this.pinFallCheckTimer = 0;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
this.spinVelocity = 0;
|
|
this.spin = this.pinAttachAngle;
|
|
t.stuckPushpinId = this.id;
|
|
t.sleeping = false;
|
|
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
|
|
if (isOshibyo) {
|
|
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
|
|
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
|
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
|
|
return true;
|
|
}
|
|
if (t.enterPanic) {
|
|
t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
|
|
} else {
|
|
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
|
|
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
|
|
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
|
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
|
|
}
|
|
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
|
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
|
|
this.pinLogAt = worldRef.time || 0;
|
|
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
|
}
|
|
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
|
|
return true;
|
|
},
|
|
|
|
updateLodgedPushpin(dt, worldRef) {
|
|
const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null;
|
|
if (!t) {
|
|
this.detachPushpin(worldRef, "owner-lost");
|
|
return;
|
|
}
|
|
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
|
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
|
t.stuckPushpinId = this.id;
|
|
this.deletable = false;
|
|
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
|
const visualSide = isOshibyo ? -faceDir : faceDir;
|
|
this.pinVisualSide = visualSide;
|
|
this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
|
|
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
|
|
this.prevX = this.x;
|
|
this.prevY = this.y;
|
|
this.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
|
|
this.vx = t.vx || 0;
|
|
this.vy = t.vy || 0;
|
|
if (isOshibyo) {
|
|
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
|
|
return;
|
|
}
|
|
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
|
|
while (this.pinDamageTick >= 0.55) {
|
|
this.pinDamageTick -= 0.55;
|
|
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
|
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
|
|
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
|
|
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
|
|
}
|
|
t.sleeping = false;
|
|
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
|
|
else {
|
|
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
|
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
|
|
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
|
|
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
|
|
}
|
|
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
|
|
while (this.pinFallCheckTimer >= 1.0) {
|
|
this.pinFallCheckTimer -= 1.0;
|
|
if (Math.random() < 0.10) {
|
|
this.detachPushpin(worldRef, "fall");
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
detachPushpin(worldRef, reason = "released") {
|
|
const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null;
|
|
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
|
const wasOshibyo = Boolean(behavior?.blocksZunchi);
|
|
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
|
|
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
|
|
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
|
|
if (reason === "pinch") {
|
|
const now = worldRef?.time || 0;
|
|
this.noStickUntil = now + 1.0;
|
|
this.noStickTargetId = t?.id || "";
|
|
this.noStickTargetUntil = now + 1.0;
|
|
} else if (t) {
|
|
const now = worldRef?.time || 0;
|
|
this.noStickTargetId = t.id || "";
|
|
this.noStickTargetUntil = now + 0.55;
|
|
}
|
|
this.pinState = "loose";
|
|
this.pinTargetId = "";
|
|
this.pinDamageTick = 0;
|
|
this.pinFallCheckTimer = 0;
|
|
this.deletable = true;
|
|
if (reason === "pinch") {
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
this.spinVelocity = 0;
|
|
} else {
|
|
this.vx = rand(-24, 24);
|
|
this.vy = rand(-10, 18);
|
|
this.spinVelocity = rand(-1.6, 1.6);
|
|
this.spin += rand(-0.25, 0.25);
|
|
if (t) {
|
|
this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30));
|
|
this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30));
|
|
}
|
|
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
|
|
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
|
|
}
|
|
|
|
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
|
|
worldRef.spawnBurstZunchi(t || this, storedZunchi, this);
|
|
}
|
|
},
|
|
|
|
updateZunchi(dt, worldRef) {
|
|
const rainy = worldRef.weather === "light_rain";
|
|
const rainBoost = rainy ? 1.28 : 1;
|
|
const decayBoost = rainy ? 1.35 : 1;
|
|
let waterBoost = 0;
|
|
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
|
|
for (const it of nearby) {
|
|
if (it.type !== "water") continue;
|
|
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
|
|
}
|
|
this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65);
|
|
const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt;
|
|
if (this.stage === "fresh") {
|
|
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
|
|
this.amount -= decayDt * 0.018 * decayBoost;
|
|
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
|
|
} else if (this.stage === "dry") {
|
|
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
|
|
this.amount -= decayDt * 0.032 * decayBoost;
|
|
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
|
|
} else if (this.stage === "decomposing") {
|
|
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
|
|
this.amount -= decayDt * 0.046 * decayBoost;
|
|
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
|
|
} else if (this.stage === "fertile_soil") {
|
|
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
|
|
this.amount -= decayDt * 0.090 * decayBoost;
|
|
}
|
|
}
|
|
});
|