diff --git a/README.md b/README.md index 1cb4af3..e64b3d1 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,74 @@ -# たりないコロニー観察所 +# 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/data.js b/js/data.js index dc3de08..b681204 100644 --- a/js/data.js +++ b/js/data.js @@ -66,7 +66,6 @@ const ALPHA_BOUNDS = { }; 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, @@ -108,4 +107,3 @@ const LIGHTING_TUNING = { 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..8b14f4d --- /dev/null +++ b/js/health.js @@ -0,0 +1,148 @@ +"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", + lifespan: "\u5bff\u547d", + sleepDisease: "\u306d\u3080\u308a\u75c5", + explosionDisease: "\u7206\u767a\u75c5", + fightDisease: "\u55a7\u5629\u50b7\u75c5", + zunchiDisease: "\u305a\u3093\u3061\u75c5", + }); + 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 (/\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|\u55a7\u5629\u50b7\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease; + if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease; + 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|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return CAUSES.accident; + if (/\u5bff\u547d/.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 && (!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)) 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; + 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"); + 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..38ede23 100644 --- a/js/items.js +++ b/js/items.js @@ -7,10 +7,10 @@ class Item { 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 + food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, 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 + food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 }[type] || 80; this.age = 0; this.seed = Math.random() * 1000; @@ -68,7 +68,7 @@ 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)) this.amount -= dt * 0.12; if (this.type === "trace") this.amount -= dt * 1.35; if (this.type === "splat") this.amount -= dt * 1.05; if (this.type === "firecracker") { @@ -79,6 +79,7 @@ class Item { } } 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,6 +99,26 @@ 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; @@ -379,10 +400,10 @@ class Item { 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 + food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, 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 + food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 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; @@ -561,6 +582,36 @@ 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 === "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 +683,22 @@ 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 ? "#7d6040" : (styleWarm ? "#b98246" : "#9a6b3d"); + ctx.strokeStyle = styleNight ? "#4a3a2d" : "#5d4028"; + ctx.lineWidth = 2.4; + 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 ? "#3b2f27" : "#65432a"; + ctx.beginPath(); + ctx.arc(0, -this.r * 0.02, this.r * 0.42, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(255,230,152,0.38)"; + for (let i = 0; i < 6; i++) { + ctx.beginPath(); + ctx.ellipse(randSeed(this.seed + i, -this.r * 0.84, this.r * 0.84), randSeed(this.seed + 20 + i, this.r * 0.32, this.r * 0.68), this.r * 0.26, this.r * 0.045, randSeed(this.seed + 40 + i, -0.7, 0.7), 0, Math.PI * 2); + ctx.fill(); + } } else if (this.type === "ball") { const speed = Math.hypot(this.vx || 0, this.vy || 0); const moving = clamp(speed / 260, 0, 1); diff --git a/js/render.js b/js/render.js index 2b624db..055d424 100644 --- a/js/render.js +++ b/js/render.js @@ -633,6 +633,7 @@ function render() { } }; drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); + drawNestBoxTooltip(ctx, world); // Time HUD ctx.save(); @@ -675,6 +676,33 @@ function render() { } } +function drawNestBoxTooltip(ctx, world) { + const info = world.pointerNestBoxInfo?.(); + if (!info) return; + const box = info.box; + const names = info.occupants.length ? info.occupants.map(t => t.name).join("\u3001") : "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"; + const title = "\u5de3\u7bb1"; + const text = `${title}: ${names}`; + ctx.save(); + ctx.font = "12px Yomogi, sans-serif"; + const w = Math.min(240, Math.max(92, ctx.measureText(text).width + 22)); + const h = 38; + 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"; + ctx.fillText(text, x + 11, y + h / 2); + ctx.restore(); +} + function drawSelectedCard(ctx, world, lighting) { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) return; diff --git a/js/sim_core.js b/js/sim_core.js index 55fa064..89706e0 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -138,6 +138,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; diff --git a/js/tarinai.js b/js/tarinai.js index 5a2edb7..8feeb96 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -1,5 +1,19 @@ "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; @@ -86,6 +100,14 @@ class Tarinai { 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); 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; @@ -144,6 +166,7 @@ class Tarinai { 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, + sleepDisease: this.sleepDisease, sleepDiseaseCooldown: this.sleepDiseaseCooldown, explosionDisease: this.explosionDisease, explosionDiseaseTimer: this.explosionDiseaseTimer, fightDisease: this.fightDisease, fightDiseaseCooldown: this.fightDiseaseCooldown, lastBleedAt: this.lastBleedAt, favorite: this.favorite, insideNestBoxId: this.insideNestBoxId || null, nestFade: this.nestFade || 0, sleepDiseaseAnchorX: this.sleepDiseaseAnchorX, sleepDiseaseAnchorY: this.sleepDiseaseAnchorY, records: this.records ? this.records.slice(0, 42) : [], 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, @@ -200,6 +223,17 @@ class Tarinai { return sp ? sp.label : this.type; } + + addRecord(text, kind = "note") { + if (!text) 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; } @@ -303,100 +337,13 @@ 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 ""; - } - - 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 })); - } - } + 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); } recoverHealth(amount) { if (this.dead) return; @@ -526,12 +473,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 = { @@ -609,6 +551,192 @@ class Tarinai { } } + 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"); + 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"); + 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"); + 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"); + 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 = "\u55a7\u5629\u50b7\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\u55a7\u5629\u50b7\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight"); + return true; + } + + recoverFightDisease(reason = "") { + if (!this.fightDisease) return false; + this.fightDisease = false; + this.fightDiseaseCooldown = 20; + this.thought = reason || "\u55a7\u5629\u50b7\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\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight"); + 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; + } + + updateNestBoxPresence(dt = 0) { + const previous = this.insideNestBoxId || null; + let box = null; + if (previous) box = this.world?.items?.find?.(it => it && !it.dead && it.id === previous && it.type === "nest_box") || null; + if (!box && this.target && !this.target.dead && this.target.type === "nest_box") box = this.target; + if (!box) { + this.insideNestBoxId = null; + this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1); + return null; + } + const d = distXY(this.x, this.y, box.x, box.y); + const canEnter = (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58); + if (canEnter) { + this.insideNestBoxId = box.id; + this.nestFade = clamp((this.nestFade || 0) + dt * 2.4, 0, 1); + const pull = clamp(dt * 4.2, 0, 1); + this.x = lerp(this.x, box.x, pull); + this.y = lerp(this.y, box.y, pull); + this.vx *= Math.pow(0.45, dt * 60); + this.vy *= Math.pow(0.45, dt * 60); + return box; + } + this.insideNestBoxId = null; + this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 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"); + } + } + 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"); + } + if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { + this.recoverFightDisease("\u81ea\u7136\u306b\u55a7\u5629\u50b7\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 +770,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"; @@ -684,6 +815,37 @@ 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" || ["lonely", "calm", "playful", "teary", "cry"].includes(this.personality); + } + + 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.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; @@ -703,6 +865,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) { @@ -721,6 +885,11 @@ class Tarinai { } this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - 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,6 +961,11 @@ 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; @@ -821,6 +995,7 @@ class Tarinai { } this.maintainTargetProgress(dt); this.move(dt); + this.updateNestBoxPresence(dt); if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); this.checkLife(dt); } @@ -840,6 +1015,12 @@ 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)); @@ -862,15 +1043,18 @@ 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"; } sleepSpotFor(bed) { if (!bed) return { x: this.x, y: this.y }; + if (bed.type === "nest_box") { + return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; + } const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; const baseRing = bed.r * 0.52; const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95; @@ -1012,28 +1196,49 @@ class Tarinai { } } - 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 && cureFood.amount > 1.2 && !this.shouldAvoidTarget(cureFood)) { + this.state = "seek_food"; + this.target = cureFood; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); + this.thought = this.fightDisease ? "\u55a7\u5629\u50b7\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 = "\u55a7\u5629\u50b7\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); + const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390); if (specialMochi && specialMochi.amount > 1.2 && 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) { @@ -1097,7 +1302,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 }; @@ -1149,7 +1354,7 @@ 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); @@ -1158,7 +1363,7 @@ class Tarinai { } 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"]; const item = this.world.nearest(this, itemTypes, 420); if (item) { this.state = "seek_food"; @@ -1225,6 +1430,8 @@ class Tarinai { 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\u55a7\u5629\u50b7\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); @@ -1245,7 +1452,7 @@ class Tarinai { } } - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi") && it.amount > 1.2) { + if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && it.amount > 1.2) { const eating = Math.min(it.amount, 6.8); this.state = "eat"; this.target = it; @@ -1272,12 +1479,12 @@ class Tarinai { 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"); this.lastLog = this.world.time; } break; @@ -1335,18 +1542,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; } @@ -1516,11 +1714,12 @@ class Tarinai { } 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); @@ -1540,6 +1739,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; @@ -1639,6 +1839,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,7 +1853,7 @@ 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; @@ -1734,7 +1938,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 +1970,19 @@ 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.92, 0.08, 1) : 1; 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) { @@ -1793,6 +1999,49 @@ class Tarinai { 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: "\u4ef2\u826f\u3057" }, + 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; + 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; @@ -1833,7 +2082,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 +2095,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; @@ -1863,6 +2112,7 @@ class Tarinai { ctx.restore(); } + ctx.globalAlpha *= nestHideAlpha; if (faceRight) ctx.scale(-1, 1); const dw = drawW * squish; const dh = drawH / squish; @@ -1910,6 +2160,25 @@ class Tarinai { ctx.restore(); + if (this.favorite) { + ctx.save(); + ctx.globalAlpha = 1; + 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) { diff --git a/js/text_catalog.js b/js/text_catalog.js new file mode 100644 index 0000000..1bfdb8e --- /dev/null +++ b/js/text_catalog.js @@ -0,0 +1,71 @@ +"use strict"; + +const TEXT_CATALOG = { + toolTips: { + observe: "\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: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002", + poke: "\u3064\u3064\u3044\u3066\u53cd\u5fdc\u3068\u5c0f\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002", + pinch: "\u30c9\u30e9\u30c3\u30b0\u3067\u500b\u4f53\u3084\u9053\u5177\u3092\u3064\u307e\u3080\u3002", + new: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002", + zunchi: "\u6c5a\u308c\u3068\u611f\u67d3\u6e90\u3002\u305a\u3093\u3061\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002", + sweet: "\u305a\u3093\u3060\u9905\u3002\u6c17\u5206\u3092\u6574\u3048\u3001\u7206\u767a\u75c5\u3068\u55a7\u5629\u50b7\u75c5\u3092\u6cbb\u3059\u3002", + love_mochi: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002", + fight_mochi: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u5a01\u5687\u3068\u55a7\u5629\u306e\u5236\u9650\u3092\u5f31\u3081\u308b\u3002", + water: "\u98f2\u3080\u3068\u6c17\u5206\u56de\u5fa9\u3002\u305a\u3093\u3061\u75c5\u3068\u7206\u767a\u75c5\u3092\u6cbb\u3059\u3002", + water_bowl: "\u6d88\u3048\u306a\u3044\u6c34\u5834\u3002\u6c34\u3092\u63a2\u3059\u500b\u4f53\u306e\u6cbb\u7642\u5834\u6240\u306b\u306a\u308b\u3002", + sleep_drug: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002", + grass: "\u98df\u3079\u7269\u3002\u5468\u56f2\u306e\u30b9\u30c8\u30ec\u30b9\u3082\u5c11\u3057\u4e0b\u3052\u308b\u3002", + stone: "\u843d\u4e0b\u3059\u308b\u3068\u4e8b\u6545\u30c0\u30e1\u30fc\u30b8\u3002\u7f6e\u304b\u308c\u305f\u5f8c\u306f\u4f11\u307f\u5834\u306b\u3082\u306a\u308b\u3002", + bed: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002", + nest_box: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002", + water_hose: "\u30c9\u30e9\u30c3\u30b0\u3067\u305a\u3093\u3061\u3084\u8840\u75d5\u3092\u6d17\u3044\u6d41\u3057\u3001\u305f\u308a\u306a\u3044\u3082\u6c34\u3067\u6d41\u3059\u3002", + ball: "\u8ee2\u304c\u3063\u3066\u62bc\u3059\u3002\u4e8b\u6545\u306e\u539f\u56e0\u306b\u3082\u306a\u308b\u3002", + firecracker: "\u7206\u767a\u3067\u5468\u56f2\u3092\u5439\u304d\u98db\u3070\u3059\u3002\u7206\u767a\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002", + fence_v: "\u7e26\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002", + fence_h: "\u6a2a\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002" + }, + ecologyCards: [ + { + "title": "\u305f\u308a\u306a\u3044\u3068\u306f", + "image": "assets/sprites/tarinai_01_smile.png", + "text": "\u5ead\u3067\u52dd\u624b\u306b\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u304a\u306a\u304b\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\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\u3084\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u65e5\u3005\u306e\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002" + }, + { + "title": "\u6027\u683c\u3068\u500b\u6027", + "image": "assets/sprites/tarinai_21_normal_happy.png", + "text": "\u81c6\u75c5\u3001\u98df\u3044\u3057\u3093\u574a\u3001\u7720\u305f\u304c\u308a\u3001\u5bc2\u3057\u304c\u308a\u306a\u3069\u306e\u6027\u683c\u304c\u3042\u308b\u3002\u6027\u683c\u306f\u6050\u6016\u3001\u55a7\u5629\u3001\u7761\u7720\u3001\u4ef2\u9593\u3078\u306e\u5bc4\u308a\u65b9\u306b\u73fe\u308c\u308b\u3002" + }, + { + "title": "\u5bb6\u65cf\u3068\u95a2\u4fc2", + "image": "assets/sprites/tarinai_27_birth_ritual.png", + "text": "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u4ef2\u826f\u3057\u3001\u82e6\u624b\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u7247\u65b9\u306e\u6027\u683c\u3092\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\u55a7\u5629\u50b7\u75c5\u304c\u3042\u308b\u3002\u6c34\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u6c34\u3092\u3001\u305a\u3093\u3060\u9905\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3059\u308b\u3002" + }, + { + "title": "\u55a7\u5629\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\u55a7\u5629\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u30b9\u30c8\u30ec\u30b9\u904e\u591a\u3001\u7206\u7af9\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\u9053\u5177\u306f\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": "\u305f\u308a\u306a\u3044\u9054\u306e\u72b6\u6cc1\u3092\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7b49\u304b\u3089\u77e5\u308b\u3053\u3068\u304c\u51fa\u6765\u308b\u3002" + } + ] +}; + +window.TEXT_CATALOG = TEXT_CATALOG; diff --git a/js/ui.js b/js/ui.js index 78692a1..8bbde11 100644 --- a/js/ui.js +++ b/js/ui.js @@ -29,6 +29,7 @@ const ui = { fieldDialog: document.getElementById("fieldDialog"), fieldCancelBtn: document.getElementById("fieldCancelBtn"), ecologyDialog: document.getElementById("ecologyDialog"), + ecologyGrid: document.getElementById("ecologyGrid"), ecologyCloseBtn: document.getElementById("ecologyCloseBtn"), }; @@ -57,6 +58,7 @@ const uiCache = { chartBucketStart: -Infinity, chartBucketEnd: -Infinity, chartBucketAccum: null, + environmentInitialRow: null, lastChartSample: -Infinity, lastChartDraw: "", lastChartDrawAt: 0, @@ -150,7 +152,9 @@ function renderSelected() { 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, 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, + (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; @@ -159,9 +163,14 @@ function renderSelected() { 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(`\u55a7\u5629\u50b7\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)}`); + const recordHtml = (t.records || []).slice(0, 8).map(r => `
  • ${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}${escapeHtml(r.text || "")}
  • `).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; 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")}
    @@ -182,9 +191,18 @@ function renderSelected() {
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    \u5b50${t.children?.length || 0}
    \u305f\u308a\u306a\u3044${fmt(t.lack)}
    +

    \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) { return { idle: "\u5f85\u6a5f", @@ -193,6 +211,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: "\u55a7\u5629\u50b7\u75c5", system: "\u305a\u3093\u3061\u75c5", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", @@ -216,7 +235,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 "\u55a7\u5629\u50b7\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 +244,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", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u55a7\u5629\u50b7\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 +347,54 @@ 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 tip = btn?.dataset?.tip || ""; + if (!tip) return; + pop.textContent = tip; + pop.classList.remove("hidden"); + const rect = btn.getBoundingClientRect(); + const maxLeft = Math.max(8, window.innerWidth - 236); + pop.style.left = `${Math.min(maxLeft, Math.max(8, rect.left + rect.width / 2 - 110))}px`; + pop.style.top = `${Math.max(8, rect.top - pop.offsetHeight - 10)}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"); } @@ -411,7 +478,7 @@ function syncTopButtons() { if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; } -const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "grass", "stone", "firecracker", "fence_v", "fence_h"]); +const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "stone", "firecracker", "fence_v", "fence_h"]); const SIZE_ORDER = ["small", "medium", "large"]; const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; @@ -455,7 +522,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]"); @@ -496,6 +565,16 @@ function bindUI() { showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); }); + ui.selectedInfo?.addEventListener("click", (e) => { + 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.pauseBtn?.addEventListener("click", () => { world.paused = !world.paused; const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); @@ -599,6 +678,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 +734,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 +769,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 +835,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..68250be 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); @@ -35,18 +38,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 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 +64,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,13 +86,14 @@ 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 preview = bucket && bucket.count > 0 ? { t: Math.max(world.time || 0, bucket.start || 0), @@ -99,8 +102,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 +117,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 +133,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 +160,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") { @@ -179,13 +187,10 @@ function chartSeries(mode = "population") { { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, ]; - if (mode === "events") return [ - { key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" }, - { key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" }, - { key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" }, - { key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" }, - { key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" }, - { key: "ev_grass", label: "\u8349", color: "#489443" }, + 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" }]; } diff --git a/js/world.js b/js/world.js index 1bac9c4..fbb6ffa 100644 --- a/js/world.js +++ b/js/world.js @@ -274,12 +274,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", "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", "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 +290,7 @@ 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 (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale; if (item.type === "firecracker") item.blastScale = scale; return item; } @@ -905,6 +905,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); @@ -950,6 +951,7 @@ class World { 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)"); } let blastedBalls = 0; @@ -983,6 +985,79 @@ 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)); + o.vx += dx / d * (120 + q * 260) + rand(-34, 34); + o.vy += dy / d * (120 + q * 260) + rand(-34, 34); + 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"); + } + + 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) 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,7 +1136,7 @@ class World { } isSleepFurniture(it) { - return Boolean(it && it.type === "bed"); + return Boolean(it && (it.type === "bed" || it.type === "nest_box")); } bedOccupancy(bed) { @@ -1672,6 +1747,84 @@ class World { this.tarinai.length = write; } + + 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 security = clamp(100 - fightPressure - blood * 1.6, 0, 100); + const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - 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) return ""; + if ((s.parents || []).includes(t.id)) return "parent"; + if ((s.children || []).includes(t.id)) return "child"; + const rel = s.relationTo ? s.relationTo(t.id) : null; + if (rel && (rel.fear || 0) >= 8) return "enemy"; + if (rel && (rel.affinity || 0) >= 10) return "friend"; + return ""; + } + + nestBoxOccupants(box) { + if (!box || box.dead || box.type !== "nest_box") return []; + return this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id).slice(0, 12); + } + + 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); + return { box: best, occupants }; + } + + 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; @@ -1753,9 +1906,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; } @@ -1912,14 +2065,14 @@ 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 = { 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", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", 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; 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" + zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass", + stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h" }[tool]; if (itemType) { let px = x, py = y; @@ -1945,7 +2098,7 @@ class World { } 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]; + 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", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", 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`); } } @@ -1968,6 +2121,10 @@ class World { const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; if (!this.eventCounters) this.eventCounters = {}; this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + for (const t of this.tarinai || []) { + if (!t || !t.name || !text || !String(text).includes(t.name)) continue; + if (t.addRecord) t.addRecord(text, entry.kind); + } this.logs.unshift(entry); this.logs = this.logs.slice(0, 100); renderLog(this.logs);