diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index b705442..7b345cd 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,148 +1,19 @@ -## 15.15.0 +# Patch notes -- 巨大ファイル分割を実施。 -- `world.js` をコアクラス、視点/家系/戦闘/環境/空間管理/アリ/更新/配置ログ/ブートストラップに分割。 -- `tarinai.js` をコアクラス、人格・関係、病気・巣箱、欲求・アイテム、社会/移動/寿命、描画に分割。 -- `ui.js` を状態、ログ、選択詳細、レイアウト/ダイアログ、道具、イベント束縛に分割。 -- `ui_family.js` をデータ、非同期構築、レイアウト、パス生成、描画に分割。 -- 既存グローバルAPI互換を維持し、挙動変更を避ける形で分割。 -- Service Workerキャッシュと静的アセットバージョンを `15.15.0` に更新。 +## 15.20.3 +- Removed item-effect speech bubbles: `ドン`, `ぐるる`, `すっきり`, `Hg xN`, `ちまっ`, `でかっ`, `へにゃ`, `ムキッ`, and `mm...`. +- Reorganized the tool palette into color-coded collapsible groups: operation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles. +- Changed the tool UI ball icon to a soccer-ball emoji preview. +- Changed time phases: morning 05:00, day 10:00, evening 17:00, night 20:00. +- Increased genkotsu knockback for Tarinai and ants; genkotsu now also blasts zunchi away. +- Genkotsu damage now adds fear/stress and decreases Tarinai openness. +- Rain weather overlay is now diagonal streaks. Fallen rain-water items, zunda juice, and mercury remain horizontal oval item sprites. +- Tightened mobile operation UI visibility to smartphone-class compact touch devices. +- Sleep-disease Tarinai can be moved by ant hauling without snapping back to their sleep anchor. -## 15.9.1 修正 -- 草を地面レイヤーとして扱い、既存の草の上にもオブジェクトを配置できるようにしました。草同士・草と障害物の植え付け制限は維持しています。 -- けんか餅のゲーム内描画から星印を削除し、×印のみ表示するようにしました。 -- ダメージ後に出る「こわい…」吹き出しを削除しました。 -- 家系図に「更新」トグルを追加しました。デフォルトはOFFで、ONの間だけ家系図を描画・更新します。 -- 死亡した個体のライブIDを再利用しないようにし、友達・敵対関係が別個体へ引き継がれて見える問題を防止しました。死亡時の一時ターゲット参照も掃除します。 -- たりないデータUIをカテゴリ別の折り畳み表示に変更しました。 +## 15.20.2 and earlier summary -# Patch Notes - -## Encoding cleanup -- Normalized `index.html` back to UTF-8. -- Replaced mojibake strings with the intended labels. -- Escaped Japanese text in source files to avoid encoding-dependent corruption. - -## Personality specification fixes -- Personality tags and data UI now use Japanese labels from the four-axis personality model. -- Threshold-crossing personality logs are now generated in Japanese and only when visible trait bands change. -- Daily personality caps now track increase and decrease separately per parameter. -- Current personality effects are neutral inside the -0.5 to +0.5 band. -- Direct conversion to the legacy timid personality after damage was removed; cowardly behavior is derived from high neuroticism plus low aggression. -- Corpse/splat contact now increases stress and slowly reduces neuroticism through habituation. - -## Follow-up fixes -- Fixed CSS emoji escapes for tool UI icons. CSS now uses code-point escapes such as `\\1F448` instead of invalid surrogate escapes. -- Hid the detailed four-axis personality rows from the compact tarinai data UI. -- Restored `bestBedFor()` fallback so tarinai can choose hay beds when no usable nest box is available. -- Damage now wakes sleeping tarinai and forces tarinai hidden inside a nest box to leave the box. -- Added a short minimum stay window after entering a nest box to avoid immediate exit loops. -- Renamed the friend relation label from `\\u4ef2\\u826f\\u3057` to `\\u53cb\\u9054` in UI text and related logs. - - -## Personality cleanup v3 -- Current live friend count now changes sociability drift. Family/friend proximity raises sociability more when the tarinai has more current friends, while a lonely tarinai with no current friends can slowly lose sociability. -- Removed the legacy named personality route from constructor, serialization, child inheritance, selected data UI, selected-card overlay, and family-tree titles. The game now uses only birthPersonality/currentPersonality four-axis data. -- Kept empty legacy stubs only for old-save compatibility so older saves cannot revive the pre-four-axis personality model. - -## Follow-up fixes v4 -- Restored compact personality information in the tarinai data panel: visible tags, birth personality, and current personality. -- Moved the focused-tarinai highlight outside the sprite rotation transform so the ring and label stay upright. -- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress. -- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights. -- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies. - -## Ant nest update -- Added a new placeable `ant_nest` object and tool button. -- Added ant sprites cropped from the provided source image: queen, worker variants, and upside-down worker corpse. -- Each ant nest starts with 6 workers, caps at 10 workers, and sends at most 3 workers outside at once. -- Worker ants spawn above their home nest, random-walk, search for tarinai, and drag found tarinai back to the nest. Multiple workers dragging the same tarinai increase carrying speed. -- Dragged tarinai struggle automatically and can kill workers. Dead workers become edible `ant_corpse` items and slightly reduce hygiene. -- If a nest below capacity brings back a tarinai, its worker count increases by 1. -- If a full nest brings back another tarinai, a queen ant appears and creates a new ant nest in an open spot. -- Ant health does not recover. -- Added the requested Irasutoya credit entry. - -## Ant nest follow-up v5 -- Increased ant movement and hauling constants by 1.5x from the previous slow tuning. -- Kept queen spawning to at most one queen per nest per in-game day, while making spawned queens keep retrying until a new ant nest is built somewhere. -- Reduced the ant nest object radius from 34 to 17. -- Removed the separate ant corpse sprite asset. Ant corpses now render by flipping the worker ant sprite vertically and remain stationary while decaying. -- Added shared ant damage handling for firecracker explosions and dropped-object impacts. -- Kept ant worker HP in the nest worker pool so damaged workers remain damaged after returning to and leaving the nest. - - -## 15.11.0 -- イベントUI下部にログ種別別のプッシュ通知設定を追加。ON時は左下最前面に通知表示 -- 巣がなくなったアリは空きのある別巣へ所属変更。空き巣がない場合は死亡せず彷徨う -- 女王アリを大きめに表示 -- 画像アセットをWebP化し、参照をWebPへ切り替え -- Service Workerによる静的アセットキャッシュを追加し、indexのno-cache指定を解除 - -## 15.12.0 -- 優先度S/A/B相当の効果音イベントを追加。未指定音源はWeb Audioの仮SEで再生。 -- 効果音カテゴリを「たりないの声」「通知」「操作」に分類し、イベントUI下部から個別ON/OFF可能にした。 -- 音素材差し替え用の安定IDを `docs/SOUND_IDS.md` に整理。 -- げんこつ、爆竹、喧嘩、ダメージ、死亡、誕生、アリ、病気、回復、水、草、zunchi、UI操作などに音フックを追加。 - - -## 15.12.3 -- プッシュ通知の保存キーを更新し、初期状態はOFFに戻した。 -- 効果音設定は右上の音設定で、操作音・通知音のみデフォルトON、たりないの声はデフォルトOFFに変更。 -- 操作音と通知音の内蔵仮SE音量を大幅に増加。 -- つまむ操作でたりないデータカードが開く挙動を修正。 -- 道具から配置する全オブジェクトで配置音が鳴るよう、placeItem側に配置音を統合。 -- イベントUI折り畳み時の高さを圧縮。 - - -## 15.12.4 -- 効果音の仮SE音量を再度引き上げ。 -- イベントUI内の効果音設定を廃止し、右上の音設定メニューへ統一。 -- イベントログ欄が上下端に達した時、右メニュー全体へスクロールを受け渡すよう修正。 - -## 15.15.0 -- バージョン/キャッシュ管理を `js/version.js` と `service-worker.js` に統一。 -- Service Worker登録を `updateViaCache: "none"` に変更し、`.htaccess` でService Worker本体をno-cache化。 -- `js/event_bus.js` を追加し、ログ/フェーズ/配置/地形dirty/FPS変化などをイベント化。 -- `js/performance_manager.js` を追加し、FPSレベル、アリ更新間引き、エフェクト上限、統計更新間隔、地形再描画間隔を中央管理。 -- 地面キャッシュを全走査ハッシュ方式から `terrainVersion` dirty方式へ変更。 -- SpatialGridをアリ/障害物/食料/危険物セルに拡張。 -- `?debug=1` の内部デバッグオーバーレイを追加。 -- 音システムに音ID別ゲイン、音ID別最小間隔、同時発音上限、サンプル読み込み失敗イベントを追加。 - -## v15.15.0 -- 個体パラメータ変化アイテムを追加: 下剤、プロテイン、ンイテロプ、弾薬、怪しい薬。 -- 下剤: 効果時間中3秒ごとにずんちを放出し、食事で空腹度が下がらない。 -- プロテイン/ンイテロプ: 永続の与ダメージ・病気発症確率補正を追加。両者は排他で、最後に食べたものだけ有効。 -- 弾薬: 効果時間中の歩行速度10倍と、移動中のたりない/アリ衝突ノックバックを追加。 -- 怪しい薬: 行動変容アイテム効果と病気の中からランダム2種を付与。 -- たりないデータに「現在のアイテム効果」を追加。 -- バージョンとService Workerキャッシュを15.15.0に更新。 - -## v15.18.0 -- Implemented static `app_manifest.json` and `scripts/generate_app_files.py` for HTML/Service Worker static-list synchronization. -- Split `css/styles.css` into ordered CSS files while keeping a compatibility shim. -- Added `js/effect_registry.js` and routed item-effect labels/colors/random pools/selected display text through the registry. -- Added `js/sound_pack.js`; `audio.js` now consumes sound pack gains, cooldowns, and sample paths. -- Added mobile canvas input modes: 自動 / カメラ / 道具 / 家系図. -- Expanded debug overlay with Service Worker/cache/audio/effect diagnostics. -- Added dedicated tool icons for 下剤, プロテイン, ンイテロプ, 弾薬, 怪しい薬, 水銀, 巨大薬, 矮小薬, ずんだ汁. - -## v15.19.1 -- 効果音のマスター出力、カテゴリ別ゲイン、ID別ゲインを再調整。連続再生で小さくなりにくいよう同時発音上限とWeb Audio出力経路を変更。 -- 食べ物の自然消耗を連続減少から半日に2回程度の離散消耗へ変更。衛生統計ペナルティも同じタイミングに抑制。 -- 薬系アイテムの地面表示とUIアイコンを錠剤風に変更。 -- プロテイン/ンイテロプの外観を山盛りの粉に変更。永続効果中は継続的なオーラ/粒子エフェクトを表示。 -- ずんだ汁を緑の水滴外観へ変更。雨と水滴表示は灰色寄りへ変更。 -- バージョンとService Workerキャッシュを 15.19.1 に更新。 - - -## v15.19.1 -- Removed embedded letters/sub-labels from new item sprites and tool icons. -- Refreshed parameter item UI icons; cache-busted updated icon URLs. -- Fixed firecracker placement triggering explosion sound through accident log inference. -- Made giant drug placed sprite larger and dwarf drug placed sprite smaller. -- Redrew mercury, zunda juice, and water as rain-drop style sprites. -- Changed protein/niteropu persistent visuals to red rising particles and purple falling particles. -- Fixed remaining colony chart tab label from 生活 to 精神. +- Consolidated documentation into dense English files; left `docs/prompt.txt` untouched. +- Restored rain, revised water/zunda/mercury item sprites, added zunchi-slave head zunchi, fight win rate, fight defeat chance, and post-wake lock. +- Earlier patches added parameter items, persistent item effects, WebP assets, sound categories, push notifications, ant systems, family tree UI, performance manager, event bus, manifest generation, CSS split, and large-file JS split. diff --git a/README.md b/README.md index 2431355..5e9aa0a 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,35 @@ # Tarinai Colony Observation Game -Single-page, dependency-free browser simulation. Open `index.html`; no build step, package manager, server API, transpiler, or module loader is required. Runtime is plain HTML/CSS/JS loaded by ordered ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index ffe7f18..a82d66d 100644 --- a/js/ants.js +++ b/js/ants.js @@ -265,13 +265,21 @@ class AntActor { } const haulers = Math.max(1, this.activeHaulersForTarget().length); const leadHauler = this.isLeadHauler(); + const targetWasSleeping = Boolean(target.sleeping || target.state === "sleep"); target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.35); target.antDraggedBy = this.id; - target.state = "panic"; - target.sleeping = false; target.target = null; - target.fearTimer = Math.max(target.fearTimer || 0, 0.36); - target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b"; + if (targetWasSleeping) { + target.sleeping = true; + target.state = "sleep"; + target.sleepTimer = Math.max(target.sleepTimer || 0, 0.8); + target.thought = "眠ったままアリに運ばれている"; + } else { + target.state = "panic"; + target.sleeping = false; + target.fearTimer = Math.max(target.fearTimer || 0, 0.36); + target.thought = "アリに巣へ運ばれている"; + } const dHome = distXY(target.x, target.y, home.x, home.y) || 1; if (dHome < Math.max(18, home.r * 0.66)) { this.world.completeAntHaul?.(home, target); @@ -298,10 +306,11 @@ class AntActor { this.vy = uy * speed; const aggression = Math.max(0, target.currentPersonality?.aggression || 0); - const aggressiveAttack = target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"; - const struggle = aggressiveAttack + const stillSleeping = Boolean(target.sleeping || targetWasSleeping || target.state === "sleep"); + const aggressiveAttack = !stillSleeping && (target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"); + const struggle = stillSleeping ? 0 : (aggressiveAttack ? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9) - : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers); + : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers)); this.hp -= dt * struggle; if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { diff --git a/js/data.js b/js/data.js index e228a5f..dba56dc 100644 --- a/js/data.js +++ b/js/data.js @@ -51,14 +51,14 @@ const TOOL_DEFINITIONS = Object.freeze({ water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, - laxative: { id: "laxative", itemType: "laxative", label: "下剤", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "食べると一定時間、3秒ごとにずんちを放出し、食べても空腹度が下がらなくなる。" }, - protein: { id: "protein", itemType: "protein", label: "プロテイン", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "永続。与えるダメージ1.75倍、病気発症確率半減。ンイテロプとは最新効果だけ有効。" }, - niteropu: { id: "niteropu", itemType: "niteropu", label: "ンイテロプ", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "永続。与えるダメージ0.5倍、病気発症確率2倍。プロテインとは最新効果だけ有効。" }, - ammo: { id: "ammo", itemType: "ammo", label: "弾薬", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "効果時間中、歩行速度10倍。移動中にたりないやアリへ衝突すると弾き飛ばす。" }, - mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "怪しい薬", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "行動を変えるアイテム効果と病気の中からランダムで2つ付与する。" }, - mercury: { id: "mercury", itemType: "mercury", label: "水銀", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "食べるたびに寿命が1.3倍ずつ乗算される。ずんだ汁で解除できる。" }, - giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "巨大薬", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "永続。サイズ3倍、攻撃力2倍、移動速度1.5倍。毎秒1ダメージ。矮小薬とは最新効果だけ有効。" }, - dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "矮小薬", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "永続。サイズ0.25倍、攻撃力0.25倍、移動速度0.5倍。毎秒1ダメージ。巨大薬とは最新効果だけ有効。" }, + laxative: { id: "laxative", itemType: "laxative", label: "下剤", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "ずんちを大量に排泄させる。何かを食べてもすぐに出て行ってしまう。" }, + protein: { id: "protein", itemType: "protein", label: "プロテイン", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "体をたくましくし、病気に強くなる。" }, + niteropu: { id: "niteropu", itemType: "niteropu", label: "ンイテロプ", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "体を頼りなくし、病気に弱くなる。" }, + ammo: { id: "ammo", itemType: "ammo", label: "弾薬", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "全てを加速させる。" }, + mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "怪しい薬", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "何かが起こる。" }, + mercury: { id: "mercury", itemType: "mercury", label: "水銀", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "長生きの秘訣。" }, + giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "巨大薬", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "体を大きく強くするが、負担が大きい。" }, + dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "矮小薬", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "体を小さく弱くするが、負担も大きい。" }, zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "ずんだ汁", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "食べると全てのアイテム効果を除去する。" }, grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, diff --git a/js/items.js b/js/items.js index a7f3467..15230b3 100644 --- a/js/items.js +++ b/js/items.js @@ -27,6 +27,137 @@ function passiveFoodDecayRate(item) { return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; } +function drawOvalWaterDropSprite(ctx, r, palette = {}) { + const fill = palette.fill || "rgba(86, 157, 224, 0.82)"; + const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)"; + const highlight = palette.highlight || "rgba(255,255,255,0.64)"; + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1.6, r * 0.13); + // Horizontal oval item droplet silhouette for fallen water / zunda juice / mercury; rain overlay itself is a separate diagonal-line effect. + ctx.beginPath(); + ctx.ellipse(0, 0, r * 1.42, r * 0.56, -0.04, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.globalAlpha = 0.72; + ctx.fillStyle = highlight; + ctx.beginPath(); + ctx.ellipse(-r * 0.46, -r * 0.16, r * 0.34, r * 0.13, -0.18, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function drawSleepTabletSprite(ctx, r) { + ctx.save(); + ctx.rotate(-0.18); + ctx.fillStyle = "#ebe4ff"; + ctx.strokeStyle = "rgba(74, 60, 144, 0.72)"; + ctx.lineWidth = Math.max(1.8, r * 0.14); + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(112, 91, 188, 0.82)"; + ctx.beginPath(); + ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#ebe4ff"; + ctx.beginPath(); + ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 0.52; + ctx.strokeStyle = "rgba(255,255,255,0.94)"; + ctx.lineWidth = Math.max(1.2, r * 0.08); + ctx.beginPath(); + ctx.moveTo(-r * 0.56, -r * 0.28); + ctx.quadraticCurveTo(-r * 0.14, -r * 0.48, r * 0.48, -r * 0.20); + ctx.stroke(); + ctx.restore(); +} + +function drawLaxativeTabletSprite(ctx, r) { + ctx.save(); + ctx.fillStyle = "#f4d49a"; + ctx.strokeStyle = "rgba(98, 61, 29, 0.82)"; + ctx.lineWidth = Math.max(1.8, r * 0.14); + roundedRect(ctx, -r * 1.02, -r * 0.54, r * 2.04, r * 1.08, r * 0.34); + ctx.fill(); ctx.stroke(); + // Embedded zunchi mound motif: silhouette-based, not color-only. + ctx.fillStyle = "rgba(96, 70, 38, 0.88)"; + ctx.strokeStyle = "rgba(56, 39, 22, 0.42)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + const lumps = [ + [-0.38, 0.11, 0.25, 0.17], + [-0.12, -0.03, 0.29, 0.20], + [0.20, 0.10, 0.27, 0.18], + [0.03, -0.23, 0.20, 0.14], + ]; + for (const [x,y,w,h] of lumps) { + ctx.beginPath(); + ctx.ellipse(r*x, r*y, r*w, r*h, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + ctx.globalAlpha = 0.45; + ctx.strokeStyle = "rgba(255,255,255,0.90)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + ctx.beginPath(); + ctx.moveTo(-r * 0.68, -r * 0.32); + ctx.quadraticCurveTo(-r * 0.24, -r * 0.48, r * 0.46, -r * 0.26); + ctx.stroke(); + ctx.restore(); +} + +function drawMysteryTabletSprite(ctx, r) { + ctx.save(); + ctx.fillStyle = "#f3ecff"; + ctx.strokeStyle = "rgba(91, 55, 150, 0.84)"; + ctx.lineWidth = Math.max(1.9, r * 0.15); + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.94, r * 0.78, 0.10, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(91,55,150,0.92)"; + ctx.font = `bold ${Math.round(r * 1.35)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("?", 0, -r * 0.04); + ctx.globalAlpha = 0.52; + ctx.strokeStyle = "rgba(255,255,255,0.90)"; + ctx.lineWidth = Math.max(1.1, r * 0.08); + ctx.beginPath(); + ctx.moveTo(-r * 0.52, -r * 0.34); + ctx.quadraticCurveTo(-r * 0.12, -r * 0.54, r * 0.42, -r * 0.28); + ctx.stroke(); + ctx.restore(); +} + +function drawBulletSprite(ctx, r) { + ctx.save(); + ctx.rotate(0.0); + const g = ctx.createLinearGradient(-r, 0, r, 0); + g.addColorStop(0, "#b06f23"); + g.addColorStop(0.48, "#f4c46a"); + g.addColorStop(1, "#a05a1f"); + ctx.fillStyle = g; + ctx.strokeStyle = "rgba(87, 51, 22, 0.78)"; + ctx.lineWidth = Math.max(1.7, r * 0.13); + ctx.beginPath(); + ctx.moveTo(r * 0.86, 0); + ctx.quadraticCurveTo(r * 0.42, -r * 0.52, -r * 0.34, -r * 0.52); + ctx.lineTo(-r * 0.88, -r * 0.40); + ctx.lineTo(-r * 0.88, r * 0.40); + ctx.lineTo(-r * 0.34, r * 0.52); + ctx.quadraticCurveTo(r * 0.42, r * 0.52, r * 0.86, 0); + ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(255,237,176,0.62)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + ctx.beginPath(); + ctx.moveTo(-r * 0.44, -r * 0.34); + ctx.lineTo(r * 0.42, -r * 0.20); + ctx.stroke(); + ctx.restore(); +} + class Item { constructor(type, x, y) { this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; @@ -624,15 +755,15 @@ class Item { } } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { const meta = { - laxative: { fill: "#eef0b8", stroke: "#879046", accent: "#dfe770", kind: "pill" }, + laxative: { fill: "#f1d18a", stroke: "#774c1f", accent: "#5f3a1b", kind: "laxative" }, protein: { fill: "#f4a24f", stroke: "#b45b27", accent: "#ffe2b9", kind: "powder" }, niteropu: { fill: "#8a79d0", stroke: "#4c3f91", accent: "#d8d1ff", kind: "powder" }, - ammo: { fill: "#ffe8b6", stroke: "#b37a26", accent: "#d0942f", kind: "box" }, - mystery_drug: { fill: "#e9ffe8", stroke: "#4a9b65", accent: "#9de7ad", kind: "pill" }, - mercury: { fill: "rgba(216,219,220,0.98)", stroke: "rgba(198,202,204,0.98)", accent: "rgba(162,169,174,0.98)", kind: "droplet" }, + ammo: { fill: "#f4c46a", stroke: "#6f431f", accent: "#ffe8a8", kind: "bullet" }, + mystery_drug: { fill: "#dff4ff", stroke: "#27587b", accent: "#437c97", kind: "mystery" }, + mercury: { fill: "rgba(210,216,222,0.94)", stroke: "rgba(112,122,132,0.62)", accent: "rgba(255,255,255,0.72)", kind: "droplet" }, giant_drug: { fill: "#ffe3cf", stroke: "#c9632f", accent: "#ffb47b", kind: "pill" }, dwarf_drug: { fill: "#e9e9ff", stroke: "#6967ac", accent: "#b8b5f4", kind: "pill" }, - zunda_juice: { fill: "rgba(134,224,91,0.98)", stroke: "rgba(76,168,57,0.98)", accent: "rgba(202,255,186,0.98)", kind: "droplet" }, + zunda_juice: { fill: "rgba(125,218,82,0.94)", stroke: "rgba(63,150,50,0.62)", accent: "rgba(238,255,226,0.72)", kind: "droplet" }, }[this.type] || { fill: "#fffaf1", stroke: "#8d6f48", accent: "#ddd", kind: "pill" }; const localR = this.r * (this.type === "giant_drug" ? 1.08 : (this.type === "dwarf_drug" ? 0.92 : 1)); ctx.rotate(Math.sin(this.seed) * 0.10); @@ -662,29 +793,13 @@ class Item { ctx.fill(); ctx.restore(); } else if (meta.kind === "droplet") { - ctx.save(); - ctx.lineCap = "round"; - ctx.strokeStyle = meta.stroke; - ctx.lineWidth = Math.max(2.2, localR * 0.16); - ctx.globalAlpha = 0.92; - const lean = -localR * 0.34; - ctx.beginPath(); - ctx.moveTo(localR * 0.18, -localR * 0.90); - ctx.lineTo(localR * 0.18 + lean, localR * 0.42); - ctx.stroke(); - ctx.globalAlpha = 0.70; - ctx.fillStyle = meta.fill; - ctx.beginPath(); - ctx.ellipse(localR * 0.18 + lean, localR * 0.54, localR * 0.24, localR * 0.38, -0.18, 0, Math.PI * 2); - ctx.fill(); - ctx.globalAlpha = 0.50; - ctx.strokeStyle = meta.accent; - ctx.lineWidth = Math.max(1.0, localR * 0.07); - ctx.beginPath(); - ctx.moveTo(localR * 0.10, -localR * 0.72); - ctx.lineTo(localR * 0.10 + lean * 0.72, localR * 0.18); - ctx.stroke(); - ctx.restore(); + drawOvalWaterDropSprite(ctx, localR, { fill: meta.fill, stroke: meta.stroke, highlight: meta.accent }); + } else if (meta.kind === "laxative") { + drawLaxativeTabletSprite(ctx, localR); + } else if (meta.kind === "mystery") { + drawMysteryTabletSprite(ctx, localR); + } else if (meta.kind === "bullet") { + drawBulletSprite(ctx, localR); } else if (meta.kind === "box") { ctx.fillStyle = meta.fill; ctx.strokeStyle = meta.stroke; @@ -721,19 +836,7 @@ class Item { ctx.restore(); } } 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(255,255,255,0.58)"; - ctx.beginPath(); - ctx.ellipse(-this.r * 0.36, -this.r * 0.16, this.r * 0.25, this.r * 0.09, -0.20, 0, Math.PI * 2); - ctx.fill(); + drawSleepTabletSprite(ctx, this.r); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); @@ -786,22 +889,11 @@ class Item { 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.save(); - ctx.lineCap = "round"; - ctx.strokeStyle = "rgba(198, 202, 204, 0.98)"; - ctx.lineWidth = Math.max(2.0, this.r * 0.16); - const lean = -this.r * 0.34; - ctx.globalAlpha = 0.86; - ctx.beginPath(); - ctx.moveTo(this.r * 0.18, -this.r * 0.90); - ctx.lineTo(this.r * 0.18 + lean, this.r * 0.42); - ctx.stroke(); - ctx.globalAlpha = 0.58; - ctx.fillStyle = "rgba(216, 219, 220, 0.98)"; - ctx.beginPath(); - ctx.ellipse(this.r * 0.18 + lean, this.r * 0.54, this.r * 0.24, this.r * 0.38, -0.18, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); + drawOvalWaterDropSprite(ctx, this.r, { + fill: "rgba(86, 157, 224, 0.82)", + stroke: "rgba(45, 101, 168, 0.52)", + highlight: "rgba(255,255,255,0.68)", + }); } else if (this.type === "grass") { const growth = this.growth ?? clamp(this.amount / 120, 0, 1); const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; diff --git a/js/main.js b/js/main.js index 747a502..5ad3ed3 100644 --- a/js/main.js +++ b/js/main.js @@ -133,7 +133,7 @@ loadImages(null, (done, total) => { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { - const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.19.1"; + const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.3"; navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); }); } diff --git a/js/render.js b/js/render.js index 6c1cd05..167b9df 100644 --- a/js/render.js +++ b/js/render.js @@ -636,56 +636,38 @@ function drawRain(ctx, w, h, t) { if (world.weather !== "light_rain") return; ctx.save(); const perf = world.performanceLevel ? world.performanceLevel() : 0; - const count = perf >= 2 ? 58 : 118; - ctx.lineCap = "round"; + const count = perf >= 2 ? 64 : 138; + const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites. + const slant = Math.tan(angle); for (let i = 0; i < count; i++) { - const speed = randSeed(18100 + i, 132, 230); - const cycle = h + 320; - const len = randSeed(18500 + i, 13, 26); - const tilt = randSeed(18600 + i, 5, 12); - const fall = randSeed(18400 + i, -220, cycle) + t * speed; - const y = ((fall % cycle) + cycle) % cycle - 140; - // The droplet's movement vector matches the visible diagonal stroke. - const driftPerY = -tilt / Math.max(1, len); - const xCycle = w + 220; - const rawX = randSeed(18000 + i, -70, w + 120) + fall * driftPerY + Math.sin(t * 0.9 + i * 0.43) * 4; - const x = ((rawX % xCycle) + xCycle) % xCycle - 90; - const alpha = y < 110 ? clamp((y + 68) / 178, 0, 1) : 1; - ctx.globalAlpha = 0.34 + 0.30 * alpha; - ctx.strokeStyle = "rgba(196, 200, 202, 0.92)"; - ctx.lineWidth = randSeed(18700 + i, 1.0, 1.8); + const speed = randSeed(18100 + i, 360, 620); + const len = randSeed(18500 + i, 14, 28); + const cycleY = h + 260; + const cycleX = w + 260; + const fall = randSeed(18400 + i, -220, cycleY) + t * speed; + const y = ((fall % cycleY) + cycleY) % cycleY - 120; + const drift = fall * slant * 0.42; + const rawX = randSeed(18000 + i, -140, w + 180) + drift; + const x = ((rawX % cycleX) + cycleX) % cycleX - 130; + const alpha = y < 60 ? clamp((y + 90) / 150, 0, 1) : 1; + const dx = Math.sin(angle) * len; + const dy = Math.cos(angle) * len; + ctx.globalAlpha = (0.18 + 0.34 * randSeed(19000 + i, 0.35, 1.0)) * alpha; + ctx.strokeStyle = "rgba(88, 150, 218, 0.86)"; + ctx.lineWidth = randSeed(18700 + i, 0.8, 1.45); + ctx.lineCap = "round"; ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x - tilt, y + len); + ctx.moveTo(x - dx * 0.5, y - dy * 0.5); + ctx.lineTo(x + dx * 0.5, y + dy * 0.5); ctx.stroke(); - if (perf < 3 && i % 2 === 0) { - ctx.globalAlpha = 0.18 + 0.18 * alpha; - ctx.fillStyle = "rgba(213, 216, 218, 0.92)"; + if (perf < 3 && i % 4 === 0) { + ctx.globalAlpha = 0.13 * alpha; + ctx.strokeStyle = "rgba(255,255,255,0.70)"; + ctx.lineWidth = 0.7; ctx.beginPath(); - ctx.ellipse(x - tilt * 0.9, y + len + 2, 1.8, 3.0, -0.28, 0, Math.PI * 2); - ctx.fill(); - } - } - if (perf < 3) { - const dripCount = perf >= 2 ? 10 : 18; - for (let i = 0; i < dripCount; i++) { - const phase = (t * randSeed(18800 + i, 0.55, 1.15) + randSeed(18900 + i, 0, 100)) % 1; - const x = randSeed(19000 + i, 12, w - 12); - const topY = -22 + phase * (h + 58); - const bottomY = topY + randSeed(19100 + i, 14, 30); - const lean = randSeed(19200 + i, -6, -2); - ctx.globalAlpha = 0.44; - ctx.strokeStyle = "rgba(198, 202, 204, 0.98)"; - ctx.lineWidth = randSeed(19300 + i, 1.2, 2.2); - ctx.beginPath(); - ctx.moveTo(x, topY); - ctx.lineTo(x + lean, bottomY); + ctx.moveTo(x - dx * 0.5 - 1.5, y - dy * 0.5); + ctx.lineTo(x + dx * 0.5 - 1.5, y + dy * 0.5); ctx.stroke(); - ctx.globalAlpha = 0.26; - ctx.beginPath(); - ctx.ellipse(x + lean, bottomY + 2, 2.1, 3.5, -0.18, 0, Math.PI * 2); - ctx.fillStyle = "rgba(216, 219, 220, 0.98)"; - ctx.fill(); } } ctx.restore(); @@ -746,7 +728,6 @@ function render() { const perf = world.performanceLevel ? world.performanceLevel() : 0; if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting); - if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); const visibleRect = visibleWorldRect(world, 180); const terrainLayer = ensureTerrainCache(world, lighting); @@ -792,6 +773,7 @@ function render() { // Light affecting the whole visible scene, including characters and ground. drawAtmosphericLighting(ctx, screenW, screenH, lighting); + if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); const drawAtScreenPosition = (entity, drawFn) => { if (!entity) return; diff --git a/js/tarinai.js b/js/tarinai.js index 2ce8000..6542aa1 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -75,6 +75,8 @@ class Tarinai { constructor(world, opts = {}) { this.reproductionTimer = rand(4, CONFIG.reproductionCooldown); this.lastLog = 0; this.sleeping = false; + this.awakeLockTimer = opts.awakeLockTimer ?? 0; + this.wasSleepingLastTick = false; this.blink = rand(0, 10); this.lastSocial = 0; this.goodMode = opts.goodMode ?? stableChoice(this.familyKey, "good-mode", displayNormalSprites()) ?? "smile"; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index a4bf180..29f6c13 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -102,6 +102,7 @@ this.sleepDiseaseAnchorY = null; this.state = "idle"; this.sleeping = false; + this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); @@ -164,6 +165,7 @@ freezeSleepDiseaseMotion(dt = 0) { if (!this.sleepDisease) return false; + const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy); if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; this.state = "sleep"; @@ -171,10 +173,15 @@ this.target = null; this.vx = 0; this.vy = 0; - this.x = this.sleepDiseaseAnchorX; - this.y = this.sleepDiseaseAnchorY; + if (beingDraggedByAnt) { + this.sleepDiseaseAnchorX = this.x; + this.sleepDiseaseAnchorY = this.y; + } else { + 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"; + this.thought = beingDraggedByAnt ? "眠ったままアリに運ばれている" : "\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)"); diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index 11cc936..13aec6d 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -89,7 +89,6 @@ this.powerItemMode = mode; this.powerEffectKind = mode; this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 3.2); - this.bubble(mode === "protein" ? "ムキッ" : "へにゃ", 1.0, ITEM_EFFECT_COLORS[mode]); this.spawnPowerItemEffect(mode, 1.5); return true; }, @@ -102,7 +101,6 @@ this.refreshEffectiveSize(); this.powerEffectKind = mode; this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 4.0); - this.bubble(mode === "giant_drug" ? "でかっ" : "ちまっ", 1.0, ITEM_EFFECT_COLORS[mode]); this.spawnPowerItemEffect(mode, mode === "giant_drug" ? 1.9 : 1.2); return true; }, @@ -118,7 +116,6 @@ if (Number.isFinite(this.mercuryLifeSpanBase)) this.lifeSpan = Math.max(this.age + 5, this.mercuryLifeSpanBase * this.mercuryLifeMultiplier); this.powerEffectKind = "mercury"; this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.7); - this.bubble(`Hg x${this.mercuryLifeMultiplier.toFixed(2)}`, 1.2, ITEM_EFFECT_COLORS.mercury); this.spawnPowerItemEffect("mercury", 0.95); return true; }, @@ -142,7 +139,6 @@ this.powerEffectKind = "zunda_juice"; this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.2); this.refreshEffectiveSize(); - this.bubble("すっきり", 1.2, ITEM_EFFECT_COLORS.zunda_juice); this.spawnPowerItemEffect("zunda_juice", 1.4); return true; }, @@ -153,7 +149,6 @@ let applied = []; if (type === "laxative") { this.setTimedItemEffect("laxative", Math.round(60 * scale)); - this.bubble("ぐるる", 1.2, ITEM_EFFECT_COLORS.laxative); this.spawnPowerItemEffect("laxative", 0.75); applied.push("laxative"); } else if (type === "protein") { @@ -164,7 +159,6 @@ applied.push("niteropu"); } else if (type === "ammo") { this.setTimedItemEffect("ammo", Math.round(18 * scale)); - this.bubble("ドン", 0.9, ITEM_EFFECT_COLORS.ammo); this.spawnPowerItemEffect("ammo", 1.1); applied.push("ammo"); } else if (type === "mercury") { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 3a254a6..df9a0d9 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -232,6 +232,9 @@ this.energy = clamp(this.energy, 0, 100); this.stress = clamp(this.stress, 0, 130); + const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; + this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); + if (this.freezeSleepDiseaseMotion(dt)) { this.checkLife(dt); return; @@ -269,6 +272,8 @@ if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); this.updateNestBoxPresence(dt); if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); + const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping; + if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); this.checkLife(dt); }, @@ -622,8 +627,9 @@ } const sleepBias = this.personalityProfile().sleep; + const wakeLocked = (this.awakeLockTimer || 0) > 0 && !wasSleeping; const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); - if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) { + if (!wakeLocked && (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias)) { let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); @@ -963,7 +969,7 @@ if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; const crowded = occ > 5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, crowded ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)"); + this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); } this.energy += dt * (1.9 + comfort * 1.4); this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 0dd426c..662ad00 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -284,6 +284,19 @@ ctx.restore(); } } + if (sprite === "zunchi_slave") { + const zImg = typeof getRenderableImage === "function" ? getRenderableImage("zunchi", "zunchi") : (images && images.get ? images.get("zunchi") : null); + if (zImg) { + ctx.save(); + ctx.shadowColor = "rgba(46, 36, 24, 0.28)"; + ctx.shadowBlur = 5; + ctx.shadowOffsetY = Math.max(1, drawH * 0.012); + const zw = Math.max(12, drawW * 0.30); + const zh = zw * 0.74; + ctx.drawImage(zImg, -zw * 0.50, -dh * 0.48 - zh * 0.25, zw, zh); + ctx.restore(); + } + } drawDawnRimLight(ctx, dw, dh, lightState); if (faceRight) ctx.scale(-1, 1); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 2d7a50c..fc8349e 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -67,8 +67,12 @@ o.vy += ny * rand(34, 64); this.stress = clamp(this.stress + 1.6, 0, 130); o.stress = clamp(o.stress + 2.8, 0, 130); - this.damage(o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2, "\u55a7\u5629"); - o.damage(this.outgoingDamage ? this.outgoingDamage(1.6) : 1.6, "\u55a7\u5629"); + const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; + const damageToOther = this.outgoingDamage ? this.outgoingDamage(1.6) : 1.6; + this.damage(damageToThis, "\u55a7\u5629"); + o.damage(damageToOther, "\u55a7\u5629"); + this.world.maybeDefeatFromFightDamage?.(this, o, damageToThis); + this.world.maybeDefeatFromFightDamage?.(o, this, damageToOther); this.hurtTimer = Math.max(this.hurtTimer, 1.4); o.hurtTimer = Math.max(o.hurtTimer, 1.8); this.adjustRelation(o, -0.16, 0.12, "fight"); @@ -80,8 +84,7 @@ this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); } if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { - o.defeatedById = this.id; - o.fightWinnerId = this.id; + this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); } this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); this.nextHeadbutt = this.world.time + rand(0.34, 0.55); diff --git a/js/ui_bind.js b/js/ui_bind.js index 4893707..c7b271e 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -274,7 +274,23 @@ function bindUI() { document.body.classList.toggle("mobile-mode-tool", mode === "tool"); document.body.classList.toggle("mobile-mode-family", mode === "family"); }; + const updateTouchMobileUiClass = () => { + const ua = String(navigator.userAgent || ""); + const coarse = Boolean(window.matchMedia && window.matchMedia("(pointer: coarse)").matches); + const noHover = Boolean(window.matchMedia && window.matchMedia("(hover: none)").matches); + const touchPoints = Number(navigator.maxTouchPoints || 0); + const w = window.innerWidth || 9999; + const h = window.innerHeight || 9999; + const mobileUa = /Android|iPhone|iPod|Mobile|Silk/i.test(ua); + const compactPhoneViewport = Math.min(w, h) <= 640 && Math.max(w, h) <= 980; + const enabled = Boolean(coarse && noHover && touchPoints > 0 && compactPhoneViewport && mobileUa); + document.body.classList.toggle("touch-mobile-ui", enabled); + if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled; + }; const mobileInputMode = () => uiCache.mobileInputMode || "auto"; + updateTouchMobileUiClass(); + window.addEventListener("resize", updateTouchMobileUiClass, { passive: true }); + window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass); setMobileInputMode(uiCache.mobileInputMode || "auto"); ui.mobileModeControls?.addEventListener("click", (e) => { const btn = e.target.closest("[data-mobile-mode]"); diff --git a/js/ui_selected.js b/js/ui_selected.js index 09a0b0a..de32c82 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -147,7 +147,7 @@ function renderSelected() {
\u53cb\u9054${relationListHtml(t, "friend")}
\u6575\u5bfe${relationListHtml(t, "enemy")}
\u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
-
\u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
+
けんか勝率${escapeHtml(fightWinRateLabel(t))}
`; const familyHtml = `
\u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
@@ -233,6 +233,14 @@ function relationFightRecord(t) { return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057"; } +function fightWinRateLabel(t) { + const won = Number(t.totalFightWins || 0); + const lost = Number(t.totalFightLosses || 0); + const total = won + lost; + if (!total) return "なし"; + return `${Math.round((won / total) * 100)}%(${won}勝${lost}敗)`; +} + function setTextIfChanged(el, key, value) { if (!el) return; const next = String(value); diff --git a/js/version.js b/js/version.js index 118e484..e1b6768 100644 --- a/js/version.js +++ b/js/version.js @@ -1,7 +1,7 @@ "use strict"; (function () { - const APP_VERSION = "15.19.1"; + const APP_VERSION = "15.20.3"; const APP_BUILD = "systems-split"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index f8d534d..150ea3e 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -405,6 +405,35 @@ return true; }, + maybeDefeatFromFightDamage(victim, attacker, damage = 0) { + if (!victim || !attacker || victim.dead || attacker.dead || victim.id === attacker.id) return false; + if ((victim.fightTimer || 0) <= 0.04 || (attacker.fightTimer || 0) <= 0.04) return false; + if (victim.defeatedById || (victim.defeatedTimer || 0) > 0.04) return false; + const profile = victim.personalityProfile ? victim.personalityProfile() : { fight: 1 }; + const aggressionHold = clamp((profile.fight || 1) - 1, -0.8, 1.3); + const healthRisk = clamp((42 - (victim.energy || 0)) / 42, 0, 1.25); + const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4); + const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8); + const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78); + if (Math.random() >= chance) return false; + victim.defeatedById = attacker.id; + victim.fightWinnerId = attacker.id; + attacker.fightWinnerId = attacker.id; + attacker.defeatedById = null; + victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); + attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42)); + victim.state = "panic"; + victim.target = attacker; + victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); + const dx = victim.x - attacker.x; + const dy = victim.y - attacker.y; + const d = Math.hypot(dx, dy) || 1; + victim.vx += dx / d * rand(38, 76); + victim.vy += dy / d * rand(22, 56); + this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "はぁぁぁ!", "rgba(84,66,86,0.80)"); + return true; + }, + resolveFightOutcome(winner, loser) { if (!winner || !loser || winner.dead || loser.dead) return; const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; @@ -442,9 +471,9 @@ if (!it || it.dead || it.type === "water") return; const isStone = it.type === "stone"; const isGenkotsu = it.type === "genkotsu"; - const radius = isGenkotsu ? Math.max(132, (it.r || 88) * 1.74) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); + const radius = isGenkotsu ? Math.max(164, (it.r || 88) * 2.05) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); const damage = isGenkotsu ? rand(38, 58) : (isStone ? rand(20, 34) : rand(3, 7)); - const push = isGenkotsu ? rand(360, 520) : (isStone ? rand(145, 210) : rand(54, 92)); + const push = isGenkotsu ? rand(760, 1040) : (isStone ? rand(145, 210) : rand(54, 92)); const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); if (isGenkotsu) { it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); } let hit = 0; @@ -461,8 +490,15 @@ ny = Math.sin(a); } t.vx += nx * push * (0.35 + p); - t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 92 : (isStone ? 42 : 18)) * p; - t.damage(damage * (0.35 + p * 0.65), reason); + t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 190 : (isStone ? 42 : 18)) * p; + const dealtDamage = damage * (0.35 + p * 0.65); + t.damage(dealtDamage, reason); + if (isGenkotsu) { + t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); + t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); + t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); + t.thought = "げんこつがこわい"; + } t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); @@ -470,9 +506,10 @@ hit += 1; } const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { - push: p => push * (0.35 + p), + push: p => push * (0.62 + p * 1.25), }); if (isGenkotsu) { + this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75); for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); } else { diff --git a/js/world_family_social.js b/js/world_family_social.js index d69364c..2cebe07 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -713,21 +713,14 @@ b.fightTargetIds = b.fightTargetIds.slice(-4); a.fightTargetId = a.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0]; - const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + aAggressiveIntent * 5 + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + bAggressiveIntent * 5 + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const loser = aScore <= bScore ? a : b; - const winner = loser === a ? b : a; - loser.defeatedById = winner.id; - loser.fightWinnerId = winner.id; - winner.fightWinnerId = winner.id; - winner.defeatedById = null; - winner.defeatedTimer = 0; a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); - a.damage(b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7), "\u55a7\u5629"); - b.damage(a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7), "\u55a7\u5629"); + const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); + const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); + a.damage(damageToA, "\u55a7\u5629"); + b.damage(damageToB, "\u55a7\u5629"); + this.maybeDefeatFromFightDamage?.(a, b, damageToA); + this.maybeDefeatFromFightDamage?.(b, a, damageToB); const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1; a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); diff --git a/js/world_view.js b/js/world_view.js index 820a296..d18eece 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -70,10 +70,10 @@ }, phaseName() { - const p = this.dayProgress(); - if (p < 0.20) return "\u671d"; - if (p < 0.48) return "\u663c"; - if (p < 0.68) return "\u5915\u65b9"; + const hour = this.dayProgress() * 24; + if (hour >= 5 && hour < 10) return "\u671d"; + if (hour >= 10 && hour < 17) return "\u663c"; + if (hour >= 17 && hour < 20) return "\u5915\u65b9"; return "\u591c"; }, @@ -285,12 +285,14 @@ }, toolSizeFor(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium"; + const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; + if (!def?.scalable) return "medium"; return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; }, toolSizeScale(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1; + const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; + if (!def?.scalable) return 1; return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; }, diff --git a/scripts/generate_item_icons.py b/scripts/generate_item_icons.py new file mode 100644 index 0000000..872b64f --- /dev/null +++ b/scripts/generate_item_icons.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Generate UI icons for item designs that must match js/items.js. + +The game is buildless, but these icons are generated snapshots of the field +appearance. Do not invent separate tool-UI-only designs. +""" +from __future__ import annotations +from pathlib import Path +import math +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "assets" / "ui" + + +def save_icon(name: str, draw_fn) -> None: + im = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) + d = ImageDraw.Draw(im, "RGBA") + draw_fn(d) + im.save(OUT / name, "WEBP", lossless=True, quality=95) + + +def draw_drop(fill, stroke, highlight): + def fn(d: ImageDraw.ImageDraw) -> None: + d.ellipse([18, 60, 78, 74], fill=(60, 45, 35, 32)) + # Same horizontal oval as fallen water/zunda/mercury item sprites; rain overlay is separate diagonal lines. + d.ellipse([12, 33, 84, 63], fill=fill, outline=stroke, width=4) + d.ellipse([24, 38, 45, 48], fill=highlight) + return fn + + +def draw_laxative(d: ImageDraw.ImageDraw) -> None: + d.rounded_rectangle([14, 28, 82, 68], radius=15, fill=(244, 212, 154, 255), outline=(98, 61, 29, 235), width=4) + # Zunchi mound motif inside the tablet. + for box in ([26, 47, 44, 60], [38, 36, 60, 53], [54, 47, 74, 61], [46, 29, 61, 41]): + d.ellipse(box, fill=(96, 70, 38, 230), outline=(56, 39, 22, 130), width=2) + d.arc([22, 27, 72, 50], 205, 330, fill=(255, 255, 255, 135), width=3) + +def star_points(cx: float, cy: float, r1: float, r2: float, n: int = 5, rot: float = -math.pi / 2): + return [(cx + math.cos(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2), cy + math.sin(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2)) for i in range(n * 2)] + + +def draw_mystery(d: ImageDraw.ImageDraw) -> None: + d.ellipse([18, 23, 78, 73], fill=(243, 236, 255, 255), outline=(91, 55, 150, 240), width=5) + try: + from PIL import ImageFont + font = ImageFont.truetype("DejaVuSans-Bold.ttf", 44) + except Exception: + font = None + d.text((48, 48), "?", anchor="mm", fill=(91, 55, 150, 245), font=font) + d.arc([25, 24, 74, 54], 205, 330, fill=(255, 255, 255, 135), width=3) + +def draw_bullet(d: ImageDraw.ImageDraw) -> None: + d.ellipse([20, 62, 76, 77], fill=(60, 45, 35, 38)) + pts = [(76, 48), (60, 28), (27, 28), (16, 35), (16, 61), (27, 68), (60, 68)] + d.polygon(pts, fill=(244, 196, 106, 255), outline=(87, 51, 22, 240)) + d.line(pts + [pts[0]], fill=(87, 51, 22, 240), width=4) + d.rectangle([16, 35, 29, 61], fill=(176, 111, 35, 255), outline=(87, 51, 22, 220), width=3) + d.line([(34, 36), (64, 42)], fill=(255, 237, 176, 170), width=4) + d.line([(34, 60), (58, 56)], fill=(142, 82, 30, 110), width=3) + + +def draw_sleep(d: ImageDraw.ImageDraw) -> None: + d.ellipse([17, 28, 79, 68], fill=(235, 228, 255, 255), outline=(74, 60, 144, 230), width=4) + d.ellipse([41, 31, 65, 56], fill=(112, 91, 188, 220)) + d.ellipse([52, 27, 74, 52], fill=(235, 228, 255, 255)) + d.arc([24, 27, 74, 57], 200, 330, fill=(255, 255, 255, 190), width=3) + + +def main() -> None: + save_icon("tool_laxative.webp", draw_laxative) + save_icon("tool_mystery_drug.webp", draw_mystery) + save_icon("tool_ammo.webp", draw_bullet) + save_icon("tool_sleep_drug.webp", draw_sleep) + save_icon("tool_water.webp", draw_drop((86, 157, 224, 220), (45, 101, 168, 150), (255, 255, 255, 150))) + save_icon("tool_zunda_juice.webp", draw_drop((125, 218, 82, 235), (63, 150, 50, 160), (238, 255, 226, 160))) + save_icon("tool_mercury.webp", draw_drop((210, 216, 222, 240), (112, 122, 132, 165), (255, 255, 255, 170))) + + +if __name__ == "__main__": + main() diff --git a/service-worker.js b/service-worker.js index 9b0823d..d92003b 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "15.19.1"; +const APP_VERSION = "15.20.3"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.