From d875df86004b09c2c5c1bd834342a36f0a7795ce Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 24 Jun 2026 18:49:17 +0900 Subject: [PATCH] hmm? --- PATCH_NOTES.md | 134 +++----------------- README.md | 2 +- app_manifest.json | 2 +- css/base.css | 78 ++++++++---- css/layout.css | 120 ++++-------------- css/mobile.css | 45 +------ docs/COMPACT_SAVE_V2_REPORT.md | 5 + index.html | 176 +++++++++++++------------- js/assets.js | 7 +- js/items.js | 44 ++++--- js/render.js | 18 +-- js/snapshot_system.js | 2 +- js/structures.js | 16 ++- js/tarinai_needs_items.js | 223 ++++++++++++++++++++++++++------- js/tarinai_render.js | 123 +++++++++++++++++- js/tarinai_social_move_life.js | 7 +- js/ui_tools.js | 65 ++++++---- js/version.js | 4 +- js/world_combat_effects.js | 7 +- js/world_placement_log.js | 24 +++- js/world_update.js | 25 ++-- service-worker.js | 2 +- 22 files changed, 629 insertions(+), 500 deletions(-) diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index 201434d..f5c2c7d 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,123 +1,25 @@ -# 15.24.35 +# 15.24.53 -- 持ち主不明のかんたんベッドは、最初に利用した個体の所有物になるよう変更。 -- かんたんベッド同士が近すぎる位置に作られにくいよう、配置ブロックと建築位置選定を調整。 -- 複製機にセットされた食べ物の種類を、食べ物探索の優先順位と接触距離ソートに反映。 +- 排泄直後のずんちを通常個体が即座に食べないよう、ずんち専用の摂食保護時間を追加。 +- 通常個体のずんち摂食条件をさらに厳格化し、他の食料が近い場合はずんちを食べないよう再調整。 +- ずんちの風化・乾燥・分解・肥土化の進行速度と量減少速度を低下。 +- 日光浴スプライトの切替を `updateSunbath` に一本化し、`spriteId()` が毎フレーム再抽選しないよう修正。 +- 日光浴中はキャッシュ済み透明キャンバスではなく、読み込み済みの `sunbath` 画像を直接描画するよう修正。 -# 15.24.34 +# 15.24.52 -- ぬいぐるみを添付スプライトに差し替え、白背景を透過化。 -- たりないデータの「関係」と「家族」を1セクションに統合。 -- 観察中の友達/敵対ハイライト判定を相互関係ベースに拡張。 -- かんたんベッドで睡眠中の個体に、親子でも友達でもない個体が寝に来た場合は喧嘩開始。 +- 日光浴中の専用直接描画を停止し、通常のたりない描画経路で `sunbath` スプライトを常時表示するよう修正。 +- 日光浴中は通常描画直前の `spriteId()` で0.5秒ごとに `sunbath` 系スプライトをランダム切替するよう統一。 +- 切替フレーム以外で日光浴スプライトが消える問題を抑止。 +- 持ち主不明のかんたんベッドが放置で徐々に劣化・消滅する処理を追加。 -# Patch Notes 15.24.33 +# 15.24.52 -## 15.24.33 +- 日光浴中の描画を通常表情描画から分離し、毎フレーム必ず sunbath 系スプライトを直接描画するよう修正。 +- 0.5秒ごとのランダム切替は維持しつつ、切替フレーム以外でスプライトが消える問題を抑止。 +- sunbath画像が未ロードの瞬間も、読み込み済み候補またはフォールバックで描画を継続。 -- Version: `15.24.33`. -- Build: `compact-save-v2`. -- Added item-bucket dirty tracking, `ensureItemBuckets()`, `itemById()`, `liveItemsOfType()`, and `addItem()` to keep type buckets and id lookup coherent after runtime item additions. -- Routed common item creation paths through `World.addItem()` for placements, rain water, grass growth/spawn, zunchi burst/spawn, ant corpses/nests, Tarinai death traces, and constructed owned structures. -- Expanded runtime diagnostics with bucket dirty state, item id-map size, and bucket rebuild counters. -- Reworked several hot item lookups to use buckets or id-map helpers where safe. -- Updated regression checks for item-bucket helper availability and direct item push documentation. -- Added final pre-save runtime-polish report and remaining-work split note. -- Did not touch save migration, population/family limits, or family-tree UI. +# 15.24.50 -# Patch Notes 15.24.31 - -## 15.24.31 - -- Version: `15.24.31`. -- Build: `social-balance-runtime-polish`. -- Fixed social-action balance regression where conflict could dominate too often after ActionSpec migration. -- Reduced normal conflict pressure, lowered fight/intimidation priority, and raised mate/bond selection weight. -- Added pair-level fight cooldown guards so one pair/individual cannot trigger repeated fight starts in the same burst. -- Shortened intimidation/defeated display timers. -- Preserved fight mochi as a strong forced exception while reducing ordinary proximity-fight probability. -- Added regression checks for social balance constants and fight re-entry guards. -- Did not touch save migration, population/family limits, or family-tree UI. - -# Patch Notes 15.24.30 - -## 15.24.30 - -- Version: `15.24.30`. -- Build: `action-spec-sunbath-runtime-check`. -- Moved `sunbath` into `createSunbathActionSpec()` and registered it directly in `TARINAI_ACTION_DEFINITIONS`. -- Removed the remaining `configureTarinaiNeedActions()` late-binding bridge. -- Kept the existing sunbath body/timer subsystem intact; player-visible sunbath behavior should remain unchanged. -- Updated regression checks so late action patching is treated as a failure. -- Did not touch save migration, population/family limits, or family-tree UI. - -# Patch Notes 15.24.29 - -## 15.24.29 -- Migrated social/fear/combat/mate/birth runtime actions into ActionSpec factories. -- Kept family data, family tree UI, population limits, and save migration untouched. -- Added regression checks for pass 3 ActionSpec migration. -- Build: `action-spec-social-runtime-check`. - -# Patch Notes 15.24.28 - -## 15.24.28 - -- Added centralized behavior-text composition through `composeTarinaiBehaviorText()` and `patchBehaviorTextFromComposer()`. -- Added `textTarinaiAction()` to the ActionSpec lifecycle helpers. -- Kept existing explicit live behavior text as the default display fallback so player-facing wording does not churn during normal observation. -- Added a player-visible diff check comparing the current build against the initial 15.24.20 ZIP. -- Kept save migration, population/family caps, and family-tree UI out of scope. - -# Patch Notes 15.24.25 - -## 15.24.25 - -- Added `scripts/regression_check.py` and `docs/REGRESSION_CHECKLIST.md` to freeze the current generated-file, syntax, and ActionSpec lifecycle baseline. -- Expanded `js/tarinai_action_spec.js` with explicit lifecycle helpers: `selectTarinaiActionTarget`, `canStartTarinaiAction`, `startTarinaiAction`, `tickTarinaiAction`, `finishTarinaiAction`, and `failTarinaiAction`. -- Migrated need-action definitions from legacy `condition/run/update` shape toward explicit `selectTarget/canStart/start/tick` ActionSpec fields. -- Routed priority/action eligibility through `canStartTarinaiAction()` while preserving legacy fallback behavior. -- Added finish/fail lifecycle calls for completed or failed action ticks. -- Kept save migration, population caps, family caps, and family-tree UI out of scope. - -# Patch Notes 15.24.23 - -## 15.24.23 - -- Added coarse-grained spatial invalidation via `markSpatialDirty()` + `ensureSpatial()` for the main update loop. -- Reduced redundant spatial rebuilds around item, ball, ant, and Tarinai update phases while keeping direct `rebuildSpatial()` calls compatible. -- Added per-type item buckets in `updateItemCounts()` and switched ball-ball collision setup to `itemsOfType("ball")`. -- Reworked render layering into a cached dynamic render stack so expensive y-sort work runs on a short interval or count/signature change, not every paint. -- Kept population and family-tree UI behavior unchanged. - -# Patch Notes 15.24.22 - -- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers. -- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs. -- Re-routed love mochi, fight mochi, and sleep drug effects through forced need actions. -- Converted eating, panic, fighting, intimidation, and birth ritual display/scheduling to behavior-backed actions. -- Split social need pressure internally into bond/family/mate/conflict/fear parts and used those parts in social action selection. -- Weighted fulfill action selection by its internal reason parts. -- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced. -- UI action text now prefers `activeBehavior` over stale thoughts or intent strings. - -## 15.24.22 -- `js/tarinai_action_spec.js` を追加し、行動を `ActionSpec` として登録できる共通層を導入。 -- 既存の `condition` / `run` / `update` を維持しつつ、`canStart` / `start` / `tick` / `finish` / `fail` / `text` に段階移行できる互換ラッパーを追加。 -- `TARINAI_ACTIONS` を `TARINAI_ACTION_DEFINITIONS` から登録生成する形に変更。既存参照は維持。 -- 行動開始・継続処理を `startTarinaiAction()` / `tickTarinaiAction()` 経由へ移行。 -- `behavior` に `specId` / `specKind` を保持し、表示・旧API互換と行動仕様の対応を追いやすくした。 - -## 15.24.21 -- 外部強制行動を queued / active / retry / expire の状態機械に拡張。 -- 強制行動の source / forcedReasonText を activeBehavior に保持し、旧タイマー同期で失われにくくした。 -- 関係欲求の開始判定を social 大分類ではなく bond / family / mate / conflict のサブ欲求に寄せた。 -- 食事行動を activeBehavior 内で対象接近から摂食完了まで完結するよう整理。 -- 行動選択時に高い未選択欲求がある場合、「眠たいけど、食べている」のように対立理由を表示。 - -## 15.24.21 -- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。 -- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。 -- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。 -- 睡眠欲求を概日圧 `circadianSleepPressure` 中心に変更。夜は大きく上昇、昼下がりは微増、それ以外は減衰。 -- 初期生成時、夜なら睡眠欲求が高めになるよう初期エネルギーと概日圧を調整。 +- 日光浴中のスプライトを、通常表情ロックとは別枠で固定。 +- sunbath 系スプライトを初期ロード対象へ追加。 diff --git a/README.md b/README.md index a5324c1..6ba173d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tarinai Colony Observation Game -Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/assets.js b/js/assets.js index 50b7510..dd44218 100644 --- a/js/assets.js +++ b/js/assets.js @@ -10,8 +10,11 @@ const stainOverlayCache = new Map(); const SPRITE_CACHE_LIMIT = 128; const STAIN_CACHE_LIMIT = 64; -const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); +const SUNBATH_IMAGE_IDS = new Set((typeof SPRITES !== "undefined" ? SPRITES : []) + .map(asset => asset?.id || "") + .filter(id => String(id).startsWith("sunbath"))); +const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); function trimCanvasCache(cache, limit = 128) { while (cache.size > limit) { diff --git a/js/items.js b/js/items.js index d6fc648..a12f7e0 100644 --- a/js/items.js +++ b/js/items.js @@ -336,6 +336,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (!ctx || !type) return false; const w = Math.max(1, Number(opts.width || ctx.canvas?.width || 72)); const h = Math.max(1, Number(opts.height || ctx.canvas?.height || 72)); + const watermark = !!opts.watermark; const localR = previewItemRadiusFor(type); const item = Object.create(Item.prototype); Object.assign(item, { @@ -371,7 +372,10 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (type === "water") item.amount = 50; ctx.save(); ctx.clearRect(0, 0, w, h); - ctx.translate(w / 2, h / 2 + (type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4)); + const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4; + const centerX = watermark ? w * 0.78 : w / 2; + const centerY = watermark ? (h * 0.80 + baseOffsetY * 0.15) : (h / 2 + baseOffsetY); + ctx.translate(centerX, centerY); const scaleMap = { genkotsu: 0.52, bed: 0.80, @@ -386,7 +390,14 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { oshibyo: 0.92, firecracker: 0.90, }; - ctx.scale(scaleMap[type] || 0.92, scaleMap[type] || 0.92); + let scale = scaleMap[type] || 0.92; + if (isConsumableSpriteType(type)) scale *= 1.95; + else if (["grass", "water", "water_bowl", "zunda_juice"].includes(type)) scale *= 1.85; + else if (type === "duplicator") scale *= 1.55; + else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "firecracker", "genkotsu"].includes(type)) scale *= 1.08; + else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35; + if (watermark) scale *= 1.42; + ctx.scale(scale, scale); try { item.draw(ctx, 0, typeof getLightingState === "function" ? getLightingState(globalThis.world) : null); } catch (err) { @@ -472,7 +483,7 @@ class Item { this.stageTimer = 0; this.fertility = 0; this.freshness = 1; - this.spawnGrace = 1.2; + this.spawnGrace = 10.0; this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; } if (type === "ant_nest") { @@ -955,7 +966,7 @@ class Item { this.deletable = false; this.pinTargetId = t.id; const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = faceDir; + 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); @@ -1004,7 +1015,7 @@ class Item { t.stuckPushpinId = this.id; this.deletable = false; const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = faceDir; + 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; @@ -1171,31 +1182,31 @@ class Item { updateZunchi(dt, worldRef) { const rainy = worldRef.weather === "light_rain"; - const rainBoost = rainy ? 1.95 : 1; - const decayBoost = rainy ? 2.15 : 1; + 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 * 2.0 * (rainBoost + waterBoost * 1.3); + 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.05 * decayBoost; + 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.09 * decayBoost; + 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.12 * decayBoost; + 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.26 * decayBoost; + this.amount -= decayDt * 0.090 * decayBoost; } } get dead() { @@ -1522,6 +1533,7 @@ class Item { } else if (this.type === "signboard") { ctx.save(); const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12); + const boardHalfW = this.r * 1.20; boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8"); boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6"); boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397"); @@ -1533,7 +1545,7 @@ class Item { ctx.stroke(); ctx.fillStyle = boardGrad; ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48"; - roundedRect(ctx, -this.r * 1.36, -this.r * 1.28, this.r * 2.72, this.r * 1.38, this.r * 0.18); + roundedRect(ctx, -boardHalfW, -this.r * 1.28, boardHalfW * 2, this.r * 1.38, this.r * 0.18); ctx.fill(); ctx.stroke(); ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)"; @@ -1541,12 +1553,12 @@ class Item { for (let i = 0; i < 3; i++) { const y = -this.r * 1.02 + i * this.r * 0.36; ctx.beginPath(); - ctx.moveTo(-this.r * 1.12, y); - ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, this.r * 1.12, y + Math.cos(this.seed + i) * 2); + ctx.moveTo(-boardHalfW * 0.82, y); + ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, boardHalfW * 0.82, y + Math.cos(this.seed + i) * 2); ctx.stroke(); } ctx.fillStyle = "rgba(255,255,255,0.28)"; - roundedRect(ctx, -this.r * 1.12, -this.r * 1.10, this.r * 2.24, this.r * 0.20, this.r * 0.08); + roundedRect(ctx, -boardHalfW * 0.82, -this.r * 1.10, boardHalfW * 1.64, this.r * 0.20, this.r * 0.08); ctx.fill(); const raw = String(this.text || "").replace(/\r/g, "").trim(); if (raw) { diff --git a/js/render.js b/js/render.js index 234ced6..de23a1f 100644 --- a/js/render.js +++ b/js/render.js @@ -814,23 +814,7 @@ function render() { if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } - if (density > 0.45) { - ctx.save(); - ctx.lineWidth = 1.15; - ctx.setLineDash([4, 4]); - ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)"; - for (const t of world.tarinai) { - if (!isEntityVisibleInRect(t, visibleRect, 80)) continue; - const p = t.parentToFollow?.(); - if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue; - ctx.beginPath(); - ctx.moveTo(t.x, t.y); - ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y); - ctx.stroke(); - } - ctx.setLineDash([]); - ctx.restore(); - } + // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. for (const entry of renderStack.layered) { if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting); diff --git a/js/snapshot_system.js b/js/snapshot_system.js index f704f0a..a365c48 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -349,7 +349,7 @@ function createSnapshot(worldRef = global.world) { if (!worldRef) throw new Error("world is not ready"); - const tarinaiLive = (worldRef.tarinai || []).filter(Boolean); + const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead); const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead); const tarinaiIndex = new Map(); const familyIndex = new Map(); diff --git a/js/structures.js b/js/structures.js index 6157650..7242b7c 100644 --- a/js/structures.js +++ b/js/structures.js @@ -22,7 +22,7 @@ this.usedCount = 0; this.x = x; this.y = y; - this.r = definition.type === "plushie" ? 13 : (definition.type === "grass_bed" ? 18 : 24); + this.r = definition.type === "plushie" ? 6.5 : (definition.type === "grass_bed" ? 18 : 24); this.amount = this.hp; this.seed = Math.random() * 1000; this.roles = clonePlain(definition.roles); @@ -54,9 +54,21 @@ return; } this.x = owner.x; - this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78); + this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70); if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8); } + if (this.type === "grass_bed") { + const owner = this.owner(worldRef); + if (!owner) { + this.unownedDecayTimer = (this.unownedDecayTimer || 0) + dt; + const rainBoost = worldRef?.weather === "light_rain" ? 1.65 : 1; + const ageBoost = 1 + Math.min(1.8, this.unownedDecayTimer / 360); + this.damage(dt * 0.070 * rainBoost * ageBoost, worldRef, null); + if (this.dead) return; + } else { + this.unownedDecayTimer = 0; + } + } this.amount = Math.max(0.001, this.hp); } diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 4e49c70..6f121e8 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -46,8 +46,8 @@ const TARINAI_ACTION_LOCK_SECONDS = { use_medicine: 2.4, rest_to_recover: 6.0, sunbath: 10.5, - panic_escape: 4.2, - flee: 3.4, + panic_escape: 2.1, + flee: 1.7, hide_at_owned_structure: 6.4, approach_friend: 6.4, approach_parent_or_child: 7.0, @@ -70,10 +70,10 @@ const TARINAI_ACTION_PRIORITY = { hide_at_owned_structure: 90, use_medicine: 86, rest_to_recover: 84, - sunbath: 83, - sleep_in_bed: 80, - sleep_build_grass_bed: 79, - sleep_anywhere: 78, + sunbath: 91, + sleep_in_bed: 86, + sleep_build_grass_bed: 85, + sleep_anywhere: 84, birth_ritual: 72, approach_mate: 68, approach_parent_or_child: 66, @@ -895,12 +895,23 @@ function effectiveFoodTypeForItem(item) { return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); } +function isDuplicatorFoodSource(item) { + return !!item && item.type === "duplicator"; +} + +function duplicatorPoopMultiplier(foodType = "") { + if (foodType === "sweet") return 0.035; + if (foodType === "food") return 0.040; + if (foodType === "grass") return 0.028; + return 0.042; +} + function effectiveFoodRoleMatches(item, role) { const type = effectiveFoodTypeForItem(item); if (!type) return false; if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type)); if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice"; - if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); + if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); return false; } @@ -941,6 +952,8 @@ function foodPriorityRank(tarinai, item) { const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6; + if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7; + if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7; if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4; if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450; @@ -956,11 +969,48 @@ function foodPriorityScore(tarinai, item, distance = 0) { let rank = foodPriorityRank(tarinai, item); if (rank < 0) return -Infinity; const hunger = Number(tarinai?.hunger || 0) || 0; - if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 88) rank -= 900; + if (item?.type === "zunchi") { + if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity; + if (!tarinai?.isZunchiSlave) rank -= 1400; + if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200; + } if (item?.type === "duplicator") rank += 85; return rank - Math.max(0, Number(distance) || 0) * 0.65; } +function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { + if (!tarinai || !world) return false; + for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) { + if (!it || it.dead || it.type === "zunchi") continue; + if (tarinai.shouldAvoidTarget?.(it)) continue; + const type = effectiveFoodTypeForItem(it); + if (!type) continue; + if (["sweet", "food", "grass", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) { + if (it.type === "grass" && (it.amount || 0) <= 0) continue; + if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue; + return true; + } + } + return false; +} + +function zunchiEatingBlocked(tarinai, item, world) { + if (!tarinai || !item || item.type !== "zunchi") return false; + const itemAge = Number(item.age || 0) || 0; + const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge; + const protectedAge = Math.max(itemAge, worldAge); + const protectedUntil = Number(item.eatProtectedUntil || 0) || 0; + if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true; + const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; + const hunger = Number(tarinai.hunger || 0) || 0; + // 排泄直後のずんちは観察・接触対象であり、通常食として即消費させない。 + // ずんちどれいも数秒は待つ。通常個体は極限飢餓でも最低90秒は食べない。 + const grace = tarinai.isZunchiSlave ? 18 : 90; + if (protectedAge < grace) return true; + if (!tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 98 && hunger < 108) return true; + return false; +} + function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { let best = null, bestScore = Infinity; const hunger = Number(tarinai?.hunger || 0) || 0; @@ -1154,16 +1204,16 @@ function circadianSleepPhase(world) { function updateCircadianSleepPressure(tarinai, world, dt = 0) { const phase = circadianSleepPhase(world); if (!Number.isFinite(tarinai.circadianSleepPressure)) { - tarinai.circadianSleepPressure = phase.night ? rand(58, 78) : (phase.afternoon ? rand(8, 18) : rand(0, 5)); + tarinai.circadianSleepPressure = phase.night ? rand(68, 88) : (phase.afternoon ? rand(8, 18) : rand(0, 5)); } const step = Math.max(0, Number(dt) || 0); if (phase.night) { - tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.18, 0, 78); + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.85, 0, 94); } else if (phase.afternoon) { tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.16, 0, 26); } else { const decay = (tarinai.state === "sleep" || tarinai.sleeping) ? 1.85 : 0.42; - tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 78); + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 94); } return { ...phase, pressure: tarinai.circadianSleepPressure }; } @@ -1465,12 +1515,13 @@ function createSunbathActionSpec() { phrase: "日光浴している", weight: 32, timerBacked: true, - canStart(t) { - return (Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0) >= needThreshold("health", "start") - && !!t?.canStartSunbath?.(); + canStart(t, world, ctx = {}) { + const healthNeed = Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0; + return (ctx.leisure || healthNeed >= needThreshold("health", "start")) + && !!t?.canStartSunbath?.({ leisure: !!ctx.leisure }); }, - start(t) { - return !!t?.startSunbath?.(); + start(t, world, ctx = {}) { + return !!t?.startSunbath?.({ leisure: !!ctx.leisure }); }, tick(t) { return (t?.sunbathTimer || 0) > 0.04 ? true : "finished"; @@ -1871,8 +1922,12 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { const foodType = effectiveFoodTypeForItem(target); const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269"; const effectiveDt = Math.max(0.10, Number(dt) || 0.12); + if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) { + t.setActionState?.(role === "medicine" ? "seek_food" : "eat", { target, reason: `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, sleeping: false }); + return true; + } - if (role === "drink" && (target.type === "water" || target.type === "water_bowl")) { + if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) { const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b"; t.setActionState?.("seek_water", { target, reason: text, sleeping: false }); t.thought = text; @@ -1882,7 +1937,7 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); t.nextDrinkSound = (world.time || 0) + 0.72; } - applyNeedRelief(t, { food: -4, health: -1 }); + applyNeedRelief(t, role === "medicine" ? { food: -2, health: -5, safety: -1 } : { food: -4, health: -1 }); return true; } @@ -1916,7 +1971,12 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } - t.makePoop?.(eating * (foodType === "sweet" ? 0.18 : 0.20)); + const fromDuplicator = isDuplicatorFoodSource(target); + t.makePoop?.(eating * (fromDuplicator ? duplicatorPoopMultiplier(foodType) : (foodType === "sweet" ? 0.18 : 0.20))); + if (fromDuplicator) { + t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2); + t.intentLockTimer = Math.max(0, Math.min(t.intentLockTimer || 0, 0.18)); + } return true; } @@ -1937,7 +1997,10 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { if (target.type === "zunchi" && (target.amount || 0) > 0) { const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; - if (!t.isZunchiSlave && foodNeed < 82 && (t.hunger || 0) < 88) return false; + if (zunchiEatingBlocked(t, target, world)) return false; + if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false; + if (!t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 180 && foodNeed < 98 && (t.hunger || 0) < 108) return false; + if (!t.isZunchiSlave && foodNeed < 94 && (t.hunger || 0) < 104) return false; const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); if (!(bite > 0)) return false; @@ -2075,7 +2138,7 @@ if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINI function structureBuildProbe(type, x, y) { const def = globalThis.StructureRegistry?.get?.(type) || {}; - const r = type === "plushie" ? 13 : (type === "grass_bed" ? 18 : 24); + const r = type === "plushie" ? 6.5 : (type === "grass_bed" ? 18 : 24); return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" }; } @@ -2348,6 +2411,7 @@ function shouldStartNeedAction(tarinai, need, needs) { const hunger = Number(tarinai?.hunger || 0) || 0; if (inMealCooldown && hunger < 82 && value < 78 && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced)) return false; } + if (need === "sleep" && circadianSleepPhase(tarinai?.world).night && value >= Math.max(44, needThreshold("sleep", "start") - 16)) return true; if (value >= needThreshold(need, "start")) return true; if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need && value >= needThreshold(need, "continue")) return true; return false; @@ -2628,8 +2692,15 @@ function protectSleepSession(tarinai, world, needs) { }, spriteId() { - const next = this.rawSpriteId(); const now = this.world?.time || 0; + if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) { + this.preloadSunbathSprites?.(); + const sunbathId = this.sunbathSpriteId ? this.sunbathSpriteId() : (String(this.sunbathSpriteVariant || "").startsWith("sunbath") ? this.sunbathSpriteVariant : "sunbath_1"); + this.visibleSpriteId = sunbathId; + this.spriteLockUntil = now + 0.55; + return sunbathId; + } + const next = this.rawSpriteId(); const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04; const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04; const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate"); @@ -2653,26 +2724,69 @@ function protectSleepSession(tarinai, world, needs) { return (this.needs?.social || 0) < 70 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); }, + sunbathSpriteCandidates() { + const sprites = typeof SPRITES !== "undefined" ? SPRITES : (typeof globalThis !== "undefined" ? globalThis.SPRITES : []); + const ids = Array.isArray(sprites) + ? sprites.map(s => s?.id).filter(id => typeof id === "string" && id.startsWith("sunbath")) + : []; + const ordered = ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]; + return ordered.slice().sort((a, b) => { + const an = Number(String(a).match(/(\d+)$/)?.[1] || 0); + const bn = Number(String(b).match(/(\d+)$/)?.[1] || 0); + return an - bn || String(a).localeCompare(String(b)); + }); + }, + + + preloadSunbathSprites() { + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + for (const id of ids) { + if (typeof ensureImage === "function") ensureImage(id); + else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id); + } + }, + + normalizeSunbathSpriteId(value = "") { + if (typeof value === "number" && Number.isFinite(value)) return `sunbath_${Math.max(1, Math.floor(value))}`; + const str = String(value || ""); + if (/^sunbath_\d+$/.test(str) || str.startsWith("sunbath")) return str; + return ""; + }, + + randomSunbathSpriteId(exclude = "") { + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + if (!ids.length) return "sunbath_1"; + const normalizedExclude = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(exclude) : String(exclude || ""); + const choices = ids.length > 1 ? ids.filter(id => id !== normalizedExclude) : ids; + return choices[Math.floor(Math.random() * choices.length)] || ids[0] || "sunbath_1"; + }, + sunbathSpriteId() { - if (!this.sunbathSpriteVariant) this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); - return `sunbath_${this.sunbathSpriteVariant}`; + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + const current = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || ""); + if (!current || !ids.includes(current)) this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId() : "sunbath_1"; + return this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || "sunbath_1"); }, isSunbathTime() { - const weatherOk = (this.world?.weather || "") === "sunny"; + const weather = this.world?.weather || ""; + const weatherOk = weather === "sunny" || weather === "cloudy"; const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24); return weatherOk && hour >= 6 && hour <= 13.5; }, - canStartSunbath() { + canStartSunbath(options = {}) { if (this.dead || this.sleeping) return false; + if (!Number.isFinite(this.x) || !Number.isFinite(this.y)) return false; + if (!Number.isFinite(this.world?.w) || !Number.isFinite(this.world?.h)) return false; + if ((this.entryTimer || 0) > 0.04) return false; if (!this.isSunbathTime()) return false; if ((this.sunbathCooldown || 0) > 0) return false; if (this.stuckPushpinId || this.currentLodgedPin?.()) return false; - if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; - if (this.hunger > 68 || this.energy < 18) return false; + if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_water", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; + if (this.hunger > (options.leisure ? 58 : 68) || this.energy < (options.leisure ? 28 : 18)) return false; const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; - return healthNeed >= needThreshold("health", "start"); + return options.leisure ? healthNeed < needThreshold("health", "start") : healthNeed >= needThreshold("health", "start"); }, tryRecoverBySunbath() { @@ -2700,17 +2814,28 @@ function protectSleepSession(tarinai, world, needs) { return true; }, - startSunbath() { - if (!this.canStartSunbath()) return false; - this.setActionState?.("sunbath", { target: null, reason: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", sleeping: false }); + startSunbath(options = {}) { + if (!this.canStartSunbath(options)) return false; + const pad = Math.max(28, CONFIG.worldPadding || 30); + const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000); + const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720); + this.x = clamp(Number.isFinite(this.x) ? this.x : safeW * 0.5, pad, safeW - pad); + this.y = clamp(Number.isFinite(this.y) ? this.y : safeH * 0.5, pad, safeH - pad); + this.entryTimer = 0; + const reason = options.leisure ? "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b" : "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b"; + this.setActionState?.("sunbath", { target: null, reason, sleeping: false }); + this.preloadSunbathSprites?.(); this.sunbathTimer = Math.max(6, (CONFIG.dayLength || 120) / 12); this.sunbathCooldown = 0; - this.sunbathFrameTimer = 0; - this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + this.sunbathFrameTimer = 0.5; + this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId() : `sunbath_${1 + Math.floor(Math.random() * 3)}`; + this.sunbathSpriteFrameKey = Math.floor((this.world?.time || 0) / 0.5); + this.visibleSpriteId = this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant; + this.spriteLockUntil = 0; this.sunbathRecoveryChecked = false; this.vx = 0; this.vy = 0; - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reasonText: "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior" }); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reasonText: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior" }); if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("\u307d\u304b\u3063", 4.0, "rgba(112,86,36,0.78)"); return true; }, @@ -2722,8 +2847,11 @@ function protectSleepSession(tarinai, world, needs) { this.vx = 0; this.vy = 0; if ((this.sunbathFrameTimer || 0) <= 0) { - this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); - this.sunbathFrameTimer = 0.4; + const prev = this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant; + this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev) : `sunbath_${1 + Math.floor(Math.random() * 3)}`; + this.visibleSpriteId = this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant; + this.spriteLockUntil = (this.world?.time || 0) + 0.55; + this.sunbathFrameTimer = 0.5; } this.energy = clamp((this.energy || 0) + dt * 0.03, 0, 100); if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; @@ -3201,6 +3329,12 @@ function protectSleepSession(tarinai, world, needs) { const picked = choosePriorityNeedAction(needs, this, this.world); if (!picked?.action) { + const sunbathAction = actionById("sunbath"); + if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) { + const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) }; + startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 }); + return; + } const choice = chooseTopNeedRandom(needs, this, this.world); setActiveBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reasonText: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); clearActiveBehavior(this); @@ -3230,7 +3364,7 @@ function protectSleepSession(tarinai, world, needs) { const side = this.facingDir(); const backX = this.x - side * this.radius * rand(0.72, 0.96); const backY = this.y + this.radius * rand(0.25, 0.48); - this.world.spawnZunchi(backX, backY); + this.world.spawnZunchi(backX, backY, this); const now = this.world?.time || 0; if (now >= (this.nextPoopBubbleAt || -999)) { this.nextPoopBubbleAt = now + 2.8; @@ -3250,7 +3384,7 @@ function protectSleepSession(tarinai, world, needs) { const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start"); const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18; const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7; - const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger > 88 || foodNeed > 82)); + const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger > 108 || foodNeed > 98)) && (this.isZunchiSlave || !hasBetterFoodThanZunchiNearby(this, this.world, 240)); const quality = this.world?.performanceQuality?.() || {}; const scanLimit = Math.max(3, Number(quality.itemScanLimit || 28) || 28); const nearbyItems = this.world.nearbyItems(this.x, this.y, this.radius + 46); @@ -3297,7 +3431,9 @@ function protectSleepSession(tarinai, world, needs) { this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7"); } this.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7"; - this.makePoop(eating * (foodType === "sweet" ? 0.18 : 0.22)); + const fromDuplicator = isDuplicatorFoodSource(it); + this.makePoop(eating * (fromDuplicator ? duplicatorPoopMultiplier(foodType) : (foodType === "sweet" ? 0.18 : 0.22))); + if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); @@ -3342,7 +3478,9 @@ function protectSleepSession(tarinai, world, needs) { const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); } - this.makePoop(eating * 0.20); + const fromDuplicator = isDuplicatorFoodSource(it); + this.makePoop(eating * (fromDuplicator ? duplicatorPoopMultiplier(foodType) : 0.20)); + if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { this.world.log(`${this.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); @@ -3406,7 +3544,8 @@ function protectSleepSession(tarinai, world, needs) { if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); } this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); - if (canZunchiBite) { + const zunchiTooFresh = zunchiEatingBlocked(this, it, this.world) || (!this.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 180 && foodNeed < 98 && (this.hunger || 0) < 108); + if (canZunchiBite && !zunchiTooFresh) { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); this.beginEating(it, this.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b"); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 096f34a..8d9dafc 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -10,9 +10,31 @@ const quality = this.world.performanceQuality?.() || {}; const effectDensity = clamp(Number(quality.effectDensity || 1) || 1, 0.12, 1); const showDecor = effectDensity > 0.45 || this.world.selected === this; + if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) this.preloadSunbathSprites?.(); const sprite = this.spriteId(); - const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type)); - const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); + const sunbathSprite = String(sprite || "").startsWith("sunbath"); + const sunbathCandidates = sunbathSprite && this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : []; + if (sunbathSprite) { + for (const id of sunbathCandidates) { + if (typeof ensureImage === "function") ensureImage(id); + else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id); + } + } + const spriteFallback = sunbathSprite ? "" : "smile"; + let renderSprite = sprite; + let img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, spriteFallback) : (images.get(sprite) || images.get(spriteFallback) || images.get(this.type)); + if (sunbathSprite) { + const ready = (id) => typeof isImageReady === "function" ? isImageReady(id) : !!(images?.get?.(id)?.complete || images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width); + if (!ready(sprite)) { + const fallbackSunbath = sunbathCandidates.find(id => ready(id)); + if (fallbackSunbath) { + renderSprite = fallbackSunbath; + img = images.get(fallbackSunbath); + } + } + if (!img) img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : (images.get("smile") || images.get(this.type)); + } + const metrics = getImageMetrics(renderSprite) || getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); const w = metrics?.w || 512; const h = metrics?.h || 512; const moveSpeed = Math.hypot(this.vx, this.vy); @@ -265,7 +287,19 @@ const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); if (img) { - const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; + let spriteCacheKey = sprite; + try { + if (typeof images !== "undefined" && images.get?.(sprite) !== img) { + let fallbackId = "fallback"; + for (const [imageId, imageObj] of images) { + if (imageObj === img) { fallbackId = imageId; break; } + } + spriteCacheKey = `${sprite}:fallback:${fallbackId}`; + } + } catch (err) { + spriteCacheKey = sprite; + } + const spriteCanvas = sunbathSprite ? img : (typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(spriteCacheKey, img, cacheW, cacheH) : img); ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); } else { ctx.save(); @@ -279,7 +313,7 @@ ctx.restore(); } if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { - const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); + const stainCanvas = getCachedStainOverlayCanvas(renderSprite || sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); if (stainCanvas) { ctx.save(); ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88); @@ -364,6 +398,87 @@ } }, + drawSunbathSpriteDirect(ctx, t, lightState, showDecor = true) { + return false; + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + const candidates = (Array.isArray(ids) && ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]).filter(id => String(id || "").startsWith("sunbath")); + const now = Number(this.world?.time ?? t ?? 0) || 0; + const frameKey = Math.floor(now / 0.5); + if (this._sunbathRenderFrameKey !== frameKey || !candidates.includes(this._sunbathRenderSpriteId)) { + const prev = this._sunbathRenderSpriteId || this.sunbathSpriteVariant || ""; + const choices = candidates.length > 1 ? candidates.filter(id => id !== prev) : candidates; + this._sunbathRenderSpriteId = choices[Math.floor(Math.random() * choices.length)] || candidates[0] || "sunbath_1"; + this._sunbathRenderFrameKey = frameKey; + } + const sprite = this._sunbathRenderSpriteId || this.sunbathSpriteVariant || candidates[0] || "sunbath_1"; + this.sunbathSpriteVariant = sprite; + this.visibleSpriteId = sprite; + this.spriteLockUntil = now; + for (const id of candidates) { + if (typeof ensureImage === "function") ensureImage(id); + else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id); + } + + let img = null; + const ordered = [sprite, this.sunbathSpriteVariant, ...candidates, "smile"]; + const seen = new Set(); + for (const id of ordered) { + if (!id || seen.has(id)) continue; + seen.add(id); + const next = typeof getRenderableImage === "function" + ? getRenderableImage(id, id === "smile" ? "smile" : "smile") + : (typeof images !== "undefined" ? (images.get(id) || images.get("smile")) : null); + const ready = next && (next.complete || next.naturalWidth || next.width); + if (ready) { img = next; break; } + } + + const metrics = (typeof getImageMetrics === "function" ? (getImageMetrics(sprite) || getImageMetrics(img) || getImageMetrics("smile")) : null) || {}; + const w = metrics.w || img?.naturalWidth || img?.width || 512; + const h = metrics.h || img?.naturalHeight || img?.height || 468; + const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale; + const breathing = Math.sin(t * 1.4 + this.age * 0.18); + const bob = breathing * 0.55; + const drawW = w * visualScale * 0.30; + const drawH = h * visualScale * 0.30; + const squish = 1 + breathing * 0.006; + const dw = drawW * squish; + const dh = drawH / squish; + const faceRight = this.facingDir ? this.facingDir() > 0 : (this.facing || 1) > 0; + const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1; + if (nestHideAlpha <= 0.01) return true; + + const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8); + const shadowContactY = this.y + bob + dh * 0.30; + if (showDecor && img) { + drawImageProjectedShadow(ctx, img, this.x, shadowContactY, dw, dh, shadow, { + faceRight, + alpha: shadow.alpha * 0.92 * nestHideAlpha, + }); + } else if (showDecor) { + drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha }); + } + if (showDecor) drawCreatureGlow(ctx, this, drawW, drawH, lightState); + + ctx.save(); + ctx.translate(this.x, this.y + bob); + ctx.rotate((faceRight ? 1 : -1) * (Math.PI / 18)); + if (faceRight) ctx.scale(-1, 1); + ctx.globalAlpha = nestHideAlpha; + if (img) { + ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); + } else { + ctx.fillStyle = "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 0, dw * 0.42, dh * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + return true; + }, + contains(x, y) { if (this.insideNestBoxId || (this.nestFade || 0) >= 0.92) return false; const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 5d302fc..557e1d3 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -193,8 +193,11 @@ this.target = null; this.vx *= Math.pow(0.90, dt * 60); this.vy *= Math.pow(0.90, dt * 60); - this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); - this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + const pad = Math.max(28, CONFIG.worldPadding || 30); + const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000); + const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720); + this.x = clamp((Number.isFinite(this.x) ? this.x : safeW * 0.5) + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, pad, safeW - pad); + this.y = clamp((Number.isFinite(this.y) ? this.y : safeH * 0.5) + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, pad, safeH - pad); return; } if (this.state === "sleep") { diff --git a/js/ui_tools.js b/js/ui_tools.js index b666f60..b64ccb7 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -1,6 +1,7 @@ "use strict"; const SCALABLE_TOOLS = new Set(scalableToolIds()); +const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose"]); const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; @@ -26,8 +27,13 @@ function renderToolPalette() { if (!def || def.simulationOnly) continue; const btn = document.createElement("button"); btn.className = "tool"; + if (EMOJI_ICON_TOOLS.has(toolId)) btn.classList.add("tool-emoji-icon"); btn.dataset.tool = toolId; - btn.textContent = def.label || toolId; + const label = document.createElement("span"); + label.className = "tool-label"; + label.textContent = def.label || toolId; + btn.title = label.textContent; + btn.appendChild(label); attachToolSpritePreview(btn, def, toolId); if (toolId === world?.tool) btn.classList.add("selected"); body.appendChild(btn); @@ -38,32 +44,47 @@ function renderToolPalette() { ui.toolPalette.appendChild(frag); } +function drawToolPreviewCanvas(canvas, itemType, { watermark = false } = {}) { + if (!canvas || !itemType) return false; + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (typeof window.drawToolItemPreview === "function") { + return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false; + } + if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType) && typeof window.drawConsumableFieldSprite === "function") { + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + const baseScale = itemType === "protein" || itemType === "niteropu" ? 0.9 : 1.0; + ctx.scale(baseScale, baseScale); + window.drawConsumableFieldSprite(ctx, itemType, 17.5, 112.4, { compact: true }); + ctx.restore(); + return true; + } + return false; +} + +function createToolPreviewCanvas(className, size = 96) { + const canvas = document.createElement("canvas"); + canvas.className = className; + canvas.width = size; + canvas.height = size; + canvas.setAttribute("aria-hidden", "true"); + return canvas; +} + function attachToolSpritePreview(btn, def, toolId) { const itemType = def?.itemType || toolId; if (!btn || !itemType || !def?.placeable) return; if (toolId === "ball" || itemType === "ball") return; - const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite; - if (typeof drawPreview !== "function") return; - const canvas = document.createElement("canvas"); - canvas.className = "tool-sprite-preview"; - canvas.width = 72; - canvas.height = 72; - canvas.setAttribute("aria-hidden", "true"); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - let drawn = false; - if (typeof window.drawToolItemPreview === "function") { - drawn = window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true }) !== false; - } else if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType)) { - ctx.translate(canvas.width / 2, canvas.height / 2); - const scale = itemType === "protein" || itemType === "niteropu" ? 0.82 : 0.92; - ctx.scale(scale, scale); - window.drawConsumableFieldSprite(ctx, itemType, 13.5, 112.4, { compact: true }); - drawn = true; - } - if (!drawn) return; + if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return; + + const iconCanvas = createToolPreviewCanvas("tool-sprite-preview", 160); + const iconDrawn = drawToolPreviewCanvas(iconCanvas, itemType); + if (!iconDrawn) return; + btn.classList.add("tool-has-canvas-icon"); - btn.prepend(canvas); + btn.prepend(iconCanvas); } function toolSizeFor(tool) { diff --git a/js/version.js b/js/version.js index 7ec90cf..8bc7124 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.35"; - const APP_BUILD = "owned-bed-duplicator-priority"; + const APP_VERSION = "15.24.53"; + const APP_BUILD = "tool-ui-icon-scale-collapse-fix"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 3ca7a03..63e3d1c 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -287,7 +287,7 @@ this.ensureSpatial?.("zunchi-burst"); }, - spawnZunchi(x, y) { + spawnZunchi(x, y, source = null) { const zunchiLimit = CONFIG.zunchiLimit ?? 56; if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { let oldest = null; @@ -299,6 +299,11 @@ } const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); it.amount = 260; + it.stage = "fresh"; + it.producedById = source?.id || ""; + it.producedAt = this.time || 0; + it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0); + it.eatProtectedUntil = (this.time || 0) + 90; this.addItem?.(it, "zunchi-spawn") || this.items.push(it); audio.place?.("zunchi"); }, diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 55589a7..04785bf 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -54,6 +54,19 @@ function signboardVisibleLength(text = "") { return Array.from(String(text || "").replace(/\n/g, "")).length; } + + function replaceSignboardEditorSelection(area, text = "") { + if (!area) return; + const value = String(area.value || ""); + const start = Number(area.selectionStart || 0); + const end = Number(area.selectionEnd || start); + const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false }); + area.value = next; + const caret = Math.min(next.length, start + String(text || "").length); + area.selectionStart = caret; + area.selectionEnd = caret; + } + function openSignboardEditor(sign, worldRef) { if (!sign) return false; const dialog = ensureSignboardEditor(); @@ -97,7 +110,11 @@ const replacing = selected.length > 0; if (e.key === "Enter") { const selectedLineBreaks = (selected.match(/\n/g) || []).length; - if (lines.length - selectedLineBreaks >= SIGNBOARD_LINES) e.preventDefault(); + e.preventDefault(); + if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) { + replaceSignboardEditorSelection(area, "\n"); + syncCount(); + } return; } if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { @@ -110,10 +127,7 @@ area.onpaste = (e) => { e.preventDefault(); const text = e.clipboardData?.getData?.("text") || ""; - const start = Number(area.selectionStart || 0); - const end = Number(area.selectionEnd || start); - const next = sanitizeSignboardText((area.value || "").slice(0, start) + text + (area.value || "").slice(end), { trimTrailing: false }); - area.value = next; + replaceSignboardEditorSelection(area, text); syncCount(); }; area.oninput = () => { diff --git a/js/world_update.js b/js/world_update.js index a6e8a3b..ca81f9a 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -400,23 +400,20 @@ const result = repairPoint(ef, ef.size || 12); if (result === "delete") ef.life = 0; } + const safeW = Math.max(pad * 2 + 1, Number(this.w || 1000) || 1000); + const safeH = Math.max(pad * 2 + 1, Number(this.h || 720) || 720); + const repairTarinai = (t) => { + t.x = clamp(Number.isFinite(t.x) ? t.x : safeW * 0.5, pad, safeW - pad); + t.y = clamp(Number.isFinite(t.y) ? t.y : safeH * 0.5, pad, safeH - pad); + t.vx = 0; + t.vy = 0; + t.entryTimer = 0; + t.lastOutOfBoundsRepairAt = this.time || 0; + }; for (const t of this.tarinai || []) { if (!t || t.dead) continue; const result = repairPoint(t, t.radius || 22); - if (result === "delete") { - t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); - t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); - t.vx = 0; - t.vy = 0; - t.entryTimer = 0; - t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"); - } else if (result === "moved") { - t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); - t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); - t.vx = 0; - t.vy = 0; - t.entryTimer = 0; - } + if (result === "delete" || result === "moved") repairTarinai(t); } let changedAnts = false; for (const ant of this.ants || []) { diff --git a/service-worker.js b/service-worker.js index 9818125..fd5be6e 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "15.24.35"; +const APP_VERSION = "15.24.53"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.