// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data "use strict"; const FOOD_SERVING_TYPES = new Set(window.TarinaiFoodRegistry?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiFoodRegistry?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); function isParamEffectItemType(type = "") { return PARAM_EFFECT_ITEM_TYPES.has(type); } const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 }; function foodServingsForSize(size = "medium") { return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium; } function isServingFoodType(type = "") { return FOOD_SERVING_TYPES.has(type); } const PASSIVE_FOOD_DECAY_PER_SECOND = 0.004; const PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055; function passiveFoodDecayInterval(worldRef = null) { // Two decay ticks per half-day: dayLength/4. Default day is 120s -> 30s interval. const day = Number(worldRef?.config?.dayLength || CONFIG?.dayLength || 120) || 120; return Math.max(12, day / 4); } function passiveFoodDecayRate(item) { const size = item?.toolSize || "medium"; const sizeMul = size === "small" ? 0.74 : (size === "large" ? 1.32 : 1); const typeMul = window.TarinaiFoodRegistry?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1)); return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; } function itemVisualFor(type = "") { return typeof itemVisualDefinition === "function" ? itemVisualDefinition(type) : null; } function visualPaletteFor(type = "", fallback = {}) { return itemVisualFor(type)?.palette || fallback || {}; } function visualFieldScaleFor(type = "") { const visual = itemVisualFor(type); return visual?.fieldScale || visual?.scale || 1; } function drawOvalWaterDropSprite(ctx, r, palette = {}) { const fill = palette.fill || "rgba(86, 157, 224, 0.82)"; const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)"; const highlight = palette.highlight || "rgba(255,255,255,0.64)"; ctx.save(); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = Math.max(1.6, r * 0.13); // Horizontal oval item droplet silhouette for fallen water / zunda juice / mercury; rain overlay itself is a separate diagonal-line effect. ctx.beginPath(); ctx.ellipse(0, 0, r * 1.42, r * 0.56, -0.04, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.globalAlpha = 0.72; ctx.fillStyle = highlight; ctx.beginPath(); ctx.ellipse(-r * 0.46, -r * 0.16, r * 0.34, r * 0.13, -0.18, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawSleepTabletSprite(ctx, r, palette = {}) { const fill = palette.fill || "#ebe4ff"; const stroke = palette.stroke || "rgba(74, 60, 144, 0.72)"; const accent = palette.accent || "rgba(112, 91, 188, 0.82)"; ctx.save(); ctx.rotate(-0.18); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = Math.max(1.8, r * 0.14); ctx.beginPath(); ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 0.52; ctx.strokeStyle = "rgba(255,255,255,0.94)"; ctx.lineWidth = Math.max(1.2, r * 0.08); ctx.beginPath(); ctx.moveTo(-r * 0.56, -r * 0.28); ctx.quadraticCurveTo(-r * 0.14, -r * 0.48, r * 0.48, -r * 0.20); ctx.stroke(); ctx.restore(); } function drawLaxativeTabletSprite(ctx, r) { ctx.save(); ctx.fillStyle = "#f4d49a"; ctx.strokeStyle = "rgba(98, 61, 29, 0.82)"; ctx.lineWidth = Math.max(1.8, r * 0.14); roundedRect(ctx, -r * 1.02, -r * 0.54, r * 2.04, r * 1.08, r * 0.34); ctx.fill(); ctx.stroke(); // Embedded zunchi mound motif: silhouette-based, not color-only. ctx.fillStyle = "rgba(96, 70, 38, 0.88)"; ctx.strokeStyle = "rgba(56, 39, 22, 0.42)"; ctx.lineWidth = Math.max(1.0, r * 0.07); const lumps = [ [-0.38, 0.11, 0.25, 0.17], [-0.12, -0.03, 0.29, 0.20], [0.20, 0.10, 0.27, 0.18], [0.03, -0.23, 0.20, 0.14], ]; for (const [x,y,w,h] of lumps) { ctx.beginPath(); ctx.ellipse(r*x, r*y, r*w, r*h, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.globalAlpha = 0.45; ctx.strokeStyle = "rgba(255,255,255,0.90)"; ctx.lineWidth = Math.max(1.0, r * 0.07); ctx.beginPath(); ctx.moveTo(-r * 0.68, -r * 0.32); ctx.quadraticCurveTo(-r * 0.24, -r * 0.48, r * 0.46, -r * 0.26); ctx.stroke(); ctx.restore(); } function drawMysteryTabletSprite(ctx, r) { ctx.save(); ctx.fillStyle = "#f3ecff"; ctx.strokeStyle = "rgba(91, 55, 150, 0.84)"; ctx.lineWidth = Math.max(1.9, r * 0.15); ctx.beginPath(); ctx.ellipse(0, 0, r * 0.94, r * 0.78, 0.10, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(91,55,150,0.92)"; ctx.font = `bold ${Math.round(r * 1.35)}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("?", 0, -r * 0.04); ctx.globalAlpha = 0.52; ctx.strokeStyle = "rgba(255,255,255,0.90)"; ctx.lineWidth = Math.max(1.1, r * 0.08); ctx.beginPath(); ctx.moveTo(-r * 0.52, -r * 0.34); ctx.quadraticCurveTo(-r * 0.12, -r * 0.54, r * 0.42, -r * 0.28); ctx.stroke(); ctx.restore(); } function drawBulletSprite(ctx, r) { ctx.save(); ctx.rotate(0.0); const g = ctx.createLinearGradient(-r, 0, r, 0); g.addColorStop(0, "#b06f23"); g.addColorStop(0.48, "#f4c46a"); g.addColorStop(1, "#a05a1f"); ctx.fillStyle = g; ctx.strokeStyle = "rgba(87, 51, 22, 0.78)"; ctx.lineWidth = Math.max(1.7, r * 0.13); ctx.beginPath(); ctx.moveTo(r * 0.86, 0); ctx.quadraticCurveTo(r * 0.42, -r * 0.52, -r * 0.34, -r * 0.52); ctx.lineTo(-r * 0.88, -r * 0.40); ctx.lineTo(-r * 0.88, r * 0.40); ctx.lineTo(-r * 0.34, r * 0.52); ctx.quadraticCurveTo(r * 0.42, r * 0.52, r * 0.86, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.strokeStyle = "rgba(255,237,176,0.62)"; ctx.lineWidth = Math.max(1.0, r * 0.07); ctx.beginPath(); ctx.moveTo(-r * 0.44, -r * 0.34); ctx.lineTo(r * 0.42, -r * 0.20); ctx.stroke(); ctx.restore(); } function machineDishPosition(machine) { const r = machine?.r || 42; return { x: machine.x, y: machine.y + r * 1.18 }; } function machineMuzzlePosition(machine) { const r = machine?.r || 42; const a = Number.isFinite(machine?.cannonAngle) ? machine.cannonAngle : 0; return { x: machine.x + Math.cos(a) * r * 0.98, y: machine.y + Math.sin(a) * r * 0.98 }; } function makeMachineConsumableCopy(type, x, y, source = null, opts = {}) { const it = new Item(type, x, y); it.machineProduced = true; it.machineProducerId = source?.id || ""; it.sourceMachineType = source?.type || ""; it.noDecay = true; if (opts.infiniteServing) it.infiniteServing = true; if (opts.projectile) { it.cannonProjectile = true; it.projectileSourceId = source?.id || ""; it.projectileTimer = 0; it.projectileHitMemo = {}; } if (typeof isServingFoodType === "function" && isServingFoodType(type)) { it.toolSize = source?.machineSlotSize || "medium"; it.foodServingScale = 1; it.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize) : 5; it.foodServingsRemaining = it.foodServingsMax; it.amount = it.foodServingsMax; } if (type === "water") it.amount = 120; if (type === "grass") { it.amount = Math.max(it.amount || 0, 120); it.growth = Math.max(it.growth || 0, 0.82); } if (type === "zunchi") { it.amount = 240; it.stage = "fresh"; it.stageTimer = 0; it.freshness = 1; } return it; } class Item { constructor(type, x, y) { this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; this.type = type; this.x = x; this.y = y; this.r = itemRadiusFor(type, 12); this.amount = itemAmountFor(type, 80); this.age = 0; this.seed = Math.random() * 1000; this.dropTimer = 0; this.dropMax = 0; this.dropImpactDone = false; this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55); this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0); if (type === "grass") { this.growth = rand(0.18, 0.42); this.health = 1; this.seedTimer = rand(18, 38); this.eatenAmount = 0; this.fertilityBoost = 0; this.lifeSpan = rand(420, 860); this.wither = 0; } if (type === "bed") { this.comfort = rand(0.86, 1.14); this.wear = 0; } if (type === "signboard") { this.text = ""; this.textEditedAt = -999; } if (type === "duplicator" || type === "cannon") { this.machineSlotType = ""; this.machineSlotSize = "medium"; this.machineSpawnTimer = rand(0.4, 1.1); this.machineLastSlotType = ""; this.cannonAngle = type === "cannon" ? -Math.PI * 0.25 : 0; this.cannonFireTimer = rand(0.8, 1.6); } if (type === "ball") { this.vx = rand(-5, 5); this.vy = rand(-5, 5); this.prevX = x; this.prevY = y; this.spin = rand(0, Math.PI * 2); this.spinVelocity = rand(-1.6, 1.6); this.lastKickedAt = -999; this.lastKickerId = ""; this.lastPokedAt = -999; this.pokeCombo = 0; this.lastPokeAngle = rand(0, Math.PI * 2); } if (type === "zunchi") { this.stage = "fresh"; this.stageTimer = 0; this.fertility = 0; this.freshness = 1; this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; } if (type === "ant_nest") { this.antCount = ANT_NEST_START_COUNT || 6; this.antSpawnTimer = rand(0.6, 2.2); this.antSpawnCooldown = rand(0.8, 2.4); this.antWorkers = typeof buildAntWorkerPool === "function" ? buildAntWorkerPool(this.antCount) : Array.from({ length: this.antCount }, () => ANT_WORKER_HP || 32); this.queenSpawnAt = -999; } if (type === "ant_corpse") { this.amount = 24; this.workerSprite = "ant_worker"; this.decayTimer = 0; } if (type === "firecracker") { this.fuseTimer = 5; this.fuseMax = 5; } if (type === "genkotsu") { this.amount = 100; this.dropMax = 0; this.dropImpactDone = false; this.impactFlash = 0; this.lingerTimer = 0; this.lingerDuration = 2.0; } if (isPinType(type)) { this.amount = 100; this.vx = rand(-120, 120); this.vy = rand(-90, 90); this.prevX = x; this.prevY = y; this.spin = rand(-0.35, 0.35); this.spinVelocity = rand(-5.4, 5.4); this.pinState = "loose"; this.pinTargetId = ""; this.pinAttachAngle = 0; this.pinAttachDistance = 0; this.pinOffsetY = 0; this.pinDamageTick = 0; this.pinFallCheckTimer = 0; this.pinLogAt = -999; this.deletable = true; } if (isServingFoodType(type)) { this.toolSize = "medium"; this.foodServingScale = 1; this.foodServingsMax = foodServingsForSize(this.toolSize); this.foodServingsRemaining = this.foodServingsMax; this.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval()); this.amount = this.foodServingsRemaining; } if (window.TarinaiNeedSystem?.itemRolesFor) window.TarinaiNeedSystem.itemRolesFor(this); if (window.TarinaiNeedSystem?.ensureItemNeedEffects) window.TarinaiNeedSystem.ensureItemNeedEffects(this); } update(dt, worldRef = world) { this.age += 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.noDecay && !this.infiniteServing) this.amount -= dt * 0.9; if (isServingFoodType(this.type) && !this.noDecay && !this.infiniteServing) { 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.TarinaiFoodRegistry?.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.TarinaiFoodRegistry?.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 (isPinType(this.type)) this.updatePushpin(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); if (this.cannonProjectile) this.updateConsumableProjectile(dt, worldRef); if (this.type === "duplicator") this.updateDuplicator(dt, worldRef); if (this.type === "cannon") this.updateCannon(dt, worldRef); if (this.type === "grass") { this.lifecycleTimer += dt; const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1; const interval = (this.lifecycleInterval || 1.9) * factor; if (this.lifecycleTimer >= interval) { const lifecycleDt = Math.min(this.lifecycleTimer, 4.8); this.lifecycleTimer = 0; this.updateGrass(lifecycleDt, worldRef); } } 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); } } } machineDishPosition() { return machineDishPosition(this); } clearMachineProducedItems(worldRef, keepType = "") { for (const it of worldRef?.items || []) { if (!it || it.dead || it.machineProducerId !== this.id) continue; if (keepType && it.type === keepType && this.type === "duplicator") continue; it.amount = 0; } } updateDuplicator(dt, worldRef) { if (!worldRef || !this.machineSlotType) return; if (this.machineLastSlotType && this.machineLastSlotType !== this.machineSlotType) this.clearMachineProducedItems(worldRef); this.machineLastSlotType = this.machineSlotType; this.machineSpawnTimer = Math.max(0, (this.machineSpawnTimer || 0) - dt); const dish = this.machineDishPosition(); let serving = null; for (const it of worldRef.items || []) { if (!it || it.dead || it.machineProducerId !== this.id || it.type !== this.machineSlotType) continue; serving = it; break; } if (!serving && this.machineSpawnTimer <= 0) { serving = makeMachineConsumableCopy(this.machineSlotType, dish.x, dish.y, this, { infiniteServing: true }); serving.r = Math.max(6, (serving.r || 12) * 0.92); worldRef.items.push(serving); worldRef.itemCounts[serving.type] = (worldRef.itemCounts[serving.type] || 0) + 1; this.machineSpawnTimer = 1.2; worldRef.rebuildSpatial?.(true); } if (serving) { serving.x = dish.x; serving.y = dish.y; serving.vx = 0; serving.vy = 0; serving.prevX = serving.x; serving.prevY = serving.y; serving.noDecay = true; serving.infiniteServing = true; serving.machineProduced = true; serving.machineProducerId = this.id; if (typeof isServingFoodType === "function" && isServingFoodType(serving.type)) { if (!Number.isFinite(serving.foodServingsMax) || serving.foodServingsMax <= 0) serving.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(serving.toolSize || "medium") : 5; serving.foodServingsRemaining = serving.foodServingsMax; serving.amount = serving.foodServingsMax; } else if (serving.type === "water") serving.amount = Math.max(serving.amount || 0, 120); else if (serving.type === "grass") serving.amount = Math.max(serving.amount || 0, 120); else if (serving.type === "zunchi") serving.amount = Math.max(serving.amount || 0, 240); } } updateCannon(dt, worldRef) { if (!worldRef || !this.machineSlotType) return; this.cannonFireTimer = Math.max(0, (this.cannonFireTimer || 0) - dt); if (this.cannonFireTimer > 0) return; this.cannonFireTimer = rand(2.2, 3.4); const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; const muzzle = machineMuzzlePosition(this); const shot = makeMachineConsumableCopy(this.machineSlotType, muzzle.x, muzzle.y, this, { projectile: true }); shot.r = Math.max(5, (shot.r || 12) * 0.78); const speed = rand(470, 620); shot.vx = Math.cos(a) * speed + rand(-18, 18); shot.vy = Math.sin(a) * speed + rand(-18, 18); shot.prevX = shot.x; shot.prevY = shot.y; shot.spinVelocity = rand(-6, 6) + speed / Math.max(8, shot.r || 10) * 0.12; shot.amount = Math.max(shot.amount || 0, typeof isServingFoodType === "function" && isServingFoodType(shot.type) ? (shot.foodServingsMax || 5) : itemAmountFor(shot.type, 60)); worldRef.items.push(shot); worldRef.itemCounts[shot.type] = (worldRef.itemCounts[shot.type] || 0) + 1; worldRef.effects?.push(new Effect("ring", muzzle.x, muzzle.y, { size: Math.max(16, (this.r || 42) * 0.42), life: 0.22, color: "rgba(255,238,160,0.52)" })); worldRef.log?.(`大砲が${toolLabel(shot.type)}を撃ち出した。`, "accident"); audio.place?.(shot.type); worldRef.rebuildSpatial?.(true); } updateConsumableProjectile(dt, worldRef) { if (!worldRef) return; this.projectileTimer = (this.projectileTimer || 0) + dt; this.prevX = this.x; this.prevY = this.y; const speedBefore = Math.hypot(this.vx || 0, this.vy || 0); if (speedBefore > 0.05) { if (window.TarinaiPhysics?.applyKinematicItemMotion) { window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 28, bounce: 0.46, frictionBase: 0.62, frictionRate: 1.25, spin: false, stopSpeed: 0.12, zeroBelow: 14 }); } else { 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.46; } if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.46; } if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.46; } if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.46; } const slow = Math.pow(0.62, dt * 1.25); this.vx *= slow; this.vy *= slow; } this.spin = (this.spin || 0) + speedBefore * dt / Math.max(6, this.r || 10); this.resolveProjectileTarinaiCollision(dt, worldRef, speedBefore); } const speedAfter = Math.hypot(this.vx || 0, this.vy || 0); if (speedAfter < 18 && (this.projectileTimer || 0) > 0.30) { this.cannonProjectile = false; this.noDecay = false; this.projectileHitMemo = {}; this.vx = 0; this.vy = 0; } if ((this.projectileTimer || 0) > 28) this.cannonProjectile = false; } resolveProjectileTarinaiCollision(dt, worldRef, speed = 0) { if (!worldRef || speed < 210 || (this.amount || 0) <= 0) return; const range = Math.max(42, (this.r || 12) + speed * Math.max(dt || 0.016, 0.016) + 22); for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range, 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 || 12)) continue; const now = worldRef.time || 0; this.projectileHitMemo = this.projectileHitMemo || {}; if (this.projectileHitMemo[t.id] && this.projectileHitMemo[t.id] + 0.58 > now) continue; this.projectileHitMemo[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 * 0.95, 130, 560); t.vx = (t.vx || 0) + nx * impulse + rand(-18, 18); t.vy = (t.vy || 0) + ny * impulse * 0.70 + rand(-14, 12); t.fallTimer = Math.max(t.fallTimer || 0, 0.72); t.fallMax = Math.max(t.fallMax || 0.72, t.fallTimer); t.fallDir = nx < 0 ? -1 : 1; const label = toolLabel(this.type) || "食べ物"; if (t.enterPanic) t.enterPanic({ threat: this, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, fear: 0.72, stress: 6, stressDuration: 3.0, surpriseTimer: 0.48, cause: "cannon_projectile_hit" }); else t.setActionState?.("panic", { target: { x: this.x, y: this.y }, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, wake: true }); if (worldRef.applyImpactDamage) worldRef.applyImpactDamage(t, clamp((speed - 190) / 30, 1.4, 16), "大砲"); worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.50, 0.78); worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(17, (this.r || 12) * 1.15), life: 0.22, color: "rgba(220,160,70,0.52)" })); if (worldRef.relationNotice?.(t.id, this.id || "projectile", "cannon-projectile-hit", 2.8)) worldRef.log?.(`${t.name}は大砲から飛んできた${label}にぶつかって吹き飛ばされた。`, "accident", { participants: [t] }); this.vx *= -0.12; this.vy *= -0.12; this.cannonProjectile = false; this.noDecay = false; break; } } 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: "高速ずんちにぶつかって吹き飛んでいる", 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: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); t.addStress ? t.addStress(7, { threshold: 8, duration: 3.2 }) : (t.stress = clamp((t.stress || 0) + 7, 0, 130)); } 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); } resolveBallFenceBounce(fence, worldRef) { const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : []; for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, fence); } 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; let best = null; let bestD = Infinity; const hitRange = Math.max(10, (this.r || 8) * 1.25); for (const t of worldRef.tarinai) { if (!t || t.dead) 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 visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; this.pinAttachAngle = isOshibyo ? (visualSide < 0 ? -0.48 : 0.48) : Math.atan2(dy || -1, dx || visualSide); this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.76 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.22 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); this.pinFlipX = visualSide; 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 = "おしり鋲が刺さってずんちが出なくなった"; worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "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: "画鋲が刺さってパニックになっている", 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: "画鋲が刺さってパニックになっている", wake: true }); t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130)); } if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { this.pinLogAt = worldRef.time || 0; worldRef.log?.(`${t.name}に画鋲が刺さった。`, "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.tarinai?.find(o => o && !o.dead && o.id === 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 visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; this.pinFlipX = visualSide; this.x = t.x + visualSide * (isOshibyo ? (t.radius || 16) * 0.72 : (t.radius || 16) * 0.36); this.y = isOshibyo ? t.y + (t.radius || 16) * 0.22 : t.y - (t.radius || 16) * 0.04; this.prevX = this.x; this.prevY = this.y; this.spin = (isOshibyo ? 0.42 : 0.28) * (this.pinFlipX < 0 ? -1 : 1); 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 = "おしり鋲でずんちが溜まってつらい"; 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, "画鋲"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); t.addStress ? t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressPerTick ?? 2.6), 0, 130)); 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: "画鋲が刺さってパニックになっている", 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: "画鋲が刺さってパニックになっている", 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?.tarinai?.find(o => o && o.id === 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; 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); } } updateGrass(dt, worldRef) { const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7; const weather = worldRef.weather || "sunny"; const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0; if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) { this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28); this.amount -= dt * 34; return; } let fertility = 0; let fertileSource = null; let bestFertility = 0; let waterNear = weather === "light_rain" ? 0.35 : 0; const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360; const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []); let scanned = 0; const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999; for (const it of nearby) { if (++scanned > scanLimit) break; if (it === this) continue; const d = distXY(this.x, this.y, it.x, it.y); if ((it.type === "trace" || it.type === "splat") && d < 170) { const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55); fertility += influence; if (influence > bestFertility) { bestFertility = influence; fertileSource = it; } } if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) { const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72); fertility += influence; if (influence > bestFertility) { bestFertility = influence; fertileSource = it; } } if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25; } this.fertilityBoost = clamp(fertility, 0, 1.4); const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08; const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0; const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5; this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88); this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1); const grassLimit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96); if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) { const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2); if (spawned > 0) this.growth = rand(0.52, 0.68); this.seedTimer = rand(18, 34); } if (this.age > (this.lifeSpan || 640) * 0.82) { const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1); this.wither = fade; this.health = clamp((this.health ?? 1) - dt * (0.004 + fade * 0.020), 0, 1); this.growth = clamp((this.growth ?? 0.25) - dt * fade * 0.005, 0, 0.88); } else { this.wither = Math.max(0, (this.wither || 0) - dt * 0.08); } this.amount = clamp(this.growth * 120 * this.health, 0, 130); if (this.age > (this.lifeSpan || 640) * 1.05) this.amount -= dt * (1.2 + (this.wither || 0) * 24); this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5; if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) { this.seedTimer = rand(24, 54); if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1); } } seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) { let made = 0; const limit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96); for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) { const source = fertile && fertileSource ? fertileSource : this; const spot = worldRef.findGrassSproutSpot ? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource)) : { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) }; if (!spot) break; const sprout = new Item("grass", spot.x, spot.y); sprout.growth = rand(0.08, 0.18); sprout.amount = sprout.growth * 120; worldRef.items.push(sprout); if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1; made += 1; } return made; } updateZunchi(dt, worldRef) { const rainy = worldRef.weather === "light_rain"; const rainBoost = rainy ? 1.95 : 1; const decayBoost = rainy ? 2.15 : 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 * 2.0 * (rainBoost + waterBoost * 1.3); if (this.stage === "fresh") { this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); this.amount -= dt * 0.05 * 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 -= dt * 0.09 * 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 -= dt * 0.12 * 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 -= dt * 0.26 * decayBoost; } } get dead() { return this.amount <= 0; } draw(ctx, t, lighting = null) { const lightState = lighting || getLightingState(world); const night = clamp(lightState.nightStrength * 1.35, 0, 1); const warm = lightState.warmth; const styleNight = lightState.light < 0.42; const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170; const dropY = dropT > 0 ? -dropFallHeight * dropT : 0; const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); if (this.type !== "trace" && this.type !== "splat") { const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null; const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null; if (zunchiImg) { const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi"); const ratio = metrics?.ratio || 178 / 236; const zunchiH = this.r * 1.66 * ratio; drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, { alpha: shadow.alpha * visibleAlpha * 1.18, widthScale: 0.92, heightScale: 0.92, }); } else if (this.type === "grass") { ctx.save(); ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13); ctx.fillStyle = shadow.color; ctx.beginPath(); ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } else { drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 }); } } drawItemGlow(ctx, this, lightState, visibleAlpha); if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) { ctx.save(); ctx.globalAlpha = 0.32; ctx.strokeStyle = "rgba(255, 250, 220, 0.82)"; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } ctx.save(); ctx.translate(this.x, this.y + dropY); ctx.globalAlpha = visibleAlpha; if (this.type === "food" || this.type === "sweet" || this.type === "love_mochi" || this.type === "fight_mochi") { const sweet = this.type === "sweet"; const loveMochi = this.type === "love_mochi"; const fightMochi = this.type === "fight_mochi"; ctx.fillStyle = loveMochi ? "#fff1f6" : (fightMochi ? "#fff3ec" : (sweet ? "#fffaf1" : "#f1dfb7")); ctx.strokeStyle = loveMochi ? "#bf6f8b" : (fightMochi ? "#bc6d42" : (sweet ? "#9a8a72" : "#8d6f48")); ctx.lineWidth = 2; ctx.beginPath(); const bob = 0; ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); if (sweet) { ctx.beginPath(); ctx.fillStyle = "#76b94d"; ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.62)"; ctx.beginPath(); ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2); ctx.fill(); } else if (loveMochi) { ctx.fillStyle = "#ff6a9c"; ctx.font = `${Math.round((this.r || 13) * 1.15)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("\u2764", 0, 1); ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.beginPath(); ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2); ctx.fill(); } else if (fightMochi) { ctx.strokeStyle = "rgba(168,76,42,0.82)"; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(-this.r * 0.44, -this.r * 0.42); ctx.lineTo(this.r * 0.44, this.r * 0.42); ctx.moveTo(-this.r * 0.44, this.r * 0.42); ctx.lineTo(this.r * 0.44, -this.r * 0.42); ctx.stroke(); } else { ctx.fillStyle = "#fff7dc"; for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2); ctx.fill(); } } } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { const visual = itemVisualFor(this.type); const palette = visual?.palette || {}; const renderer = visual?.renderer || "split_pill"; const meta = { fill: palette.fill || "#fffaf1", stroke: palette.stroke || "#8d6f48", accent: palette.highlight || palette.accent || "#ddd", kind: renderer === "powder_pile" ? "powder" : renderer === "oval_droplet" ? "droplet" : renderer === "laxative_tablet" ? "laxative" : renderer === "mystery_tablet" ? "mystery" : renderer === "bullet" ? "bullet" : "pill", }; const localR = this.r * (visual?.fieldScale || visual?.scale || 1); ctx.rotate(Math.sin(this.seed) * 0.10); if (meta.kind === "powder") { ctx.save(); ctx.fillStyle = "rgba(68,46,28,0.18)"; ctx.beginPath(); ctx.ellipse(0, localR * 0.50, localR * 1.22, localR * 0.34, 0, 0, Math.PI * 2); ctx.fill(); for (let i = 0; i < 18; i++) { const px = randSeed(this.seed + i * 7, -localR * 0.85, localR * 0.85); const layer = 1 - Math.abs(px) / Math.max(1, localR * 0.90); const py = randSeed(this.seed + i * 11, -localR * 0.15, localR * 0.48) - layer * localR * 0.48; const rr = randSeed(this.seed + i * 13, localR * 0.14, localR * 0.32) * (0.75 + layer * 0.45); ctx.fillStyle = i % 3 === 0 ? meta.accent : meta.fill; ctx.strokeStyle = meta.stroke; ctx.globalAlpha = 0.86 + layer * 0.12; ctx.lineWidth = 1.0; ctx.beginPath(); ctx.ellipse(px, py, rr * 1.18, rr * 0.72, randSeed(this.seed + i, -0.45, 0.45), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.globalAlpha = 1; ctx.fillStyle = "rgba(255,255,255,0.62)"; ctx.beginPath(); ctx.ellipse(-localR * 0.30, -localR * 0.42, localR * 0.28, localR * 0.10, -0.35, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } else if (meta.kind === "droplet") { drawOvalWaterDropSprite(ctx, localR, { fill: meta.fill, stroke: meta.stroke, highlight: meta.accent }); } else if (meta.kind === "laxative") { drawLaxativeTabletSprite(ctx, localR); } else if (meta.kind === "mystery") { drawMysteryTabletSprite(ctx, localR); } else if (meta.kind === "bullet") { drawBulletSprite(ctx, localR); } else if (meta.kind === "box") { ctx.fillStyle = meta.fill; ctx.strokeStyle = meta.stroke; ctx.lineWidth = 2; roundedRect(ctx, -localR * 0.96, -localR * 0.78, localR * 1.92, localR * 1.56, localR * 0.24); ctx.fill(); ctx.stroke(); ctx.fillStyle = meta.accent; for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.ellipse(randSeed(this.seed + i, -localR * 0.50, localR * 0.50), randSeed(this.seed + i + 5, -localR * 0.30, localR * 0.34), localR * 0.18, localR * 0.34, 0.28, 0, Math.PI * 2); ctx.fill(); } ctx.strokeStyle = "rgba(110,76,30,0.52)"; ctx.beginPath(); ctx.moveTo(-localR * 0.70, localR * 0.50); ctx.lineTo(localR * 0.70, -localR * 0.50); ctx.stroke(); } else { ctx.save(); ctx.fillStyle = meta.fill; ctx.strokeStyle = meta.stroke; ctx.lineWidth = 2.2; ctx.rotate(-0.38); roundedRect(ctx, -localR * 1.15, -localR * 0.46, localR * 2.30, localR * 0.92, localR * 0.46); ctx.fill(); ctx.stroke(); ctx.fillStyle = meta.accent; ctx.globalAlpha = 0.78; ctx.fillRect(-1.5, -localR * 0.42, 3.0, localR * 0.84); ctx.globalAlpha = 0.36; ctx.fillStyle = "rgba(255,255,255,0.92)"; ctx.beginPath(); ctx.ellipse(-localR * 0.48, -localR * 0.16, localR * 0.32, localR * 0.10, -0.10, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } else if (this.type === "sleep_drug") { drawSleepTabletSprite(ctx, this.r * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug")); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); ctx.save(); ctx.shadowColor = "rgba(54,42,31,0.26)"; ctx.shadowBlur = 5 + impact * 8; ctx.shadowOffsetY = 4; const squashY = 1 - impact * 0.08; const stretchX = 1 + impact * 0.05; ctx.scale(stretchX, squashY); if (img) { const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null; const w = this.r * 2.55; const h = w * (metrics?.ratio || 1.27); ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h); } else { ctx.fillStyle = "rgba(255,255,255,0.94)"; ctx.strokeStyle = "rgba(64,64,64,0.78)"; ctx.lineWidth = 5; roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(42,46,52,0.92)"; ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("\u6b63", 0, -this.r * 0.74); ctx.fillText("\u7fa9", 0, -this.r * 0.27); } if (impact > 0.01) { ctx.globalAlpha = impact * 0.20; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } else if (this.type === "duplicator" || this.type === "cannon") { const r = this.r || 42; const isCannon = this.type === "cannon"; ctx.save(); ctx.shadowColor = "rgba(40,35,30,0.24)"; ctx.shadowBlur = 6; ctx.shadowOffsetY = 3; if (!isCannon) { // Upper machine body. const g = ctx.createLinearGradient(0, -r * 1.0, 0, r * 0.32); g.addColorStop(0, "#e7f1ff"); g.addColorStop(1, "#8aa2bd"); ctx.fillStyle = g; ctx.strokeStyle = "rgba(48,68,92,0.70)"; ctx.lineWidth = Math.max(2, r * 0.06); roundedRect(ctx, -r * 0.82, -r * 1.05, r * 1.64, r * 0.92, r * 0.18); ctx.fill(); ctx.stroke(); ctx.fillStyle = this.machineSlotType ? "rgba(100,214,102,0.92)" : "rgba(255,180,72,0.92)"; ctx.beginPath(); ctx.arc(r * 0.48, -r * 0.76, r * 0.12, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(42,55,68,0.54)"; ctx.lineWidth = Math.max(1.6, r * 0.04); ctx.beginPath(); ctx.moveTo(-r * 0.48, -r * 0.78); ctx.lineTo(r * 0.20, -r * 0.78); ctx.stroke(); // Plate for tarinai below. ctx.fillStyle = "rgba(245,244,233,0.96)"; ctx.strokeStyle = "rgba(116,96,75,0.58)"; ctx.lineWidth = Math.max(2, r * 0.05); ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.95, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(196,180,154,0.22)"; ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.58, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); if (this.machineSlotType) { ctx.fillStyle = "rgba(42,36,28,0.72)"; ctx.font = `${Math.max(10, Math.round(r * 0.25))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(toolLabel(this.machineSlotType), 0, r * 0.52); } } else { const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; // Base plate. ctx.fillStyle = "#6c7685"; ctx.strokeStyle = "rgba(42,48,58,0.72)"; ctx.lineWidth = Math.max(2, r * 0.06); ctx.beginPath(); ctx.ellipse(0, r * 0.72, r * 0.78, r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Barrel. ctx.rotate(a); const bg = ctx.createLinearGradient(-r * 0.34, -r * 0.26, r * 1.08, r * 0.26); bg.addColorStop(0, "#596575"); bg.addColorStop(0.55, "#2f3946"); bg.addColorStop(1, "#1f2630"); ctx.fillStyle = bg; ctx.strokeStyle = "rgba(16,20,26,0.82)"; roundedRect(ctx, -r * 0.28, -r * 0.26, r * 1.32, r * 0.52, r * 0.18); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(10,12,16,0.88)"; ctx.beginPath(); ctx.ellipse(r * 1.02, 0, r * 0.16, r * 0.23, 0, 0, Math.PI * 2); ctx.fill(); ctx.rotate(-a); ctx.fillStyle = "#8793a5"; ctx.beginPath(); ctx.arc(0, 0, r * 0.36, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); if (this.machineSlotType) { ctx.fillStyle = "rgba(42,36,28,0.72)"; ctx.font = `${Math.max(10, Math.round(r * 0.23))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(toolLabel(this.machineSlotType), 0, -r * 0.62); } } ctx.restore(); } else if (isPinType(this.type)) { const stuck = this.pinState === "lodged"; const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); const isOshibyo = Boolean(behavior?.blocksZunchi); const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); ctx.save(); if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); else if ((this.pinFlipX || 1) < 0) ctx.scale(-1, 1); ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 2; if (img) { const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; const ratio = metrics?.ratio || (stuck ? 1 : 2.0); const w = isOshibyo ? (stuck ? this.r * 2.45 : this.r * 1.25) : (stuck ? this.r * 2.15 : this.r * 1.78); const h = w * ratio; ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); } else if (stuck) { ctx.fillStyle = "#d92736"; ctx.strokeStyle = "rgba(125,18,30,0.78)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2); ctx.fillStyle = "rgba(176,20,30,0.82)"; ctx.fill(); } else { ctx.fillStyle = "#d92736"; ctx.strokeStyle = "rgba(125,18,30,0.78)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.strokeStyle = "rgba(210,210,215,0.92)"; ctx.lineWidth = Math.max(1.5, this.r * 0.12); ctx.beginPath(); ctx.moveTo(0, this.r * 0.42); ctx.lineTo(0, this.r * 1.65); ctx.stroke(); } ctx.restore(); } else if (this.type === "water_bowl") { const palette = visualPaletteFor("water_bowl"); ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "water") { drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); } else if (this.type === "grass") { const growth = this.growth ?? clamp(this.amount / 120, 0, 1); const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; const eaten = this.eatenAmount > 14 || growth < 0.16; const grassLoad = 1; const wither = clamp(this.wither || 0, 0, 1); ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"))); ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.lineCap = "round"; ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2); const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7; const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades); for (let i = 0; i < bladeCount; i++) { const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70; const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread); const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1); const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0; ctx.beginPath(); ctx.moveTo(rootX, 8); ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78); ctx.stroke(); } if (stage === "mature" && !eaten && grassLoad <= 1) { ctx.save(); ctx.globalAlpha = 0.16; ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)"; ctx.beginPath(); ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } else if (this.type === "stone") { ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8"); ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63"; ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.lineWidth = 2; roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.34)"; ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "bed") { ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" ); ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" ); ctx.lineWidth = 2.2; ctx.beginPath(); ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.strokeStyle = "rgba(116, 89, 42, 0.45)"; ctx.lineWidth = 1.5; for (let i = 0; i < 13; i++) { const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08); const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46); const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70); const a = randSeed(this.seed + i + 60, -0.75, 0.75); ctx.beginPath(); ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5); ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); ctx.stroke(); } } else if (this.type === "nest_box") { ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237"); ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24"; ctx.lineWidth = 2.6; roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9); ctx.fill(); ctx.stroke(); ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b"; roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6); ctx.fill(); ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)"; ctx.lineWidth = 2.0; ctx.beginPath(); ctx.moveTo(-this.r * 0.95, -this.r * 0.50); ctx.lineTo(this.r * 0.95, -this.r * 0.50); ctx.moveTo(-this.r * 0.95, this.r * 0.48); ctx.lineTo(this.r * 0.95, this.r * 0.48); ctx.stroke(); ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f"; ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "signboard") { ctx.save(); ctx.fillStyle = styleNight ? "#6d543b" : (styleWarm ? "#a67848" : "#8d633e"); ctx.strokeStyle = styleNight ? "#34281c" : "#573c27"; ctx.lineWidth = 2.2; ctx.fillRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); ctx.strokeRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); roundedRect(ctx, -this.r * 1.18, -this.r * 1.18, this.r * 2.36, this.r * 1.18, this.r * 0.14); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.16)"; ctx.fillRect(-this.r * 0.92, -this.r * 0.96, this.r * 1.02, this.r * 0.14); const raw = String(this.text || "").replace(/\s+/g, " ").trim(); if (raw) { const charsPerLine = 7; const lines = []; for (let i = 0; i < raw.length && lines.length < 2; i += charsPerLine) lines.push(raw.slice(i, i + charsPerLine)); ctx.fillStyle = styleNight ? "rgba(255,249,236,0.92)" : "rgba(51,34,19,0.92)"; ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.38))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; lines.forEach((line, index) => ctx.fillText(line, 0, -this.r * 0.68 + index * this.r * 0.34)); } else { ctx.fillStyle = styleNight ? "rgba(255,249,236,0.62)" : "rgba(51,34,19,0.50)"; ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.30))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("…", 0, -this.r * 0.58); } ctx.restore(); } else if (this.type === "ant_nest") { const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = styleNight ? "#211a17" : "#30231d"; ctx.beginPath(); ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(38,37,35,0.88)"; for (let i = 0; i < Math.min(6, count); i++) { const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0); const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55); ctx.beginPath(); ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = "rgba(255,248,220,0.82)"; ctx.font = "bold 10px ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(`${count}`, 0, this.r * 1.25); } else if (this.type === "ant_corpse") { const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker"); ctx.save(); ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26); ctx.scale(1, -1); ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1); if (img) { const w = this.r * 3.9; const metrics = getImageMetrics("ant_worker"); const h = w * (metrics?.ratio || 0.52); ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h); } else { ctx.fillStyle = "rgba(42,39,35,0.85)"; ctx.beginPath(); ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } else if (this.type === "ball") { const speed = Math.hypot(this.vx || 0, this.vy || 0); const moving = clamp(speed / 260, 0, 1); ctx.save(); ctx.rotate(this.spin || 0); ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)"; ctx.shadowBlur = 2 + moving * 3; ctx.shadowOffsetY = 2; ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("\u26bd", 0, 1); if (moving > 0.55) { ctx.globalAlpha = 0.14 + moving * 0.10; ctx.shadowColor = "transparent"; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } else if (this.type === "firecracker") { const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1); const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0; ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12); ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43"; ctx.strokeStyle = "rgba(96,48,34,0.78)"; ctx.lineWidth = 2; roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#f4d15b"; ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20); ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20); ctx.strokeStyle = "rgba(74,55,39,0.82)"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(0, -this.r * 0.88); ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42); ctx.stroke(); ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)"; ctx.beginPath(); ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(42,36,29,0.66)"; ctx.font = "bold 10px ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42); } else if (this.type === "fence_v" || this.type === "fence_h") { const vertical = this.type === "fence_v"; const len = Math.max(112, this.r * 3.55); const thick = Math.max(10, this.r * 0.31); ctx.save(); ctx.rotate(vertical ? 0 : Math.PI / 2); const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"); const edge = styleNight ? "#4d3d2f" : "#5f3f25"; const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)"; ctx.fillStyle = wood; ctx.strokeStyle = edge; ctx.lineWidth = 2.4; ctx.beginPath(); roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = hi; ctx.beginPath(); roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3); ctx.fill(); ctx.restore(); } else if (this.type === "zunchi") { const zunchiId = this.zunchiVariant || "zunchi"; const img = images.get(zunchiId) || images.get("zunchi"); if (img) { const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi"); const ratio = metrics?.ratio || 178 / 236; const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46); ctx.globalAlpha *= stageAlpha; ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio); } } else if (this.type === "trace") { ctx.fillStyle = "rgba(122, 100, 76, 0.12)"; ctx.strokeStyle = "rgba(122, 100, 76, 0.16)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else if (this.type === "splat") { const fade = clamp(this.amount / 260, 0, 1); ctx.globalAlpha = visibleAlpha * fade; ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`; ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`; ctx.lineWidth = 1.8; const blobs = [ [-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5] ]; for (const [bx, by, bw, bh] of blobs) { ctx.beginPath(); ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } for (let i = 0; i < 7; i++) { ctx.beginPath(); ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } }