diff --git a/assets/ui/tool_poke.png b/assets/ui/tool_poke.png index 6afa389..a63c6b0 100644 Binary files a/assets/ui/tool_poke.png and b/assets/ui/tool_poke.png differ diff --git a/css/styles.css b/css/styles.css index c79b88b..0b413e8 100644 --- a/css/styles.css +++ b/css/styles.css @@ -106,17 +106,63 @@ h1 { border-radius: calc(var(--radius) + 8px); overflow: hidden; box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24); - min-height: 620px; + min-height: var(--field-min-height, 620px); } #gameCanvas { width: 100%; - height: calc(100vh - 140px); - min-height: 620px; + height: var(--field-height, calc(100vh - 140px)); + min-height: var(--field-min-height, 620px); display: block; cursor: crosshair; } +.field-dialog { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 18px; + background: rgba(34, 29, 22, 0.38); + backdrop-filter: blur(3px); +} +.field-dialog.hidden { display: none; } +.field-dialog-panel { + width: min(440px, 100%); + border-radius: 16px; + padding: 16px; + background: rgba(255, 252, 245, 0.96); + border: 1px solid rgba(84,68,48,0.16); + box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22); +} +.field-dialog-panel h2 { + margin: 0 0 12px; + font-size: 17px; +} +.field-choice-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.field-choice { + min-height: 74px; + border: 1px solid rgba(84,68,48,0.16); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72)); + cursor: pointer; + color: var(--ink); +} +.field-choice:hover { border-color: rgba(112,178,66,0.45); } +.field-choice strong, .field-choice span { display: block; } +.field-choice strong { font-size: 15px; } +.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; } +.field-dialog-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + .toast { position: absolute; left: 50%; @@ -378,8 +424,9 @@ meter { .topbar { align-items: flex-start; flex-direction: column; } .layout { grid-template-columns: 1fr; } .panel { max-height: none; overflow: visible; } - #gameCanvas { height: 66vh; min-height: 460px; } - .stage-wrap { min-height: 460px; } + #gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); } + .stage-wrap { min-height: var(--field-mobile-min-height, 460px); } + .field-choice-grid { grid-template-columns: 1fr; } } @@ -415,7 +462,7 @@ meter { max-height: 560px; min-height: 320px; overflow: auto; - overscroll-behavior: contain; + overscroll-behavior: auto; padding: 8px 4px 16px; background: rgba(255,250,244,0.48); border-radius: 14px; @@ -497,13 +544,13 @@ body::before { } .family-node { display: grid; - grid-template-columns: 26px minmax(0, 1fr); + grid-template-columns: 22px minmax(0, 1fr); align-items: center; - gap: 6px; + gap: 5px; min-width: 0; - min-height: 42px; - padding: 5px 7px; - border-radius: 11px; + min-height: 34px; + padding: 4px 6px; + border-radius: 10px; background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90)); border: 1px solid rgba(84,68,48,0.14); box-shadow: 0 3px 8px rgba(64,48,32,0.045); @@ -539,7 +586,7 @@ body::before { .family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); } .family-node.dead { opacity: 0.68; filter: grayscale(0.35); } .family-node.missing { opacity: 0.48; border-style: dashed; } -.family-node img { width: 26px; height: 26px; object-fit: contain; } +.family-node img { width: 22px; height: 22px; object-fit: contain; } .family-copy { min-width: 0; line-height: 1.12; } .family-copy strong, .family-copy span { display: block; @@ -551,11 +598,12 @@ body::before { white-space: normal; overflow: visible; text-overflow: clip; - line-height: 1.18; + line-height: 1.12; + margin-top: 1px; color: rgba(126, 80, 64, 0.92); } -.family-copy strong { font-size: 12px; } -.family-copy span { color: var(--muted); font-size: 9px; margin-top: 2px; } +.family-copy strong { font-size: 11px; } +.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; } @media (max-width: 760px) { .family-grid { grid-template-columns: 1fr; } .family-cluster { min-height: 102px; } @@ -653,8 +701,8 @@ body::before { align-items: stretch; } .family-node.lineage-node { - width: 154px; - min-height: 76px; + width: 132px; + min-height: 54px; align-items: flex-start; } .lineage-children { @@ -698,8 +746,8 @@ body::before { .lineage-links .child-link { stroke-width: 1.9; stroke: rgba(107, 88, 61, 0.54); } .tree-node { position: absolute; - width: 154px; - min-height: 76px; + width: 132px; + min-height: 54px; align-items: flex-start; z-index: 2; } @@ -712,3 +760,111 @@ body::before { z-index: 1; } .lineage-family { overflow-x: auto; } + +.lineage-family-readable { + overflow: visible; +} +.lineage-readable-grid { + display: grid; + grid-template-columns: minmax(300px, 1.05fr) minmax(280px, 0.95fr); + gap: 12px; + align-items: start; +} +.lineage-member-column, +.lineage-reproduction-column { + min-width: 0; +} +.lineage-generation-block { + padding: 8px 0; + border-top: 1px dashed rgba(84,68,48,0.12); +} +.lineage-generation-block:first-child { border-top: 0; padding-top: 0; } +.lineage-generation-block > b, +.lineage-column-title { + display: block; + margin: 0 0 7px; + color: rgba(84,68,48,0.72); + font-size: 11px; +} +.lineage-person-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 7px; +} +.family-node.lineage-person { + width: auto; + min-height: 46px; +} +.lineage-pair-list { + display: grid; + gap: 8px; +} +.lineage-pair-card { + padding: 8px; + border-radius: 12px; + border: 1px solid rgba(84,68,48,0.12); + background: rgba(255,253,248,0.70); +} +.lineage-pair-parents, +.lineage-pair-children { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} +.lineage-pair-parents { + padding-bottom: 5px; +} +.lineage-pair-children { + padding-top: 5px; + border-top: 1px solid rgba(84,68,48,0.08); +} +.lineage-plus { + color: rgba(84,68,48,0.44); + font-weight: 700; + line-height: 28px; +} +.lineage-birth-arrow { + color: rgba(107, 88, 61, 0.62); + font-size: 14px; + line-height: 1; + padding: 1px 0 3px 13px; +} +.lineage-mini { + display: inline-grid; + grid-template-columns: 20px minmax(0, 1fr); + grid-template-rows: auto auto; + gap: 0 4px; + align-items: center; + max-width: 132px; + min-width: 88px; + padding: 4px 6px; + border-radius: 9px; + border: 1px solid rgba(84,68,48,0.12); + background: rgba(255,255,255,0.64); +} +.lineage-mini.child { + border-color: rgba(103,178,44,0.28); + background: rgba(246,255,235,0.62); +} +.lineage-mini.dead { opacity: 0.64; filter: grayscale(0.25); } +.lineage-mini img { + grid-row: 1 / span 2; + width: 20px; + height: 20px; + object-fit: contain; +} +.lineage-mini b, +.lineage-mini small { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lineage-mini b { font-size: 10px; line-height: 1.1; } +.lineage-mini small { color: var(--muted); font-size: 8px; line-height: 1.1; } + +@media (max-width: 760px) { + .lineage-readable-grid { grid-template-columns: 1fr; } +} diff --git a/docs/code_map.md b/docs/code_map.md new file mode 100644 index 0000000..2ba87a2 --- /dev/null +++ b/docs/code_map.md @@ -0,0 +1,29 @@ +# Code Map + +Use this map to open the smallest relevant file first. + +## Simulation +- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes. +- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing. +- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing. +- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination. +- `js/sim.js`: compatibility export for `window.TarinaiSim`. + +## UI And Rendering +- `js/render.js`: canvas scene rendering, lighting, weather visuals, HUD, and selected-card overlay. +- `js/ui.js`: DOM references, selected/log panels, labels, save/load buttons, tool controls, canvas input, and UI event binding. +- `js/ui_charts.js`: colony history aggregation and chart rendering. +- `js/ui_family.js`: family tree components, layout, archive rendering, and validation helper. +- `js/ui_exports.js`: compatibility export for `window.TarinaiUI`. + +## Common Tasks +- Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`. +- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`. +- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`. +- Change family tree display only: start with `js/ui_family.js`. +- Change colony charts: start with `js/ui_charts.js`. +- Change buttons, selected info, logs, or input handling: start with `js/ui.js`. +- Change the garden canvas visuals: start with `js/render.js`. + +## Loading Order +`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later. diff --git a/docs/design_notes.md b/docs/design_notes.md index aa580fe..2b1c1e8 100644 --- a/docs/design_notes.md +++ b/docs/design_notes.md @@ -101,7 +101,7 @@ 繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。 ### 系譜 -系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。二親制のため、同じ子が複数枝に表示される場合がある。 +系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。 ### 食事エフェクト 食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。 diff --git a/index.html b/index.html index fc5f1fc..7cef262 100644 --- a/index.html +++ b/index.html @@ -56,7 +56,7 @@
- +
@@ -102,13 +102,34 @@ + + + + + + + + + diff --git a/js/data.js b/js/data.js index 2d8cf82..804a27f 100644 --- a/js/data.js +++ b/js/data.js @@ -69,8 +69,12 @@ const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", const CONFIG = { initialPopulation: 18, maxPopulation: Number.MAX_SAFE_INTEGER, - itemLimit: Number.MAX_SAFE_INTEGER, - saveKey: "tarinai-colony-save-v37", + itemLimit: 260, + zunchiLimit: 56, + traceLimit: 64, + splatLimit: 48, + effectLimit: 96, + saveKey: "tarinai-colony-save-v38", worldPadding: 30, hungerPerMinute: 11.2, lonelinessPerMinute: 7.6, @@ -88,6 +92,11 @@ const CONFIG = { highPopulationMode: 70, ultraPopulationMode: 120, }; +const FIELD_TYPES = { + cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 9 }, + garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 18 }, + park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 28 }, +}; const LIGHTING_TUNING = { rayAlpha: 0.040, nightOverlayAlpha: 0.135, @@ -95,4 +104,4 @@ const LIGHTING_TUNING = { bloomWashAlpha: 0.040, }; -window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, LIGHTING_TUNING }; \ No newline at end of file +window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING }; diff --git a/js/items.js b/js/items.js new file mode 100644 index 0000000..1c4083f --- /dev/null +++ b/js/items.js @@ -0,0 +1,491 @@ +"use strict"; + +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 = { + food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 + }[type] || 12; + this.amount = { + food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 + }[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; + } + if (type === "bed") { + this.comfort = rand(0.86, 1.14); + this.wear = 0; + } + 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 === "firecracker") { + this.fuseTimer = 5; + this.fuseMax = 5; + } + } + 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.amount -= dt * 0.9; + if (this.type === "sweet") this.amount -= dt * 0.12; + if (this.type === "trace") this.amount -= dt * 1.35; + if (this.type === "splat") this.amount -= dt * 1.05; + 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 === "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); + } + } + } + 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 = 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); + } + this.amount = clamp(this.growth * 120 * this.health, 3, 130); + 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 = 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 rainBoost = worldRef.weather === "light_rain" ? 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); + if (this.stage === "fresh") { + this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); + 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); + 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; + 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; + } + } + get dead() { + return this.amount <= 0; + } + serialize() { + return { + type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed, + stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness, + growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, + comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax + }; + } + static from(o) { + const type = o?.type || "grass"; + const it = Object.create(Item.prototype); + Item._loadId = (Item._loadId || 0) + 1; + it.id = o.id || `loaded-${Item._loadId}`; + it.type = type; + it.x = o.x; + it.y = o.y; + it.r = { + food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 + }[type] || 12; + it.amount = o.amount ?? ({ + food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 + }[type] || 80); + it.age = o.age || 0; + it.seed = o.seed || Math.random() * 1000; + it.dropTimer = 0; + it.dropMax = 0; + it.dropImpactDone = false; + it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55); + it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0)); + if (type === "grass") { + it.growth = 0.25; + it.health = 1; + it.seedTimer = rand(18, 38); + it.eatenAmount = 0; + it.fertilityBoost = 0; + } + if (type === "bed") { + it.comfort = 1; + it.wear = 0; + } + if (type === "zunchi") { + it.stage = "fresh"; + it.stageTimer = 0; + it.fertility = 0; + it.freshness = 1; + it.zunchiVariant = "zunchi"; + } + if (type === "firecracker") { + it.fuseTimer = 5; + it.fuseMax = 5; + } + it.stage = o.stage || it.stage; + it.stageTimer = o.stageTimer || 0; + it.fertility = o.fertility || 0; + it.freshness = o.freshness ?? it.freshness; + it.growth = o.growth ?? it.growth; + it.health = o.health ?? it.health; + it.seedTimer = o.seedTimer ?? it.seedTimer; + it.eatenAmount = o.eatenAmount || 0; + it.fertilityBoost = o.fertilityBoost || 0; + it.comfort = o.comfort ?? it.comfort; + it.wear = o.wear ?? it.wear; + it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi"; + it.toolSize = o.toolSize || it.toolSize || "medium"; + it.blastScale = o.blastScale || it.blastScale || 1; + it.fuseTimer = o.fuseTimer ?? it.fuseTimer; + it.fuseMax = o.fuseMax ?? it.fuseMax; + it.dropTimer = 0; + it.dropMax = 0; + it.dropImpactDone = true; + return it; + } + 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 visibleAlpha = clamp(this.amount / 40, 0.24, 1); + const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; + const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 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 ? (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") { + const sweet = this.type === "sweet"; + ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7"; + ctx.strokeStyle = 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 { + 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 (this.type === "water") { + ctx.fillStyle = "rgba(86, 157, 224, 0.50)"; + ctx.strokeStyle = "rgba(62, 113, 178, 0.42)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.55)"; + ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill(); + } 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; + ctx.strokeStyle = eaten ? "#7b8755" : (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 === "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(); + } +} + diff --git a/js/main.js b/js/main.js index 9d64ba9..7b2b2b6 100644 --- a/js/main.js +++ b/js/main.js @@ -3,6 +3,8 @@ let last = nowSec(); let statsTimer = 0; let fpsTimer = 0; let fpsFrames = 0; +let perfResizeTimer = 0; +let lastPerfLevel = 0; window.__tarinaiFps = 0; function loop() { const t = nowSec(); @@ -20,6 +22,14 @@ function loop() { render(); statsTimer += dt; const perf = world.performanceLevel ? world.performanceLevel() : 0; + perfResizeTimer += rawDt; + if (perfResizeTimer > 1.5) { + if (perf !== lastPerfLevel) { + lastPerfLevel = perf; + resizeCanvas(); + } + perfResizeTimer = 0; + } const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48; if (statsTimer > statsInterval) { renderStats(); @@ -30,8 +40,8 @@ function loop() { loadImages().then(() => { bindUI(); - resizeCanvas(); - world.reset(); + applyFieldLayout("garden"); + world.reset(null, "garden"); selectTool("observe"); renderLog(world.logs); renderArchive(); diff --git a/js/render.js b/js/render.js index 8212b60..13e425d 100644 --- a/js/render.js +++ b/js/render.js @@ -153,11 +153,12 @@ const backgroundCache = { function backgroundCacheKey(world, lighting) { const weather = world.weather || "sunny"; + const field = world.fieldType || "garden"; const perf = world.performanceLevel ? world.performanceLevel() : 0; const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180; const progressBucket = Math.round((lighting.progress || 0) * step); const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2)); - return `${weather}:${progressBucket}:${weatherBucket}`; + return `${field}:${weather}:${progressBucket}:${weatherBucket}`; } function ensureBackgroundCache(w, h, lighting) { @@ -246,10 +247,13 @@ function drawGardenBackdrop(w, h, lighting) { function drawGardenBed(w, h, lighting) { const light = lighting.light; + const fieldType = world.fieldType || "garden"; const x = 18, y = 22, bw = w - 36, bh = h - 44; const border = activeCtx.createLinearGradient(0, y, 0, y + bh); - border.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66") : "#6f6c72"); - border.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b") : "#565a68"); + const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); + const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b"); + border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72"); + border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68"); activeCtx.fillStyle = border; roundedRect(activeCtx, x, y, bw, bh, 34); activeCtx.fill(); @@ -263,9 +267,14 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14); - soil.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2") : "#bbbcc6"); - soil.addColorStop(0.38, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb") : "#afb1bd"); - soil.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf") : "#a0a3af"); + const palette = fieldType === "park" + ? ["#cfe5a8", "#b8d990", "#91bd74"] + : fieldType === "cage" + ? ["#d7d2c5", "#c7c2b6", "#aaa79e"] + : [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"]; + soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6"); + soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd"); + soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af"); activeCtx.fillStyle = soil; roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28); activeCtx.fill(); @@ -275,7 +284,8 @@ function drawGardenBed(w, h, lighting) { roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28); activeCtx.clip(); - for (let i = 0; i < 46; i++) { + const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46; + for (let i = 0; i < speckCount; i++) { const px = randSeed(900 + i, x + 24, x + bw - 24); const py = randSeed(1200 + i, y + 22, y + bh - 24); const r = randSeed(1600 + i, 1.2, 3.8); @@ -286,7 +296,31 @@ function drawGardenBed(w, h, lighting) { activeCtx.fill(); } - for (let i = 0; i < 8; i++) { + if (fieldType === "cage") { + activeCtx.save(); + activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)"; + activeCtx.lineWidth = 1.1; + for (let gx = x + 44; gx < x + bw - 26; gx += 44) { + activeCtx.beginPath(); activeCtx.moveTo(gx, y + 20); activeCtx.lineTo(gx, y + bh - 20); activeCtx.stroke(); + } + for (let gy = y + 44; gy < y + bh - 24; gy += 44) { + activeCtx.beginPath(); activeCtx.moveTo(x + 22, gy); activeCtx.lineTo(x + bw - 22, gy); activeCtx.stroke(); + } + activeCtx.restore(); + } else if (fieldType === "park") { + activeCtx.save(); + activeCtx.globalAlpha = light > 0.42 ? 0.24 : 0.14; + for (let i = 0; i < 24; i++) { + activeCtx.fillStyle = i % 2 ? "rgba(74, 132, 58, 0.35)" : "rgba(238, 222, 112, 0.32)"; + activeCtx.beginPath(); + activeCtx.ellipse(randSeed(5400 + i, x + 34, x + bw - 34), randSeed(5600 + i, y + 36, y + bh - 36), randSeed(5800 + i, 3, 8), randSeed(6000 + i, 2, 5), randSeed(6200 + i, -0.8, 0.8), 0, Math.PI * 2); + activeCtx.fill(); + } + activeCtx.restore(); + } + + const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8; + for (let i = 0; i < patchCount; i++) { const px = randSeed(3300 + i, x + 40, x + bw - 40); const py = randSeed(3600 + i, y + 44, y + bh - 46); const rw = randSeed(3900 + i, 24, 64); @@ -298,7 +332,8 @@ function drawGardenBed(w, h, lighting) { } // edge grasses and tiny flowers - for (let i = 0; i < 14; i++) { + const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14; + for (let i = 0; i < edgeGrassCount; i++) { const side = i % 2; const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30); const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4); @@ -317,7 +352,8 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); } - for (let i = 0; i < 9; i++) { + const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9; + for (let i = 0; i < flowerCount; i++) { const px = randSeed(7000 + i, x + 44, x + bw - 44); const py = randSeed(7400 + i, y + 46, y + bh - 44); if (i % 3 === 0) { @@ -335,7 +371,8 @@ function drawGardenBed(w, h, lighting) { } } - for (let i = 0; i < 10; i++) { + const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10; + for (let i = 0; i < leafCount; i++) { const px = randSeed(7800 + i, x + 36, x + bw - 36); const py = randSeed(8200 + i, y + 34, y + bh - 34); if (i % 2 === 0) { @@ -451,22 +488,40 @@ function weatherLabel(weather) { function render() { activeCtx = ctx; - const w = world.w, h = world.h; - ctx.clearRect(0, 0, w, h); + const sceneW = world.w, sceneH = world.h; + const screenW = world.viewportW || sceneW; + const screenH = world.viewportH || sceneH; + const fieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const cameraX = world.cameraX || 0; + const cameraY = world.cameraY || 0; + const viewScale = world.viewScale ? world.viewScale() : 1; + ctx.clearRect(0, 0, screenW, screenH); const lighting = getLightingState(world); const light = lighting.light; - const cachedBackground = ensureBackgroundCache(w, h, lighting); - if (cachedBackground) { - ctx.drawImage(cachedBackground, 0, 0, w, h); - } else { - drawGardenBackdrop(w, h, lighting); - drawGardenBed(w, h, lighting); - } - const perf = world.performanceLevel ? world.performanceLevel() : 0; - if (perf < 2) drawLightRays(ctx, w, h, lighting); - if (perf < 3 || world.weather === "light_rain") drawRain(ctx, w, h, world.time); + const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting); + const beginFieldTransform = () => { + ctx.save(); + ctx.translate(fieldOffset.x, fieldOffset.y); + ctx.scale(viewScale, viewScale); + ctx.translate(-cameraX, -cameraY); + }; + + beginFieldTransform(); + if (cachedBackground) { + ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH); + } else { + drawGardenBackdrop(sceneW, sceneH, lighting); + drawGardenBed(sceneW, sceneH, lighting); + } + ctx.restore(); + + const perf = world.performanceLevel ? world.performanceLevel() : 0; + if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting); + if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); + + beginFieldTransform(); for (const it of world.items) { if (it.type !== "zunchi") it.draw(ctx, world.time, lighting); } @@ -483,7 +538,7 @@ function render() { if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96) continue; ctx.beginPath(); ctx.moveTo(t.x, t.y); - ctx.quadraticCurveTo((t.x + p.x) / 2, Math.min(t.y, p.y) - 10, p.x, p.y); + ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y); ctx.stroke(); } ctx.setLineDash([]); @@ -492,14 +547,32 @@ function render() { const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; for (const t of sorted) t.draw(ctx, world.time, lighting); for (const ef of world.effects) ef.draw(ctx); + ctx.restore(); - // Light affecting the whole scene, including characters and ground. - drawAtmosphericLighting(ctx, w, h, lighting); + // Light affecting the whole visible scene, including characters and ground. + drawAtmosphericLighting(ctx, screenW, screenH, lighting); + + const drawAtScreenPosition = (entity, drawFn) => { + if (!entity) return; + const ox = entity.x; + const oy = entity.y; + const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy }; + entity.x = screen.x; + entity.y = screen.y; + try { + drawFn(); + } finally { + entity.x = ox; + entity.y = oy; + } + }; + drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); // Time HUD ctx.save(); const hudW = 150; const hudH = 56; + const w = screenW, h = screenH; const hudX = w - hudW - 22; const hudY = 16; ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)"; @@ -516,9 +589,6 @@ function render() { ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45); ctx.restore(); - drawSelectedCard(ctx, world, lighting); - - ctx.save(); ctx.textAlign = "right"; ctx.font = "10px ui-rounded, sans-serif"; @@ -542,7 +612,8 @@ function render() { function drawSelectedCard(ctx, world, lighting) { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) return; - const w = world.w, h = world.h; + const w = world.viewportW || world.w; + const h = world.viewportH || world.h; const cardW = 152; const cardH = 66; let x = t.x + 18; diff --git a/js/sim.js b/js/sim.js index b918386..7f68635 100644 --- a/js/sim.js +++ b/js/sim.js @@ -1,3372 +1,3 @@ -"use strict"; -function makeName(worldRef = null) { - const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name)); - const build = () => { - const len = randi(2, 7); - const hasStop = len >= 2 && Math.random() < 0.30; - const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0)); - let name = "\u306f"; - for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045"; - if (hasStop) name += "\u3063"; - return name; - }; - for (let i = 0; i < 80; i++) { - const candidate = build(); - if (!existing.has(candidate)) return candidate; - } - return build(); -} - -function spawnableSprites() { - return SPRITES.filter(s => !s.actionOnly && !s.displayOnly); -} - -function displayNormalSprites() { - return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id)); -} - -function lowStressSprites() { - return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id)); -} - -function stableChoice(seed, salt, options) { - if (!options?.length) return null; - const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length)); - return options[i]; -} - -function baseSpriteId() { - return pick(spawnableSprites()).id; -} - -function makeTrait(type) { - const base = { - smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 }, - angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 }, - teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 }, - jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 }, - drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 }, - cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 }, - sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 }, - pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 }, - stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 }, - }[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 }; - return { ...base }; -} - -function stableUnit(seed, salt = "") { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return ((h >>> 0) % 100000) / 100000; -} - -const PERSONALITIES = { - timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 }, - glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 }, - sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 }, - lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 }, - irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 }, - calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 }, -}; -const PERSONALITY_IDS = Object.keys(PERSONALITIES); - -function personalityForSeed(seed) { - return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm"; -} - -function relationDefaults() { - return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 }; -} - -function isParentChild(a, b) { - if (!a || !b) return false; - return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id)); -} - -function relationDisplayName(worldRef, id) { - if (!id) return "\u306a\u3057"; - const live = worldRef?.tarinai?.find(t => t.id === id); - if (live) return live.name; - const fam = worldRef?.family?.[id]; - return fam?.name || "\u4e0d\u660e"; -} - -class Effect { - constructor(type, x, y, options = {}) { - this.type = type; - this.x = x; - this.y = y; - this.vx = options.vx ?? rand(-10, 10); - this.vy = options.vy ?? rand(-10, 10); - this.size = options.size ?? rand(3, 8); - this.life = options.life ?? 0.7; - this.maxLife = this.life; - this.color = options.color || "#fff4a8"; - this.seed = Math.random() * 1000; - this.text = options.text || ""; - } - - update(dt) { - this.life -= dt; - this.x += this.vx * dt; - this.y += this.vy * dt; - this.vx *= Math.pow(0.92, dt * 60); - this.vy *= Math.pow(0.92, dt * 60); - } - get dead() { return this.life <= 0; } - - draw(ctx) { - const alpha = clamp(this.life / this.maxLife, 0, 1); - ctx.save(); - ctx.globalAlpha = alpha; - ctx.translate(this.x, this.y); - if (this.type === "eat") { - ctx.fillStyle = this.color; - ctx.strokeStyle = "rgba(143, 111, 43, 0.7)"; - ctx.lineWidth = 1.2; - ctx.beginPath(); - ctx.arc(0, 0, this.size, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } else if (this.type === "bleed") { - ctx.fillStyle = this.color; - ctx.beginPath(); - ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2); - ctx.fill(); - } else if (this.type === "ring") { - ctx.strokeStyle = this.color; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2); - ctx.stroke(); - } else if (this.type === "fight") { - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - const puffColor = this.color || "rgba(105, 62, 34, 0.86)"; - ctx.fillStyle = puffColor; - ctx.strokeStyle = puffColor; - for (let i = 0; i < 4; i++) { - const a = this.seed + i * 1.55; - const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72); - const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48); - ctx.globalAlpha = alpha * (0.26 + i * 0.08); - ctx.beginPath(); - ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2); - ctx.fill(); - } - ctx.globalAlpha = alpha * 0.78; - ctx.lineWidth = 2.0; - for (let i = 0; i < 2; i++) { - const dir = i === 0 ? -1 : 1; - const y = dir * this.size * 0.14; - ctx.beginPath(); - ctx.moveTo(-this.size * 0.62, y); - ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06); - ctx.stroke(); - } - } else if (this.type === "fall") { - ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)"; - for (let i = 0; i < 5; i++) { - const a = this.seed + i * 1.37; - const rr = this.size * randSeed(this.seed + i, 0.18, 0.82); - const px = Math.cos(a) * rr; - const py = Math.sin(a) * rr * 0.42; - ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42); - ctx.beginPath(); - ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2); - ctx.fill(); - } - } else if (this.type === "explosion") { - const burst = 1 - alpha; - ctx.globalAlpha = alpha * 0.95; - for (let i = 0; i < 3; i++) { - ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)"); - ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018)); - ctx.beginPath(); - ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2); - ctx.stroke(); - } - ctx.fillStyle = "rgba(255,214,86,0.88)"; - for (let i = 0; i < 14; i++) { - const a = this.seed + i * 0.73; - const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00); - ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88); - ctx.beginPath(); - ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2); - ctx.fill(); - } - } else if (this.type === "bubble") { - const w = Math.max(30, 14 + this.text.length * 7); - const h = 20; - ctx.globalAlpha = Math.min(1, alpha * 1.25); - ctx.fillStyle = "rgba(255, 252, 240, 0.92)"; - ctx.strokeStyle = "rgba(78, 64, 48, 0.22)"; - ctx.lineWidth = 1.2; - roundedRect(ctx, -w / 2, -h, w, h, 9); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "rgba(255, 252, 240, 0.92)"; - ctx.beginPath(); - ctx.moveTo(-4, -1); - ctx.lineTo(2, 5); - ctx.lineTo(6, -2); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = this.color || "rgba(42,36,29,0.78)"; - ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(this.text, 0, -10); - } - ctx.restore(); - } -} - -class SpatialGrid { - constructor(cellSize = 96) { - this.cellSize = cellSize; - this.itemCells = new Map(); - this.tarinaiCells = new Map(); - } - - clear() { - this.itemCells.clear(); - this.tarinaiCells.clear(); - } - - keyFor(x, y) { - const cx = Math.floor(x / this.cellSize); - const cy = Math.floor(y / this.cellSize); - return cx + cy * 100000; - } - - add(map, entity) { - const key = this.keyFor(entity.x, entity.y); - let bucket = map.get(key); - if (!bucket) { - bucket = []; - map.set(key, bucket); - } - bucket.push(entity); - } - - rebuild(items, tarinai) { - this.clear(); - for (const it of items) { - if (!it.dead) this.add(this.itemCells, it); - } - for (const t of tarinai) { - if (!t.dead) this.add(this.tarinaiCells, t); - } - } - - nearby(map, x, y, radius, out = []) { - out.length = 0; - const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); - const minX = Math.floor((x - r) / this.cellSize); - const maxX = Math.floor((x + r) / this.cellSize); - const minY = Math.floor((y - r) / this.cellSize); - const maxY = Math.floor((y + r) / this.cellSize); - for (let cy = minY; cy <= maxY; cy++) { - for (let cx = minX; cx <= maxX; cx++) { - const bucket = map.get(cx + cy * 100000); - if (!bucket) continue; - for (const entity of bucket) out.push(entity); - } - } - return out; - } -} - -function drawHeartShape(ctx, x, y, size, opts = {}) { - const fill = opts.fill || "rgba(240,91,135,0.90)"; - const stroke = opts.stroke || "rgba(255,252,246,0.92)"; - const alpha = opts.alpha == null ? 1 : opts.alpha; - const rotation = opts.rotation || 0; - ctx.save(); - ctx.translate(x, y); - if (rotation) ctx.rotate(rotation); - ctx.globalAlpha *= alpha; - ctx.fillStyle = fill; - ctx.strokeStyle = stroke; - ctx.lineWidth = Math.max(1.2, size * 0.14); - ctx.beginPath(); - ctx.moveTo(0, size * 0.72); - ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52); - ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72); - ctx.fill(); - ctx.stroke(); - ctx.restore(); -} - -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 = { - food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 - }[type] || 12; - this.amount = { - food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 - }[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; - } - if (type === "bed") { - this.comfort = rand(0.86, 1.14); - this.wear = 0; - } - 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 === "firecracker") { - this.fuseTimer = 5; - this.fuseMax = 5; - } - } - 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.amount -= dt * 0.9; - if (this.type === "sweet") this.amount -= dt * 0.12; - if (this.type === "trace") this.amount -= dt * 1.35; - if (this.type === "splat") this.amount -= dt * 1.05; - 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 === "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); - } - } - } - 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 = 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); - } - this.amount = clamp(this.growth * 120 * this.health, 3, 130); - 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 = 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 rainBoost = worldRef.weather === "light_rain" ? 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); - if (this.stage === "fresh") { - this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); - 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); - 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; - 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; - } - } - get dead() { - return this.amount <= 0; - } - serialize() { - return { - type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed, - stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness, - growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, - comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax - }; - } - static from(o) { - const it = new Item(o.type, o.x, o.y); - it.amount = o.amount; - it.age = o.age || 0; - it.seed = o.seed || Math.random() * 1000; - it.stage = o.stage || it.stage; - it.stageTimer = o.stageTimer || 0; - it.fertility = o.fertility || 0; - it.freshness = o.freshness ?? it.freshness; - it.growth = o.growth ?? it.growth; - it.health = o.health ?? it.health; - it.seedTimer = o.seedTimer ?? it.seedTimer; - it.eatenAmount = o.eatenAmount || 0; - it.fertilityBoost = o.fertilityBoost || 0; - it.comfort = o.comfort ?? it.comfort; - it.wear = o.wear ?? it.wear; - it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi"; - it.toolSize = o.toolSize || it.toolSize || "medium"; - it.blastScale = o.blastScale || it.blastScale || 1; - it.fuseTimer = o.fuseTimer ?? it.fuseTimer; - it.fuseMax = o.fuseMax ?? it.fuseMax; - it.dropTimer = 0; - it.dropMax = 0; - it.dropImpactDone = true; - return it; - } - 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 visibleAlpha = clamp(this.amount / 40, 0.24, 1); - const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; - const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 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 ? (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") { - const sweet = this.type === "sweet"; - ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7"; - ctx.strokeStyle = 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 { - 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 (this.type === "water") { - ctx.fillStyle = "rgba(86, 157, 224, 0.50)"; - ctx.strokeStyle = "rgba(62, 113, 178, 0.42)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.55)"; - ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill(); - } 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; - ctx.strokeStyle = eaten ? "#7b8755" : (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 === "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(); - } -} - -class Tarinai { - constructor(world, opts = {}) { - this.world = world; - this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); - this.name = opts.name || makeName(world); - this.personality = opts.personality || personalityForSeed(this.id); - this.type = opts.type || baseSpriteId(); - const typeMeta = SPRITES.find(s => s.id === this.type); - if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); - this.trait = makeTrait(this.type); - const pdef = PERSONALITIES[this.personality] || PERSONALITIES.calm; - this.trait.social *= pdef.social ?? 1; - this.trait.stress /= pdef.fear ?? 1; - this.trait.sleep *= pdef.sleep ?? 1; - this.x = opts.x ?? rand(100, world.w - 100); - this.y = opts.y ?? rand(100, world.h - 100); - this.vx = opts.vx ?? rand(-12, 12); - this.vy = opts.vy ?? rand(-12, 12); - this.scale = opts.scale ?? rand(0.22, 0.33); - this.radius = 56 * this.scale; - this.age = opts.age ?? rand(0, 20); - this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200); - if (this.lifeSpan < 1200) this.lifeSpan = rand(1400, 2200); - this.generation = opts.generation ?? 1; - this.hasPaired = opts.hasPaired ?? false; - this.hunger = opts.hunger ?? rand(16, 45); - this.loneliness = opts.loneliness ?? rand(18, 62); - this.energy = opts.energy ?? rand(54, 98); - this.stress = opts.stress ?? rand(0, 20); - this.hpBarTimer = opts.hpBarTimer ?? 0; - this.affection = opts.affection ?? rand(0, 30); - this.need = opts.need || pick(NEEDS); - this.state = opts.state || "idle"; - this.dead = false; - this.deathReason = ""; - this.thought = ""; - this.target = null; - this.targetKey = ""; - this.targetSince = 0; - this.targetBestDist = Infinity; - this.targetGiveupKey = opts.targetGiveupKey || ""; - this.targetGiveupUntil = opts.targetGiveupUntil ?? 0; - this.wanderAngle = rand(0, Math.PI * 2); - this.reproductionTimer = rand(4, CONFIG.reproductionCooldown); - this.lastLog = 0; - this.sleeping = false; - this.blink = rand(0, 10); - this.lastSocial = 0; - this.goodMode = opts.goodMode ?? stableChoice(this.id, "good-mode", displayNormalSprites()) ?? "smile"; - this.facing = opts.facing ?? (stableUnit(this.id, "facing") < 0.5 ? -1 : 1); - this.visualFacing = opts.visualFacing ?? this.facing; - this.facingLockUntil = opts.facingLockUntil ?? 0; - this.eatTimer = opts.eatTimer ?? 0; - this.eatCooldown = 0; - this.foodReactTimer = opts.foodReactTimer ?? 0; - this.surpriseTimer = opts.surpriseTimer ?? 0; - this.fearTimer = opts.fearTimer ?? 0; - this.intimidateTimer = opts.intimidateTimer ?? 0; - this.intimidateTargetId = opts.intimidateTargetId || null; - this.intimidatedTimer = opts.intimidatedTimer ?? 0; - this.birthRitualTimer = opts.birthRitualTimer ?? 0; - this.birthRitualMax = opts.birthRitualMax ?? 0; - this.birthPartnerId = opts.birthPartnerId || null; - this.birthRitualRole = opts.birthRitualRole || 0; - this.birthRitualLeader = !!opts.birthRitualLeader; - this.pokeFlashTimer = opts.pokeFlashTimer ?? 0; - this.zunchiStain = opts.zunchiStain ?? 0; - this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain"); - this.tempComfort = opts.tempComfort ?? 0.62; - this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; - this.parents = opts.parents || []; - this.parentNames = opts.parentNames || []; - this.children = opts.children || []; - this.birthTime = opts.birthTime ?? this.world.time; - this.nextEatSound = opts.nextEatSound ?? 0; - this.fightTimer = opts.fightTimer ?? 0; - this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown); - this.fightTargetId = opts.fightTargetId || null; - this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []); - this.nextHeadbutt = opts.nextHeadbutt ?? 0; - this.digest = opts.digest ?? rand(0, 2.4); - this.poopCount = opts.poopCount ?? 0; - this.stretchTimer = opts.stretchTimer ?? 0; - this.lastMealColor = opts.lastMealColor || "#f7cf67"; - this.grassEatTimer = opts.grassEatTimer ?? 0; - this.hurtTimer = opts.hurtTimer ?? 0; - this.defeatedTimer = opts.defeatedTimer ?? 0; - this.defeatedById = opts.defeatedById || null; - this.fightWinnerId = opts.fightWinnerId || null; - this.relationships = opts.relationships && typeof opts.relationships === "object" ? JSON.parse(JSON.stringify(opts.relationships)) : {}; - this.fallTimer = opts.fallTimer ?? 0; - this.fallMax = opts.fallMax ?? 1.15; - this.fallDir = opts.fallDir ?? (stableUnit(this.id, "fall-dir") < 0.5 ? -1 : 1); - this.blastSpinTimer = opts.blastSpinTimer ?? 0; - this.blastSpinMax = opts.blastSpinMax ?? 0; - this.postBirthPeaceTimer = opts.postBirthPeaceTimer ?? 0; - this.sleepStart = 0.60 + stableUnit(this.id, "sleep-start") * 0.22; - this.sleepEnd = 0.15 + stableUnit(this.id, "sleep-end") * 0.14; - this.sleepFacing = stableUnit(this.id, "sleep-facing") < 0.5 ? -1 : 1; - this.nextBubbleAt = 0; - this.nextSleepBubbleAt = 0; - this.nextFearBubbleAt = 0; - this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22; - this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55; - this.visibleSpriteId = opts.visibleSpriteId || null; - this.spriteLockUntil = opts.spriteLockUntil ?? 0; - } - - serialize() { - return { - id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy, - scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired, - hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, - affection: this.affection, need: this.need, state: this.state, - goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer, - foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer, - intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, intimidatedTimer: this.intimidatedTimer, - birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId, - birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader, - pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, - tempComfort: this.tempComfort, tempTimer: this.tempTimer, - parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, - fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil, - nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer, - grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer, - defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId, - relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir, - blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer, - lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, - }; - } - - static from(world, o) { - return new Tarinai(world, o); - } - - get lack() { - return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100); - } - - get mood() { - return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100); - } - - get ageSinceBirth() { - return Math.max(0, this.world.time - this.birthTime); - } - - get growth() { - return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1); - } - - get juvenile() { - return this.generation > 1 && this.growth < 1; - } - - parentToFollow() { - if (!this.juvenile || !this.parents?.length) return null; - let best = null, bestD = Infinity; - for (const id of this.parents) { - const p = this.world.tarinai.find(t => t.id === id && !t.dead); - if (!p) continue; - const d = dist(this, p); - if (d < bestD) { best = p; bestD = d; } - } - return best; - } - - label() { - const sp = SPRITES.find(s => s.id === this.type); - return sp ? sp.label : this.type; - } - - personalityProfile() { - return PERSONALITIES[this.personality] || PERSONALITIES.calm; - } - - personalityLabel() { - return this.personalityProfile().label || this.personality || "\u4e0d\u660e"; - } - - relationTo(id) { - if (!id) return relationDefaults(); - if (!this.relationships) this.relationships = {}; - if (!this.relationships[id]) this.relationships[id] = relationDefaults(); - return this.relationships[id]; - } - - adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { - if (!other || !other.id || other === this) return; - const rel = this.relationTo(other.id); - rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); - rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); - rel.fightsWon = rel.fightsWon || 0; - rel.fightsLost = rel.fightsLost || 0; - if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } - } - - strongestRelation(kind = "friend") { - let bestId = null; - let bestScore = kind === "fear" ? 5 : 8; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); - if (score > bestScore) { bestId = id; bestScore = score; } - } - return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null; - } - - relationSummary() { - const friend = this.strongestRelation("friend"); - const fear = this.strongestRelation("fear"); - return { friend, fear }; - } - - bestFriendLive(minScore = 10, maxDist = Infinity) { - let best = null, bestScore = minScore; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const score = rel.affinity || 0; - if (score <= bestScore) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; - best = t; - bestScore = score; - } - return best; - } - - recentFightRival(maxAge = 52, maxDist = Infinity) { - let best = null, bestT = -Infinity; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const event = String(rel.lastEvent || ""); - const when = rel.lastTime || -Infinity; - if (this.world.time - when > maxAge) continue; - if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; - if (when > bestT) { best = t; bestT = when; } - } - return best; - } - - poke() { - audio.poke(); - this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e"); - this.stress = clamp(this.stress + 22, 0, 130); - this.surpriseTimer = 0.55; - this.fearTimer = Math.max(this.fearTimer, 1.2); - this.pokeFlashTimer = 0.55; - this.hurtTimer = Math.max(this.hurtTimer, 2.4); - this.vx += rand(-100, 100); - this.vy += rand(-100, 100); - if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e"); - } - - - showHpBar(duration = 4.8) { - this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration); - } - - damage(amount, reason = "") { - const before = this.energy; - this.energy = clamp(this.energy - Math.max(0, amount || 0), 0, 100); - if (this.energy < before - 0.01) this.showHpBar(); - if (this.energy <= 0.5 && reason) this.die(reason); - } - - familyJoined() { - return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length); - } - - displayGeneration() { - return this.familyJoined() ? this.generation : null; - } - - targetIdentity(target = this.target) { - if (!target) return ""; - if (target.id) return `${target.type || "tarinai"}:${target.id}`; - if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`; - return ""; - } - - targetIgnoresFence(target = this.target) { - return Boolean(target?.type === "sweet" || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); - } - - shouldAvoidTarget(target) { - if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false; - if (this.targetIgnoresFence(target)) return false; - const key = this.targetIdentity(target); - if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true; - return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false; - } - - maintainTargetProgress(dt) { - const target = this.target; - if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { - this.targetKey = ""; - this.targetSince = this.world.time; - this.targetBestDist = Infinity; - return; - } - if (this.targetIgnoresFence(target)) return; - const key = this.targetIdentity(target); - const d = distXY(this.x, this.y, target.x, target.y); - if (key !== this.targetKey) { - this.targetKey = key; - this.targetSince = this.world.time; - this.targetBestDist = d; - } else if (d < (this.targetBestDist || Infinity) - 8) { - this.targetBestDist = d; - this.targetSince = this.world.time; - } - if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) { - this.targetGiveupKey = key; - this.targetGiveupUntil = this.world.time + 8; - this.target = null; - this.state = "idle"; - this.wanderAngle += rand(-1.4, 1.4); - return; - } - if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) { - this.targetGiveupKey = key; - this.targetGiveupUntil = this.world.time + 12; - this.target = null; - this.state = "idle"; - this.wanderAngle += rand(-2.2, 2.2); - this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f"; - } - } - - lowHealthSprite() { - const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118; - if (critical) return this.stretchTimer > 0 ? "stretch" : "weak"; - const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104; - if (weak) return "weak"; - return null; - } - - variantSprite(category) { - const pools = { - hurt: ["hurt", "hurt2"], - fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"], - flee: ["flee", "flee_fear2"], - sleep: ["sleep", "sleep2"], - stress: ["stress_dizzy", "stress_sweat"], - low_stress: lowStressSprites(), - intimidate: ["intimidate"], - normal: displayNormalSprites(), - }; - const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id)); - return stableChoice(this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; - } - - rawSpriteId() { - if (this.dead) return this.variantSprite("fear"); - if (this.type === "cry") return "cry"; - if (this.type === "stretch") return "stretch"; - if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; - if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat"; - if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt"); - if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual"; - const low = this.lowHealthSprite(); - if (low) return low; - if (this.defeatedTimer > 0.04) return this.variantSprite("flee"); - if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); - if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear"); - if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); - if (this.state === "fight" || this.fightTimer > 0.04) return "angry"; - if (this.surpriseTimer > 0.08) return "pokan"; - if (this.state === "cursor_friend") return "smile"; - if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; - if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back"; - if (this.state === "seek_food") return "drool"; - if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); - if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); - if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; - if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; - return this.goodMode || this.variantSprite("normal"); - } - - - spriteId() { - const next = this.rawSpriteId(); - const now = this.world?.time || 0; - if (!this.visibleSpriteId) { - this.visibleSpriteId = next; - this.spriteLockUntil = now + 1.0; - return next; - } - if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) { - this.visibleSpriteId = next; - this.spriteLockUntil = now + 1.0; - } - return this.visibleSpriteId; - } - - update(dt) { - if (this.dead) return; - - const minute = dt / 60; - this.age += dt; - this.reproductionTimer -= dt; - this.blink += dt; - this.eatTimer = Math.max(0, this.eatTimer - dt); - this.eatCooldown = Math.max(0, this.eatCooldown - dt); - this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); - this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); - this.fearTimer = Math.max(0, this.fearTimer - dt); - this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); - this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); - this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); - this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); - this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); - if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; - if (this.birthRitualTimer > 0) { - const beforeBirthTimer = this.birthRitualTimer; - this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); - if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - if (partner) this.world.finishBirthRitual(this, partner); - this.birthRitualLeader = false; - } - } - this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); - const wasFighting = this.fightTimer > 0.04; - this.fightTimer = Math.max(0, this.fightTimer - dt); - this.fightCooldown = Math.max(0, this.fightCooldown - dt); - this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); - if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { - const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null; - this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); - this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); - this.hurtTimer = Math.max(this.hurtTimer, 1.8); - this.fallTimer = Math.max(this.fallTimer, 1.15); - this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); - this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; - this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); - if (winner) this.world.resolveFightOutcome(winner, this); - this.fightTargetIds = []; - this.fightTargetId = null; - } - this.stretchTimer = Math.max(0, this.stretchTimer - dt); - this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); - this.hurtTimer = Math.max(0, this.hurtTimer - dt); - this.fallTimer = Math.max(0, this.fallTimer - dt); - this.zunchiStain = Math.max(0, this.zunchiStain - dt * 0.0045); - if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { - this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); - } - if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { - this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); - } - if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { - const side = this.facingDir(); - this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); - } - - this.tempTimer += dt; - if (this.tempTimer >= 0.55) { - this.tempComfort = this.world.temperatureAt(this.x, this.y); - this.tempTimer = 0; - } - const tempComfort = this.tempComfort ?? 0.62; - const tooCold = Math.max(0, 0.48 - tempComfort); - this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); - this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); - this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; - this.stress += tooCold * dt * 0.25; - - const phase = this.world.lightLevel(); - if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) this.stress += dt * 0.04; - if (this.world.weather === "cloudy") { - this.stress -= dt * 0.018; - this.vx *= Math.pow(0.996, dt * 60); - this.vy *= Math.pow(0.996, dt * 60); - } else if (this.world.weather === "light_rain") { - this.energy += dt * 0.018; - this.stress -= dt * 0.025; - } else if (this.world.weather === "sunny") { - this.vx *= Math.pow(1.001, dt * 60); - this.vy *= Math.pow(1.001, dt * 60); - } - - if (this.juvenile) { - const adultScale = 0.25 + (makeTrait(this.type).speed % 0.08); - this.scale = lerp(this.scale, adultScale, dt * 0.018 + 0.002); - this.radius = 56 * this.scale; - } - - this.hunger = clamp(this.hunger, 0, 115); - this.loneliness = clamp(this.loneliness, 0, 110); - this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); - - this.aiTimer += dt; - const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; - const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; - const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.id, "ai-interval") * 0.09 + popPerf * 0.055); - if (this.aiTimer >= aiInterval) { - const aiDt = Math.min(this.aiTimer, 0.48); - this.aiTimer = 0; - this.resolveNeeds(aiDt); - const sleepingNow = this.state === "sleep"; - if (!sleepingNow) { - this.reactToFreshZunchi(aiDt); - this.updateFacing(); - this.interactWithItems(aiDt); - this.interactWithOthers(aiDt); - } else { - this.updateFacing(); - } - } else { - this.updateFacing(); - } - this.envTimer += dt; - const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48; - if (this.envTimer >= envInterval) { - const envDt = Math.min(this.envTimer, 1.2); - this.envTimer = 0; - this.applyEnvironment(envDt); - } - this.maintainTargetProgress(dt); - this.move(dt); - if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); - this.checkLife(); - } - - applyEnvironment(dt) { - const parent = this.parentToFollow ? this.parentToFollow() : null; - if (parent && dist(this, parent) < 96) { - this.stress = clamp(this.stress - dt * 0.42, 0, 130); - this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); - } - const friend = this.bestFriendLive ? this.bestFriendLive(12, 92) : null; - if (friend) this.stress = clamp(this.stress - dt * 0.36, 0, 130); - let grassComfort = 0; - for (const it of this.world.nearbyItems(this.x, this.y, 105)) { - if (it.type !== "grass" || it.dead) continue; - const d = distXY(this.x, this.y, it.x, it.y); - const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); - grassComfort += clamp(1 - d / 105, 0, 1) * lush; - } - if (grassComfort > 0) { - this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); - if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); - if (this.need === "\u9759\u3051\u3055" || this.personality === "timid") this.affection += dt * Math.min(0.06, grassComfort * 0.012); - } - } - - bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") { - if (this.world.time < this.nextBubbleAt) return; - this.nextBubbleAt = this.world.time + cooldown; - this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); - } - - mouthPosition(target = null) { - const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir(); - return { - x: this.x + dir * this.radius * 0.40 - this.radius * 0.34, - y: this.y - this.radius * 0.28, - }; - } - - sleepSpotFor(bed) { - if (!bed) return { x: this.x, y: this.y }; - const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; - const baseRing = bed.r * 0.52; - const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95; - return { - x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), - y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding), - }; - } - - desiredFacingDir() { - if (this.state === "sleep") return this.sleepFacing || this.facing || 1; - if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1; - if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx); - return this.facing || 1; - } - - facingDir() { - return this.visualFacing || this.facing || 1; - } - - updateFacing() { - const desired = this.desiredFacingDir(); - const now = this.world?.time || 0; - this.facing = desired; - if (!this.visualFacing) { - this.visualFacing = desired; - this.facingLockUntil = now + 1.0; - return; - } - if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) { - this.visualFacing = Math.sign(desired) || this.visualFacing || 1; - this.facingLockUntil = now + 1.0; - } - } - - reactToFreshZunchi(dt) { - if (this.state === "panic" || this.fearTimer > 0.2) return; - const z = this.world.nearest(this, ["zunchi"], 90); - if (!z || z.stage !== "fresh") return; - const d = dist(this, z); - const push = clamp(1 - d / 90, 0, 1); - this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); - this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; - this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; - } - - resolveNeeds(dt) { - const wasSleeping = this.state === "sleep"; - const previousTarget = this.target && !this.target.dead ? this.target : null; - this.target = null; - this.sleeping = false; - - if (this.birthRitualTimer > 0.04) { - this.state = "birth_ritual"; - this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; - this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; - return; - } - - if (this.intimidateTimer > 0.04) { - this.state = "intimidate"; - this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; - this.bubble("!", 1.2); - this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; - return; - } - - if (this.fightTimer > 0.04) { - this.state = "fight"; - this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); - this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; - let best = null, bestD = Infinity; - for (const id of this.fightTargetIds) { - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - const d = dist(this, t); - if (d < bestD) { best = t; bestD = d; } - } - this.target = best; - this.bubble("!", 1.6); - this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b"; - return; - } - - if (this.defeatedTimer > 0.04) { - const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); - this.state = "panic"; - this.target = opponent || null; - this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b"; - if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; - return; - } else if (this.defeatedById && this.fightTimer <= 0.04) { - this.defeatedById = null; - this.fightWinnerId = null; - } - - const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null; - if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { - this.state = "panic"; - this.target = friendNearby.target; - this.fearTimer = Math.max(this.fearTimer, 0.32); - this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; - return; - } - - const dayP = this.world.dayProgress(); - const personalSleep = this.sleepStart < this.sleepEnd - ? dayP >= this.sleepStart && dayP < this.sleepEnd - : dayP >= this.sleepStart || dayP < this.sleepEnd; - - const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; - const feared = this.strongestRelation("fear"); - if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { - const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); - if (rival && dist(this, rival) < 150) { - this.state = "panic"; - const awayX = clamp(this.x + (this.x - rival.x) * 1.2 + rand(-48, 48), CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); - const awayY = clamp(this.y + (this.y - rival.y) * 1.2 + rand(-48, 48), CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); - this.target = { x: awayX, y: awayY, dead: false, detour: true }; - this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear); - this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`; - if (this.world.relationNotice(this.id, rival.id, "avoid", 22)) this.world.log(`${this.name}\u306f\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u3001${rival.name}\u3092\u907f\u3051\u305f\u3002`, "relation"); - return; - } - } - - const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; - const zunda = this.world.nearest(this, ["sweet"], zundaRange); - const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); - const zundaReward = this.affection > 8 && this.hunger > 7; - if (zunda && zunda.amount > 1.2 && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { - this.state = "seek_food"; - this.target = zunda; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); - this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - - if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { - this.state = "panic"; - this.thought = "\u9003\u3052\u3066\u3044\u308b"; - return; - } - - const sleepBias = this.personalityProfile().sleep; - const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); - if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) { - let bed = previousTarget && previousTarget.type === "bed" ? previousTarget : null; - if (!bed && friendNearby && friendNearby.target?.type === "bed" && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; - if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360); - if (bed && !wasSleeping && this.energy > 18) { - const spot = this.sleepSpotFor(bed); - if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { - this.state = "seek_bed"; - this.target = bed; - this.thought = "\u5e72\u3057\u8349\u30d9\u30c3\u30c9\u3078\u5411\u304b\u3063\u3066\u3044\u308b"; - return; - } - } - this.state = "sleep"; - this.target = bed; - this.sleeping = true; - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"; - return; - } - - const pointer = this.world.pointer; - if (pointer?.inside) { - const pd = distXY(this.x, this.y, pointer.x, pointer.y); - if (pd < 170) { - const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; - const friendly = !hostile && (this.mood > 52 || this.affection > 12 || this.goodMode === "smile"); - if (hostile) { - this.state = "cursor_enemy"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; - this.fearTimer = Math.max(this.fearTimer, 0.18); - if (this.world.time > this.nextFearBubbleAt) { - this.nextFearBubbleAt = this.world.time + 2.4; - this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); - } - this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; - return; - } - if (friendly) { - this.state = "cursor_friend"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; - this.goodMode = "smile"; - this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - } - - const parent = this.parentToFollow(); - if (parent && !this.shouldAvoidTarget(parent)) { - const d = dist(this, parent); - if (d > 34 || (this.hunger < 78 && this.energy > 24)) { - this.state = "follow_parent"; - this.target = parent; - this.bubble("?", 4.0); - this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - - if (friendNearby && this.hunger > 44 && friendNearby.target && ["sweet", "grass"].includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { - this.state = "seek_food"; - this.target = friendNearby.target; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; - return; - } - - if (this.hunger > 62) { - const itemTypes = ["sweet", "grass"]; - const item = this.world.nearest(this, itemTypes, 420); - if (item) { - this.state = "seek_food"; - this.target = item; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) { - const other = this.world.nearestOther(this, 300); - if (other) { - this.state = "seek_friend"; - this.target = other; - this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { - this.state = "panic"; - this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - return; - } - - if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); - this.state = "idle"; - this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`; - } - - makePoop(force = 1) { - this.digest += force; - if (this.digest < 6.2) return; - this.digest = rand(0.4, 1.6); - this.poopCount += 1; - 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.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)"); - if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`); - } - - interactWithItems(dt) { - const canBite = this.eatCooldown <= 0 && this.hunger > 18; - const canSweetBite = this.eatCooldown <= 0 && this.hunger > 7; - for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { - const d = dist(this, it); - if (d > this.radius + it.r + 12) continue; - - if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && it.amount > 1.2) { - const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5); - if (eating > 0) { - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.25, dt * 60); - this.vy *= Math.pow(0.25, dt * 60); - it.amount -= eating; - this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95); - this.energy += eating * 0.04; - this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; - if (it.type === "sweet") { - this.stress = clamp(this.stress - eating * 0.42, 0, 130); - this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); - } - this.eatTimer = Math.max(this.eatTimer, 0.32); - this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); - this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); - if (Math.random() < dt * 2.2) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7"); - } - this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7"; - this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22)); - 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`); - this.lastLog = this.world.time; - } - break; - } - } - - if (canBite && it.type === "grass") { - const bite = Math.min(it.amount, rand(4.2, 7.2)); - this.state = "eat"; - this.target = it; - this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); - this.vx *= Math.pow(0.20, dt * 60); - this.vy *= Math.pow(0.20, dt * 60); - it.amount -= bite; - it.eatenAmount = (it.eatenAmount || 0) + bite; - it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); - this.hunger -= bite * 0.46; - this.stress -= dt * 0.5; - this.eatTimer = Math.max(this.eatTimer, 0.22); - this.eatCooldown = rand(0.68, 1.15); - this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); - if (Math.random() < dt * 2.0) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); - } - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } - this.makePoop(bite * 0.10); - if (this.type === "jito") this.affection += dt * 0.25; - break; - } - - if (it.type === "zunchi") { - const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130); - this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * 0.74, 0, 1); - if (canBite && this.hunger > 104) { - const bite = Math.min(it.amount, rand(3.4, 5.8)); - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.18, dt * 60); - this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; - this.hunger -= bite * 0.18; - this.stress = clamp(this.stress + bite * 0.52, 0, 130); - this.zunchiStain = clamp((this.zunchiStain || 0) + 0.55, 0, 1); - this.eatTimer = Math.max(this.eatTimer, 0.24); - this.eatCooldown = rand(0.9, 1.4); - this.bubble("...", 2.2, "rgba(74,91,50,0.78)"); - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } - break; - } - } - - if (it.type === "water") { - this.stress -= dt * 2.4; - this.energy += dt * 0.5; - this.hunger -= dt * 0.2; - it.amount -= dt * 2.4; - if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; - } - - if (it.type === "stone") { - this.stress -= dt * 0.85; - this.energy += dt * 0.18; - if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; - } - - if (it.type === "bed") { - if (this.energy < 74 + 6 * this.personalityProfile().sleep) { - const occ = this.world.bedOccupancy(it); - const comfort = this.world.bedComfort(it); - this.state = "sleep"; - this.sleeping = true; - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, occ > 5 ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)"); - } - this.energy += dt * (1.9 + comfort * 1.4); - this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); - it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); - } - } - - if (it.type === "trace") { - this.loneliness -= dt * 0.25; - this.stress += dt * 0.06; - } - } - this.hunger = clamp(this.hunger, 0, 115); - this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); - this.loneliness = clamp(this.loneliness, 0, 110); - } - - interactWithOthers(dt) { - let closeCount = 0; - for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) { - if (o === this || o.dead) continue; - const dx = o.x - this.x, dy = o.y - this.y; - const d = Math.hypot(dx, dy); - if (d < 0.01 || d > 74) continue; - closeCount += 1; - - const overlap = this.radius + o.radius - d; - if (overlap > -2) { - const push = (overlap + 6) * 0.17; - this.vx -= (dx / d) * push; - this.vy -= (dy / d) * push; - if (Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); - } - - if (d < 60) { - const rel = this.relationTo(o.id); - const pdef = this.personalityProfile(); - this.loneliness -= dt * 1.65 * this.trait.social; - this.affection += dt * 0.28 * this.trait.social; - this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby"); - if ((rel.affinity || 0) > 10) { - this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); - this.affection = clamp(this.affection + dt * 0.18, 0, 80); - if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation"); - } - if ((rel.affinity || 0) > 8 && this.world.relationNotice(this.id, o.id, "friend", 32)) { - this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation"); - } - if (this.type === "angry") this.stress += dt * 0.08; - if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; - } - - const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); - if (fightingThis && d < 36 && this.id < o.id) { - const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; - if (pulse && this.world.time > this.nextHeadbutt) { - const nx = dx / d, ny = dy / d; - this.vx -= nx * rand(24, 44); - this.vy -= ny * rand(24, 44); - o.vx += nx * rand(34, 64); - o.vy += ny * rand(34, 64); - this.stress = clamp(this.stress + 1.6, 0, 130); - o.stress = clamp(o.stress + 2.8, 0, 130); - this.damage(1.2); - o.damage(1.6); - this.hurtTimer = Math.max(this.hurtTimer, 1.4); - o.hurtTimer = Math.max(o.hurtTimer, 1.8); - this.adjustRelation(o, -0.16, 0.12, "fight"); - o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); - if (Math.random() < 0.22 || o.energy < 24) { - o.fallTimer = Math.max(o.fallTimer, 0.58); - o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); - o.fallDir = nx > 0 ? 1 : -1; - this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); - } - if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { - o.defeatedById = this.id; - o.fightWinnerId = this.id; - } - this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); - this.nextHeadbutt = this.world.time + rand(0.34, 0.55); - } - } - - const relA = this.relationTo(o.id); - const relB = o.relationTo(this.id); - const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable"); - const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); - const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); - const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight; - const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; - if (d < 56 && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake) { - this.world.startConflict(this, o); - } - - if (d < 36 && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { - const enough = this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44; - const lonelyBond = this.loneliness > 24 || o.loneliness > 24; - if (enough && lonelyBond && Math.random() < dt * 0.25) { - this.world.startBirthRitual(this, o); - } - } - } - - if (closeCount > 8) { - this.stress += dt * 0.9; - this.energy -= dt * 0.25; - this.fearTimer = Math.max(this.fearTimer, 0.18); - } - - if (this.loneliness < 12 && Math.random() < dt * 0.002) { - this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`); - } - } - - move(dt) { - let ax = 0, ay = 0; - const baseSpeed = 31 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); - - if (this.state === "sleep") { - const bedComfort = this.target?.type === "bed" ? this.world.bedComfort(this.target) : 0.72; - const bedCrowd = this.target?.type === "bed" ? this.world.bedOccupancy(this.target) : 0; - this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); - this.stress = clamp(this.stress + dt * (bedCrowd > 5 ? 0.18 * (bedCrowd - 5) : -0.12 * bedComfort), 0, 130); - this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); - if (this.target && !this.target.dead && this.target.type === "bed") { - const spot = this.sleepSpotFor(this.target); - const dx = spot.x - this.x; - const dy = spot.y - this.y; - const d = Math.hypot(dx, dy); - if (d > 1.6) { - const step = Math.min(d, dt * (12 + bedComfort * 14)); - this.x += dx / d * step; - this.y += dy / d * step; - } - } - this.vx *= Math.pow(0.55, dt * 60); - this.vy *= Math.pow(0.55, dt * 60); - return; - } - - if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - if (partner) { - const midX = (this.x + partner.x) / 2; - const midY = (this.y + partner.y) / 2; - const offset = (this.birthRitualRole || 1) * this.radius * 0.44; - const dx = midX - this.x; - const dy = (midY + offset) - this.y; - this.vx += dx * dt * 2.8; - this.vy += dy * dt * 2.8; - } - this.vx *= Math.pow(0.56, dt * 60); - this.vy *= Math.pow(0.56, dt * 60); - this.x += Math.sin(this.world.time * 26 + this.age) * 0.32; - return; - } - - if (this.intimidateTimer > 0.04 || this.state === "intimidate") { - this.vx *= Math.pow(0.22, dt * 60); - this.vy *= Math.pow(0.22, dt * 60); - return; - } - - if (this.eatTimer > 0.04 || this.state === "eat") { - this.vx = 0; - this.vy = 0; - return; - } - - if (this.target && !this.target.dead) { - const dx = this.target.x - this.x; - const dy = this.target.y - this.y; - const d = Math.hypot(dx, dy) || 1; - let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1; - if (this.target?.detour) sign = 1; - let strength = 1; - if (this.state === "follow_parent") { - strength = d < 42 ? 0.18 : 1.22; - sign = 1; - } else if (this.state === "cursor_friend") { - strength = d < 30 ? 0.12 : 0.95; - sign = 1; - } else if (this.state === "cursor_enemy") { - strength = d < 150 ? 1.35 : 0.35; - sign = -1; - } else if (this.state === "fight") { - const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37); - if (d < 18) { - strength = 1.05; - sign = -1; - } else { - strength = headbutt > -0.15 ? 2.65 : 0.65; - sign = headbutt > -0.15 ? 1 : -1; - } - } - ax += (dx / d) * baseSpeed * sign * strength; - ay += (dy / d) * baseSpeed * sign * strength; - } else if (this.state === "panic") { - this.wanderAngle += rand(-3.8, 3.8) * dt; - ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; - ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; - } else { - ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34; - ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34; - } - - this.vx += ax * dt; - this.vy += ay * dt; - this.vx *= Math.pow(0.84, dt * 8); - this.vy *= Math.pow(0.84, dt * 8); - - const maxV = this.state === "panic" ? 116 : 76; - const v = Math.hypot(this.vx, this.vy); - if (v > maxV) { - this.vx = this.vx / v * maxV; - this.vy = this.vy / v * maxV; - } - - this.x += this.vx * dt; - this.y += this.vy * dt; - - const p = CONFIG.worldPadding; - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } - if (this.x > this.world.w - p) { this.x = this.world.w - p; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } - if (this.y > this.world.h - p) { this.y = this.world.h - p; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } - } - - checkLife() { - let reason = ""; - if (this.hunger >= 108) reason = "\u98df\u3079\u7269\u304c\u305f\u308a\u306a\u3044"; - else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d"; - else if (this.stress >= 124) reason = "\u30b9\u30c8\u30ec\u30b9\u304c\u591a\u3059\u304e\u308b"; - else if (this.energy <= 0 && this.hunger > 90) reason = "\u75b2\u308c\u3068\u7a7a\u8179\u304c\u91cd\u306a\u3063\u305f"; - if (reason) this.die(reason); - } - - die(reason) { - if (this.dead) return; - this.dead = true; - this.deathReason = reason; - this.world.deadCount += 1; - this.world.items.push(new Item("trace", this.x, this.y)); - this.world.items.push(new Item("splat", this.x, this.y)); - if (Math.random() < 0.55) { - const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); - if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); - } - this.world.markDead(this, reason); - audio.death(); - this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u7406\u7531: ${reason}`, "death"); - if (this.world.selected === this) this.world.selected = null; - } - - draw(ctx, t, lighting = null) { - if (this.dead) return; - const lightState = lighting || getLightingState(this.world); - const sprite = this.spriteId(); - const img = images.get(sprite) || images.get(this.type); - const metrics = getImageMetrics(sprite) || getImageMetrics(this.type); - const w = metrics?.w || 512; - const h = metrics?.h || 512; - const moveSpeed = Math.hypot(this.vx, this.vy); - const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8); - const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18); - const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0; - const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0; - const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0; - const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20; - const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0; - const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0; - const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0; - const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25; - const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; - const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); - const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin; - let drawW = w * this.scale * 0.30; - let drawH = h * this.scale * 0.30; - if (sprite === "stretch") { - drawW *= 1.16; - drawH *= 1.24; - } - if (sprite === "intimidate") { - drawW *= 1.32; - drawH *= 1.32; - } - if (sprite === "birth_ritual") { - drawW *= 0.64; - drawH *= 0.64; - } - if (fallPose > 0) { - drawW *= 1 + fallPose * 0.08; - drawH *= 1 - fallPose * 0.07; - } - const squish = this.state === "sleep" - ? 1 + breathing * 0.018 - : 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03; - const faceRight = this.state === "sleep" - ? this.sleepFacing > 0 - : this.facingDir() > 0; - const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt; - - const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8); - const shadowContactY = this.y + bob + (drawH / squish) * 0.30; - drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { - faceRight, - alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92), - widthScale: this.state === "sleep" ? 1.12 : 1, - heightScale: this.state === "sleep" ? 0.88 : 1, - }); - drawCreatureGlow(ctx, this, drawW, drawH, lightState); - if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - const hx = partner ? (this.x + partner.x) / 2 : this.x; - const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60; - const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10; - const size = Math.max(10, this.radius * 0.62) * pulse; - drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" }); - } - - ctx.save(); - ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY); - ctx.rotate(angle); - - if (this.world.selected === this) { - ctx.save(); - ctx.globalAlpha = 0.85; - ctx.strokeStyle = "rgba(165, 220, 98, 0.92)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([5, 4]); - ctx.lineWidth = 1.2; - ctx.strokeStyle = "rgba(255,255,255,0.86)"; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - ctx.restore(); - } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { - ctx.save(); - ctx.globalAlpha = 0.28; - ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - - if (this.eatTimer > 0.02) { - ctx.save(); - ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); - ctx.strokeStyle = this.lastMealColor || "#f7cf67"; - ctx.lineWidth = 1.6; - ctx.beginPath(); - ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - - if (this.pokeFlashTimer > 0.03) { - ctx.save(); - ctx.globalAlpha = this.pokeFlashTimer * 0.9; - ctx.fillStyle = "rgba(255, 120, 120, 0.22)"; - ctx.beginPath(); - ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { - ctx.save(); - ctx.globalAlpha = this.state === "cursor_friend" ? 0.82 : 0.34; - ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); - for (let i = 0; i < 2; i++) { - const px = Math.cos(t * 2.2 + this.age + i * 2.4) * drawW * 0.34; - const py = -drawH * 0.24 + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * 0.11; - if (this.state === "cursor_friend") { - const s = Math.max(5, drawW * 0.075); - drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 }); - } else { - ctx.beginPath(); - ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.restore(); - } - - if (faceRight) ctx.scale(-1, 1); - const dw = drawW * squish; - const dh = drawH / squish; - const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17); - const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14); - const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0; - const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); - const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); - const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; - ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); - if (this.zunchiStain > 0.01 && img) { - const stain = clamp(this.zunchiStain, 0, 1); - const stainCanvas = typeof getCachedStainOverlayCanvas === "function" ? getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, stain, this.zunchiStainSeed || 0.5) : null; - if (stainCanvas) ctx.drawImage(stainCanvas, -dw / 2, -dh / 2, dw, dh); - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.globalAlpha = clamp(0.30 + stain * 0.42, 0.30, 0.72); - ctx.fillStyle = "rgba(30, 92, 24, 0.92)"; - const ss = this.zunchiStainSeed || 0.5; - const spots = [ - [-0.12 + ss * 0.10, dh * 0.31, dw * 0.18, dh * 0.045, -0.08], - [0.16 - ss * 0.08, dh * 0.36, dw * 0.19, dh * 0.050, 0.10], - [0.00, dh * 0.41, dw * 0.24, dh * 0.044, 0.02], - ]; - for (const [sx, sy, rx, ry, rot] of spots) { - ctx.beginPath(); - ctx.ellipse(sx * dw, -dh / 2 + sy, rx, ry, rot, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } - drawDawnRimLight(ctx, dw, dh, lightState); - if (faceRight) ctx.scale(-1, 1); - - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) { - ctx.save(); - ctx.globalAlpha = 0.80; - ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(42,36,29,0.72)"; - const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?"); - ctx.fillText(mark, 0, -drawH * 0.72); - ctx.restore(); - } - - ctx.restore(); - - if ((this.hpBarTimer || 0) > 0) { - const bw = Math.max(28, this.radius * 2.35); - const bh = 5; - const x = this.x - bw / 2; - const y = this.y - drawH * 0.55 - 16; - const pct = clamp(this.energy / 100, 0, 1); - ctx.save(); - ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1); - ctx.fillStyle = "rgba(255,252,242,0.86)"; - roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); - ctx.fill(); - ctx.fillStyle = "rgba(72,58,45,0.22)"; - roundedRect(ctx, x, y, bw, bh, 4); - ctx.fill(); - ctx.fillStyle = pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"); - roundedRect(ctx, x, y, bw * pct, bh, 4); - ctx.fill(); - ctx.restore(); - } - } - - contains(x, y) { - return distXY(this.x, this.y, x, y) < this.radius * 1.9; - } -} - -class World { - constructor() { - this.w = 1000; - this.h = 720; - this.time = 0; - this.day = 1; - this.paused = false; - this.speed = 1; - this.tool = "observe"; - this.toolSize = "medium"; - this.toolSizes = {}; - this.tarinai = []; - this.items = []; - this.effects = []; - this.logs = []; - this.eventCounters = {}; - this.countsTimer = 0; - this.compactTimer = 0; - this.drawSortTimer = 0; - this.drawListDirty = true; - this.selected = null; - this.deadCount = 0; - this.maxGeneration = 1; - this.family = {}; - this.familyVersion = 0; - this.relationNotices = {}; - this.resolvedFightIds = {}; - this.pointer = { x: 0, y: 0, inside: false }; - this.lastPhase = ""; - this.weather = "sunny"; - this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.spatial = new SpatialGrid(96); - this.spatialScratch = []; - this.drawList = []; - this.itemCounts = {}; - this.effectCounts = {}; - this.countsTimer = 0; - this.compactTimer = 0; - this.drawSortTimer = 0; - this.drawListDirty = true; - this.updateItemCounts(); - this.updateEffectCounts(); - this.rebuildSpatial(); - } - - dayProgress() { - return (this.time % CONFIG.dayLength) / CONFIG.dayLength; - } - - lightLevel() { - return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2; - } - - phaseName() { - const p = this.dayProgress(); - if (p < 0.20) return "\u671d"; - if (p < 0.48) return "\u663c"; - if (p < 0.68) return "\u5915\u65b9"; - return "\u591c"; - } - - clockString() { - const totalMinutes = Math.floor(this.dayProgress() * 24 * 60); - const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); - const mm = String(totalMinutes % 60).padStart(2, "0"); - return `${hh}:${mm}`; - } - - edgeSpawnPoint(targetX = null, targetY = null, margin = 34) { - const w = Math.max(this.w || 1000, margin * 2 + 1); - const h = Math.max(this.h || 720, margin * 2 + 1); - let side; - if (Number.isFinite(targetX) && Number.isFinite(targetY)) { - const distances = [targetY, w - targetX, h - targetY, targetX]; - side = distances.indexOf(Math.min(...distances)); - } else { - side = Math.floor(Math.random() * 4); - } - if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 }; - if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI }; - if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 }; - return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 }; - } - - toolSizeFor(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium"; - return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; - } - - toolSizeScale(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1; - return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; - } - - applyToolSize(item) { - if (!item) return item; - const scale = this.toolSizeScale(item.type); - const size = this.toolSizeFor(item.type); - item.toolSize = size; - this.toolSize = size; - item.r *= scale; - if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale; - if (item.type === "firecracker") item.blastScale = scale; - return item; - } - - reset(seedPopulation = CONFIG.initialPopulation) { - this.time = 0; - this.day = 1; - this.paused = false; - this.speed = 1; - this.toolSize = this.toolSize || "medium"; - this.toolSizes = this.toolSizes || {}; - this.tarinai = []; - this.items = []; - this.effects = []; - this.logs = []; - this.eventCounters = {}; - this.countsTimer = 0; - this.compactTimer = 0; - this.drawSortTimer = 0; - this.drawListDirty = true; - this.selected = null; - this.deadCount = 0; - this.maxGeneration = 1; - this.family = {}; - this.familyVersion = 0; - this.relationNotices = {}; - this.resolvedFightIds = {}; - this.pointer = { x: this.w / 2, y: this.h / 2, inside: false }; - this.lastPhase = this.phaseName(); - this.weather = "sunny"; - this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - const types = spawnableSprites().map(s => s.id); - for (let i = 0; i < seedPopulation; i++) { - this.addTarinai({ - type: types[i % types.length], - x: rand(80, this.w - 80), - y: rand(80, this.h - 80), - generation: 1, - }); - } - for (let i = 0; i < 18; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); - this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); - this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22)); - this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78)); - this.updateItemCounts(); - this.updateEffectCounts(); - this.rebuildSpatial(); - this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002"); - showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002"); - syncTopButtons(); - } - - - recordFamily(t) { - if (!this.family) this.family = {}; - let changed = false; - if (!this.family[t.id]) { - this.family[t.id] = { - id: t.id, - name: t.name, - type: t.type, - generation: t.generation, - hasPaired: !!t.hasPaired, - parents: [...(t.parents || [])], - parentNames: [...(t.parentNames || [])], - children: [...(t.children || [])], - birthTime: t.birthTime, - alive: true, - deathReason: t.deathReason || "", - }; - changed = true; - } else { - const nextAlive = !t.dead; - const nextChildren = [...(t.children || this.family[t.id].children || [])]; - if (this.family[t.id].hasPaired !== !!t.hasPaired) changed = true; - if (this.family[t.id].generation !== t.generation) changed = true; - if ((this.family[t.id].deathReason || "") !== (t.deathReason || "")) changed = true; - if (this.family[t.id].alive !== nextAlive) changed = true; - if (nextChildren.length !== (this.family[t.id].children || []).length) changed = true; - this.family[t.id].alive = nextAlive; - this.family[t.id].generation = t.generation; - this.family[t.id].hasPaired = !!t.hasPaired; - this.family[t.id].deathReason = t.deathReason || this.family[t.id].deathReason || ""; - this.family[t.id].children = nextChildren; - } - if (changed) this.familyVersion = (this.familyVersion || 0) + 1; - } - - markDead(t, reason) { - if (!this.family) this.family = {}; - if (this.family[t.id]) { - if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== reason) { - this.family[t.id].alive = false; - this.family[t.id].deathReason = reason; - this.familyVersion = (this.familyVersion || 0) + 1; - } - } - this.pruneExtinctFamilies(); - } - - pruneExtinctFamilies() { - const family = this.family || {}; - const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); - if (!nodes.length) return; - const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(); - const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); }; - const link = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - add(a); add(b); - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - add(n.id); - for (const p of n.parents || []) link(n.id, p); - for (const c of n.children || []) link(n.id, c); - } - const seen = new Set(); - const remove = []; - for (const n of nodes) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - comp.push(id); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (!comp.some(id => family[id]?.alive)) remove.push(...comp); - } - if (!remove.length) return; - for (const id of remove) delete family[id]; - this.familyVersion = (this.familyVersion || 0) + 1; - } - - addTarinai(opts = {}) { - const t = new Tarinai(this, opts); - this.tarinai.push(t); - this.drawListDirty = true; - this.recordFamily(t); - this.maxGeneration = Math.max(this.maxGeneration, t.generation); - return t; - } - - startBirthRitual(a, b) { - if (!a || !b || a.dead || b.dead) return false; - if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; - const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); - const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length); - if (aJoined && !bJoined) b.generation = a.generation; - else if (bJoined && !aJoined) a.generation = b.generation; - else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; } - a.hasPaired = true; - b.hasPaired = true; - this.recordFamily(a); - this.recordFamily(b); - const duration = 10.0; - a.birthRitualTimer = b.birthRitualTimer = duration; - a.birthRitualMax = b.birthRitualMax = duration; - a.birthPartnerId = b.id; - b.birthPartnerId = a.id; - a.birthRitualRole = -1; - b.birthRitualRole = 1; - a.birthRitualLeader = true; - b.birthRitualLeader = false; - a.state = b.state = "birth_ritual"; - a.target = b; - b.target = a; - a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); - b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); - a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); - b.surpriseTimer = Math.max(b.surpriseTimer, 0.35); - return true; - } - - finishBirthRitual(a, b) { - if (!a || !b || a.dead || b.dead) return null; - a.birthRitualTimer = b.birthRitualTimer = 0; - a.birthPartnerId = b.birthPartnerId = null; - a.birthRitualLeader = b.birthRitualLeader = false; - a.state = b.state = "idle"; - a.fightTimer = b.fightTimer = 0; - a.fightTargetIds = []; b.fightTargetIds = []; - a.fightTargetId = b.fightTargetId = null; - a.defeatedById = b.defeatedById = null; - a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18; - a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0); - const children = []; - const countRoll = Math.random(); - const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1); - for (let i = 0; i < childCount; i++) { - const child = this.spawnChild(a, b, i, childCount); - if (child) { child.postBirthPeaceTimer = 18; children.push(child); } - } - if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b${children.length}\u5339\u306e\u5b50\u304c\u73fe\u308c\u305f\u3002`, "birth"); - return children[0] || null; - } - - spawnChild(a, b, index = 0, total = 1) { - const inherited = Math.random() < 0.56 ? a.type : b.type; - const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; - const child = this.addTarinai({ - type: mutation, - name: makeName(), - age: 0, - x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, - y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0), - scale: rand(0.17, 0.23), - hunger: rand(20, 40), - loneliness: rand(30, 56), - energy: rand(55, 95), - stress: rand(0, 14), - need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), - generation: Math.max(a.generation, b.generation) + 1, - lifeSpan: rand(1500, 2400), - parents: [a.id, b.id], - parentNames: [a.name, b.name], - birthTime: this.time, - }); - this.maxGeneration = Math.max(this.maxGeneration, child.generation); - a.affection = 0; b.affection = 0; - a.loneliness *= 0.65; b.loneliness *= 0.65; - a.children.push(child.id); - b.children.push(child.id); - this.recordFamily(a); - this.recordFamily(b); - this.recordFamily(child); - if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id); - if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id); - audio.birth(); - if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`, "birth"); - return child; - } - - startConflict(a, b) { - if (!a || !b || a.dead || b.dead) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; - const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5); - const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5); - const actor = scoreA >= scoreB ? a : b; - const target = actor === a ? b : a; - const actorScore = actor === a ? scoreA : scoreB; - const targetScore = actor === a ? scoreB : scoreA; - const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); - const intimidationChance = 0.40; - if (canIntimidate && Math.random() < intimidationChance) { - this.startIntimidation(actor, target, actorScore, targetScore); - return; - } - this.startFight(a, b); - } - - startIntimidation(actor, target, actorScore = 0, targetScore = 0) { - if (!actor || !target || actor.dead || target.dead) return false; - if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; - const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; - const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5); - const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96); - actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); - target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); - actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); - actor.intimidateTargetId = target.id; - actor.state = "intimidate"; - this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { - life: 0.42, - size: actor.radius * 0.55, - color: "rgba(145, 106, 55, 0.70)", - })); - this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45)); - target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); - if (Math.random() >= successChance) { - target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); - return true; - } - target.defeatedById = actor.id; - target.fightWinnerId = actor.id; - target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); - target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); - target.state = "panic"; - target.target = actor; - this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); - target.vy += rand(-18, 18); - actor.adjustRelation(target, -0.08, 0.06, "intimidate"); - target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); - if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { - this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight"); - actor.lastLog = target.lastLog = this.time; - } - return true; - } - - startFight(a, b) { - if (!a || !b || a.dead || b.dead) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - a.state = "fight"; b.state = "fight"; - a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); - b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); - a.nextHeadbutt = this.time + rand(0.08, 0.18); - b.nextHeadbutt = a.nextHeadbutt; - a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); - b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); - if (!a.fightTargetIds) a.fightTargetIds = []; - if (!b.fightTargetIds) b.fightTargetIds = []; - if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); - if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id); - a.fightTargetIds = a.fightTargetIds.slice(-4); - b.fightTargetIds = b.fightTargetIds.slice(-4); - a.fightTargetId = a.fightTargetIds[0]; - b.fightTargetId = b.fightTargetIds[0]; - const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7); - const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7); - const loser = aScore <= bScore ? a : b; - const winner = loser === a ? b : a; - loser.defeatedById = winner.id; - loser.fightWinnerId = winner.id; - winner.fightWinnerId = winner.id; - winner.defeatedById = null; - winner.defeatedTimer = 0; - a.stress = clamp(a.stress + rand(8, 18), 0, 130); - b.stress = clamp(b.stress + rand(8, 18), 0, 130); - a.damage(rand(2, 7)); - b.damage(rand(2, 7)); - const dx = b.x - a.x, dy = b.y - a.y; - const d = Math.hypot(dx, dy) || 1; - a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); - b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); - a.surpriseTimer = 0.24; b.surpriseTimer = 0.24; - this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, { - size: rand(12, 20), - life: rand(0.32, 0.52), - color: "rgba(116, 73, 38, 0.82)", - })); - audio.fight(); - if (this.time - Math.max(a.lastLog, b.lastLog) > 6) { - this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight"); - a.lastLog = b.lastLog = this.time; - } - } - - explodeFirecracker(it) { - if (!it || it.dead || it.type !== "firecracker") return; - it.amount = 0; - const x = it.x, y = it.y; - const blastRadius = 240 * (it.blastScale || 1); - this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" })); - this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" })); - for (let r = 0; r < 4; r++) { - this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" })); - } - for (let i = 0; i < 34; i++) { - const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); - const speed = rand(90, 260); - this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { - vx: Math.cos(a) * speed, - vy: Math.sin(a) * speed, - size: rand(8, i % 3 === 0 ? 24 : 20), - life: rand(0.26, 0.72), - color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", - })); - } - for (const t of this.tarinai) { - if (t.dead) continue; - t.stress = clamp(t.stress + rand(8, 15), 0, 130); - t.fearTimer = Math.max(t.fearTimer, 0.58); - const dx = t.x - x; - const dy = t.y - y; - const d = Math.hypot(dx, dy) || 1; - if (d > blastRadius) continue; - const p = clamp(1 - d / blastRadius, 0, 1); - t.damage(5 + p * 18, "\u7206\u7af9\u306b\u9a5a\u3044\u305f"); - t.stress = clamp(t.stress + 22 * p, 0, 130); - t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); - t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); - t.fearTimer = Math.max(t.fearTimer, 1.7 + p); - t.state = "panic"; - t.target = { x, y }; - const power = 170 + p * 360; - t.vx += dx / d * power + rand(-55, 55); - t.vy += dy / d * power + rand(-55, 55); - t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35); - t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); - t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); - t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); - this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); - if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - } - audio.explode(); - this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); - } - - spawnZunchi(x, y) { - const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); - it.amount = 260; - this.items.push(it); - this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; - this.spawnBubble(it.x, it.y - 16, "\u3076\u308a\u3085\u3063", "rgba(62,84,45,0.78)"); - } - - spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { - if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; - if (text === "z") text = "Zzz..."; - audio.bubble(text); - this.effects.push(new Effect("bubble", x, y, { - vx: rand(-2, 2), - vy: rand(-5, -2), - life: rand(2.2, 3.2), - size: 8, - text, - color, - })); - this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1; - } - - spawnHeadbuttEffect(x, y) { - this.effects.push(new Effect("fight", x, y, { - size: rand(10, 16), - life: rand(0.20, 0.34), - color: "rgba(105, 62, 34, 0.86)", - })); - if (Math.random() < 0.5) audio.fight(); - } - - spawnFallEffect(x, y, scale = 1) { - if ((this.effectCounts.fall || 0) > 18) return; - this.effects.push(new Effect("fall", x, y, { - vx: rand(-12, 12), - vy: rand(-4, 3), - size: rand(20, 32) * scale, - life: rand(0.48, 0.72), - color: "rgba(154, 124, 80, 0.58)", - })); - } - - spawnEatEffect(x, y, color = "#f1dfb7") { - if (Math.random() < 0.70) { - this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { - vx: rand(-10, 10), - vy: rand(-18, -3), - size: rand(2.2, 4.2), - life: rand(0.18, 0.32), - color, - })); - } - if (Math.random() < 0.08) { - this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); - } - } - - spawnBleedEffect(x, y) { - this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { - vx: rand(-8, 8), - vy: rand(3, 14), - size: rand(2.0, 3.8), - life: rand(0.34, 0.62), - color: "rgba(80, 172, 55, 0.78)", - })); - } - - bedOccupancy(bed) { - if (!bed || bed.type !== "bed") return 0; - let n = 0; - const radius = Math.max(86, bed.r * 2.45); - for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { - if (t.dead) continue; - if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; - } - return n; - } - - bedComfort(bed) { - if (!bed || bed.type !== "bed") return 0.7; - const occ = this.bedOccupancy(bed); - return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); - } - - bestBedFor(t, maxDist = 360) { - let best = null; - let bestScore = Infinity; - for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { - if (bed.dead || bed.type !== "bed") continue; - if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; - const d = dist(t, bed); - const occ = this.bedOccupancy(bed); - const comfort = this.bedComfort(bed); - const score = d - comfort * 46 + Math.max(0, occ - 3) * 34; - if (score < bestScore) { best = bed; bestScore = score; } - } - return best; - } - - relationNotice(aId, bId, kind, cooldown = 30) { - const key = [aId, bId].sort().join(":") + `:${kind}`; - const last = this.relationNotices?.[key] || -Infinity; - if (this.time - last < cooldown) return false; - if (!this.relationNotices) this.relationNotices = {}; - this.relationNotices[key] = this.time; - return true; - } - - resolveFightOutcome(winner, loser) { - if (!winner || !loser || winner.dead || loser.dead) return; - const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; - if (!this.resolvedFightIds) this.resolvedFightIds = {}; - if (this.resolvedFightIds[key]) return; - this.resolvedFightIds[key] = true; - winner.defeatedById = null; - winner.defeatedTimer = 0; - winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); - winner.fightTargetIds = []; - winner.fightTargetId = null; - winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f"); - loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f"); - winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1; - loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1; - loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); - this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - winner.affection = clamp(winner.affection + 0.8, 0, 80); - winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); - if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { - this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight"); - } - } - - itemDropImpact(it) { - if (!it || it.dead) return; - const isStone = it.type === "stone"; - const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75); - const damage = isStone ? rand(20, 34) : rand(3, 7); - const push = isStone ? rand(145, 210) : rand(54, 92); - let hit = 0; - for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { - if (!t || t.dead) continue; - const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); - const p = clamp(1 - d / (radius + t.radius), 0, 1); - if (p <= 0) continue; - const nx = (t.x - it.x) / d; - const ny = (t.y - it.y) / d; - t.vx += nx * push * (0.35 + p); - t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p; - t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); - t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25); - t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26); - t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - hit += 1; - } - this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); - if (hit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45); - if (isStone && hit > 0) audio.poke(); - if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); - } - - nearest(entity, types, maxDist = Infinity) { - let best = null, bestD = maxDist; - const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; - for (const it of candidates) { - if (it.dead || !types.includes(it.type)) continue; - if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue; - const d = dist(entity, it); - if (d < bestD) { best = it; bestD = d; } - } - return best; - } - - nearestOther(entity, maxDist = Infinity) { - let best = null, bestD = maxDist; - const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; - for (const o of candidates) { - if (o === entity || o.dead) continue; - if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; - const d = dist(entity, o); - if (d < bestD) { best = o; bestD = d; } - } - return best; - } - - temperatureAt(x, y) { - let temp = 0.54 + Math.sin(this.time / 55) * 0.05; - for (const it of this.nearbyItems(x, y, 190)) { - if (it.type === "stone") { - const d = distXY(x, y, it.x, it.y); - temp += clamp(1 - d / 190, 0, 1) * 0.35; - } - } - return clamp(temp, 0, 1); - } - - - fenceRect(it) { - const vertical = it?.type === "fence_v"; - const horizontal = it?.type === "fence_h"; - if (!vertical && !horizontal) return null; - const len = Math.max(82, (it.r || 42) * 1.95); - const thick = Math.max(20, (it.r || 42) * 0.43); - const halfW = vertical ? thick : len; - const halfH = vertical ? len : thick; - return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal }; - } - - resolveFenceCollision(t) { - if (!t || t.dead) return; - const rr = Math.max(8, t.radius * 0.74); - for (const it of this.nearbyItems(t.x, t.y, rr + 92)) { - if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; - const r = this.fenceRect(it); - if (!r) continue; - const cx = clamp(t.x, r.left, r.right); - const cy = clamp(t.y, r.top, r.bottom); - let dx = t.x - cx; - let dy = t.y - cy; - let d = Math.hypot(dx, dy); - if (d >= rr) continue; - if (d < 0.001) { - const left = Math.abs(t.x - r.left); - const right = Math.abs(r.right - t.x); - const top = Math.abs(t.y - r.top); - const bottom = Math.abs(r.bottom - t.y); - const m = Math.min(left, right, top, bottom); - if (m === left) { dx = -1; dy = 0; d = 1; } - else if (m === right) { dx = 1; dy = 0; d = 1; } - else if (m === top) { dx = 0; dy = -1; d = 1; } - else { dx = 0; dy = 1; d = 1; } - } - const push = rr - d + 0.8; - t.x += dx / d * push; - t.y += dy / d * push; - if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; - else t.vy *= -0.18; - } - } - - - - segmentIntersectsRect(x1, y1, x2, y2, r) { - if (!r) return false; - if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true; - const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => { - const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px); - return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); - }; - return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top) - || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom) - || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom) - || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); - } - - pathBlockedByFence(x1, y1, x2, y2, padding = 16) { - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; - const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; - for (const it of this.items) { - if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; - if (distXY(cx, cy, it.x, it.y) > maxD + 110) continue; - const base = this.fenceRect(it); - if (!base) continue; - const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding }; - if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; - } - return false; - } - - grassBlockedAt(x, y, self = null) { - for (const it of this.nearbyItems(x, y, 52)) { - if (it === self || it.dead) continue; - if (it.type === "fence_v" || it.type === "fence_h") { - const r = this.fenceRect(it); - if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true; - } - if (it.type !== "zunchi") continue; - const dx = (x - it.x) / Math.max(22, it.r * 1.35); - const dy = (y - it.y) / Math.max(12, it.r * 0.82); - if (dx * dx + dy * dy < 1) return true; - } - return false; - } - - grassCrowdedAt(x, y, minGap = 30, self = null) { - for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) { - if (it === self || it.dead || it.type !== "grass") continue; - if (distXY(x, y, it.x, it.y) < minGap) return true; - } - return false; - } - - grassOnTarinaiAt(x, y, minGap = 24) { - for (const t of this.nearbyTarinai(x, y, minGap + 36)) { - if (t.dead) continue; - if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; - } - return false; - } - - grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) { - if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false; - if (this.grassBlockedAt(x, y, self)) return false; - if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false; - if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false; - return true; - } - - findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) { - const cx = clamp(x, 44, this.w - 44); - const cy = clamp(y, 44, this.h - 44); - if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy }; - const blockedAtCenter = this.grassBlockedAt(cx, cy); - const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius; - const outer = Math.max(maxRadius, inner + 42); - const golden = Math.PI * (3 - Math.sqrt(5)); - for (let i = 0; i < attempts; i++) { - const t = attempts <= 1 ? 1 : i / (attempts - 1); - const radius = lerp(inner, outer, Math.sqrt(t)); - const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2; - const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44); - const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44); - if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py }; - } - return null; - } - - findGrassSproutSpot(source, fertile = false) { - const attempts = fertile ? 24 : 10; - const minR = fertile ? Math.max(30, source.r * 1.9) : 18; - const maxR = fertile ? 125 : 72; - for (let i = 0; i < attempts; i++) { - const angle = rand(0, Math.PI * 2); - const radius = rand(minR, maxR); - const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44); - const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44); - if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y }; - } - return null; - } - - performanceLevel() { - const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60; - if (!fps || !Number.isFinite(fps)) return 0; - if (fps < 22) return 3; - if (fps < 34) return 2; - if (fps < 48) return 1; - return 0; - } - - grassLoadLevel() { - const grass = this.itemCounts?.grass || 0; - const perf = this.performanceLevel(); - if (perf >= 3 || grass >= 180) return 3; - if (perf >= 2 || grass >= 120) return 2; - if (perf >= 1 || grass >= 72) return 1; - return 0; - } - - grassUpdateFactor() { - return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0; - } - - sortedDrawList() { - if (!this.drawList) this.drawList = []; - if (this.drawListDirty || this.drawList.length !== this.tarinai.length) { - this.drawList.length = 0; - for (const t of this.tarinai) if (!t.dead) this.drawList.push(t); - this.drawList.sort((a, b) => a.y - b.y); - this.drawListDirty = false; - } - return this.drawList; - } - - nearbyItems(x, y, radius) { - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch); - } - - nearbyTarinai(x, y, radius) { - return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch); - } - - rebuildSpatial() { - this.spatial.rebuild(this.items, this.tarinai); - } - - updateItemCounts() { - const counts = this.itemCounts; - for (const key of Object.keys(counts)) counts[key] = 0; - for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1; - } - - updateEffectCounts() { - const counts = this.effectCounts; - for (const key of Object.keys(counts)) counts[key] = 0; - for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1; - } - - compactItems() { - const before = this.items.length; - let write = 0; - for (let read = 0; read < this.items.length; read++) { - const it = this.items[read]; - if (it.type === "mirror") continue; - if (!it.dead) this.items[write++] = it; - } - this.items.length = write; - return before !== this.items.length; - } - - compactEffects() { - let write = 0; - for (let read = 0; read < this.effects.length; read++) { - const ef = this.effects[read]; - if (!ef.dead) this.effects[write++] = ef; - } - this.effects.length = write; - } - - compactTarinai() { - let write = 0; - for (let read = 0; read < this.tarinai.length; read++) { - const t = this.tarinai[read]; - if (!t.dead) this.tarinai[write++] = t; - } - if (this.tarinai.length !== write) this.drawListDirty = true; - this.tarinai.length = write; - } - - update(dt) { - if (this.paused) return; - dt *= this.speed; - dt = Math.min(dt, 0.12 * this.speed); - - this.time += dt; - this.day = Math.floor(this.time / CONFIG.dayLength) + 1; - this.updateWeather(dt); - this.rebuildSpatial(); - - const perf = this.performanceLevel(); - const itemCountBefore = this.items.length; - for (const it of this.items) it.update(dt, this); - - for (const ef of this.effects) ef.update(dt); - - for (const t of this.tarinai) { - t.update(dt); - } - - this.compactTimer += dt; - const compactInterval = perf >= 2 ? 0.95 : 0.58; - let itemsCompacted = false; - if (this.compactTimer >= compactInterval) { - itemsCompacted = this.compactItems(); - this.compactEffects(); - this.compactTarinai(); - this.compactTimer = 0; - } - - this.countsTimer += dt; - const countsInterval = perf >= 2 ? 0.85 : 0.42; - if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) { - this.updateItemCounts(); - this.updateEffectCounts(); - this.countsTimer = 0; - } - - this.drawSortTimer += dt; - const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07; - if (this.drawSortTimer >= sortInterval) { - this.drawListDirty = true; - this.drawSortTimer = 0; - } - - const phase = this.phaseName(); - if (phase !== this.lastPhase) { - this.lastPhase = phase; - audio.phase(); - } - - if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) { - const spot = this.edgeSpawnPoint(); - const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() }); - t.wanderAngle = spot.angle; - this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002"); - } - - - const grassCount = this.itemCounts.grass || 0; - if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) { - const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { - allowOriginal: true, - minRadius: 28, - maxRadius: 140, - attempts: 24, - }); - if (spot) { - const sprout = new Item("grass", spot.x, spot.y); - sprout.amount = rand(22, 48); - sprout.growth = rand(0.12, 0.30); - this.items.push(sprout); - this.itemCounts.grass = grassCount + 1; - } - } - - if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) { - const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58)); - drop.amount = rand(18, 40); - this.items.push(drop); - this.itemCounts.water = (this.itemCounts.water || 0) + 1; - } - - if (Math.random() < dt * 0.004) { - const entries = [ - "\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002", - "\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002", - "\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002", - "\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002", - `${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002` - ]; - this.log(pick(entries)); - } - } - - updateWeather(dt) { - this.nextWeatherChange -= dt; - if (this.nextWeatherChange > 0) return; - const current = this.weather || "sunny"; - const choices = current === "sunny" - ? ["sunny", "cloudy", "light_rain"] - : current === "cloudy" - ? ["sunny", "cloudy", "light_rain"] - : ["sunny", "cloudy"]; - this.weather = pick(choices); - this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); - } - - placeItem(item, dropped = true) { - if (!item) return null; - if (dropped) { - item.dropMax = item.type === "stone" ? 0.72 : 0.58; - item.dropTimer = item.dropMax; - item.dropImpactDone = false; - } - this.items.push(item); - this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1; - this.rebuildSpatial(); - return item; - } - - handleClick(x, y) { - const tool = this.tool; - if (tool === "observe") { - let found = null; - for (let i = this.tarinai.length - 1; i >= 0; i--) { - if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } - } - this.selected = found; - if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`); - return; - } - - if (tool === "poke") { - const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); - if (target) { - target.poke(); - if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`); - } else { - this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002"); - } - return; - } - - if (tool === "new") { - const spot = this.edgeSpawnPoint(x, y); - const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 }); - t.wanderAngle = spot.angle; - audio.birth(); - this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); - return; - } - - if (tool === "delete") { - let best = null, bestD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (it.dead) continue; - const hit = Math.max(22, (it.r || 12) * 2.0); - const d = distXY(x, y, it.x, it.y); - if (d < hit && d < bestD) { best = it; bestD = d; } - } - if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } - best.amount = 0; - this.compactItems(); - this.updateItemCounts(); - this.rebuildSpatial(); - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; - this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); - return; - } - - const itemType = { - zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass", - stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h" - }[tool]; - if (itemType) { - let px = x, py = y; - if (itemType === "grass") { - const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 }); - if (!spot) { - showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002"); - this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); - return; - } - px = spot.x; - py = spot.y; - } - this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true); - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType]; - this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); - } - } - - inferLogKind(text) { - const s = String(text || "").toLowerCase(); - if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; - if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight"; - if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; - if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:")) return "death"; - if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; - if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; - if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; - if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe"; - if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation"; - return "note"; - } - - log(text, kind = null) { - const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; - if (!this.eventCounters) this.eventCounters = {}; - this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; - this.logs.unshift(entry); - this.logs = this.logs.slice(0, 100); - renderLog(this.logs); - } - - serialize() { - return { - w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount, - maxGeneration: this.maxGeneration, - tarinai: this.tarinai.map(t => t.serialize()), - items: this.items.map(i => i.serialize()), - logs: this.logs.slice(0, 30), - family: this.family || {}, - weather: this.weather || "sunny", - eventCounters: this.eventCounters || {}, - toolSize: this.toolSize || "medium", - toolSizes: this.toolSizes || {}, - nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin, - }; - } - - load(data) { - this.time = data.time || 0; - this.day = data.day || 1; - this.deadCount = data.deadCount || 0; - this.eventCounters = data.eventCounters || {}; - this.toolSize = data.toolSize || this.toolSize || "medium"; - this.toolSizes = data.toolSizes || this.toolSizes || {}; - this.maxGeneration = data.maxGeneration || 1; - this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o)); - this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food").map(o => Item.from(o)); - this.effects = []; - this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") }); - this.family = data.family || {}; - this.familyVersion = (this.familyVersion || 0) + 1; - this.pruneExtinctFamilies(); - this.weather = data.weather || "sunny"; - this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.relationNotices = {}; - this.resolvedFightIds = {}; - for (const t of this.tarinai) this.recordFamily(t); - this.lastPhase = this.phaseName(); - this.selected = null; - this.updateItemCounts(); - this.updateEffectCounts(); - this.rebuildSpatial(); - this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); - } -} - -const world = new World(); - +"use strict"; window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world }; diff --git a/js/sim_core.js b/js/sim_core.js new file mode 100644 index 0000000..3e30e94 --- /dev/null +++ b/js/sim_core.js @@ -0,0 +1,306 @@ +"use strict"; +function makeName(worldRef = null) { + const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name)); + const build = () => { + const len = randi(2, 7); + const hasStop = len >= 2 && Math.random() < 0.30; + const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0)); + let name = "\u306f"; + for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045"; + if (hasStop) name += "\u3063"; + return name; + }; + for (let i = 0; i < 80; i++) { + const candidate = build(); + if (!existing.has(candidate)) return candidate; + } + return build(); +} + +function spawnableSprites() { + return SPRITES.filter(s => !s.actionOnly && !s.displayOnly); +} + +function displayNormalSprites() { + return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id)); +} + +function lowStressSprites() { + return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id)); +} + +function stableChoice(seed, salt, options) { + if (!options?.length) return null; + const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length)); + return options[i]; +} + +function baseSpriteId() { + return pick(spawnableSprites()).id; +} + +function makeTrait(type) { + const base = { + smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 }, + angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 }, + teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 }, + jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 }, + drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 }, + cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 }, + sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 }, + pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 }, + stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 }, + }[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 }; + return { ...base }; +} + +function stableUnit(seed, salt = "") { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 100000) / 100000; +} + +const PERSONALITIES = { + timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 }, + glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 }, + sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 }, + lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 }, + irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 }, + calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 }, +}; +const PERSONALITY_IDS = Object.keys(PERSONALITIES); + +function personalityForSeed(seed) { + return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm"; +} + +function relationDefaults() { + return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 }; +} + +function isParentChild(a, b) { + if (!a || !b) return false; + return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id)); +} + +function relationDisplayName(worldRef, id) { + if (!id) return "\u306a\u3057"; + const live = worldRef?.tarinai?.find(t => t.id === id); + if (live) return live.name; + const fam = worldRef?.family?.[id]; + return fam?.name || "\u4e0d\u660e"; +} + +class Effect { + constructor(type, x, y, options = {}) { + this.type = type; + this.x = x; + this.y = y; + this.vx = options.vx ?? rand(-10, 10); + this.vy = options.vy ?? rand(-10, 10); + this.size = options.size ?? rand(3, 8); + this.life = options.life ?? 0.7; + this.maxLife = this.life; + this.color = options.color || "#fff4a8"; + this.seed = Math.random() * 1000; + this.text = options.text || ""; + } + + update(dt) { + this.life -= dt; + this.x += this.vx * dt; + this.y += this.vy * dt; + this.vx *= Math.pow(0.92, dt * 60); + this.vy *= Math.pow(0.92, dt * 60); + } + get dead() { return this.life <= 0; } + + draw(ctx) { + const alpha = clamp(this.life / this.maxLife, 0, 1); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.translate(this.x, this.y); + if (this.type === "eat") { + ctx.fillStyle = this.color; + ctx.strokeStyle = "rgba(143, 111, 43, 0.7)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } else if (this.type === "bleed") { + ctx.fillStyle = this.color; + ctx.beginPath(); + ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2); + ctx.fill(); + } else if (this.type === "ring") { + ctx.strokeStyle = this.color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2); + ctx.stroke(); + } else if (this.type === "fight") { + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + const puffColor = this.color || "rgba(105, 62, 34, 0.86)"; + ctx.fillStyle = puffColor; + ctx.strokeStyle = puffColor; + for (let i = 0; i < 4; i++) { + const a = this.seed + i * 1.55; + const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72); + const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48); + ctx.globalAlpha = alpha * (0.26 + i * 0.08); + ctx.beginPath(); + ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = alpha * 0.78; + ctx.lineWidth = 2.0; + for (let i = 0; i < 2; i++) { + const dir = i === 0 ? -1 : 1; + const y = dir * this.size * 0.14; + ctx.beginPath(); + ctx.moveTo(-this.size * 0.62, y); + ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06); + ctx.stroke(); + } + } else if (this.type === "fall") { + ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)"; + for (let i = 0; i < 5; i++) { + const a = this.seed + i * 1.37; + const rr = this.size * randSeed(this.seed + i, 0.18, 0.82); + const px = Math.cos(a) * rr; + const py = Math.sin(a) * rr * 0.42; + ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42); + ctx.beginPath(); + ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2); + ctx.fill(); + } + } else if (this.type === "explosion") { + const burst = 1 - alpha; + ctx.globalAlpha = alpha * 0.95; + for (let i = 0; i < 3; i++) { + ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)"); + ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018)); + ctx.beginPath(); + ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2); + ctx.stroke(); + } + ctx.fillStyle = "rgba(255,214,86,0.88)"; + for (let i = 0; i < 14; i++) { + const a = this.seed + i * 0.73; + const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00); + ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88); + ctx.beginPath(); + ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2); + ctx.fill(); + } + } else if (this.type === "bubble") { + const w = Math.max(30, 14 + this.text.length * 7); + const h = 20; + ctx.globalAlpha = Math.min(1, alpha * 1.25); + ctx.fillStyle = "rgba(255, 252, 240, 0.92)"; + ctx.strokeStyle = "rgba(78, 64, 48, 0.22)"; + ctx.lineWidth = 1.2; + roundedRect(ctx, -w / 2, -h, w, h, 9); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(255, 252, 240, 0.92)"; + ctx.beginPath(); + ctx.moveTo(-4, -1); + ctx.lineTo(2, 5); + ctx.lineTo(6, -2); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = this.color || "rgba(42,36,29,0.78)"; + ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(this.text, 0, -10); + } + ctx.restore(); + } +} + +class SpatialGrid { + constructor(cellSize = 96) { + this.cellSize = cellSize; + this.itemCells = new Map(); + this.tarinaiCells = new Map(); + } + + clear() { + this.itemCells.clear(); + this.tarinaiCells.clear(); + } + + keyFor(x, y) { + const cx = Math.floor(x / this.cellSize); + const cy = Math.floor(y / this.cellSize); + return cx + cy * 100000; + } + + add(map, entity) { + const key = this.keyFor(entity.x, entity.y); + let bucket = map.get(key); + if (!bucket) { + bucket = []; + map.set(key, bucket); + } + bucket.push(entity); + } + + rebuild(items, tarinai) { + this.clear(); + for (const it of items) { + if (!it.dead) this.add(this.itemCells, it); + } + for (const t of tarinai) { + if (!t.dead) this.add(this.tarinaiCells, t); + } + } + + nearby(map, x, y, radius, out = []) { + out.length = 0; + const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); + const minX = Math.floor((x - r) / this.cellSize); + const maxX = Math.floor((x + r) / this.cellSize); + const minY = Math.floor((y - r) / this.cellSize); + const maxY = Math.floor((y + r) / this.cellSize); + for (let cy = minY; cy <= maxY; cy++) { + for (let cx = minX; cx <= maxX; cx++) { + const bucket = map.get(cx + cy * 100000); + if (!bucket) continue; + for (const entity of bucket) out.push(entity); + } + } + return out; + } +} + +function drawHeartShape(ctx, x, y, size, opts = {}) { + const fill = opts.fill || "rgba(240,91,135,0.90)"; + const stroke = opts.stroke || "rgba(255,252,246,0.92)"; + const alpha = opts.alpha == null ? 1 : opts.alpha; + const rotation = opts.rotation || 0; + ctx.save(); + ctx.translate(x, y); + if (rotation) ctx.rotate(rotation); + ctx.globalAlpha *= alpha; + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1.2, size * 0.14); + ctx.beginPath(); + ctx.moveTo(0, size * 0.72); + ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52); + ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + diff --git a/js/tarinai.js b/js/tarinai.js new file mode 100644 index 0000000..98df229 --- /dev/null +++ b/js/tarinai.js @@ -0,0 +1,1448 @@ +"use strict"; + +class Tarinai { + constructor(world, opts = {}) { + this.world = world; + this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + this.name = opts.name || makeName(world); + this.personality = opts.personality || personalityForSeed(this.id); + this.type = opts.type || baseSpriteId(); + const typeMeta = SPRITES.find(s => s.id === this.type); + if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); + this.trait = makeTrait(this.type); + const pdef = PERSONALITIES[this.personality] || PERSONALITIES.calm; + this.trait.social *= pdef.social ?? 1; + this.trait.stress /= pdef.fear ?? 1; + this.trait.sleep *= pdef.sleep ?? 1; + this.x = opts.x ?? rand(100, world.w - 100); + this.y = opts.y ?? rand(100, world.h - 100); + this.vx = opts.vx ?? rand(-12, 12); + this.vy = opts.vy ?? rand(-12, 12); + this.scale = opts.scale ?? rand(0.22, 0.33); + this.radius = 56 * this.scale; + this.age = opts.age ?? rand(0, 20); + this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200); + if (this.lifeSpan < 1200) this.lifeSpan = rand(1400, 2200); + this.generation = opts.generation ?? 1; + this.hasPaired = opts.hasPaired ?? false; + this.hunger = opts.hunger ?? rand(16, 45); + this.loneliness = opts.loneliness ?? rand(18, 62); + this.energy = opts.energy ?? rand(54, 98); + this.stress = opts.stress ?? rand(0, 20); + this.hpBarTimer = opts.hpBarTimer ?? 0; + this.affection = opts.affection ?? rand(0, 30); + this.need = opts.need || pick(NEEDS); + this.state = opts.state || "idle"; + this.dead = false; + this.deathReason = ""; + this.thought = ""; + this.target = null; + this.targetKey = ""; + this.targetSince = 0; + this.targetBestDist = Infinity; + this.targetGiveupKey = opts.targetGiveupKey || ""; + this.targetGiveupUntil = opts.targetGiveupUntil ?? 0; + this.panicTarget = opts.panicTarget || null; + this.panicTargetUntil = opts.panicTargetUntil ?? 0; + this.panicStuckTimer = opts.panicStuckTimer ?? 0; + this.wanderAngle = rand(0, Math.PI * 2); + this.reproductionTimer = rand(4, CONFIG.reproductionCooldown); + this.lastLog = 0; + this.sleeping = false; + this.blink = rand(0, 10); + this.lastSocial = 0; + this.goodMode = opts.goodMode ?? stableChoice(this.id, "good-mode", displayNormalSprites()) ?? "smile"; + this.facing = opts.facing ?? (stableUnit(this.id, "facing") < 0.5 ? -1 : 1); + this.visualFacing = opts.visualFacing ?? this.facing; + this.facingLockUntil = opts.facingLockUntil ?? 0; + this.eatTimer = opts.eatTimer ?? 0; + this.eatCooldown = 0; + this.foodReactTimer = opts.foodReactTimer ?? 0; + this.surpriseTimer = opts.surpriseTimer ?? 0; + this.fearTimer = opts.fearTimer ?? 0; + this.intimidateTimer = opts.intimidateTimer ?? 0; + this.intimidateTargetId = opts.intimidateTargetId || null; + this.intimidatedTimer = opts.intimidatedTimer ?? 0; + this.birthRitualTimer = opts.birthRitualTimer ?? 0; + this.birthRitualMax = opts.birthRitualMax ?? 0; + this.birthPartnerId = opts.birthPartnerId || null; + this.birthRitualRole = opts.birthRitualRole || 0; + this.birthRitualLeader = !!opts.birthRitualLeader; + this.pokeFlashTimer = opts.pokeFlashTimer ?? 0; + this.zunchiStain = 0; + this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain"); + this.tempComfort = opts.tempComfort ?? 0.62; + this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; + this.parents = opts.parents || []; + this.parentNames = opts.parentNames || []; + this.children = opts.children || []; + this.birthTime = opts.birthTime ?? this.world.time; + this.nextEatSound = opts.nextEatSound ?? 0; + this.fightTimer = opts.fightTimer ?? 0; + this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown); + this.fightTargetId = opts.fightTargetId || null; + this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []); + this.nextHeadbutt = opts.nextHeadbutt ?? 0; + this.digest = opts.digest ?? rand(0, 2.4); + this.poopCount = opts.poopCount ?? 0; + this.stretchTimer = opts.stretchTimer ?? 0; + this.lastMealColor = opts.lastMealColor || "#f7cf67"; + this.grassEatTimer = opts.grassEatTimer ?? 0; + this.hurtTimer = opts.hurtTimer ?? 0; + this.defeatedTimer = opts.defeatedTimer ?? 0; + this.defeatedById = opts.defeatedById || null; + this.fightWinnerId = opts.fightWinnerId || null; + this.relationships = opts.relationships && typeof opts.relationships === "object" ? JSON.parse(JSON.stringify(opts.relationships)) : {}; + this.fallTimer = opts.fallTimer ?? 0; + this.fallMax = opts.fallMax ?? 1.15; + this.fallDir = opts.fallDir ?? (stableUnit(this.id, "fall-dir") < 0.5 ? -1 : 1); + this.blastSpinTimer = opts.blastSpinTimer ?? 0; + this.blastSpinMax = opts.blastSpinMax ?? 0; + this.postBirthPeaceTimer = opts.postBirthPeaceTimer ?? 0; + this.sleepStart = 0.60 + stableUnit(this.id, "sleep-start") * 0.22; + this.sleepEnd = 0.15 + stableUnit(this.id, "sleep-end") * 0.14; + this.sleepFacing = stableUnit(this.id, "sleep-facing") < 0.5 ? -1 : 1; + this.nextBubbleAt = 0; + this.nextSleepBubbleAt = 0; + this.nextFearBubbleAt = 0; + this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22; + this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55; + this.visibleSpriteId = opts.visibleSpriteId || null; + this.spriteLockUntil = opts.spriteLockUntil ?? 0; + } + + serialize() { + return { + id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy, + scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired, + hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, + affection: this.affection, need: this.need, state: this.state, + goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer, + foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer, + intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, intimidatedTimer: this.intimidatedTimer, + birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId, + birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader, + pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, + tempComfort: this.tempComfort, tempTimer: this.tempTimer, + parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, + fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil, + panicTarget: this.panicTarget, panicTargetUntil: this.panicTargetUntil, panicStuckTimer: this.panicStuckTimer, + nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer, + grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer, + defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId, + relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir, + blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer, + lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, + }; + } + + static from(world, o) { + return new Tarinai(world, o); + } + + get lack() { + return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100); + } + + get mood() { + return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100); + } + + get ageSinceBirth() { + return Math.max(0, this.world.time - this.birthTime); + } + + get growth() { + return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1); + } + + get juvenile() { + return this.generation > 1 && this.growth < 1; + } + + parentToFollow() { + if (!this.juvenile || !this.parents?.length) return null; + let best = null, bestD = Infinity; + for (const id of this.parents) { + const p = this.world.tarinai.find(t => t.id === id && !t.dead); + if (!p) continue; + const d = dist(this, p); + if (d < bestD) { best = p; bestD = d; } + } + return best; + } + + label() { + const sp = SPRITES.find(s => s.id === this.type); + return sp ? sp.label : this.type; + } + + personalityProfile() { + return PERSONALITIES[this.personality] || PERSONALITIES.calm; + } + + personalityLabel() { + return this.personalityProfile().label || this.personality || "\u4e0d\u660e"; + } + + relationTo(id) { + if (!id) return relationDefaults(); + if (!this.relationships) this.relationships = {}; + if (!this.relationships[id]) this.relationships[id] = relationDefaults(); + return this.relationships[id]; + } + + adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { + if (!other || !other.id || other === this) return; + const rel = this.relationTo(other.id); + rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); + rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); + rel.fightsWon = rel.fightsWon || 0; + rel.fightsLost = rel.fightsLost || 0; + if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } + } + + strongestRelation(kind = "friend") { + let bestId = null; + let bestScore = kind === "fear" ? 5 : 8; + for (const [id, rel] of Object.entries(this.relationships || {})) { + const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); + if (score > bestScore) { bestId = id; bestScore = score; } + } + return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null; + } + + relationSummary() { + const friend = this.strongestRelation("friend"); + const fear = this.strongestRelation("fear"); + return { friend, fear }; + } + + bestFriendLive(minScore = 10, maxDist = Infinity) { + let best = null, bestScore = minScore; + for (const [id, rel] of Object.entries(this.relationships || {})) { + const score = rel.affinity || 0; + if (score <= bestScore) continue; + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; + best = t; + bestScore = score; + } + return best; + } + + recentFightRival(maxAge = 52, maxDist = Infinity) { + let best = null, bestT = -Infinity; + for (const [id, rel] of Object.entries(this.relationships || {})) { + const event = String(rel.lastEvent || ""); + const when = rel.lastTime || -Infinity; + if (this.world.time - when > maxAge) continue; + if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue; + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; + if (when > bestT) { best = t; bestT = when; } + } + return best; + } + + poke() { + audio.poke(); + this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e"); + this.stress = clamp(this.stress + 22, 0, 130); + this.surpriseTimer = 0.55; + this.fearTimer = Math.max(this.fearTimer, 1.2); + this.pokeFlashTimer = 0.55; + this.hurtTimer = Math.max(this.hurtTimer, 2.4); + this.vx += rand(-100, 100); + this.vy += rand(-100, 100); + if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e"); + } + + + showHpBar(duration = 4.8) { + this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration); + } + + damage(amount, reason = "") { + const before = this.energy; + this.energy = clamp(this.energy - Math.max(0, amount || 0), 0, 100); + if (this.energy < before - 0.01) this.showHpBar(); + if (this.energy <= 0.5 && reason) this.die(reason); + } + + recoverHealth(amount) { + if (this.dead) return; + const before = this.energy; + this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); + if (this.energy > before + 0.01 && before < 99.5) this.showHpBar(2.4); + } + + familyJoined() { + return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length); + } + + displayGeneration() { + return this.familyJoined() ? this.generation : null; + } + + targetIdentity(target = this.target) { + if (!target) return ""; + if (target.id) return `${target.type || "tarinai"}:${target.id}`; + if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`; + return ""; + } + + targetIgnoresFence(target = this.target) { + return Boolean(target?.type === "sweet" || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); + } + + shouldAvoidTarget(target) { + if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false; + if (this.targetIgnoresFence(target)) return false; + const key = this.targetIdentity(target); + if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true; + return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false; + } + + maintainTargetProgress(dt) { + const target = this.target; + if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { + this.targetKey = ""; + this.targetSince = this.world.time; + this.targetBestDist = Infinity; + return; + } + if (this.targetIgnoresFence(target)) return; + const key = this.targetIdentity(target); + const d = distXY(this.x, this.y, target.x, target.y); + if (key !== this.targetKey) { + this.targetKey = key; + this.targetSince = this.world.time; + this.targetBestDist = d; + } else if (d < (this.targetBestDist || Infinity) - 8) { + this.targetBestDist = d; + this.targetSince = this.world.time; + } + if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) { + this.targetGiveupKey = key; + this.targetGiveupUntil = this.world.time + 8; + this.target = null; + this.state = "idle"; + this.wanderAngle += rand(-1.4, 1.4); + return; + } + if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) { + this.targetGiveupKey = key; + this.targetGiveupUntil = this.world.time + 12; + this.target = null; + this.state = "idle"; + this.wanderAngle += rand(-2.2, 2.2); + this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f"; + } + } + + panicDestination(threat = null, force = false) { + const now = this.world.time || 0; + const current = this.panicTarget; + if (!force && current && now < (this.panicTargetUntil || 0) && distXY(this.x, this.y, current.x, current.y) > 34) return current; + let ax = Math.cos(this.wanderAngle || 0); + let ay = Math.sin(this.wanderAngle || 0); + if (threat && Number.isFinite(threat.x) && Number.isFinite(threat.y)) { + const dx = this.x - threat.x; + const dy = this.y - threat.y; + const d = Math.hypot(dx, dy) || 1; + ax = dx / d; + ay = dy / d; + } + const turn = rand(-0.75, 0.75); + const cos = Math.cos(turn); + const sin = Math.sin(turn); + const dirX = ax * cos - ay * sin; + const dirY = ax * sin + ay * cos; + const distance = rand(150, 250); + const p = CONFIG.worldPadding + this.radius; + let x = clamp(this.x + dirX * distance, p, this.world.w - p); + let y = clamp(this.y + dirY * distance, p, this.world.h - p); + if (distXY(this.x, this.y, x, y) < 70) { + x = clamp(this.x - dirY * distance, p, this.world.w - p); + y = clamp(this.y + dirX * distance, p, this.world.h - p); + } + this.panicTarget = { x, y, dead: false, detour: true, panic: true }; + this.panicTargetUntil = now + rand(1.6, 3.0); + this.panicStuckTimer = 0; + this.wanderAngle = Math.atan2(y - this.y, x - this.x); + return this.panicTarget; + } + + lowHealthSprite() { + const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118; + if (critical) return this.stretchTimer > 0 ? "stretch" : "weak"; + const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104; + if (weak) return "weak"; + return null; + } + + variantSprite(category) { + const pools = { + hurt: ["hurt", "hurt2"], + fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"], + flee: ["flee", "flee_fear2"], + sleep: ["sleep", "sleep2"], + stress: ["stress_dizzy", "stress_sweat"], + low_stress: lowStressSprites(), + intimidate: ["intimidate"], + normal: displayNormalSprites(), + }; + const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id)); + return stableChoice(this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; + } + + rawSpriteId() { + if (this.dead) return this.variantSprite("fear"); + if (this.type === "cry") return "cry"; + if (this.type === "stretch") return "stretch"; + if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; + if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat"; + if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt"); + if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual"; + const low = this.lowHealthSprite(); + if (low) return low; + if (this.defeatedTimer > 0.04) return this.variantSprite("flee"); + if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); + if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear"); + if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); + if (this.state === "fight" || this.fightTimer > 0.04) return "angry"; + if (this.surpriseTimer > 0.08) return "pokan"; + if (this.state === "cursor_friend") return "smile"; + if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; + if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back"; + if (this.state === "seek_food") return "drool"; + if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); + if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); + if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; + if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; + return this.goodMode || this.variantSprite("normal"); + } + + + spriteId() { + const next = this.rawSpriteId(); + const now = this.world?.time || 0; + if (!this.visibleSpriteId) { + this.visibleSpriteId = next; + this.spriteLockUntil = now + 1.0; + return next; + } + if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) { + this.visibleSpriteId = next; + this.spriteLockUntil = now + 1.0; + } + return this.visibleSpriteId; + } + + update(dt) { + if (this.dead) return; + + const minute = dt / 60; + this.age += dt; + this.reproductionTimer -= dt; + this.blink += dt; + this.eatTimer = Math.max(0, this.eatTimer - dt); + this.eatCooldown = Math.max(0, this.eatCooldown - dt); + this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); + this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); + this.fearTimer = Math.max(0, this.fearTimer - dt); + this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); + this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); + this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); + this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); + this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); + if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; + if (this.birthRitualTimer > 0) { + const beforeBirthTimer = this.birthRitualTimer; + this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); + if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + if (partner) this.world.finishBirthRitual(this, partner); + this.birthRitualLeader = false; + } + } + this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); + const wasFighting = this.fightTimer > 0.04; + this.fightTimer = Math.max(0, this.fightTimer - dt); + this.fightCooldown = Math.max(0, this.fightCooldown - dt); + this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); + if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { + const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null; + this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); + this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); + this.hurtTimer = Math.max(this.hurtTimer, 1.8); + this.fallTimer = Math.max(this.fallTimer, 1.15); + this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); + this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; + this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); + if (winner) this.world.resolveFightOutcome(winner, this); + this.fightTargetIds = []; + this.fightTargetId = null; + } + this.stretchTimer = Math.max(0, this.stretchTimer - dt); + this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); + this.hurtTimer = Math.max(0, this.hurtTimer - dt); + this.fallTimer = Math.max(0, this.fallTimer - dt); + this.zunchiStain = 0; + if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { + this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); + } + if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { + this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); + } + if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { + const side = this.facingDir(); + this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); + } + + this.tempTimer += dt; + if (this.tempTimer >= 0.55) { + this.tempComfort = this.world.temperatureAt(this.x, this.y); + this.tempTimer = 0; + } + const tempComfort = this.tempComfort ?? 0.62; + const tooCold = Math.max(0, 0.48 - tempComfort); + this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); + this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); + this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); + this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; + this.stress += tooCold * dt * 0.25; + + const phase = this.world.lightLevel(); + if (phase < 0.25) this.energy += dt * 0.07; + if (phase > 0.75) this.stress += dt * 0.04; + if (this.world.weather === "cloudy") { + this.stress -= dt * 0.018; + this.vx *= Math.pow(0.996, dt * 60); + this.vy *= Math.pow(0.996, dt * 60); + } else if (this.world.weather === "light_rain") { + this.energy += dt * 0.018; + this.stress -= dt * 0.025; + } else if (this.world.weather === "sunny") { + this.vx *= Math.pow(1.001, dt * 60); + this.vy *= Math.pow(1.001, dt * 60); + } + + if (this.juvenile) { + const adultScale = 0.25 + (makeTrait(this.type).speed % 0.08); + this.scale = lerp(this.scale, adultScale, dt * 0.018 + 0.002); + this.radius = 56 * this.scale; + } + + this.hunger = clamp(this.hunger, 0, 115); + this.loneliness = clamp(this.loneliness, 0, 110); + this.energy = clamp(this.energy, 0, 100); + this.stress = clamp(this.stress, 0, 130); + + this.aiTimer += dt; + const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; + const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; + const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.id, "ai-interval") * 0.09 + popPerf * 0.055); + if (this.aiTimer >= aiInterval) { + const aiDt = Math.min(this.aiTimer, 0.48); + this.aiTimer = 0; + this.resolveNeeds(aiDt); + const sleepingNow = this.state === "sleep"; + if (!sleepingNow) { + this.reactToFreshZunchi(aiDt); + this.updateFacing(); + this.interactWithItems(aiDt); + this.interactWithOthers(aiDt); + } else { + this.updateFacing(); + } + } else { + this.updateFacing(); + } + this.envTimer += dt; + const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48; + if (this.envTimer >= envInterval) { + const envDt = Math.min(this.envTimer, 1.2); + this.envTimer = 0; + this.applyEnvironment(envDt); + } + this.maintainTargetProgress(dt); + this.move(dt); + if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); + this.checkLife(); + } + + applyEnvironment(dt) { + const parent = this.parentToFollow ? this.parentToFollow() : null; + if (parent && dist(this, parent) < 96) { + this.stress = clamp(this.stress - dt * 0.42, 0, 130); + this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); + } + const friend = this.bestFriendLive ? this.bestFriendLive(12, 92) : null; + if (friend) this.stress = clamp(this.stress - dt * 0.36, 0, 130); + let grassComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 105)) { + if (it.type !== "grass" || it.dead) continue; + const d = distXY(this.x, this.y, it.x, it.y); + const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); + grassComfort += clamp(1 - d / 105, 0, 1) * lush; + } + if (grassComfort > 0) { + this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); + if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); + if (this.need === "\u9759\u3051\u3055" || this.personality === "timid") this.affection += dt * Math.min(0.06, grassComfort * 0.012); + } + } + + bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") { + if (this.world.time < this.nextBubbleAt) return; + this.nextBubbleAt = this.world.time + cooldown; + this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); + } + + mouthPosition(target = null) { + const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir(); + return { + x: this.x + dir * this.radius * 0.40 - this.radius * 0.34, + y: this.y - this.radius * 0.28, + }; + } + + sleepSpotFor(bed) { + if (!bed) return { x: this.x, y: this.y }; + const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; + const baseRing = bed.r * 0.52; + const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95; + return { + x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), + y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding), + }; + } + + desiredFacingDir() { + if (this.state === "sleep") return this.sleepFacing || this.facing || 1; + if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1; + if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx); + return this.facing || 1; + } + + facingDir() { + return this.visualFacing || this.facing || 1; + } + + updateFacing() { + const desired = this.desiredFacingDir(); + const now = this.world?.time || 0; + this.facing = desired; + if (!this.visualFacing) { + this.visualFacing = desired; + this.facingLockUntil = now + 1.0; + return; + } + if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) { + this.visualFacing = Math.sign(desired) || this.visualFacing || 1; + this.facingLockUntil = now + 1.0; + } + } + + reactToFreshZunchi(dt) { + if (this.state === "panic" || this.fearTimer > 0.2) return; + const z = this.world.nearest(this, ["zunchi"], 90); + if (!z || z.stage !== "fresh") return; + const d = dist(this, z); + const push = clamp(1 - d / 90, 0, 1); + this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); + this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; + this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; + } + + resolveNeeds(dt) { + const wasSleeping = this.state === "sleep"; + const previousTarget = this.target && !this.target.dead ? this.target : null; + this.target = null; + this.sleeping = false; + + if (this.birthRitualTimer > 0.04) { + this.state = "birth_ritual"; + this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; + this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; + return; + } + + if (this.intimidateTimer > 0.04) { + this.state = "intimidate"; + this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; + this.bubble("!", 1.2); + this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; + return; + } + + if (this.fightTimer > 0.04) { + this.state = "fight"; + this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); + this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; + let best = null, bestD = Infinity; + for (const id of this.fightTargetIds) { + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + const d = dist(this, t); + if (d < bestD) { best = t; bestD = d; } + } + this.target = best; + this.bubble("!", 1.6); + this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b"; + return; + } + + if (this.defeatedTimer > 0.04) { + const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); + this.state = "panic"; + this.target = this.panicDestination(opponent); + this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b"; + if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; + return; + } else if (this.defeatedById && this.fightTimer <= 0.04) { + this.defeatedById = null; + this.fightWinnerId = null; + } + + const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null; + if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { + this.state = "panic"; + this.target = this.panicDestination(friendNearby.target); + this.fearTimer = Math.max(this.fearTimer, 0.32); + this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; + return; + } + + const dayP = this.world.dayProgress(); + const personalSleep = this.sleepStart < this.sleepEnd + ? dayP >= this.sleepStart && dayP < this.sleepEnd + : dayP >= this.sleepStart || dayP < this.sleepEnd; + + const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; + const feared = this.strongestRelation("fear"); + if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { + const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); + if (rival && dist(this, rival) < 150) { + this.state = "panic"; + this.target = this.panicDestination(rival); + this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear); + this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`; + if (this.world.relationNotice(this.id, rival.id, "avoid", 22)) this.world.log(`${this.name}\u306f\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u3001${rival.name}\u3092\u907f\u3051\u305f\u3002`, "relation"); + return; + } + } + + const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; + const zunda = this.world.nearest(this, ["sweet"], zundaRange); + const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); + const zundaReward = this.affection > 8 && this.hunger > 7; + if (zunda && zunda.amount > 1.2 && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { + this.state = "seek_food"; + this.target = zunda; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); + this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; + return; + } + + if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { + this.state = "panic"; + this.target = this.panicDestination(); + this.thought = "\u9003\u3052\u3066\u3044\u308b"; + return; + } + + const sleepBias = this.personalityProfile().sleep; + const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); + if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) { + let bed = previousTarget && previousTarget.type === "bed" ? previousTarget : null; + if (!bed && friendNearby && friendNearby.target?.type === "bed" && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; + if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360); + if (bed && !wasSleeping && this.energy > 18) { + const spot = this.sleepSpotFor(bed); + if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { + this.state = "seek_bed"; + this.target = bed; + this.thought = "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b"; + return; + } + } + this.state = "sleep"; + this.target = bed; + this.sleeping = true; + if (this.world.time > this.nextSleepBubbleAt) { + this.nextSleepBubbleAt = this.world.time + 4.5; + this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"; + return; + } + + const pointer = this.world.pointer; + if (pointer?.inside) { + const pd = distXY(this.x, this.y, pointer.x, pointer.y); + if (pd < 170) { + const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; + const friendly = !hostile && (this.mood > 52 || this.affection > 12 || this.goodMode === "smile"); + if (hostile) { + this.state = "cursor_enemy"; + this.target = { x: pointer.x, y: pointer.y, dead: false }; + this.fearTimer = Math.max(this.fearTimer, 0.18); + if (this.world.time > this.nextFearBubbleAt) { + this.nextFearBubbleAt = this.world.time + 2.4; + this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); + } + this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; + return; + } + if (friendly) { + this.state = "cursor_friend"; + this.target = { x: pointer.x, y: pointer.y, dead: false }; + this.goodMode = "smile"; + this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; + return; + } + } + } + + const parent = this.parentToFollow(); + if (parent && !this.shouldAvoidTarget(parent)) { + const d = dist(this, parent); + if (d > 34 || (this.hunger < 78 && this.energy > 24)) { + this.state = "follow_parent"; + this.target = parent; + this.bubble("?", 4.0); + this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f"; + return; + } + } + + if (friendNearby && this.hunger > 44 && friendNearby.target && ["sweet", "grass"].includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { + this.state = "seek_food"; + this.target = friendNearby.target; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); + this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; + return; + } + + if (this.hunger > 62) { + const itemTypes = ["sweet", "grass"]; + const item = this.world.nearest(this, itemTypes, 420); + if (item) { + this.state = "seek_food"; + this.target = item; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); + this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"; + return; + } + } + + if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) { + const other = this.world.nearestOther(this, 300); + if (other) { + this.state = "seek_friend"; + this.target = other; + this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b"; + return; + } + } + + if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { + this.state = "panic"; + this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + return; + } + + if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); + this.state = "idle"; + this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`; + } + + makePoop(force = 1) { + this.digest += force; + if (this.digest < 12.4) return; + this.digest = rand(0.4, 1.6); + this.poopCount += 1; + 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.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)"); + if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`); + } + + interactWithItems(dt) { + const canBite = this.eatCooldown <= 0 && this.hunger > 18; + const canSweetBite = this.eatCooldown <= 0 && this.hunger > 7; + for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { + const d = dist(this, it); + if (d > this.radius + it.r + 12) continue; + + if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && it.amount > 1.2) { + const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5); + if (eating > 0) { + this.state = "eat"; + this.target = it; + this.vx *= Math.pow(0.25, dt * 60); + this.vy *= Math.pow(0.25, dt * 60); + it.amount -= eating; + this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95); + this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); + this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; + if (it.type === "sweet") { + this.stress = clamp(this.stress - eating * 0.42, 0, 130); + this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); + } + this.eatTimer = Math.max(this.eatTimer, 0.32); + this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); + this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); + if (Math.random() < dt * 2.2) { + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7"); + } + this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7"; + this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22)); + 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`); + this.lastLog = this.world.time; + } + break; + } + } + + if (canBite && it.type === "grass") { + const bite = Math.min(it.amount, rand(4.2, 7.2)); + this.state = "eat"; + this.target = it; + this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); + this.vx *= Math.pow(0.20, dt * 60); + this.vy *= Math.pow(0.20, dt * 60); + it.amount -= bite; + it.eatenAmount = (it.eatenAmount || 0) + bite; + it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); + this.hunger -= bite * 0.46; + this.recoverHealth(bite * 0.18); + this.stress -= dt * 0.5; + this.eatTimer = Math.max(this.eatTimer, 0.22); + this.eatCooldown = rand(0.68, 1.15); + this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); + if (Math.random() < dt * 2.0) { + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); + } + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } + this.makePoop(bite * 0.10); + if (this.type === "jito") this.affection += dt * 0.25; + break; + } + + if (it.type === "zunchi") { + const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); + this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130); + this.zunchiStain = 0; + if (canBite && this.hunger > 104) { + const bite = Math.min(it.amount, rand(3.4, 5.8)); + this.state = "eat"; + this.target = it; + this.vx *= Math.pow(0.18, dt * 60); + this.vy *= Math.pow(0.18, dt * 60); + it.amount -= bite; + this.hunger -= bite * 0.18; + this.stress = clamp(this.stress + bite * 0.52, 0, 130); + this.zunchiStain = 0; + this.eatTimer = Math.max(this.eatTimer, 0.24); + this.eatCooldown = rand(0.9, 1.4); + this.bubble("...", 2.2, "rgba(74,91,50,0.78)"); + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } + break; + } + } + + if (it.type === "water") { + this.stress -= dt * 2.4; + this.energy += dt * 0.5; + this.hunger -= dt * 0.2; + it.amount -= dt * 2.4; + if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; + } + + if (it.type === "stone") { + this.stress -= dt * 0.85; + this.energy += dt * 0.18; + if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; + } + + if (it.type === "bed") { + if (this.energy < 74 + 6 * this.personalityProfile().sleep) { + const occ = this.world.bedOccupancy(it); + const comfort = this.world.bedComfort(it); + this.state = "sleep"; + this.sleeping = true; + if (this.world.time > this.nextSleepBubbleAt) { + this.nextSleepBubbleAt = this.world.time + 4.5; + this.world.spawnBubble(this.x, this.y - this.radius * 1.18, occ > 5 ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)"); + } + this.energy += dt * (1.9 + comfort * 1.4); + this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); + it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); + } + } + + if (it.type === "trace") { + this.loneliness -= dt * 0.25; + this.stress += dt * 0.06; + } + } + this.hunger = clamp(this.hunger, 0, 115); + this.energy = clamp(this.energy, 0, 100); + this.stress = clamp(this.stress, 0, 130); + this.loneliness = clamp(this.loneliness, 0, 110); + } + + interactWithOthers(dt) { + let closeCount = 0; + for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) { + if (o === this || o.dead) continue; + const dx = o.x - this.x, dy = o.y - this.y; + const d = Math.hypot(dx, dy); + if (d < 0.01 || d > 74) continue; + closeCount += 1; + + const overlap = this.radius + o.radius - d; + if (overlap > -2) { + const push = (overlap + 6) * 0.17; + this.vx -= (dx / d) * push; + this.vy -= (dy / d) * push; + if (Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); + } + + if (d < 60) { + const rel = this.relationTo(o.id); + const pdef = this.personalityProfile(); + this.loneliness -= dt * 1.65 * this.trait.social; + this.affection += dt * 0.28 * this.trait.social; + this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby"); + if ((rel.affinity || 0) > 10) { + this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); + this.affection = clamp(this.affection + dt * 0.18, 0, 80); + if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation"); + } + if ((rel.affinity || 0) > 8 && this.world.relationNotice(this.id, o.id, "friend", 32)) { + this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation"); + } + if (this.type === "angry") this.stress += dt * 0.08; + if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; + } + + const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); + if (fightingThis && d < 36 && this.id < o.id) { + const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; + if (pulse && this.world.time > this.nextHeadbutt) { + const nx = dx / d, ny = dy / d; + this.vx -= nx * rand(24, 44); + this.vy -= ny * rand(24, 44); + o.vx += nx * rand(34, 64); + o.vy += ny * rand(34, 64); + this.stress = clamp(this.stress + 1.6, 0, 130); + o.stress = clamp(o.stress + 2.8, 0, 130); + this.damage(1.2); + o.damage(1.6); + this.hurtTimer = Math.max(this.hurtTimer, 1.4); + o.hurtTimer = Math.max(o.hurtTimer, 1.8); + this.adjustRelation(o, -0.16, 0.12, "fight"); + o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); + if (Math.random() < 0.22 || o.energy < 24) { + o.fallTimer = Math.max(o.fallTimer, 0.58); + o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); + o.fallDir = nx > 0 ? 1 : -1; + this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); + } + if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { + o.defeatedById = this.id; + o.fightWinnerId = this.id; + } + this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); + this.nextHeadbutt = this.world.time + rand(0.34, 0.55); + } + } + + const relA = this.relationTo(o.id); + const relB = o.relationTo(this.id); + const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable"); + const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); + const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); + const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight; + const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; + if (d < 56 && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake) { + this.world.startConflict(this, o); + } + + if (d < 36 && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { + const enough = this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44; + const lonelyBond = this.loneliness > 24 || o.loneliness > 24; + if (enough && lonelyBond && Math.random() < dt * 0.25) { + this.world.startBirthRitual(this, o); + } + } + } + + if (closeCount > 8) { + this.stress += dt * 0.9; + this.energy -= dt * 0.25; + this.fearTimer = Math.max(this.fearTimer, 0.18); + } + + if (this.loneliness < 12 && Math.random() < dt * 0.002) { + this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`); + } + } + + move(dt) { + let ax = 0, ay = 0; + const baseSpeed = 31 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); + + if (this.state === "sleep") { + const bedComfort = this.target?.type === "bed" ? this.world.bedComfort(this.target) : 0.72; + const bedCrowd = this.target?.type === "bed" ? this.world.bedOccupancy(this.target) : 0; + this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); + this.stress = clamp(this.stress + dt * (bedCrowd > 5 ? 0.18 * (bedCrowd - 5) : -0.12 * bedComfort), 0, 130); + this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); + if (this.target && !this.target.dead && this.target.type === "bed") { + const spot = this.sleepSpotFor(this.target); + const dx = spot.x - this.x; + const dy = spot.y - this.y; + const d = Math.hypot(dx, dy); + if (d > 1.6) { + const step = Math.min(d, dt * (12 + bedComfort * 14)); + this.x += dx / d * step; + this.y += dy / d * step; + } + } + this.vx *= Math.pow(0.55, dt * 60); + this.vy *= Math.pow(0.55, dt * 60); + return; + } + + if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + if (partner) { + const midX = (this.x + partner.x) / 2; + const midY = (this.y + partner.y) / 2; + const offset = (this.birthRitualRole || 1) * this.radius * 0.44; + const dx = midX - this.x; + const dy = (midY + offset) - this.y; + this.vx += dx * dt * 2.8; + this.vy += dy * dt * 2.8; + } + this.vx *= Math.pow(0.56, dt * 60); + this.vy *= Math.pow(0.56, dt * 60); + this.x += Math.sin(this.world.time * 26 + this.age) * 0.32; + return; + } + + if (this.intimidateTimer > 0.04 || this.state === "intimidate") { + this.vx *= Math.pow(0.22, dt * 60); + this.vy *= Math.pow(0.22, dt * 60); + return; + } + + if (this.eatTimer > 0.04 || this.state === "eat") { + this.vx = 0; + this.vy = 0; + return; + } + + if (this.state === "panic") { + const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity; + const speed = Math.hypot(this.vx, this.vy); + this.panicStuckTimer = speed < 8 ? (this.panicStuckTimer || 0) + dt : 0; + if (!this.target || this.target.dead || targetDist < 28 || this.panicStuckTimer > 0.75) { + this.target = this.panicDestination(null, true); + } + } + + if (this.target && !this.target.dead) { + const dx = this.target.x - this.x; + const dy = this.target.y - this.y; + const d = Math.hypot(dx, dy) || 1; + let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1; + if (this.target?.detour) sign = 1; + let strength = 1; + if (this.state === "follow_parent") { + strength = d < 42 ? 0.18 : 1.22; + sign = 1; + } else if (this.state === "cursor_friend") { + strength = d < 30 ? 0.12 : 0.95; + sign = 1; + } else if (this.state === "cursor_enemy") { + strength = d < 150 ? 1.35 : 0.35; + sign = -1; + } else if (this.state === "fight") { + const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37); + if (d < 18) { + strength = 1.05; + sign = -1; + } else { + strength = headbutt > -0.15 ? 2.65 : 0.65; + sign = headbutt > -0.15 ? 1 : -1; + } + } + ax += (dx / d) * baseSpeed * sign * strength; + ay += (dy / d) * baseSpeed * sign * strength; + } else if (this.state === "panic") { + this.wanderAngle += rand(-3.8, 3.8) * dt; + ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; + ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; + } else { + ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34; + ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34; + } + + this.vx += ax * dt; + this.vy += ay * dt; + this.vx *= Math.pow(0.84, dt * 8); + this.vy *= Math.pow(0.84, dt * 8); + + const maxV = this.state === "panic" ? 116 : 76; + const v = Math.hypot(this.vx, this.vy); + if (v > maxV) { + this.vx = this.vx / v * maxV; + this.vy = this.vy / v * maxV; + } + + this.x += this.vx * dt; + this.y += this.vy * dt; + + const p = CONFIG.worldPadding; + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } + if (this.x > this.world.w - p) { this.x = this.world.w - p; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } + if (this.y > this.world.h - p) { this.y = this.world.h - p; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } + } + + checkLife() { + let reason = ""; + if (this.hunger >= 108) reason = "\u98df\u3079\u7269\u304c\u305f\u308a\u306a\u3044"; + else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d"; + else if (this.stress >= 124) reason = "\u30b9\u30c8\u30ec\u30b9\u304c\u591a\u3059\u304e\u308b"; + else if (this.energy <= 0 && this.hunger > 90) reason = "\u75b2\u308c\u3068\u7a7a\u8179\u304c\u91cd\u306a\u3063\u305f"; + if (reason) this.die(reason); + } + + die(reason) { + if (this.dead) return; + this.dead = true; + this.deathReason = reason; + this.world.deadCount += 1; + this.world.items.push(new Item("trace", this.x, this.y)); + this.world.items.push(new Item("splat", this.x, this.y)); + if (Math.random() < 0.55) { + const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); + if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); + } + this.world.markDead(this, reason); + audio.death(); + this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u7406\u7531: ${reason}`, "death"); + if (this.world.selected === this) this.world.selected = null; + } + + draw(ctx, t, lighting = null) { + if (this.dead) return; + const lightState = lighting || getLightingState(this.world); + const sprite = this.spriteId(); + const img = images.get(sprite) || images.get(this.type); + const metrics = getImageMetrics(sprite) || getImageMetrics(this.type); + const w = metrics?.w || 512; + const h = metrics?.h || 512; + const moveSpeed = Math.hypot(this.vx, this.vy); + const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8); + const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18); + const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0; + const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0; + const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0; + const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20; + const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0; + const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0; + const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0; + const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25; + const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; + const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); + const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin; + let drawW = w * this.scale * 0.30; + let drawH = h * this.scale * 0.30; + if (sprite === "stretch") { + drawW *= 1.16; + drawH *= 1.24; + } + if (sprite === "intimidate") { + drawW *= 1.32; + drawH *= 1.32; + } + if (sprite === "birth_ritual") { + drawW *= 0.64; + drawH *= 0.64; + } + if (fallPose > 0) { + drawW *= 1 + fallPose * 0.08; + drawH *= 1 - fallPose * 0.07; + } + const squish = this.state === "sleep" + ? 1 + breathing * 0.018 + : 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03; + const faceRight = this.state === "sleep" + ? this.sleepFacing > 0 + : this.facingDir() > 0; + const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt; + + const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8); + const shadowContactY = this.y + bob + (drawH / squish) * 0.30; + drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { + faceRight, + alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92), + widthScale: this.state === "sleep" ? 1.12 : 1, + heightScale: this.state === "sleep" ? 0.88 : 1, + }); + drawCreatureGlow(ctx, this, drawW, drawH, lightState); + if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + const hx = partner ? (this.x + partner.x) / 2 : this.x; + const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60; + const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10; + const size = Math.max(10, this.radius * 0.62) * pulse; + drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" }); + } + + ctx.save(); + ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY); + ctx.rotate(angle); + + if (this.world.selected === this) { + ctx.save(); + ctx.globalAlpha = 0.85; + ctx.strokeStyle = "rgba(165, 220, 98, 0.92)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([5, 4]); + ctx.lineWidth = 1.2; + ctx.strokeStyle = "rgba(255,255,255,0.86)"; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { + ctx.save(); + ctx.globalAlpha = 0.28; + ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + if (this.eatTimer > 0.02) { + ctx.save(); + ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); + ctx.strokeStyle = this.lastMealColor || "#f7cf67"; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + if (this.pokeFlashTimer > 0.03) { + ctx.save(); + ctx.globalAlpha = this.pokeFlashTimer * 0.9; + ctx.fillStyle = "rgba(255, 120, 120, 0.22)"; + ctx.beginPath(); + ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { + ctx.save(); + ctx.globalAlpha = this.state === "cursor_friend" ? 0.82 : 0.34; + ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); + for (let i = 0; i < 2; i++) { + const px = Math.cos(t * 2.2 + this.age + i * 2.4) * drawW * 0.34; + const py = -drawH * 0.24 + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * 0.11; + if (this.state === "cursor_friend") { + const s = Math.max(5, drawW * 0.075); + drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 }); + } else { + ctx.beginPath(); + ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + } + + if (faceRight) ctx.scale(-1, 1); + const dw = drawW * squish; + const dh = drawH / squish; + const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17); + const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14); + const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0; + const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); + const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); + const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; + ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); + this.zunchiStain = 0; + drawDawnRimLight(ctx, dw, dh, lightState); + if (faceRight) ctx.scale(-1, 1); + + if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) { + ctx.save(); + ctx.globalAlpha = 0.80; + ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(42,36,29,0.72)"; + const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?"); + ctx.fillText(mark, 0, -drawH * 0.72); + ctx.restore(); + } + + ctx.restore(); + + if ((this.hpBarTimer || 0) > 0) { + const bw = Math.max(28, this.radius * 2.35); + const bh = 5; + const x = this.x - bw / 2; + const y = this.y - drawH * 0.55 - 16; + const pct = clamp(this.energy / 100, 0, 1); + ctx.save(); + ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1); + ctx.fillStyle = "rgba(255,252,242,0.86)"; + roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); + ctx.fill(); + ctx.fillStyle = "rgba(72,58,45,0.22)"; + roundedRect(ctx, x, y, bw, bh, 4); + ctx.fill(); + ctx.fillStyle = pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"); + roundedRect(ctx, x, y, bw * pct, bh, 4); + ctx.fill(); + ctx.restore(); + } + } + + contains(x, y) { + const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; + return distXY(this.x, this.y, x, y) < hit; + } +} + diff --git a/js/ui.js b/js/ui.js index bd44396..1f0de71 100644 --- a/js/ui.js +++ b/js/ui.js @@ -20,6 +20,8 @@ const ui = { colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), + fieldDialog: document.getElementById("fieldDialog"), + fieldCancelBtn: document.getElementById("fieldCancelBtn"), }; const uiCache = { @@ -30,6 +32,7 @@ const uiCache = { archiveVersion: "", archiveRows: [], archiveScrollScheduled: false, + deferArchiveUntil: 0, lastArchiveWindow: "", chartHistory: [], chartBucketStart: -Infinity, @@ -37,7 +40,13 @@ const uiCache = { chartBucketAccum: null, lastChartSample: -Infinity, lastChartDraw: "", + lastChartDrawAt: 0, chartMode: "population", + canvasDpr: 0, + panning: false, + panLastX: 0, + panLastY: 0, + panMoved: false, }; function renderLog(logs) { @@ -142,7 +151,7 @@ function stateLabel(s) { return { idle: "\u5f85\u6a5f", seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046", - seek_bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9\u3078\u5411\u304b\u3046", + seek_bed: "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3046", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f", @@ -171,7 +180,7 @@ function reasonLabel(t) { function targetLabel(target) { if (!target) return "\u306a\u3057"; if (target.name) return target.name; - if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; + if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb"; return "\u4e0d\u660e"; } @@ -185,191 +194,6 @@ function relationFightRecord(t) { return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057"; } -function renderStats() { - const alive = world.tarinai; - const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0; - const values = { - pop: alive.length, - dead: world.deadCount, - hunger: avg(t => t.hunger), - lonely: avg(t => t.loneliness), - stress: avg(t => t.stress), - lack: avg(t => t.lack), - events: { ...(world.eventCounters || {}) }, - objects: { ...(world.itemCounts || {}) }, - }; - setTextIfChanged(ui.statPop, "pop", values.pop); - setTextIfChanged(ui.statDead, "dead", values.dead); - updateColonyHistory(values); - drawColonyChart(values); - renderSelected(); - renderArchive(); -} - -function updateColonyHistory(values) { - const t = Math.max(0, world.time || 0); - const history = uiCache.chartHistory; - const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); - const maxPoints = 7 * 12; - if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) { - history.length = 0; - uiCache.chartBucketStart = -Infinity; - uiCache.chartBucketEnd = -Infinity; - uiCache.chartBucketAccum = null; - uiCache.lastChartDraw = ""; - } - const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"]; - const objectKeys = ["grass", "zunchi"]; - const makeBucket = (start) => ({ - start, - end: start + interval, - count: 0, - sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 }, - objects: Object.fromEntries(objectKeys.map(k => [k, 0])), - eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])), - events: Object.fromEntries(eventKeys.map(k => [k, 0])), - }); - if (!uiCache.chartBucketAccum) { - const start = Math.floor(t / interval) * interval; - uiCache.chartBucketStart = start; - uiCache.chartBucketEnd = start + interval; - uiCache.chartBucketAccum = makeBucket(start); - } - const pushBucket = (bucket) => { - if (!bucket || bucket.count <= 0) return; - const row = { - t: bucket.end, - pop: bucket.sums.pop / bucket.count, - hunger: bucket.sums.hunger / bucket.count, - lonely: bucket.sums.lonely / bucket.count, - stress: bucket.sums.stress / bucket.count, - lack: bucket.sums.lack / bucket.count, - ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), - ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])), - }; - history.push(row); - if (history.length > maxPoints) history.splice(0, history.length - maxPoints); - }; - while (t >= uiCache.chartBucketEnd) { - const b = uiCache.chartBucketAccum; - for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0)); - pushBucket(b); - uiCache.chartBucketStart = uiCache.chartBucketEnd; - uiCache.chartBucketEnd = uiCache.chartBucketStart + interval; - uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart); - } - const bucket = uiCache.chartBucketAccum; - bucket.count += 1; - bucket.sums.pop += values.pop || 0; - bucket.sums.hunger += values.hunger || 0; - bucket.sums.lonely += values.lonely || 0; - bucket.sums.stress += values.stress || 0; - bucket.sums.lack += values.lack || 0; - for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0; - for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0)); - uiCache.lastChartDraw = ""; -} - -function chartRows(values) { - const bucket = uiCache.chartBucketAccum; - const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"]; - const objectKeys = ["grass", "zunchi"]; - const preview = bucket && bucket.count > 0 ? { - t: Math.max(world.time || 0, bucket.start || 0), - pop: bucket.sums.pop / bucket.count, - hunger: bucket.sums.hunger / bucket.count, - lonely: bucket.sums.lonely / bucket.count, - stress: bucket.sums.stress / bucket.count, - lack: bucket.sums.lack / bucket.count, - ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), - ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])), - } : null; - return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }]; -} - -function drawColonyChart(values) { - const chart = ui.colonyChart; - if (!chart) return; - const rows = chartRows(values); - const mode = uiCache.chartMode || "population"; - const snapshot = `${mode}:${rows.length}:${Math.round((rows[rows.length - 1]?.pop || values.pop) * 10)}:${JSON.stringify(values.events || {})}:${JSON.stringify(values.objects || {})}:${chart.clientWidth}x${chart.clientHeight}`; - if (uiCache.lastChartDraw === snapshot) return; - uiCache.lastChartDraw = snapshot; - const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320)); - const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188)); - if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH); - const pad = { l: 30, r: 12, t: 12, b: 24 }; - const w = cssW - pad.l - pad.r; - const h = cssH - pad.t - pad.b; - const series = chartSeries(mode); - const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0)))); - const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w; - const yFor = (row, s) => { - const raw = Number(row[s.key] || 0); - const scale = s.percent ? 100 : maxVal; - const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100); - return pad.t + h - h * v / 100; - }; - const grid = [0, 25, 50, 75, 100].map(yv => { - const y = pad.t + h - h * yv / 100; - return `${yv}`; - }).join(""); - const lines = series.map(s => { - const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" "); - const last = rows[rows.length - 1]; - const cx = xFor(rows.length - 1).toFixed(1); - const cy = yFor(last, s).toFixed(1); - return ``; - }).join(""); - chart.innerHTML = ``; - if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}`).join(""); -} - -function drawEventsChart(chart, rows, cssW, cssH) { - const series = chartSeries("events"); - const weekRows = rows.slice(-7 * 12); - const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) })); - const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0)); - const pad = { l: 68, r: 16, t: 12, b: 16 }; - const barW = cssW - pad.l - pad.r; - const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length); - const bars = counts.map((s, i) => { - const y = pad.t + i * rowH + 4; - const pct = s.value / total; - const bw = Math.max(2, barW * pct); - return `${s.label}${s.value}\u4ef6 / ${Math.round(pct * 100)}%`; - }).join(""); - chart.innerHTML = ``; - if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570`; -} - -function chartSeries(mode = "population") { - if (mode === "life") return [ - { key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true }, - { key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true }, - { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true }, - { key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true }, - ]; - if (mode === "objects") return [ - { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, - { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, - ]; - if (mode === "events") return [ - { key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" }, - { key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" }, - { key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" }, - { key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" }, - { key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" }, - { key: "ev_grass", label: "\u8349", color: "#489443" }, - ]; - return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; -} - -function formatChartValue(key, values) { - const v = values[key] ?? 0; - return String(Math.round(v)); -} - function setTextIfChanged(el, key, value) { if (!el) return; const next = String(value); @@ -394,260 +218,78 @@ function escapeHtml(value) { "'": "'" }[c])); } - -function buildArchiveRows(family) { - return liveFamilyComponents(family); -} - -function liveFamilyComponents(family) { - const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); - const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(); - const addEdge = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - if (!graph.has(a)) graph.set(a, new Set()); - if (!graph.has(b)) graph.set(b, new Set()); - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - if (!graph.has(n.id)) graph.set(n.id, new Set()); - for (const p of n.parents || []) addEdge(n.id, p); - for (const c of n.children || []) addEdge(n.id, c); - } - const seen = new Set(); - const comps = []; - for (const n of nodes) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - const item = family[id]; - if (item) comp.push(item); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (comp.some(x => x.alive)) comps.push(comp); - } - comps.sort((a, b) => { - const ag = Math.min(...a.map(n => n.generation || 1)); - const bg = Math.min(...b.map(n => n.generation || 1)); - const at = Math.min(...a.map(n => n.birthTime || 0)); - const bt = Math.min(...b.map(n => n.birthTime || 0)); - return ag - bg || at - bt || b.length - a.length; - }); - return comps; -} - -function familyStatusText(n) { - if (!n) return "\u4e0d\u660e"; - return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; -} - -function lineageNodeHtml(n, family, x = 0, y = 0) { - const live = n?.id ? world.tarinai.find(t => t.id === n.id && !t.dead) : null; - const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; - const name = escapeHtml(n?.name || "\u4e0d\u660e"); - const joined = Boolean(n?.hasPaired || (n?.parents || []).length || (n?.children || []).length); - const gen = joined && n?.generation ? `\u4e16\u4ee3 ${n.generation}` : ""; - const status = escapeHtml(familyStatusText(n)); - const reason = !n?.alive && n?.deathReason ? `\u7406\u7531: ${escapeHtml(n.deathReason)}` : ""; - const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; - const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; - return `
- -
- ${name} - ${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}${reason} -
-
`; -} - -function lineageFamilyHtml(component, index, family) { - const byGen = new Map(); - for (const n of component) { - const g = n.generation || 1; - if (!byGen.has(g)) byGen.set(g, []); - byGen.get(g).push(n); - } - const gens = Array.from(byGen.keys()).sort((a, b) => a - b); - const nodeW = 154; - const nodeH = 76; - const colGap = 34; - const rowGap = 82; - const padX = 72; - const padY = 18; - const maxCount = Math.max(1, ...gens.map(g => byGen.get(g).length)); - const width = Math.max(460, padX * 2 + maxCount * nodeW + Math.max(0, maxCount - 1) * colGap + 120); - const rowH = nodeH + rowGap; - const height = Math.max(190, padY * 2 + gens.length * nodeH + Math.max(0, gens.length - 1) * rowGap + 34); - const pos = new Map(); - const nodesHtml = []; - const genLabels = []; - const rowIds = new Map(); - - const parentCenterFor = (nodes) => { - const centers = []; - for (const n of nodes) { - for (const p of n.parents || []) { - const pp = pos.get(p); - if (pp) centers.push(pp.x + nodeW / 2); - } - } - return centers.length ? centers.reduce((a, b) => a + b, 0) / centers.length : null; - }; - - gens.forEach((g, row) => { - const source = byGen.get(g).sort((a, b) => (a.birthTime || 0) - (b.birthTime || 0) || String(a.name).localeCompare(String(b.name))); - const y = padY + row * rowH; - genLabels.push(`
\u4e16\u4ee3 ${g}
`); - let ordered = []; - if (row === 0) { - ordered = source.map(n => ({ nodes: [n], desired: null })); - } else { - const groups = new Map(); - for (const n of source) { - const key = (n.parents || []).slice().sort().join("+") || `solo:${n.id}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(n); - } - ordered = Array.from(groups.values()).map(nodes => ({ nodes, desired: parentCenterFor(nodes) })); - ordered.sort((a, b) => (a.desired ?? 1e9) - (b.desired ?? 1e9) || (a.nodes[0].birthTime || 0) - (b.nodes[0].birthTime || 0)); - } - const groupsW = ordered.map(gp => gp.nodes.length * nodeW + Math.max(0, gp.nodes.length - 1) * colGap); - let x = padX; - if (ordered.length && ordered.every(gp => gp.desired == null)) { - const totalW = groupsW.reduce((a, b) => a + b, 0) + Math.max(0, groupsW.length - 1) * colGap; - x = Math.max(padX, (width - totalW) / 2); - } - ordered.forEach((gp, gi) => { - const groupW = groupsW[gi]; - if (gp.desired != null) x = Math.max(x, gp.desired - groupW / 2); - if (x + groupW > width - padX) x = Math.max(padX, width - padX - groupW); - gp.nodes.forEach((n, ci) => { - const nx = x + ci * (nodeW + colGap); - pos.set(n.id, { x: nx, y, row }); - if (!rowIds.has(row)) rowIds.set(row, []); - rowIds.get(row).push(n.id); - }); - x += groupW + colGap; - }); - }); - - const allIds = Array.from(rowIds.values()).flat(); - const allXs = allIds.map(id => pos.get(id)?.x).filter(v => Number.isFinite(v)); - if (allXs.length) { - const treeMin = Math.min(...allXs); - const treeMax = Math.max(...allIds.map(id => (pos.get(id)?.x || 0) + nodeW)); - const shift = width / 2 - (treeMin + treeMax) / 2; - for (const id of allIds) { - const p = pos.get(id); - if (p) p.x += shift; - } - } - for (const g of gens) { - const row = gens.indexOf(g); - const ids = rowIds.get(row) || []; - for (const id of ids) { - const n = family[id]; - const p = pos.get(id); - if (!n || !p) continue; - nodesHtml.push(lineageNodeHtml(n, family, p.x, p.y)); - } - } - - const links = []; - const pairGroups = new Map(); - for (const child of component) { - const parents = (child.parents || []).filter(id => pos.has(id)); - if (!parents.length || !pos.has(child.id)) continue; - const key = parents.slice().sort().join("+"); - if (!pairGroups.has(key)) pairGroups.set(key, { parents, children: [] }); - pairGroups.get(key).children.push(child.id); - } - for (const group of pairGroups.values()) { - const parentCenters = group.parents.map(id => { - const p = pos.get(id); - return { id, x: p.x + nodeW / 2, y: p.y + nodeH }; - }).sort((a, b) => a.x - b.x); - const childCenters = group.children.map(id => { - const p = pos.get(id); - return { id, x: p.x + nodeW / 2, y: p.y }; - }).sort((a, b) => a.x - b.x); - if (!parentCenters.length || !childCenters.length) continue; - const pMinX = Math.min(...parentCenters.map(p => p.x)); - const pMaxX = Math.max(...parentCenters.map(p => p.x)); - const pBottom = Math.max(...parentCenters.map(p => p.y)); - const cTop = Math.min(...childCenters.map(c => c.y)); - const mY = Math.min(cTop - 28, pBottom + 22); - const trunkX = (pMinX + pMaxX) / 2; - const childRailY = cTop - 18; - if (parentCenters.length >= 2) { - links.push(``); - for (const p of parentCenters) links.push(``); - } else { - links.push(``); - } - links.push(``); - if (childCenters.length > 1) { - const cMinX = Math.min(...childCenters.map(c => c.x)); - const cMaxX = Math.max(...childCenters.map(c => c.x)); - links.push(``); - } - for (const c of childCenters) links.push(``); - } - const aliveCount = component.filter(n => n.alive).length; - return `

\u5bb6\u7cfb ${index + 1} / ${component.length}\u5339 / \u751f\u5b58 ${aliveCount}

${genLabels.join("")}${nodesHtml.join("")}
`; -} - -function renderArchive() { - if (!ui.archiveContent) return; - const family = world.family || {}; - const ids = Object.keys(family); - const liveSpriteVersion = world.tarinai.map(t => `${t.id}:${t.spriteId ? t.spriteId() : t.type}`).join(","); - const version = `${world.familyVersion || 0}:${ids.length}:${liveSpriteVersion}`; - if (uiCache.archiveVersion === version) return; - uiCache.archiveVersion = version; - uiCache.lastArchiveWindow = ""; - const components = liveFamilyComponents(family); - uiCache.archiveRows = components; - if (!components.length) { - ui.archiveContent.innerHTML = `
\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
`; - return; - } - const shown = components.reduce((a, c) => a + c.length, 0); - const note = `
${components.length} \u4ef6\u306e\u5bb6\u7cfb / ${shown} \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002
`; - ui.archiveContent.innerHTML = `${note}
${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}
`; -} - -function renderArchiveWindow() { - renderArchive(); -} - -function scheduleArchiveWindowRender() { - renderArchive(); -} - function resizeCanvas() { const rect = canvas.getBoundingClientRect(); - const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); - canvas.width = Math.floor(rect.width * dpr); - canvas.height = Math.floor(rect.height * dpr); + const perf = world?.performanceLevel ? world.performanceLevel() : 0; + const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2); + const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr)); + const nextW = Math.floor(rect.width * dpr); + const nextH = Math.floor(rect.height * dpr); + if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return; + uiCache.canvasDpr = dpr; + canvas.width = nextW; + canvas.height = nextH; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - world.w = rect.width; - world.h = rect.height; + world.setViewportSize(rect.width, rect.height); uiCache.lastChartDraw = ""; } +function applyFieldLayout(fieldType = world.fieldType || "garden") { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + const root = document.documentElement; + root.style.setProperty("--field-height", field.height); + root.style.setProperty("--field-min-height", field.minHeight || field.height); + root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height); + root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px"); + world.fieldType = field.id; + const rect = canvas.getBoundingClientRect(); + world.setViewportSize(rect.width, rect.height); + resizeCanvas(); +} + +function openFieldDialog() { + ui.fieldDialog?.classList.remove("hidden"); +} + +function closeFieldDialog() { + ui.fieldDialog?.classList.add("hidden"); +} + +function resetWithField(fieldType) { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + closeFieldDialog(); + applyFieldLayout(field.id); + world.reset(null, field.id); + uiCache.archiveVersion = ""; + renderLog(world.logs); + renderArchive(); + renderStats(); + render(); +} + function screenToWorld(e) { const rect = canvas.getBoundingClientRect(); const touch = e.touches ? e.touches[0] : e; - return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; + const sx = touch.clientX - rect.left; + const sy = touch.clientY - rect.top; + return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; +} + +function resizeFieldFromWheel(e) { + if (!world.setFieldZoom) return; + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; + const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08; + if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const scale = world.viewScale ? world.viewScale() : 1; + world.cameraX = focus.x - (sx - off.x) / scale; + world.cameraY = focus.y - (sy - off.y) / scale; + world.clampCamera?.(); + render(); } function selectTool(tool) { @@ -675,7 +317,19 @@ function loadGame() { const raw = localStorage.getItem(CONFIG.saveKey); if (!raw) { showToast("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } try { + const t0 = performance.now(); world.load(JSON.parse(raw)); + applyFieldLayout(world.fieldType || "garden"); + uiCache.archiveVersion = ""; + uiCache.deferArchiveUntil = performance.now() + 80; + if (ui.archiveContent) ui.archiveContent.innerHTML = `
\u5bb6\u7cfb\u56f3\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002
`; + renderSelected(); + renderStats(); + requestAnimationFrame(() => { + uiCache.deferArchiveUntil = 0; + renderArchive(); + }); + if (typeof console !== "undefined") console.debug?.(`loaded save in ${Math.round(performance.now() - t0)}ms`); showToast("\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); } catch (e) { console.error(e); @@ -741,6 +395,7 @@ function bindUI() { uiCache.chartMode = btn.dataset.chart || "population"; for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn); uiCache.lastChartDraw = ""; + uiCache.lastChartDrawAt = 0; renderStats(); }); syncToolSizeBadges(); @@ -775,7 +430,14 @@ function bindUI() { ui.saveBtn.addEventListener("click", saveGame); ui.loadBtn.addEventListener("click", loadGame); ui.resetBtn.addEventListener("click", () => { - if (confirm("\u73fe\u5728\u306e\u89b3\u5bdf\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3059\u304b\uff1f \u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306f\u6b8b\u308a\u307e\u3059\u3002")) world.reset(); + openFieldDialog(); + }); + ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog); + ui.fieldDialog?.addEventListener("click", (e) => { + if (e.target === ui.fieldDialog) { closeFieldDialog(); return; } + const btn = e.target.closest("[data-field-type]"); + if (!btn) return; + resetWithField(btn.dataset.fieldType || "garden"); }); ui.toolPalette.addEventListener("click", (e) => { @@ -794,8 +456,59 @@ function bindUI() { selectTool(tool); }); + const chainScroll = (el) => { + if (!el) return; + el.addEventListener("wheel", (e) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const page = document.scrollingElement || document.documentElement; + page.scrollTop += e.deltaY; + } + }, { passive: true }); + }; + chainScroll(document.querySelector(".panel")); + chainScroll(ui.archiveContent); + chainScroll(ui.log); + + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + canvas.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + e.preventDefault(); + uiCache.panning = true; + uiCache.panMoved = false; + uiCache.panLastX = e.clientX; + uiCache.panLastY = e.clientY; + canvas.style.cursor = "grabbing"; + }); + window.addEventListener("mouseup", (e) => { + if (e.button !== 2 && !uiCache.panning) return; + if (uiCache.panning) { + uiCache.panning = false; + canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"); + } + }); + window.addEventListener("mousemove", (e) => { + if (!uiCache.panning) return; + const dx = e.clientX - uiCache.panLastX; + const dy = e.clientY - uiCache.panLastY; + uiCache.panLastX = e.clientX; + uiCache.panLastY = e.clientY; + if (Math.abs(dx) + Math.abs(dy) > 0.5) { + world.panCamera?.(-dx, -dy); + uiCache.panMoved = true; + render(); + } + }); + canvas.addEventListener("click", (e) => { + if (uiCache.panMoved) { + uiCache.panMoved = false; + return; + } const p = screenToWorld(e); + if (!p.inside) return; world.handleClick(p.x, p.y); renderStats(); }); @@ -804,13 +517,12 @@ function bindUI() { const p = screenToWorld(e); world.pointer.x = p.x; world.pointer.y = p.y; - world.pointer.inside = true; + world.pointer.inside = !!p.inside; }); + canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); canvas.addEventListener("mouseleave", () => { world.pointer.inside = false; }); window.addEventListener("resize", resizeCanvas); } - -window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, resizeCanvas, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI }; diff --git a/js/ui_charts.js b/js/ui_charts.js new file mode 100644 index 0000000..df3ffe4 --- /dev/null +++ b/js/ui_charts.js @@ -0,0 +1,193 @@ +"use strict"; + +function renderStats() { + const alive = world.tarinai; + const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0; + const values = { + pop: alive.length, + dead: world.deadCount, + hunger: avg(t => t.hunger), + lonely: avg(t => t.loneliness), + stress: avg(t => t.stress), + lack: avg(t => t.lack), + events: { ...(world.eventCounters || {}) }, + objects: { ...(world.itemCounts || {}) }, + }; + setTextIfChanged(ui.statPop, "pop", values.pop); + setTextIfChanged(ui.statDead, "dead", values.dead); + updateColonyHistory(values); + drawColonyChart(values); + renderSelected(); + renderArchive(); +} + +function updateColonyHistory(values) { + const t = Math.max(0, world.time || 0); + const history = uiCache.chartHistory; + const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); + const maxPoints = 7 * 12; + if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) { + history.length = 0; + uiCache.chartBucketStart = -Infinity; + uiCache.chartBucketEnd = -Infinity; + uiCache.chartBucketAccum = null; + uiCache.lastChartDraw = ""; + } + const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"]; + const objectKeys = ["grass", "zunchi"]; + const makeBucket = (start) => ({ + start, + end: start + interval, + count: 0, + sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 }, + objects: Object.fromEntries(objectKeys.map(k => [k, 0])), + eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])), + events: Object.fromEntries(eventKeys.map(k => [k, 0])), + }); + if (!uiCache.chartBucketAccum) { + const start = Math.floor(t / interval) * interval; + uiCache.chartBucketStart = start; + uiCache.chartBucketEnd = start + interval; + uiCache.chartBucketAccum = makeBucket(start); + } + const pushBucket = (bucket) => { + if (!bucket || bucket.count <= 0) return; + const row = { + t: bucket.end, + pop: bucket.sums.pop / bucket.count, + hunger: bucket.sums.hunger / bucket.count, + lonely: bucket.sums.lonely / bucket.count, + stress: bucket.sums.stress / bucket.count, + lack: bucket.sums.lack / bucket.count, + ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), + ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])), + }; + history.push(row); + if (history.length > maxPoints) history.splice(0, history.length - maxPoints); + }; + while (t >= uiCache.chartBucketEnd) { + const b = uiCache.chartBucketAccum; + for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0)); + pushBucket(b); + uiCache.chartBucketStart = uiCache.chartBucketEnd; + uiCache.chartBucketEnd = uiCache.chartBucketStart + interval; + uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart); + } + const bucket = uiCache.chartBucketAccum; + bucket.count += 1; + bucket.sums.pop += values.pop || 0; + bucket.sums.hunger += values.hunger || 0; + bucket.sums.lonely += values.lonely || 0; + bucket.sums.stress += values.stress || 0; + bucket.sums.lack += values.lack || 0; + for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0; + for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0)); +} + +function chartRows(values) { + const bucket = uiCache.chartBucketAccum; + const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"]; + const objectKeys = ["grass", "zunchi"]; + const preview = bucket && bucket.count > 0 ? { + t: Math.max(world.time || 0, bucket.start || 0), + pop: bucket.sums.pop / bucket.count, + hunger: bucket.sums.hunger / bucket.count, + lonely: bucket.sums.lonely / bucket.count, + stress: bucket.sums.stress / bucket.count, + lack: bucket.sums.lack / bucket.count, + ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), + ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])), + } : null; + return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }]; +} + +function drawColonyChart(values) { + const chart = ui.colonyChart; + if (!chart) return; + const now = performance.now(); + const perf = world.performanceLevel ? world.performanceLevel() : 0; + const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0; + if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return; + const rows = chartRows(values); + const mode = uiCache.chartMode || "population"; + const last = rows[rows.length - 1] || values; + const series = chartSeries(mode); + const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(","); + const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`; + if (uiCache.lastChartDraw === snapshot) return; + uiCache.lastChartDraw = snapshot; + uiCache.lastChartDrawAt = now; + const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320)); + const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188)); + if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH); + const pad = { l: 30, r: 12, t: 12, b: 24 }; + const w = cssW - pad.l - pad.r; + const h = cssH - pad.t - pad.b; + const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0)))); + const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w; + const yFor = (row, s) => { + const raw = Number(row[s.key] || 0); + const scale = s.percent ? 100 : maxVal; + const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100); + return pad.t + h - h * v / 100; + }; + const grid = [0, 25, 50, 75, 100].map(yv => { + const y = pad.t + h - h * yv / 100; + return `${yv}`; + }).join(""); + const lines = series.map(s => { + const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" "); + const last = rows[rows.length - 1]; + const cx = xFor(rows.length - 1).toFixed(1); + const cy = yFor(last, s).toFixed(1); + return ``; + }).join(""); + chart.innerHTML = ``; + if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}`).join(""); +} + +function drawEventsChart(chart, rows, cssW, cssH) { + const series = chartSeries("events"); + const weekRows = rows.slice(-7 * 12); + const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) })); + const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0)); + const pad = { l: 68, r: 16, t: 12, b: 16 }; + const barW = cssW - pad.l - pad.r; + const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length); + const bars = counts.map((s, i) => { + const y = pad.t + i * rowH + 4; + const pct = s.value / total; + const bw = Math.max(2, barW * pct); + return `${s.label}${s.value}\u4ef6 / ${Math.round(pct * 100)}%`; + }).join(""); + chart.innerHTML = ``; + if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570`; +} + +function chartSeries(mode = "population") { + if (mode === "life") return [ + { key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true }, + { key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true }, + { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true }, + { key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true }, + ]; + if (mode === "objects") return [ + { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, + { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, + ]; + if (mode === "events") return [ + { key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" }, + { key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" }, + { key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" }, + { key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" }, + { key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" }, + { key: "ev_grass", label: "\u8349", color: "#489443" }, + ]; + return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; +} + +function formatChartValue(key, values) { + const v = values[key] ?? 0; + return String(Math.round(v)); +} + diff --git a/js/ui_exports.js b/js/ui_exports.js new file mode 100644 index 0000000..9a80ef9 --- /dev/null +++ b/js/ui_exports.js @@ -0,0 +1,3 @@ +"use strict"; + +window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI }; diff --git a/js/ui_family.js b/js/ui_family.js new file mode 100644 index 0000000..9a1dcff --- /dev/null +++ b/js/ui_family.js @@ -0,0 +1,187 @@ +"use strict"; + +function buildArchiveRows(family) { + return liveFamilyComponents(family); +} + +function liveFamilyComponents(family) { + const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); + const ids = new Set(nodes.map(n => n.id)); + const graph = new Map(); + const addEdge = (a, b) => { + if (!a || !b || !ids.has(a) || !ids.has(b)) return; + if (!graph.has(a)) graph.set(a, new Set()); + if (!graph.has(b)) graph.set(b, new Set()); + graph.get(a).add(b); + graph.get(b).add(a); + }; + for (const n of nodes) { + if (!graph.has(n.id)) graph.set(n.id, new Set()); + for (const p of n.parents || []) addEdge(n.id, p); + for (const c of n.children || []) addEdge(n.id, c); + } + const seen = new Set(); + const comps = []; + for (const n of nodes) { + if (seen.has(n.id)) continue; + const stack = [n.id]; + const comp = []; + seen.add(n.id); + while (stack.length) { + const id = stack.pop(); + const item = family[id]; + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (comp.some(x => x.alive)) comps.push(comp); + } + comps.sort((a, b) => { + const ag = Math.min(...a.map(n => n.generation || 1)); + const bg = Math.min(...b.map(n => n.generation || 1)); + const at = Math.min(...a.map(n => n.birthTime || 0)); + const bt = Math.min(...b.map(n => n.birthTime || 0)); + return ag - bg || at - bt || b.length - a.length; + }); + return comps; +} + +function familyStatusText(n) { + if (!n) return "\u4e0d\u660e"; + return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; +} + +function lineagePersonHtml(n) { + const live = n?.id ? world.tarinai.find(t => t.id === n.id && !t.dead) : null; + const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; + const name = escapeHtml(n?.name || "\u4e0d\u660e"); + const joined = Boolean(n?.hasPaired || (n?.parents || []).length || (n?.children || []).length); + const gen = joined && n?.generation ? `\u4e16\u4ee3 ${n.generation}` : ""; + const status = escapeHtml(familyStatusText(n)); + const reason = !n?.alive && n?.deathReason ? `\u7406\u7531: ${escapeHtml(n.deathReason)}` : ""; + const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; + const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; + return `
+ +
+ ${name} + ${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}${reason} +
+
`; +} + +function lineageMiniHtml(n, role = "") { + if (!n) return `\u4e0d\u660e`; + const img = tarinaiSpritePathForId(n.id); + const name = escapeHtml(n.name || n.id); + const gen = n.generation ? `G${n.generation}` : ""; + const status = n.alive ? "\u751f" : "\u6b7b"; + return ` + + ${name} + ${gen} / ${status} + `; +} + +function lineageFamilyHtml(component, index, family) { + const nodeSort = (a, b) => + (a.generation || 1) - (b.generation || 1) || + (a.birthTime || 0) - (b.birthTime || 0) || + String(a.name || "").localeCompare(String(b.name || "")) || + String(a.id || "").localeCompare(String(b.id || "")); + const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(nodeSort); + const byGeneration = new Map(); + for (const n of uniqueComponent) { + const key = n.generation || 1; + if (!byGeneration.has(key)) byGeneration.set(key, []); + byGeneration.get(key).push(n); + } + const generationBlocks = Array.from(byGeneration.entries()).sort((a, b) => a[0] - b[0]).map(([generation, nodes]) => ` +
+ \u4e16\u4ee3 ${generation} +
${nodes.sort(nodeSort).map(lineagePersonHtml).join("")}
+
+ `).join(""); + + const pairMap = new Map(); + for (const child of uniqueComponent) { + const parents = (child.parents || []).filter(id => family[id]); + if (!parents.length) continue; + const key = parents.slice().sort().join("+"); + if (!pairMap.has(key)) pairMap.set(key, { parents: parents.slice().sort(), children: [] }); + pairMap.get(key).children.push(child.id); + } + const pairs = Array.from(pairMap.values()).sort((a, b) => { + const achild = family[a.children[0]]; + const bchild = family[b.children[0]]; + return nodeSort(achild, bchild); + }); + const pairCards = pairs.length ? pairs.map(pair => { + const parents = pair.parents.map(id => family[id]).filter(Boolean).sort(nodeSort); + const children = pair.children.map(id => family[id]).filter(Boolean).sort(nodeSort); + return `
+
${parents.map(n => lineageMiniHtml(n, "parent")).join('+')}
+
\u2193
+
${children.map(n => lineageMiniHtml(n, "child")).join("")}
+
`; + }).join("") : `
\u307e\u3060\u89aa\u5b50\u95a2\u4fc2\u306f\u8a18\u9332\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002
`; + const aliveCount = uniqueComponent.filter(n => n.alive).length; + return `
+

\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

+
+
${generationBlocks}
+
+
\u7e41\u6b96\u30da\u30a2
+
${pairCards}
+
+
+
`; +} + +function renderArchive() { + if (!ui.archiveContent) return; + if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; + uiCache.deferArchiveUntil = 0; + if (world.normalizeFamily) world.normalizeFamily(); + const family = world.family || {}; + const ids = Object.keys(family); + const version = `${world.familyVersion || 0}:${ids.length}`; + if (uiCache.archiveVersion === version) return; + uiCache.archiveVersion = version; + uiCache.lastArchiveWindow = ""; + const components = liveFamilyComponents(family); + uiCache.archiveRows = components; + if (!components.length) { + ui.archiveContent.innerHTML = `
\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
`; + return; + } + const shown = components.reduce((a, c) => a + c.length, 0); + const note = `
${components.length} \u4ef6\u306e\u5bb6\u7cfb / ${shown} \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002
`; + ui.archiveContent.innerHTML = `${note}
${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}
`; +} + +function validateFamilyTree() { + const issues = world.validateFamily ? [...world.validateFamily()] : []; + const rendered = new Map(); + document.querySelectorAll("[data-family-tarinai-id]").forEach(node => { + const id = node.getAttribute("data-family-tarinai-id"); + rendered.set(id, (rendered.get(id) || 0) + 1); + }); + for (const [id, count] of rendered.entries()) { + if (count > 1) issues.push({ type: "duplicate-render-node", id, count }); + } + return issues; +} + +function renderArchiveWindow() { + renderArchive(); +} + +function scheduleArchiveWindowRender() { + renderArchive(); +} + +window.validateFamilyTree = validateFamilyTree; diff --git a/js/world.js b/js/world.js new file mode 100644 index 0000000..9bf36e1 --- /dev/null +++ b/js/world.js @@ -0,0 +1,1525 @@ +"use strict"; + +class World { + constructor() { + this.w = 1000; + this.h = 720; + this.viewportW = 1000; + this.viewportH = 720; + this.time = 0; + this.day = 1; + this.paused = false; + this.speed = 1; + this.tool = "observe"; + this.toolSize = "medium"; + this.toolSizes = {}; + this.tarinai = []; + this.items = []; + this.effects = []; + this.logs = []; + this.eventCounters = {}; + this.countsTimer = 0; + this.compactTimer = 0; + this.drawSortTimer = 0; + this.drawListDirty = true; + this.selected = null; + this.deadCount = 0; + this.maxGeneration = 1; + this.family = {}; + this.familyVersion = 0; + this.relationNotices = {}; + this.resolvedFightIds = {}; + this.pointer = { x: 0, y: 0, inside: false }; + this.lastPhase = ""; + this.weather = "sunny"; + this.fieldType = "garden"; + this.fieldZoom = 1; + this.cameraX = 0; + this.cameraY = 0; + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + this.spatial = new SpatialGrid(96); + this.spatialScratch = []; + this.drawList = []; + this.itemCounts = {}; + this.effectCounts = {}; + this.countsTimer = 0; + this.compactTimer = 0; + this.drawSortTimer = 0; + this.drawListDirty = true; + this.updateItemCounts(); + this.updateEffectCounts(); + this.rebuildSpatial(); + } + + dayProgress() { + return (this.time % CONFIG.dayLength) / CONFIG.dayLength; + } + + lightLevel() { + return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2; + } + + phaseName() { + const p = this.dayProgress(); + if (p < 0.20) return "\u671d"; + if (p < 0.48) return "\u663c"; + if (p < 0.68) return "\u5915\u65b9"; + return "\u591c"; + } + + clockString() { + const totalMinutes = Math.floor(this.dayProgress() * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${hh}:${mm}`; + } + + fieldDefinition() { + return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden; + } + + fieldWorldScale() { + const field = this.fieldDefinition(); + // Field type defines the logical simulation area. Wheel zoom must not + // rewrite this size; it is a camera/display transform only. + return Math.max(0.22, field.worldScale || 1); + } + + viewScale() { + return clamp(Number(this.fieldZoom) || 1, 0.42, 2.45); + } + + visibleWorldW() { + return (this.viewportW || this.w) / this.viewScale(); + } + + visibleWorldH() { + return (this.viewportH || this.h) / this.viewScale(); + } + + screenSizeToWorld(size) { + // Kept as a compatibility shim for older hit-test call sites. The values + // currently passed here are already logical world units, not CSS pixels. + return size; + } + + fieldScreenOffset() { + const scale = this.viewScale(); + return { + x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2), + y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2), + }; + } + + maxCameraX() { + return Math.max(0, this.w - this.visibleWorldW()); + } + + maxCameraY() { + return Math.max(0, this.h - this.visibleWorldH()); + } + + clampCamera() { + this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX()); + this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY()); + } + + panCamera(dx, dy) { + const scale = this.viewScale(); + this.cameraX = (this.cameraX || 0) + dx / scale; + this.cameraY = (this.cameraY || 0) + dy / scale; + this.clampCamera(); + } + + screenToWorld(x, y) { + const scale = this.viewScale(); + const off = this.fieldScreenOffset(); + const rawX = (x - off.x) / scale + (this.cameraX || 0); + const rawY = (y - off.y) / scale + (this.cameraY || 0); + return { + x: clamp(rawX, 0, this.w), + y: clamp(rawY, 0, this.h), + inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h, + }; + } + + worldToScreen(x, y) { + const scale = this.viewScale(); + const off = this.fieldScreenOffset(); + return { + x: (x - (this.cameraX || 0)) * scale + off.x, + y: (y - (this.cameraY || 0)) * scale + off.y, + }; + } + + setViewportSize(width, height, opts = {}) { + const oldW = this.w || width; + const oldH = this.h || height; + this.viewportW = Math.max(1, width || this.viewportW || 1000); + this.viewportH = Math.max(1, height || this.viewportH || 720); + const requestedScale = this.fieldWorldScale(); + const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH); + this.w = this.viewportW * scale; + this.h = this.viewportH * scale; + this.clampCamera(); + if (opts.scaleContents && oldW > 0 && oldH > 0) { + const sx = this.w / oldW; + const sy = this.h / oldH; + const scalePoint = (p) => { + if (!p) return; + if (Number.isFinite(p.x)) p.x *= sx; + if (Number.isFinite(p.y)) p.y *= sy; + }; + for (const t of this.tarinai || []) scalePoint(t); + for (const it of this.items || []) scalePoint(it); + for (const ef of this.effects || []) scalePoint(ef); + scalePoint(this.pointer); + this.cameraX *= sx; + this.cameraY *= sy; + this.clampCamera(); + this.rebuildSpatial(); + this.drawListDirty = true; + } + } + + setFieldZoom(nextZoom) { + const next = clamp(Number(nextZoom) || 1, 0.42, 2.45); + if (Math.abs(next - (this.fieldZoom || 1)) < 0.001) return false; + this.fieldZoom = next; + this.clampCamera(); + this.drawListDirty = true; + return true; + } + + edgeSpawnPoint(targetX = null, targetY = null, margin = 34) { + const w = Math.max(this.w || 1000, margin * 2 + 1); + const h = Math.max(this.h || 720, margin * 2 + 1); + let side; + if (Number.isFinite(targetX) && Number.isFinite(targetY)) { + const distances = [targetY, w - targetX, h - targetY, targetX]; + side = distances.indexOf(Math.min(...distances)); + } else { + side = Math.floor(Math.random() * 4); + } + if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 }; + if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI }; + if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 }; + return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 }; + } + + toolSizeFor(type = "") { + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium"; + return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; + } + + toolSizeScale(type = "") { + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1; + return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; + } + + applyToolSize(item) { + if (!item) return item; + const scale = this.toolSizeScale(item.type); + const size = this.toolSizeFor(item.type); + item.toolSize = size; + this.toolSize = size; + item.r *= scale; + if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale; + if (item.type === "firecracker") item.blastScale = scale; + return item; + } + + reset(seedPopulation = null, fieldType = this.fieldType || "garden") { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + this.fieldType = field.id; + this.fieldZoom = 1; + this.setViewportSize(this.viewportW, this.viewportH); + this.time = 0; + this.day = 1; + this.paused = false; + this.speed = 1; + this.toolSize = this.toolSize || "medium"; + this.toolSizes = this.toolSizes || {}; + this.tarinai = []; + this.items = []; + this.effects = []; + this.logs = []; + this.eventCounters = {}; + this.countsTimer = 0; + this.compactTimer = 0; + this.drawSortTimer = 0; + this.drawListDirty = true; + this.selected = null; + this.deadCount = 0; + this.maxGeneration = 1; + this.family = {}; + this.familyVersion = 0; + this.relationNotices = {}; + this.resolvedFightIds = {}; + this.pointer = { x: this.w / 2, y: this.h / 2, inside: false }; + this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2); + this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2); + this.clampCamera(); + this.lastPhase = this.phaseName(); + this.weather = "sunny"; + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation; + const grassCount = field.grass ?? 18; + const types = spawnableSprites().map(s => s.id); + for (let i = 0; i < seedPopulation; i++) { + this.addTarinai({ + type: types[i % types.length], + x: rand(80, this.w - 80), + y: rand(80, this.h - 80), + generation: 1, + }); + } + for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); + this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); + this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22)); + this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78)); + this.updateItemCounts(); + this.updateEffectCounts(); + this.rebuildSpatial(); + this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002"); + showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002"); + syncTopButtons(); + } + + + recordFamily(t) { + if (!this.family) this.family = {}; + const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); + const sameList = (a, b) => { + const aa = uniqueIds(a); + const bb = uniqueIds(b); + return aa.length === bb.length && aa.every((id, i) => id === bb[i]); + }; + let changed = false; + const nextParents = uniqueIds(t.parents || []); + const nextChildren = uniqueIds(t.children || []); + if (!this.family[t.id]) { + this.family[t.id] = { + id: t.id, + name: t.name, + type: t.type, + generation: t.generation, + hasPaired: !!t.hasPaired, + parents: nextParents, + parentNames: [...(t.parentNames || [])], + children: nextChildren, + birthTime: t.birthTime, + alive: !t.dead, + deathReason: t.deathReason || "", + }; + changed = true; + } else { + const entry = this.family[t.id]; + const nextAlive = !t.dead; + if (entry.name !== t.name) changed = true; + if (entry.type !== t.type) changed = true; + if (entry.hasPaired !== !!t.hasPaired) changed = true; + if (entry.generation !== t.generation) changed = true; + if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; + if (entry.alive !== nextAlive) changed = true; + if (!sameList(entry.parents, nextParents)) changed = true; + if (!sameList(entry.children, nextChildren)) changed = true; + entry.name = t.name; + entry.type = t.type; + entry.alive = nextAlive; + entry.generation = t.generation; + entry.hasPaired = !!t.hasPaired; + entry.deathReason = t.deathReason || entry.deathReason || ""; + entry.parents = nextParents; + entry.parentNames = [...(t.parentNames || entry.parentNames || [])]; + entry.children = nextChildren; + } + if (changed) this.familyVersion = (this.familyVersion || 0) + 1; + } + + normalizeFamily() { + if (!this.family) this.family = {}; + if (this.familyCleanVersion === (this.familyVersion || 0)) return false; + const family = this.family; + const liveById = new Map((this.tarinai || []).map(t => [t.id, t])); + let changed = false; + const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); + const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); + for (const t of this.tarinai || []) { + const before = this.familyVersion || 0; + this.recordFamily(t); + changed = changed || before !== (this.familyVersion || 0); + } + const known = new Set(Object.keys(family)); + for (const id of Object.keys(family)) { + const n = family[id] || {}; + const live = liveById.get(id); + const parents = uniqueIds(n.parents, known); + const children = uniqueIds(n.children, known); + if (live) { + const liveParents = uniqueIds(live.parents, known); + const liveChildren = uniqueIds(live.children, known); + for (const p of liveParents) if (!parents.includes(p)) parents.push(p); + for (const c of liveChildren) if (!children.includes(c)) children.push(c); + } + const next = { + id, + name: live?.name || n.name || id, + type: live?.type || n.type || "smile", + generation: Math.max(1, live?.generation || n.generation || 1), + hasPaired: !!(live?.hasPaired || n.hasPaired), + parents, + parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])], + children, + birthTime: live?.birthTime ?? n.birthTime ?? 0, + alive: live ? !live.dead : n.alive !== false, + deathReason: live?.deathReason || n.deathReason || "", + }; + for (const key of Object.keys(next)) { + const a = family[id]?.[key]; + const b = next[key]; + const equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b; + if (!equal) changed = true; + } + family[id] = next; + } + for (const child of Object.values(family)) { + for (const parentId of child.parents || []) { + const parent = family[parentId]; + if (!parent) continue; + if (!parent.children.includes(child.id)) { + parent.children.push(child.id); + changed = true; + } + } + } + for (const parent of Object.values(family)) { + const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id)); + if (!sameList(parent.children || [], nextChildren)) { + parent.children = nextChildren; + changed = true; + } + } + for (const [id, live] of liveById.entries()) { + const n = family[id]; + if (!n) continue; + if (!sameList(live.parents || [], n.parents || [])) { + live.parents = [...(n.parents || [])]; + changed = true; + } + if (!sameList(live.children || [], n.children || [])) { + live.children = [...(n.children || [])]; + changed = true; + } + if (live.hasPaired !== !!n.hasPaired) { + live.hasPaired = !!n.hasPaired; + changed = true; + } + if (live.generation !== n.generation) { + live.generation = n.generation; + changed = true; + } + } + if (changed) this.familyVersion = (this.familyVersion || 0) + 1; + this.familyCleanVersion = this.familyVersion || 0; + return changed; + } + + validateFamily() { + this.normalizeFamily(); + const family = this.family || {}; + const issues = []; + const seenRenderIds = new Set(); + for (const n of Object.values(family)) { + if (!n?.id) continue; + const childSeen = new Set(); + for (const p of n.parents || []) { + if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p }); + else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p }); + } + for (const c of n.children || []) { + if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c }); + childSeen.add(c); + if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c }); + else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c }); + } + if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id }); + seenRenderIds.add(n.id); + } + return issues; + } + + markDead(t, reason) { + if (!this.family) this.family = {}; + if (this.family[t.id]) { + if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== reason) { + this.family[t.id].alive = false; + this.family[t.id].deathReason = reason; + this.familyVersion = (this.familyVersion || 0) + 1; + } + } + this.normalizeFamily(); + this.pruneExtinctFamilies(); + } + + pruneExtinctFamilies(opts = {}) { + if (opts.normalize !== false) this.normalizeFamily(); + const family = this.family || {}; + const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); + if (!nodes.length) return; + const ids = new Set(nodes.map(n => n.id)); + const graph = new Map(); + const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); }; + const link = (a, b) => { + if (!a || !b || !ids.has(a) || !ids.has(b)) return; + add(a); add(b); + graph.get(a).add(b); + graph.get(b).add(a); + }; + for (const n of nodes) { + add(n.id); + for (const p of n.parents || []) link(n.id, p); + for (const c of n.children || []) link(n.id, c); + } + const seen = new Set(); + const remove = []; + for (const n of nodes) { + if (seen.has(n.id)) continue; + const stack = [n.id]; + const comp = []; + seen.add(n.id); + while (stack.length) { + const id = stack.pop(); + comp.push(id); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (!comp.some(id => family[id]?.alive)) remove.push(...comp); + } + if (!remove.length) return; + for (const id of remove) delete family[id]; + this.familyVersion = (this.familyVersion || 0) + 1; + this.familyCleanVersion = this.familyVersion || 0; + } + + addTarinai(opts = {}) { + const t = new Tarinai(this, opts); + this.tarinai.push(t); + this.drawListDirty = true; + this.recordFamily(t); + this.maxGeneration = Math.max(this.maxGeneration, t.generation); + return t; + } + + startBirthRitual(a, b) { + if (!a || !b || a.dead || b.dead) return false; + if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; + const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); + const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length); + if (aJoined && !bJoined) b.generation = a.generation; + else if (bJoined && !aJoined) a.generation = b.generation; + else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; } + a.hasPaired = true; + b.hasPaired = true; + this.recordFamily(a); + this.recordFamily(b); + const duration = 10.0; + a.birthRitualTimer = b.birthRitualTimer = duration; + a.birthRitualMax = b.birthRitualMax = duration; + a.birthPartnerId = b.id; + b.birthPartnerId = a.id; + a.birthRitualRole = -1; + b.birthRitualRole = 1; + a.birthRitualLeader = true; + b.birthRitualLeader = false; + a.state = b.state = "birth_ritual"; + a.target = b; + b.target = a; + a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); + b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); + a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); + b.surpriseTimer = Math.max(b.surpriseTimer, 0.35); + return true; + } + + finishBirthRitual(a, b) { + if (!a || !b || a.dead || b.dead) return null; + a.birthRitualTimer = b.birthRitualTimer = 0; + a.birthPartnerId = b.birthPartnerId = null; + a.birthRitualLeader = b.birthRitualLeader = false; + a.state = b.state = "idle"; + a.fightTimer = b.fightTimer = 0; + a.fightTargetIds = []; b.fightTargetIds = []; + a.fightTargetId = b.fightTargetId = null; + a.defeatedById = b.defeatedById = null; + a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18; + a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0); + const children = []; + const countRoll = Math.random(); + const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1); + for (let i = 0; i < childCount; i++) { + const child = this.spawnChild(a, b, i, childCount); + if (child) { child.postBirthPeaceTimer = 18; children.push(child); } + } + if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b${children.length}\u5339\u306e\u5b50\u304c\u73fe\u308c\u305f\u3002`, "birth"); + return children[0] || null; + } + + spawnChild(a, b, index = 0, total = 1) { + const inherited = Math.random() < 0.56 ? a.type : b.type; + const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; + const child = this.addTarinai({ + type: mutation, + name: makeName(), + age: 0, + x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, + y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0), + scale: rand(0.17, 0.23), + hunger: rand(20, 40), + loneliness: rand(30, 56), + energy: rand(55, 95), + stress: rand(0, 14), + need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), + generation: Math.max(a.generation, b.generation) + 1, + lifeSpan: rand(1500, 2400), + parents: [a.id, b.id], + parentNames: [a.name, b.name], + birthTime: this.time, + }); + this.maxGeneration = Math.max(this.maxGeneration, child.generation); + a.affection = 0; b.affection = 0; + a.loneliness *= 0.65; b.loneliness *= 0.65; + a.children.push(child.id); + b.children.push(child.id); + this.recordFamily(a); + this.recordFamily(b); + this.recordFamily(child); + if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id); + if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id); + this.normalizeFamily(); + audio.birth(); + if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`, "birth"); + return child; + } + + startConflict(a, b) { + if (!a || !b || a.dead || b.dead) return; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; + if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; + const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5); + const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5); + const actor = scoreA >= scoreB ? a : b; + const target = actor === a ? b : a; + const actorScore = actor === a ? scoreA : scoreB; + const targetScore = actor === a ? scoreB : scoreA; + const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); + const intimidationChance = 0.40; + if (canIntimidate && Math.random() < intimidationChance) { + this.startIntimidation(actor, target, actorScore, targetScore); + return; + } + this.startFight(a, b); + } + + startIntimidation(actor, target, actorScore = 0, targetScore = 0) { + if (!actor || !target || actor.dead || target.dead) return false; + if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; + const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; + const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5); + const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96); + actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); + target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); + actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); + target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); + actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); + actor.intimidateTargetId = target.id; + actor.state = "intimidate"; + this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { + life: 0.42, + size: actor.radius * 0.55, + color: "rgba(145, 106, 55, 0.70)", + })); + this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45)); + target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); + if (Math.random() >= successChance) { + target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); + return true; + } + target.defeatedById = actor.id; + target.fightWinnerId = actor.id; + target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); + target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); + target.state = "panic"; + target.target = actor; + this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); + target.vy += rand(-18, 18); + actor.adjustRelation(target, -0.08, 0.06, "intimidate"); + target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); + if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { + this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight"); + actor.lastLog = target.lastLog = this.time; + } + return true; + } + + startFight(a, b) { + if (!a || !b || a.dead || b.dead) return; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; + a.state = "fight"; b.state = "fight"; + a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); + b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); + a.nextHeadbutt = this.time + rand(0.08, 0.18); + b.nextHeadbutt = a.nextHeadbutt; + a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); + b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); + if (!a.fightTargetIds) a.fightTargetIds = []; + if (!b.fightTargetIds) b.fightTargetIds = []; + if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); + if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id); + a.fightTargetIds = a.fightTargetIds.slice(-4); + b.fightTargetIds = b.fightTargetIds.slice(-4); + a.fightTargetId = a.fightTargetIds[0]; + b.fightTargetId = b.fightTargetIds[0]; + const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7); + const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7); + const loser = aScore <= bScore ? a : b; + const winner = loser === a ? b : a; + loser.defeatedById = winner.id; + loser.fightWinnerId = winner.id; + winner.fightWinnerId = winner.id; + winner.defeatedById = null; + winner.defeatedTimer = 0; + a.stress = clamp(a.stress + rand(8, 18), 0, 130); + b.stress = clamp(b.stress + rand(8, 18), 0, 130); + a.damage(rand(2, 7)); + b.damage(rand(2, 7)); + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); + b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); + a.surpriseTimer = 0.24; b.surpriseTimer = 0.24; + this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, { + size: rand(12, 20), + life: rand(0.32, 0.52), + color: "rgba(116, 73, 38, 0.82)", + })); + audio.fight(); + if (this.time - Math.max(a.lastLog, b.lastLog) > 6) { + this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight"); + a.lastLog = b.lastLog = this.time; + } + } + + explodeFirecracker(it) { + if (!it || it.dead || it.type !== "firecracker") return; + it.amount = 0; + const x = it.x, y = it.y; + const blastRadius = 240 * (it.blastScale || 1); + this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" })); + this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" })); + for (let r = 0; r < 4; r++) { + this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" })); + } + for (let i = 0; i < 34; i++) { + const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); + const speed = rand(90, 260); + this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { + vx: Math.cos(a) * speed, + vy: Math.sin(a) * speed, + size: rand(8, i % 3 === 0 ? 24 : 20), + life: rand(0.26, 0.72), + color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", + })); + } + for (const t of this.tarinai) { + if (t.dead) continue; + t.stress = clamp(t.stress + rand(8, 15), 0, 130); + t.fearTimer = Math.max(t.fearTimer, 0.58); + const dx = t.x - x; + const dy = t.y - y; + const d = Math.hypot(dx, dy) || 1; + if (d > blastRadius) continue; + const p = clamp(1 - d / blastRadius, 0, 1); + t.damage(5 + p * 18, "\u7206\u7af9\u306b\u9a5a\u3044\u305f"); + t.stress = clamp(t.stress + 22 * p, 0, 130); + t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); + t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); + t.fearTimer = Math.max(t.fearTimer, 1.7 + p); + t.state = "panic"; + t.target = { x, y }; + const power = 170 + p * 360; + t.vx += dx / d * power + rand(-55, 55); + t.vy += dy / d * power + rand(-55, 55); + t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); + t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); + t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); + this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); + if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + } + audio.explode(); + this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); + } + + spawnZunchi(x, y) { + const zunchiLimit = CONFIG.zunchiLimit ?? 56; + if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { + let oldest = null; + for (const it of this.items) { + if (it.type !== "zunchi" || it.dead) continue; + if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it; + } + if (oldest) oldest.amount = 0; + } + const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); + it.amount = 260; + this.items.push(it); + this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + this.spawnBubble(it.x, it.y - 16, "\u3076\u308a\u3085\u3063", "rgba(62,84,45,0.78)"); + } + + spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { + if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; + if (text === "z") text = "Zzz..."; + audio.bubble(text); + this.effects.push(new Effect("bubble", x, y, { + vx: rand(-2, 2), + vy: rand(-5, -2), + life: rand(2.2, 3.2), + size: 8, + text, + color, + })); + this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1; + } + + spawnHeadbuttEffect(x, y) { + this.effects.push(new Effect("fight", x, y, { + size: rand(10, 16), + life: rand(0.20, 0.34), + color: "rgba(105, 62, 34, 0.86)", + })); + if (Math.random() < 0.5) audio.fight(); + } + + spawnFallEffect(x, y, scale = 1) { + if ((this.effectCounts.fall || 0) > 18) return; + this.effects.push(new Effect("fall", x, y, { + vx: rand(-12, 12), + vy: rand(-4, 3), + size: rand(20, 32) * scale, + life: rand(0.48, 0.72), + color: "rgba(154, 124, 80, 0.58)", + })); + } + + spawnEatEffect(x, y, color = "#f1dfb7") { + if (Math.random() < 0.70) { + this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { + vx: rand(-10, 10), + vy: rand(-18, -3), + size: rand(2.2, 4.2), + life: rand(0.18, 0.32), + color, + })); + } + if (Math.random() < 0.08) { + this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); + } + } + + spawnBleedEffect(x, y) { + this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { + vx: rand(-8, 8), + vy: rand(3, 14), + size: rand(2.0, 3.8), + life: rand(0.34, 0.62), + color: "rgba(80, 172, 55, 0.78)", + })); + } + + bedOccupancy(bed) { + if (!bed || bed.type !== "bed") return 0; + let n = 0; + const radius = Math.max(86, bed.r * 2.45); + for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { + if (t.dead) continue; + if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; + } + return n; + } + + bedComfort(bed) { + if (!bed || bed.type !== "bed") return 0.7; + const occ = this.bedOccupancy(bed); + return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); + } + + bestBedFor(t, maxDist = 360) { + let best = null; + let bestScore = Infinity; + for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { + if (bed.dead || bed.type !== "bed") continue; + if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; + const d = dist(t, bed); + const occ = this.bedOccupancy(bed); + const comfort = this.bedComfort(bed); + const score = d - comfort * 46 + Math.max(0, occ - 3) * 34; + if (score < bestScore) { best = bed; bestScore = score; } + } + return best; + } + + relationNotice(aId, bId, kind, cooldown = 30) { + const key = [aId, bId].sort().join(":") + `:${kind}`; + const last = this.relationNotices?.[key] || -Infinity; + if (this.time - last < cooldown) return false; + if (!this.relationNotices) this.relationNotices = {}; + this.relationNotices[key] = this.time; + return true; + } + + resolveFightOutcome(winner, loser) { + if (!winner || !loser || winner.dead || loser.dead) return; + const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; + if (!this.resolvedFightIds) this.resolvedFightIds = {}; + if (this.resolvedFightIds[key]) return; + this.resolvedFightIds[key] = true; + winner.defeatedById = null; + winner.defeatedTimer = 0; + winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); + winner.fightTargetIds = []; + winner.fightTargetId = null; + winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f"); + loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f"); + winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1; + loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1; + loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); + this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + winner.affection = clamp(winner.affection + 0.8, 0, 80); + winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); + if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { + this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight"); + } + } + + itemDropImpact(it) { + if (!it || it.dead) return; + const isStone = it.type === "stone"; + const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75); + const damage = isStone ? rand(20, 34) : rand(3, 7); + const push = isStone ? rand(145, 210) : rand(54, 92); + let hit = 0; + for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { + if (!t || t.dead) continue; + const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); + const p = clamp(1 - d / (radius + t.radius), 0, 1); + if (p <= 0) continue; + const nx = (t.x - it.x) / d; + const ny = (t.y - it.y) / d; + t.vx += nx * push * (0.35 + p); + t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p; + t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); + t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25); + t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26); + t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + hit += 1; + } + this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); + if (hit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45); + if (isStone && hit > 0) audio.poke(); + if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + } + + nearest(entity, types, maxDist = Infinity) { + let best = null, bestD = maxDist; + const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; + for (const it of candidates) { + if (it.dead || !types.includes(it.type)) continue; + if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue; + const d = dist(entity, it); + if (d < bestD) { best = it; bestD = d; } + } + return best; + } + + nearestOther(entity, maxDist = Infinity) { + let best = null, bestD = maxDist; + const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; + for (const o of candidates) { + if (o === entity || o.dead) continue; + if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; + const d = dist(entity, o); + if (d < bestD) { best = o; bestD = d; } + } + return best; + } + + temperatureAt(x, y) { + let temp = 0.54 + Math.sin(this.time / 55) * 0.05; + for (const it of this.nearbyItems(x, y, 190)) { + if (it.type === "stone") { + const d = distXY(x, y, it.x, it.y); + temp += clamp(1 - d / 190, 0, 1) * 0.35; + } + } + return clamp(temp, 0, 1); + } + + + fenceRect(it) { + const vertical = it?.type === "fence_v"; + const horizontal = it?.type === "fence_h"; + if (!vertical && !horizontal) return null; + const len = Math.max(82, (it.r || 42) * 1.95); + const thick = Math.max(20, (it.r || 42) * 0.43); + const halfW = vertical ? thick : len; + const halfH = vertical ? len : thick; + return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal }; + } + + resolveFenceCollision(t) { + if (!t || t.dead) return; + const rr = Math.max(8, t.radius * 0.74); + for (const it of this.nearbyItems(t.x, t.y, rr + 92)) { + if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; + const r = this.fenceRect(it); + if (!r) continue; + const cx = clamp(t.x, r.left, r.right); + const cy = clamp(t.y, r.top, r.bottom); + let dx = t.x - cx; + let dy = t.y - cy; + let d = Math.hypot(dx, dy); + if (d >= rr) continue; + if (d < 0.001) { + const left = Math.abs(t.x - r.left); + const right = Math.abs(r.right - t.x); + const top = Math.abs(t.y - r.top); + const bottom = Math.abs(r.bottom - t.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; d = 1; } + else if (m === right) { dx = 1; dy = 0; d = 1; } + else if (m === top) { dx = 0; dy = -1; d = 1; } + else { dx = 0; dy = 1; d = 1; } + } + const push = rr - d + 0.8; + t.x += dx / d * push; + t.y += dy / d * push; + if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; + else t.vy *= -0.18; + } + } + + + + segmentIntersectsRect(x1, y1, x2, y2, r) { + if (!r) return false; + if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true; + const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => { + const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px); + return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); + }; + return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top) + || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom) + || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom) + || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); + } + + pathBlockedByFence(x1, y1, x2, y2, padding = 16) { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; + for (const it of this.items) { + if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; + if (distXY(cx, cy, it.x, it.y) > maxD + 110) continue; + const base = this.fenceRect(it); + if (!base) continue; + const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding }; + if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; + } + return false; + } + + grassBlockedAt(x, y, self = null) { + for (const it of this.nearbyItems(x, y, 52)) { + if (it === self || it.dead) continue; + if (it.type === "fence_v" || it.type === "fence_h") { + const r = this.fenceRect(it); + if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true; + } + if (it.type !== "zunchi") continue; + const dx = (x - it.x) / Math.max(22, it.r * 1.35); + const dy = (y - it.y) / Math.max(12, it.r * 0.82); + if (dx * dx + dy * dy < 1) return true; + } + return false; + } + + grassCrowdedAt(x, y, minGap = 30, self = null) { + for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) { + if (it === self || it.dead || it.type !== "grass") continue; + if (distXY(x, y, it.x, it.y) < minGap) return true; + } + return false; + } + + grassOnTarinaiAt(x, y, minGap = 24) { + for (const t of this.nearbyTarinai(x, y, minGap + 36)) { + if (t.dead) continue; + if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; + } + return false; + } + + grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) { + if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false; + if (this.grassBlockedAt(x, y, self)) return false; + if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false; + if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false; + return true; + } + + findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) { + const cx = clamp(x, 44, this.w - 44); + const cy = clamp(y, 44, this.h - 44); + if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy }; + const blockedAtCenter = this.grassBlockedAt(cx, cy); + const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius; + const outer = Math.max(maxRadius, inner + 42); + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < attempts; i++) { + const t = attempts <= 1 ? 1 : i / (attempts - 1); + const radius = lerp(inner, outer, Math.sqrt(t)); + const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2; + const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44); + const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44); + if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py }; + } + return null; + } + + findGrassSproutSpot(source, fertile = false) { + const attempts = fertile ? 24 : 10; + const minR = fertile ? Math.max(30, source.r * 1.9) : 18; + const maxR = fertile ? 125 : 72; + for (let i = 0; i < attempts; i++) { + const angle = rand(0, Math.PI * 2); + const radius = rand(minR, maxR); + const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44); + const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44); + if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y }; + } + return null; + } + + performanceLevel() { + const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60; + if (!fps || !Number.isFinite(fps)) return 0; + if (fps < 22) return 3; + if (fps < 34) return 2; + if (fps < 48) return 1; + return 0; + } + + grassLoadLevel() { + const grass = this.itemCounts?.grass || 0; + const perf = this.performanceLevel(); + if (perf >= 3 || grass >= 180) return 3; + if (perf >= 2 || grass >= 120) return 2; + if (perf >= 1 || grass >= 72) return 1; + return 0; + } + + grassUpdateFactor() { + return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0; + } + + sortedDrawList() { + if (!this.drawList) this.drawList = []; + if (this.drawListDirty || this.drawList.length !== this.tarinai.length) { + this.drawList.length = 0; + for (const t of this.tarinai) if (!t.dead) this.drawList.push(t); + this.drawList.sort((a, b) => a.y - b.y); + this.drawListDirty = false; + } + return this.drawList; + } + + nearbyItems(x, y, radius) { + return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch); + } + + nearbyTarinai(x, y, radius) { + return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch); + } + + rebuildSpatial() { + this.spatial.rebuild(this.items, this.tarinai); + } + + updateItemCounts() { + const counts = this.itemCounts; + for (const key of Object.keys(counts)) counts[key] = 0; + for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1; + } + + updateEffectCounts() { + const counts = this.effectCounts; + for (const key of Object.keys(counts)) counts[key] = 0; + for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1; + } + + compactItems() { + this.enforceItemBudget(); + const before = this.items.length; + let write = 0; + for (let read = 0; read < this.items.length; read++) { + const it = this.items[read]; + if (it.type === "mirror") continue; + if (!it.dead) this.items[write++] = it; + } + this.items.length = write; + return before !== this.items.length; + } + + enforceItemBudget() { + const pruneOldest = (type, limit) => { + if (!Number.isFinite(limit) || limit < 0) return; + let count = 0; + for (const it of this.items) if (it.type === type && !it.dead) count += 1; + if (count <= limit) return; + const victims = this.items + .filter(it => it.type === type && !it.dead) + .sort((a, b) => (b.age || 0) - (a.age || 0)) + .slice(0, count - limit); + for (const it of victims) it.amount = 0; + }; + pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56); + pruneOldest("trace", CONFIG.traceLimit ?? 64); + pruneOldest("splat", CONFIG.splatLimit ?? 48); + pruneOldest("grass", CONFIG.grassLimit ?? 96); + + const limit = CONFIG.itemLimit ?? Infinity; + if (!Number.isFinite(limit) || this.items.length <= limit) return; + const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7 }; + const removable = this.items + .filter(it => !it.dead && (priority[it.type] || 99) < 99) + .sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0)); + let extra = this.items.length - limit; + for (const it of removable) { + if (extra <= 0) break; + it.amount = 0; + extra -= 1; + } + } + + compactEffects() { + const limit = CONFIG.effectLimit ?? 96; + if (Number.isFinite(limit) && this.effects.length > limit) { + this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001))); + const remove = this.effects.length - limit; + for (let i = 0; i < remove; i++) this.effects[i].life = 0; + } + let write = 0; + for (let read = 0; read < this.effects.length; read++) { + const ef = this.effects[read]; + if (!ef.dead) this.effects[write++] = ef; + } + this.effects.length = write; + } + + compactTarinai() { + let write = 0; + for (let read = 0; read < this.tarinai.length; read++) { + const t = this.tarinai[read]; + if (!t.dead) this.tarinai[write++] = t; + } + if (this.tarinai.length !== write) this.drawListDirty = true; + this.tarinai.length = write; + } + + update(dt) { + if (this.paused) return; + dt *= this.speed; + dt = Math.min(dt, 0.12 * this.speed); + + this.time += dt; + this.day = Math.floor(this.time / CONFIG.dayLength) + 1; + this.updateWeather(dt); + this.rebuildSpatial(); + + const perf = this.performanceLevel(); + const itemCountBefore = this.items.length; + for (const it of this.items) it.update(dt, this); + + for (const ef of this.effects) ef.update(dt); + + for (const t of this.tarinai) { + t.update(dt); + } + + this.compactTimer += dt; + const compactInterval = perf >= 2 ? 0.95 : 0.58; + let itemsCompacted = false; + if (this.compactTimer >= compactInterval) { + itemsCompacted = this.compactItems(); + this.compactEffects(); + this.compactTarinai(); + this.compactTimer = 0; + } + + this.countsTimer += dt; + const countsInterval = perf >= 2 ? 0.85 : 0.42; + if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) { + this.updateItemCounts(); + this.updateEffectCounts(); + this.countsTimer = 0; + } + + this.drawSortTimer += dt; + const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07; + if (this.drawSortTimer >= sortInterval) { + this.drawListDirty = true; + this.drawSortTimer = 0; + } + + const phase = this.phaseName(); + if (phase !== this.lastPhase) { + this.lastPhase = phase; + audio.phase(); + } + + if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) { + const spot = this.edgeSpawnPoint(); + const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() }); + t.wanderAngle = spot.angle; + this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002"); + } + + + const grassCount = this.itemCounts.grass || 0; + if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) { + const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { + allowOriginal: true, + minRadius: 28, + maxRadius: 140, + attempts: 24, + }); + if (spot) { + const sprout = new Item("grass", spot.x, spot.y); + sprout.amount = rand(22, 48); + sprout.growth = rand(0.12, 0.30); + this.items.push(sprout); + this.itemCounts.grass = grassCount + 1; + } + } + + if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) { + const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58)); + drop.amount = rand(18, 40); + this.items.push(drop); + this.itemCounts.water = (this.itemCounts.water || 0) + 1; + } + + if (Math.random() < dt * 0.004) { + const entries = [ + "\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002", + "\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002", + "\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002", + "\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002", + `${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002` + ]; + this.log(pick(entries)); + } + } + + updateWeather(dt) { + this.nextWeatherChange -= dt; + if (this.nextWeatherChange > 0) return; + const current = this.weather || "sunny"; + const choices = current === "sunny" + ? ["sunny", "cloudy", "light_rain"] + : current === "cloudy" + ? ["sunny", "cloudy", "light_rain"] + : ["sunny", "cloudy"]; + this.weather = pick(choices); + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); + } + + placeItem(item, dropped = true) { + if (!item) return null; + if (dropped) { + item.dropMax = item.type === "stone" ? 0.72 : 0.58; + item.dropTimer = item.dropMax; + item.dropImpactDone = false; + } + this.items.push(item); + this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1; + this.rebuildSpatial(); + return item; + } + + handleClick(x, y) { + const tool = this.tool; + if (tool === "observe") { + let found = null; + for (let i = this.tarinai.length - 1; i >= 0; i--) { + if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } + } + this.selected = found; + if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`); + return; + } + + if (tool === "poke") { + const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); + if (target) { + target.poke(); + if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`); + } else { + this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002"); + } + return; + } + + if (tool === "new") { + const spot = this.edgeSpawnPoint(x, y); + const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 }); + t.wanderAngle = spot.angle; + audio.birth(); + this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + return; + } + + if (tool === "delete") { + let best = null, bestD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (it.dead) continue; + const visualHit = Math.max(22, (it.r || 12) * 2.0); + const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit; + const d = distXY(x, y, it.x, it.y); + if (d < hit && d < bestD) { best = it; bestD = d; } + } + if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } + best.amount = 0; + this.compactItems(); + this.updateItemCounts(); + this.rebuildSpatial(); + const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; + this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); + return; + } + + const itemType = { + zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass", + stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h" + }[tool]; + if (itemType) { + let px = x, py = y; + if (itemType === "grass") { + const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 }); + if (!spot) { + showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002"); + this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); + return; + } + px = spot.x; + py = spot.y; + } + this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true); + const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType]; + this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + } + } + + inferLogKind(text) { + const s = String(text || "").toLowerCase(); + if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; + if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight"; + if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; + if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:")) return "death"; + if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; + if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; + if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; + if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe"; + if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation"; + return "note"; + } + + log(text, kind = null) { + const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; + if (!this.eventCounters) this.eventCounters = {}; + this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + this.logs.unshift(entry); + this.logs = this.logs.slice(0, 100); + renderLog(this.logs); + } + + serialize() { + return { + w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount, + maxGeneration: this.maxGeneration, + tarinai: this.tarinai.map(t => t.serialize()), + items: this.items.map(i => i.serialize()), + logs: this.logs.slice(0, 30), + family: this.family || {}, + weather: this.weather || "sunny", + fieldType: this.fieldType || "garden", + fieldZoom: this.fieldZoom || 1, + cameraX: this.cameraX || 0, + cameraY: this.cameraY || 0, + eventCounters: this.eventCounters || {}, + toolSize: this.toolSize || "medium", + toolSizes: this.toolSizes || {}, + nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin, + }; + } + + load(data) { + this.time = data.time || 0; + this.day = data.day || 1; + this.deadCount = data.deadCount || 0; + this.eventCounters = data.eventCounters || {}; + this.toolSize = data.toolSize || this.toolSize || "medium"; + this.toolSizes = data.toolSizes || this.toolSizes || {}; + this.maxGeneration = data.maxGeneration || 1; + this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden"; + this.fieldZoom = clamp(Number(data.fieldZoom) || 1, 0.42, 2.45); + this.cameraX = Number(data.cameraX) || 0; + this.cameraY = Number(data.cameraY) || 0; + this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o)); + this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food").map(o => Item.from(o)); + this.effects = []; + this.compactItems(); + this.compactEffects(); + this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") }); + this.family = data.family || {}; + this.familyCleanVersion = null; + this.familyVersion = (this.familyVersion || 0) + 1; + this.weather = data.weather || "sunny"; + this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + this.relationNotices = {}; + this.resolvedFightIds = {}; + for (const t of this.tarinai) this.recordFamily(t); + this.normalizeFamily(); + this.pruneExtinctFamilies({ normalize: false }); + this.lastPhase = this.phaseName(); + this.selected = null; + this.updateItemCounts(); + this.updateEffectCounts(); + this.rebuildSpatial(); + this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); + } +} + +const world = new World();