diff --git a/.htaccess b/.htaccess index d579227..36b3f0a 100644 --- a/.htaccess +++ b/.htaccess @@ -1,21 +1,21 @@ ExpiresActive On ExpiresByType text/html "access plus 0 seconds" - ExpiresByType text/css "access plus 0 seconds" - ExpiresByType application/javascript "access plus 0 seconds" - ExpiresByType application/json "access plus 0 seconds" + ExpiresByType text/css "access plus 30 days" + ExpiresByType application/javascript "access plus 30 days" + ExpiresByType application/json "access plus 30 days" ExpiresByType image/png "access plus 30 days" ExpiresByType image/webp "access plus 30 days" ExpiresByType audio/mpeg "access plus 30 days" ExpiresByType audio/wav "access plus 30 days" - - Header set Cache-Control "no-cache, no-store, must-revalidate" + + Header set Cache-Control "no-cache, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" - + Header set Cache-Control "public, max-age=2592000" diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md new file mode 100644 index 0000000..938feeb --- /dev/null +++ b/PATCH_NOTES.md @@ -0,0 +1,69 @@ +## 15.9.2 修正 +- 巣箱アイコンとゲーム内描画から、枠内の明色・透過っぽく見える装飾を除去。 +- アリのHPバーを、たりないと同様にダメージ直後だけ表示する方式へ変更。 +- 道具UIに「げんこつ」を追加。添付画像から正義げんこつ部分を切り抜き、上から落下してたりない・アリにダメージと吹っ飛びを与える効果を追加。 +- 初期ロード画面と読み込み進捗バーを追加。JS/CSSをdefer化し、CSS/JSのサーバーキャッシュを有効化して再訪問時の通信量を削減。 + + +## 15.9.1 修正 +- 草を地面レイヤーとして扱い、既存の草の上にもオブジェクトを配置できるようにしました。草同士・草と障害物の植え付け制限は維持しています。 +- けんか餅のゲーム内描画から星印を削除し、×印のみ表示するようにしました。 +- ダメージ後に出る「こわい…」吹き出しを削除しました。 +- 家系図に「更新」トグルを追加しました。デフォルトはOFFで、ONの間だけ家系図を描画・更新します。 +- 死亡した個体のライブIDを再利用しないようにし、友達・敵対関係が別個体へ引き継がれて見える問題を防止しました。死亡時の一時ターゲット参照も掃除します。 +- たりないデータUIをカテゴリ別の折り畳み表示に変更しました。 + +# Patch Notes + +## Encoding cleanup +- Normalized `index.html` back to UTF-8. +- Replaced mojibake strings with the intended labels. +- Escaped Japanese text in source files to avoid encoding-dependent corruption. + +## Personality specification fixes +- Personality tags and data UI now use Japanese labels from the four-axis personality model. +- Threshold-crossing personality logs are now generated in Japanese and only when visible trait bands change. +- Daily personality caps now track increase and decrease separately per parameter. +- Current personality effects are neutral inside the -0.5 to +0.5 band. +- Direct conversion to the legacy timid personality after damage was removed; cowardly behavior is derived from high neuroticism plus low aggression. +- Corpse/splat contact now increases stress and slowly reduces neuroticism through habituation. + +## Follow-up fixes +- Fixed CSS emoji escapes for tool UI icons. CSS now uses code-point escapes such as `\\1F448` instead of invalid surrogate escapes. +- Hid the detailed four-axis personality rows from the compact tarinai data UI. +- Restored `bestBedFor()` fallback so tarinai can choose hay beds when no usable nest box is available. +- Damage now wakes sleeping tarinai and forces tarinai hidden inside a nest box to leave the box. +- Added a short minimum stay window after entering a nest box to avoid immediate exit loops. +- Renamed the friend relation label from `\\u4ef2\\u826f\\u3057` to `\\u53cb\\u9054` in UI text and related logs. + + +## Personality cleanup v3 +- Current live friend count now changes sociability drift. Family/friend proximity raises sociability more when the tarinai has more current friends, while a lonely tarinai with no current friends can slowly lose sociability. +- Removed the legacy named personality route from constructor, serialization, child inheritance, selected data UI, selected-card overlay, and family-tree titles. The game now uses only birthPersonality/currentPersonality four-axis data. +- Kept empty legacy stubs only for old-save compatibility so older saves cannot revive the pre-four-axis personality model. + +## Follow-up fixes v4 +- Restored compact personality information in the tarinai data panel: visible tags, birth personality, and current personality. +- Moved the focused-tarinai highlight outside the sprite rotation transform so the ring and label stay upright. +- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress. +- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights. +- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies. + +## Ant nest update +- Added a new placeable `ant_nest` object and tool button. +- Added ant sprites cropped from the provided source image: queen, worker variants, and upside-down worker corpse. +- Each ant nest starts with 6 workers, caps at 10 workers, and sends at most 3 workers outside at once. +- Worker ants spawn above their home nest, random-walk, search for tarinai, and drag found tarinai back to the nest. Multiple workers dragging the same tarinai increase carrying speed. +- Dragged tarinai struggle automatically and can kill workers. Dead workers become edible `ant_corpse` items and slightly reduce hygiene. +- If a nest below capacity brings back a tarinai, its worker count increases by 1. +- If a full nest brings back another tarinai, a queen ant appears and creates a new ant nest in an open spot. +- Ant health does not recover. +- Added the requested Irasutoya credit entry. + +## Ant nest follow-up v5 +- Increased ant movement and hauling constants by 1.5x from the previous slow tuning. +- Kept queen spawning to at most one queen per nest per in-game day, while making spawned queens keep retrying until a new ant nest is built somewhere. +- Reduced the ant nest object radius from 34 to 17. +- Removed the separate ant corpse sprite asset. Ant corpses now render by flipping the worker ant sprite vertically and remain stationary while decaying. +- Added shared ant damage handling for firecracker explosions and dropped-object impacts. +- Kept ant worker HP in the nest worker pool so damaged workers remain damaged after returning to and leaving the nest. diff --git a/README.md b/README.md index 1cb4af3..69725d1 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,79 @@ -# たりないコロニー観察所 +# Tarinai Colony Observation Game -ブラウザで動く、観察主体の小さなコロニーゲームです。 -外部ライブラリやサーバーは不要です。 +Single-page, dependency-free browser simulation. Open `index.html`; no build step, package manager, server API, transpiler, or module loader is required. Runtime is plain HTML/CSS/JS loaded by ordered ` - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js new file mode 100644 index 0000000..afbc83f --- /dev/null +++ b/js/ants.js @@ -0,0 +1,406 @@ +"use strict"; + +const ANT_NEST_START_COUNT = 6; +const ANT_NEST_MAX_COUNT = 10; +const ANT_NEST_MAX_OUTSIDE = 3; +const ANT_WORKER_HP = 32; +const ANT_QUEEN_HP = 180; +const ANT_WORKER_RADIUS = 4.2; +const ANT_QUEEN_RADIUS = 9.5; +const ANT_WORKER_SEARCH_SPEED = 8.625; +const ANT_WORKER_RETURN_SPEED = 12.0; +const ANT_WORKER_RETURN_WEAK_SPEED = 6.0; +const ANT_WORKER_DRAG_SPEED = 4.125; +const ANT_QUEEN_SPEED = 9.0; + +function clampAntHp(v) { + return clamp(Number(v) || 0, 0, ANT_WORKER_HP); +} + +function buildAntWorkerPool(count = ANT_NEST_START_COUNT) { + const n = clamp(Math.round(Number(count) || 0), 0, ANT_NEST_MAX_COUNT); + return Array.from({ length: n }, () => ANT_WORKER_HP); +} + +function sanitizeAntWorkerPool(list, fallbackCount = 0) { + if (!Array.isArray(list)) return buildAntWorkerPool(fallbackCount); + const out = list.map(v => clampAntHp(v)).filter(v => v > 0.01); + return out.slice(0, ANT_NEST_MAX_COUNT); +} + +function ensureAntNestWorkerPool(nest) { + if (!nest || nest.type !== "ant_nest") return []; + if (!Array.isArray(nest.antWorkers)) { + nest.antWorkers = buildAntWorkerPool(nest.antCount ?? ANT_NEST_START_COUNT); + } else { + nest.antWorkers = sanitizeAntWorkerPool(nest.antWorkers, nest.antCount ?? nest.antWorkers.length); + } + return nest.antWorkers; +} + +function syncAntNestCount(worldRef, nest) { + if (!nest || nest.type !== "ant_nest") return 0; + ensureAntNestWorkerPool(nest); + const outside = (worldRef?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; + nest.antCount = clamp((nest.antWorkers?.length || 0) + outside, 0, ANT_NEST_MAX_COUNT); + return nest.antCount; +} + +class AntActor { + constructor(worldRef, opts = {}) { + this.world = worldRef; + this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `ant-${Date.now()}-${Math.random()}`); + this.kind = opts.kind || "worker"; + this.homeId = opts.homeId || ""; + this.targetId = opts.targetId || ""; + this.targetToken = opts.targetToken || 0; + this.targetRef = opts.targetRef || null; + this.x = Number.isFinite(opts.x) ? opts.x : 0; + this.y = Number.isFinite(opts.y) ? opts.y : 0; + this.vx = opts.vx || 0; + this.vy = opts.vy || 0; + this.r = opts.r || (this.kind === "queen" ? ANT_QUEEN_RADIUS : ANT_WORKER_RADIUS); + this.maxHp = Number.isFinite(opts.maxHp) ? opts.maxHp : (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP); + this.hp = Number.isFinite(opts.hp) ? opts.hp : this.maxHp; + this.hpBarTimer = Number.isFinite(opts.hpBarTimer) ? opts.hpBarTimer : 0; + this.state = opts.state || (this.kind === "queen" ? "founding" : "search"); + this.age = opts.age || 0; + this.seed = opts.seed || Math.random() * 1000; + this.wanderAngle = opts.wanderAngle || rand(0, Math.PI * 2); + this.nextTurn = opts.nextTurn || rand(0.4, 1.6); + this.returnTimer = opts.returnTimer || 0; + this.foundingX = opts.foundingX; + this.foundingY = opts.foundingY; + this.foundingDelayUntil = Number.isFinite(opts.foundingDelayUntil) ? opts.foundingDelayUntil : -Infinity; + this.carryLogAt = opts.carryLogAt || -999; + this.foundingAttempts = opts.foundingAttempts || 0; + this.dead = Boolean(opts.dead); + } + + homeNest() { + return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null; + } + + targetTarinai() { + if (this.targetRef && !this.targetRef.dead && this.targetRef.id === this.targetId && this.targetRef.liveToken === this.targetToken) return this.targetRef; + const live = this.world?.liveTarinaiById?.(this.targetId, this.targetToken) || null; + this.targetRef = live; + return live; + } + + activeHaulersForTarget() { + return (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId && a.targetId === this.targetId && a.targetToken === this.targetToken); + } + + isLeadHauler() { + const haulers = this.activeHaulersForTarget(); + if (!haulers.length) return true; + let lead = haulers[0]; + for (const a of haulers) if (String(a.id) < String(lead.id)) lead = a; + return lead === this; + } + + chooseTarget(maxDist = 56) { + let best = null; + let bestScore = Infinity; + for (const t of this.world?.tarinai || []) { + if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > maxDist) continue; + if ((t.antDraggedTimer || 0) > 0 && (t.antDraggedBy || "") && t.antDraggedBy !== this.id) { + const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id && a.targetToken === t.liveToken).length; + if (haulers >= ANT_NEST_MAX_OUTSIDE) continue; + } + const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1); + const score = d - weak * 18 + stableUnit(`${this.id}:${t.id}`, "ant-prey") * 6; + if (score < bestScore) { + best = t; + bestScore = score; + } + } + return best; + } + + update(dt) { + if (this.dead) return; + const hpBefore = this.hp; + this.age += dt; + if (this.kind === "queen") this.updateQueen(dt); + else this.updateWorker(dt); + if (Number.isFinite(hpBefore) && this.hp < hpBefore - 0.01) this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.25); + this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); + } + + updateWorker(dt) { + const home = this.homeNest(); + if (!home) { + this.dead = true; + return; + } + if (this.hp <= 0) { + this.dieAsCorpse(home); + return; + } + if (this.state === "drag") return this.updateDrag(dt, home); + if (this.state === "return") return this.updateReturn(dt, home); + this.updateSearch(dt, home); + } + + updateSearch(dt, home) { + const target = this.chooseTarget(52); + if (target) { + this.state = "drag"; + this.targetId = target.id; + this.targetToken = target.liveToken || 0; + this.targetRef = target; + this.carryLogAt = this.world?.time || 0; + target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.45); + target.antDraggedBy = this.id; + target.fearTimer = Math.max(target.fearTimer || 0, 0.55); + target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"; + this.world.drawListDirty = true; + return; + } + this.nextTurn -= dt; + if (this.nextTurn <= 0) { + const toHome = Math.atan2(home.y - this.y, home.x - this.x); + const far = distXY(this.x, this.y, home.x, home.y); + const bias = far > 220 ? 0.58 : 0.18; + this.wanderAngle = this.wanderAngle * (1 - bias) + toHome * bias + rand(-1.2, 1.2); + this.nextTurn = rand(0.7, 1.9); + } + const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0); + const speed = (ANT_WORKER_SEARCH_SPEED + Math.sin(this.age * 2.4 + this.seed) * 0.75) * hpFactor; + this.vx = Math.cos(this.wanderAngle) * speed; + this.vy = Math.sin(this.wanderAngle) * speed * 0.78; + this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + if (distXY(this.x, this.y, home.x, home.y) > 220) this.state = "return"; + } + + returnToNest(home) { + if (!home || home.dead || home.type !== "ant_nest") { + this.dead = true; + return; + } + ensureAntNestWorkerPool(home).push(clampAntHp(this.hp)); + syncAntNestCount(this.world, home); + this.dead = true; + this.world.drawListDirty = true; + } + + updateReturn(dt, home) { + const d = distXY(this.x, this.y, home.x, home.y) || 1; + if (d < Math.max(16, home.r * 0.66)) { + this.returnToNest(home); + return; + } + const speed = this.hp <= 8 ? ANT_WORKER_RETURN_WEAK_SPEED : ANT_WORKER_RETURN_SPEED; + this.x += (home.x - this.x) / d * speed * dt; + this.y += (home.y - this.y) / d * speed * dt; + this.vx = (home.x - this.x) / d * speed; + this.vy = (home.y - this.y) / d * speed; + } + + updateDrag(dt, home) { + const target = this.targetTarinai(); + if (!target || target.dead || target.insideNestBoxId) { + this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; + this.state = "return"; + return; + } + const haulers = Math.max(1, this.activeHaulersForTarget().length); + const leadHauler = this.isLeadHauler(); + target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.35); + target.antDraggedBy = this.id; + target.state = "panic"; + target.sleeping = false; + target.target = null; + target.fearTimer = Math.max(target.fearTimer || 0, 0.36); + target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b"; + const dHome = distXY(target.x, target.y, home.x, home.y) || 1; + if (dHome < Math.max(18, home.r * 0.66)) { + this.world.completeAntHaul?.(home, target); + this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; + this.state = "return"; + return; + } + const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0); + const speed = ANT_WORKER_DRAG_SPEED * hpFactor; + const ux = (home.x - target.x) / dHome; + const uy = (home.y - target.y) / dHome; + if (leadHauler) { + target.x = clamp(target.x + ux * speed * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + target.y = clamp(target.y + uy * speed * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + target.energy = clamp((target.energy || 0) - dt * 0.028, 0, 100); + } + const offsetAngle = this.seed + this.age * 2.0 + (haulers > 1 ? this.activeHaulersForTarget().findIndex(a => a === this) * 1.7 : 0); + const ring = target.radius * (0.64 + 0.06 * Math.min(haulers, 3)); + this.x = target.x - ux * ring + Math.cos(offsetAngle) * target.radius * 0.24; + this.y = target.y - uy * ring * 0.72 + Math.sin(offsetAngle) * target.radius * 0.18; + this.vx = ux * speed; + this.vy = uy * speed; + + const aggression = Math.max(0, target.currentPersonality?.aggression || 0); + const aggressiveAttack = target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"; + const struggle = aggressiveAttack + ? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9) + : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers); + this.hp -= dt * struggle; + + if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { + this.carryLogAt = this.world.time || 0; + this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event", { participants: [target] }); + } + if (this.hp <= 0) this.dieAsCorpse(home); + } + + dieAsCorpse(home = null) { + if (this.dead) return; + this.dead = true; + const corpse = new Item("ant_corpse", this.x, this.y); + corpse.amount = 24; + corpse.workerSprite = this.workerSpriteId(); + this.world.items.push(corpse); + this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" })); + if (home) syncAntNestCount(this.world, home); + this.world.updateItemCounts?.(); + this.world.drawListDirty = true; + } + + buildNestAtFoundingSpot(force = false) { + if (!this.world) return null; + const base = new Item("ant_nest", this.foundingX, this.foundingY); + let spot = null; + if (this.world.findPlacementSpot) { + spot = this.world.findPlacementSpot(base, { allowOriginal: true, maxRadius: force ? 520 : 220, attempts: force ? 160 : 72 }); + } + if (!spot && force) { + spot = this.world.placementClampPointFor ? this.world.placementClampPointFor(base, this.foundingX, this.foundingY) : { x: this.foundingX, y: this.foundingY }; + } + if (!spot) return null; + base.x = spot.x; + base.y = spot.y; + base.antCount = ANT_NEST_START_COUNT; + base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT); + if (!force && this.world.placementBlocked?.(base)) return null; + this.world.items.push(base); + this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1; + this.world.rebuildSpatial?.(); + this.world.drawListDirty = true; + this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event"); + return base; + } + + updateQueen(dt) { + if (!Number.isFinite(this.foundingX) || !Number.isFinite(this.foundingY)) { + const p = this.world?.findAntNestFoundingSpot?.(this) || { x: this.x + rand(-140, 140), y: this.y + rand(-100, 100) }; + this.foundingX = p.x; + this.foundingY = p.y; + } + let d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1; + if (d < 20) { + if ((this.world?.time || 0) < (this.foundingDelayUntil || -Infinity)) { + this.vx = 0; + this.vy = 0; + return; + } + this.foundingAttempts += 1; + const placed = this.buildNestAtFoundingSpot(this.foundingAttempts >= 8); + if (placed) { + this.dead = true; + return; + } + const retrySpot = this.world?.findAntNestFoundingSpot?.({ x: this.x, y: this.y }) || this.world?.findAntNestFoundingSpot?.(this.homeNest?.() || this); + if (retrySpot) { + this.foundingX = retrySpot.x; + this.foundingY = retrySpot.y; + } else { + this.wanderAngle += rand(-1.4, 1.4); + this.foundingX = clamp(this.x + Math.cos(this.wanderAngle) * 90, 56, this.world.w - 56); + this.foundingY = clamp(this.y + Math.sin(this.wanderAngle) * 70, 56, this.world.h - 56); + } + d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1; + } + const speed = ANT_QUEEN_SPEED; + this.x += (this.foundingX - this.x) / d * speed * dt; + this.y += (this.foundingY - this.y) / d * speed * dt; + this.vx = (this.foundingX - this.x) / d * speed; + this.vy = (this.foundingY - this.y) / d * speed; + } + + workerSpriteId() { + return "ant_worker"; + } + + spriteId() { + return this.kind === "queen" ? "ant_queen" : this.workerSpriteId(); + } + + draw(ctx, time = 0, lighting = null) { + if (this.dead) return; + const id = this.spriteId(); + const img = typeof getRenderableImage === "function" ? getRenderableImage(id, id) : images.get(id); + const lightState = lighting || getLightingState(this.world); + ctx.save(); + const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.58 : 0.26); + drawProjectedShadow(ctx, this.x, this.y + this.r * 0.62, this.r * 1.5, this.r * 0.30, { ...shadow, alpha: shadow.alpha * 0.72 }); + ctx.translate(this.x, this.y); + const angle = this.kind === "queen" + ? Math.atan2((this.foundingY ?? this.y) - this.y, (this.foundingX ?? this.x) - this.x) + : Math.atan2(this.vy || 0, this.vx || 1); + ctx.rotate(angle); + if (img) { + const w = this.kind === "queen" ? this.r * 4.1 : this.r * 3.8; + const metrics = getImageMetrics(id); + const h = w * (metrics?.ratio || 0.52); + ctx.globalAlpha = 0.96; + ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h); + } else { + ctx.fillStyle = "rgba(44,44,44,0.92)"; + ctx.beginPath(); + ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.72, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(this.r * 1.15, -this.r * 0.05, this.r * 0.46, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + this.drawHealthBar(ctx); + } + + drawHealthBar(ctx) { + if ((this.hpBarTimer || 0) <= 0) return; + const maxHp = Math.max(1, this.maxHp || (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP)); + const pct = clamp((this.hp || 0) / maxHp, 0, 1); + const bw = this.kind === "queen" ? 28 : 18; + const bh = this.kind === "queen" ? 4 : 3; + const y = this.y - this.r * (this.kind === "queen" ? 2.0 : 2.6); + ctx.save(); + ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.65, 0.35, 1); + ctx.fillStyle = "rgba(255,252,242,0.86)"; + roundedRect(ctx, this.x - bw / 2 - 1.5, y - 1.5, bw + 3, bh + 3, 4); + ctx.fill(); + ctx.fillStyle = "rgba(63,50,42,0.30)"; + roundedRect(ctx, this.x - bw / 2, y, bw, bh, 3); + 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, this.x - bw / 2, y, bw * pct, bh, 3); + ctx.fill(); + ctx.restore(); + } +} + +window.AntActor = AntActor; +window.ANT_NEST_START_COUNT = ANT_NEST_START_COUNT; +window.ANT_NEST_MAX_COUNT = ANT_NEST_MAX_COUNT; +window.ANT_NEST_MAX_OUTSIDE = ANT_NEST_MAX_OUTSIDE; +window.ANT_WORKER_HP = ANT_WORKER_HP; +window.ANT_QUEEN_HP = ANT_QUEEN_HP; +window.buildAntWorkerPool = buildAntWorkerPool; +window.ensureAntNestWorkerPool = ensureAntNestWorkerPool; +window.syncAntNestCount = syncAntNestCount; diff --git a/js/assets.js b/js/assets.js index e5a8cb2..efd8b2b 100644 --- a/js/assets.js +++ b/js/assets.js @@ -8,8 +8,8 @@ const imageAssetIndex = new Map(); const scaledSpriteCache = new Map(); const stainOverlayCache = new Map(); -const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02"]); +const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02", "genkotsu"]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02", "genkotsu"]); function trimCanvasCache(cache, limit = 420) { while (cache.size > limit) { @@ -160,14 +160,18 @@ function getRenderableImage(id, fallbackId = "smile") { return null; } -function loadImages(ids = null) { +function loadImages(ids = null, onProgress = null) { setupAssetIndex(); const requested = ids ? new Set(ids) : INITIAL_IMAGE_IDS; - const tasks = []; - for (const id of requested) { - const asset = imageAssetIndex.get(id); - if (asset) tasks.push(loadImageAsset(asset, "high")); - } + const assets = [...requested].map(id => imageAssetIndex.get(id)).filter(Boolean); + const total = Math.max(assets.length, 1); + let done = 0; + if (typeof onProgress === "function") onProgress(done, total, null); + const tasks = assets.map(asset => loadImageAsset(asset, "high").then((img) => { + done += 1; + if (typeof onProgress === "function") onProgress(done, total, asset); + return img; + })); return Promise.all(tasks).then(() => undefined); } diff --git a/js/data.js b/js/data.js index dc3de08..dea50df 100644 --- a/js/data.js +++ b/js/data.js @@ -32,7 +32,75 @@ const SPRITES = [ const DECOR_ASSETS = [ { id: "zunchi", path: "assets/objects/zunchi.png" }, { id: "zunchi_02", path: "assets/objects/zunchi_02.png" }, + { id: "ant_queen", path: "assets/objects/ant_queen.png" }, + { id: "ant_worker", path: "assets/objects/ant_worker.png" }, + { id: "genkotsu", path: "assets/objects/genkotsu.png" }, ]; + +const TOOL_DEFINITIONS = Object.freeze({ + observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.png", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, + delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.png", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, + poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, + pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, + new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.png", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, + water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, + zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, + sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.png", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.png", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, + grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.png", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, + stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 72, amount: 100, icon: "assets/ui/tool_genkotsu.png", tooltip: "\u4e0a\u304b\u3089\u6b63\u7fa9\u3052\u3093\u3053\u3064\u3092\u843d\u3068\u3057\u3001\u30a2\u30ea\u3068\u305f\u308a\u306a\u3044\u306b\u30c0\u30e1\u30fc\u30b8\u3068\u5439\u3063\u98db\u3073\u3092\u4e0e\u3048\u308b\u3002" }, + bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, + nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.png", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, + ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.png", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, + ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.png", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, + firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.png", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, + fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, + fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, + water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true }, + trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, + splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, + food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, + ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 8, amount: 24, simulationOnly: true }, +}); + +function toolDefinition(id = "") { + return TOOL_DEFINITIONS[id] || null; +} + +function toolLabel(id = "") { + const def = toolDefinition(id); + return def?.label || id || ""; +} + +function toolItemType(id = "") { + const def = toolDefinition(id); + return def?.placeable ? (def.itemType || def.id) : null; +} + +function itemDefinition(type = "") { + return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; +} + +function itemRadiusFor(type = "", fallback = 12) { + return itemDefinition(type)?.radius ?? fallback; +} + +function itemAmountFor(type = "", fallback = 80) { + return itemDefinition(type)?.amount ?? fallback; +} + +function scalableToolIds() { + return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); +} + +function toolTipsFromDefinitions() { + return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); +} + const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, @@ -63,10 +131,12 @@ const ALPHA_BOUNDS = { zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 }, zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 }, zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 }, + ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, + ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, + genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, }; const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; -const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", "ro", "mu", "a"]; const CONFIG = { initialPopulation: 18, @@ -78,7 +148,6 @@ const CONFIG = { traceLimit: 64, splatLimit: 48, effectLimit: 96, - saveKey: "tarinai-colony-save-v38", worldPadding: 30, hungerPerMinute: 11.2, lonelinessPerMinute: 7.6, @@ -107,5 +176,3 @@ const LIGHTING_TUNING = { goldenOverlayAlpha: 0.045, bloomWashAlpha: 0.040, }; - -window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING }; diff --git a/js/health.js b/js/health.js new file mode 100644 index 0000000..33fc663 --- /dev/null +++ b/js/health.js @@ -0,0 +1,157 @@ +"use strict"; + +const HEALTH = (() => { + const MAJOR_DAMAGE_WINDOW = 10; + const WEAKENED_SUFFIX = "\u3067\u8870\u5f31"; + const CAUSES = Object.freeze({ + fight: "\u55a7\u5629", + hunger: "\u98e2\u9913", + stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a", + poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f", + firecracker: "\u7206\u7af9", + accident: "\u4e8b\u6545", + collision: "\u885d\u7a81", + outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664", + lifespan: "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f", + sleepDisease: "\u306d\u3080\u308a\u75c5", + explosionDisease: "\u7206\u767a\u75c5", + fightDisease: "\u304d\u305a\u3064\u304d\u75c5", + zunchiDisease: "\u305a\u3093\u3061\u75c5", + antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f", + }); + const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; + + function causeLabel(reason = "") { + const text = String(reason || ""); + if (!text) return ""; + if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); + if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; + if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; + if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; + if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision; + if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke; + if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease; + if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease; + if (/\u3051\u3093\u304b\u75c5|\u304d\u305a\u3064\u304d\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease; + if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease; + if (/\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f|eaten.*ant|ant.*eaten/.test(text)) return CAUSES.antEaten; + if (/\u75c5/.test(text)) { + const disease = text.match(DISEASE_RE); + return disease ? disease[1] : CAUSES.zunchiDisease; + } + if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger; + if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress; + if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return CAUSES.accident; + if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return CAUSES.lifespan; + return ""; + } + + function recentDamageCause(t, maxAge = MAJOR_DAMAGE_WINDOW) { + const now = t.world?.time || 0; + if (t.lastDamageCause && now - (t.lastDamageAt || -999) <= maxAge) return t.lastDamageCause; + return ""; + } + + function rememberDamage(t, amount, reason = "") { + const cause = causeLabel(reason) || CAUSES.accident; + const now = t.world?.time || 0; + t.lastDamageCause = cause; + t.lastDamageAmount = Math.max(0, amount || 0); + t.lastDamageAt = now; + if (t.lastDamageAmount >= 10 || t.lastDamageAmount >= Math.max(6, (t.energy || 0) * 0.36)) { + t.lastMajorDamageCause = cause; + t.lastMajorDamageAmount = t.lastDamageAmount; + t.lastMajorDamageAt = now; + } + return cause; + } + + function weakenedDeathReasonFor(cause = "") { + const text = String(cause || "").trim(); + if (!text) return ""; + return text.includes(WEAKENED_SUFFIX) ? text : `${text}${WEAKENED_SUFFIX}`; + } + + function hasRecentMajorDamage(t) { + const now = t.world?.time || 0; + return Boolean(t.lastMajorDamageCause && now - (t.lastMajorDamageAt || -999) <= MAJOR_DAMAGE_WINDOW); + } + + function isFightingContext(t, recent) { + return Boolean(t.fightTimer > 0.04 || t.defeatedTimer > 0.04 || t.defeatedById || t.fightWinnerId || recent === CAUSES.fight); + } + + function dominantDeathCause(t, reason = "", opts = {}) { + const text = String(reason || "").trim(); + const direct = causeLabel(text); + const recentMajor = hasRecentMajorDamage(t); + const recent = recentDamageCause(t, 20); + const fighting = isFightingContext(t, recent); + if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision; + if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 22 || t.mood <= 20 || t.stress >= 118)) { + return weakenedDeathReasonFor(t.lastMajorDamageCause); + } + if (direct && direct !== CAUSES.stress) return direct; + if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease; + if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger; + if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight; + if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 20 || t.stress >= 118 || opts.fromDamage)) return recent; + if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan; + if (direct) return direct; + if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress; + if (t.energy <= 0.5) return CAUSES.accident; + return ""; + } + + function normalizeDeathReason(t, reason = "", opts = {}) { + const text = String(reason || "").trim(); + if (text.includes(WEAKENED_SUFFIX)) return text; + const cause = dominantDeathCause(t, text, opts) || CAUSES.accident; + if (cause.includes(WEAKENED_SUFFIX)) return cause; + const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision; + if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause); + return cause; + } + + function applyDamage(t, amount, reason = "") { + const before = t.energy; + const loss = Math.max(0, amount || 0); + t.energy = clamp(t.energy - loss, 0, 100); + const actualLoss = Math.max(0, before - t.energy); + let cause = ""; + if (actualLoss > 0.01) { + cause = rememberDamage(t, actualLoss, reason); + if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); + if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); + if (t.wakeFromDamage) t.wakeFromDamage(cause || reason); + if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason); + t.showHpBar(); + } + if (t.energy <= 0.5) { + const majorNow = actualLoss >= Math.max(10, before * 0.42); + t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true, weakened: majorNow })); + } + } + + function briefDeathReason(t, reason = t.deathReason) { + const text = String(reason || ""); + if (!text) return ""; + const normalized = normalizeDeathReason(t, text); + return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized; + } + + return Object.freeze({ + MAJOR_DAMAGE_WINDOW, + CAUSES, + causeLabel, + recentDamageCause, + rememberDamage, + weakenedDeathReasonFor, + dominantDeathCause, + normalizeDeathReason, + applyDamage, + briefDeathReason, + }); +})(); + +window.HEALTH = HEALTH; diff --git a/js/items.js b/js/items.js index c4bd39d..30c8d86 100644 --- a/js/items.js +++ b/js/items.js @@ -1,17 +1,23 @@ +// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data "use strict"; +const FOOD_SERVING_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"]); +const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 }; +function foodServingsForSize(size = "medium") { + return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium; +} +function isServingFoodType(type = "") { + return FOOD_SERVING_TYPES.has(type); +} + 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, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 - }[type] || 12; - this.amount = { - food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 - }[type] || 80; + this.r = itemRadiusFor(type, 12); + this.amount = itemAmountFor(type, 80); this.age = 0; this.seed = Math.random() * 1000; this.dropTimer = 0; @@ -52,10 +58,37 @@ class Item { this.freshness = 1; this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; } + if (type === "ant_nest") { + this.antCount = ANT_NEST_START_COUNT || 6; + this.antSpawnTimer = rand(0.6, 2.2); + this.antSpawnCooldown = rand(0.8, 2.4); + this.antWorkers = typeof buildAntWorkerPool === "function" + ? buildAntWorkerPool(this.antCount) + : Array.from({ length: this.antCount }, () => ANT_WORKER_HP || 32); + this.queenSpawnAt = -999; + } + if (type === "ant_corpse") { + this.amount = 24; + this.workerSprite = "ant_worker"; + this.decayTimer = 0; + } if (type === "firecracker") { this.fuseTimer = 5; this.fuseMax = 5; } + if (type === "genkotsu") { + this.amount = 100; + this.dropMax = 0; + this.dropImpactDone = false; + this.impactFlash = 0; + } + if (isServingFoodType(type)) { + this.toolSize = "medium"; + this.foodServingScale = 1; + this.foodServingsMax = foodServingsForSize(this.toolSize); + this.foodServingsRemaining = this.foodServingsMax; + this.amount = this.foodServingsRemaining; + } } update(dt, worldRef = world) { this.age += dt; @@ -68,9 +101,10 @@ class Item { } } if (this.type === "water") this.amount -= dt * 0.9; - if (["sweet", "love_mochi", "fight_mochi"].includes(this.type)) this.amount -= dt * 0.12; + if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type) && !Number.isFinite(this.foodServingsRemaining)) 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 === "ant_corpse") this.amount -= dt * 0.018; if (this.type === "firecracker") { this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt); if (this.fuseTimer <= 0) { @@ -78,7 +112,13 @@ class Item { else this.amount = 0; } } + if (this.type === "genkotsu") { + this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); + if (this.dropImpactDone) this.amount -= dt * 115; + else this.amount = Math.max(this.amount || 0, 100); + } if (this.type === "ball") this.updateBall(dt, worldRef); + if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); if (this.type === "grass") { this.lifecycleTimer += dt; const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1; @@ -98,19 +138,30 @@ class Item { } } } + + updateZunchiMotion(dt, worldRef) { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } + const slow = Math.pow(0.68, dt * 2.4); + this.vx *= slow; + this.vy *= slow; + this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); + worldRef.drawListDirty = true; + } + updateBall(dt, worldRef) { this.prevX = this.x; this.prevY = this.y; - const maxSpeed = 1250; - const clampVelocity = () => { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed > maxSpeed) { - this.vx = (this.vx || 0) / speed * maxSpeed; - this.vy = (this.vy || 0) / speed * maxSpeed; - } - return Math.hypot(this.vx || 0, this.vy || 0); - }; - const moving = clampVelocity(); + const moving = Math.hypot(this.vx || 0, this.vy || 0); if (moving > 0.04) { this.x += (this.vx || 0) * dt; this.y += (this.vy || 0) * dt; @@ -122,7 +173,6 @@ class Item { this.resolveBallObstacleCollisions(dt, worldRef); this.x = clamp(this.x, p, worldRef.w - p); this.y = clamp(this.y, p, worldRef.h - p); - clampVelocity(); const groundFriction = Math.pow(0.72, dt); this.vx *= groundFriction; this.vy *= groundFriction; @@ -135,21 +185,18 @@ class Item { resolveBallObstacleCollisions(dt, worldRef) { if (!worldRef?.nearbyItems) return; const speed = Math.hypot(this.vx || 0, this.vy || 0); - const searchRadius = Math.max(130, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 96); + const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || []; - let fenceChecks = 0; - const maxFenceChecks = CONFIG.maxFenceCollisionChecks ?? 18; for (const it of items) { if (!it || it === this || it.dead) continue; if (it.type === "bed") { this.applyBallHayDrag(it, dt, worldRef); } else if (it.type === "stone") { this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef); - } else if (it.type === "fence_v" || it.type === "fence_h") { - if (fenceChecks >= maxFenceChecks) continue; - fenceChecks += 1; - this.resolveBallFenceBounce(it, worldRef); } + const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; + if (!rects.length) continue; + for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it); } } @@ -174,7 +221,27 @@ class Item { let dx = this.x - obstacle.x; let dy = this.y - obstacle.y; let d = Math.hypot(dx, dy); - if (d >= hitRadius) return; + if (d >= hitRadius) { + const px = this.prevX; + const py = this.prevY; + if (!Number.isFinite(px) || !Number.isFinite(py)) return; + const sx = this.x - px; + const sy = this.y - py; + const len2 = sx * sx + sy * sy; + if (len2 <= 0.0001) return; + const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1); + const cx = px + sx * t; + const cy = py + sy * t; + dx = cx - obstacle.x; + dy = cy - obstacle.y; + d = Math.hypot(dx, dy); + if (d >= hitRadius) return; + if (d < 0.001) { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } + else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; } + } + } if (d < 0.001) { const speed = Math.hypot(this.vx || 0, this.vy || 0); if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } @@ -198,29 +265,69 @@ class Item { this.emitBallBounce(worldRef, obstacle); } - resolveBallFenceBounce(fence, worldRef) { - const vertical = fence.type === "fence_v"; - const len = Math.max(112, (fence.r || 42) * 3.55); - const thick = Math.max(10, (fence.r || 42) * 0.31); - const halfLen = len / 2; - const sx1 = vertical ? fence.x : fence.x - halfLen; - const sy1 = vertical ? fence.y - halfLen : fence.y; - const sx2 = vertical ? fence.x : fence.x + halfLen; - const sy2 = vertical ? fence.y + halfLen : fence.y; - const vx = sx2 - sx1; - const vy = sy2 - sy1; - const segLenSq = vx * vx + vy * vy || 1; - const u = clamp(((this.x - sx1) * vx + (this.y - sy1) * vy) / segLenSq, 0, 1); - const cx = sx1 + vx * u; - const cy = sy1 + vy * u; - const hitRadius = (this.r || 18) + thick / 2 + 2; + resolveBallRectBounce(rect, worldRef, source = null) { + if (!rect) return; + const hitRadius = (this.r || 18) + 2; + const cx = clamp(this.x, rect.left, rect.right); + const cy = clamp(this.y, rect.top, rect.bottom); let dx = this.x - cx; let dy = this.y - cy; let d = Math.hypot(dx, dy); - if (d >= hitRadius) return; + if (d >= hitRadius) { + const px = this.prevX; + const py = this.prevY; + const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius }; + if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return; + let nx = 0; + let ny = 0; + const vx = this.vx || 0; + const vy = this.vy || 0; + const candidates = []; + const sx = this.x - px; + const sy = this.y - py; + if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) }); + if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) }); + if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) }); + if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) }); + if (candidates.length) { + candidates.sort((a, b) => a.t - b.t); + nx = candidates[0].nx; + ny = candidates[0].ny; + } else if (Math.abs(vx) >= Math.abs(vy)) { + nx = vx >= 0 ? -1 : 1; + } else { + ny = vy >= 0 ? -1 : 1; + } + const toward = vx * nx + vy * ny; + if (nx < 0) this.x = expanded.left - 0.5; + else if (nx > 0) this.x = expanded.right + 0.5; + if (ny < 0) this.y = expanded.top - 0.5; + else if (ny > 0) this.y = expanded.bottom + 0.5; + if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom); + if (ny) this.x = clamp(this.x, expanded.left, expanded.right); + if (toward < 0) { + this.vx = vx - 1.78 * toward * nx; + this.vy = vy - 1.78 * toward * ny; + } else { + this.vx = vx + nx * 18; + this.vy = vy + ny * 18; + } + this.vx *= 0.94; + this.vy *= 0.94; + this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); + this.emitBallBounce(worldRef, source || rect.item || rect); + return; + } if (d < 0.001) { - if (vertical) { dx = (this.vx || 0) >= 0 ? -1 : 1; dy = 0; } - else { dx = 0; dy = (this.vy || 0) >= 0 ? -1 : 1; } + const left = Math.abs(this.x - rect.left); + const right = Math.abs(rect.right - this.x); + const top = Math.abs(this.y - rect.top); + const bottom = Math.abs(rect.bottom - this.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; } + else if (m === right) { dx = 1; dy = 0; } + else if (m === top) { dx = 0; dy = -1; } + else { dx = 0; dy = 1; } d = 1; } const nx = dx / d; @@ -231,11 +338,19 @@ class Item { if (toward < 0) { this.vx = (this.vx || 0) - 1.78 * toward * nx; this.vy = (this.vy || 0) - 1.78 * toward * ny; + } else { + this.vx = (this.vx || 0) + nx * 18; + this.vy = (this.vy || 0) + ny * 18; } this.vx *= 0.94; this.vy *= 0.94; this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); - this.emitBallBounce(worldRef, fence); + this.emitBallBounce(worldRef, source || rect.item || rect); + } + + resolveBallFenceBounce(fence, worldRef) { + const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : []; + for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, fence); } emitBallBounce(worldRef, obstacle) { @@ -359,117 +474,17 @@ class Item { 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, lifeSpan: this.lifeSpan, wither: this.wither, - comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax, - vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY, - spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId, - lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle - }; - } - 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, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 - }[type] || 12; - it.amount = o.amount ?? ({ - food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 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 === "ball") { - it.vx = o.vx || 0; - it.vy = o.vy || 0; - it.prevX = o.prevX ?? o.x; - it.prevY = o.prevY ?? o.y; - it.spin = o.spin || 0; - it.spinVelocity = o.spinVelocity || 0; - it.lastKickedAt = o.lastKickedAt ?? -999; - it.lastKickerId = o.lastKickerId || ""; - it.lastPokedAt = o.lastPokedAt ?? -999; - it.pokeCombo = o.pokeCombo || 0; - it.lastPokeAngle = o.lastPokeAngle || rand(0, Math.PI * 2); - } - 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.lifeSpan = o.lifeSpan ?? it.lifeSpan; - it.wither = o.wither ?? it.wither ?? 0; - it.comfort = o.comfort ?? it.comfort; - it.wear = o.wear ?? it.wear; - it.crawlOpen = o.crawlOpen ?? it.crawlOpen; - it.vx = o.vx ?? it.vx ?? 0; - it.vy = o.vy ?? it.vy ?? 0; - it.prevX = o.prevX ?? it.prevX ?? it.x; - it.prevY = o.prevY ?? it.prevY ?? it.y; - it.spin = o.spin ?? it.spin ?? 0; - it.spinVelocity = o.spinVelocity ?? it.spinVelocity ?? 0; - it.lastKickedAt = o.lastKickedAt ?? it.lastKickedAt ?? -999; - it.lastKickerId = o.lastKickerId || it.lastKickerId || ""; - it.lastPokedAt = o.lastPokedAt ?? it.lastPokedAt ?? -999; - it.pokeCombo = o.pokeCombo ?? it.pokeCombo ?? 0; - it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2); - 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 servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; + const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; - const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0; + const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170; + const dropY = dropT > 0 ? -dropFallHeight * dropT : 0; const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); if (this.type !== "trace" && this.type !== "splat") { const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null; @@ -540,13 +555,8 @@ class Item { ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2); ctx.fill(); } else if (fightMochi) { - ctx.fillStyle = "#d55632"; - ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u2726", 0, 1); - ctx.strokeStyle = "rgba(168,76,42,0.72)"; - ctx.lineWidth = 1.4; + ctx.strokeStyle = "rgba(168,76,42,0.82)"; + ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(-this.r * 0.44, -this.r * 0.42); ctx.lineTo(this.r * 0.44, this.r * 0.42); @@ -561,6 +571,72 @@ class Item { ctx.fill(); } } + } else if (this.type === "sleep_drug") { + ctx.fillStyle = "#f5f0ff"; + ctx.strokeStyle = "rgba(112, 83, 170, 0.55)"; + ctx.lineWidth = 2; + ctx.rotate(-0.45); + roundedRect(ctx, -this.r * 0.95, -this.r * 0.45, this.r * 1.90, this.r * 0.90, this.r * 0.45); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(152, 116, 218, 0.58)"; + ctx.fillRect(-1, -this.r * 0.42, 2, this.r * 0.84); + ctx.rotate(0.45); + ctx.fillStyle = "rgba(74,58,104,0.72)"; + ctx.font = `${Math.round((this.r || 13) * 0.78)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Z", 0, this.r * 1.18); + } else if (this.type === "genkotsu") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); + const impact = clamp(this.impactFlash || 0, 0, 1); + ctx.save(); + ctx.shadowColor = "rgba(54,42,31,0.26)"; + ctx.shadowBlur = 5 + impact * 8; + ctx.shadowOffsetY = 4; + const squashY = 1 - impact * 0.08; + const stretchX = 1 + impact * 0.05; + ctx.scale(stretchX, squashY); + if (img) { + const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null; + const w = this.r * 2.55; + const h = w * (metrics?.ratio || 1.27); + ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h); + } else { + ctx.fillStyle = "rgba(255,255,255,0.94)"; + ctx.strokeStyle = "rgba(64,64,64,0.78)"; + ctx.lineWidth = 5; + roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(42,46,52,0.92)"; + ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("\u6b63", 0, -this.r * 0.74); + ctx.fillText("\u7fa9", 0, -this.r * 0.27); + } + if (impact > 0.01) { + ctx.globalAlpha = impact * 0.20; + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } else if (this.type === "water_bowl") { + ctx.fillStyle = "rgba(171, 120, 76, 0.72)"; + ctx.strokeStyle = "rgba(94, 69, 48, 0.55)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(86, 157, 224, 0.58)"; + ctx.strokeStyle = "rgba(62, 113, 178, 0.36)"; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.54)"; + ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "water") { ctx.fillStyle = "rgba(86, 157, 224, 0.50)"; ctx.strokeStyle = "rgba(62, 113, 178, 0.42)"; @@ -632,6 +708,70 @@ class Item { ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); ctx.stroke(); } + } else if (this.type === "nest_box") { + ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237"); + ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24"; + ctx.lineWidth = 2.6; + roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b"; + roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6); + ctx.fill(); + ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)"; + ctx.lineWidth = 2.0; + ctx.beginPath(); + ctx.moveTo(-this.r * 0.95, -this.r * 0.50); + ctx.lineTo(this.r * 0.95, -this.r * 0.50); + ctx.moveTo(-this.r * 0.95, this.r * 0.48); + ctx.lineTo(this.r * 0.95, this.r * 0.48); + ctx.stroke(); + ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f"; + ctx.beginPath(); + ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); + ctx.fill(); + } else if (this.type === "ant_nest") { + const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); + ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); + ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = styleNight ? "#211a17" : "#30231d"; + ctx.beginPath(); + ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(38,37,35,0.88)"; + for (let i = 0; i < Math.min(6, count); i++) { + const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0); + const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55); + ctx.beginPath(); + ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(255,248,220,0.82)"; + ctx.font = "bold 10px ui-rounded, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(`${count}`, 0, this.r * 1.25); + } else if (this.type === "ant_corpse") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker"); + ctx.save(); + ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26); + ctx.scale(1, -1); + ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1); + if (img) { + const w = this.r * 3.9; + const metrics = getImageMetrics("ant_worker"); + const h = w * (metrics?.ratio || 0.52); + ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h); + } else { + ctx.fillStyle = "rgba(42,39,35,0.85)"; + ctx.beginPath(); + ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); } else if (this.type === "ball") { const speed = Math.hypot(this.vx || 0, this.vy || 0); const moving = clamp(speed / 260, 0, 1); @@ -742,4 +882,3 @@ class Item { ctx.restore(); } } - diff --git a/js/main.js b/js/main.js index d2e6542..3b9286f 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,25 @@ let fpsFrames = 0; let perfResizeTimer = 0; let lastPerfLevel = 0; window.__tarinaiFps = 0; + +function setLoadingProgress(text = "", ratio = 0) { + const screen = document.getElementById("loadingScreen"); + if (!screen) return; + const pct = clamp(Number(ratio) || 0, 0, 1); + const label = document.getElementById("loadingText"); + const bar = document.getElementById("loadingBar"); + const percent = document.getElementById("loadingPercent"); + if (label && text) label.textContent = text; + if (bar) bar.style.width = `${Math.round(pct * 100)}%`; + if (percent) percent.textContent = `${Math.round(pct * 100)}%`; +} + +function hideLoadingScreen() { + setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1); + const screen = document.getElementById("loadingScreen"); + if (!screen) return; + window.setTimeout(() => screen.classList.add("hidden"), 120); +} function loop() { const t = nowSec(); const rawDt = t - last; @@ -38,16 +57,25 @@ function loop() { requestAnimationFrame(loop); } -loadImages().then(() => { +setLoadingProgress("\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d", 0.08); +loadImages(null, (done, total) => { + const p = total > 0 ? done / total : 1; + setLoadingProgress(`\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d ${done}/${total}`, 0.08 + p * 0.46); +}).then(() => { + setLoadingProgress("UI\u3092\u6e96\u5099\u4e2d", 0.62); bindUI(); + setLoadingProgress("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u69cb\u7bc9\u4e2d", 0.72); applyFieldLayout("garden"); world.reset(null, "garden"); selectTool("observe"); + setLoadingProgress("\u8868\u793a\u3092\u521d\u671f\u5316\u4e2d", 0.84); renderLog(world.logs); renderArchive(); render(); renderStats(); syncTopButtons(); if (typeof startBackgroundImageLoading === "function") startBackgroundImageLoading(); + setLoadingProgress("\u30b7\u30df\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u59cb\u4e2d", 0.94); loop(); + requestAnimationFrame(hideLoadingScreen); }); diff --git a/js/render.js b/js/render.js index 2b624db..1b59734 100644 --- a/js/render.js +++ b/js/render.js @@ -124,6 +124,77 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) { return; } +function placementPreviewFor(world) { + const p = world?.pointer; + const type = toolItemType(world?.tool || ""); + if (!p?.inside || !type) return null; + const pad = CONFIG.worldPadding || 30; + let x = p.x; + let y = p.y; + const r = itemRadiusFor(type, 12); + const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); + const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; + let blocked = false; + let tmp = { type, x, y, r, dead: false }; + if ((type === "fence_v" || type === "fence_h") && world.fenceRect) { + const rawRect = world.fenceRect(tmp); + return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) }; + } + if (type === "nest_box" && world.nestBoxBaseRect) { + const rect = world.nestBoxBaseRect(tmp); + return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) }; + } + if (type === "grass") { + blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55)); + } + if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp); + return { type, x, y, r, blocked }; +} + +function drawPlacementPreview(ctx, world) { + const preview = placementPreviewFor(world); + if (!preview) return; + const { type, x, y, r, rect, blocked } = preview; + const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10; + ctx.save(); + ctx.globalAlpha = blocked ? 0.44 : 0.72; + ctx.lineWidth = blocked ? 2.2 : 2.0; + ctx.setLineDash(blocked ? [5, 4] : [7, 5]); + ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)"; + ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)"; + if (rect) { + roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8); + ctx.fill(); + ctx.stroke(); + if (type === "nest_box" && world.nestBoxSolidRects) { + ctx.setLineDash([3, 4]); + ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)"; + for (const part of world.nestBoxSolidRects({ type, x, y, r, dead: false })) { + ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); + } + } + } else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { + ctx.beginPath(); + ctx.arc(x, y, Math.max(16, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.ellipse(x, y, Math.max(14, r * 1.55), Math.max(9, r * 0.86), 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.setLineDash([]); + ctx.globalAlpha = blocked ? 0.52 : 0.70; + ctx.beginPath(); + ctx.moveTo(x - 8, y); + ctx.lineTo(x + 8, y); + ctx.moveTo(x, y - 8); + ctx.lineTo(x, y + 8); + ctx.stroke(); + ctx.restore(); +} + function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) { if (tarinai.dead || tarinai.world.selected !== tarinai) return; ctx.save(); @@ -580,6 +651,7 @@ function render() { if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); beginFieldTransform(); + drawPlacementPreview(ctx, world); for (const it of world.items) { if (it.type === "trace" || it.type === "splat") it.draw(ctx, world.time, lighting); } @@ -633,6 +705,7 @@ function render() { } }; drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); + drawNestBoxTooltip(ctx, world); // Time HUD ctx.save(); @@ -675,6 +748,37 @@ function render() { } } +function drawNestBoxTooltip(ctx, world) { + const info = world.pointerNestBoxInfo?.(); + if (!info) return; + const box = info.box; + const capacity = info.capacity || world.nestBoxCapacity?.(box) || 5; + const title = `\u5de3\u7bb1 (${info.occupants.length}/${capacity})\u5339`; + const lines = info.occupants.length + ? [title, ...info.occupants.map(t => t.name)] + : [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"]; + ctx.save(); + ctx.font = "12px Yomogi, sans-serif"; + const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); + const lineH = 17; + const w = Math.min(240, Math.max(92, textW + 22)); + const h = Math.max(38, 16 + lines.length * lineH); + const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y }; + const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8); + const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8); + ctx.fillStyle = "rgba(46,38,30,0.62)"; + ctx.strokeStyle = "rgba(255,248,220,0.72)"; + ctx.lineWidth = 1.2; + roundedRect(ctx, x, y, w, h, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#fff8e6"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + lines.forEach((line, i) => ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2)); + ctx.restore(); +} + function drawSelectedCard(ctx, world, lighting) { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) return; @@ -699,10 +803,9 @@ function drawSelectedCard(ctx, world, lighting) { const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : ""; const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : ""; ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35); - const personality = t.personalityLabel ? t.personalityLabel() : (t.personality || "\u4e0d\u660e"); - ctx.fillText(`\u6027\u683c ${personality}`, x + 10, y + 52); const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : ""; - ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 68); + ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52); + ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68); ctx.restore(); } diff --git a/js/sim.js b/js/sim.js deleted file mode 100644 index da252cd..0000000 --- a/js/sim.js +++ /dev/null @@ -1,3 +0,0 @@ -"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 index 55fa064..a8330e9 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -64,19 +64,187 @@ function stableUnit(seed, salt = "") { 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, play: 0.78 }, - 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, play: 0.84 }, - 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, play: 0.68 }, - 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, play: 1.08 }, - 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, play: 0.82 }, - playful: { label: "\u904a\u3073\u597d\u304d", desc: "\u30dc\u30fc\u30eb\u3084\u52d5\u304f\u3082\u306e\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3084\u3059\u3044", fear: 0.92, fight: 0.74, social: 1.10, relation: 1.04, sleep: 0.94, zunda: 0.96, play: 1.72 }, - 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, play: 0.86 }, -}; -const PERSONALITY_IDS = Object.keys(PERSONALITIES); +const PERSONALITIES = Object.freeze({}); +const PERSONALITY_IDS = []; function personalityForSeed(seed) { - return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm"; + return ""; +} + +const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"]; +const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 }; +const FRIEND_AFFINITY_THRESHOLD = 16; +const PERSONALITY_LABELS = { + aggression: { label: "\u653b\u6483\u6027", low: "\u304a\u3060\u3084\u304b", high: "\u6012\u308a\u3063\u307d\u3044" }, + openness: { label: "\u958b\u653e\u6027", low: "\u4fdd\u5b88\u7684", high: "\u904a\u3073\u597d\u304d" }, + sociability: { label: "\u793e\u4ea4\u6027", low: "\u4e00\u4eba\u597d\u304d", high: "\u4ef2\u9593\u597d\u304d" }, + neuroticism: { label: "\u795e\u7d4c\u8cea\u6027", low: "\u7121\u9813\u7740", high: "\u7e4a\u7d30" }, +}; + +function clampPersonalityValue(value) { + return clamp(Number(value) || 0, -1, 1); +} + +function normalizePersonality(source = null) { + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(source?.[key]); + return out; +} + +function randomBirthPersonality(seed = "") { + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(stableUnit(seed, `personality-${key}`) * 1.2 - 0.6); + return out; +} + +function personalityFromParents(a, b) { + const pa = ensurePersonality(a).currentPersonality; + const pb = ensurePersonality(b).currentPersonality; + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(((pa[key] || 0) + (pb[key] || 0)) / 2 + rand(-0.2, 0.2)); + return out; +} + +function getTraitStrength(value) { + const v = clampPersonalityValue(value); + if (v >= 0.75) return { direction: 1, strength: "strong" }; + if (v >= 0.5) return { direction: 1, strength: "slight" }; + if (v <= -0.75) return { direction: -1, strength: "strong" }; + if (v <= -0.5) return { direction: -1, strength: "slight" }; + return { direction: 0, strength: "neutral" }; +} + +function personalityBand(value) { + const t = getTraitStrength(value); + return t.direction === 0 ? "neutral" : `${t.direction > 0 ? "high" : "low"}-${t.strength}`; +} + +function personalityEffectValue(value) { + const trait = getTraitStrength(value); + if (!trait.direction) return 0; + return trait.direction * (trait.strength === "strong" ? 1 : 0.5); +} + +function personalityReasonText(reason = "") { + const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim(); + const map = { + "after winning fights": "\u55a7\u5629\u306b\u52dd\u3063\u305f\u5f71\u97ff\u3067", + "after losing fights": "\u55a7\u5629\u306b\u8ca0\u3051\u305f\u5f71\u97ff\u3067", + "after being petted": "\u64ab\u3067\u3089\u308c\u305f\u5f71\u97ff\u3067", + "after staying near family": "\u89aa\u5b50\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067", + "after staying near friends": "\u53cb\u9054\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067", + "after having no friends": "\u53cb\u9054\u304c\u3044\u306a\u3044\u6642\u9593\u304c\u7d9a\u3044\u305f\u5f71\u97ff\u3067", + "after repeated contact with poop": "\u305a\u3093\u3061\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", + "after repeated contact with corpses": "\u6b7b\u9ab8\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", + "after being hurt": "\u75db\u307f\u3092\u899a\u3048\u305f\u5f71\u97ff\u3067", + }; + return map[key] || key; +} + +function personalityBecomeText(label = "") { + const text = String(label || ""); + if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u304f"; + return text; +} + +function personalityAppearanceText(label = "") { + const text = String(label || ""); + if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u3044\u69d8\u5b50"; + return `${text}\u306a\u69d8\u5b50`; +} + +function getPersonalityTraitTags(tarinai) { + const p = ensurePersonality(tarinai).currentPersonality; + const tags = []; + for (const key of PERSONALITY_KEYS) { + const trait = getTraitStrength(p[key]); + if (!trait.direction) continue; + const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; + tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label); + } + const cowardly = (p.neuroticism || 0) >= 0.75 && (p.aggression || 0) <= -0.5; + if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5"); + return tags; +} + +function personalityDailyState(tarinai) { + const day = tarinai?.world?.day || 1; + const state = tarinai.personalityDaily || {}; + if (state.day !== day) { + tarinai.personalityDaily = { day, total: 0, byKey: {} }; + return tarinai.personalityDaily; + } + state.byKey = state.byKey || {}; + for (const key of Object.keys(state.byKey)) { + const v = state.byKey[key]; + if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v) }; + else state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0) }; + } + state.total = Math.max(0, Number(state.total) || 0); + tarinai.personalityDaily = state; + return state; +} + +function ensurePersonality(tarinai) { + if (!tarinai) return { birthPersonality: normalizePersonality(), currentPersonality: normalizePersonality() }; + if (!tarinai.birthPersonality) tarinai.birthPersonality = randomBirthPersonality(tarinai.id || Math.random()); + tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality); + tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || tarinai.birthPersonality); + personalityDailyState(tarinai); + return { birthPersonality: tarinai.birthPersonality, currentPersonality: tarinai.currentPersonality }; +} + +function personalityThresholdLogText(tarinai, key, value, reason = "", previousBand = "") { + const trait = getTraitStrength(value); + const name = tarinai?.name || "\u305f\u308a\u306a\u3044"; + if (!trait.direction) { + const oldSide = String(previousBand).startsWith("low") ? "low" : "high"; + const oldLabel = PERSONALITY_LABELS[key]?.[oldSide] || key; + return `\u300c${name}\u300d\u306f${personalityAppearanceText(oldLabel)}\u304c\u76ee\u7acb\u305f\u306a\u304f\u306a\u3063\u305f\u3002`; + } + const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; + const visible = trait.strength === "slight" ? `\u5c11\u3057${personalityBecomeText(label)}` : personalityBecomeText(label); + const prefix = personalityReasonText(reason); + return `\u300c${name}\u300d\u306f${prefix ? `${prefix}\u3001` : ""}${visible}\u306a\u3063\u305f\u3002`; +} + +function adjustPersonality(tarinai, key, delta, reason = "") { + if (!tarinai || !PERSONALITY_KEYS.includes(key)) return 0; + ensurePersonality(tarinai); + const requested = Number(delta) || 0; + if (!requested) return 0; + const state = personalityDailyState(tarinai); + const dir = Math.sign(requested); + const used = state.byKey[key] || { up: 0, down: 0 }; + const dirKey = dir > 0 ? "up" : "down"; + const keyRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.perAxis - (used[dirKey] || 0)); + const totalRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.total - (state.total || 0)); + const allowed = Math.min(Math.abs(requested), keyRoom, totalRoom); + if (allowed <= 0) return 0; + const before = tarinai.currentPersonality[key] || 0; + const beforeBand = personalityBand(before); + const applied = dir * allowed; + const after = clampPersonalityValue(before + applied); + const actual = after - before; + if (!actual) return 0; + tarinai.currentPersonality[key] = after; + state.byKey[key] = used; + used[actual > 0 ? "up" : "down"] = (used[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual); + state.total = (state.total || 0) + Math.abs(actual); + const afterBand = personalityBand(after); + if (beforeBand !== afterBand) { + const text = personalityThresholdLogText(tarinai, key, after, reason, beforeBand); + tarinai.world?.log?.(text, "observe"); + tarinai.addRecord?.(text, "observe"); + } + return actual; +} + +function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) { + const p = ensurePersonality(tarinai).currentPersonality; + const trait = getTraitStrength(p[key]); + if (trait.direction !== Math.sign(direction || 1)) return false; + return trait.strength === "strong" || Math.random() < 0.5; } function relationDefaults() { @@ -85,12 +253,14 @@ function relationDefaults() { function isParentChild(a, b) { if (!a || !b) return false; - return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id)); + const aKey = a.familyKey || a.id; + const bKey = b.familyKey || b.id; + return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey)); } function relationDisplayName(worldRef, id) { if (!id) return "\u306a\u3057"; - const live = worldRef?.tarinai?.find(t => t.id === id); + const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id); if (live) return live.name; const fam = worldRef?.family?.[id]; return fam?.name || "\u4e0d\u660e"; @@ -138,6 +308,10 @@ class Effect { 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 === "heart") { + const s = this.size * (0.80 + (1 - alpha) * 0.36); + ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18); + drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 }); } else if (this.type === "ring") { ctx.strokeStyle = this.color; ctx.lineWidth = 2; @@ -317,4 +491,3 @@ function drawHeartShape(ctx, x, y, size, opts = {}) { ctx.stroke(); ctx.restore(); } - diff --git a/js/tarinai.js b/js/tarinai.js index 5a2edb7..65532f1 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -1,23 +1,43 @@ "use strict"; +function tarinaiRoundRectPath(ctx, x, y, w, h, r) { + const rr = Math.max(0, Math.min(r || 0, Math.abs(w) / 2, Math.abs(h) / 2)); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.lineTo(x + w - rr, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + rr); + ctx.lineTo(x + w, y + h - rr); + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h); + ctx.lineTo(x + rr, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - rr); + ctx.lineTo(x, y + rr); + ctx.quadraticCurveTo(x, y, x + rr, y); +} + class Tarinai { constructor(world, opts = {}) { this.world = world; - this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + this.familyKey = opts.familyKey || opts.archiveKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + this.id = opts.id || ""; + this.liveToken = opts.liveToken || 0; this.name = opts.name || makeName(world); - this.personality = opts.personality || personalityForSeed(this.id); + // Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model. + this.personality = ""; this.type = opts.type || baseSpriteId(); const typeMeta = SPRITES.find(s => s.id === this.type); if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); + this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth); + if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.familyKey); + this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality); + this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null; + ensurePersonality(this); 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.impulseVx = opts.impulseVx ?? 0; + this.impulseVy = opts.impulseVy ?? 0; this.scale = opts.scale ?? rand(0.22, 0.33); this.radius = 56 * this.scale; this.age = opts.age ?? rand(0, 20); @@ -58,8 +78,8 @@ class Tarinai { 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.goodMode = opts.goodMode ?? stableChoice(this.familyKey, "good-mode", displayNormalSprites()) ?? "smile"; + this.facing = opts.facing ?? (stableUnit(this.familyKey, "facing") < 0.5 ? -1 : 1); this.visualFacing = opts.visualFacing ?? this.facing; this.facingLockUntil = opts.facingLockUntil ?? 0; this.eatTimer = opts.eatTimer ?? 0; @@ -77,18 +97,32 @@ class Tarinai { this.birthRitualLeader = !!opts.birthRitualLeader; this.pokeFlashTimer = opts.pokeFlashTimer ?? 0; this.zunchiStain = opts.zunchiStain ?? 0; - this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain"); + this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.familyKey, "zunchi-stain"); this.zunchiDisease = Boolean(opts.zunchiDisease); this.zunchiDiseaseSeverity = opts.zunchiDiseaseSeverity ?? (this.zunchiDisease ? 1 : 0); this.zunchiDiseaseCooldown = opts.zunchiDiseaseCooldown ?? 0; this.totalFightLosses = opts.totalFightLosses || 0; + this.totalFightWins = opts.totalFightWins || 0; this.isZunchiSlave = Boolean(opts.isZunchiSlave); this.formerName = opts.formerName || null; this.loveMochiTimer = opts.loveMochiTimer ?? 0; this.fightMochiTimer = opts.fightMochiTimer ?? 0; + this.sleepDisease = Boolean(opts.sleepDisease); + this.sleepDiseaseCooldown = opts.sleepDiseaseCooldown ?? 0; + this.explosionDisease = Boolean(opts.explosionDisease); + this.explosionDiseaseTimer = opts.explosionDiseaseTimer ?? (this.explosionDisease ? (CONFIG.dayLength || 120) * 0.5 : 0); + this.fightDisease = Boolean(opts.fightDisease); + this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0; + this.lastBleedAt = opts.lastBleedAt ?? -999; + this.favorite = Boolean(opts.favorite); + this.insideNestBoxId = opts.insideNestBoxId || null; + this.nestFade = opts.nestFade ?? (this.insideNestBoxId ? 1 : 0); + this.nestBoxEnteredAt = opts.nestBoxEnteredAt ?? (this.insideNestBoxId ? (world?.time || 0) : -Infinity); + this.nestBoxStayUntil = opts.nestBoxStayUntil ?? (this.insideNestBoxId ? ((world?.time || 0) + 10) : -Infinity); + this.focusPulseTimer = opts.focusPulseTimer ?? 0; if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; this.tempComfort = opts.tempComfort ?? 0.62; - this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; + this.tempTimer = opts.tempTimer ?? stableUnit(this.familyKey, "temp-start") * 0.55; this.parents = opts.parents || []; this.parentNames = opts.parentNames || []; this.children = opts.children || []; @@ -111,58 +145,25 @@ class Tarinai { 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.fallDir = opts.fallDir ?? (stableUnit(this.familyKey, "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.sleepStart = 0.60 + stableUnit(this.familyKey, "sleep-start") * 0.22; + this.sleepEnd = 0.15 + stableUnit(this.familyKey, "sleep-end") * 0.14; + this.sleepFacing = stableUnit(this.familyKey, "sleep-facing") < 0.5 ? -1 : 1; this.nextBubbleAt = 0; this.nextSleepBubbleAt = 0; this.nextFearBubbleAt = 0; this.nextCursorHeartAt = opts.nextCursorHeartAt ?? 0; this.cursorPetting = opts.cursorPetting ?? 0; - this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22; - this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55; + this.aiTimer = opts.aiTimer ?? stableUnit(this.familyKey, "ai-start") * 0.22; + this.envTimer = opts.envTimer ?? stableUnit(this.familyKey, "env-start") * 0.55; this.visibleSpriteId = opts.visibleSpriteId || null; this.spriteLockUntil = opts.spriteLockUntil ?? 0; this.entryTimer = opts.entryTimer ?? 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, - dead: this.dead, deathReason: this.deathReason, lastDamageCause: this.lastDamageCause, lastDamageAmount: this.lastDamageAmount, lastDamageAt: this.lastDamageAt, lastMajorDamageCause: this.lastMajorDamageCause, lastMajorDamageAmount: this.lastMajorDamageAmount, lastMajorDamageAt: this.lastMajorDamageAt, - hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, stressBarTimer: this.stressBarTimer, - 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, - totalFightLosses: this.totalFightLosses, isZunchiSlave: this.isZunchiSlave, formerName: this.formerName, loveMochiTimer: this.loveMochiTimer, fightMochiTimer: this.fightMochiTimer, - zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown, - 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, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting, - entryTimer: this.entryTimer || 0, - }; - } - - 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); } @@ -187,7 +188,7 @@ class Tarinai { 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); + const p = this.world.tarinai.find(t => (t.familyKey || t.id) === id && !t.dead); if (!p) continue; const d = dist(this, p); if (d < bestD) { best = p; bestD = d; } @@ -200,12 +201,65 @@ class Tarinai { return sp ? sp.label : this.type; } + + addRecord(text, kind = "note", options = {}) { + if (!text) return; + if (options.hiddenFromObservation) return; + if (!Array.isArray(this.records)) this.records = []; + const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" }; + const head = this.records[0]; + if (head && head.text === entry.text && Math.abs((head.time || 0) - entry.time) < 0.05) return; + this.records.unshift(entry); + if (this.records.length > 42) this.records.length = 42; + } + personalityProfile() { - return PERSONALITIES[this.personality] || PERSONALITIES.calm; + const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; + const p = ensurePersonality(this).currentPersonality; + const e = { + neuroticism: personalityEffectValue(p.neuroticism), + aggression: personalityEffectValue(p.aggression), + sociability: personalityEffectValue(p.sociability), + openness: personalityEffectValue(p.openness), + }; + return { + ...base, + fear: clamp((base.fear || 1) * (1 + e.neuroticism * 0.38), 0.55, 1.85), + fight: clamp((base.fight || 1) * (1 + e.aggression * 0.55), 0.35, 2.35), + social: clamp((base.social || 1) * (1 + e.sociability * 0.24), 0.62, 1.52), + relation: clamp((base.relation || 1) * (1 + e.sociability * 0.24), 0.62, 1.60), + play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10), + }; } personalityLabel() { - return this.personalityProfile().label || this.personality || "\u4e0d\u660e"; + const tags = getPersonalityTraitTags(this); + return tags.length ? tags.join(" / ") : "\u4e2d\u7acb"; + } + + personalityTags() { return getPersonalityTraitTags(this); } + adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); } + shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); } + + fightWinCount() { + return this.totalFightWins || 0; + } + + fightLossCount() { + return this.totalFightLosses || 0; + } + + maybeBecomeTimidAfterDamage(cause = "") { + if (this.dead) return false; + const losses = this.totalFightLosses || 0; + const wins = this.totalFightWins || 0; + if (losses <= wins) return false; + if (Math.random() >= 0.05) return false; + const changedA = this.adjustPersonality?.("neuroticism", 0.025, "after being hurt") || 0; + const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; + this.fearTimer = Math.max(this.fearTimer || 0, 1.2); + this.stress = clamp((this.stress || 0) + 5, 0, 130); + return Boolean(changedA || changedB); } relationTo(id) { @@ -225,10 +279,12 @@ class Tarinai { if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } } - strongestRelation(kind = "friend") { + strongestRelation(kind = "friend", liveOnly = true) { let bestId = null; - let bestScore = kind === "fear" ? 5 : 8; + let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null; for (const [id, rel] of Object.entries(this.relationships || {})) { + if (liveIds && !liveIds.has(id)) continue; const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); if (score > bestScore) { bestId = id; bestScore = score; } } @@ -241,7 +297,23 @@ class Tarinai { return { friend, fear }; } - bestFriendLive(minScore = 10, maxDist = Infinity) { + currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) { + let count = 0; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null; + for (const [id, rel] of Object.entries(this.relationships || {})) { + if ((rel.affinity || 0) <= minScore) continue; + if (liveIds && !liveIds.has(id)) continue; + count += 1; + } + return count; + } + + sociabilityFriendScale() { + const friends = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + return clamp(0.82 + Math.min(6, friends) * 0.16, 0.82, 1.78); + } + + bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) { let best = null, bestScore = minScore; for (const [id, rel] of Object.entries(this.relationships || {})) { const score = rel.affinity || 0; @@ -303,99 +375,30 @@ class Tarinai { return gained; } - damageCauseLabel(reason = "") { - const text = String(reason || ""); - if (!text) return ""; - if (text.includes("\u3067\u8870\u5f31")) return text.replace(/\u3067\u8870\u5f31.*$/, ""); - if (/\u55a7\u5629|fight|headbutt/.test(text)) return "\u55a7\u5629"; - if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; - if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f"; - if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5"; - if (/\u75c5/.test(text)) { - const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/); - return disease ? disease[1] : "\u305a\u3093\u3061\u75c5"; - } - if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; - if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; - if (/\u5bff\u547d/.test(text)) return "\u5bff\u547d"; - return ""; - } + damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); } + recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); } + rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); } + weakenedDeathReasonFor(cause = "") { return HEALTH.weakenedDeathReasonFor(cause); } + dominantDeathCause(reason = "", opts = {}) { return HEALTH.dominantDeathCause(this, reason, opts); } + normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); } + damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); } - recentDamageCause(maxAge = 10) { - const now = this.world?.time || 0; - if (this.lastDamageCause && now - (this.lastDamageAt || -999) <= maxAge) return this.lastDamageCause; - return ""; - } - - rememberDamage(amount, reason = "") { - const cause = this.damageCauseLabel(reason) || "\u4e8b\u6545"; - const now = this.world?.time || 0; - this.lastDamageCause = cause; - this.lastDamageAmount = Math.max(0, amount || 0); - this.lastDamageAt = now; - if (this.lastDamageAmount >= 10 || this.lastDamageAmount >= Math.max(6, (this.energy || 0) * 0.36)) { - this.lastMajorDamageCause = cause; - this.lastMajorDamageAmount = this.lastDamageAmount; - this.lastMajorDamageAt = now; - } - return cause; - } - - weakenedDeathReasonFor(cause = "") { - const text = String(cause || "").trim(); - if (!text) return ""; - return text.includes("\u3067\u8870\u5f31") ? text : `${text}\u3067\u8870\u5f31`; - } - - dominantDeathCause(reason = "", opts = {}) { - const text = String(reason || "").trim(); - const direct = this.damageCauseLabel(text); - const now = this.world?.time || 0; - const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10; - const recent = this.recentDamageCause ? this.recentDamageCause(20) : ""; - const fighting = this.fightTimer > 0.04 || this.defeatedTimer > 0.04 || this.defeatedById || this.fightWinnerId || recent === "\u55a7\u5629"; - if (recentMajor && (!direct || direct === this.lastMajorDamageCause || opts.fromDamage || this.energy <= 22 || this.mood <= 20 || this.stress >= 118)) { - return this.weakenedDeathReasonFor(this.lastMajorDamageCause); - } - if (direct && direct !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a") return direct; - if (this.zunchiDisease && ((this.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return "\u305a\u3093\u3061\u75c5"; - if (this.hunger >= 108 || (this.hunger >= 94 && this.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return "\u98e2\u9913"; - if (fighting && (this.energy <= 44 || this.stress >= 112 || this.mood <= 22 || opts.fromDamage)) return "\u55a7\u5629"; - if (recent && recent !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && (this.energy <= 34 || this.mood <= 20 || this.stress >= 118 || opts.fromDamage)) return recent; - if (text.includes("\u5bff\u547d")) return "\u5bff\u547d"; - if (direct) return direct; - if (this.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - if (this.energy <= 0.5) return "\u4e8b\u6545"; - return ""; - } - - normalizeDeathReason(reason = "", opts = {}) { - const text = String(reason || "").trim(); - if (text.includes("\u3067\u8870\u5f31")) return text; - const cause = this.dominantDeathCause(text, opts) || "\u4e8b\u6545"; - if (cause.includes("\u3067\u8870\u5f31")) return cause; - const now = this.world?.time || 0; - const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10; - const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== "\u98e2\u9913" && cause !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && cause !== "\u5bff\u547d"; - if (canWeaken && (opts.weakened || (recentMajor && cause === this.lastMajorDamageCause))) return this.weakenedDeathReasonFor(cause); - return cause; - } - - damage(amount, reason = "") { - const before = this.energy; - const loss = Math.max(0, amount || 0); - this.energy = clamp(this.energy - loss, 0, 100); - const actualLoss = Math.max(0, before - this.energy); - let cause = ""; - if (actualLoss > 0.01) { - cause = this.rememberDamage(actualLoss, reason); - this.showHpBar(); - } - if (this.energy <= 0.5) { - const majorNow = actualLoss >= Math.max(10, before * 0.42); - this.die(this.normalizeDeathReason(reason || cause, { fromDamage: true, weakened: majorNow })); - } + wakeFromDamage(reason = "") { + if (this.dead) return false; + const wasSleeping = this.state === "sleep" || this.sleeping || this.insideNestBoxId; + if (!wasSleeping) return false; + const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; + this.sleeping = false; + this.state = "panic"; + this.target = null; + this.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f"; + this.fearTimer = Math.max(this.fearTimer || 0, 0.85); + this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); + this.nestBoxStayUntil = -Infinity; + if (this.insideNestBoxId) this.leaveNestBox(box); + this.vx += rand(-34, 34); + this.vy += rand(-26, 8); + return true; } recoverHealth(amount) { @@ -435,7 +438,7 @@ class Tarinai { } 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"); + return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); } shouldAvoidTarget(target) { @@ -526,12 +529,7 @@ class Tarinai { return null; } - briefDeathReason(reason = this.deathReason) { - const text = String(reason || ""); - if (!text) return ""; - const normalized = this.normalizeDeathReason ? this.normalizeDeathReason(text) : text; - return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized; - } + briefDeathReason(reason = this.deathReason) { return HEALTH.briefDeathReason(this, reason); } variantSprite(category) { const pools = { @@ -545,7 +543,7 @@ class Tarinai { 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"; + return stableChoice(this.familyKey || this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; } infectZunchiDisease(source = null, force = false) { @@ -595,7 +593,7 @@ class Tarinai { } if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) { this.infectZunchiDisease(null, true); - this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); } if (this.zunchiDisease) { this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); @@ -604,11 +602,279 @@ class Tarinai { if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); if (this.zunchiDiseaseSeverity <= 0.04) { - if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); + if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); } } } + hasWaterCurableDisease() { + return Boolean(this.zunchiDisease || this.explosionDisease); + } + + hasZundaCurableDisease() { + return Boolean(this.explosionDisease || this.fightDisease); + } + + infectSleepDisease(source = null) { + if (this.dead || this.sleepDisease) return false; + this.sleepDisease = true; + this.sleepDiseaseCooldown = 8; + this.sleepDiseaseAnchorX = this.x; + this.sleepDiseaseAnchorY = this.y; + this.state = "sleep"; + this.target = null; + this.sleeping = true; + this.fightTimer = 0; + this.intimidateTimer = 0; + this.fightTargetIds = []; + this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(72,76,120,0.76)"); + this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident", { participants: [this] }); + return true; + } + + recoverSleepDisease(reason = "") { + if (!this.sleepDisease) return false; + this.sleepDisease = false; + this.sleepDiseaseCooldown = 14; + this.sleepDiseaseAnchorX = null; + this.sleepDiseaseAnchorY = null; + this.state = "idle"; + this.sleeping = false; + this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"; + this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); + this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + return true; + } + + infectExplosionDisease(source = null) { + if (this.dead || this.explosionDisease) return false; + this.explosionDisease = true; + this.explosionDiseaseTimer = Math.max(this.explosionDiseaseTimer || 0, (CONFIG.dayLength || 120) * 0.5); + this.fearTimer = Math.max(this.fearTimer || 0, 0.55); + this.thought = "\u7206\u767a\u75c5\u3067\u843d\u3061\u7740\u304b\u306a\u3044"; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.2, "!", "rgba(178,78,42,0.82)"); + this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); + return true; + } + + recoverExplosionDisease(reason = "") { + if (!this.explosionDisease) return false; + this.explosionDisease = false; + this.explosionDiseaseTimer = 0; + this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; + this.stress = clamp(this.stress - 10, 0, 130); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); + this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + return true; + } + + recordBleedExposure() { + this.lastBleedAt = this.world?.time || 0; + } + + infectFightDisease(source = null) { + if (this.dead || this.fightDisease) return false; + this.fightDisease = true; + this.fightDiseaseCooldown = 18; + this.fearTimer = Math.max(this.fearTimer || 0, 0.35); + this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)"); + this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight", { participants: [this] }); + return true; + } + + recoverFightDisease(reason = "") { + if (!this.fightDisease) return false; + this.fightDisease = false; + this.fightDiseaseCooldown = 20; + this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; + this.stress = clamp(this.stress - 12, 0, 130); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); + this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); + return true; + } + + freezeSleepDiseaseMotion(dt = 0) { + if (!this.sleepDisease) return false; + if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; + if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; + this.state = "sleep"; + this.sleeping = true; + this.target = null; + this.vx = 0; + this.vy = 0; + this.x = this.sleepDiseaseAnchorX; + this.y = this.sleepDiseaseAnchorY; + this.wanderAngle = this.wanderAngle || 0; + this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; + if (this.world && this.world.time > (this.nextSleepBubbleAt || 0)) { + this.nextSleepBubbleAt = this.world.time + 5.0; + this.world.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + return true; + } + + nestBoxById(id) { + if (!id) return null; + return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; + } + + isInsideNestBox() { + return Boolean(this.insideNestBoxId); + } + + wantsNestBox(box = null) { + if (!box || box.dead || box.type !== "nest_box") return false; + if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true; + if (!(this.state === "sleep" || this.state === "seek_bed")) return false; + return this.target === box || this.insideNestBoxId === box.id; + } + + enterNestBox(box, dt = 0.016) { + if (!box || box.dead || box.type !== "nest_box" || this.dead) return false; + const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; + const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; + const alreadyInside = this.insideNestBoxId === box.id; + if (!alreadyInside && occupants.length >= capacity) return false; + if (!alreadyInside) { + this.nestBoxEnteredAt = this.world?.time || 0; + this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14); + } + this.insideNestBoxId = box.id; + this.target = box; + if (this.state === "seek_bed") this.state = "sleep"; + if (this.state === "sleep") this.sleeping = true; + const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; + const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); + this.x = lerp(this.x, inner.x, pull); + this.y = lerp(this.y, inner.y, pull); + if (this.nestFade >= 0.88 || distXY(this.x, this.y, inner.x, inner.y) < 3.5) { + this.x = inner.x; + this.y = inner.y; + } + this.nestFade = clamp((this.nestFade || 0) + dt * 5.8, 0, 1); + this.vx = 0; + this.vy = 0; + this.impulseVx = 0; + this.impulseVy = 0; + return true; + } + + leaveNestBox(box = null) { + const b = box || this.nestBoxById(this.insideNestBoxId); + if (b && this.world?.nestBoxEntryPoint) { + const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); + this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + this.vx = rand(-8, 8); + this.vy = rand(6, 18); + } + this.insideNestBoxId = null; + this.nestFade = 0; + this.nestBoxEnteredAt = -Infinity; + this.nestBoxStayUntil = -Infinity; + return true; + } + + updateNestBoxPresence(dt = 0) { + const currentBox = this.nestBoxById(this.insideNestBoxId); + const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; + const box = currentBox || targetBox; + if (!box) { + this.insideNestBoxId = null; + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + if (currentBox && !this.wantsNestBox(currentBox)) { + this.leaveNestBox(currentBox); + return null; + } + + if (currentBox) { + if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { + this.state = "sleep"; + this.sleeping = true; + this.target = currentBox; + } + this.enterNestBox(currentBox, dt); + return currentBox; + } + + if (!this.wantsNestBox(targetBox)) { + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; + const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null; + const r = targetBox.r || 42; + const entryD = distXY(this.x, this.y, entry.x, entry.y); + const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y); + const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48; + const captureRange = Math.max(118, r * 2.05, this.radius + 82); + const nearEnough = entryD <= captureRange || centerD <= Math.max(104, r * 1.90) || inDoorColumn; + if (nearEnough) { + this.enterNestBox(targetBox, dt || 0.016); + return targetBox; + } + + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + applyWaterEffect(dt, source = "water") { + const power = Math.max(0, dt || 0); + this.stress = clamp(this.stress - power * 2.4, 0, 130); + this.energy = clamp(this.energy + power * 0.5, 0, 100); + this.hunger = clamp(this.hunger - power * 0.2, 0, 115); + this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); + if (this.zunchiDisease) { + this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - power * (this.energy > 70 ? 0.050 : 0.028)); + if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { + this.world?.log?.(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); + } + } + if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + } + + updateSpecialDiseases(dt) { + this.sleepDiseaseCooldown = Math.max(0, (this.sleepDiseaseCooldown || 0) - dt); + this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt); + if (this.explosionDisease) { + this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); + this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130)); + if (Math.random() < dt * 2.1) { + const a = rand(0, Math.PI * 2); + this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { + vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)" + })); + } + if (this.explosionDiseaseTimer <= 0.01) { + this.world?.explodeDiseaseTarinai?.(this); + return; + } + if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); + } + const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999); + if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) { + if (Math.random() < dt * 0.000375) this.infectFightDisease(); + } + if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { + if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + } + if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { + this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + if (this.fightDisease) { + this.stress = clamp(this.stress + dt * 0.16, 0, 130); + if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); + if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); + } + } + canEatItemType(type) { return this.isZunchiSlave ? type === "zunchi" : true; } @@ -642,6 +908,9 @@ class Tarinai { rawSpriteId() { if (this.dead) return this.variantSprite("fear"); + if (this.sleepDisease) return this.variantSprite("sleep"); + if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan"; + if (this.fightDisease) return "stress_dizzy"; if (this.isZunchiSlave) return "zunchi_slave"; if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; @@ -651,7 +920,7 @@ class Tarinai { const low = this.lowHealthSprite(); if (low === "stretch") 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 === "intimidate" || this.state === "ant_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 === "fight" || this.fightTimer > 0.04) return "angry"; if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); @@ -684,8 +953,42 @@ class Tarinai { return this.visibleSpriteId; } + + isAwakeCursorFriendly() { + if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false; + if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false; + if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false; + if (this.mood < 46 || this.stress > 64) return false; + return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); + } + + applyCursorContactCare(dt) { + const pointer = this.world?.pointer; + if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; + const d = distXY(this.x, this.y, pointer.x, pointer.y); + const touch = this.radius * 1.35; + if (d > touch) return; + const p = clamp(1 - d / touch, 0, 1); + const relief = dt * (1.35 + 2.65 * p); + this.stress = clamp((this.stress || 0) - relief, 0, 130); + this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); + this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); + this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); + this.goodMode = "smile"; + if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { + this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); + for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { + this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { + vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" + })); + } + } + } + update(dt) { if (this.dead) return; + this.prevX = this.x; + this.prevY = this.y; const minute = dt / 60; this.age += dt; @@ -703,6 +1006,8 @@ class Tarinai { this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); this.updateZunchiDisease(dt); + this.updateSpecialDiseases(dt); + if (this.dead) return; this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); if (this.fightMochiTimer > 0.04) { @@ -720,7 +1025,13 @@ class Tarinai { } } this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); + this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + this.applyCursorContactCare(dt); const wasFighting = this.fightTimer > 0.04; this.fightTimer = Math.max(0, this.fightTimer - dt); this.fightCooldown = Math.max(0, this.fightCooldown - dt); @@ -792,10 +1103,15 @@ class Tarinai { this.energy = clamp(this.energy, 0, 100); this.stress = clamp(this.stress, 0, 130); + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + 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); + const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055); if (this.aiTimer >= aiInterval) { const aiDt = Math.min(this.aiTimer, 0.48); this.aiTimer = 0; @@ -821,6 +1137,7 @@ class Tarinai { } this.maintainTargetProgress(dt); this.move(dt); + this.updateNestBoxPresence(dt); if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); this.checkLife(dt); } @@ -830,9 +1147,16 @@ class Tarinai { 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); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); + } + const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; + const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + if (friend) { + this.stress = clamp(this.stress - dt * 0.36, 0, 130); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); + } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { + this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); } - 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; @@ -840,10 +1164,16 @@ class Tarinai { const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); grassComfort += clamp(1 - d / 105, 0, 1) * lush; } + let nestComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 120)) { + if (!it || it.dead || it.type !== "nest_box") continue; + nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); + } + if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130); 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); + if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); } } @@ -862,18 +1192,55 @@ class Tarinai { } isSleepFurniture(target) { - return Boolean(target && target.type === "bed"); + return Boolean(target && (target.type === "bed" || target.type === "nest_box")); } sleepFurnitureLabel(target) { - return "\u5e72\u8349\u5bdd\u5e8a"; + return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; + } + + isServingFoodItem(it) { + return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); + } + + foodItemHasServingLeft(it) { + if (!it || it.dead) return false; + if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; + this.ensureFoodServings(it); + return (it.foodServingsRemaining || 0) > 0; + } + + ensureFoodServings(it) { + if (!this.isServingFoodItem(it)) return; + const max = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize || "medium") : 5; + if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max; + if (!Number.isFinite(it.foodServingsRemaining)) { + const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58 }[it.type] || 62; + const ratio = clamp((it.amount ?? original) / original, 0.05, 1); + it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio)); + } + it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax); + it.amount = it.foodServingsRemaining; + } + + consumeFoodServing(it, nutrition) { + this.ensureFoodServings(it); + if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; + it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); + it.amount = it.foodServingsRemaining; + return nutrition; } 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; + if (bed.type === "nest_box") { + if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this); + if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); + return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; + } + const angle = stableUnit(this.familyKey || 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; + const ring = baseRing + stableUnit(this.familyKey || 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), @@ -914,6 +1281,7 @@ class Tarinai { 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); + if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); 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; } @@ -968,12 +1336,46 @@ class Tarinai { this.fightWinnerId = null; } - const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null; + const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 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"; + this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; + return; + } + + const nearbyAnts = (this.world.ants || []).filter(a => a && !a.dead && distXY(this.x, this.y, a.x, a.y) <= (a.kind === "queen" ? 112 : 82)); + let nearestAnt = null; + let nearestAntDist = Infinity; + for (const ant of nearbyAnts) { + const ad = distXY(this.x, this.y, ant.x, ant.y); + if (ad < nearestAntDist) { + nearestAnt = ant; + nearestAntDist = ad; + } + } + if (nearestAnt) { + const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1); + if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { + if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { + // Intimidation has no effect on ants. This is only a visible reaction. + this.state = "ant_intimidate"; + this.target = nearestAnt; + this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); + this.thought = "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; + return; + } + this.state = "ant_attack"; + this.target = nearestAnt; + this.goodMode = "angry"; + this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; + return; + } + this.state = "panic"; + this.target = this.panicDestination(nearestAnt); + this.fearTimer = Math.max(this.fearTimer, 0.34); + this.thought = "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b"; return; } @@ -982,8 +1384,8 @@ class Tarinai { ? dayP >= this.sleepStart && dayP < this.sleepEnd : dayP >= this.sleepStart || dayP < this.sleepEnd; - if (this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const opponent = this.world.nearestOther(this, 320); + if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o)); if (opponent) { const d = dist(this, opponent); if (d <= Math.max(48, this.radius + opponent.radius + 18)) { @@ -1007,33 +1409,54 @@ class Tarinai { 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"); + this.world.relationNotice(this.id, rival.id, "avoid", 22); return; } } - if (this.zunchiDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const water = this.world.nearest(this, ["water"], 360); + if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const water = this.world.nearest(this, ["water", "water_bowl"], 760); if (water && !water.dead) { this.state = "seek_water"; this.target = water; - this.thought = "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; + this.thought = this.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } - this.state = "zunchi_sick"; + if (this.zunchiDisease) { + this.state = "zunchi_sick"; + this.target = null; + this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); + return; + } + } + + if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const cureFood = this.world.nearest(this, ["sweet"], 700); + if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { + this.state = "seek_food"; + this.target = cureFood; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); + this.thought = this.fightDisease ? "\u304d\u305a\u3064\u304d\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; + return; + } + } + + if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + this.state = "fight_sick"; this.target = null; - this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); + this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); return; } if (!this.isZunchiSlave) { - const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi"], wasSleeping ? 520 : 390); - if (specialMochi && specialMochi.amount > 1.2 && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) { + const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390); + if (specialMochi && this.foodItemHasServingLeft(specialMochi) && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) { this.state = "seek_food"; this.target = specialMochi; this.foodReactTimer = Math.max(this.foodReactTimer, 0.34); - this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; + this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : (specialMochi.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u306d\u3080\u308a\u85ac\u3092\u63a2\u3057\u3066\u3044\u308b"); return; } } else if (this.hunger > 20) { @@ -1051,7 +1474,7 @@ class Tarinai { 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)) { + if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { this.state = "seek_food"; this.target = zunda; this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); @@ -1071,7 +1494,7 @@ class Tarinai { if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) { let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; - if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360); + if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); if (bed && !wasSleeping && this.energy > 18) { const spot = this.sleepSpotFor(bed); if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { @@ -1088,7 +1511,7 @@ class Tarinai { 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"; + this.thought = bed ? (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b") : "\u7720\u3063\u3066\u3044\u308b"; return; } @@ -1097,7 +1520,7 @@ class Tarinai { 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"); + const friendly = !hostile && this.isAwakeCursorFriendly(); if (hostile) { this.state = "cursor_enemy"; this.target = { x: pointer.x, y: pointer.y, dead: false }; @@ -1120,19 +1543,21 @@ class Tarinai { } const playTrait = this.personalityProfile().play ?? 1; - const ballRange = this.personality === "playful" ? 540 : 240 + 60 * playTrait; + const openPlay = this.shouldApplyPersonalityBehavior("openness", 1); + const closedPlay = this.shouldApplyPersonalityBehavior("openness", -1); + const ballRange = openPlay ? 540 : (closedPlay ? 170 : 240 + 60 * playTrait); const ball = this.world.nearest(this, ["ball"], ballRange); if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0; - const baseChance = this.personality === "playful" ? 0.74 : 0.08; + const baseChance = openPlay ? 0.74 : (closedPlay ? 0.025 : 0.08); const movingBonus = clamp(ballSpeed / 120, 0, 0.42); const socialBonus = this.loneliness > 48 ? 0.10 : 0; - if (chasers < 5 && (this.personality === "playful" || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { + if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { this.state = "play_ball"; this.target = ball; this.goodMode = "smile"; - this.thought = this.personality === "playful" ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; + this.thought = openPlay ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; return; } } @@ -1149,16 +1574,16 @@ class Tarinai { } } - if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { + if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "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"; + this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; return; } if (this.hunger > 62) { - const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"]; + const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "ant_corpse"]; const item = this.world.nearest(this, itemTypes, 420); if (item) { this.state = "seek_food"; @@ -1169,8 +1594,10 @@ class Tarinai { } } - if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) { - const other = this.world.nearestOther(this, 300); + const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1); + const lonerSeek = this.shouldApplyPersonalityBehavior("sociability", -1); + if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { + const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); if (other) { this.state = "seek_friend"; this.target = other; @@ -1200,7 +1627,7 @@ class Tarinai { 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`); + if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] }); } interactWithItems(dt) { @@ -1211,20 +1638,21 @@ class Tarinai { 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 (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it, 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); + if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } this.eatTimer = Math.max(this.eatTimer, 0.32); this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); @@ -1238,20 +1666,20 @@ class Tarinai { 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.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); this.lastLog = this.world.time; } break; } } - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi") && it.amount > 1.2) { - const eating = Math.min(it.amount, 6.8); + if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it, 6.8); + if (eating <= 0) continue; 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 * 0.82; this.recoverHealth(eating * 0.24); this.affection += eating * 0.04; @@ -1260,31 +1688,40 @@ class Tarinai { this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); this.lastMealColor = "#ff7bab"; - } else { + } else if (it.type === "fight_mochi") { this.stress = clamp(this.stress + eating * 0.22, 0, 130); this.fearTimer = Math.max(this.fearTimer || 0, 0.32); this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); this.lastMealColor = "#e07c43"; + } else { + this.stress = clamp(this.stress - eating * 0.10, 0, 130); + this.energy = clamp(this.energy - eating * 0.20, 0, 100); + this.fearTimer = Math.max(0, (this.fearTimer || 0) - 0.18); + this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - 2.5); + this.bubble("Zzz...", 2.3, "rgba(104,84,168,0.78)"); + this.lastMealColor = "#b89cff"; + if (!this.sleepDisease && Math.random() < Math.min(0.98, 0.35 + eating * 0.075)) this.infectSleepDisease(it); } this.eatTimer = Math.max(this.eatTimer, 0.30); this.eatCooldown = rand(0.38, 0.68); 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 === "love_mochi" ? "#ff7bab" : "#e07c43"); + this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : (it.type === "fight_mochi" ? "#e07c43" : "#b89cff")); } this.makePoop(eating * 0.20); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { - this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : "\u3051\u3093\u304b\u9905"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food"); + this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : (it.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905" : "\u306d\u3080\u308a\u85ac")}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); this.lastLog = this.world.time; } break; } if (canBite && it.type === "grass") { - const bite = Math.min(it.amount, rand(4.2, 7.2)); + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); this.state = "eat"; this.target = it; this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); @@ -1309,12 +1746,39 @@ class Tarinai { break; } + if (canBite && it.type === "ant_corpse") { + const bite = Math.min(it.amount, rand(3.2, 5.4)); + if (bite > 0) { + 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.62; + this.recoverHealth(bite * 0.12); + this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); + this.eatTimer = Math.max(this.eatTimer, 0.26); + this.eatCooldown = rand(0.75, 1.25); + this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); + this.lastMealColor = "#4a3f36"; + this.makePoop(bite * 0.08); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; } + break; + } + } + if (it.type === "zunchi") { const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (!this.isZunchiSlave) this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130); + if (!this.isZunchiSlave) { + this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); + if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); + } this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); if (canZunchiBite) { - const bite = Math.min(it.amount, this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)); + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); this.state = "eat"; this.target = it; this.vx *= Math.pow(0.18, dt * 60); @@ -1322,7 +1786,8 @@ class Tarinai { it.amount -= bite; this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18); this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); - this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52), 0, 130); + this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); + if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); this.lastMealColor = "#6b8d3f"; this.eatTimer = Math.max(this.eatTimer, 0.24); @@ -1335,18 +1800,9 @@ class Tarinai { } } - if (it.type === "water") { - this.stress -= dt * 2.4; - this.energy += dt * 0.5; - this.hunger -= dt * 0.2; - this.zunchiStain = clamp((this.zunchiStain || 0) - dt * 10.5, 0, 100); - if (this.zunchiDisease) { - this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * (this.energy > 70 ? 0.050 : 0.028)); - if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { - this.world.log(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); - } - } - it.amount -= dt * 2.4; + if (it.type === "water" || it.type === "water_bowl") { + this.applyWaterEffect(dt, it.type); + if (it.type === "water") it.amount -= dt * 2.4; if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; } @@ -1376,7 +1832,7 @@ class Tarinai { if (it.type === "ball") { const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (this.state === "play_ball" || this.target === it || this.personality === "playful") { + if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { this.goodMode = "smile"; this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); @@ -1384,9 +1840,17 @@ class Tarinai { } } + if (it.type === "splat") { + const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); + if (touch > 0) { + this.stress = clamp(this.stress + touch * dt * 2.8 * this.personalityProfile().fear, 0, 130); + if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); + } + } + if (it.type === "trace") { this.loneliness -= dt * 0.25; - this.stress += dt * 0.06; + this.stress += dt * 0.06 * this.personalityProfile().fear; } } this.hunger = clamp(this.hunger, 0, 115); @@ -1404,16 +1868,16 @@ class Tarinai { if (d < 0.01 || d > 74) continue; closeCount += 1; - const fightMochiContact = this.hasFightMochiEffect() || o.hasFightMochiEffect(); + const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect()); if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) { if (this.world.startForcedFight?.(this, o)) continue; } if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); + if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); + if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } const pairBond = this.world.areCoParents?.(this, o); @@ -1435,13 +1899,13 @@ class Tarinai { 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) { + if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { 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 (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", { participants: [o, this] }); } - 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 ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && 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", { participants: [o, this] }); } if (this.type === "angry") this.stress += dt * 0.08; if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; @@ -1483,19 +1947,23 @@ class Tarinai { const relB = o.relationTo(this.id); const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect(); const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect(); - 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" || battleDrug || o.isZunchiSlave || this.isZunchiSlave); + const aggressiveThis = this.shouldApplyPersonalityBehavior("aggression", 1); + const aggressiveOther = o.shouldApplyPersonalityBehavior?.("aggression", 1); + const calmThis = this.shouldApplyPersonalityBehavior("aggression", -1); + const calmOther = o.shouldApplyPersonalityBehavior?.("aggression", -1); + const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); - const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight; + const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); const conflictBias = (battleDrug ? 2.9 : 1) * (o.isZunchiSlave ? 2.2 : 1) * (this.isZunchiSlave ? 1.15 : 1); const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; - if (d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { + if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { this.world.startConflict(this, o); } - if (!this.isZunchiSlave && !o.isZunchiSlave && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { + if (!this.isZunchiSlave && !o.isZunchiSlave && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { const enough = loveDrug ? (this.hunger < 90 && o.hunger < 92 && this.energy > 18 && o.energy > 18) : (this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44); const lonelyBond = loveDrug ? (this.loneliness > 4 || o.loneliness > 4 || this.affection > 0 || o.affection > 0) : (this.loneliness > 24 || o.loneliness > 24); if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) { @@ -1505,27 +1973,34 @@ class Tarinai { } if (closeCount > 8) { - this.stress += dt * 0.9; + this.stress += dt * 0.9 * this.personalityProfile().fear; 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`); + 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`, null, { participants: [this] }); } } move(dt) { + if (this.freezeSleepDiseaseMotion(dt)) return; let ax = 0, ay = 0; const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); if (this.state === "sleep") { - const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) : 0.72; + const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72; const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const crowdedSleep = bedCrowd > 5; this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, 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 === "nest_box") { + this.updateNestBoxPresence(dt); + this.vx = 0; + this.vy = 0; + return; + } if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { const spot = this.sleepSpotFor(this.target); const dx = spot.x - this.x; @@ -1540,6 +2015,7 @@ class Tarinai { this.y = spot.y; } } + this.updateNestBoxPresence(dt); this.vx *= Math.pow(0.55, dt * 60); this.vy *= Math.pow(0.55, dt * 60); return; @@ -1568,7 +2044,7 @@ class Tarinai { return; } - if (this.intimidateTimer > 0.04 || this.state === "intimidate") { + if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") { this.vx *= Math.pow(0.22, dt * 60); this.vy *= Math.pow(0.22, dt * 60); return; @@ -1598,9 +2074,13 @@ class Tarinai { const bedDx = this.target.x - this.x; const bedDy = this.target.y - this.y; const bedDist = Math.hypot(bedDx, bedDy); - if (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)) { + const reachedSleepFurniture = this.target.type === "nest_box" + ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) + : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); + if (reachedSleepFurniture) { this.state = "sleep"; this.sleeping = true; + if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); return; } } @@ -1628,6 +2108,19 @@ class Tarinai { strength = headbutt > -0.15 ? 2.65 : 0.65; sign = headbutt > -0.15 ? 1 : -1; } + } else if (this.state === "ant_attack") { + strength = d < this.radius + ((this.target && this.target.r) || 4) + 6 ? 0.18 : 1.32; + sign = 1; + if (d < this.radius + ((this.target && this.target.r) || 4) + 10) { + const aggression = Math.max(0, this.currentPersonality?.aggression || 0); + if ((this.world.time || 0) >= (this.nextAntAttackAt || 0)) { + this.nextAntAttackAt = (this.world.time || 0) + 0.34; + if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (4.6 + aggression * 3.4)); + this.fearTimer = Math.max(this.fearTimer, 0.12); + this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; + if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); + } + } } ax += (dx / d) * baseSpeed * sign * strength; ay += (dy / d) * baseSpeed * sign * strength; @@ -1639,6 +2132,10 @@ class Tarinai { this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72; + } else if (this.state === "fight_sick") { + this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; + ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88; + ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88; } else { ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34; @@ -1649,15 +2146,20 @@ class Tarinai { this.vx *= Math.pow(0.84, dt * 8); this.vy *= Math.pow(0.84, dt * 8); - const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : 76); + const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 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 impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; + const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; + this.x += (this.vx + impulseVx) * dt; + this.y += (this.vy + impulseVy) * dt; + const impulseDrag = Math.pow(0.58, dt * 3.2); + this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; + this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; if (this.entryTimer > 0) { this.entryTimer = Math.max(0, this.entryTimer - dt); @@ -1680,7 +2182,7 @@ class Tarinai { reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; } else if (this.hunger >= 108) reason = "\u98e2\u9913"; - else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d"; + else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; else if (this.stress >= 124) { reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; } @@ -1708,8 +2210,10 @@ class Tarinai { if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); } this.world.markDead(this, finalReason); + this.releasedLiveToken = this.liveToken || 0; + this.world.releaseTarinaiLiveId?.(this); audio.death(); - this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death"); + this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] }); if (this.world.selected === this) this.world.selected = null; } @@ -1734,7 +2238,8 @@ class Tarinai { 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; + const diseaseSpin = this.fightDisease ? (t * 3.6 + this.age) : 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 + diseaseSpin; const insideCardboard = false; let drawW = w * this.scale * 0.30; let drawH = h * this.scale * 0.30; @@ -1765,18 +2270,20 @@ class Tarinai { ? this.sleepFacing > 0 : this.facingDir() > 0; const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt; + const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1; + if (nestHideAlpha <= 0.01) return; const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8); const shadowContactY = this.y + bob + (drawH / squish) * 0.30; if (img) { drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { faceRight, - alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92), + alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha, widthScale: this.state === "sleep" ? 1.12 : 1, heightScale: this.state === "sleep" ? 0.88 : 1, }); } else { - drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 }); + drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha }); } drawCreatureGlow(ctx, this, drawW, drawH, lightState); if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { @@ -1790,24 +2297,83 @@ class Tarinai { ctx.save(); ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY); - ctx.rotate(angle); - if (insideCardboard) ctx.globalAlpha *= 0.58; + const observeKind = this.world.observeHighlightKind ? this.world.observeHighlightKind(this) : ""; + if (observeKind && this.world.selected !== this) { + const style = { + parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" }, + child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" }, + friend: { color: "rgba(240,91,135,0.90)", label: "\u53cb\u9054" }, + enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" }, + }[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" }; + ctx.save(); + ctx.globalAlpha = 0.96 * nestHideAlpha; + ctx.shadowColor = style.color; + ctx.shadowBlur = 16; + ctx.strokeStyle = style.color; + ctx.lineWidth = 5.2; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.78, drawH * 0.60, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineWidth = 2.0; + ctx.setLineDash([8, 5]); + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.86, drawH * 0.66, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + if (style.label) { + const labelY = -drawH * 0.62; + ctx.font = `${Math.max(11, this.radius * 0.52)}px Yomogi, ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const labelW = ctx.measureText(style.label).width + 16; + ctx.fillStyle = "rgba(255,252,238,0.92)"; + ctx.strokeStyle = style.color; + ctx.lineWidth = 2.2; + tarinaiRoundRectPath(ctx, -labelW / 2, labelY - 10, labelW, 20, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = style.color; + ctx.fillText(style.label, 0, labelY); + } + ctx.restore(); + } if (this.world.selected === this) { ctx.save(); - ctx.globalAlpha = 0.85; - ctx.strokeStyle = "rgba(165, 220, 98, 0.92)"; - ctx.lineWidth = 2; + const pulse = 0.5 + 0.5 * Math.sin(this.world.time * 7.5); + const jumpPulse = clamp(this.focusPulseTimer || 0, 0, 1); + ctx.globalAlpha = (0.82 + pulse * 0.12) * nestHideAlpha; + ctx.shadowColor = "rgba(124, 210, 76, 0.92)"; + ctx.shadowBlur = 14 + 18 * Math.max(pulse, jumpPulse); + ctx.strokeStyle = "rgba(112, 198, 70, 0.98)"; + ctx.lineWidth = 4.2 + jumpPulse * 2.0; ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2); + ctx.ellipse(0, drawH * 0.14, drawW * (0.74 + jumpPulse * 0.10), drawH * (0.56 + jumpPulse * 0.08), 0, 0, Math.PI * 2); ctx.stroke(); - ctx.setLineDash([5, 4]); - ctx.lineWidth = 1.2; - ctx.strokeStyle = "rgba(255,255,255,0.86)"; + ctx.shadowBlur = 0; + ctx.setLineDash([9, 5]); + ctx.lineWidth = 2.0; + ctx.strokeStyle = "rgba(255,255,255,0.96)"; ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2); + ctx.ellipse(0, drawH * 0.14, drawW * 0.88, drawH * 0.68, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = "rgba(255,252,232,0.94)"; + ctx.strokeStyle = "rgba(112,198,70,0.88)"; + ctx.lineWidth = 2; + const focusLabel = "\u6ce8\u76ee"; + ctx.font = `${Math.max(11, this.radius * 0.54)}px Yomogi, ui-rounded, sans-serif`; + const lw = ctx.measureText(focusLabel).width + 18; + const ly = -drawH * 0.64; + tarinaiRoundRectPath(ctx, -lw / 2, ly - 11, lw, 22, 11); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(72,126,42,0.96)"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(focusLabel, 0, ly); 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(); @@ -1820,6 +2386,11 @@ class Tarinai { ctx.restore(); } + ctx.rotate(angle); + ctx.globalAlpha *= nestHideAlpha; + if (insideCardboard) ctx.globalAlpha *= 0.58; + + if (this.eatTimer > 0.02) { ctx.save(); ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); @@ -1833,7 +2404,7 @@ class Tarinai { if (this.pokeFlashTimer > 0.03) { ctx.save(); - ctx.globalAlpha = this.pokeFlashTimer * 0.9; + ctx.globalAlpha = this.pokeFlashTimer * 0.9 * nestHideAlpha; 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); @@ -1846,7 +2417,7 @@ class Tarinai { const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0; ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); - const count = this.state === "cursor_friend" ? (cursorLove > 0.18 ? 5 : 3) : 2; + const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2; for (let i = 0; i < count; i++) { const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05; const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit; @@ -1910,6 +2481,25 @@ class Tarinai { ctx.restore(); + if (this.favorite) { + ctx.save(); + ctx.globalAlpha = nestHideAlpha; + const starSize = Math.max(18, this.radius * 0.88); + const sx = this.x; + const sy = this.y - drawH * 0.64 - 20; + ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.shadowColor = "rgba(255, 198, 28, 0.82)"; + ctx.shadowBlur = 14; + ctx.lineWidth = Math.max(2, starSize * 0.12); + ctx.strokeStyle = "rgba(92, 62, 0, 0.86)"; + ctx.fillStyle = "rgba(255, 218, 46, 1)"; + ctx.strokeText("\u2605", sx, sy); + ctx.fillText("\u2605", sx, sy); + ctx.restore(); + } + const hpActive = (this.hpBarTimer || 0) > 0; const stressActive = (this.stressBarTimer || 0) > 0; if (hpActive || stressActive) { @@ -1933,18 +2523,18 @@ class Tarinai { }; if (stressActive) { const pct = clamp((this.stress || 0) / 130, 0, 1); - drawMeter(baseY - (hpActive ? 11 : 0), pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); + drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); } if (hpActive) { const pct = clamp(this.energy / 100, 0, 1); - drawMeter(baseY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); + drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); } } } contains(x, y) { + if (this.insideNestBoxId || (this.nestFade || 0) >= 0.92) return false; const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; return distXY(this.x, this.y, x, y) < hit; } } - diff --git a/js/text_catalog.js b/js/text_catalog.js new file mode 100644 index 0000000..7e58f38 --- /dev/null +++ b/js/text_catalog.js @@ -0,0 +1,54 @@ +"use strict"; + +const TEXT_CATALOG = { + toolTips: toolTipsFromDefinitions(), + ecologyCards: [ + { + title: "\u305f\u308a\u306a\u3044\u3068\u306f", + image: "assets/sprites/tarinai_01_smile.png", + text: "\u5ead\u3067\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u7a7a\u8179\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002", + }, + { + title: "\u4f53\u529b\u3068\u30b9\u30c8\u30ec\u30b9", + image: "assets/ui/ecology_hp_stress.png", + text: "\u30d0\u30fc\u306f\u4e0a\u304c\u4f53\u529b\u3001\u4e0b\u304c\u30b9\u30c8\u30ec\u30b9\u3002\u4f53\u529b\u306f\u3051\u304c\u3084\u4e8b\u6545\u3067\u6e1b\u308a\u30010\u3067\u6b7b\u4ea1\u3002\u30b9\u30c8\u30ec\u30b9\u306f\u6050\u6016\u3001\u6df7\u96d1\u3001\u6c5a\u308c\u3001\u3051\u3093\u304b\u3067\u5897\u3048\u3001\u9ad8\u3044\u3068\u884c\u52d5\u304c\u4e71\u308c\u308b\u3002", + }, + { + title: "\u66ae\u3089\u3057\u3068\u6b32\u6c42", + image: "assets/sprites/tarinai_05_drool.png", + text: "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3001\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002", + }, + { + title: "\u6027\u683c\u3068\u500b\u6027", + image: "assets/sprites/tarinai_21_normal_happy.png", + text: "\u653b\u6483\u6027\u3001\u958b\u653e\u6027\u3001\u793e\u4ea4\u6027\u3001\u795e\u7d4c\u8cea\u6027\u304c\u7d4c\u9a13\u3067\u5c11\u3057\u305a\u3064\u5909\u308f\u308b\u3002\u95be\u5024\u3092\u8d85\u3048\u308b\u3068\u6027\u683c\u30bf\u30b0\u3068\u884c\u52d5\u5dee\u306b\u51fa\u308b\u3002", + }, + { + title: "\u5bb6\u65cf\u3068\u95a2\u4fc2", + image: "assets/sprites/tarinai_27_birth_ritual.png", + text: "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u53cb\u9054\u3001\u6575\u5bfe\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u73fe\u5728\u6027\u683c\u3092\u53d7\u3051\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002", + }, + { + title: "\u75c5\u6c17\u3068\u6cbb\u7642", + image: "assets/sprites/tarinai_24_stress_dizzy.png", + text: "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u304d\u305a\u3064\u304d\u75c5\u304c\u3042\u308b\u3002\u75c5\u6c17\u3054\u3068\u306b\u6c34\u3084\u305a\u3093\u3060\u9905\u306a\u3069\u3092\u6c42\u3081\u308b\u3002", + }, + { + title: "\u3051\u3093\u304b\u3068\u4e8b\u6545", + image: "assets/sprites/tarinai_26_intimidate.png", + text: "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u3051\u3093\u304b\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002", + }, + { + title: "\u9053\u5177\u3068\u4ecb\u5165", + image: "assets/sprites/tarinai_18_fear.png", + text: "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002", + }, + { + title: "\u89b3\u5bdf\u306e\u65b9\u6cd5", + image: "assets/sprites/tarinai_11_weak.png", + text: "\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7d71\u8a08\u304b\u3089\u500b\u4f53\u306e\u5909\u5316\u3092\u8ffd\u3048\u308b\u3002", + }, + ], +}; + +window.TEXT_CATALOG = TEXT_CATALOG; diff --git a/js/ui.js b/js/ui.js index 78692a1..993a464 100644 --- a/js/ui.js +++ b/js/ui.js @@ -13,6 +13,8 @@ const ui = { creditsCloseBtn: document.getElementById("creditsCloseBtn"), toolPalette: document.getElementById("toolPalette"), toolCard: document.getElementById("toolCard"), + selectedCard: document.getElementById("selectedCard"), + selectedCloseBtn: document.getElementById("selectedCloseBtn"), colonyChartTabs: document.getElementById("colonyChartTabs"), selectedInfo: document.getElementById("selectedInfo"), log: document.getElementById("log"), @@ -22,6 +24,7 @@ const ui = { colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), + archiveUpdateToggleBtn: document.getElementById("archiveUpdateToggleBtn"), archiveResetBtn: document.getElementById("archiveResetBtn"), archiveResetDialog: document.getElementById("archiveResetDialog"), archiveResetCancelBtn: document.getElementById("archiveResetCancelBtn"), @@ -29,6 +32,7 @@ const ui = { fieldDialog: document.getElementById("fieldDialog"), fieldCancelBtn: document.getElementById("fieldCancelBtn"), ecologyDialog: document.getElementById("ecologyDialog"), + ecologyGrid: document.getElementById("ecologyGrid"), ecologyCloseBtn: document.getElementById("ecologyCloseBtn"), }; @@ -36,6 +40,8 @@ const uiCache = { stats: {}, selectedSnapshot: "", selectedEmpty: false, + selectedCollapsedCategories: new Set(), + archiveUpdateEnabled: false, toolButtons: [], archiveVersion: "", archiveRows: [], @@ -44,9 +50,12 @@ const uiCache = { archiveHtmlCache: new Map(), archiveRenderTimer: 0, archiveRenderForce: false, + archiveRenderRunning: false, archiveScheduled: false, archivePendingOffscreen: false, archivePendingWhileRunning: false, + archivePendingDirtyAfterRun: false, + archiveLastHiddenCheckAt: -Infinity, archiveLastRenderedAt: 0, archiveLastScheduleFamilyVersion: null, archiveDiagnostics: { renders: 0, lastMs: 0, lastFamilies: 0, lastNodes: 0, skippedWhileRunning: 0 }, @@ -57,6 +66,7 @@ const uiCache = { chartBucketStart: -Infinity, chartBucketEnd: -Infinity, chartBucketAccum: null, + environmentInitialRow: null, lastChartSample: -Infinity, lastChartDraw: "", lastChartDrawAt: 0, @@ -117,7 +127,7 @@ function logKindLabel(kind) { } function tarinaiSpritePathForId(id) { - const live = world?.tarinai?.find(t => t.id === id && !t.dead); + const live = world?.tarinai?.find(t => (t.id === id || t.familyKey === id) && !t.dead); const sid = live?.spriteId ? live.spriteId() : live?.type; const fam = world?.family?.[id]; const fallback = fam?.type; @@ -129,9 +139,113 @@ function relationIconHtml(id) { return ``; } +function relationEntries(t, kind = "friend") { + const rows = []; + const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id)); + for (const [id, rel] of Object.entries(t?.relationships || {})) { + if (!liveIds.has(id)) continue; + const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0); + const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD; + if (score <= threshold) continue; + rows.push({ id, score, name: relationDisplayName(world, id) }); + } + rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "ja")); + return rows; +} + +function relationListHtml(t, kind = "friend") { + const rows = relationEntries(t, kind); + if (!rows.length) return "\u306a\u3057"; + const sign = kind === "friend" ? "+" : ""; + return `
${rows.map(r => ` + `).join("")}
`; +} + +function focusTarinaiById(id) { + const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id)); + if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } + world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); + selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } + uiCache.selectedSnapshot = ""; + renderSelected(); + renderStats(); + render?.(); + showToast(`\u300c${t.name}\u300d\u3078\u79fb\u52d5\u3057\u307e\u3057\u305f\u3002`); + return true; +} + +function quoteNameInRecordText(text, name) { + const raw = String(text || ""); + const n = String(name || "").trim(); + if (!n) return raw; + const parts = raw.split(n); + if (parts.length <= 1) return raw; + let out = ""; + for (let i = 0; i < parts.length - 1; i++) { + const before = parts[i].slice(-1); + const after = parts[i + 1].charAt(0); + out += parts[i] + ((before === "\u300c" && after === "\u300d") ? n : `\u300c${n}\u300d`); + } + return out + parts[parts.length - 1]; +} + +function renameSelectedTarinai(rawName) { + const t = world.selected; + if (!t || t.dead || !world.tarinai.includes(t)) return false; + const next = String(rawName || "").replace(/\s+/g, " ").trim().slice(0, 18); + if (!next) { + uiCache.selectedSnapshot = ""; + renderSelected(); + return false; + } + if (next === t.name) return false; + const oldName = t.name; + t.name = next; + if (t.isZunchiSlave) t.formerName = next; + world.recordFamily?.(t); + world.markFamilyTreeDirty?.("rename"); + uiCache.archiveFamilyVersion = null; + uiCache.selectedSnapshot = ""; + renderSelected(); + renderArchive?.(); + render?.(); + showToast(`\u300c${oldName}\u300d\u3092\u300c${next}\u300d\u306b\u6539\u540d\u3057\u307e\u3057\u305f\u3002`); + return true; +} + +function personalityValueText(value) { + const n = clamp(Number(value) || 0, -1, 1); + return `${n >= 0 ? "+" : ""}${n.toFixed(2)}`; +} + +function personalityRowsHtml(personality = {}) { + const p = normalizePersonality(personality); + return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); +} + +function selectedDataCategoryHtml(id, label, bodyHtml) { + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + const collapsed = uiCache.selectedCollapsedCategories.has(id); + return `
+ +
${bodyHtml}
+
`; +} + function renderSelected() { const t = world.selected; - if (!t || !world.tarinai.includes(t)) { + if (!t || t.dead || !world.tarinai.includes(t)) { + ui.selectedCard?.classList.add("hidden"); if (uiCache.selectedEmpty) return; uiCache.selectedSnapshot = ""; uiCache.selectedEmpty = true; @@ -140,49 +254,106 @@ function renderSelected() { return; } + ui.selectedCard?.classList.remove("hidden"); uiCache.selectedEmpty = false; + const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id; + if (activeNameInput) return; const rel = t.relationSummary ? t.relationSummary() : {}; const fightRecord = relationFightRecord(t); + const generationDisplay = t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : ""); + ensurePersonality(t); + const personalityTags = t.personalityTags ? t.personalityTags() : getPersonalityTraitTags(t); + const personalitySnapshot = [ + ...PERSONALITY_KEYS.map(key => personalityValueText(t.birthPersonality?.[key])), + ...PERSONALITY_KEYS.map(key => personalityValueText(t.currentPersonality?.[key])), + personalityTags.join(",") + ].join(","); const snapshot = [ - t.id, t.type, t.name, (t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "")), t.state, t.personality, t.thought, targetLabel(t.target), + t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), fmt(t.energy), fmt(t.stress), fmt(t.mood), t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), - rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord, + relationEntries(t, "friend").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), relationEntries(t, "enemy").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), fightRecord, t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0), - t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0) + t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0), + t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0, + personalitySnapshot, + (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") ].join("|"); if (uiCache.selectedSnapshot === snapshot) return; uiCache.selectedSnapshot = snapshot; ui.selectedInfo.className = "selected-info"; const effectBadges = []; - effectBadges.push(`\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}`); if (t.isZunchiSlave) effectBadges.push(`\u305a\u3093\u3061\u3069\u308c\u3044`); + if (t.favorite) effectBadges.push(`\u2605`); if (t.zunchiDisease) effectBadges.push(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); + if (t.sleepDisease) effectBadges.push(`\u306d\u3080\u308a\u75c5`); + if (t.explosionDisease) effectBadges.push(`\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}`); + if (t.fightDisease) effectBadges.push(`\u304d\u305a\u3064\u304d\u75c5`); if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); - ui.selectedInfo.innerHTML = ` -
${escapeHtml(t.label())}${escapeHtml(t.name)}${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `\u4e16\u4ee3 ${t.generation}` : ""}${effectBadges.join("")}
-
\u6027\u683c${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}
+ const personalityTagHtml = personalityTags.length + ? personalityTags.map(tag => `${escapeHtml(tag)}`).join("") + : `\u4e2d\u7acb`; + const personalityHtml = ` +
\u6027\u683c\u30bf\u30b0${personalityTagHtml}
+
\u8a95\u751f\u6642\u6027\u683c${personalityRowsHtml(t.birthPersonality)}
+
\u73fe\u5728\u6027\u683c${personalityRowsHtml(t.currentPersonality)}
+ `; + const recordHtml = (t.records || []).slice(0, 8).map(r => { + const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); + const recordText = quoteNameInRecordText(r.text || "", t.name); + return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; + }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; + const basicHtml = ` +
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    \u7406\u7531${escapeHtml(reasonLabel(t))}
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    + `; + const healthHtml = `
    \u7a7a\u8179${fmt(t.hunger)}
    \u5bc2\u3057\u3055${fmt(t.loneliness)}
    \u4f53\u529b${fmt(t.energy)}
    \u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
    \u305a\u3093\u3061\u6c5a\u308c${fmt(t.zunchiStain || 0)}
    \u6c17\u5206${fmt(t.mood)}
    -
    \u4ef2\u826f\u3057${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}
    -
    \u907f\u3051\u308b\u76f8\u624b${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}
    -
    \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
    -
    \u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
    -
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    -
    \u5b50${t.children?.length || 0}
    \u305f\u308a\u306a\u3044${fmt(t.lack)}
    `; + const relationHtml = ` +
    \u53cb\u9054${relationListHtml(t, "friend")}
    +
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    +
    \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
    +
    \u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
    + `; + const familyHtml = ` +
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    +
    \u5b50${t.children?.length || 0}
    + `; + ui.selectedInfo.innerHTML = ` +
    + +
    + ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} +
    + ${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)} + ${selectedDataCategoryHtml("health", "\u4f53\u8abf", healthHtml)} + ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} + ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} + ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} + ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    `)} +
    +
    + `; +} + +function formatRecordTime(time) { + const totalMinutes = Math.floor(((time || 0) % (CONFIG.dayLength || 120)) / (CONFIG.dayLength || 120) * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${hh}:${mm}`; } function stateLabel(s) { @@ -193,6 +364,7 @@ function stateLabel(s) { play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", seek_water: "\u6c34\u3092\u63a2\u3059", zunchi_sick: "\u305a\u3093\u3061\u75c5", + fight_sick: "\u304d\u305a\u3064\u304d\u75c5", system: "\u305a\u3093\u3061\u75c5", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", @@ -216,7 +388,8 @@ function reasonLabel(t) { if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b"; - if (t.state === "seek_water") return "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5"; return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; } @@ -224,7 +397,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", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; + if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", 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"; } @@ -327,7 +500,62 @@ function hydrateLazyImages(root) { } } + +function applyToolTips() { + const tips = window.TEXT_CATALOG?.toolTips || {}; + if (!ui.toolPalette) return; + let pop = document.getElementById("toolTipPopover"); + if (!pop) { + pop = document.createElement("div"); + pop.id = "toolTipPopover"; + pop.className = "tool-tip-popover hidden"; + document.body.appendChild(pop); + } + const showTip = (btn) => { + const baseTip = btn?.dataset?.tip || ""; + if (!baseTip) return; + let tip = baseTip; + if (btn?.dataset?.tool === "nest_box") { + const count = world?.nestBoxToolTipCount?.(); + if (count) tip = `${baseTip} +(${count.current}/${count.max})\u5339`; + } + pop.textContent = tip; + pop.classList.remove("hidden"); + const rect = btn.getBoundingClientRect(); + const popW = pop.offsetWidth || 220; + const popH = pop.offsetHeight || 36; + const left = rect.left - popW - 12; + pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`; + pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`; + }; + const hideTip = () => pop.classList.add("hidden"); + for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) { + const tip = tips[btn.dataset.tool]; + if (!tip) continue; + btn.dataset.tip = tip; + if (btn.dataset.tipBound === "1") continue; + btn.dataset.tipBound = "1"; + btn.addEventListener("mouseenter", () => showTip(btn)); + btn.addEventListener("focus", () => showTip(btn)); + btn.addEventListener("mouseleave", hideTip); + btn.addEventListener("blur", hideTip); + } +} + +function renderEcologyCards() { + if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return; + const cards = window.TEXT_CATALOG?.ecologyCards || []; + ui.ecologyGrid.innerHTML = cards.map(card => ` +
    + +
    ${escapeHtml(card.title || "")}

    ${escapeHtml(card.text || "")}

    +
    `).join(""); + ui.ecologyGrid.dataset.ready = "1"; +} + function openEcologyDialog() { + renderEcologyCards(); hydrateLazyImages(ui.ecologyDialog); ui.ecologyDialog?.classList.remove("hidden"); } @@ -409,9 +637,39 @@ function syncTopButtons() { ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62"; ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`; if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; + syncArchiveUpdateButton(); } -const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "grass", "stone", "firecracker", "fence_v", "fence_h"]); +function archiveAutoUpdateEnabled() { + return Boolean(uiCache.archiveUpdateEnabled); +} + +function syncArchiveUpdateButton() { + const btn = ui.archiveUpdateToggleBtn; + if (!btn) return; + const on = archiveAutoUpdateEnabled(); + btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF"; + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); +} + +function setArchiveUpdateEnabled(on) { + uiCache.archiveUpdateEnabled = Boolean(on); + syncArchiveUpdateButton(); + if (uiCache.archiveUpdateEnabled) { + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + world.familyTreeDirty = true; + scheduleArchiveWindowRender?.(); + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002"); + } else { + if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; } + uiCache.archiveScheduled = false; + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); + } +} + +const SCALABLE_TOOLS = new Set(scalableToolIds()); const SIZE_ORDER = ["small", "medium", "large"]; const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; @@ -455,7 +713,9 @@ function syncToolSizeBadges() { } function bindUI() { - uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool")); + applyToolTips(); + renderEcologyCards(); + uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); syncToolSizeBadges(); ui.colonyChartTabs?.addEventListener("click", (e) => { const btn = e.target.closest("button[data-chart]"); @@ -488,18 +748,78 @@ function bindUI() { const node = e.target.closest("[data-family-tarinai-id]"); if (!node) return; const id = node.getAttribute("data-family-tarinai-id"); - const t = world.tarinai.find(x => x.id === id && !x.dead); + const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } renderStats(); showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); }); + if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { + uiCache.archiveAutoRefreshTimer = window.setInterval(() => { + if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; + scheduleArchiveWindowRender(); + }, 5000); + } + + ui.selectedInfo?.addEventListener("click", (e) => { + const categoryToggle = e.target.closest(".selected-category-toggle"); + if (categoryToggle) { + const category = categoryToggle.closest(".selected-category"); + const id = category?.dataset?.selectedCategory || ""; + if (!category || !id) return; + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + category.classList.toggle("collapsed"); + const collapsed = category.classList.contains("collapsed"); + if (collapsed) uiCache.selectedCollapsedCategories.add(id); + else uiCache.selectedCollapsedCategories.delete(id); + categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + return; + } + const focusBtn = e.target.closest("[data-focus-tarinai-id]"); + if (focusBtn) { + e.preventDefault(); + focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id")); + return; + } + const btn = e.target.closest("[data-selected-action=\"favorite\"]"); + if (!btn || !world.selected || world.selected.dead) return; + world.selected.favorite = !world.selected.favorite; + uiCache.selectedSnapshot = ""; + renderSelected(); + render(); + showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); + }); + ui.selectedInfo?.addEventListener("change", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + renameSelectedTarinai(input.value); + }); + ui.selectedInfo?.addEventListener("keydown", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + if (e.key === "Enter") { e.preventDefault(); input.blur(); } + if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); } + }); + ui.selectedCloseBtn?.addEventListener("click", () => { + world.selected = null; + uiCache.selectedSnapshot = ""; + uiCache.selectedEmpty = false; + renderSelected(); + render(); + }); + ui.pauseBtn?.addEventListener("click", () => { world.paused = !world.paused; const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); - if (world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { + if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { uiCache.archivePendingWhileRunning = false; scheduleArchiveWindowRender(); } @@ -536,6 +856,8 @@ function bindUI() { ui.ecologyDialog?.addEventListener("click", (e) => { if (e.target === ui.ecologyDialog) closeEcologyDialog(); }); + ui.archiveUpdateToggleBtn?.addEventListener("click", () => setArchiveUpdateEnabled(!archiveAutoUpdateEnabled())); + syncArchiveUpdateButton(); ui.archiveResetBtn?.addEventListener("click", openArchiveResetDialog); ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog); ui.archiveResetDialog?.addEventListener("click", (e) => { @@ -543,7 +865,7 @@ function bindUI() { }); ui.archiveResetConfirmBtn?.addEventListener("click", () => { world.resetFamilyTree?.(); - uiCache.archiveVersion = ""; + window.resetArchiveRenderState?.(); closeArchiveResetDialog(); renderArchive(); renderSelected(); @@ -599,6 +921,20 @@ function bindUI() { canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); canvas?.addEventListener("mousedown", (e) => { + if (e.button === 0 && world.tool === "water_hose") { + const p = screenToWorld(e); + if (!p.inside) return; + e.preventDefault(); + uiCache.hosing = true; + uiCache.hoseMoved = false; + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + uiCache.hoseStartX = e.clientX; + uiCache.hoseStartY = e.clientY; + world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); + render(); + return; + } if (e.button === 0 && world.tool === "pinch") { const p = screenToWorld(e); if (!p.inside) return; @@ -641,6 +977,13 @@ function bindUI() { uiCache.panLastY = e.clientY; }); window.addEventListener("mouseup", (e) => { + if (uiCache.hosing) { + uiCache.hosing = false; + canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"))); + world.rebuildSpatial?.(); + renderStats(); + return; + } if (uiCache.grabbing) { const target = uiCache.grabTarget; if (target) { @@ -669,6 +1012,19 @@ function bindUI() { canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); }); window.addEventListener("mousemove", (e) => { + if (uiCache.hosing) { + const p = screenToWorld(e); + const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); + const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY)); + if (totalMove > 3) uiCache.hoseMoved = true; + if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); + canvas.style.cursor = "crosshair"; + render(); + return; + } if (uiCache.grabbing) { const target = uiCache.grabTarget; if (!target || target.dead) { @@ -722,6 +1078,10 @@ function bindUI() { }); canvas?.addEventListener("click", (e) => { + if (uiCache.hoseMoved) { + uiCache.hoseMoved = false; + return; + } if (uiCache.grabMoved) { uiCache.grabMoved = false; return; diff --git a/js/ui_charts.js b/js/ui_charts.js index b9cb768..caf9f7b 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -3,6 +3,7 @@ function renderStats() { const alive = world.tarinai; const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0; + const environment = world.environmentScores ? world.environmentScores() : { security: 100, hygiene: 100, happiness: 100 }; const values = { pop: alive.length, dead: world.deadCount, @@ -10,7 +11,9 @@ function renderStats() { lonely: avg(t => t.loneliness), stress: avg(t => t.stress), lack: avg(t => t.lack), - events: { ...(world.eventCounters || {}) }, + security: environment.security, + hygiene: environment.hygiene, + happiness: environment.happiness, objects: { ...(world.itemCounts || {}) }, }; setTextIfChanged(ui.statPop, "pop", values.pop); @@ -18,11 +21,7 @@ function renderStats() { updateColonyHistory(values); drawColonyChart(values); renderSelected(); - const familyVersion = world.familyVersion || 0; - if (uiCache.archiveLastScheduleFamilyVersion !== familyVersion) { - uiCache.archiveLastScheduleFamilyVersion = familyVersion; - scheduleArchiveWindowRender(); - } + if (world.familyTreeDirty) scheduleArchiveWindowRender(); } function updateColonyHistory(values) { @@ -35,18 +34,16 @@ function updateColonyHistory(values) { uiCache.chartBucketStart = -Infinity; uiCache.chartBucketEnd = -Infinity; uiCache.chartBucketAccum = null; + uiCache.environmentInitialRow = null; uiCache.lastChartDraw = ""; } - const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"]; - const objectKeys = ["grass", "zunchi"]; + const objectKeys = ["grass", "zunchi", "trace"]; const makeBucket = (start) => ({ start, end: start + interval, count: 0, - sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 }, + sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0, security: 0, hygiene: 0, happiness: 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; @@ -63,15 +60,16 @@ function updateColonyHistory(values) { lonely: bucket.sums.lonely / bucket.count, stress: bucket.sums.stress / bucket.count, lack: bucket.sums.lack / bucket.count, + security: bucket.sums.security / bucket.count, + hygiene: bucket.sums.hygiene / bucket.count, + happiness: bucket.sums.happiness / 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; @@ -84,14 +82,15 @@ function updateColonyHistory(values) { bucket.sums.lonely += values.lonely || 0; bucket.sums.stress += values.stress || 0; bucket.sums.lack += values.lack || 0; + bucket.sums.security += values.security || 0; + bucket.sums.hygiene += values.hygiene || 0; + bucket.sums.happiness += values.happiness || 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 objectKeys = ["grass", "zunchi", "trace"]; const preview = bucket && bucket.count > 0 ? { t: Math.max(world.time || 0, bucket.start || 0), pop: bucket.sums.pop / bucket.count, @@ -99,8 +98,10 @@ function chartRows(values) { lonely: bucket.sums.lonely / bucket.count, stress: bucket.sums.stress / bucket.count, lack: bucket.sums.lack / bucket.count, + security: bucket.sums.security / bucket.count, + hygiene: bucket.sums.hygiene / bucket.count, + happiness: bucket.sums.happiness / 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 }]; } @@ -112,8 +113,13 @@ function drawColonyChart(values) { 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"; + if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) { + uiCache.environmentInitialRow = { t: world.time || 0, ...values }; + } + const rows = mode === "environment" + ? (uiCache.chartHistory.length ? uiCache.chartHistory : [uiCache.environmentInitialRow || { t: world.time || 0, ...values }]) + : chartRows(values); 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(","); @@ -123,7 +129,7 @@ function drawColonyChart(values) { 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); + if (mode === "environment") return drawEnvironmentChart(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; @@ -150,22 +156,20 @@ function drawColonyChart(values) { 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 }; +function drawEnvironmentChart(chart, rows, cssW, cssH) { + const series = chartSeries("environment"); + const row = rows[rows.length - 1] || {}; + const pad = { l: 58, r: 18, t: 16, b: 18 }; 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)}%`; + const rowH = Math.max(30, (cssH - pad.t - pad.b) / series.length); + const bars = series.map((s, i) => { + const y = pad.t + i * rowH + 6; + const v = clamp(Number(row[s.key] || 0), 0, 100); + const bw = barW * v / 100; + return `${s.label}${Math.round(v)}`; }).join(""); - chart.innerHTML = ``; - if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570`; + chart.innerHTML = ``; + if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `${s.label} ${formatChartValue(s.key, row)}`).join(""); } function chartSeries(mode = "population") { @@ -178,14 +182,12 @@ function chartSeries(mode = "population") { if (mode === "objects") return [ { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, + { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, ]; - 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" }, + if (mode === "environment") return [ + { key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true }, + { key: "hygiene", label: "\u885b\u751f", color: "#6f8a3f", percent: true }, + { key: "happiness", label: "\u5e78\u798f", color: "#d98bc0", percent: true }, ]; return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; } @@ -194,4 +196,3 @@ 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 deleted file mode 100644 index 6253b33..0000000 --- a/js/ui_exports.js +++ /dev/null @@ -1,3 +0,0 @@ -"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 index d5452cb..b4a6d6a 100644 --- a/js/ui_family.js +++ b/js/ui_family.js @@ -8,17 +8,46 @@ function lineageEdgeFamily(family = world.family || {}) { return family || {}; } +let lineageRelationIndexCache = { family: null, version: null, index: null }; + +function lineageBuildRelationIndex(family) { + const known = new Set(Object.keys(family || {})); + const parentsByChild = new Map(); + const childrenByParent = new Map(); + const addParent = (childId, parentId) => { + if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; + if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); + parentsByChild.get(childId).add(parentId); + childrenByParent.get(parentId).add(childId); + }; + for (const n of Object.values(family || {})) { + if (!n?.id) continue; + for (const parentId of n.parents || []) addParent(n.id, parentId); + for (const childId of n.children || []) addParent(childId, n.id); + } + return { parentsByChild, childrenByParent }; +} + +function lineageRelationIndex(family = world.family || {}) { + family = lineageEdgeFamily(family); + if (family !== (world.family || {})) return lineageBuildRelationIndex(family); + const version = world.familyVersion || 0; + if (lineageRelationIndexCache.family !== family || lineageRelationIndexCache.version !== version) { + lineageRelationIndexCache = { family, version, index: lineageBuildRelationIndex(family) }; + } + return lineageRelationIndexCache.index; +} + function lineageMutualParentIds(n, family = world.family || {}, idSet = null) { const seen = new Set(); const parents = []; if (!n?.id) return parents; + const relationIndex = lineageRelationIndex(family); const candidates = new Set((n.parents || []).filter(Boolean)); - // Older saves may contain only one side of the parent/child edge. For - // rendering, recover the missing side by scanning parent.children as well. - for (const parent of Object.values(family || {})) { - if (!parent?.id || parent.id === n.id) continue; - if (Array.isArray(parent.children) && parent.children.includes(n.id)) candidates.add(parent.id); - } + // Runtime updates can briefly touch one side first; recover the reciprocal + // edge from the relation index so the archive does not flicker. + for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId); for (const id of candidates) { if (!id || id === n.id || seen.has(id)) continue; if (idSet && !idSet.has(id)) continue; @@ -38,13 +67,10 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) { const seen = new Set(); const children = []; if (!n?.id) return children; + const relationIndex = lineageRelationIndex(family); const candidates = new Set((n.children || []).filter(Boolean)); - // Same recovery as above, in the opposite direction: child.parents is enough - // to draw a real parent/child edge even if parent.children was not persisted. - for (const child of Object.values(family || {})) { - if (!child?.id || child.id === n.id) continue; - if (Array.isArray(child.parents) && child.parents.includes(n.id)) candidates.add(child.id); - } + // Same recovery as above, in the opposite direction. + for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId); for (const id of candidates) { if (!id || id === n.id || seen.has(id)) continue; if (idSet && !idSet.has(id)) continue; @@ -65,11 +91,13 @@ function lineageHasActualRelation(n, family = world.family || {}) { } function lineageRelatedIdSet(family) { + family = lineageEdgeFamily(family); + const relationIndex = lineageRelationIndex(family); const ids = new Set(); for (const n of Object.values(family || {}).filter(Boolean)) { - const parents = lineageMutualParentIds(n, family); - const children = lineageMutualChildIds(n, family); - if (parents.length || children.length) ids.add(n.id); + const parents = relationIndex.parentsByChild.get(n.id) || []; + const children = relationIndex.childrenByParent.get(n.id) || []; + if (parents.size || children.size) ids.add(n.id); for (const id of parents) ids.add(id); for (const id of children) ids.add(id); } @@ -123,6 +151,167 @@ function liveFamilyComponents(family) { return comps; } +function lineageIdleSchedule(fn, timeout = 180) { + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + schedule(fn, { timeout }); +} + +function lineageSameFamilyToken(token) { + return uiCache.archiveRenderToken === token; +} + +function lineageBuildComponentsIdle(family, token, onProgress, onDone) { + family = lineageEdgeFamily(family); + const entries = Object.values(family || {}).filter(n => n?.id); + const known = new Set(entries.map(n => n.id)); + const parentsByChild = new Map(); + const childrenByParent = new Map(); + const relatedIds = new Set(); + const graph = new Map(); + const comps = []; + let stage = "relations"; + let entryIndex = 0; + let nodeIndex = 0; + let nodes = []; + let sortedNodes = []; + let dfsIndex = 0; + const seen = new Set(); + + const addParent = (childId, parentId) => { + if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; + if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); + parentsByChild.get(childId).add(parentId); + childrenByParent.get(parentId).add(childId); + }; + const addGraphEdge = (a, b) => { + if (!a || !b || !graph.has(a) || !graph.has(b)) return; + graph.get(a).add(b); + graph.get(b).add(a); + }; + const validParentChild = (childId, parentId) => { + const child = family[childId]; + const parent = family[parentId]; + if (!child || !parent) return false; + const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId); + const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId); + return childListsParent || parentListsChild; + }; + const finishEmpty = () => { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + onDone([]); + }; + + const pump = () => { + if (!lineageSameFamilyToken(token)) return; + const started = performance.now(); + while (performance.now() - started < 6) { + if (stage === "relations") { + if (entryIndex >= entries.length) { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + stage = "related"; + entryIndex = 0; + onProgress?.("\u95a2\u4fc2\u3092\u6574\u7406\u4e2d", 0, entries.length); + continue; + } + const n = entries[entryIndex++]; + for (const parentId of n.parents || []) addParent(n.id, parentId); + for (const childId of n.children || []) addParent(childId, n.id); + } else if (stage === "related") { + if (entryIndex >= entries.length) { + nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); + for (const n of nodes) graph.set(n.id, new Set()); + if (!nodes.length) return finishEmpty(); + stage = "graph"; + nodeIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d", 0, nodes.length); + continue; + } + const n = entries[entryIndex++]; + const parents = parentsByChild.get(n.id) || new Set(); + const children = childrenByParent.get(n.id) || new Set(); + if (parents.size || children.size) relatedIds.add(n.id); + for (const id of parents) relatedIds.add(id); + for (const id of children) relatedIds.add(id); + } else if (stage === "graph") { + if (nodeIndex >= nodes.length) { + sortedNodes = nodes.slice().sort(lineageNodeSort); + stage = "components"; + dfsIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", 0, sortedNodes.length); + continue; + } + const n = nodes[nodeIndex++]; + const parentCandidates = new Set((n.parents || []).filter(Boolean)); + for (const parentId of parentsByChild.get(n.id) || []) parentCandidates.add(parentId); + for (const parentId of parentCandidates) { + if (parentId !== n.id && graph.has(parentId) && validParentChild(n.id, parentId)) addGraphEdge(n.id, parentId); + } + const childCandidates = new Set((n.children || []).filter(Boolean)); + for (const childId of childrenByParent.get(n.id) || []) childCandidates.add(childId); + for (const childId of childCandidates) { + if (childId !== n.id && graph.has(childId) && validParentChild(childId, n.id)) addGraphEdge(n.id, childId); + } + } else if (stage === "components") { + while (dfsIndex < sortedNodes.length && seen.has(sortedNodes[dfsIndex].id)) dfsIndex += 1; + if (dfsIndex >= sortedNodes.length) { + 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; + }); + onDone(comps); + return; + } + const start = sortedNodes[dfsIndex++]; + const stack = [start.id]; + const comp = []; + seen.add(start.id); + while (stack.length && performance.now() - started < 6) { + 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 (stack.length) { + const continueComp = () => { + if (!lineageSameFamilyToken(token)) return; + const resumedStarted = performance.now(); + while (stack.length && performance.now() - resumedStarted < 6) { + 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 (stack.length) return lineageIdleSchedule(continueComp, 180); + if (comp.some(x => x.alive)) comps.push(comp); + lineageIdleSchedule(pump, 180); + }; + return lineageIdleSchedule(continueComp, 180); + } + if (comp.some(x => x.alive)) comps.push(comp); + } + } + onProgress?.( + stage === "relations" ? "\u95a2\u4fc2\u3092\u6574\u7406\u4e2d" : stage === "related" ? "\u8868\u793a\u5bfe\u8c61\u3092\u9078\u5225\u4e2d" : stage === "graph" ? "\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d" : "\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", + stage === "graph" ? nodeIndex : entryIndex, + stage === "graph" ? nodes.length : entries.length + ); + lineageIdleSchedule(pump, 180); + }; + lineageIdleSchedule(pump, 160); +} + function familyStatusText(n) { if (!n) return "\u4e0d\u660e"; return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; @@ -134,6 +323,8 @@ function familyDeathReasonLabel(rawReason, entity = null) { if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text; if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629"; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; + if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"; + if (/\u885d\u7a81|collision/.test(text)) return "\u885d\u7a81"; if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f"; if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5"; if (/\u75c5/.test(text)) { @@ -142,8 +333,8 @@ function familyDeathReasonLabel(rawReason, entity = null) { } if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - if (/\u5bff\u547d/.test(text)) return "\u5bff\u547d"; - if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; + if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; + if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; return "\u4e8b\u6545"; } @@ -156,7 +347,7 @@ function lineageNodeSort(a, b) { } function lineageTreeNodeHtml(n, x, y) { - const entity = n?.id ? world.tarinai.find(t => t.id === n.id) : null; + const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; const live = entity && !entity.dead ? entity : null; const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; const name = escapeHtml(n?.name || "\u4e0d\u660e"); @@ -165,9 +356,7 @@ function lineageTreeNodeHtml(n, x, y) { const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; const briefReason = familyDeathReasonLabel(rawReason, entity); const reason = briefReason ? ` / ${briefReason}` : ""; - const personality = live?.personalityLabel ? live.personalityLabel() : ((PERSONALITIES[n?.personality] || {}).label || n?.personality || ""); - const personalityPart = personality ? ` / \u6027\u683c ${personality}` : ""; - const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${personalityPart}${reason}`); + const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}`); const deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const childCls = (n?.parents || []).length ? " child" : ""; @@ -454,7 +643,8 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) { const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet); const totalNodes = nodes.length; const maxRow = Math.max(0, ...rows.map(row => row.length)); - const passes = totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6); + const veryLarge = totalNodes > 220 || maxRow > 44; + const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6)); for (let pass = 0; pass < passes; pass++) { let rank = lineageRankMap(rows); @@ -464,9 +654,11 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) { const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index])); rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank); } - rank = lineageRankMap(rows); - rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); - if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); + if (!veryLarge) { + rank = lineageRankMap(rows); + rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); + if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); + } } return rows; } @@ -683,7 +875,39 @@ function lineageLayoutRows(rows, idSet) { return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP }; } -function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { +function lineageSegmentKind(x1, y1, x2, y2) { + if (Math.abs(y2 - y1) < 0.01) return 'h'; + if (Math.abs(x2 - x1) < 0.01) return 'v'; + return 'd'; +} + +function lineageSegmentsFromPoints(points) { + const segments = []; + for (let i = 1; i < points.length; i++) { + const [x1, y1] = points[i - 1]; + const [x2, y2] = points[i]; + segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 }); + } + return segments; +} + +function lineagePathFromPoints(points) { + if (!points.length) return ""; + return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); +} + +function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) { + const ax = a.x + nodeW / 2; + const bx = b.x + nodeW / 2; + const ay = a.y + nodeH; + const by = b.y + nodeH; + const y = Math.max(unionY, ay + 12, by + 12); + const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2; + const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]]; + return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y }; +} + +function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { const left = a.x <= b.x ? a : b; const right = left === a ? b : a; const midYLeft = left.y + nodeH / 2 + attachOffsetA; @@ -702,14 +926,12 @@ function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffse const points = [ [x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight] ]; - const d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); - const segments = []; - for (let i = 1; i < points.length; i++) { - const [x1s, y1s] = points[i - 1]; - const [x2s, y2s] = points[i]; - segments.push({ kind: Math.abs(y2s - y1s) < 0.01 ? 'h' : (Math.abs(x2s - x1s) < 0.01 ? 'v' : 'd'), x1: x1s, y1: y1s, x2: x2s, y2: y2s }); - } - return { d, segments }; + return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y }; +} + +function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { + if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY); + return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB); } function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) { @@ -758,6 +980,15 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) { const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals); return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) }; } + if (childPoints.length === 1) { + const p = childPoints[0]; + const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals); + const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); + const parentLane = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges); + const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`; + const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).replace(/^M\s+[-\d.]+\s+[-\d.]+/, ""); + return { d: `${parentLane}${horizontal}${childLane}` }; + } const minX = Math.min(joinX, ...childPoints.map(p => p.x)); const maxX = Math.max(joinX, ...childPoints.map(p => p.x)); let d = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals)); @@ -795,6 +1026,47 @@ function lineageComponentSignature(component, family) { .join("|"); } +function lineageComponentIdentity(component) { + return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(","); +} + +function lineageFamilyCacheKey(component, family, signature = "") { + const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); + return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`; +} + +function lineageHydrateFamilyHtml(html, index) { + return html + .split("{{LINEAGE_INDEX}}").join(String(index + 1)) + .split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`); +} + +function lineageCaptureArchiveScroll() { + const root = ui.archiveContent; + if (!root) return null; + const familyScroll = {}; + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (key) familyScroll[key] = el.scrollLeft || 0; + } + return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll }; +} + +function lineageRestoreArchiveScroll(state) { + const root = ui.archiveContent; + if (!root || !state) return; + const maxTop = Math.max(0, root.scrollHeight - root.clientHeight); + const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth); + root.scrollTop = Math.min(state.top || 0, maxTop); + root.scrollLeft = Math.min(state.left || 0, maxLeft); + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue; + el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth)); + } +} + + function lineageExpectedChildEdgeCount(component, family) { family = lineageEdgeFamily(family); const idSet = new Set(component.map(x => x?.id).filter(Boolean)); @@ -805,30 +1077,19 @@ function lineageExpectedChildEdgeCount(component, family) { function lineageVisibleRelationSignature(family) { family = lineageEdgeFamily(family); - const known = new Set(Object.keys(family)); - const parentEdges = new Map(); - const childEdges = new Map(); + const relationIndex = lineageRelationIndex(family); const relatedIds = new Set(); - const addEdge = (parentId, childId) => { - if (!parentId || !childId || parentId === childId || !known.has(parentId) || !known.has(childId)) return; - if (!parentEdges.has(childId)) parentEdges.set(childId, new Set()); - if (!childEdges.has(parentId)) childEdges.set(parentId, new Set()); - parentEdges.get(childId).add(parentId); - childEdges.get(parentId).add(childId); - relatedIds.add(parentId); + for (const [childId, parents] of relationIndex.parentsByChild.entries()) { + if (!parents.size) continue; relatedIds.add(childId); - }; - for (const n of Object.values(family || {})) { - if (!n?.id) continue; - for (const parentId of n.parents || []) addEdge(parentId, n.id); - for (const childId of n.children || []) addEdge(n.id, childId); + for (const parentId of parents) relatedIds.add(parentId); } return Array.from(relatedIds) .sort() .map(id => { const n = family[id] || {}; - const parents = Array.from(parentEdges.get(id) || []).sort().join(","); - const children = Array.from(childEdges.get(id) || []).sort().join(","); + const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(","); + const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(","); return [ id, n.name || "", @@ -859,7 +1120,7 @@ function lineageSetArchiveStaleStatus(show) { return; } if (existing) return; - ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306f\u4e00\u6642\u505c\u6b62\u4e2d\u3067\u3059\u3002\u4e00\u6642\u505c\u6b62\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); + ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); } function lineageAssignPartnerAttachOffsets(linkGroups) { @@ -912,6 +1173,7 @@ function lineageBuildLinks(nodes, idSet, layout) { linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() }); } lineageAssignPartnerAttachOffsets(linkGroups); + const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0); const byGap = new Map(); for (const group of linkGroups) { @@ -940,15 +1202,17 @@ function lineageBuildLinks(nodes, idSet, layout) { const attachA = 0; const attachB = 0; const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; - const clearY = clamp(group.parentBottom + 22 + i * 2, partnerY + 18, group.childTop - 36); + const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB); + if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX; + const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY; + const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36); group.fromY = clearY; group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3)); - const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB); if (partner.d) { partnerLinks.push(partner.d); horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h')); } - const trunk = lineageVerticalBridgePath(group.joinX, partnerY, group.fromY, []); + const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []); if (trunk) childLinks.push(trunk); } else { group.fromY = group.parentBottom; @@ -960,8 +1224,8 @@ function lineageBuildLinks(nodes, idSet, layout) { for (const groupsInGap of byGap.values()) { groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length); for (const group of groupsInGap) { - if (group.children.length > 1) { - const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b); + const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b); + if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) { horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY }); } const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments); @@ -969,7 +1233,7 @@ function lineageBuildLinks(nodes, idSet, layout) { } } - return { partnerLinks, childLinks, childLinkGroups: linkGroups.length }; + return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges }; } function lineageSplitByActualEdges(component) { @@ -1036,6 +1300,73 @@ function lineageCleanComponent(component, family) { return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length); } +function lineageFastLayoutNeeded(nodes, idSet, family) { + const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0); + const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length)); + return { + edgeCount, + fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34, + }; +} + +function lineageLayoutRowsFast(rows, idSet) { + const NODE_W = 132; + const NODE_H = 68; + const SIBLING_GAP = 22; + const ROW_GAP = 116; + const LEFT_PAD = 82; + const TOP_PAD = 44; + const RIGHT_PAD = 48; + const BOTTOM_PAD = 30; + const positions = new Map(); + let width = 420; + rows.forEach((row, rowIndex) => { + const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); + let x = LEFT_PAD; + for (const n of row) { + positions.set(n.id, { x, y, rowIndex, node: n }); + x += NODE_W + SIBLING_GAP; + } + width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD); + }); + const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; + return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true }; +} + +function lineageBuildLinksFast(nodes, idSet, layout) { + const { positions, nodeW, nodeH } = layout; + const partnerLinks = []; + const childLinks = []; + const seenPartners = new Set(); + let childLinkGroups = 0; + let childLinkEdges = 0; + for (const child of nodes) { + const childPos = positions.get(child.id); + if (!childPos) continue; + const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); + if (!parents.length) continue; + childLinkGroups += 1; + childLinkEdges += parents.length; + const childX = childPos.x + nodeW / 2; + const childY = childPos.y; + const fromY = Math.max(...parents.map(p => p.y + nodeH)); + const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; + if (parents.length >= 2) { + const left = parents[0]; + const right = parents[parents.length - 1]; + const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+"); + if (partnerKey && !seenPartners.has(partnerKey)) { + seenPartners.add(partnerKey); + const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; + partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`); + } + } + const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48)); + childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`); + } + return { partnerLinks, childLinks, childLinkGroups, childLinkEdges }; +} + function buildLineageTreeLayout(component, family) { family = lineageEdgeFamily(family); const cleaned = lineageCleanComponent(component, family); @@ -1044,22 +1375,23 @@ function buildLineageTreeLayout(component, family) { const idSet = new Set(idToNode.keys()); const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b); const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet); - const layout = lineageLayoutRows(rows, idSet); - const links = lineageBuildLinks(nodes, idSet, layout); + const perf = lineageFastLayoutNeeded(nodes, idSet, family); + const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet); + const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout); const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 })); const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y)); - return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups }; + return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount }; } function lineageFamilyHtml(component, index, family, signature = "") { const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); - const cacheKey = `${index}:${signature || lineageComponentSignature(uniqueComponent, family)}`; - if (uiCache.archiveHtmlCache?.has(cacheKey)) return uiCache.archiveHtmlCache.get(cacheKey); + const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature); + if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index); const layout = buildLineageTreeLayout(uniqueComponent, family); const aliveCount = uniqueComponent.filter(n => n.alive).length; const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family); - const maskId = `lineage-node-mask-${index}`; + const maskId = "{{LINEAGE_MASK_ID}}"; const nodeMaskRects = layout.nodes .map(p => ``) .join(""); @@ -1069,8 +1401,8 @@ function lineageFamilyHtml(component, index, family, signature = "") { const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); const empty = !layout.childLinks.length ? `
    \u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    ` : ""; - const html = `
    -

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

    + const html = `
    +

    \u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

    `; uiCache.archiveHtmlCache?.set(cacheKey, html); - return html; + return lineageHydrateFamilyHtml(html, index); } function renderArchive() { if (!ui.archiveContent) return; + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (!uiCache.archiveVersion) { + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002
    `; + } else if (world.familyTreeDirty) { + lineageSetArchiveStaleStatus(true); + } + return; + } if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; uiCache.deferArchiveUntil = 0; uiCache.archiveScheduled = false; + const scrollState = lineageCaptureArchiveScroll(); const familyVersion = world.familyVersion || 0; - if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion) return; + const familyDirty = Boolean(world.familyTreeDirty); + if (uiCache.archiveRenderRunning) { + if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; + return; + } + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return; if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { uiCache.archivePendingOffscreen = true; - return; - } - const running = world && !world.paused; - if (uiCache.archiveVersion && running) { - uiCache.archivePendingWhileRunning = true; - if (uiCache.archiveDiagnostics) uiCache.archiveDiagnostics.skippedWhileRunning = (uiCache.archiveDiagnostics.skippedWhileRunning || 0) + 1; - lineageSetArchiveStaleStatus(true); - return; + const hiddenInterval = 30000; + const hiddenNow = performance.now(); + if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return; + uiCache.archiveLastHiddenCheckAt = hiddenNow; } const now = performance.now(); - const minUpdateGap = 700; + const minUpdateGap = 4800; if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { if (!uiCache.archiveRenderTimer) { uiCache.archiveRenderTimer = setTimeout(() => { @@ -1119,18 +1462,12 @@ function renderArchive() { world.pruneExtinctFamilies(); } const family = world.family || {}; - const relationSignature = lineageVisibleRelationSignature(family); - if (uiCache.archiveVersion && uiCache.archiveRelationSignature === relationSignature) { - uiCache.archiveFamilyVersion = world.familyVersion || 0; + const version = String(world.familyVersion || 0); + if (uiCache.archiveVersion === version) { + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; return; } - const ids = Object.keys(family); - const edgeCount = ids.reduce((sum, id) => { - const n = family[id]; - return sum + (Array.isArray(n?.parents) ? n.parents.length : 0) + (Array.isArray(n?.children) ? n.children.length : 0); - }, 0); - const version = `${world.familyVersion || 0}:${ids.length}:${edgeCount}`; - if (uiCache.archiveVersion === version) return; const renderStarted = performance.now(); lineageSetArchiveStaleStatus(false); if (uiCache.archiveRenderTimer) { @@ -1140,58 +1477,109 @@ function renderArchive() { uiCache.archiveVersion = version; uiCache.archiveFamilyVersion = world.familyVersion || 0; uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0; - uiCache.archiveRelationSignature = relationSignature; uiCache.archiveLastRenderedAt = performance.now(); uiCache.lastArchiveWindow = ""; - const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).map(c => lineageCleanComponent(c, family)).filter(c => c.length); - uiCache.archiveRows = components; - const signatures = components.map(component => lineageComponentSignature(component, family)); if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map(); - const activeCacheKeys = new Set(signatures.map((signature, index) => `${index}:${signature}`)); - for (const key of Array.from(uiCache.archiveHtmlCache.keys())) { - if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key); - } + const activeCacheKeys = new Set(); const token = `${version}:${Math.random().toString(36).slice(2)}`; uiCache.archiveRenderToken = token; - 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; - } - - ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${components.length}
    `; + uiCache.archiveRenderRunning = true; + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026
    `; + lineageRestoreArchiveScroll(scrollState); const status = ui.archiveContent.querySelector(".lineage-render-status"); const forest = ui.archiveContent.querySelector(".lineage-forest"); - let index = 0; - const pump = () => { - if (uiCache.archiveRenderToken !== token || !forest) return; + const renderComponents = (components) => { + if (uiCache.archiveRenderToken !== token || !forest) { + uiCache.archiveRenderRunning = false; + return; + } + const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length); + uiCache.archiveRows = splitComponents; + if (!splitComponents.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
    `; + lineageRestoreArchiveScroll(scrollState); + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; + return; + } + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`; + let index = 0; + const pump = () => { + if (uiCache.archiveRenderToken !== token || !forest) { + uiCache.archiveRenderRunning = false; + return; + } const started = performance.now(); - let nodeBudget = 72; + let nodeBudget = 28; let renderedThisSlice = 0; - while (index < components.length) { - const component = components[index]; + while (index < splitComponents.length) { + const component = splitComponents[index]; if (renderedThisSlice > 0 && component.length > nodeBudget) break; - forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signatures[index])); + const signature = lineageComponentSignature(component, family); + activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature)); + forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature)); nodeBudget -= Math.max(1, component.length); index += 1; renderedThisSlice += 1; - if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 14)) break; + if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break; } - if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${components.length}`; - if (index < components.length) { - const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); - schedule(pump, { timeout: 120 }); - } else if (status) { - status.remove(); + lineageRestoreArchiveScroll(scrollState); + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`; + if (index < splitComponents.length) { + lineageIdleSchedule(pump, 220); + } else { + if (status) status.remove(); + for (const key of Array.from(uiCache.archiveHtmlCache.keys())) { + if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key); + } + lineageRestoreArchiveScroll(scrollState); + const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion; + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = renderDirtiedAgain; if (uiCache.archiveDiagnostics) { uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1; uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10; - uiCache.archiveDiagnostics.lastFamilies = components.length; - uiCache.archiveDiagnostics.lastNodes = components.reduce((sum, component) => sum + component.length, 0); + uiCache.archiveDiagnostics.lastFamilies = splitComponents.length; + uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0); + } + if (renderDirtiedAgain) { + uiCache.archivePendingDirtyAfterRun = false; + scheduleArchiveWindowRender(); } } }; - const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); - schedule(pump, { timeout: 80 }); + lineageIdleSchedule(pump, 160); + }; + lineageBuildComponentsIdle( + family, + token, + (label, done, total) => { + if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`; + }, + renderComponents + ); +} + +function resetArchiveRenderState() { + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + uiCache.archiveRenderToken = ""; + uiCache.archiveRenderRunning = false; + uiCache.archiveScheduled = false; + uiCache.archivePendingOffscreen = false; + uiCache.archivePendingWhileRunning = false; + uiCache.archivePendingDirtyAfterRun = false; + uiCache.archiveLastHiddenCheckAt = -Infinity; + uiCache.archiveLastScheduleFamilyVersion = null; + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + uiCache.archiveRelationSignature = ""; + uiCache.archiveRows = []; + uiCache.lastArchiveWindow = ""; + uiCache.archiveHtmlCache?.clear?.(); } function validateFamilyTree() { @@ -1211,36 +1599,21 @@ function validateFamilyTree() { } document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => { const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0); + const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0); const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0); + const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0); const pathCount = section.querySelectorAll(".lineage-links .child-link").length; + if (renderedEdges !== expectedEdges) { + issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges }); + } if (expectedEdges > 0 && pathCount < 1) { issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount }); } if (expectedGroups > 0 && pathCount < expectedGroups) { issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount }); } - const panel = section.querySelector(".lineage-tree-panel"); - const svg = section.querySelector(".lineage-links"); - if (panel && svg) { - const panelRect = panel.getBoundingClientRect(); - const nodes = Array.from(panel.querySelectorAll(".tree-node")).map(node => { - const r = node.getBoundingClientRect(); - return { - id: node.getAttribute("data-family-tarinai-id") || "", - left: r.left - panelRect.left + 1, - top: r.top - panelRect.top + 1, - right: r.right - panelRect.left - 1, - bottom: r.bottom - panelRect.top - 1, - }; - }); - section.querySelectorAll(".lineage-links .child-link").forEach((path, pathIndex) => { - if (!path.getBBox) return; - const box = path.getBBox(); - for (const node of nodes) { - const overlaps = box.x < node.right && box.x + box.width > node.left && box.y < node.bottom && box.y + box.height > node.top; - if (overlaps) issues.push({ type: "child-link-bbox-overlaps-node-body", familyIndex: index, pathIndex, node: node.id }); - } - }); + if (renderedPaths !== pathCount) { + issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount }); } }); return issues; @@ -1251,17 +1624,34 @@ function renderArchiveWindow() { } function scheduleArchiveWindowRender() { - if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0)) return; + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); + return; + } + const familyDirty = Boolean(world.familyTreeDirty); + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return; + if (uiCache.archiveRenderRunning) { + if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; + return; + } + if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { + uiCache.archivePendingOffscreen = true; + const now = performance.now(); + if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return; + uiCache.archiveLastHiddenCheckAt = now; + } if (uiCache.archiveRenderTimer) return; if (uiCache.archiveScheduled) return; uiCache.archiveScheduled = true; const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); schedule(() => { renderArchive(); - }, { timeout: 700 }); + }, { timeout: 1200 }); } function schedulePendingArchiveRender() { + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; if (!uiCache.archivePendingOffscreen) return; if (!lineageArchiveNearViewport()) return; uiCache.archivePendingOffscreen = false; @@ -1270,3 +1660,4 @@ function schedulePendingArchiveRender() { window.validateFamilyTree = validateFamilyTree; window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) }); +window.resetArchiveRenderState = resetArchiveRenderState; diff --git a/js/world.js b/js/world.js index 1bac9c4..ec04b10 100644 --- a/js/world.js +++ b/js/world.js @@ -15,6 +15,7 @@ class World { this.toolSizes = {}; this.tarinai = []; this.items = []; + this.ants = []; this.effects = []; this.logs = []; this.eventCounters = {}; @@ -24,10 +25,16 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.liveIdNext = 1; + this.liveIdFree = []; + this.liveIdSerial = 0; + this.liveTarinai = new Map(); this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; this.familyVersion = 0; + this.familyTreeDirty = true; + this.familyTreeDirtyReason = "init"; this.relationNotices = {}; this.resolvedFightIds = {}; this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 }; @@ -39,7 +46,8 @@ class World { this.cameraY = 0; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); this.spatial = new SpatialGrid(96); - this.spatialScratch = []; + this.spatialItemScratch = []; + this.spatialTarinaiScratch = []; this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; @@ -274,12 +282,12 @@ class World { } toolSizeFor(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return "medium"; + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "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", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return 1; + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1; return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; } @@ -290,7 +298,16 @@ class World { item.toolSize = size; this.toolSize = size; item.r *= scale; - if (["sweet", "love_mochi", "fight_mochi", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { + const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5; + item.foodServingScale = scale; + item.foodServingsMax = servings; + item.foodServingsRemaining = servings; + item.amount = servings; + } else if (["grass", "zunchi"].includes(item.type)) { + item.amount *= scale * scale; + item.foodServingScale = scale; + } if (item.type === "firecracker") item.blastScale = scale; return item; } @@ -308,6 +325,7 @@ class World { this.toolSizes = this.toolSizes || {}; this.tarinai = []; this.items = []; + this.ants = []; this.effects = []; this.logs = []; this.eventCounters = {}; @@ -317,6 +335,10 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.liveIdNext = 1; + this.liveIdFree = []; + this.liveIdSerial = 0; + this.liveTarinai = new Map(); this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; @@ -354,9 +376,51 @@ class World { } + tarinaiFamilyKey(t) { + return t?.familyKey || t?.archiveKey || t?.id || ""; + } + + assignTarinaiLiveId(t) { + if (!t) return ""; + if (!this.liveTarinai) this.liveTarinai = new Map(); + // Live IDs are never recycled. Relationships and transient targets are keyed by + // live ID, so reuse can make dead friends/enemies leak onto new individuals. + this.liveIdFree = []; + if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1; + if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0; + const id = `t${this.liveIdNext++}`; + t.id = id; + t.liveToken = ++this.liveIdSerial; + this.liveTarinai.set(id, { token: t.liveToken, target: t }); + return id; + } + + releaseTarinaiLiveId(t) { + if (!t?.id || !this.liveTarinai) return false; + const current = this.liveTarinai.get(t.id); + if (!current || current.target !== t || current.token !== t.liveToken) return false; + const id = t.id; + this.liveTarinai.delete(id); + // Do not recycle live IDs. Relationships are keyed by live ID; recycling can + // make a dead friend's/enemy's relationship appear on a newly spawned individual. + t.releasedLiveId = id; + t.id = ""; + t.liveToken = 0; + return true; + } + + liveTarinaiById(id, token = null) { + if (!id || !this.liveTarinai) return null; + const entry = this.liveTarinai.get(id); + if (!entry || (token !== null && entry.token !== token)) return null; + const t = entry.target; + return t && !t.dead ? t : null; + } + recordFamily(t) { if (!this.family) this.family = {}; - if (!t?.id) return; + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey) return; const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); const sameList = (a, b) => { const aa = uniqueIds(a); @@ -366,12 +430,13 @@ class World { 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, + const nextPersonalityTags = getPersonalityTraitTags(t); + if (!this.family[familyKey]) { + this.family[familyKey] = { + id: familyKey, name: t.name, type: t.type, - personality: t.personality || "", + personalityTags: nextPersonalityTags, generation: t.generation, hasPaired: !!t.hasPaired, parents: nextParents, @@ -383,11 +448,11 @@ class World { }; changed = true; } else { - const entry = this.family[t.id]; + const entry = this.family[familyKey]; const nextAlive = !t.dead; if (entry.name !== t.name) changed = true; if (entry.type !== t.type) changed = true; - if ((entry.personality || "") !== (t.personality || "")) changed = true; + if (!sameList(entry.personalityTags, nextPersonalityTags)) 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; @@ -396,7 +461,8 @@ class World { if (!sameList(entry.children, nextChildren)) changed = true; entry.name = t.name; entry.type = t.type; - entry.personality = t.personality || entry.personality || ""; + delete entry.personality; + entry.personalityTags = nextPersonalityTags; entry.alive = nextAlive; entry.generation = t.generation; entry.hasPaired = !!t.hasPaired; @@ -405,19 +471,29 @@ class World { entry.parentNames = [...(t.parentNames || entry.parentNames || [])]; entry.children = nextChildren; } - if (changed) this.familyVersion = (this.familyVersion || 0) + 1; + if (changed) { + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-record"); + } + } + + markFamilyTreeDirty(reason = "family") { + this.familyTreeDirty = true; + this.familyTreeDirtyReason = reason; + this.familyTreeDirtyAt = this.time || 0; } 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])); + const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); 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 || []) { - if (!this.family[t.id] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length)) continue; + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue; const before = this.familyVersion || 0; this.recordFamily(t); changed = changed || before !== (this.familyVersion || 0); @@ -438,7 +514,7 @@ class World { id, name: live?.name || n.name || id, type: live?.type || n.type || "smile", - personality: live?.personality || n.personality || "", + personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])], generation: Math.max(1, live?.generation || n.generation || 1), hasPaired: !!(live?.hasPaired || n.hasPaired), parents, @@ -500,7 +576,10 @@ class World { changed = true; } } - if (changed) this.familyVersion = (this.familyVersion || 0) + 1; + if (changed) { + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-normalize"); + } this.familyCleanVersion = this.familyVersion || 0; return changed; } @@ -509,6 +588,7 @@ class World { this.family = {}; this.familyCleanVersion = 0; this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-reset"); this.maxGeneration = 1; for (const t of this.tarinai || []) { t.parents = []; @@ -543,15 +623,42 @@ class World { return issues; } + clearLiveReferencesTo(dead) { + const deadId = dead?.id || dead?.releasedLiveId || ""; + if (!deadId) return false; + let changed = false; + for (const other of this.tarinai || []) { + if (!other || other === dead || other.dead) continue; + if (other.target === dead || other.target?.id === deadId) { + other.target = null; + if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.state = "idle"; + changed = true; + } + if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } + if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; } + if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) { + other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId); + changed = true; + } + for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) { + if (other[key] === deadId) { other[key] = null; changed = true; } + } + } + return changed; + } + markDead(t, reason) { + this.clearLiveReferencesTo(t); if (!this.family) this.family = {}; const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; - if (this.family[t.id]) { - if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) { - this.family[t.id].alive = false; - this.family[t.id].deathReason = finalReason; - this.family[t.id].personality = t.personality || this.family[t.id].personality || ""; + const familyKey = this.tarinaiFamilyKey(t); + if (familyKey && this.family[familyKey]) { + if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) { + this.family[familyKey].alive = false; + this.family[familyKey].deathReason = finalReason; + delete this.family[familyKey].personality; this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("death"); } } this.familyPrunePending = true; @@ -597,11 +704,13 @@ class World { if (!remove.length) return; for (const id of remove) delete family[id]; this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-prune"); this.familyCleanVersion = this.familyVersion || 0; } addTarinai(opts = {}) { const t = new Tarinai(this, opts); + this.assignTarinaiLiveId(t); this.tarinai.push(t); this.drawListDirty = true; if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t); @@ -612,6 +721,8 @@ class World { startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; if (a.isZunchiSlave || b.isZunchiSlave) return false; + if (this.areParentChild(a, b)) return false; + if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) 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); @@ -642,10 +753,15 @@ class World { finishBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return null; + const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; a.birthRitualTimer = b.birthRitualTimer = 0; a.birthPartnerId = b.birthPartnerId = null; a.birthRitualLeader = b.birthRitualLeader = false; a.state = b.state = "idle"; + if (reproductionBlockedByDisease) { + a.birthRitualRole = b.birthRitualRole = 0; + return null; + } a.fightTimer = b.fightTimer = 0; a.fightTargetIds = []; b.fightTargetIds = []; a.fightTargetId = b.fightTargetId = null; @@ -660,25 +776,28 @@ class World { if (child) { child.postBirthPeaceTimer = 18; children.push(child); } } if (children.length > 0) this.lastBirthAt = this.time; - if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth"); + if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] }); return children[0] || null; } areParentChild(a, b) { if (!a || !b) return false; - const aId = a.id || a; - const bId = b.id || b; + const aId = this.tarinaiFamilyKey(a) || a; + const bId = this.tarinaiFamilyKey(b) || b; if (!aId || !bId) return false; return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId)); } areCoParents(a, b) { if (!a || !b) return false; - const aId = a.id || a; - const bId = b.id || b; + const aLiveId = a.id || a; + const bLiveId = b.id || b; + if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false; + if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true; + if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true; + const aId = this.tarinaiFamilyKey(a) || aLiveId; + const bId = this.tarinaiFamilyKey(b) || bLiveId; if (!aId || !bId || aId === bId) return false; - if (a.birthPartnerId && a.birthPartnerId === bId) return true; - if (b.birthPartnerId && b.birthPartnerId === aId) return true; const aChildren = new Set((a.children || []).filter(Boolean)); if (!aChildren.size) return false; for (const id of (b.children || [])) { @@ -687,13 +806,34 @@ class World { return false; } + canStartFight(t) { + if (!t || t.dead) return false; + if (t.sleepDisease) return false; + // Disease fight rule: explosion, zunchi, and fight disease can lash out; + // sleep disease cannot fight or be challenged, and other diseases cannot start fights. + if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true; + const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value); + const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease); + if (hasOtherDisease) return false; + return true; + } + + canBeFightTarget(t) { + return Boolean(t && !t.dead && !t.sleepDisease); + } + + canFightPair(actor, target) { + return this.canStartFight(actor) && this.canBeFightTarget(target); + } + 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 inheritedPersonality = a.personality === b.personality ? a.personality : (Math.random() < 0.5 ? a.personality : b.personality); + const childBirthPersonality = personalityFromParents(a, b); const child = this.addTarinai({ type: mutation, - personality: inheritedPersonality || personalityForSeed(`${a.id}:${b.id}:${this.time}:${index}`), + birthPersonality: childBirthPersonality, + currentPersonality: childBirthPersonality, name: makeName(), age: 0, x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, @@ -706,7 +846,7 @@ class World { 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], + parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)], parentNames: [a.name, b.name], birthTime: this.time, }); @@ -719,22 +859,26 @@ class World { b.adjustRelation?.(a, 0.65, -0.20, "co_parent"); a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28); b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28); - a.children.push(child.id); - b.children.push(child.id); + const childKey = this.tarinaiFamilyKey(child); + const aKey = this.tarinaiFamilyKey(a); + const bKey = this.tarinaiFamilyKey(b); + a.children.push(childKey); + b.children.push(childKey); 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); + if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey); + if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey); this.familyCleanVersion = null; audio.birth(); this.lastBirthAt = this.time; - if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth"); + if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] }); return child; } startForcedFight(a, b) { if (!a || !b || a === b || a.dead || b.dead) return false; + if (!this.canFightPair(a, b)) return false; const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id); if (alreadyFighting) return true; for (const t of [a, b]) { @@ -765,6 +909,7 @@ class World { const bDrug = (b.fightMochiTimer || 0) > 0.04; const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); const target = actor === a ? b : a; + if (!this.canFightPair(actor, target)) return false; actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); actor.intimidateTargetId = target.id; actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); @@ -787,10 +932,13 @@ class World { if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) 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) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + 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) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const actor = scoreA >= scoreB ? a : b; const target = actor === a ? b : a; + if (!this.canFightPair(actor, target)) return; const actorScore = actor === a ? scoreA : scoreB; const targetScore = actor === a ? scoreB : scoreA; const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); @@ -807,7 +955,7 @@ class World { 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 resistance = target.energy * 0.30 + target.mood * 0.16 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 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); @@ -841,7 +989,7 @@ class World { 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"); + this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] }); actor.lastLog = target.lastLog = this.time; } return true; @@ -849,6 +997,7 @@ class World { startFight(a, b) { if (!a || !b || a.dead || b.dead) return; + if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) 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)); @@ -865,8 +1014,10 @@ class World { 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) + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); + const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + aAggressiveIntent * 5 + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); + const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + bAggressiveIntent * 5 + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); const loser = aScore <= bScore ? a : b; const winner = loser === a ? b : a; loser.defeatedById = winner.id; @@ -890,11 +1041,45 @@ class World { })); 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"); + this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] }); a.lastLog = b.lastLog = this.time; } } + damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) { + let hit = 0; + for (const ant of this.ants || []) { + if (!ant || ant.dead) continue; + const d = Math.max(1, distXY(ant.x, ant.y, x, y)); + if (d > radius + (ant.r || 4)) continue; + const p = clamp(1 - d / Math.max(1, radius), 0, 1); + if (p <= 0 && d > radius) continue; + const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0); + if (damage > 0) { + ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage); + ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25); + } + if (opts.push) { + let nx = (ant.x - x) / d; + let ny = (ant.y - y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const push = typeof opts.push === "function" ? opts.push(p, ant, d) : Number(opts.push || 0); + ant.vx = (ant.vx || 0) + nx * push; + ant.vy = (ant.vy || 0) + ny * push; + ant.x = clamp(ant.x + nx * Math.min(18, push * 0.018), CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ant.y = clamp(ant.y + ny * Math.min(18, push * 0.018), CONFIG.worldPadding, this.h - CONFIG.worldPadding); + } + hit += 1; + if (ant.hp <= 0 && ant.dieAsCorpse) ant.dieAsCorpse(ant.homeNest?.()); + } + if (hit > 0) this.drawListDirty = true; + return hit; + } + explodeFirecracker(it) { if (!it || it.dead || it.type !== "firecracker") return; it.amount = 0; @@ -905,6 +1090,7 @@ class World { 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)" })); } + this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1); for (let i = 0; i < 34; i++) { const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); const speed = rand(90, 260); @@ -941,17 +1127,32 @@ class World { 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); + let nx = dx / d; + let ny = dy / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const distanceBoost = p * p * 0.55 + p * 0.45; + const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); + this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { + panic: true, + target: { x, y }, + fearTimer: 1.7 + p, + }); + t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); 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 && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it); 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)"); } + this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { + push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), + }); let blastedBalls = 0; for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; @@ -965,14 +1166,8 @@ class World { dx = Math.cos(a); dy = Math.sin(a); d = 1; } const blast = 430 + p * 960; - ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80); - ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80); + this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - const cap = 1250; - if (speed > cap) { - ball.vx = ball.vx / speed * cap; - ball.vy = ball.vy / speed * cap; - } ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.lastPokedAt = this.time || 0; ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); @@ -983,6 +1178,90 @@ class World { this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); } + explodeDiseaseTarinai(t) { + if (!t || t.dead) return; + const x = t.x, y = t.y; + const blastRadius = 190; + t.explosionDisease = false; + t.explosionDiseaseTimer = 0; + this.effects.push(new Effect("explosion", x, y, { size: 72, life: 0.82, color: "rgba(255,176,68,0.88)" })); + this.effects.push(new Effect("ring", x, y, { size: 58, life: 0.52, color: "rgba(255,238,132,0.82)" })); + this.blastZunchiFrom(x, y, blastRadius * 0.92, 0.82); + for (const o of this.tarinai) { + if (!o || o.dead || o === t) continue; + const dx = o.x - x; + const dy = o.y - y; + const d = Math.hypot(dx, dy) || 1; + if (d > blastRadius) continue; + const q = clamp(1 - d / blastRadius, 0, 1); + o.damage(4 + q * 15, "\u7206\u767a\u75c5"); + o.addStress ? o.addStress(16 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 16 * q, 0, 130)); + let nx = dx / d; + let ny = dy / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const diseaseBlast = (190 + q * 620) * rand(0.80, 1.22); + this.applyImpulse(o, nx * diseaseBlast + rand(-64, 64) * (0.5 + q), ny * diseaseBlast + rand(-64, 64) * (0.5 + q), { + panic: true, + target: { x, y }, + fearTimer: 1.0 + q, + }); + o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q); + o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q); + if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t); + } + t.die("\u7206\u767a\u75c5"); + audio.explode(); + this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] }); + } + + applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) { + const radius = 96; + const speed = Math.hypot(dx, dy) || 1; + const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1); + const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7); + let cleaned = 0; + for (const it of this.nearbyItems(x, y, radius + 80)) { + if (!it || it.dead) continue; + const d = distXY(it.x, it.y, x, y); + if (d > radius + (it.r || 12)) continue; + const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1); + if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") { + it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p); + cleaned += p; + } + } + for (const ef of this.effects || []) { + if (!ef || ef.dead) continue; + if (ef.type !== "bleed" && ef.type !== "splat") continue; + const d = distXY(ef.x || 0, ef.y || 0, x, y); + if (d > radius + 22) continue; + const p = clamp(1 - d / (radius + 22), 0, 1); + ef.life = Math.min(ef.life || 0.1, Math.max(0.01, (ef.life || 0.1) - dt * (1.6 + p * 5.0))); + cleaned += p * 0.55; + } + for (const t of this.nearbyTarinai(x, y, radius + 70)) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + const d = distXY(t.x, t.y, x, y); + if (d > radius + t.radius) continue; + const p = clamp(1 - d / (radius + t.radius), 0, 1); + t.vx += nx * (95 + 165 * p) + rand(-10, 10); + t.vy += ny * (95 + 165 * p) + rand(-10, 10); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); + t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose"); + } + if (Math.random() < 0.72) { + this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" })); + } + if (cleaned > 0.25) { + this.drawListDirty = true; + this.updateItemCounts(); + } + } + spawnZunchi(x, y) { const zunchiLimit = CONFIG.zunchiLimit ?? 56; if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { @@ -1061,15 +1340,22 @@ class World { } isSleepFurniture(it) { - return Boolean(it && it.type === "bed"); + return Boolean(it && (it.type === "bed" || it.type === "nest_box")); + } + + isTarinaiHiddenInNestBox(t) { + return Boolean(t && !t.dead && t.insideNestBoxId); } bedOccupancy(bed) { if (!this.isSleepFurniture(bed)) return 0; + if (bed.type === "nest_box") { + return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 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.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; } return n; @@ -1082,19 +1368,27 @@ class World { } bestBedFor(t, maxDist = 360) { - let best = null; - let bestScore = Infinity; - for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { - if (bed.dead || !this.isSleepFurniture(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 crowdPenalty = Math.max(0, occ - 3) * 34; - const score = d - comfort * 46 + crowdPenalty; - if (score < bestScore) { best = bed; bestScore = score; } - } - return best; + const choose = (kind) => { + let best = null; + let bestScore = Infinity; + for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { + if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue; + if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; + if (occupants.length >= this.nestBoxCapacity(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 crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34; + const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty; + if (score < bestScore) { best = bed; bestScore = score; } + } + return best; + }; + // Prefer an available nest box, then fall back to a hay bed. + return choose("nest_box") || choose("bed"); } relationNotice(aId, bId, kind, cooldown = 30) { @@ -1106,6 +1400,52 @@ class World { return true; } + applyImpulse(entity, vx = 0, vy = 0, options = {}) { + if (!entity || entity.dead) return false; + if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; + if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { + entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; + entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; + } else { + entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx; + entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; + } + if (options.panic && entity instanceof Tarinai) { + entity.state = "panic"; + entity.target = options.target || null; + entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); + } + return true; + } + + applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { + if (!entity || !Number.isFinite(strength)) return false; + let dx = (entity.x || 0) - sourceX; + let dy = (entity.y || 0) - sourceY; + let d = Math.hypot(dx, dy); + if (!Number.isFinite(d) || d < 0.001) { + const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); + dx = Math.cos(a); + dy = Math.sin(a); + d = 1; + } + const random = options.random || 0; + return this.applyImpulse( + entity, + dx / d * strength + rand(-random, random), + dy / d * strength + rand(-random, random), + options, + ); + } + + applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { + if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; + const normalized = options.cause || cause || "\u885d\u7a81"; + if (target.damage) target.damage(amount, normalized); + else if (Number.isFinite(target.hp)) target.hp -= amount; + return true; + } + resolveFightOutcome(winner, loser) { if (!winner || !loser || winner.dead || loser.dead) return; const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; @@ -1121,48 +1461,70 @@ class World { 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; + winner.totalFightWins = (winner.totalFightWins || 0) + 1; loser.totalFightLosses = (loser.totalFightLosses || 0) + 1; + winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); + loser.adjustPersonality?.("aggression", -0.018, "after losing fights."); if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) { const oldName = loser.formerName || loser.name; this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); - this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight"); + this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: [loser] }); } 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"); + this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } } itemDropImpact(it) { if (!it || it.dead || it.type === "water") 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); + const isGenkotsu = it.type === "genkotsu"; + const radius = isGenkotsu ? Math.max(118, (it.r || 72) * 1.72) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); + const damage = isGenkotsu ? rand(34, 54) : (isStone ? rand(20, 34) : rand(3, 7)); + const push = isGenkotsu ? rand(330, 470) : (isStone ? rand(145, 210) : rand(54, 92)); + const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064\u304c\u843d\u3061\u305f" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); + if (isGenkotsu) it.impactFlash = 1; let hit = 0; - for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { - if (!t || t.dead) continue; + for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) 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; + let nx = (t.x - it.x) / d; + let ny = (t.y - it.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } 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.vy += ny * push * (0.35 + p) - (isGenkotsu ? 92 : (isStone ? 42 : 18)) * p; + t.damage(damage * (0.35 + p * 0.65), reason); + t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); + t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (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"); + const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { + push: p => push * (0.35 + p), + }); + if (isGenkotsu) { + for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); + for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); + } else { + 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 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.22 : (isStone ? 0.9 : 0.45)); + if ((isStone || isGenkotsu) && (hit > 0 || antHit > 0)) audio.poke(); + if (hit > 0 || antHit > 0) { + const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); + this.log(`${label}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u3068${antHit}\u5339\u306e\u30a2\u30ea\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + } } countBallChasers(ball, except = null) { @@ -1187,8 +1549,8 @@ class World { for (const [ball, arr] of groups) { if (arr.length <= 5) continue; arr.sort((a, b) => { - const ap = a.personality === "playful" ? -120 : 0; - const bp = b.personality === "playful" ? -120 : 0; + const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; + const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { @@ -1223,26 +1585,109 @@ class World { ball.vx = (ball.vx || 0) + nx * impulse; ball.vy = (ball.vy || 0) + ny * impulse; const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - const cap = 1250; - if (speed > cap) { - ball.vx = ball.vx / speed * cap; - ball.vy = ball.vy / speed * cap; - } ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38); ball.amount = Math.max(ball.amount || 999, 999); audio.poke(); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" })); - if (ball.pokeCombo >= 4 || speed > 430) this.log(`\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064\u304d\u3001\u52e2\u3044\u304c\u5897\u3057\u305f\u3002`, "observe"); - else this.log(`\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u5f3e\u3044\u305f\u3002`, "observe"); + this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true }); return true; } + resolveBallBallCollisions(dt) { + if ((this.itemCounts?.ball || 0) < 2) return; + const now = this.time || 0; + if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); + const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball"); + for (const a of balls) { + const ar = a.r || 18; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; + for (const b of this.nearbyItems(a.x, a.y, range) || []) { + if (!b || b === a || b.dead || b.type !== "ball") continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; + const br = b.r || 18; + const hitRadius = ar + br; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d = Math.hypot(dx, dy); + let hitX = a.x; + let hitY = a.y; + if (d > hitRadius) { + const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; + const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; + const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; + const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; + const rvx = (a.x - ax0) - (b.x - bx0); + const rvy = (a.y - ay0) - (b.y - by0); + const rx = ax0 - bx0; + const ry = ay0 - by0; + const aa = rvx * rvx + rvy * rvy; + if (aa <= 0.0001) continue; + const bb = 2 * (rx * rvx + ry * rvy); + const cc = rx * rx + ry * ry - hitRadius * hitRadius; + const disc = bb * bb - 4 * aa * cc; + if (disc < 0) continue; + const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); + const axHit = ax0 + (a.x - ax0) * u; + const ayHit = ay0 + (a.y - ay0) * u; + const bxHit = bx0 + (b.x - bx0) * u; + const byHit = by0 + (b.y - by0) * u; + dx = bxHit - axHit; + dy = byHit - ayHit; + d = Math.hypot(dx, dy); + hitX = (axHit + bxHit) * 0.5; + hitY = (ayHit + byHit) * 0.5; + if (d > hitRadius + 0.5) continue; + } else { + hitX = (a.x + b.x) * 0.5; + hitY = (a.y + b.y) * 0.5; + } + if (d < 0.001) { + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const rs = Math.hypot(rvx, rvy); + if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } + else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } + } + const nx = dx / d; + const ny = dy / d; + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const relNormal = rvx * nx + rvy * ny; + if (relNormal > 0 && d >= hitRadius - 0.5) continue; + this.ballCollisionMemo.set(key, now); + const restitution = 0.86; + const impulse = -(1 + restitution) * relNormal / 2; + if (Number.isFinite(impulse)) { + a.vx = (a.vx || 0) - impulse * nx; + a.vy = (a.vy || 0) - impulse * ny; + b.vx = (b.vx || 0) + impulse * nx; + b.vy = (b.vy || 0) + impulse * ny; + } + const overlap = Math.max(0, hitRadius - d + 0.6); + if (overlap > 0) { + a.x -= nx * overlap * 0.5; + a.y -= ny * overlap * 0.5; + b.x += nx * overlap * 0.5; + b.y += ny * overlap * 0.5; + } + const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); + a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); + b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); + this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); + } + } + } + resolveBallInteractions(dt) { if (!this.itemCounts?.ball) return; for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const sweep = Math.min(420, ballSpeed * Math.max(0.016, dt || 0.016) + 84); + const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; const range = (ball.r || 18) + 72 + sweep; const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; @@ -1250,7 +1695,7 @@ class World { const sy = ball.y - py; const segLenSq = sx * sx + sy * sy; for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { - if (!t || t.dead || t.state === "sleep") continue; + if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; let hitX = ball.x; let hitY = ball.y; if (segLenSq > 1) { @@ -1281,42 +1726,43 @@ class World { const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); - const push = clamp(ballSpeed * 0.34, 95, 310); t.lastBallDamageAt = now; - t.vx += bvx * push; - t.vy += bvy * push; - t.damage(damage, "\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u305f"); - const timidStress = t.personality === "timid" ? 1.55 : 1.0; - const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18); + this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { + panic: true, + target: { x: ball.x, y: ball.y }, + fearTimer: 0.95, + }); + this.applyImpactDamage(t, damage, "\u885d\u7a81"); + const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); + const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); + t.state = "panic"; + t.target = { x: ball.x, y: ball.y }; t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); - t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38); - t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer); + t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); + t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); t.fallDir = bvx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer, 0.8); - this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6)); + t.fearTimer = Math.max(t.fearTimer, 0.95); + t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44); + t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); + this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" })); - if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u3066\u30c0\u30e1\u30fc\u30b8\u3092\u53d7\u3051\u305f\u3002`, "accident"); + if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); - if (t.dead) continue; + continue; } const relVx = (t.vx || 0) - (ball.vx || 0); const relVy = (t.vy || 0) - (ball.vy || 0); const relativeSpeed = Math.hypot(relVx, relVy); const playIntent = t.state === "play_ball" || t.target === ball; - const playful = t.personality === "playful"; + const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const kickCap = 1250; - if (kickedSpeed > kickCap) { - ball.vx = ball.vx / kickedSpeed * kickCap; - ball.vy = ball.vy / kickedSpeed * kickCap; - } ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); @@ -1326,8 +1772,8 @@ class World { t.vy -= ny * Math.min(22, impulse * 0.10); t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; - const timidBump = t.personality === "timid" && !playIntent ? clamp(ballSpeed / 190, 0.9, 3.8) : 0; - t.stress = clamp(t.stress - playRelief + timidBump, 0, 130); + const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; + t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); if (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100); @@ -1337,12 +1783,87 @@ class World { t.nextPlayBubbleAt = now + rand(2.0, 3.8); this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } - if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe"); + if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); } } } + resolveTarinaiHighSpeedCollisions(dt) { + const threshold = 150; + const now = this.time || 0; + if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); + for (const a of this.tarinai || []) { + if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + if (aSpeed < threshold) continue; + const px = Number.isFinite(a.prevX) ? a.prevX : a.x; + const py = Number.isFinite(a.prevY) ? a.prevY : a.y; + const sx = a.x - px; + const sy = a.y - py; + const segLenSq = sx * sx + sy * sy; + const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; + for (const b of this.nearbyTarinai(a.x, a.y, range)) { + if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; + const bvx = b.vx || 0; + const bvy = b.vy || 0; + const relVx = avx - bvx; + const relVy = avy - bvy; + const relSpeed = Math.hypot(relVx, relVy); + if (relSpeed < threshold) continue; + let hitX = a.x; + let hitY = a.y; + if (segLenSq > 1) { + const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); + hitX = px + sx * u; + hitY = py + sy * u; + } + const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); + const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; + if (d > hitDistance) continue; + this.tarinaiCollisionMemo.set(key, now); + const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; + const target = impactor === a ? b : a; + const ivx = impactor.vx || 0; + const ivy = impactor.vy || 0; + const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; + let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); + let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const aa = rand(0, Math.PI * 2); + nx = Math.cos(aa); + ny = Math.sin(aa); + } + const damage = clamp((relSpeed - 118) / 26, 1.2, 22); + this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { + panic: true, + target: { x: impactor.x, y: impactor.y }, + fearTimer: 0.55, + }); + impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); + impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); + this.applyImpactDamage(target, damage, "\u885d\u7a81"); + if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); + for (const t of [target, impactor]) { + if (!t || t.dead) continue; + t.state = "panic"; + t.target = { x: impactor.x, y: impactor.y }; + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); + t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + } + this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); + if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] }); + } + } + } + nearest(entity, types, maxDist = Infinity) { let best = null, bestD = maxDist; const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; @@ -1355,11 +1876,12 @@ class World { return best; } - nearestOther(entity, maxDist = Infinity) { + nearestOther(entity, maxDist = Infinity, predicate = null) { let best = null, bestD = maxDist; const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; for (const o of candidates) { - if (o === entity || o.dead) continue; + if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue; + if (predicate && !predicate(o)) continue; if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; const d = dist(entity, o); if (d < bestD) { best = o; bestD = d; } @@ -1383,48 +1905,167 @@ class World { 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; + const len = Math.max(112, (it.r || 42) * 3.55); + const thick = Math.max(10, (it.r || 42) * 0.31); + const halfW = vertical ? thick / 2 : len / 2; + const halfH = vertical ? len / 2 : thick / 2; return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal }; } - resolveFenceCollision(t) { - if (!t || t.dead) return; - const rr = Math.max(8, t.radius * 0.74); - const maxChecks = CONFIG.maxFenceCollisionChecks ?? 18; + nestBoxCapacity() { + return 5; + } + + nestBoxBaseRect(it) { + if (!it || it.dead || it.type !== "nest_box") return null; + const r = it.r || 42; + return { + left: it.x - r * 1.28, + right: it.x + r * 1.28, + top: it.y - r * 0.80, + bottom: it.y + r * 0.72, + type: "nest_box", + item: it, + }; + } + + nestBoxSolidRects(it) { + const base = this.nestBoxBaseRect(it); + if (!base) return []; + const w = base.right - base.left; + const h = base.bottom - base.top; + const colW = w / 3; + const rowH = h / 3; + const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it }); + return [ + mk(base.left, base.right, base.top, base.top + rowH, "top"), + mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"), + mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"), + ]; + } + + nestBoxTopRect(it) { + return this.nestBoxSolidRects(it)[0] || null; + } + + nestBoxEntryPoint(box) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const r = box.r || 42; + // Move the approach point to the center of the open middle cell. The + // collision resolver also ignores this nest box for active sleepers near + // the door, so tarinai can cross the threshold instead of sliding off posts. + return { + x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18), + }; + } + + nestBoxExitPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return this.nestBoxEntryPoint(box); + const r = box.r || 42; + const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; + return { + x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding), + }; + } + + nestBoxInnerPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : []; + let index = occupants.indexOf(occupant); + if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); + const r = box.r || 42; + const slots = [ + [0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16], + ]; + const slot = slots[index % slots.length]; + return { + x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), + y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), + }; + } + + solidObstacleRects(it) { + if (!it || it.dead) return []; + if (this.isFenceType(it.type)) { + const rect = this.fenceRect(it); + return rect ? [{ ...rect, type: it.type, item: it }] : []; + } + if (it.type === "nest_box") return this.nestBoxSolidRects(it); + return []; + } + + shouldIgnoreNestBoxCollisionFor(t, box) { + if (!t || !box || box.dead || box.type !== "nest_box") return false; + if (t.insideNestBoxId) return true; + if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false; + const entry = this.nestBoxEntryPoint(box); + const base = this.nestBoxBaseRect(box); + const r = box.r || 42; + const entryD = distXY(t.x, t.y, entry.x, entry.y); + const centerD = distXY(t.x, t.y, box.x, box.y); + const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0); + const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58; + return Boolean(aroundDoor || inDoorColumn); + } + + nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { + const rects = []; let checked = 0; - let pushed = false; - for (const it of this.nearbyItems(t.x, t.y, rr + 132)) { - if (checked >= maxChecks) break; - if (it.dead || !this.isFenceType(it.type)) continue; + for (const it of this.nearbyItems(x, y, radius)) { + if (!it || it === exclude || it.dead) continue; + if (include && !include(it)) continue; + const partRects = this.solidObstacleRects(it); + if (!partRects.length) continue; checked += 1; - 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 = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); - t.x += dx / d * push; - t.y += dy / d * push; - pushed = true; - if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; - else t.vy *= -0.18; + for (const rect of partRects) rects.push(rect); + if (checked >= maxChecks) break; + } + return rects; + } + + pointInRect(x, y, r, padding = 0) { + return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding); + } + + pushTarinaiOutOfRect(t, r, rr) { + if (!t || !r) return false; + const cx = clamp(t.x, r.left, r.right); + const cy = clamp(t.y, r.top, r.bottom); + let dx = t.x - cx; + let dy = t.y - cy; + let d = Math.hypot(dx, dy); + if (d >= rr) return false; + if (d < 0.001) { + const left = Math.abs(t.x - r.left); + const right = Math.abs(r.right - t.x); + const top = Math.abs(t.y - r.top); + const bottom = Math.abs(r.bottom - t.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; d = 1; } + else if (m === right) { dx = 1; dy = 0; d = 1; } + else if (m === top) { dx = 0; dy = -1; d = 1; } + else { dx = 0; dy = 1; d = 1; } + } + const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); + t.x += dx / d * push; + t.y += dy / d * push; + if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; + else t.vy *= -0.18; + return true; + } + + resolveSolidObstacleCollision(t) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; + const rr = Math.max(8, t.radius * 0.74); + let pushed = false; + for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { + if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; + if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true; } if (pushed) { const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); @@ -1435,6 +2076,10 @@ class World { } } + resolveFenceCollision(t) { + this.resolveSolidObstacleCollision(t); + } + segmentIntersectsRect(x1, y1, x2, y2, r) { @@ -1454,23 +2099,18 @@ class World { 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.nearbyItems(cx, cy, maxD + padding + 160)) { - if (it.dead || !this.isFenceType(it.type)) continue; - if (distXY(cx, cy, it.x, it.y) > maxD + padding + 130) 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 }; + for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) { + const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } return false; } grassBlockedAt(x, y, self = null) { - for (const it of this.nearbyItems(x, y, 52)) { + for (const it of this.nearbyItems(x, y, 96)) { 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; + for (const r of this.solidObstacleRects(it)) { + if (this.pointInRect(x, y, r, 10)) return true; } if (it.type !== "zunchi") continue; const dx = (x - it.x) / Math.max(22, it.r * 1.35); @@ -1490,7 +2130,7 @@ class World { grassOnTarinaiAt(x, y, minGap = 24) { for (const t of this.nearbyTarinai(x, y, minGap + 36)) { - if (t.dead) continue; + if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; } return false; @@ -1561,21 +2201,24 @@ class World { sortedDrawList() { if (!this.drawList) this.drawList = []; - if (this.drawListDirty || this.drawList.length !== this.tarinai.length) { + const antCount = (this.ants || []).filter(a => a && !a.dead).length; + const tarinaiCount = (this.tarinai || []).filter(t => t && !t.dead).length; + if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) { 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); + for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t); + for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a); + this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0)); this.drawListDirty = false; } return this.drawList; } nearbyItems(x, y, radius) { - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch); + return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch); } nearbyTarinai(x, y, radius) { - return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch); + return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch); } rebuildSpatial() { @@ -1672,13 +2315,220 @@ class World { this.tarinai.length = write; } + compactAnts() { + if (!this.ants) this.ants = []; + let write = 0; + for (let read = 0; read < this.ants.length; read++) { + const a = this.ants[read]; + if (a && !a.dead) this.ants[write++] = a; + } + if (this.ants.length !== write) this.drawListDirty = true; + this.ants.length = write; + } + + updateAnts(dt) { + if (!this.ants) this.ants = []; + const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest"); + for (const nest of nests) { + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); + if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6; + if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); + nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt); + const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; + const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); + const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0); + if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) { + const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32); + const a = new AntActor(this, { + kind: "worker", + homeId: nest.id, + x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18), + y: nest.y - nest.r * 1.00 + rand(-3, 3), + hp, + maxHp: ANT_WORKER_HP || 32, + }); + this.ants.push(a); + if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); + nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45; + this.drawListDirty = true; + } + } + for (const a of this.ants) a.update(dt); + } + + completeAntHaul(home, target) { + if (!home || home.dead || home.type !== "ant_nest" || !target || target.dead) return; + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(home); + const before = typeof syncAntNestCount === "function" + ? syncAntNestCount(this, home) + : clamp(Math.round(home.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); + if (before >= (ANT_NEST_MAX_COUNT || 10)) { + this.spawnQueenAnt(home); + } else { + if (Array.isArray(home.antWorkers)) home.antWorkers.push(ANT_WORKER_HP || 32); + home.antCount = clamp(before + 1, 0, ANT_NEST_MAX_COUNT || 10); + if (typeof syncAntNestCount === "function") syncAntNestCount(this, home); + } + target.x = home.x; + target.y = home.y; + target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f"); + this.drawListDirty = true; + } + + spawnQueenAnt(home) { + if (!home || home.dead || home.type !== "ant_nest") return null; + if ((home.queenSpawnAt || -999) + (CONFIG.dayLength || 120) > (this.time || 0)) return null; + if ((this.ants || []).some(a => a && !a.dead && a.kind === "queen" && a.homeId === home.id)) return null; + const spot = this.findAntNestFoundingSpot(home) || { + x: clamp((home.x || this.w * 0.5) + rand(-220, 220), 56, this.w - 56), + y: clamp((home.y || this.h * 0.5) + rand(-160, 160), 56, this.h - 56), + }; + home.queenSpawnAt = this.time || 0; + const queen = new AntActor(this, { + kind: "queen", + homeId: home.id, + x: home.x, + y: home.y - (home.r || 34) * 0.9, + foundingX: spot.x, + foundingY: spot.y, + foundingDelayUntil: (this.time || 0) + 0.35, + }); + this.ants.push(queen); + this.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u5de3\u5834\u6240\u3092\u63a2\u3057\u59cb\u3081\u305f\u3002", "event"); + this.drawListDirty = true; + return queen; + } + + findAntNestFoundingSpot(source) { + const baseX = Number.isFinite(source?.x) ? source.x : this.w * 0.5; + const baseY = Number.isFinite(source?.y) ? source.y : this.h * 0.5; + let best = null; + let bestD = -Infinity; + for (let i = 0; i < 70; i++) { + const a = rand(0, Math.PI * 2); + const r = rand(150, Math.min(430, Math.max(this.w, this.h) * 0.52)); + const x = clamp(baseX + Math.cos(a) * r, 56, this.w - 56); + const y = clamp(baseY + Math.sin(a) * r * 0.74, 56, this.h - 56); + const nest = new Item("ant_nest", x, y); + const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y }; + if (!spot) continue; + let nearest = Infinity; + for (const it of this.items || []) { + if (!it || it.dead || it.type !== "ant_nest") continue; + nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y)); + } + if (nearest > bestD) { best = spot; bestD = nearest; } + if (nearest > 280) break; + } + return best; + } + + + environmentScores() { + const alive = this.tarinai || []; + const avgStress = alive.length ? alive.reduce((sum, t) => sum + (t.stress || 0), 0) / alive.length : 0; + let fightPressure = 0; + let diseaseCount = 0; + for (const t of alive) { + if (!t || t.dead) continue; + if (t.state === "fight") fightPressure += 16; + if (t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04 || (t.intimidatedTimer || 0) > 0.04) fightPressure += 7; + if ((t.hurtTimer || 0) > 0.04 || (t.lastBleedAt || -999) + 30 > (this.time || 0)) fightPressure += 5; + if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) diseaseCount += 1; + } + let blood = 0; + for (const ef of this.effects || []) if (ef && !ef.dead && (ef.type === "bleed" || ef.type === "splat")) blood += 1; + const zunchi = this.itemCounts?.zunchi || 0; + const splat = this.itemCounts?.splat || 0; + const trace = this.itemCounts?.trace || 0; + const antCorpse = this.itemCounts?.ant_corpse || 0; + const security = clamp(100 - fightPressure - blood * 1.6, 0, 100); + const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - antCorpse * 0.65 - blood * 1.8 - diseaseCount * 8.0, 0, 100); + const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100); + return { security, hygiene, happiness }; + } + + observeHighlightKind(t) { + const s = this.selected; + if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; + const tFamilyKey = this.tarinaiFamilyKey(t); + if ((s.parents || []).includes(tFamilyKey)) return "parent"; + if ((s.children || []).includes(tFamilyKey)) return "child"; + const rel = s.relationTo ? s.relationTo(t.id) : null; + if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend"; + if (rel && (rel.fear || 0) >= 8) return "enemy"; + return ""; + } + + nestBoxOccupants(box, limit = 5) { + if (!box || box.dead || box.type !== "nest_box") return []; + const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id); + return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; + } + + pointerNestBoxInfo() { + const p = this.pointer; + if (!p?.inside) return null; + let best = null, bestD = Infinity; + for (const it of this.nearbyItems(p.x, p.y, 140)) { + if (!it || it.dead || it.type !== "nest_box") continue; + const d = distXY(p.x, p.y, it.x, it.y); + if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; } + } + if (!best) return null; + const occupants = this.nestBoxOccupants(best, Infinity); + const capacity = this.nestBoxCapacity(best); + return { box: best, occupants, capacity }; + } + + nestBoxToolTipCount() { + const pointed = this.pointerNestBoxInfo?.(); + if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) }; + const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box"); + if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() }; + let current = 0, max = 0; + for (const box of boxes) { + current += this.nestBoxOccupants(box, Infinity).length; + max += this.nestBoxCapacity(box); + } + return { current, max }; + } + + blastZunchiFrom(x, y, radius, strength = 1) { + let moved = 0; + for (const it of this.nearbyItems(x, y, radius + 60)) { + if (!it || it.dead || it.type !== "zunchi") continue; + let dx = it.x - x; + let dy = it.y - y; + let d = Math.hypot(dx, dy) || 1; + if (d > radius) continue; + if (d < 0.001) { + const a = rand(0, Math.PI * 2); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const p = clamp(1 - d / radius, 0, 1); + it.vx = (it.vx || 0) + dx / d * (130 + p * 430) * strength + rand(-45, 45); + it.vy = (it.vy || 0) + dy / d * (130 + p * 430) * strength + rand(-45, 45); + it.amount = Math.max(10, (it.amount || 80) - p * 8); + it.stage = "fresh"; + moved += 1; + if (moved < 12) this.effects.push(new Effect("zunchi_miasma", it.x, it.y - 4, { vx: (it.vx || 0) * 0.15, vy: (it.vy || 0) * 0.15 - 18, size: rand(9, 18), life: rand(0.36, 0.62), color: "rgba(58,102,38,0.48)" })); + } + if (moved) this.drawListDirty = true; + return moved; + } + update(dt) { if (this.paused) return; dt *= this.speed; dt = Math.min(dt, 0.12 * this.speed); this.time += dt; + const previousDay = this.day || 1; this.day = Math.floor(this.time / CONFIG.dayLength) + 1; + if (this.day !== previousDay) { + for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {} }; + } if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4); this.updateWeather(dt); this.rebuildSpatial(); @@ -1686,15 +2536,22 @@ class World { const perf = this.performanceLevel(); const itemCountBefore = this.items.length; for (const it of this.items) it.update(dt, this); - if (this.itemCounts?.ball) this.rebuildSpatial(); + if (this.itemCounts?.ball) { + this.rebuildSpatial(); + this.resolveBallBallCollisions(dt); + this.rebuildSpatial(); + } + this.updateAnts?.(dt); for (const ef of this.effects) ef.update(dt); for (const t of this.tarinai) { t.update(dt); } + this.rebuildSpatial(); this.limitBallChasers(); this.resolveBallInteractions(dt); + this.resolveTarinaiHighSpeedCollisions(dt); this.compactTimer += dt; const compactInterval = perf >= 2 ? 0.95 : 0.58; @@ -1703,6 +2560,7 @@ class World { itemsCompacted = this.compactItems(); this.compactEffects(); this.compactTarinai(); + this.compactAnts?.(); this.compactTimer = 0; } @@ -1714,6 +2572,11 @@ class World { this.countsTimer = 0; } + if ((this.outOfBoundsCheckAt || -999) + 1.0 <= this.time) { + this.outOfBoundsCheckAt = this.time; + this.sanitizeOutOfBounds(); + } + this.drawSortTimer += dt; const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07; if (this.drawSortTimer >= sortInterval) { @@ -1753,9 +2616,9 @@ class World { } } - if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) { + if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 24 && Math.random() < dt * 0.12) { const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58)); - drop.amount = rand(18, 40); + drop.amount = rand(24, 58); this.items.push(drop); this.itemCounts.water = (this.itemCounts.water || 0) + 1; } @@ -1772,6 +2635,82 @@ class World { } } + + sanitizeOutOfBounds() { + const pad = Math.max(28, CONFIG.worldPadding || 30); + const far = Math.max(360, Math.max(this.w || 1000, this.h || 720) * 0.75); + let changedItems = false; + const repairPoint = (obj, radius = 12) => { + if (!obj) return "delete"; + if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return "delete"; + const minX = pad - radius; + const maxX = (this.w || 1000) - pad + radius; + const minY = pad - radius; + const maxY = (this.h || 720) - pad + radius; + if (obj.x >= minX && obj.x <= maxX && obj.y >= minY && obj.y <= maxY) return "ok"; + if (obj.x < -far || obj.x > (this.w || 1000) + far || obj.y < -far || obj.y > (this.h || 720) + far) return "delete"; + obj.x = clamp(obj.x, pad, (this.w || 1000) - pad); + obj.y = clamp(obj.y, pad, (this.h || 720) - pad); + if (Number.isFinite(obj.vx)) obj.vx *= -0.18; + if (Number.isFinite(obj.vy)) obj.vy *= -0.18; + return "moved"; + }; + for (const it of this.items || []) { + const result = repairPoint(it, it.r || 12); + if (result === "delete") { it.amount = 0; changedItems = true; } + else if (result === "moved") changedItems = true; + } + for (const ef of this.effects || []) { + const result = repairPoint(ef, ef.size || 12); + if (result === "delete") ef.life = 0; + } + for (const t of this.tarinai || []) { + if (!t || t.dead) continue; + const result = repairPoint(t, t.radius || 22); + if (result === "delete") { + t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); + t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); + t.vx = 0; + t.vy = 0; + t.entryTimer = 0; + t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"); + } else if (result === "moved") { + t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); + t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); + t.vx = 0; + t.vy = 0; + t.entryTimer = 0; + } + } + let changedAnts = false; + for (const ant of this.ants || []) { + if (!ant || ant.dead) continue; + const result = repairPoint(ant, ant.kind === "queen" ? 18 : 10); + if (result === "delete") { + ant.dead = true; + changedAnts = true; + continue; + } + if (result === "moved") { + ant.vx = 0; + ant.vy = 0; + if (ant.state === "drag") { + ant.state = "return"; + ant.targetId = ""; + ant.targetToken = 0; + ant.targetRef = null; + } + changedAnts = true; + } + } + if (changedAnts) this.compactAnts?.(); + if (changedItems) { + this.compactItems(); + this.updateItemCounts(); + this.rebuildSpatial(); + } + } + updateWeather(dt) { this.nextWeatherChange -= dt; if (this.nextWeatherChange > 0) return; @@ -1781,9 +2720,10 @@ class World { : current === "cloudy" ? ["sunny", "cloudy", "light_rain"] : ["sunny", "cloudy"]; - this.weather = pick(choices); + const next = pick(choices); + this.weather = next; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); + if (next !== current) this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); } fencePlacementBlocked(item) { @@ -1794,20 +2734,155 @@ class World { if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72; for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { - if (!it || it.dead || !this.isFenceType(it.type)) continue; - const r = this.fenceRect(it); - if (!r) continue; - const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; - if (!separated) return true; + if (!it || it.dead) continue; + for (const r of this.solidObstacleRects(it)) { + const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; + if (!separated) return true; + } } return false; } + importantPlacementOverlapBlocked(item) { + if (!item || item.dead) return true; + const important = new Set(["grass", "fence_v", "fence_h", "nest_box", "water_bowl"]); + if (!important.has(item.type)) return false; + const placingGrass = item.type === "grass"; + const itemRects = this.solidObstacleRects(item); + const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback); + const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12); + const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => { + if (!rect) return false; + const px = clamp(cx, rect.left - margin, rect.right + margin); + const py = clamp(cy, rect.top - margin, rect.bottom + margin); + return distXY(cx, cy, px, py) < radius + margin; + }; + const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom)); + for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) { + if (!other || other === item || other.dead || !important.has(other.type)) continue; + // Grass is a ground layer: objects may be placed on top of existing grass. + // Grass placement itself still avoids objects and other grass via grassSpotOpen(). + if (!placingGrass && other.type === "grass") continue; + const otherRects = this.solidObstacleRects(other); + const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12); + const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); + if (itemRects.length && otherRects.length) { + if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; + continue; + } + if (itemRects.length) { + if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true; + continue; + } + if (otherRects.length) { + if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true; + continue; + } + if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true; + } + return false; + } + + placementBlocked(item) { + if (!item || item.dead) return true; + if (item.type === "genkotsu") return false; + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + for (const rect of rects) { + if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; + } + } else { + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; + } + if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; + if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; + if (this.importantPlacementOverlapBlocked(item)) return true; + if (item.type === "nest_box") { + const margin = Math.max(8, (item.r || 42) * 0.18); + for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { + if (!it || it === item || it.dead) continue; + for (const rect of this.solidObstacleRects(it)) { + for (const own of rects) { + const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom; + if (!separated) return true; + } + } + } + } + return false; + } + + placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + let left = 0, right = 0, top = 0, bottom = 0; + for (const rect of rects) { + left = Math.max(left, (item.x || 0) - rect.left); + right = Math.max(right, rect.right - (item.x || 0)); + top = Math.max(top, (item.y || 0) - rect.top); + bottom = Math.max(bottom, rect.bottom - (item.y || 0)); + } + return { + x: clamp(x, pad + left, this.w - pad - right), + y: clamp(y, pad + top, this.h - pad - bottom), + }; + } + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + return { + x: clamp(x, pad + radius, this.w - pad - radius), + y: clamp(y, pad + radius, this.h - pad - radius), + }; + } + + placementProbeItem(item, x, y) { + return { ...item, x, y, dead: false }; + } + + findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) { + if (!item || item.dead) return null; + if (item.type === "grass") { + const spot = this.findGrassPlantingSpot + ? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true }) + : null; + if (!spot) return null; + const probe = this.placementProbeItem(item, spot.x, spot.y); + return this.placementBlocked(probe) ? null : spot; + } + + const start = this.placementClampPointFor(item, item.x, item.y); + const original = this.placementProbeItem(item, start.x, start.y); + if (allowOriginal && !this.placementBlocked(original)) return start; + + const solid = this.isFenceType(item.type) || item.type === "nest_box"; + const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); + const count = attempts || (solid ? 72 : 36); + const golden = Math.PI * (3 - Math.sqrt(5)); + let best = null; + let bestD = Infinity; + for (let i = 0; i < count; i++) { + const t = count <= 1 ? 1 : i / (count - 1); + const radius = Math.max(8, searchRadius * Math.sqrt(t)); + const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2; + const rawX = item.x + Math.cos(angle) * radius; + const rawY = item.y + Math.sin(angle) * radius * 0.74; + const p = this.placementClampPointFor(item, rawX, rawY); + const probe = this.placementProbeItem(item, p.x, p.y); + if (this.placementBlocked(probe)) continue; + const d = distXY(item.x, item.y, p.x, p.y); + if (d < bestD) { best = p; bestD = d; } + if (d < Math.max(18, (item.r || 12) * 0.75)) break; + } + return best; + } + findGrabTargetAt(x, y) { let foundT = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { const t = this.tarinai[i]; - if (!t || t.dead) continue; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; } } let foundItem = null, foundItemD = Infinity; @@ -1838,13 +2913,13 @@ class World { showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); return null; } - if (this.fencePlacementBlocked(item)) { - showToast("\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002"); - return null; - } + } + if (this.placementBlocked(item)) { + showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002"); + return null; } if (dropped) { - item.dropMax = item.type === "stone" ? 0.72 : 0.58; + item.dropMax = item.type === "genkotsu" ? 0.78 : (item.type === "stone" ? 0.72 : 0.58); item.dropTimer = item.dropMax; item.dropImpactDone = false; } @@ -1859,10 +2934,9 @@ class World { 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; } + if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && 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; } @@ -1881,9 +2955,9 @@ class World { 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`); + if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); } else { - this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002"); + this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002", "observe", { hiddenFromObservation: true, countInStats: false }); } return; } @@ -1912,48 +2986,27 @@ class World { this.compactItems(); this.updateItemCounts(); this.rebuildSpatial(); - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; + const label = toolLabel(best.type); this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); return; } - const itemType = { - zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", water: "water", grass: "grass", - stone: "stone", bed: "bed", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h" - }[tool]; + const itemType = toolItemType(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; - } const rawItem = this.applyToolSize(new Item(itemType, px, py)); - if (this.isFenceType(rawItem.type)) { - const rect = this.fenceRect(rawItem); - if (rect) { - const dx = Math.max(CONFIG.worldPadding - rect.left, 0) - Math.max(rect.right - (this.w - CONFIG.worldPadding), 0); - const dy = Math.max(CONFIG.worldPadding - rect.top, 0) - Math.max(rect.bottom - (this.h - CONFIG.worldPadding), 0); - rawItem.x += dx; - rawItem.y += dy; - } - } + // Placement uses the same footprint that the preview validates. const placed = this.placeItem(rawItem, true); if (!placed) return; - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType]; - this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + const label = toolLabel(itemType); + this.log(itemType === "genkotsu" ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${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("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) 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:") || s.includes("\u6b7b\u56e0:")) 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"; @@ -1964,71 +3017,57 @@ class World { return "note"; } - log(text, kind = null) { - const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; + shouldHideFromObservationRecords(entry = {}) { + const type = typeof entry === "string" ? "" : (entry.eventType || ""); + return type === "ball_poke" || type === "avoid_relationship"; + } + + log(text, kind = null, options = {}) { + const entry = { + time: this.time, + text, + kind: kind || this.inferLogKind(text), + eventType: options.eventType || "", + hiddenFromObservation: Boolean(options.hiddenFromObservation), + participants: (options.participants || []).filter(Boolean).map(t => ({ + id: t.id || t.releasedLiveId || "", + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), + participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id), + participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({ + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), + }; + if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; + const countInStats = options.countInStats ?? !entry.hiddenFromObservation; + entry.countInStats = Boolean(countInStats); if (!this.eventCounters) this.eventCounters = {}; - this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + for (const t of options.participants || []) { + if (!t) continue; + if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); + } 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, lastBirthAt: this.lastBirthAt || -999, - 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.lastBirthAt = Number.isFinite(data.lastBirthAt) ? data.lastBirthAt : -999; - this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden"; - this.fieldZoom = Number(data.fieldZoom) || 1; - 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" && o?.type !== "cardboard").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(); +let world; +try { + world = new World(); +} catch (error) { + if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error); + throw error; +}