diff --git a/AI_MAP.md b/AI_MAP.md index 02a0d2f..a115816 100644 --- a/AI_MAP.md +++ b/AI_MAP.md @@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th - Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order. - Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader. -- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.164`. +- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.167`. - Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render. - Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches. - Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`. diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 59f4bf0..d5ba9c1 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -1,3 +1,23 @@ +tarinai_physics_tuning_v167 +- Replaced the 8-sided circle template with a 24-sided, 20px-grid-aligned contour. +- Raised bounce-fence restitution from 1.08 to 2.40 and increased minimum/capped launch speeds. +- Made rope endpoint and particle constraints inextensible while retaining slack/flexibility. +- Set sticky-bomb blastScale to 0.68, matching the small firecracker. + +tarinai_mechanical_collision_grid_limit_audit_v1 +- Restored shared SAT geometry exports so collision-enabled rotators, reciprocators, and poison blocks physically interact with one another. +- Mechanical shape editors now snap all geometry to a coarse 20px lattice. +- Removed central colony-limit bypass options and added direct-insertion, capacity, save-roundtrip, and over-limit regression checks. +- Version: 39.15.167 + +tarinai_champion_limits_mechanical_defaults_v1 +- Tarinai champions actively prioritize other champions as breeding partners, with stronger mate drive and shorter champion-pair cooldowns. +- Reciprocators now default to 90 degrees. +- Poison blocks now use a smaller default footprint. +- Colony panel now supports optional tarinai-population and object-count limits; blank/0 means unlimited. +- Colony limits are saved in snapshot/binary data. +- Version: 39.15.165 + tarinai_plushie_fling_speed_v1 - Reduced plushie poke-fling velocity to one third. - Lowered plushie fling minimum-speed normalization to the same one-third scale. diff --git a/FILES.json b/FILES.json index c7645ba..6b545ed 100644 --- a/FILES.json +++ b/FILES.json @@ -1,6 +1,6 @@ { "purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.", - "version": "39.15.164", + "version": "39.15.167", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index a96d255..4418ac7 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -2,6 +2,20 @@ This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map. +## v39.15.167 mechanical interaction and limit audit + +- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。 +- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。 +- コロニー上限は中央生成APIで常時適用され、通常実行時に上限を迂回するオプションは持たない。既存要素やセーブ復元要素は削除せず、その後の新規生成のみを抑止する。 + +## v39.15.165 behavior and colony controls + +- たりない王は、繁殖可能な別のたりない王を広い範囲から優先して探し、通常より強い繁殖欲求と短い再繁殖待機時間を持つ。 +- 往復体の新規配置時の標準方向は90度。 +- 毒ブロックの新規配置時の標準輪郭は小型(半径44、輪郭104×40、太さ11)。 +- コロニーパネルで、たりない個体数と生存オブジェクト総数の上限を設定できる。空欄または0は上限なし。既存要素は削除せず、新規追加だけを抑止する。 +- 上限値はスナップショットとバイナリセーブに保存される。 + ## Game Overview `tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records. @@ -76,10 +90,10 @@ The core loop is: ### Physics -- **ヒモ / rope**: connect two targets with a flexible link. +- **ヒモ / rope**: connect two targets with a flexible, inextensible link that may go slack but does not lengthen. - **棒 / rod**: connect two targets with a rigid link. - **柵 / fence**: blocks tarinai movement; Q/E rotate by 45 degrees, Shift+Q/E by 5 degrees. -- **バウンス柵 / bounce fence**: fence that rebounds things that hit it. +- **バウンス柵 / bounce fence**: high-restitution fence that strongly launches things that hit it. - **ゲート柵 / gate fence**: click to open or close. - **回転体 / rotator**: custom mechanical shape that rotates; click to edit shape and speed. - **毒ブロック / poison block**: custom hazard block; touching it damages skin; click to edit shape. diff --git a/README.md b/README.md index b700605..5a50794 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; ## Runtime Shape -- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.164`. +- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.167`. - Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots. - Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases. - UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`. diff --git a/app_manifest.json b/app_manifest.json index 4d46a30..a501ae8 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.164", + "version": "39.15.167", "css": [ "css/base.css", "css/layout.css", diff --git a/css/components.css b/css/components.css index e6649bd..1c320ca 100644 --- a/css/components.css +++ b/css/components.css @@ -829,6 +829,27 @@ opacity: 0.52; } +.colony-limit-control { + margin: 8px 0 4px; + padding: 10px 11px; + border: 1px solid rgba(118, 92, 61, 0.16); + border-radius: 14px; + background: rgba(255, 251, 244, 0.56); +} +.colony-limit-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + color: rgba(68, 55, 41, 0.84); + font-size: 12px; +} +.colony-limit-head small { color: var(--muted); font-size: 10px; } +.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; } +.colony-limit-grid label { display: grid; gap: 4px; min-width: 0; font-size: 11px; color: var(--muted); } +.colony-limit-grid input { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 6px 7px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; } +.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; } + /* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */ .tool-card.collapsed { diff --git a/index.html b/index.html index 2e2ded9..ae742ff 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -107,6 +107,14 @@
気温10℃
+
+
上限設定空欄・0は上限なし
+
+ + +
+
たりない 0 / ∞ オブジェクト 0 / ∞
+
@@ -252,167 +260,167 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index 68164e2..20a231b 100644 --- a/js/ants.js +++ b/js/ants.js @@ -414,7 +414,7 @@ class AntActor { audio.antDie?.(); const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; - this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse); + this.world.addItem?.(corpse, "ant-corpse"); this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" })); if (home) syncAntNestCount(this.world, home); this.world.ensureItemBuckets?.("ant-corpse"); @@ -439,8 +439,9 @@ class AntActor { base.antCount = ANT_NEST_START_COUNT; base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT); if (!force && this.world.placementBlocked?.(base)) return null; + const addedNest = this.world.addItem?.(base, "ant-nest-built") || null; + if (!addedNest) return null; audio.antNest?.(); - this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base); this.world.ensureSpatial?.("ant-nest-built"); this.world.drawListDirty = true; this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event"); diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index c97abf4..cd0ca91 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -125,6 +125,17 @@ } global.TarinaiCollisionFootprints = Object.freeze({ + // Mechanical-body narrowphase consumes the same SAT primitives as + // placement/hit testing. Keep these delegates public so collision-enabled + // rotators, reciprocators, and poison blocks can physically interact. + rectLocalPoint, + rectWorldVector, + rectCorners, + rectAxes, + projectPoints, + aabbOverlap, + rectOverlapInfo, + rectsOverlap, rectCircleOverlap, isSoftPlacementIgnoredType, itemFootprintRadius, diff --git a/js/collision_response_system.js b/js/collision_response_system.js index f9f972f..8788f7c 100644 --- a/js/collision_response_system.js +++ b/js/collision_response_system.js @@ -275,23 +275,36 @@ } - function resolveTarinaiHighSpeedCollisions(worldRef, dt) { + function highSpeedThreshold() { + return PHYSICAL_DAMAGE_SPEED_THRESHOLD; + } + + function resolveTarinaiHighSpeedCollisions(worldRef, dt, opts = {}) { if (!worldRef) return undefined; - const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD; + const threshold = Math.max(1, Number(opts.threshold || PHYSICAL_DAMAGE_SPEED_THRESHOLD) || PHYSICAL_DAMAGE_SPEED_THRESHOLD); const now = worldRef.time || 0; if (!worldRef.tarinaiCollisionMemo) worldRef.tarinaiCollisionMemo = new Map(); - const liveSource = worldRef.tarinai || []; - const live = []; + const providedFastSources = Array.isArray(opts.fastSources); + const liveSource = providedFastSources ? opts.fastSources : (worldRef.tarinai || []); + const fastSources = []; for (const t of liveSource) { - if (t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t)) live.push(t); + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue; + if (Math.hypot(t.vx || 0, t.vy || 0) >= threshold) fastSources.push(t); + } + if (!fastSources.length) return; + const live = providedFastSources ? null : []; + if (!providedFastSources) { + for (const t of worldRef.tarinai || []) { + if (t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t)) live.push(t); + } + if (live.length < 2) return; + live.sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))); } - if (live.length < 2) return; - live.sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))); const candidates = []; - const maxCandidates = Math.max(24, Math.min(320, live.length * 6)); + const maxCandidates = Math.max(24, Math.min(320, fastSources.length * 12)); const seenPairs = new Set(); - for (let i = 0; i < live.length; i += 1) { - const a = live[i]; + for (let i = 0; i < fastSources.length; i += 1) { + const a = fastSources[i]; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); @@ -306,7 +319,7 @@ const queryX = ax0 + asx * 0.5; const queryY = ay0 + asy * 0.5; const queryRadius = Math.max(140, (a.radius || 22) * 1.6 + moveDist + threshold * Math.max(0.016, Number(dt || 0.016) || 0.016) + 96); - const nearby = typeof worldRef.nearbyTarinai === "function" ? Array.from(worldRef.nearbyTarinai(queryX, queryY, queryRadius, true) || []) : live; + const nearby = typeof worldRef.nearbyTarinai === "function" ? (worldRef.nearbyTarinai(queryX, queryY, queryRadius, true) || []) : (live || worldRef.tarinai || []); for (const b of nearby) { if (!b || b === a || b.dead || worldRef.isTarinaiHiddenInNestBox(b)) continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; @@ -410,6 +423,7 @@ global.TarinaiCollisionResponseSystem = Object.freeze({ + highSpeedThreshold, resolveBallBallCollisions, resolveBallInteractions, resolveTarinaiHighSpeedCollisions, diff --git a/js/constraint_system.js b/js/constraint_system.js index 44f4ff3..19bd5be 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -446,11 +446,11 @@ const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0; const sum = ia + ib || 1; const step = Math.max(0.012, Math.min(0.05, Number(dt || 0.016) || 0.016)); - const compliance = mode === "rod" ? 0.085 / (step * 60) : 0.46 / (step * 60); + const compliance = mode === "rod" ? 0.085 / (step * 60) : 0; const target = delta / (1 + compliance); const cap = mode === "rod" ? Math.max(6.0, Math.min(46, length * maxFraction)) - : Math.max(1.5, length * maxFraction); + : Math.max(12, length * maxFraction); const corr = clamp(target, -cap, cap); let changed = false; changed = moveEndpointObject(pa.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), ep(item, 0), dt, strength, 0, mode) || changed; @@ -459,7 +459,15 @@ }; let changed = false; if (mode === "rope") { - changed = solveOnce(a, b, 0.58, 0.044) || changed; + // A rope may go slack, but must not become longer than its recorded length. + // Re-read anchors between projections because the endpoint objects move. + const iterations = 8; + for (let i = 0; i < iterations; i += 1) { + const aa = endpointWorld(ep(item, 0), item.world || null) || a; + const bb = endpointWorld(ep(item, 1), item.world || null) || b; + if (distXY(aa.x, aa.y, bb.x, bb.y) <= length + 0.25) break; + changed = solveOnce(aa, bb, 1.0, 0.35) || changed; + } } else { const iterations = 5; for (let i = 0; i < iterations; i += 1) { @@ -738,6 +746,21 @@ } } } + for (let iter = 0; iter < 4; iter += 1) { + ps[0].x = a.x; ps[0].y = a.y; + ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y; + for (let i = 0; i < ps.length - 1; i += 1) { + const p1 = ps[i], p2 = ps[i + 1]; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const d = Math.max(0.001, Math.hypot(dx, dy)); + if (d <= rest + 0.02) continue; + const corrX = dx * ((d - rest) / d); + const corrY = dy * ((d - rest) / d); + if (i === 0) { p2.x -= corrX; p2.y -= corrY; } + else if (i + 1 === ps.length - 1) { p1.x += corrX; p1.y += corrY; } + else { p1.x += corrX * 0.5; p1.y += corrY * 0.5; p2.x -= corrX * 0.5; p2.y -= corrY * 0.5; } + } + } ps[0].x = a.x; ps[0].y = a.y; ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y; const mid = ps[Math.floor(ps.length * 0.5)] || ps[0]; diff --git a/js/fire_runtime_system.js b/js/fire_runtime_system.js index bb4cefa..5ebd0d9 100644 --- a/js/fire_runtime_system.js +++ b/js/fire_runtime_system.js @@ -549,9 +549,10 @@ function updateFirecracker(item, dt, worldRef) { fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5)); fire.vx = 0; fire.vy = 0; - worldRef.addItem?.(fire, "spawn-fire", { terrain: false }); + const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null; + if (!addedFire) return null; worldRef.drawListDirty = true; - return fire; + return addedFire; } function scatterFireFrom(item, worldRef) { diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index 4f532a1..994eb08 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -516,8 +516,9 @@ if (!Number.isFinite(Number(drop.vx))) drop.vx = vx; if (!Number.isFinite(Number(drop.vy))) drop.vy = vy; if (!Number.isFinite(Number(drop.splashFlightTimer))) drop.splashFlightTimer = flight; - if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1; - else { worldRef.items?.push?.(drop); spawned += 1; } + const addedDrop = worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false }) || null; + if (!addedDrop) continue; + spawned += 1; if (i < 8) { worldRef.effects?.push(new Effect("fight", drop.x, drop.y, { size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i), diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index 5e27371..12d070f 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -42,6 +42,7 @@ const STICKY_BOMB_PANIC_TEXT = "粘着ボムが付いてパニックになっている。"; const STICKY_BOMB_FUSE_SECONDS = 12.0; + const STICKY_BOMB_BLAST_SCALE = 0.68; // identical to TOOL_SIZE_SCALES.small function tarinaiById(worldRef, id = "") { return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null; @@ -96,7 +97,7 @@ if (!item || item.dead) return false; item.stickyBombCarrierId = ""; item.stickyBombTransferCooldown = 0; - item.blastScale = Number(item.blastScale || 1) || 1; + item.blastScale = STICKY_BOMB_BLAST_SCALE; if (worldRef?.explodeFirecracker) { return Boolean(worldRef.explodeFirecracker(item)); } diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index aa72263..19da494 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -92,12 +92,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) { pin.noStickTargetId = ""; pin.noStickTargetUntil = 0; const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null; - if (!added && worldRef?.items) { - pin.world = worldRef; - worldRef.items.push(pin); - worldRef.markItemBucketsDirty?.("duplicator-pin"); - worldRef.markSpatialDirty?.("duplicator-pin"); - } + if (!added) return false; const ok = global.TarinaiItemDynamicPinSystem.attach(pin, t, worldRef, distXY(dish.x, dish.y, t.x, t.y)); if (!ok) { pin.amount = 0; diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 4ca0321..b11085e 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -1152,8 +1152,8 @@ draw(ctx, t, lighting = null) { const angle = itemAngleFor(this); const pb = window.TarinaiPhysicsBodySystem; const enabled = pb?.scalar?.(this, "solid", true) !== false; - const segments = (pb?.segments?.(this) || [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]); - const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 14) || 14) || 14)); + const segments = (pb?.segments?.(this) || [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]); + const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 11) || 11) || 11)); ctx.save(); ctx.rotate(angle); ctx.lineCap = "round"; diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index 1637623..9d4f229 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -58,11 +58,11 @@ const TOOL_DEFINITIONS = Object.freeze({ fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, glass_wall: { id: "glass_wall", itemType: "glass_wall", label: "\u30ac\u30e9\u30b9\u58c1", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "\u25ad", collisionShape: "oriented_rect", tooltip: "\u900f\u660e\u306a\u58c1\u3002\u305f\u308a\u306a\u3044\u306f\u901a\u308c\u306a\u3044\u304c\u3001\u898b\u3048\u3066\u3044\u308b\u5bfe\u8c61\u3078\u306f\u8fc2\u56de\u305b\u305a\u306b\u5411\u304b\u3046\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, - poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" }, + poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" }, reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" }, trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, @@ -91,7 +91,7 @@ function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); } function defaultItemAngle(type = "") { const t = String(type || ""); - if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2; + if (t === "fence_v" || t === "bounce_fence_v" || t === "reciprocator") return Math.PI / 2; return 0; } function normalizedItemAngle(angle = 0, fallback = 0) { diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 838d83b..494e4ee 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -114,6 +114,7 @@ function initializeItemTypeState(item, type, x, y) { } if (type === "sticky_bomb") { item.fuseTimer = 12.0; + item.blastScale = 0.68; item.fuseMax = 12.0; item.vx = rand(-8, 8); item.vy = rand(-8, 8); @@ -170,7 +171,7 @@ function initializeItemTypeState(item, type, x, y) { velocity: { x: 0, y: 0, angular: 0, linear: 0 }, motor: { powered: false, speed: 0, direction: 1 }, rail: null, - shape: { model: "segments", thickness: 14, segments: [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]], version: 0 }, + shape: { model: "segments", thickness: 11, segments: [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]], version: 0 }, collision: { solid: true, hazard: true }, hazard: { kind: "poison", damage: 7 }, sleep: { awakeUntil: 0 }, diff --git a/js/mechanical_system.js b/js/mechanical_system.js index dda6c25..8a7b708 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -181,7 +181,7 @@ if (out.length >= 128) break; } if (!out.length) { - if (item?.type === "poison_block") out.push([-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]); + if (item?.type === "poison_block") out.push([-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]); else out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : [])); } return out; @@ -236,7 +236,7 @@ const cache = geomCache(item); const shape = bodyShape(item); const raw = Array.isArray(shape?.segments) ? shape.segments : []; - const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 14 : 12); + const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 11 : 12); const key = `${item?.type || ""}|${shapeVersion(item)}|${raw.length}|${rawSegmentSignature(raw)}|${thickKey || ""}`; if (cache?.localKey === key && cache.localSegments) return cache.localSegments; const normalized = normalizeRawSegments(item); @@ -248,7 +248,7 @@ return out; } - function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 14 : 12)))); } + function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 11 : 12)))); } function extent(item) { const cache = geomCache(item); diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 8e3706f..403da56 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -121,7 +121,7 @@ } - function bounceCircleOffRect(obj, rect, radius, restitution = 1.08, worldRef = null, opts = {}) { + function bounceCircleOffRect(obj, rect, radius, restitution = 2.40, worldRef = null, opts = {}) { if (!obj || !rect) return false; if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts); const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12); @@ -194,9 +194,9 @@ const rects = worldRef.nearbySolidObstacleRects(item.x, item.y, searchRadius, { include: it => it?.type === "bounce_fence" || it?.type === "bounce_fence_v", exclude: item, maxChecks: 16 }) || []; for (const rect of rects) { if (!rect?.bounce) continue; - if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 1.08), worldRef, opts)) { + if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 2.40), worldRef, opts)) { bounced = true; - const maxSpeed = opts.bounceFenceMaxSpeed ?? 760; + const maxSpeed = opts.bounceFenceMaxSpeed ?? 1180; item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed); item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed); const now = worldRef.time || 0; diff --git a/js/physics_shape_editor_system.js b/js/physics_shape_editor_system.js index 95a7423..616220f 100644 --- a/js/physics_shape_editor_system.js +++ b/js/physics_shape_editor_system.js @@ -18,18 +18,33 @@ undo: "\u21B6 \u4E00\u3064\u623B\u3059", clear: "\uD83D\uDDD1 \u5168\u6D88\u3057", }); + const DEFAULT_GRID_SIZE = 20; + function normalizedGridSize(value = DEFAULT_GRID_SIZE) { + return Math.max(10, Math.min(80, Math.round(Number(value || DEFAULT_GRID_SIZE) || DEFAULT_GRID_SIZE))); + } + function snapValue(value, gridSize = DEFAULT_GRID_SIZE) { + const grid = normalizedGridSize(gridSize); + return Math.round((Number(value) || 0) / grid) * grid; + } + function snapPoint(point, gridSize = DEFAULT_GRID_SIZE) { + return { x: snapValue(point?.x, gridSize), y: snapValue(point?.y, gridSize) }; + } function templateSegments(name) { - if (name === "bar") return [[-130, 0, 130, 0]]; - if (name === "cross") return [[-118, 0, 118, 0], [0, -90, 0, 90]]; + if (name === "bar") return [[-120, 0, 120, 0]]; + if (name === "cross") return [[-120, 0, 120, 0], [0, -80, 0, 80]]; if (name === "circle") { - const segments = []; - const n = 18, r = 108; - for (let i = 0; i < n; i += 1) { - const a = i / n * Math.PI * 2; - const b = (i + 1) / n * Math.PI * 2; - segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); - } - return segments; + // 24-sided lattice circle. More vertices preserve a round silhouette while + // every endpoint remains on the shared 20px editor grid. + const points = [ + [120, 0], [120, 40], [100, 60], [80, 80], [60, 100], [40, 120], + [0, 120], [-40, 120], [-60, 100], [-80, 80], [-100, 60], [-120, 40], + [-120, 0], [-120, -40], [-100, -60], [-80, -80], [-60, -100], [-40, -120], + [0, -120], [40, -120], [60, -100], [80, -80], [100, -60], [120, -40], + ]; + return points.map((point, index) => { + const next = points[(index + 1) % points.length]; + return [point[0], point[1], next[0], next[1]]; + }); } return null; } @@ -65,23 +80,32 @@ function cleanSegments(segments = [], opts = {}) { const limit = Math.max(1, Number(opts.limit || 96) || 96); const bound = Math.max(1, Number(opts.bound || 420) || 420); - const minLength = Math.max(0, Number(opts.minLength || 4) || 4); - const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0]; + const gridSize = normalizedGridSize(opts.gridSize); + const snap = opts.snap !== false; + const minLength = Math.max(gridSize, Number(opts.minLength || gridSize) || gridSize); + const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0]; + const coord = value => { + const raw = snap ? snapValue(value, gridSize) : (Number(value) || 0); + return Math.max(-bound, Math.min(bound, raw)); + }; const out = []; for (const seg of segments || []) { if (!Array.isArray(seg) || seg.length < 4) continue; - const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0)); - const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0)); - const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0)); - const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0)); + const x1 = coord(seg[0]); + const y1 = coord(seg[1]); + const x2 = coord(seg[2]); + const y2 = coord(seg[3]); if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]); if (out.length >= limit) break; } - if (!out.length) out.push(fallback.slice(0, 4)); + if (!out.length) { + const snappedFallback = fallback.slice(0, 4).map(value => coord(value)); + out.push(snappedFallback); + } return out; } - function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) { + function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0, gridSize = DEFAULT_GRID_SIZE) { if (!ctx || !canvas) return false; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); @@ -90,8 +114,9 @@ ctx.translate(cx, cy); ctx.strokeStyle = "rgba(100,80,60,0.16)"; ctx.lineWidth = 1; - for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); } - for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); } + const grid = normalizedGridSize(gridSize); + for (let x = -cx; x <= cx; x += grid) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); } + for (let y = -cy; y <= cy; y += grid) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); } ctx.strokeStyle = "rgba(70,60,50,0.38)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke(); @@ -173,19 +198,20 @@ const prefix = opts.prefix || "rotator"; const cx = opts.cx ?? canvas.width / 2; const cy = opts.cy ?? canvas.height / 2; - const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0]; + const gridSize = normalizedGridSize(opts.gridSize); + const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0]; let mode = "line"; - let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); + let segments = cleanSegments(opts.segments || [], { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); let draft = null; let drawing = false; const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12); const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle; - const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); }; - const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy); + const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); }; + const toLocal = (e) => snapPoint(canvasLocalPoint(canvas, e, cx, cy), gridSize); const draw = () => { - drawEditorBase(ctx, canvas, cx, cy); + drawEditorBase(ctx, canvas, cx, cy, gridSize); if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() }); const lw = lineWidth(); drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" }); @@ -240,7 +266,7 @@ const last = segments[segments.length - 1]; if (!last) return; const lx = last[2], ly = last[3]; - if (Math.hypot(p.x - lx, p.y - ly) >= 5) { + if (Math.hypot(p.x - lx, p.y - ly) >= gridSize) { last[2] = p.x; last[3] = p.y; segments.push([p.x, p.y, p.x, p.y]); } @@ -257,7 +283,7 @@ if (!draft) return; if (mode === "line") { const seg = [draft[0], draft[1], p.x, p.y]; - if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg); + if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= gridSize) segments.push(seg); } draft = null; clean(); @@ -281,7 +307,7 @@ draw, getSegments: () => segments.map(seg => seg.slice(0, 4)), setMode, - setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); }, + setSegments(next = []) { segments = cleanSegments(next, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); draft = null; draw(); }, }; } @@ -289,5 +315,9 @@ decorateToolbar, createSegmentEditorController, syncInputPair, + snapPoint, + cleanSegments, + templateSegments, + gridSize: DEFAULT_GRID_SIZE, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/physics_world_system.js b/js/physics_world_system.js index 5afca76..eaac1ad 100644 --- a/js/physics_world_system.js +++ b/js/physics_world_system.js @@ -29,7 +29,7 @@ function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); } function defaultSegments(type) { - if (type === "poison_block") return [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]; + if (type === "poison_block") return [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]; if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]]; return [[-78, 0, 78, 0]]; } @@ -63,7 +63,7 @@ velocity: { x: 0, y: 0, angular: 0, linear: 0 }, motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 }, rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null, - shape: { model: "segments", thickness: type === "poison_block" ? 14 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 }, + shape: { model: "segments", thickness: type === "poison_block" ? 11 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 }, collision: { solid: true, hazard: type === "poison_block" }, hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null, sleep: { awakeUntil: 0 }, @@ -191,12 +191,12 @@ b.velocity = b.velocity || {}; b.motor = b.motor || {}; b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null); - b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 14 : 12, segments: defaultSegments(item.type), version: 0 }; + b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 11 : 12, segments: defaultSegments(item.type), version: 0 }; b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" }; b.sleep = b.sleep || { awakeUntil: 0 }; if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 }; switch (key) { - case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 14 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break; + case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 11 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break; case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break; case "motorSpeed": b.motor.speed = Math.max(0, num(value)); break; case "spin": b.velocity.angular = num(value); break; @@ -466,7 +466,7 @@ body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 }; body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 }; body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null; - body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 140 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 }; + body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 110 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 }; body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 }; body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null; body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10); diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js index 05113d3..012ac88 100644 --- a/js/placement_preview_system.js +++ b/js/placement_preview_system.js @@ -60,7 +60,7 @@ pb?.setScalar?.(item, "thickness", 12, "preview"); pb?.setSegments?.(item, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview"); } else if (item.type === "poison_block") { - pb?.setScalar?.(item, "thickness", 14, "preview"); + pb?.setScalar?.(item, "thickness", 11, "preview"); item.world = worldRef || null; } else if (item.type === "reciprocator") { pb?.setScalar?.(item, "railOn", true, "preview"); diff --git a/js/save_codec.js b/js/save_codec.js index bfbf071..6aacfe1 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -361,23 +361,29 @@ if (valuesDiffer(w[6], 1)) mask |= 2; if (valuesDiffer(w[7], 0)) mask |= 4; if (valuesDiffer(w[9], 0)) mask |= 8; - writeBitFields(writer, [mask], [4]); + if (valuesDiffer(w[10], 0)) mask |= 16; + if (valuesDiffer(w[11], 0)) mask |= 32; + writeBitFields(writer, [mask], [6]); if (mask & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative if (mask & 2) writer.u(w[6] || 1); // generation if (mask & 4) writer.u(w[7] || 0); // dead count writeSaveString(writer, w[8] || ""); // worldSeed if (mask & 8) writer.u(w[9] || 0); // birthSerial + if (mask & 16) writer.u(w[10] || 0); // tarinai population limit + if (mask & 32) writer.u(w[11] || 0); // object limit } function readWorld(reader) { const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]); const tick = reader.u(); - const [mask] = readBitFields(reader, [4]); + const [mask] = readBitFields(reader, [6]); const lastBirthAt = (mask & 1) ? reader.s() : -9990; const generation = (mask & 2) ? reader.u() : 1; const deadCount = (mask & 4) ? reader.u() : 0; const seed = readSaveString(reader); const birthSerial = (mask & 8) ? reader.u() : 0; - return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial]; + const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0; + const objectLimit = (mask & 32) ? reader.u() : 0; + return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit]; } function seedPersonalityArray(seed = "", flags = 0) { diff --git a/js/save_schema.js b/js/save_schema.js index a74f4a5..abcc803 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -8,8 +8,8 @@ if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js"); if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js"); - const SNAPSHOT_VERSION = 22; - const BINARY_SCHEMA_VERSION = 27; + const SNAPSHOT_VERSION = 23; + const BINARY_SCHEMA_VERSION = 28; function moodValue(index, fallback = "relaxed") { return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback)); diff --git a/js/simulation_ambient_system.js b/js/simulation_ambient_system.js index 29bdb70..2b1dbe6 100644 --- a/js/simulation_ambient_system.js +++ b/js/simulation_ambient_system.js @@ -4,9 +4,10 @@ // Owns low-frequency ambient Tarinai/grass/rain-water creation. (function (global) { function spawnAmbientTarinai(worldRef, dt) { - if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { + if ((worldRef.canAddTarinai?.(1) ?? true) && worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { const spot = worldRef.edgeSpawnPoint(null, null, 42, true); const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 }); + if (!t) return; t.wanderAngle = spot.angle; t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); @@ -27,8 +28,7 @@ if (spot) { const sprout = new Item("grass", spot.x, spot.y); global.TarinaiGrass.setStage(sprout, 0, { force: true }); - if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn"); - else worldRef.items.push(sprout); + worldRef.addItem?.(sprout, "grass-spawn"); } } } @@ -39,7 +39,7 @@ // share the rain overlay renderer. if (global.TarinaiWeatherSystem.shouldDropRainWater(worldRef, dt)) { const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef); - worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop); + worldRef.addItem?.(drop, "rain-water"); } } diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js index a4d2a5a..eded819 100644 --- a/js/simulation_creature_system.js +++ b/js/simulation_creature_system.js @@ -119,17 +119,23 @@ worldRef.limitBallChasers(); worldRef.resolveBallInteractions(dt); if (endBallInteractions) endBallInteractions(); - const hasFastTarinai = (worldRef.tarinai || []).some(t => { - if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) return false; - return Math.hypot(t.vx || 0, t.vy || 0) >= 150; - }); - const collisionInterval = hasFastTarinai ? 0 : 0.55; - if (hasFastTarinai || now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { - worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; + let fastTarinai = null; + const collisionSpeedThreshold = global.TarinaiCollisionResponseSystem?.highSpeedThreshold?.() || 165; + for (const t of worldRef.tarinai || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + if (Math.hypot(t.vx || 0, t.vy || 0) < collisionSpeedThreshold) continue; + if (!fastTarinai) fastTarinai = []; + fastTarinai.push(t); + } + if (fastTarinai?.length) { + worldRef.nextTarinaiCollisionCheckAt = now; worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null; - try { worldRef.resolveTarinaiHighSpeedCollisions(dt); } + try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); } finally { if (endCollision) endCollision(); } + } else if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { + worldRef.nextTarinaiCollisionCheckAt = now + 0.55; + worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); } else { worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); } diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 2fed463..14ef473 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -147,6 +147,8 @@ q(worldRef?.deadCount, 1, 0), worldRef?.worldSeed || global.TarinaiSeedFactory.ensureWorldSeed(worldRef) || "", q(worldRef?.birthSerial, 1, 0), + q(worldRef?.tarinaiPopulationLimit, 1, 0), + q(worldRef?.objectLimit, 1, 0), ]; } function applyCompactWorld(worldRef, arr = []) { @@ -157,6 +159,8 @@ worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny"); worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`; worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0)); + worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0)); + worldRef.objectLimit = Math.max(0, u(arr[11], 1, 0)); worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1); worldRef.time = totalTime - (worldRef.day - 1) * dayLength; worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index f7b9035..6ec0664 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -22,7 +22,12 @@ function selectOwnedStructure(type, range = Infinity) { } function selectMateTarget(tarinai, world, range = 440) { - return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null; + const eligible = other => other && other !== tarinai && !other.dead && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease; + if (tarinai?.isTarinaiChampion) { + const champion = world?.nearestOther?.(tarinai, range + 260, other => eligible(other) && other.isTarinaiChampion) || null; + if (champion) return champion; + } + return world?.nearestOther?.(tarinai, range, eligible) || null; } function basicCanStartWithTarget(t, world, ctx = {}) { @@ -380,7 +385,7 @@ function createApproachMateActionSpec() { phrase: "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", weight: 48, selectTarget(t, world) { return selectMateTarget(t, world, 520); }, - canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, start(t, world, ctx = {}) { const other = ctx.target ?? this.selectTarget(t, world, ctx); if (!other) return false; diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index edabe9b..ab75305 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -168,6 +168,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) { } function buildOwnedStructureFromGrass(t, world, type, label) { + if (!(world?.canAddObjects?.(1) ?? true)) return false; const material = findNearbyBuildMaterial(world, t, type, 520); if (!material || !globalThis.StructureRegistry) return false; t.buildPlan = { @@ -208,6 +209,9 @@ function continueBuildPlan(t, world, dt) { if (!spot) { return stopFailedBuildPlan(t, world, plan.type, "\u4F5C\u308B\u5834\u6240\u304C\u898B\u3064\u304B\u3089\u306A\u3044"); } + if (!(world?.canAddObjects?.(1) ?? true)) { + return stopFailedBuildPlan(t, world, plan.type, "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u308b"); + } consumeGrassMaterial(material, structureGrassStageCost(plan.type)); const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world); if (!structure) { @@ -219,7 +223,7 @@ function continueBuildPlan(t, world, dt) { structure.x = t.x; structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28); } - const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || (world.items.push(structure), structure); + const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || null; if (!addedStructure) { return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); } diff --git a/js/tarinai_forced_behavior.js b/js/tarinai_forced_behavior.js index 25be03b..19e80b8 100644 --- a/js/tarinai_forced_behavior.js +++ b/js/tarinai_forced_behavior.js @@ -350,7 +350,7 @@ function processForcedBehaviorQueue(tarinai, world, needs) { tarinai.sleepSession = null; } } - const started = startNeedAction(tarinai, world, choice, action, reasonText, { + const started = globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, action, reasonText, { forced: true, forcedId: entry.uid, forcedReasonText: entry.reasonText || reasonText, diff --git a/js/tarinai_needs_core.js b/js/tarinai_needs_core.js index ed0bb4a..ac6ea77 100644 --- a/js/tarinai_needs_core.js +++ b/js/tarinai_needs_core.js @@ -223,7 +223,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3 tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72); const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 }; const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world); - if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) { + if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, fightAction, reasonText)) { tarinai.lastNeedShockBreaker = null; return true; } @@ -232,7 +232,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3 if (!panicAction) return false; const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 }; const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world); - if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) { + if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, panicAction, reasonText)) { tarinai.lastNeedShockBreaker = null; return true; } diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 92d4553..a403aa6 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -111,7 +111,11 @@ function updateNeeds(tarinai, world, dt = 0) { socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16); raw.social = socialParts.bond; if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; } - if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; } + const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true) + ? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null) + : null; + if (championMate) { socialParts.mate += 58; socialParts.championMate = 1; raw.social += 58; } + else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; } const enemy = tarinai.strongestRelation?.("fear"); if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } const conflict = evaluateConflictUrge(tarinai, world, 112); @@ -289,6 +293,7 @@ function socialFightMateActionWeights(tarinai) { fightWeight *= clamp(1 - Math.max(0, Number(parts.bond || 0) || 0) / 180, 0.55, 1.04); if ((tarinai?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; } if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; } + if (parts.championMate) { mateWeight *= 5.0; fightWeight *= 0.35; } const total = Math.max(0, fightWeight) + Math.max(0, mateWeight); return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 }; } @@ -469,9 +474,6 @@ function startNeedAction(tarinai, world, choice, action, reasonText, options = { return Boolean(ran); } -// Needed by tarinai_needs_core.js and tarinai_forced_behavior.js, which run outside -// this module's private IIFE but start need-driven actions through the same runtime path. -global.startNeedAction = startNeedAction; function continueCurrentBehavior(tarinai, world, needs, dt) { const behavior = currentTarinaiBehavior(tarinai); @@ -1189,5 +1191,6 @@ function resolveNeedsCore(dt) { global.TarinaiNeedsRuntime = Object.freeze({ updateNeeds, + startNeedAction, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index cb24e17..a36cc2b 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -7,6 +7,12 @@ function mateTargetFor(t, world, maxDist = 520) { if (!t || !world) return null; const current = currentBehaviorTarget(t) || t.target; const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; + const championOk = o => ok(o) && !!t.isTarinaiChampion && !!o.isTarinaiChampion; + if (championOk(current) && dist(t, current) <= maxDist + 260) return current; + if (t.isTarinaiChampion) { + const champion = world.nearestOther?.(t, maxDist + 260, championOk) || null; + if (champion) return champion; + } if (ok(current) && dist(t, current) <= maxDist + 120) return current; return world.nearestOther?.(t, maxDist, ok) || null; } @@ -45,12 +51,15 @@ function socialFightMateWeights(t, other, world, options = {}) { && (world.fightPairCooldownRemaining?.(t, other) || 0) <= 0.04 && d <= Math.max(Number(options.fightRange || 58) || 58, (t.radius || 20) + (other.radius || 20) + 18); const sameStatus = !!t.isZunchiSlave === !!other.isZunchiSlave; + const championPair = Boolean(t.isTarinaiChampion && other.isTarinaiChampion); const mateWindow = Number.isFinite(Number(options.mateTimerWindow)) ? Number(options.mateTimerWindow) : 10; const requireBirthReady = options.requireBirthReady !== false; const timerOk = requireBirthReady ? ((t.reproductionTimer || 0) <= 0.04 && (other.reproductionTimer || 0) <= 0.04) : ((t.reproductionTimer || 0) <= mateWindow && (other.reproductionTimer || 0) <= mateWindow); const mateDistanceOk = d <= Math.max(loveMochi ? 54 : 44, (t.radius || 20) + (other.radius || 20) + 14); - const enough = loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44); - const lonelyBond = loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24); + const enough = championPair + ? ((t.hunger || 0) < 76 && (other.hunger || 0) < 78 && (t.energy || 0) > 30 && (other.energy || 0) > 30) + : (loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44)); + const lonelyBond = championPair || (loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24)); const mateEligible = sameStatus && timerOk && mateDistanceOk @@ -73,6 +82,7 @@ function socialFightMateWeights(t, other, world, options = {}) { fightWeight *= clamp(1 - affinity / 150, 0.34, 1.08); fightWeight *= clamp(1 - fear / 170, fightMochi ? 0.72 : 0.42, 1.05); fightWeight *= clamp(1.14 - Math.max(0, fearTrait - 1) * 0.16, 0.62, 1.22); + if (championPair) fightWeight *= 0.35; if (options.mode === "conflict") fightWeight *= 1.35; } if (mateWeight > 0) { @@ -82,6 +92,7 @@ function socialFightMateWeights(t, other, world, options = {}) { mateWeight *= 1 + Math.min(0.70, affinity / 140); if (loveMochi) mateWeight *= 3.15; if (fightMochi) mateWeight *= 0.52; + if (championPair) mateWeight *= 5.0; if (options.mode === "mate") mateWeight *= 1.42; } const total = Math.max(0, fightWeight) + Math.max(0, mateWeight); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index ec2b472..a063972 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -713,14 +713,13 @@ this.world.drawListDirty = true; this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup"); this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup"); - this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y)); - this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y)); + this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace"); + this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat"); if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) { const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); if (spot) { const deathGrass = new Item("grass", spot.x, spot.y); - if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass"); - else this.world.items.push(deathGrass); + this.world.addItem?.(deathGrass, "tarinai-death-grass"); } } this.world.markDead(this, finalReason); diff --git a/js/ui.js b/js/ui.js index 9f1b33f..2d6634a 100644 --- a/js/ui.js +++ b/js/ui.js @@ -44,6 +44,9 @@ const ui = { temperatureValue: document.getElementById("temperatureValue"), temperatureSlider: document.getElementById("temperatureSlider"), temperatureAutoToggle: document.getElementById("temperatureAutoToggle"), + tarinaiPopulationLimitInput: document.getElementById("tarinaiPopulationLimitInput"), + objectLimitInput: document.getElementById("objectLimitInput"), + colonyLimitStatus: document.getElementById("colonyLimitStatus"), statGroundType: document.getElementById("statGroundType"), groundTypeBtn: document.getElementById("groundTypeBtn"), colonyChart: document.getElementById("colonyChart"), diff --git a/js/ui_bind.js b/js/ui_bind.js index 6dab96f..f0c6ed2 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -154,6 +154,19 @@ function bindUI() { ui.temperatureSlider?.addEventListener("input", applyTemperatureSlider); ui.temperatureSlider?.addEventListener("change", applyTemperatureSlider); + const applyColonyLimit = (kind) => { + if (!world) return; + const input = kind === "tarinai" ? ui.tarinaiPopulationLimitInput : ui.objectLimitInput; + if (!input) return; + const value = input.value.trim() === "" ? 0 : Number(input.value); + if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value); + else world.setObjectLimit?.(value); + audio.uiClick?.(); + renderStats?.(); + }; + ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai")); + ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object")); + ui.panel?.addEventListener("scroll", () => { const panel = ui.panel; if (!panel || !ui.panelQuickTabs) return; diff --git a/js/ui_charts.js b/js/ui_charts.js index 2416bdc..26de176 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -55,6 +55,12 @@ function renderStats() { ui.temperatureSlider.setAttribute("aria-valuetext", tempLabel); } if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false; + const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0); + const objectLimit = Math.max(0, Number(world.objectLimit) || 0); + const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length; + if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : ""; + if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : ""; + setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`); bindColonyMoodTooltip(); window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood); window.TarinaiGroundUI.update(world); diff --git a/js/version.js b/js/version.js index 09c0c34..fbe5664 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.164"; - const APP_BUILD = "tarinai-sleep-family-tooltip-v39-15-164"; + const APP_VERSION = "39.15.167"; + const APP_BUILD = "tarinai-mechanical-collision-grid-limit-audit-v39-15-166"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index 280af9f..80f6717 100644 --- a/js/world.js +++ b/js/world.js @@ -13,6 +13,8 @@ class World { constructor() { this.toolSize = "medium"; this.toolSizes = {}; this.toolAngles = {}; + this.tarinaiPopulationLimit = 0; + this.objectLimit = 0; this.resetRuntimeCollections(); this.pointer = { x: 0, y: 0, inside: false, motion: 0 }; this.hoverGrabTarget = null; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 6c32ec1..d709c61 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -371,6 +371,7 @@ const n = Math.max(1, Math.min(28, Math.floor(count || 1))); const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5); const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5); + let spawned = 0; for (let i = 0; i < n; i++) { const a = -Math.PI * 0.15 + deterministicRange(this, "zunchi-burst-angle", -0.92, 0.92, source, pin, i) + (i / Math.max(1, n - 1) - 0.5) * 1.2; const it = new Item("zunchi", clamp(sx + deterministicRange(this, "zunchi-burst-x", -8, 8, source, pin, i), 40, this.w - 40), clamp(sy + deterministicRange(this, "zunchi-burst-y", -8, 8, source, pin, i), 40, this.h - 40)); @@ -378,12 +379,14 @@ it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i); it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i); it.stage = "fresh"; - this.addItem?.(it, "zunchi-burst") || this.items.push(it); + if (this.addItem?.(it, "zunchi-burst")) spawned += 1; } + if (!spawned) return 0; this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] }); audio.place?.("zunchi"); this.ensureSpatial?.("zunchi-burst"); + return spawned; }, spawnZunchi(x, y, source = null) { @@ -392,8 +395,10 @@ it.stage = "fresh"; it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0); it.eatProtectedUntil = (this.time || 0) + 90; - this.addItem?.(it, "zunchi-spawn") || this.items.push(it); + const added = this.addItem?.(it, "zunchi-spawn"); + if (!added) return null; audio.place?.("zunchi"); + return added; }, spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { diff --git a/js/world_environment.js b/js/world_environment.js index 97fc4b2..93953f1 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -5,6 +5,9 @@ if (!World) throw new Error("World is not available for mixin: world_environment.js"); const Geometry = global.TarinaiGeometry; + const BOUNCE_FENCE_RESTITUTION = 2.40; + const BOUNCE_FENCE_MIN_SPEED = 520; + const BOUNCE_FENCE_MAX_SPEED = 1180; function angleForItemLocal(it) { return itemAngleFor(it); @@ -274,7 +277,7 @@ cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)), bounce, gate, glassWall, gateOpen: Boolean(it.gateOpen), - restitution: bounce ? 1.08 : undefined, minBounceSpeed: bounce ? 260 : undefined + restitution: bounce ? BOUNCE_FENCE_RESTITUTION : undefined, minBounceSpeed: bounce ? BOUNCE_FENCE_MIN_SPEED : undefined }; }, @@ -666,7 +669,7 @@ t.lastSolidObstacleCollisionSource = r.item || null; t.lastSolidObstacleCollisionAt = this.time || 0; if (r.bounce) { - const restitution = Number.isFinite(r.restitution) ? r.restitution : 1.08; + const restitution = Number.isFinite(r.restitution) ? r.restitution : BOUNCE_FENCE_RESTITUTION; const vx = t.vx || 0; const vy = t.vy || 0; const toward = vx * nx + vy * ny; @@ -684,13 +687,13 @@ const now = this.time || 0; if (currentOut < minBounceSpeed && ((t.lastBounceFenceImpulseAt || -999) + 0.16 <= now || toward < 0)) { const add = minBounceSpeed - currentOut; - t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -520, 520); - t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -520, 520); + t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); + t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.lastBounceFenceImpulseAt = now; } } - t.vx = clamp(t.vx || 0, -520, 520); - t.vy = clamp(t.vy || 0, -520, 520); + t.vx = clamp(t.vx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); + t.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.18); if (t.bubble && (this.time || 0) >= (t.lastBounceFenceBubbleAt || -999) + 0.55) { t.lastBounceFenceBubbleAt = this.time || 0; diff --git a/js/world_family_social.js b/js/world_family_social.js index 04eec1c..d138ac4 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -494,6 +494,7 @@ }, addTarinai(opts = {}) { + if (!(this.canAddTarinai?.(1) ?? true)) return null; opts = global.TarinaiSeedFactory.prepareAddOptions(this, opts) || opts; const seedParents = Array.isArray(opts.seedParents) ? opts.seedParents.filter(p => p && typeof p === "object") : []; if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") { @@ -611,6 +612,7 @@ startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; + if (!(this.canAddTarinai?.(1) ?? true)) return false; if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false; if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false; if (this.areParentChild(a, b)) return false; @@ -657,8 +659,10 @@ const bText = buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }); setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b }); setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a }); - a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); - b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); + const championPair = Boolean(a.isTarinaiChampion && b.isTarinaiChampion); + const reproductionCooldown = championPair ? Math.max(8, CONFIG.reproductionCooldown * 0.45) + rand(1, 4) : CONFIG.reproductionCooldown + rand(2, 10); + a.reproductionTimer = reproductionCooldown; + b.reproductionTimer = reproductionCooldown; a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); b.surpriseTimer = Math.max(b.surpriseTimer, 0.35); return true; @@ -816,6 +820,7 @@ sizeItemMode: "", lifeItemMode: "", }); + if (!child) return null; if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true }); this.maxGeneration = Math.max(this.maxGeneration, child.generation); a.hasPaired = true; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 6a19483..c2c455d 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -339,7 +339,7 @@ -

\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u76f4\u7dda\u30fb\u81ea\u7531\u63cf\u753b\u30fb\u6d88\u3057\u30b4\u30e0\u3092\u9078\u3073\u3001\u30c9\u30e9\u30c3\u30b0\u3067\u8f2a\u90ed\u3092\u7de8\u96c6\u3002

+

\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002

@@ -353,11 +353,11 @@ function phyScalar(item, key, fallback = 0) { return physicsApi()?.scalar?.(item, key, fallback) ?? fallback; } function phySet(item, key, value, reason = "editor") { return physicsApi()?.setScalar?.(item, key, value, reason) || false; } function cloneSegmentsForRotator(item) { - const fallback = item?.type === "poison_block" ? [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]] : [[-78, 0, 78, 0], [0, -52, 0, 52]]; + const fallback = item?.type === "poison_block" ? [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]] : [[-78, 0, 78, 0], [0, -52, 0, 52]]; const src = physicsApi()?.segments?.(item) || fallback; return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]); } - function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 14 : 12)) { + function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 11 : 12)) { return Math.max(...segments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + Math.max(4, thick) + 8; } @@ -400,7 +400,7 @@ }); if (!controller) return false; const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI); - const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))); + const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12))); if (titleEl) titleEl.textContent = isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u56de\u8ee2\u4f53"; const speedLabel = speed?.closest?.("label"); const poweredLabel = poweredInput?.closest?.("label"); @@ -424,7 +424,7 @@ controller.cleanSegments(); const editedSegments = controller.getSegments(); physicsApi()?.setSegments?.(item, editedSegments, isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit"); - phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))), "editor-thickness"); + phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12))), "editor-thickness"); phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid"); if (isPoisonBlock) { phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage"); @@ -478,7 +478,7 @@
-

\u4e2d\u592e\u306e\u8f2a\u90ed\u5168\u4f53\u304c\u3001\u8a2d\u5b9a\u3057\u305f\u65b9\u5411\u3078\u5f80\u5fa9\u79fb\u52d5\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002

+

\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002

@@ -672,7 +672,7 @@ data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) }; } if (item.type === "poison_block") { - data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 14) || 14), segments: cloneSegmentsForRotator(item) }; + data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 11) || 11), segments: cloneSegmentsForRotator(item) }; } if (item.type === "reciprocator") { data.physics = { solid: phyScalar(item, "solid", true) !== false, railOn: phyScalar(item, "railOn", true) !== false, railMotorSpeed: Number(phyScalar(item, "railMotorSpeed", 92) || 92), railTravel: Number(phyScalar(item, "railTravel", 150) || 150), railPhase: Number(phyScalar(item, "railPhase", 0) || 0), railDir: Number(phyScalar(item, "railDir", 1) || 1), railAxis: Number(phyScalar(item, "railAxis", item.angle || 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) }; @@ -743,7 +743,7 @@ phySet(item, "solid", ph.solid !== false, "copy"); phySet(item, "damage", Math.max(1, Number(ph.damage || 7) || 7), "copy"); phySet(item, "spin", 0, "copy"); - phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 14) || 14)), "copy"); + phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 11) || 11)), "copy"); physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy"); const extent = physicsShapeExtent(item); item.r = Math.max(item.r || 64, Math.min(460, extent)); @@ -1199,6 +1199,10 @@ placeItem(item, dropped = true) { if (!item) return null; + if (!(this.canAddObjects?.(1) ?? true)) { + showToast(`オブジェクト総数は${this.objectLimit}個までです。`); + return null; + } if (item.type === "grass" && !this.canAddGrass?.(1)) { showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`); return null; @@ -1220,7 +1224,8 @@ if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear"); const placed = this.addItem?.(item, `place:${item.type}`); if (!placed) { - if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`); + if (!(this.canAddObjects?.(1) ?? true)) showToast(`オブジェクト総数は${this.objectLimit}個までです。`); + else if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`); return null; } this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); @@ -1357,6 +1362,7 @@ if (tool === "new") { const spot = this.edgeSpawnPoint(x, y, 42, true); const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 }); + if (!t) { showToast(`たりない個体数は${this.tarinaiPopulationLimit}匹までです。`); return; } t.wanderAngle = spot.angle; this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); return; diff --git a/js/world_reset_presets.js b/js/world_reset_presets.js index c1cdf60..5c751c1 100644 --- a/js/world_reset_presets.js +++ b/js/world_reset_presets.js @@ -215,9 +215,7 @@ const RESET_PRESETS = Object.freeze({ item.y = spot.y; syncPresetMechanicalPose(item); } - if (worldRef.addItem) worldRef.addItem(item, opts.reason || `preset-${type}`); - else worldRef.items.push(item); - return item; + return worldRef.addItem?.(item, opts.reason || `preset-${type}`) || null; } function addPresetGrass(worldRef, count, zone = null) { diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index c0df21a..37347ef 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -7,9 +7,13 @@ Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ beginFramePerformanceBudgets() { + const count = Math.max(1, (this.tarinai || []).length || 1); + const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760); + const aiCap = mobile ? 8 : 12; + const envCap = mobile ? 4 : 6; this.workBudget = { - ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)), - env: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 3)), + ai: Math.max(1, Math.min(aiCap, Math.ceil(count / 2))), + env: Math.max(1, Math.min(envCap, Math.ceil(count / 3))), aiUsed: 0, envUsed: 0, }; @@ -286,8 +290,46 @@ return false; }, + normalizeColonyLimit(value = 0) { + const n = Math.floor(Number(value) || 0); + return Number.isFinite(n) && n > 0 ? n : 0; + }, + + activeTarinaiCount() { + let count = 0; + for (const t of this.tarinai || []) if (t && !t.dead) count += 1; + return count; + }, + + activeObjectCount() { + let count = 0; + for (const item of this.items || []) if (item && !item.dead) count += 1; + return count; + }, + + canAddTarinai(count = 1) { + const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit); + return limit <= 0 || this.activeTarinaiCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit; + }, + + canAddObjects(count = 1) { + const limit = this.normalizeColonyLimit(this.objectLimit); + return limit <= 0 || this.activeObjectCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit; + }, + + setTarinaiPopulationLimit(value = 0) { + this.tarinaiPopulationLimit = this.normalizeColonyLimit(value); + return this.tarinaiPopulationLimit; + }, + + setObjectLimit(value = 0) { + this.objectLimit = this.normalizeColonyLimit(value); + return this.objectLimit; + }, + addItem(item, reason = "add-item", opts = {}) { if (!item) return null; + if (!this.canAddObjects(1)) return null; if (item.type === "grass" && !this.canAddGrass?.(1)) return null; item.world = this; this.items.push(item); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index f40ad8f..4540fda 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -177,6 +177,42 @@ if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cl ok("behavior state smoke test passed") +def need_action_runtime_bridge_smoke() -> None: + items_source = read_text(ROOT / "js/tarinai_needs_items.js") + core_source = read_text(ROOT / "js/tarinai_needs_core.js") + forced_source = read_text(ROOT / "js/tarinai_forced_behavior.js") + js = f""" +const vm = require('vm'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.Tarinai = class Tarinai {{}}; +vm.createContext(context); +vm.runInContext({items_source!r}, context, {{ filename: 'tarinai_needs_items.js' }}); +if (!context.TarinaiNeedsRuntime) throw new Error('TarinaiNeedsRuntime missing'); +if (typeof context.TarinaiNeedsRuntime.startNeedAction !== 'function') throw new Error('startNeedAction bridge missing'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("need-action runtime bridge smoke test failed") + qualified_call = "globalThis.TarinaiNeedsRuntime?.startNeedAction?.(" + if core_source.count(qualified_call) != 2: + fail("tarinai_needs_core.js must route both emergency starts through TarinaiNeedsRuntime") + if forced_source.count(qualified_call) != 1: + fail("tarinai_forced_behavior.js must route forced starts through TarinaiNeedsRuntime") + if re.search(r"(? None: tokens = re.compile(r"\b(activeBehavior|currentAction|activeForcedBehavior|intent)\b") offenders = [] @@ -1216,6 +1252,383 @@ if (grass.normalized) throw new Error('grass should not be normalized by per-ite fail("Item lifecycle pipeline smoke test failed") ok("Item lifecycle pipeline boundary present") + + +def mechanical_collision_and_editor_smoke() -> None: + footprints = read_text(ROOT / "js/collision_footprint_system.js") + editor_source = read_text(ROOT / "js/physics_shape_editor_system.js") + placement_source = read_text(ROOT / "js/world_placement_log.js") + for token in ("rectCorners,", "rectAxes,", "projectPoints,", "aabbOverlap,", "rectOverlapInfo,", "rectsOverlap,"): + if token not in footprints: + fail(f"mechanical SAT primitive is not exported: {token}") + if "const DEFAULT_GRID_SIZE = 20;" not in editor_source: + fail("mechanical shape editor grid must use coarse 20px snapping") + if editor_source.count("snapPoint(") < 2 or "gridSize: DEFAULT_GRID_SIZE" not in editor_source: + fail("mechanical shape editor does not route pointer coordinates through grid snapping") + if placement_source.count("20px\\u9593\\u9694") < 2: + fail("mechanical editor hints do not disclose 20px grid snapping") + + js = f""" +const vm = require('vm'); +const fs = require('fs'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.TarinaiPerf = {{ begin() {{ return null; }} }}; +context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }}; +context.normalizedItemAngle = angle => angle; +vm.createContext(context); +for (const path of {repr([str(ROOT / rel) for rel in [ + 'js/math.js', 'js/geometry_helpers.js', 'js/collision_footprint_system.js', + 'js/physics_world_system.js', 'js/mechanical_system.js', 'js/physics_shape_editor_system.js' +]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }}); + +const fp = context.TarinaiCollisionFootprints; +for (const key of ['rectCorners', 'rectAxes', 'projectPoints', 'aabbOverlap', 'rectOverlapInfo', 'rectsOverlap']) {{ + if (typeof fp[key] !== 'function') throw new Error(`missing footprint primitive: ${{key}}`); +}} + +const editor = context.TarinaiPhysicsShapeEditorSystem; +if (editor.gridSize !== 20) throw new Error('editor grid size mismatch'); +const snapped = editor.snapPoint({{ x: 13, y: 31 }}); +if (snapped.x !== 20 || snapped.y !== 40) throw new Error('pointer did not snap to grid'); +const cleaned = editor.cleanSegments([[13, 31, 91, 44], [-52, -20, 52, -20]], {{ fallback: [-80, 0, 80, 0] }}); +if (!cleaned.length || cleaned.some(seg => seg.some(value => Math.abs(value % 20) > 1e-9))) throw new Error('segment endpoints are not grid aligned'); +if (cleaned.some(seg => Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) < 20)) throw new Error('editor retained sub-grid segments'); + +let serial = 0; +function makeItem(type, x, y) {{ + return {{ id: `m${{++serial}}`, type, x, y, angle: 0, r: 64, amount: 999, dead: false }}; +}} +function activate(item) {{ + const api = context.TarinaiPhysicsBodySystem; + api.setSegments(item, [[-80, 0, 80, 0]], 'test-shape'); + api.setScalar(item, 'solid', true, 'test-solid'); + if (item.type === 'rotator') {{ api.setScalar(item, 'motorOn', true, 'test'); api.setScalar(item, 'motorSpeed', 1.4, 'test'); }} + if (item.type === 'reciprocator') {{ api.setScalar(item, 'railOn', true, 'test'); api.setScalar(item, 'railAxis', 0, 'test'); api.setScalar(item, 'railMotorSpeed', 80, 'test'); }} + if (item.type === 'poison_block') {{ api.setScalar(item, 'xv', 24, 'test'); api.setScalar(item, 'awakeUntil', 10, 'test'); }} +}} +function pairResult(typeA, typeB, disableB = false) {{ + const a = makeItem(typeA, 120, 120); + const b = makeItem(typeB, 120, 128); + const items = [a, b]; + const world = {{ + items, tarinai: [], time: 1, frameCount: 1, w: 900, h: 700, drawListDirty: false, + itemCounts: {{ rotator: 0, poison_block: 0, reciprocator: 0 }}, + ensureItemBuckets() {{}}, markSpatialDirty() {{}}, markItemBucketsDirty() {{}}, + nearbyObstacles() {{ return items; }}, nearbyItems() {{ return items; }}, + itemsOfType(type) {{ return items.filter(item => item.type === type && !item.dead); }}, + }}; + for (const item of items) {{ item.world = world; world.itemCounts[item.type] += 1; context.TarinaiPhysicsBodySystem.ensureBody(item, world); activate(item); }} + if (disableB) context.TarinaiPhysicsBodySystem.setScalar(b, 'solid', false, 'test-disabled'); + const solved = context.TarinaiMechanicalSystem.resolveMechanicalPairs(world, 0.016, {{ maxPairs: 40 }}); + return {{ solved, a, b }}; +}} +for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rotator', 'poison_block'], ['reciprocator', 'reciprocator'], ['reciprocator', 'poison_block'], ['poison_block', 'poison_block']]) {{ + const result = pairResult(a, b, false); + if (!(result.solved > 0)) throw new Error(`${{a}}/${{b}} collision-on pair did not interact`); + const disabled = pairResult(a, b, true); + if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`); +}} +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("mechanical collision/editor smoke test failed") + ok("collision-enabled mechanical pairs interact and editor geometry snaps to the 20px lattice") + +def colony_limits_and_defaults_smoke() -> None: + tool_defs = read_text(ROOT / "js/item_tool_definitions.js") + initializer = read_text(ROOT / "js/item_type_initializers.js") + world_budget = read_text(ROOT / "js/world_spatial_budget.js") + world_family = read_text(ROOT / "js/world_family_social.js") + needs = read_text(ROOT / "js/tarinai_needs_items.js") + social = read_text(ROOT / "js/tarinai_social_action_runtime.js") + snapshot = read_text(ROOT / "js/snapshot_system.js") + codec = read_text(ROOT / "js/save_codec.js") + index = read_text(ROOT / "index.html") + if 't === "reciprocator") return Math.PI / 2' not in tool_defs: + fail("reciprocator default angle is not 90 degrees") + small = "[[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]" + poison_files = [ + "js/item_type_initializers.js", "js/physics_world_system.js", "js/item_render_runtime.js", + "js/mechanical_system.js", "js/world_placement_log.js", + ] + for rel in poison_files: + body = read_text(ROOT / rel) + if not all(token in body for token in ("-52", "-20", "52", "20")): + fail(f"small poison-block shape missing from {rel}") + if "[-82, -28, 82, -28]" in body: + fail(f"legacy large poison-block shape remains in {rel}") + if 'radius: 44' not in tool_defs or 'thickness: 11' not in initializer: + fail("small poison-block radius/thickness defaults are not synchronized") + for token in ("canAddTarinai(count = 1)", "canAddObjects(count = 1)", "setTarinaiPopulationLimit", "setObjectLimit"): + if token not in world_budget: + fail(f"colony limit runtime missing: {token}") + if "if (!(this.canAddTarinai?.(1) ?? true)) return null;" not in world_family: + fail("tarinai creation/birth is not unconditionally guarded by population limit") + if "if (!this.canAddObjects(1)) return null;" not in world_budget: + fail("object creation is not unconditionally guarded by object limit") + if "ignoreColonyLimit" in world_family or "ignoreColonyLimit" in world_budget: + fail("colony limit bypass option remains in central creation APIs") + unsafe_fallback = re.compile(r"addItem\?\.[^\n;]+\|\|[^\n;]*items\.push") + for rel in (ROOT / "js").glob("*.js"): + if unsafe_fallback.search(read_text(rel)): + fail(f"object-limit bypass fallback remains in {rel.name}") + direct_item_pushes = [] + direct_tarinai_pushes = [] + for rel in (ROOT / "js").glob("*.js"): + body = read_text(rel) + if re.search(r"\b(?:this|worldRef|world)\.items\.push\(", body): + direct_item_pushes.append(rel.name) + if re.search(r"\b(?:this|worldRef|world)\.tarinai\.push\(", body): + direct_tarinai_pushes.append(rel.name) + if sorted(direct_item_pushes) != ["snapshot_system.js", "world_spatial_budget.js"]: + fail("unexpected direct world item insertion can bypass object limit: " + ", ".join(sorted(direct_item_pushes))) + if sorted(direct_tarinai_pushes) != ["snapshot_system.js", "world_family_social.js"]: + fail("unexpected direct tarinai insertion can bypass population limit: " + ", ".join(sorted(direct_tarinai_pushes))) + guarded_sources = { + "js/item_dynamic_ball_system.js": "if (!addedDrop) continue;", + "js/fire_runtime_system.js": "if (!addedFire) return null;", + "js/tarinai_building_behavior.js": "canAddObjects?.(1)", + "js/world_combat_effects.js": "if (!spawned) return 0;", + } + for rel, token in guarded_sources.items(): + if token not in read_text(ROOT / rel): + fail(f"object-limit guard missing from {rel}: {token}") + if "championMate" not in needs or "championPair" not in social or "mateWeight *= 5.0" not in social: + fail("champion-pair breeding preference is incomplete") + for token in ("tarinaiPopulationLimit", "objectLimit"): + if token not in snapshot or token not in codec: + fail(f"colony limit persistence missing: {token}") + for element_id in ("tarinaiPopulationLimitInput", "objectLimitInput", "colonyLimitStatus"): + if f'id="{element_id}"' not in index: + fail(f"colony limit UI missing: {element_id}") + + js = f""" +const vm = require('vm'); +const fs = require('fs'); +class World {{ + constructor() {{ + this.items = []; + this.tarinai = []; + this.itemCounts = {{}}; + this.tarinaiPopulationLimit = 0; + this.objectLimit = 0; + }} + markItemBucketsDirty() {{}} + markSpatialDirty() {{}} + markTerrainDirty() {{}} + markTerrainDirtyAt() {{}} +}} +const context = {{ console, World, CONFIG: {{ grassLimit: Infinity }}, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.TarinaiGround = {{ grassLimit() {{ return Infinity; }} }}; +context.TarinaiPerf = {{ snapshot() {{ return null; }}, begin() {{ return null; }} }}; +vm.createContext(context); +vm.runInContext(fs.readFileSync({str(ROOT / 'js/world_spatial_budget.js')!r}, 'utf8'), context); +const world = new context.World(); +if (world.tarinaiPopulationLimit !== 0 || world.objectLimit !== 0) throw new Error('limits must default to unlimited'); +world.setObjectLimit(2.9); +if (world.objectLimit !== 2) throw new Error('object limit must normalize to a positive integer'); +if (!world.canAddObjects(2) || world.canAddObjects(3)) throw new Error('object capacity query is incorrect'); +if (!world.addItem({{ type: 'stone' }}, 'test-1')) throw new Error('first object rejected'); +if (!world.addItem({{ type: 'stone' }}, 'test-2')) throw new Error('second object rejected'); +if (world.addItem({{ type: 'stone' }}, 'test-3') !== null) throw new Error('object limit not enforced'); +world.items[0].dead = true; +if (!world.addItem({{ type: 'stone' }}, 'test-4')) throw new Error('dead object should free capacity'); +world.setObjectLimit(1); +if (world.addItem({{ type: 'stone' }}, 'over-limit-existing') !== null) throw new Error('existing over-limit world must reject new objects'); +for (const item of world.items) item.dead = true; +if (!world.addItem({{ type: 'stone' }}, 'capacity-restored')) throw new Error('capacity was not restored after all live objects died'); +world.setTarinaiPopulationLimit(1.8); +if (world.tarinaiPopulationLimit !== 1) throw new Error('tarinai limit must normalize to a positive integer'); +world.tarinai.push({{ dead: false }}); +if (world.canAddTarinai(1)) throw new Error('tarinai limit not enforced'); +world.tarinai[0].dead = true; +if (!world.canAddTarinai(1)) throw new Error('dead tarinai should free capacity'); +world.setObjectLimit(''); +if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited'); +context.TarinaiSnapshot = {{ version: 23 }}; +context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 28, FIELD_IDS: ['garden'] }}; +context.CONFIG.dayLength = 120; +context.TextEncoder = TextEncoder; +context.TextDecoder = TextDecoder; +vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context); +(async () => {{ + const source = {{ v: 23, a: 'tj1', m: [1, 0, 'garden'], w: [0, 0, 0, 120, 0, -9990, 1, 0, 'seed', 0, 12, 345], t: [], i: [] }}; + const encoded = await context.TarinaiSaveCodec.encodeSnapshot(source); + const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded); + if (decoded.w[10] !== 12 || decoded.w[11] !== 345) throw new Error('colony limits did not survive save roundtrip'); +}})().catch(error => {{ console.error(error); process.exitCode = 1; }}); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("colony limit runtime smoke test failed") + ok("champion breeding, mechanical defaults, and colony limits are wired") + + +def physics_tuning_smoke() -> None: + editor = read_text(ROOT / "js/physics_shape_editor_system.js") + environment = read_text(ROOT / "js/world_environment.js") + helpers = read_text(ROOT / "js/physics_helpers.js") + constraints = read_text(ROOT / "js/constraint_system.js") + dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.js") + initializer = read_text(ROOT / "js/item_type_initializers.js") + + for token in ("BOUNCE_FENCE_RESTITUTION = 2.40", "BOUNCE_FENCE_MIN_SPEED = 520", "BOUNCE_FENCE_MAX_SPEED = 1180"): + if token not in environment: + fail(f"strong bounce-fence tuning missing: {token}") + if "const iterations = 8" not in constraints or "const compliance = mode === \"rod\" ? 0.085 / (step * 60) : 0" not in constraints: + fail("rope endpoint constraint is still compliant/stretchable") + if "STICKY_BOMB_BLAST_SCALE = 0.68" not in dynamic_tools or "item.blastScale = 0.68" not in initializer: + fail("sticky bomb is not synchronized with small-firecracker blast scale") + + js = f""" +const vm = require('vm'); +const editorSource = {editor!r}; +const helperSource = {helpers!r}; +const constraintSource = {constraints!r}; +const dynamicSource = {dynamic_tools!r}; + +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +context.distXY = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1); +context.CONFIG = {{ worldPadding: 0 }}; +context.Effect = function Effect() {{}}; +context.TarinaiGeometry = {{ + pointSegmentDistance(px, py, x1, y1, x2, y2) {{ + const dx = x2 - x1, dy = y2 - y1; + const den = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / den)); + return Math.hypot(px - (x1 + dx * t), py - (y1 + dy * t)); + }}, + rectLocalPoint(rect, x, y) {{ return {{ x: x - (rect.cx || 0), y: y - (rect.cy || 0) }}; }}, + rectWorldVector(_rect, x, y) {{ return {{ x, y }}; }}, + rectWorldNormal(_rect, x, y) {{ return {{ x, y }}; }}, +}}; +context.TarinaiMechanicalSystem = {{ + isMechanicalType() {{ return false; }}, + isPowered() {{ return false; }}, + applySurfaceVelocityToCircle() {{}}, + wakeItem() {{}}, +}}; +context.TarinaiPhysicsProjectionSystem = {{ markSpatialDirty() {{ return false; }} }}; +context.TarinaiMovementUpdateStep = {{ markSleepExternalMotion() {{}} }}; +context.itemAngleFor = item => Number(item?.angle || 0) || 0; +context.normalizedItemAngle = angle => Number(angle || 0) || 0; +vm.createContext(context); + +vm.runInContext(editorSource, context, {{ filename: 'physics_shape_editor_system.js' }}); +const circle = context.TarinaiPhysicsShapeEditorSystem.templateSegments('circle'); +if (!Array.isArray(circle) || circle.length !== 24) throw new Error('circle template is not a 24-sided contour'); +for (const segment of circle) {{ + if (segment.length !== 4 || segment.some(value => value % 20 !== 0)) throw new Error('circle template left the 20px grid'); +}} +for (let i = 0; i < circle.length; i += 1) {{ + const next = circle[(i + 1) % circle.length]; + if (circle[i][2] !== next[0] || circle[i][3] !== next[1]) throw new Error('circle contour is not closed'); +}} + +vm.runInContext(helperSource, context, {{ filename: 'physics_helpers.js' }}); +const ball = {{ x: -4, y: 5, prevX: -14, prevY: 5, vx: 100, vy: 0, r: 5, spinVelocity: 0 }}; +const fence = {{ left: 0, right: 10, top: 0, bottom: 10, bounce: true, restitution: 2.40, minBounceSpeed: 520 }}; +if (!context.TarinaiPhysics.bounceCircleOffRect(ball, fence, 5, fence.restitution, null, {{ minBounceSpeed: fence.minBounceSpeed }})) throw new Error('bounce-fence collision did not resolve'); +if (ball.vx > -519) throw new Error('bounce-fence launch is not substantially stronger'); + +context.TarinaiPhysicsBodySystem = {{ + scalar(item, key, fallback) {{ return Number(item.physicsBody?.[key] ?? fallback); }}, + setScalar(item, key, value) {{ item.physicsBody ||= {{}}; item.physicsBody[key] = value; return true; }}, + linkScalar(item, key, fallback) {{ + const c = item.physicsConstraint || {{}}; + const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }}; + return Number(c[map[key] || key] ?? fallback); + }}, + setLinkScalar(item, key, value) {{ + const c = item.physicsConstraint ||= {{}}; + const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }}; + c[map[key] || key] = value; + return true; + }}, + endpoint(item, index) {{ return item.physicsConstraint?.endpoints?.[index] || null; }}, + particles(item) {{ return item.physicsConstraint?.particles || null; }}, + setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }}, + ensureConstraint(item) {{ return item.physicsConstraint; }}, + applyConstraintState() {{ return true; }}, + normalizeConstraintState(_item, state) {{ return state; }}, + syncPoseFromItem() {{ return true; }}, + normalizeBodyState() {{ return true; }}, + markBodyChanged() {{ return true; }}, +}}; +vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }}); +const a = {{ id: 'a', type: 'ball', x: 0, y: 0, r: 10, dead: false }}; +const b = {{ id: 'b', type: 'ball', x: 200, y: 0, r: 10, dead: false }}; +const objects = new Map([[a.id, a], [b.id, b]]); +const world = {{ + time: 1, + items: [a, b], + tarinai: [], + itemCounts: {{ rope: 1 }}, + itemById(id) {{ return objects.get(id) || null; }}, + nearbyTarinai() {{ return []; }}, + nearbySolidObstacleRects() {{ return []; }}, + markSpatialDirty() {{}}, + drawListDirty: false, +}}; +const rope = {{ + id: 'rope-1', type: 'rope', dead: false, amount: 999, world, + physicsConstraint: {{ endpoints: [{{ kind: 'item', id: 'a', x: 0, y: 0 }}, {{ kind: 'item', id: 'b', x: 200, y: 0 }}], length: 100, particles: null }}, +}}; +context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world); +const tautDistance = Math.hypot(b.x - a.x, b.y - a.y); +if (tautDistance > 100.26) throw new Error(`rope stretched to ${{tautDistance}}px`); +const beforeA = {{ x: a.x, y: a.y }}, beforeB = {{ x: b.x, y: b.y }}; +b.x = a.x + 60; +context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world); +if (Math.hypot(a.x - beforeA.x, a.y - beforeA.y) > 0.01) throw new Error('slack rope pushed endpoint A'); +if (Math.abs((b.x - a.x) - 60) > 0.01) throw new Error('slack rope pushed endpoints apart'); + +context.deterministicChance = () => false; +context.Effect = function Effect() {{}}; +context.audio = {{ explode() {{}} }}; +vm.runInContext(dynamicSource, context, {{ filename: 'item_dynamic_tool_system.js' }}); +let capturedScale = null; +const sticky = {{ type: 'sticky_bomb', dead: false, fuseTimer: 0, stickyBombCarrierId: '', stickyBombTransferCooldown: 0, spin: 0, spinVelocity: 0 }}; +const stickyWorld = {{ tarinai: [], explodeFirecracker(item) {{ capturedScale = item.blastScale; return true; }} }}; +context.TarinaiItemDynamicToolSystem.update(sticky, 1 / 60, stickyWorld); +if (Math.abs(capturedScale - 0.68) > 1e-9) throw new Error('sticky bomb does not match small firecracker blast scale'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("physics tuning smoke test failed") + ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified") + def main() -> None: data = manifest() check_files_exist(data) @@ -1227,6 +1640,10 @@ def main() -> None: node_check([*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js"]) action_spec_smoke() behavior_state_smoke() + need_action_runtime_bridge_smoke() + mechanical_collision_and_editor_smoke() + physics_tuning_smoke() + colony_limits_and_defaults_smoke() check_basic_action_spec_runtime() check_social_action_spec_runtime() check_action_spec_no_legacy_hooks() diff --git a/service-worker.js b/service-worker.js index 6d1d907..657617f 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.15.164"; +const APP_VERSION = "39.15.167"; 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.