diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index fa1b98e6..208cc6f8 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -25,10 +25,6 @@ "src/dev/**/*.tsx" ], - "entry": [ - "scripts/generate-ridge-blockout-sources.test.mjs" - ], - "health": { "ignore": ["**/*.generated.ts", "dist/**", "storybook-static/**"] }, @@ -77,5 +73,31 @@ { "from": "core", "allow": ["core", "shared"], "allowTypeOnly": ["shared"] }, { "from": "shared", "allow": ["shared"] } ] - } + }, + "ignoreExports": [ + { + "file": "src/game/core/ecs/systems/playerSystems.ts", + "exports": ["*"] + }, + { + "file": "src/game/core/player/PlayerController.ts", + "exports": ["*"] + }, + { + "file": "src/game/scenes/potassiumSlip/runtime/renderer.ts", + "exports": ["*"] + }, + { + "file": "src/game/scenes/potassiumSlip/runtime/session.ts", + "exports": ["*"] + }, + { + "file": "src/game/scenes/potassiumSlip/runtime/phaserData.ts", + "exports": ["*"] + }, + { + "file": "src/game/scenes/potassiumSlip/runtime/waves.ts", + "exports": ["*"] + } + ] } diff --git a/README.md b/README.md index 7e876f71..c6de797f 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,9 @@ Ridge Stage Debugger with `?mode=ridge-stage-debugger` after starting `pnpm dev`; it hosts the live Bridge preview, route-beat controls, Walk Rail readouts, Stage Spot movement, and Bridge Stage debug overlays. -Legacy Ridge blockout source files still live under -`src/game/scenes/ridge/blockout/sources/`. If you edit those files, run -`pnpm ridge:source` to refresh committed `.generated.ts` artifacts, or -`pnpm ridge:source:check` to verify freshness. `pnpm check` includes that -freshness gate. +Stampede Sketch remains a standalone mini-game scene (`stampedeSketch`) for +optional future content; reach it via the dev scene switcher or basement console +(`stampede`) until the Ridge rework wires a player-facing entry. ## Architecture diff --git a/docs/adr/0001-ridge-blockout-as-exploration-map-source.md b/docs/adr/0001-ridge-blockout-as-exploration-map-source.md index 88fd3577..2d8ca151 100644 --- a/docs/adr/0001-ridge-blockout-as-exploration-map-source.md +++ b/docs/adr/0001-ridge-blockout-as-exploration-map-source.md @@ -1,7 +1,7 @@ # Adopt Ridge Blockout As Exploration Map Source Of Truth -Status: accepted for the current/prototype Ridge runtime; superseded as future -Ridge route canon by the Ridge pre-production plan. +Status: **superseded** — blockout implementation removed from the repo (Bridge +Tracer is the active Ridge runtime). Kept as historical ADR. This ADR explains why the existing Phaser Ridge prototype compiles a typed blockout source into spatial facts. It does not require the desired @@ -23,6 +23,6 @@ route, anchor, shortcut, and home-mutation facts before runtime presentation and interaction modules consume it. This deepens the original runtime decision rather than superseding it. -The current/prototype blockout source lives beside the generated runtime artifact at -`src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts`. The -language documentation remains in `docs/game-design/ridge/map-language.md`. +The folded-desk blockout source and compiler were removed from the repository. +The language documentation remains in `docs/game-design/ridge/map-language.md` +as a historical contract reference. diff --git a/docs/adr/README.md b/docs/adr/README.md index 92591e70..f3aeed4f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,7 +13,7 @@ override the Ridge pre-production route. | ADR | Current Scope | | --- | --- | -| [`0001-ridge-blockout-as-exploration-map-source.md`](./0001-ridge-blockout-as-exploration-map-source.md) | Current/prototype Ridge blockout runtime only; superseded as future route canon. | +| [`0001-ridge-blockout-as-exploration-map-source.md`](./0001-ridge-blockout-as-exploration-map-source.md) | Historical folded-desk blockout runtime (removed from repo); superseded as future route canon. | | [`0002-audio-adapter-boundary.md`](./0002-audio-adapter-boundary.md) | Current audio ownership boundary. | -| [`0003-ridge-blockout-source-contract.md`](./0003-ridge-blockout-source-contract.md) | Current/prototype Ridge blockout tooling contract only; superseded as future route canon. | +| [`0003-ridge-blockout-source-contract.md`](./0003-ridge-blockout-source-contract.md) | Historical blockout tooling contract (removed from repo); superseded as future route canon. | | [`0004-ridge-stage-composition-source.md`](./0004-ridge-stage-composition-source.md) | Experimental Ridge exploration and First Playable Route staging source. | diff --git a/docs/architecture-direction.md b/docs/architecture-direction.md index 310da112..e6740d31 100644 --- a/docs/architecture-direction.md +++ b/docs/architecture-direction.md @@ -5,9 +5,12 @@ why behind the runtime shape, and the direction future refactors should preserve For what exists in code today, treat [`runtime-architecture.md`](runtime-architecture.md), `AGENTS.md`, and the scoped `.agents/rules/` files as the operational guidance. -Ridge caveat: architecture notes about the typed Ridge blockout apply to the -current Phaser prototype/runtime. They do not override the Ridge pre-production -route in [`game-design/ridge/README.md`](./game-design/ridge/README.md). +Ridge caveat: the folded-desk blockout stack was removed from the repo. Active +Ridge runtime spatial truth lives in Bridge stage composition +(`src/game/scenes/ridge/bridge/`). Historical blockout notes in ADRs and +[`game-design/ridge/map-language.md`](./game-design/ridge/map-language.md) do +not override the pre-production route in +[`game-design/ridge/README.md`](./game-design/ridge/README.md). ## 1. Architectural Philosophy @@ -51,9 +54,9 @@ paths, prefer [`runtime-architecture.md`](runtime-architecture.md). When proposing future refactors: - Prefer extending existing lifecycle, bridge, overlay, scene UI, and shared runtime seams instead of re-introducing callback-only scene orchestration, ad-hoc overlay maps, or ad-hoc global state. -- For current/prototype Ridge runtime work, keep spatial truth in the typed - Ridge blockout source and compiled facts instead of rebuilding parallel - parent/route/spatial catalogs. +- For current Ridge runtime work, keep spatial truth in Bridge stage + composition (`stageComposition.ts`) instead of rebuilding parallel catalogs or + resurrecting the removed blockout pipeline. - Introduce shared render helpers only when repeated render policy code appears. --- diff --git a/docs/game-design/README.md b/docs/game-design/README.md index 1709c4d1..b38700d8 100644 --- a/docs/game-design/README.md +++ b/docs/game-design/README.md @@ -1,8 +1,8 @@ # Game Design This directory separates shipped behavior, active Ridge pre-production design, -current runtime prototype truth, long-term direction, runtime blockout source, -and reference material. Do not treat every file here as equally active. +current runtime prototype truth, long-term direction, and reference material. +Do not treat every file here as equally active. ## Source Of Truth @@ -13,8 +13,7 @@ and reference material. Do not treat every file here as equally active. - **Current Ridge runtime/prototype snapshot:** [`ridge/ridge-snapshot.md`](./ridge/ridge-snapshot.md). - **Current Bridge runtime spatial source:** [`stageComposition.ts`](../../src/game/scenes/ridge/bridge/stageComposition.ts), described by [`ridge/ridge-snapshot.md`](./ridge/ridge-snapshot.md). -- **Legacy folded blockout source:** [`folded-desk-ridge.source.ts`](../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts), described by - [`ridge/map-language.md`](./ridge/map-language.md). +- **Legacy blockout contract (historical):** [`ridge/map-language.md`](./ridge/map-language.md) — source removed from repo. - **Pre-production product vision:** [`ridge/summit.md`](./ridge/summit.md). - **Milestone map:** [`ridge/milestone-plan.md`](./ridge/milestone-plan.md) for current route-reset milestones and prototype reuse rules. @@ -66,12 +65,9 @@ prefer GitHub Issues. - **[Sketchbook Ridge Milestone Plan](./ridge/milestone-plan.md)**: Current route-reset milestones, source stack, prototype reuse rules, and first-agent checklist. It is not the live issue tracker. -- **[Ridge Blockout Source](./ridge/map-language.md)**: Typed blockout source - contract for Ridge room beats, environment tags, traversal primitives, and - greybox generation. -- **[Legacy Ridge Blockout Source](../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts)**: - Folded prototype authoring data retained as build input and reference, not - the active Bridge rail/stage authoring surface. +- **[Ridge Blockout Source](./ridge/map-language.md)**: Historical typed + blockout contract (removed from repo; Bridge stage composition is the active + spatial source). - **[Ridge Legacy Docs](./ridge/legacy/README.md)**: Superseded folded/Cicka Home map plans, old summit/milestone history, and blockout reviews. Reference only unless a current active doc explicitly links there. @@ -105,9 +101,8 @@ prefer GitHub Issues. `mini-games/stampede-sketch.md`. These are local mini-game contracts, not required first-ending Living Proof. - **Current Ridge runtime/prototype reality:** `ridge/ridge-snapshot.md`. -- **Current runtime source:** - `../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts`. -- **Current runtime source documentation:** `ridge/map-language.md`. +- **Current runtime source:** `../../src/game/scenes/ridge/bridge/stageComposition.ts`. +- **Historical blockout documentation:** `ridge/map-language.md`. - **Legacy/prototype topology and planning history:** `ridge/legacy/`. - **Reference/provenance:** `ridge/reference/` plus `docs/research/`. These can guide future work, but should not override active Ridge design, shipped diff --git a/docs/game-design/ridge/README.md b/docs/game-design/ridge/README.md index bf4ba441..07163755 100644 --- a/docs/game-design/ridge/README.md +++ b/docs/game-design/ridge/README.md @@ -32,7 +32,7 @@ Use each file for exactly one concern: | Active open questions | [`open-questions.md`](./open-questions.md) | True design unknowns and blockout-detail TBD. Area premises may already be accepted even when prompt/topology details remain open. | | Product vision | [`summit.md`](./summit.md) | Durable fantasy and pillars, not detailed route implementation. | | Implementation sequencing | [`milestone-plan.md`](./milestone-plan.md) | Current route-reset milestones, Bridge Tracer Slice, source stack, Agent-Ready Slice Contract, prototype reuse rules, and agent checklist. Not live backlog. | -| Runtime blockout contract | [`map-language.md`](./map-language.md) | Source format and generated facts for the current/prototype blockout. | +| Historical blockout contract | [`map-language.md`](./map-language.md) | Superseded source format (removed from repo); Bridge stage composition is active. | | Legacy prototype history | [`legacy/`](./legacy/README.md) | Superseded folded/Cicka Home plans, old summit/milestone history, map plans, and reviews. Reference only unless an active doc links there. | | Art/audio/asset support | [`reference/`](./reference/README.md) | Reference packs that support active docs but do not override route canon. | | Live work | GitHub Issues | PRDs, current backlog, triage state, and agent briefs. | @@ -69,9 +69,9 @@ Treat these as prototype/reference unless a task explicitly says to adapt them: - Main-path slopes, ramps, wall jumps, double jumps, or precision platforming. - Mini-game clears as required first-ending proof. -Useful legacy pieces may still be preserved or adapted: the Ridge Blockout -Source contract, compiler, generated facts, validation, and preview/debugger -workflow. +The Ridge Blockout Source implementation was removed from the repo; keep +[`map-language.md`](./map-language.md) and git history as contract reference. +The Ridge Stage Debugger (`?mode=ridge-stage-debugger`) remains Bridge-first. Superseded planning docs live under [`legacy/`](./legacy/README.md). Do not search that folder first when updating active route design. diff --git a/docs/game-design/ridge/legacy/reviews/blockout-fun-review.md b/docs/game-design/ridge/legacy/reviews/blockout-fun-review.md index 56f48e9f..db173b7f 100644 --- a/docs/game-design/ridge/legacy/reviews/blockout-fun-review.md +++ b/docs/game-design/ridge/legacy/reviews/blockout-fun-review.md @@ -1,7 +1,7 @@ # Ridge Blockout Fun Review -Status: design review of -[`folded-desk-ridge.source.ts`](../../../../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts). +Status: design review of the removed `folded-desk-ridge.source.ts` blockout +(see git history; source no longer in the repo). This review scores the first text skeleton against the current research notes on 2D map design, Lucky Luna, and Nine Sols. diff --git a/docs/game-design/ridge/map-language.md b/docs/game-design/ridge/map-language.md index 0d4ba483..d5988aac 100644 --- a/docs/game-design/ridge/map-language.md +++ b/docs/game-design/ridge/map-language.md @@ -1,6 +1,11 @@ # Ridge Blockout Source -Status: active runtime source and design tool. +> Status: **superseded / removed from repo.** The folded-desk blockout source, +> compiler, and generated artifacts were deleted when the active Ridge runtime +> became the Bridge Tracer Slice only. This doc remains as historical contract +> reference; do not run `pnpm ridge:source` (scripts removed). + +Status: historical design tool (no longer in `src/`). The Ridge Blockout Source is a typed TypeScript authoring contract for designing Ridge room beats before final assets exist. It should be readable by @@ -8,22 +13,15 @@ Danilo, easy for agents to edit, and strict enough that TypeScript plus the source generator can produce a Phaser greybox, typed facts, traversal connectors, numeric tile rows, and progress-gated presentation inputs. -## Decision - -`folded-desk-ridge.source.ts` is the authoring source for Ridge greybox -generation. It is not design prose. Runtime imports the committed generated -artifact next to it. +## Decision (historical) -The first full skeleton lives in -[`src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts`](../../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts). -Source-contract work should use that file, not a toy example, so the language -evolves against the real Ridge topology. +`folded-desk-ridge.source.ts` was the authoring source for Ridge greybox +generation. It was not design prose. Runtime imported a committed generated +artifact compiled from typed `.source.ts` files. -Location note: the blockout used to live under `docs/game-design/` and then as -a raw `.blockout.txt` runtime import because the language started as a design -artifact. It now lives beside the generated Ridge source artifact because the -build checks it as typed source data. If it moves again, update the generator, -runtime import, this document, and ADR-0001 in the same migration. +That implementation was removed from the repository when the active Ridge +scene became the Bridge Tracer Slice only. Restore from git history if the +contract is needed again for a future area blockout. The default grid cell size is **48px**, declared at the source level instead of hard-coded into the runtime: @@ -95,20 +93,12 @@ encounter logic stay outside v0. separate enough that one can change without rewriting all three. - **Asset-light:** symbols reserve intent before final art exists. -Agents should edit `.source.ts` files and then run `pnpm ridge:source`. -`pnpm ridge:source:check` and the standard `pnpm check` command fail when a -generated artifact is stale or when source validation rejects the contract. - ## Tooling Status -This contract is retained for the legacy folded Ridge prototype source, -compiler, generated facts, and validation tests. It is no longer the main Ridge -development surface. Current Bridge rail/stage work uses the Ridge Stage -Debugger at `?mode=ridge-stage-debugger` and the Bridge Stage Composition Source -under `src/game/scenes/ridge/bridge/`. - -If this legacy source is edited, source edits still happen in `.source.ts` -followed by `pnpm ridge:source`. +The blockout generator scripts and the former Ridge blockout source tree were +removed. Current Bridge rail/stage work uses the Ridge Stage Debugger at +`?mode=ridge-stage-debugger` and the Bridge Stage Composition Source under +`src/game/scenes/ridge/bridge/stageComposition.ts`. ## Core Shape @@ -263,12 +253,10 @@ beats should not overlap unless the map explicitly declares a merge rule. v0 should avoid merge rules and treat conflicting non-empty overlap as a validation error. -## Current Runtime Output +## Historical Runtime Output -`src/game/scenes/ridge/blockout/` generates a committed `.generated.ts` -artifact from the typed `.source.ts` file. `RidgeScene` renders every room into -one whole-world greybox by default, derives typed traversal connectors from -`movement` metadata, compiles typed facts for presentation/interaction modules, -resolves shortcuts from durable Ridge progress, and keeps future routes, locked -shortcuts, and future Cicka Home mutations as inactive promises until their -progress source exists. +The removed pipeline generated a committed `.generated.ts` artifact from typed +`.source.ts` files, derived traversal connectors from `movement` metadata, +compiled facts for presentation/interaction modules, and resolved shortcuts from +durable Ridge progress. None of that runs in the current Bridge-only +`RidgeScene`. diff --git a/docs/game-design/ridge/ridge-snapshot.md b/docs/game-design/ridge/ridge-snapshot.md index 9dd699d7..ed3eb64e 100644 --- a/docs/game-design/ridge/ridge-snapshot.md +++ b/docs/game-design/ridge/ridge-snapshot.md @@ -12,17 +12,14 @@ - **Ridge source router:** [`README.md`](./README.md). - **Active story/route canon:** [`story-level-bible.md`](./story-level-bible.md). - **Active area design:** [`areas/`](./areas/README.md). -- **Legacy blockout spatial data:** [`folded-desk-ridge.source.ts`](../../../src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts). -- **Blockout language contract:** [`map-language.md`](./map-language.md). +- **Legacy blockout language (historical):** [`map-language.md`](./map-language.md) — superseded; folded-desk source removed from the repo. - **Legacy folded topology reference:** [`legacy/`](./legacy/README.md). - **Product vision:** [`summit.md`](./summit.md). - **Live implementation work:** GitHub Issues. If this file disagrees with the current Ridge runtime code about implemented -behavior, the code wins. If this file describes the legacy folded desk blockout -and disagrees with the folded desk Ridge blockout about room layout, route -order, anchors, shortcuts, or progress-gated geometry, the blockout wins. -If this file disagrees with GitHub about active work state, GitHub wins. +behavior, the code wins. If this file disagrees with GitHub about active work +state, GitHub wins. If this file disagrees with `story-level-bible.md` or the matching `areas/` doc about future route intent, the active design docs win. @@ -56,32 +53,13 @@ Current runtime characteristics: post-Bridge area state. - Bridge prompts and dialogue are mirrored into a small typed runtime data layer from the accepted Bridge dialogue IDs. -- The folded desk blockout, generated facts, traversal helpers, Cicka Home - mutation code, Trail Cards, and Stampede reward path remain in the repo as - protected prototype/reference assets, but they are not the active Ridge route - presented by the `ridge` scene. -- Stampede remains loadable as its own scene and through development tooling, - but the active Bridge slice does not route to Stampede/Telegraph/Domino. - -## Protected PoC Assets - -The valuable Ridge proof-of-concept investment is the authoring and QA spine, -not the current moment-to-moment traversal model. - -Preserve or adapt: - -- Ridge Blockout Source / source contract. -- compiler and generated spatial facts. -- route, anchor, shortcut, collider, validation, and mutation facts. -- read-only previewer/debugger workflow. -- map-loading path that lets agents and Danilo inspect topology quickly. - -Disposable if a better Ridge emerges: - -- current required-jump traversal feel. -- slope/ramp-first geometry assumptions. -- current folded-desk room arrangement. -- any runtime module whose main job was proving the old platformer-like route. +- The legacy folded-desk blockout stack (source compiler, generated map, + traversal helpers, Cicka Home mutations, trail-card anchors) was removed from + the repo; see git history and [`map-language.md`](./map-language.md) for the + old contract description. +- **Stampede Sketch** remains as a standalone scene and development entry point + (dev switcher, basement `stampede` command). It is not on the active Bridge + route; keep it for optional mini-game content in a future rework. ## Active Runtime Route Read diff --git a/docs/runtime-architecture.md b/docs/runtime-architecture.md index cbd0e42e..605a914e 100644 --- a/docs/runtime-architecture.md +++ b/docs/runtime-architecture.md @@ -43,14 +43,13 @@ game vocabulary is explicit: - **Shared scene runtime** - reusable Phaser-facing machinery lives in `src/game/sharedSceneRuntime`: side-view player lifecycle, camera policy, scene presentation, resume policy, keyboard pause, interior interactions, text, textures, and vision helpers. - **Pure gameplay decisions** - `src/game/core` contains deterministic ECS, input, and player logic that can be tested without Phaser, React, browser globals, or bridge state. - **Scene-owned modules** - scene folders own local layout, triggers, Phaser objects, scene contexts, scene-local overlays, and heavy scene runtime modules. -- **Ridge exploration runtime** - Current Bridge exploration treats +- **Ridge exploration runtime** - Bridge exploration treats `src/game/scenes/ridge/bridge/stageComposition.ts` as the Bridge Stage Composition Source: it owns the Primary Walk Rail, Stage Spots, Stage Plates, Stage Objects, Stage Occluders, camera bounds, and route-beat presentation - predicates consumed by the Ridge scene. The legacy folded desk prototype - still treats `src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts` - as typed authoring source data and imports the committed generated artifact - for blockout facts, traversal helpers, and legacy prototype tests. + predicates consumed by the Ridge scene. The legacy folded-desk blockout stack + was removed from the repo; see [`game-design/ridge/map-language.md`](game-design/ridge/map-language.md) + for the historical contract. ## Scene presentation and camera @@ -77,10 +76,10 @@ re-apply camera bounds/profile math. - Add new Phaser worlds under `src/game/scenes//` with a scene context and, when loadable, a scene registry entry. - Keep scene triggers in the owning scene. A trigger should call `enterScene(sceneId)` or `openOverlay(overlayId, options)` through the bridge callback it receives at scene start. -- Use overlay options for scene-owned overlay params and return intent. Ridge - Trail Cards use this path to show reusable entry cards. Stampede can enter - its movement prototype from the Trail Card, while later Ridge props stay - disabled until their target scenes exist. +- Use overlay options for scene-owned overlay params and return intent. The + shared `trailCard` overlay remains available for future Ridge/area entry + cards. Stampede Sketch is a standalone scene (dev switcher / basement console + today), not wired through the active Bridge tracer route. - Put scene-local overlays under `src/game/scenes//overlays` and export overlay definitions from that scene. Put shared/global overlays under `src/game/overlays`. - Use `src/game/overlays/OverlayHost.tsx` for overlay rendering. Do not add local React overlay maps to shell or scene code. - Use `src/game/sceneUi/SceneUiHost.tsx` for scene-owned React status/panels @@ -108,10 +107,10 @@ re-apply camera bounds/profile math. - Use `sceneResumePolicy` for resume persistence and reset rules. The low-level resume store should not be imported directly by scenes or adapters. - Side-view player scenes should compose `SideViewPlayerRuntime` before creating colliders against `runtime.player`; pass its camera config when the scene should follow and clamp the player. - Interior rooms should describe prop targets and effect commands, then let `InteriorInteractionRuntime` choose the active target and prompt/effect result. Phaser text mutation, bridge writes, and scene-local helpers stay in the scene. -- Ridge spatial changes should start in the typed Ridge blockout source, then flow through the generated blockout artifact, geometry derivation, and compiled fact layer. Keep raw string attributes at the source-contract boundary; callers should consume typed facts or geometry outputs. -- Ridge traversal assists are owned by the Ridge traversal runtime. The current runtime consumes compiled geometry and applies ramp, climb, drop, step-up, mantle, safe-position capture, and fall-recovery decisions around the shared side-view player. -- Ridge landmark presentation owns ordinary landmark visuals such as Stampede blanket, Telegraph, Relay, Domino, guide, and high ledges. Cicka Home mutation visuals are resolved separately from compiled home-mutation facts and durable Ridge progress. -- Cicka Home mutation declarations are data-driven but conservative: active mutations render only when a durable source exists, and future declarations stay typed promises until their progress source is implemented. +- Ridge spatial changes for the active route should start in + `src/game/scenes/ridge/bridge/stageComposition.ts` (Bridge Stage Composition + Source) and the colocated Bridge tracer modules, not the removed blockout + pipeline. ## Folder ownership @@ -157,19 +156,16 @@ Potassium uses focused runtime modules to keep the large arcade scene navigable: - `potassiumSlipEnemyFactory` is the enemy setup seam for kind facts, spawn placement, and body/attachment setup. - `potassiumSlipPhaserData` is the typed data seam for Potassium-specific Phaser object metadata. -Ridge uses focused runtime modules to keep the Exploration Map editable: - -- The source compiler accepts the typed Ridge blockout source and validates - rooms, symbols, anchors, route references, shortcuts, traversal movement - values, and runtime cell overlap. -- The geometry layer turns grid cells, exits, and progress-gated shortcuts into - bounds, collider runs, connector platforms, and assist zones. -- The compiled fact layer exposes room beats, route links, anchors, shortcuts, - and home mutations for presentation and interaction callers. -- Ridge traversal runtime owns forgiving movement assists for the Exploration - Map; opt-in mini-games such as Stampede keep their own movement systems. -- Cicka Home mutation resolution maps compiled home-mutation facts to durable - Ridge progress and keeps unresolved future mutations inactive. +Ridge runtime modules for the active Bridge tracer: + +- `bridge/stageComposition.ts` — Bridge Stage Composition Source (rails, spots, + plates, objects, route-beat presentation). +- `bridge/bridgeTracerSlice.ts` — interaction targets and world constants for + the flat Bridge route. +- `bridge/BridgeTracerStageRuntime.ts` — stage visuals, prompts, dialogue, toy-car test. +- `bridge/BridgeWalkRailPlayerRuntime.ts` — rail-based player movement. +- Opt-in mini-games such as Stampede Sketch keep their own scenes and movement + systems under `src/game/scenes/stampedeSketch/`. ## Manual smoke verification diff --git a/fallow-baselines/dead-code.json b/fallow-baselines/dead-code.json index ceade662..5f641201 100644 --- a/fallow-baselines/dead-code.json +++ b/fallow-baselines/dead-code.json @@ -1,94 +1,15 @@ { - "unused_files": [ - "src/game/scenes/basement/index.ts", - "src/game/scenes/basement/runtime/index.ts", - "src/game/scenes/hobbies/index.ts", - "src/game/scenes/hobbies/runtime/index.ts", - "src/game/scenes/overworld/index.ts", - "src/game/scenes/potassiumSlip/index.ts", - "src/game/scenes/potassiumSlip/runtime/index.ts", - "src/game/scenes/ridge/index.ts", - "src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts", - "src/game/scenes/stampedeSketch/index.ts", - "src/game/scenes/stampedeSketch/runtime/index.ts" - ], + "unused_files": [], "unused_exports": [ - "src/game/bridge/store.ts:INVENTORY_ITEM_IDS", - "src/game/bridge/store.ts:SECRET_DISCOVERY_IDS", - "src/game/overlays/overlayIds.ts:PORTFOLIO_OVERLAY_IDS", - "src/game/overlays/overlayIds.ts:BASEMENT_OVERLAY_IDS", - "src/game/overlays/overlayIds.ts:GLOBAL_OVERLAY_IDS", - "src/game/overlays/overlayIds.ts:INVENTORY_OVERLAY_ID", - "src/game/overlays/overlayIds.ts:TRAIL_CARD_OVERLAY_ID", - "src/game/overlays/overlayIds.ts:MANUAL_PAGE_OVERLAY_ID", - "src/game/overlays/overlayIds.ts:isOverlayId", - "src/game/sceneUi/registry.ts:SCENE_UI_DEFINITIONS", - "src/game/sceneUi/types.ts:SCENE_UI_SURFACE_IDS", - "src/game/scenes/hobbies/roomLayout.ts:HOBBIES_PROXIMITY_ANCHOR_Y", - "src/game/scenes/hobbies/roomLayout.ts:HOBBIES_INTERACT_PROMPT_Y", - "src/game/scenes/potassiumSlip/runtime/boss.ts:POTASSIUM_BOSS_PHASE_3_DRIFT", - "src/game/scenes/potassiumSlip/runtime/combat.ts:POTASSIUM_RECALL_DAMAGE", - "src/game/scenes/potassiumSlip/runtime/combat.ts:POTASSIUM_FIRE_TICK_COOLDOWN_MS", - "src/game/scenes/potassiumSlip/runtime/combat.ts:POTASSIUM_DUPLICATE_CLONE_LIFETIME_MS", - "src/game/scenes/potassiumSlip/runtime/enemyFactory.ts:POTASSIUM_SPLITTER_CHILD_SPEED", - "src/game/scenes/potassiumSlip/runtime/waves.ts:POTASSIUM_ROW_SPAWN_DELAY_MS", - "src/game/scenes/potassiumSlip/runtime/waves.ts:POTASSIUM_MID_GAME_ROW_SPAWN_DELAY_MS", - "src/game/scenes/potassiumSlip/runtime/waves.ts:POTASSIUM_GENERIC_UPGRADES", - "src/game/scenes/potassiumSlip/runtime/waves.ts:getPotassiumDraftPool", - "src/game/scenes/ridge/blockout/geometry.ts:deriveRidgeBlockoutBounds", - "src/game/scenes/ridge/blockout/geometry.ts:findRidgeBlockoutAnchorPoint", - "src/game/scenes/ridge/blockout/geometry.ts:getRidgeBlockoutSpawnPoint", - "src/game/scenes/ridge/blockout/parser.ts:RIDGE_BLOCKOUT_RUNTIME_SYMBOLS", - "src/game/scenes/ridge/blockout/parser.ts:RIDGE_BLOCKOUT_DESIGN_SYMBOLS", - "src/game/scenes/ridge/blockout/parser.ts:validateRidgeBlockout", - "src/game/scenes/ridge/blockout/parser.ts:isRuntimeActiveRidgeBlockoutSymbol", - "src/game/scenes/ridge/blockout/ridgeBlockout.ts:RIDGE_BLOCKOUT_SOURCE", - "src/game/scenes/ridge/blockout/ridgeBlockout.ts:RIDGE_BLOCKOUT_TILE_REGISTRY", - "src/game/scenes/ridge/blockout/ridgeBlockout.ts:RIDGE_BLOCKOUT_RUNTIME_TILE_ROOMS", - "src/game/scenes/ridge/blockout/sourceCompiler.ts:RidgeBlockoutSourceValidationError", - "src/game/scenes/ridge/blockout/sourceCompiler.ts:toRidgeBlockoutMap", - "src/game/scenes/ridge/blockout/sourceCompiler.ts:isRidgeBlockoutSourceTraversalMovement", "src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts:default", - "src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts:FOLDED_DESK_RIDGE_TILE_REGISTRY", "src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts:default", - "src/game/scenes/ridge/bridge/bridgeTracerSlice.ts:BRIDGE_TRACER_CAMERA_BOUNDS", - "src/game/scenes/ridge/cicka/assets.ts:CICKA_ASSET_PATH", - "src/game/scenes/ridge/cicka/assets.ts:CICKA_FRAME_WIDTH", - "src/game/scenes/ridge/cicka/assets.ts:CICKA_FRAME_HEIGHT", - "src/game/scenes/ridge/cicka/walkBy.ts:CICKA_WALK_BY_RADIUS", - "src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts:createRidgeDebugOverlay", - "src/game/scenes/ridge/runtime/ridgeDevControls.ts:RIDGE_DEV_MIN_CAMERA_ZOOM", - "src/game/scenes/ridge/runtime/ridgeDevControls.ts:RIDGE_DEV_MAX_CAMERA_ZOOM", - "src/game/scenes/ridge/runtime/ridgeDevControls.ts:RIDGE_DEV_DEFAULT_DEBUG_SETTINGS", - "src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts:createRidgeLandmarkPresentation", - "src/game/scenes/ridge/runtime/traversalComfort.ts:isPointInsideTraversalZone", - "src/game/scenes/stampedeSketch/runtime/autoAttack.ts:STAMPEDE_AUTO_ATTACK_RANGE", - "src/game/scenes/stampedeSketch/runtime/autoAttack.ts:STAMPEDE_AUTO_ATTACK_MAX_HITS", - "src/game/scenes/stampedeSketch/runtime/movement.ts:STAMPEDE_STICK_DEAD_ZONE", - "src/game/scenes/stampedeSketch/runtime/movement.ts:STAMPEDE_STICK_FULL_DISTANCE", - "src/game/scenes/stampedeSketch/runtime/progression.ts:STAMPEDE_SCRAP_PICKUP_RADIUS", - "src/game/scenes/stampedeSketch/runtime/progression.ts:STAMPEDE_UPGRADE_SCRAP_GOAL", - "src/game/scenes/stampedeSketch/runtime/upgrades.ts:STAMPEDE_UPGRADE_IDS", - "src/game/sharedSceneRuntime/config.ts:HOBBIES_WALK_SPEED", - "src/game/shell/devRidgeProgressParams.ts:DEV_RIDGE_STAMP_IDS", + "src/game/scenes/ridge/cicka/CickaPerch.ts:createCickaPerch", "src/game/shell/index.ts:InteractiveApp", - "src/game/shell/notebookFocusLayout.ts:NOTEBOOK_STAGE_MAX_WIDTH", - "src/shared/i18n/index.ts:enMessages", - "src/shared/i18n/index.ts:getCurrentLocale", - "src/shared/i18n/index.ts:setCurrentLocale", "src/shared/i18n/index.ts:DEFAULT_LOCALE", - "src/shared/i18n/index.ts:LOCALE_QUERY_PARAM", - "src/shared/i18n/index.ts:SUPPORTED_LOCALES", - "src/shared/i18n/index.ts:isLocale", "src/shared/i18n/index.ts:readLocaleFromBrowser", - "src/shared/i18n/index.ts:readStoredLocale", "src/shared/i18n/index.ts:resolveLocale", "src/shared/i18n/index.ts:writeLocaleToBrowserUrl", "src/shared/i18n/index.ts:writeStoredLocale", - "src/shared/i18n/locale.ts:SUPPORTED_LOCALES", - "src/shared/i18n/locale.ts:LOCALE_QUERY_PARAM", - "src/shared/i18n/locale.ts:isLocale", - "src/shared/i18n/locale.ts:readStoredLocale", "src/shared/ui/index.ts:NotebookMenuSheet", "src/shared/ui/index.ts:NotebookHeaderChrome", "src/shared/ui/index.ts:NotebookScrapNote", @@ -96,14 +17,7 @@ "src/shared/ui/index.ts:SceneChoiceCard", "src/shared/ui/index.ts:SceneChoiceGrid", "src/shared/ui/index.ts:ScenePanelSheet", - "src/shared/ui/index.ts:SceneStatusSlip", - "src/shared/ui/index.ts:SectionHeader", - "src/shared/ui/index.ts:notebookShadowRoles", - "src/shared/ui/index.ts:sketchBorders", - "src/shared/ui/index.ts:sketchColors", - "src/shared/ui/index.ts:sketchShadows", - "src/shared/ui/index.ts:cn", - "src/shared/ui/tokens.ts:sketchColors" + "src/shared/ui/index.ts:SceneStatusSlip" ], "unused_types": [], "private_type_leaks": [], @@ -124,11 +38,7 @@ ], "unresolved_imports": [], "unlisted_dependencies": [], - "duplicate_exports": [ - "PlayerStepResult|src/game/core/ecs/systems/playerSystems.ts|src/game/core/player/PlayerController.ts", - "PotassiumTerminalAction|src/game/scenes/potassiumSlip/runtime/renderer.ts|src/game/scenes/potassiumSlip/runtime/session.ts", - "getPotassiumShieldSide|src/game/scenes/potassiumSlip/runtime/phaserData.ts|src/game/scenes/potassiumSlip/runtime/waves.ts" - ], + "duplicate_exports": [], "type_only_dependencies": [], "test_only_dependencies": [], "boundary_violations": [], diff --git a/fallow-baselines/dupes.json b/fallow-baselines/dupes.json index aa2ecb89..e90b5da7 100644 --- a/fallow-baselines/dupes.json +++ b/fallow-baselines/dupes.json @@ -5,8 +5,8 @@ "src/game/overlays/portfolio/contact/ContactOverlay.tsx:53-61|src/static/sections/ContactSection.tsx:26-34", "src/game/overlays/portfolio/experience/ExperienceOverlay.tsx:24-41|src/static/sections/ExperienceSection.tsx:14-31", "src/game/overlays/portfolio/profile/ProfileOverlay.tsx:26-37|src/static/sections/AboutSection.tsx:13-24", - "src/game/sceneLifecycle/SceneLifecycleController.test.ts:126-132|src/game/sceneLifecycle/SceneLifecycleController.test.ts:139-146", - "src/game/sceneLifecycle/SceneLifecycleController.test.ts:157-162|src/game/sceneLifecycle/SceneLifecycleController.test.ts:171-176", + "src/game/sceneLifecycle/SceneLifecycleController.test.ts:133-138|src/game/sceneLifecycle/SceneLifecycleController.test.ts:146-152", + "src/game/sceneLifecycle/SceneLifecycleController.test.ts:163-168|src/game/sceneLifecycle/SceneLifecycleController.test.ts:177-182", "src/game/sceneLifecycle/SceneManager.test.ts:3-8|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:6-11", "src/game/sceneLifecycle/SceneManager.test.ts:15-30|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:33-48", "src/game/sceneLifecycle/SceneManager.test.ts:105-112|src/game/sceneLifecycle/SceneManager.test.ts:45-52", @@ -14,28 +14,19 @@ "src/game/sceneLifecycle/SceneManager.test.ts:66-71|src/game/sceneLifecycle/SceneManager.test.ts:82-87", "src/game/sceneLifecycle/SceneManager.test.ts:128-134|src/game/sceneLifecycle/SceneManager.test.ts:150-156", "src/game/sceneLifecycle/SceneManager.test.ts:128-133|src/game/sceneLifecycle/SceneManager.test.ts:150-155|src/game/sceneLifecycle/SceneManager.test.ts:175-180", - "src/game/sceneLifecycle/SceneManager.ts:45-54|src/game/sceneLifecycle/SceneManager.ts:64-73", - "src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:111-117|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:86-92", - "src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:129-137|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:99-107", + "src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:112-118|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:87-93", + "src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:100-108|src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts:130-138", "src/game/sceneLifecycle/contexts/createSceneContexts.test.ts:114-121|src/game/sceneLifecycle/contexts/createSceneContexts.test.ts:92-99", "src/game/scenes/basement/runtime/BasementScene.ts:53-78|src/game/scenes/hobbies/runtime/HobbiesScene.ts:53-78", "src/game/scenes/basement/runtime/BasementScene.ts:175-188|src/game/scenes/hobbies/runtime/HobbiesScene.ts:169-182", "src/game/scenes/potassiumSlip/runtime/PotassiumSlipScene.ts:507-513|src/game/scenes/potassiumSlip/runtime/PotassiumSlipScene.ts:536-542", - "src/game/scenes/potassiumSlip/runtime/commandAdapter.ts:648-653|src/game/scenes/potassiumSlip/runtime/enemyFactory.ts:237-242", "src/game/scenes/potassiumSlip/runtime/waves.test.ts:175-182|src/game/scenes/potassiumSlip/runtime/waves.test.ts:55-62", "src/game/scenes/ridge/blockout/facts.test.ts:10-20|src/game/scenes/ridge/worldLayout.test.ts:13-23", - "src/game/scenes/ridge/blockout/facts.ts:184-188|src/game/scenes/ridge/blockout/geometry.ts:211-215", - "src/game/scenes/ridge/blockout/parser.ts:118-131|src/game/scenes/ridge/blockout/parser.ts:62-75", - "src/game/scenes/ridge/blockout/parser.ts:318-327|src/game/scenes/ridge/blockout/sourceCompiler.ts:193-202", - "src/game/scenes/ridge/blockout/parser.ts:336-347|src/game/scenes/ridge/blockout/sourceCompiler.ts:220-231", - "src/game/scenes/ridge/blockout/parser.ts:360-366|src/game/scenes/ridge/blockout/sourceCompiler.ts:258-264", - "src/game/scenes/ridge/blockout/parser.ts:368-375|src/game/scenes/ridge/blockout/sourceCompiler.ts:272-279", - "src/game/scenes/ridge/blockout/parser.ts:520-532|src/game/scenes/ridge/blockout/sourceCompiler.ts:293-306", "src/game/scenes/ridge/bridge/BridgeTracerStageRuntime.ts:400-409|src/game/scenes/ridge/bridge/BridgeTracerStageRuntime.ts:440-449", "src/game/scenes/ridge/bridge/BridgeWalkRailPlayerRuntime.ts:81-97|src/game/sharedSceneRuntime/player/SideViewPlayerRuntime.ts:78-94", "src/game/scenes/ridge/bridge/BridgeWalkRailPlayerRuntime.ts:112-122|src/game/sharedSceneRuntime/player/SideViewPlayerRuntime.ts:112-122", "src/game/scenes/ridge/bridge/BridgeWalkRailPlayerRuntime.ts:152-162|src/game/sharedSceneRuntime/player/SideViewPlayerRuntime.ts:143-155", - "src/game/scenes/ridge/bridge/bridgeStageDebugOverlay.ts:138-211|src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts:103-176", + "src/game/scenes/ridge/bridge/bridgeStageDebugOverlay.ts:138-196|src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts:93-151", "src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:263-278|src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:59-74", "src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:226-242|src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:375-391", "src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:301-315|src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts:350-365", diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index befd88bf..3fab1c7d 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -8,11 +8,6 @@ "count": 2 } }, - "scripts/generate-ridge-blockout-sources.mjs": { - "crap_moderate": { - "count": 1 - } - }, "src/App.tsx": { "crap_moderate": { "count": 1 @@ -150,11 +145,6 @@ "count": 3 } }, - "src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts": { - "crap_moderate": { - "count": 3 - } - }, "src/game/scenes/ridge/runtime/ridgeTraversalRuntime.ts": { "crap_moderate": { "count": 1 @@ -209,10 +199,7 @@ }, "runtime_coverage_findings": [], "target_keys": [ - "src/game/scenes/ridge/blockout/sourceCompiler.ts:dead code", - "src/game/overlays/overlayIds.ts:dead code", "src/game/sceneLifecycle/SceneManager.ts:high impact", - "src/game/scenes/ridge/blockout/geometry.ts:dead code", "src/game/scenes/potassiumSlip/runtime/commandAdapter.ts:complexity", "src/game/scenes/overworld/runtime/overworld/overworldInteractionState.ts:complexity" ] diff --git a/package.json b/package.json index eee5b8ae..77947e59 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,12 @@ "dev": "vite", "build": "tsc -b && vite build", "docs:check": "node scripts/check-docs.mjs", - "ridge:source": "node scripts/generate-ridge-blockout-sources.mjs", - "ridge:source:check": "node scripts/generate-ridge-blockout-sources.mjs --check", "lint": "eslint . --cache", "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "typecheck": "tsc -b --noEmit", - "check": "pnpm ridge:source:check && pnpm lint && pnpm typecheck && pnpm fallow:audit && pnpm test", + "check": "pnpm lint && pnpm typecheck && pnpm fallow:audit && pnpm test", "fallow": "fallow", "fallow:health": "fallow health --report-only", "fallow:audit": "fallow audit --changed-since main --fail-on-issues --quiet", diff --git a/scripts/generate-ridge-blockout-sources.mjs b/scripts/generate-ridge-blockout-sources.mjs deleted file mode 100644 index ffd37201..00000000 --- a/scripts/generate-ridge-blockout-sources.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - -const require = createRequire(import.meta.url); -const repoRoot = process.cwd(); -const outDir = path.join(repoRoot, 'node_modules', '.tmp', 'ridge-source-generator'); -const checkOnly = process.argv.includes('--check'); - -const ridgeSources = [ - { - sourcePath: 'src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts', - generatedPath: 'src/game/scenes/ridge/blockout/sources/contract-fixture.generated.ts', - exportName: 'CONTRACT_FIXTURE_RIDGE_COMPILED_BLOCKOUT', - typeImportPath: '../sourceContract' - }, - { - sourcePath: 'src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts', - generatedPath: 'src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts', - exportName: 'FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT', - typeImportPath: '../sourceContract' - } -]; - -export function formatStaleGeneratedFilesMessage(staleFiles) { - return [ - 'Ridge source generated files are stale:', - ...staleFiles.map((filePath) => `- ${filePath}`) - ].join('\n'); -} - -export function assertNoStaleGeneratedFiles(staleFiles) { - if (staleFiles.length === 0) return; - const error = new Error(formatStaleGeneratedFilesMessage(staleFiles)); - error.exitCode = 1; - throw error; -} - -function cleanGeneratedRuntime() { - fs.rmSync(outDir, { recursive: true, force: true }); - fs.mkdirSync(outDir, { recursive: true }); - fs.writeFileSync(path.join(outDir, 'package.json'), '{"type":"commonjs"}\n'); -} - -function emitRuntimeModules(rootNames) { - const program = ts.createProgram({ - rootNames, - options: { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.CommonJS, - moduleResolution: ts.ModuleResolutionKind.Node10, - rootDir: repoRoot, - outDir, - strict: true, - skipLibCheck: true, - esModuleInterop: true, - ignoreDeprecations: '6.0' - } - }); - const diagnostics = ts.getPreEmitDiagnostics(program); - if (diagnostics.length > 0) { - const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { - getCanonicalFileName: (fileName) => fileName, - getCurrentDirectory: () => repoRoot, - getNewLine: () => '\n' - }); - throw new Error(formatted); - } - const result = program.emit(); - if (result.emitSkipped) { - throw new Error('Ridge source generator runtime emit was skipped.'); - } -} - -function compiledModulePath(relativePath) { - return path.join(outDir, relativePath).replace(/\.ts$/, '.js'); -} - -function readSourceModule(sourcePath) { - const compiledPath = compiledModulePath(sourcePath); - const sourceModule = require(compiledPath); - return sourceModule.default; -} - -function runGenerator() { - cleanGeneratedRuntime(); - emitRuntimeModules([ - path.join(repoRoot, 'src/game/scenes/ridge/blockout/sourceCompiler.ts'), - ...ridgeSources.map((source) => path.join(repoRoot, source.sourcePath)) - ]); - - const { - compileRidgeBlockoutSource, - serializeRidgeCompiledBlockout - } = require(compiledModulePath('src/game/scenes/ridge/blockout/sourceCompiler.ts')); - - const staleFiles = []; - - for (const source of ridgeSources) { - const blockoutSource = readSourceModule(source.sourcePath); - const compiled = compileRidgeBlockoutSource(blockoutSource); - const nextContent = serializeRidgeCompiledBlockout({ - exportName: source.exportName, - compiled, - typeImportPath: source.typeImportPath - }); - const generatedPath = path.join(repoRoot, source.generatedPath); - const currentContent = fs.existsSync(generatedPath) - ? fs.readFileSync(generatedPath, 'utf8') - : ''; - - if (currentContent !== nextContent) { - if (checkOnly) { - staleFiles.push(source.generatedPath); - } else { - fs.writeFileSync(generatedPath, nextContent); - console.log(`generated ${source.generatedPath}`); - } - } - } - - assertNoStaleGeneratedFiles(staleFiles); - - if (checkOnly) { - console.log(`Ridge source generated files are current (${ridgeSources.length}).`); - } -} - -function isDirectRun() { - return process.argv[1] !== undefined && - path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); -} - -if (isDirectRun()) { - try { - runGenerator(); - } catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exit(error.exitCode ?? 1); - } -} diff --git a/scripts/generate-ridge-blockout-sources.test.mjs b/scripts/generate-ridge-blockout-sources.test.mjs deleted file mode 100644 index 7b239080..00000000 --- a/scripts/generate-ridge-blockout-sources.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - assertNoStaleGeneratedFiles, - formatStaleGeneratedFilesMessage -} from './generate-ridge-blockout-sources.mjs'; - -describe('generate-ridge-blockout-sources script', () => { - it('formats stale generated source failures with actionable file paths', () => { - expect(formatStaleGeneratedFilesMessage([ - 'src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts' - ])).toBe([ - 'Ridge source generated files are stale:', - '- src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts' - ].join('\n')); - }); - - it('throws for the check-mode stale generation path', () => { - expect(() => assertNoStaleGeneratedFiles([ - 'src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts' - ])).toThrow('Ridge source generated files are stale:'); - }); -}); diff --git a/src/game/bridge/store.ts b/src/game/bridge/store.ts index 0cdf50b1..a880f263 100644 --- a/src/game/bridge/store.ts +++ b/src/game/bridge/store.ts @@ -32,11 +32,9 @@ export interface SceneControlPointerEvent { timestamp: number; } -export const INVENTORY_ITEM_IDS = ['glasses', 'circuit'] as const; -export type InventoryItemId = (typeof INVENTORY_ITEM_IDS)[number]; +export type InventoryItemId = 'glasses' | 'circuit'; -export const SECRET_DISCOVERY_IDS = ['banana-peel-clue'] as const; -export type SecretDiscoveryId = (typeof SECRET_DISCOVERY_IDS)[number]; +export type SecretDiscoveryId = 'banana-peel-clue'; export interface BridgeInventoryState { ownedItemIds: InventoryItemId[]; @@ -261,10 +259,8 @@ function emit(): void { listeners.forEach((listener) => listener()); } -function setState(updater: (current: BridgeState) => BridgeState): void { - const previous = state; - const next = updater(state); - const candidate: BridgeState = { +function projectBridgeState(next: BridgeState): BridgeState { + return { ...next, activeOverlayId: next.activeOverlay?.id ?? null, progress: { @@ -273,7 +269,10 @@ function setState(updater: (current: BridgeState) => BridgeState): void { }, isPaused: next.activeOverlay !== null || next.loadingSceneId !== null }; - const unchanged = +} + +function isBridgeSceneShellUnchanged(previous: BridgeState, candidate: BridgeState): boolean { + return ( previous.activeSceneId === candidate.activeSceneId && overlayRequestsEqual(previous.activeOverlay, candidate.activeOverlay) && previous.activeOverlayId === candidate.activeOverlayId && @@ -282,8 +281,19 @@ function setState(updater: (current: BridgeState) => BridgeState): void { sceneUiSurfacesEqual(previous.sceneUi.status, candidate.sceneUi.status) && sceneUiSurfacesEqual(previous.sceneUi.panel, candidate.sceneUi.panel) && sceneUiActionsEqual(previous.sceneUi.lastAction, candidate.sceneUi.lastAction) && + previous.sceneHintText === candidate.sceneHintText + ); +} + +function isBridgeInventoryUnchanged(previous: BridgeState, candidate: BridgeState): boolean { + return ( arraysEqual(previous.inventory.ownedItemIds, candidate.inventory.ownedItemIds) && - arraysEqual(previous.equipment.equippedItemIds, candidate.equipment.equippedItemIds) && + arraysEqual(previous.equipment.equippedItemIds, candidate.equipment.equippedItemIds) + ); +} + +function isBridgeProgressUnchanged(previous: BridgeState, candidate: BridgeState): boolean { + return ( previous.progress.hasGlasses === candidate.progress.hasGlasses && arraysEqual(previous.progress.discoveredSecretIds, candidate.progress.discoveredSecretIds) && arraysEqual(previous.progress.ridge.stampIds, candidate.progress.ridge.stampIds) && @@ -293,8 +303,12 @@ function setState(updater: (current: BridgeState) => BridgeState): void { previous.progress.ridge.firstPlayableRoute.activeAreaId === candidate.progress.ridge.firstPlayableRoute.activeAreaId && previous.progress.ridge.firstPlayableRoute.bridgeBeat === - candidate.progress.ridge.firstPlayableRoute.bridgeBeat && - previous.sceneHintText === candidate.sceneHintText && + candidate.progress.ridge.firstPlayableRoute.bridgeBeat + ); +} + +function isBridgeTouchInputUnchanged(previous: BridgeState, candidate: BridgeState): boolean { + return ( previous.touch.left === candidate.touch.left && previous.touch.right === candidate.touch.right && previous.touch.up === candidate.touch.up && @@ -304,8 +318,23 @@ function setState(updater: (current: BridgeState) => BridgeState): void { sceneControlPointerEventsEqual( previous.sceneControlPointerEvents, candidate.sceneControlPointerEvents - ); - if (unchanged) return; + ) + ); +} + +function isBridgeStateUnchanged(previous: BridgeState, candidate: BridgeState): boolean { + return ( + isBridgeSceneShellUnchanged(previous, candidate) && + isBridgeInventoryUnchanged(previous, candidate) && + isBridgeProgressUnchanged(previous, candidate) && + isBridgeTouchInputUnchanged(previous, candidate) + ); +} + +function setState(updater: (current: BridgeState) => BridgeState): void { + const previous = state; + const candidate = projectBridgeState(updater(state)); + if (isBridgeStateUnchanged(previous, candidate)) return; state = candidate; emit(); } diff --git a/src/game/overlays/overlayIds.ts b/src/game/overlays/overlayIds.ts index 9c75b027..a5415b14 100644 --- a/src/game/overlays/overlayIds.ts +++ b/src/game/overlays/overlayIds.ts @@ -1,4 +1,4 @@ -export const PORTFOLIO_OVERLAY_IDS = [ +const PORTFOLIO_OVERLAY_IDS = [ 'profile', 'experiences', 'projects', @@ -7,8 +7,8 @@ export const PORTFOLIO_OVERLAY_IDS = [ ] as const; export const HOBBIES_OVERLAY_IDS = ['art', 'music', 'fitness', 'dancing'] as const; -export const BASEMENT_OVERLAY_IDS = ['games'] as const; -export const GLOBAL_OVERLAY_IDS = ['inventory', 'devSwitcher', 'trailCard', 'manualPage'] as const; +const BASEMENT_OVERLAY_IDS = ['games'] as const; +const GLOBAL_OVERLAY_IDS = ['inventory', 'devSwitcher', 'trailCard', 'manualPage'] as const; export const OVERLAY_IDS = [ ...PORTFOLIO_OVERLAY_IDS, @@ -23,12 +23,5 @@ export type BasementOverlayId = (typeof BASEMENT_OVERLAY_IDS)[number]; export type GlobalOverlayId = (typeof GLOBAL_OVERLAY_IDS)[number]; export type OverlayId = (typeof OVERLAY_IDS)[number]; -export const INVENTORY_OVERLAY_ID = 'inventory' satisfies OverlayId; export const DEV_SWITCHER_OVERLAY_ID = 'devSwitcher' satisfies OverlayId; -export const TRAIL_CARD_OVERLAY_ID = 'trailCard' satisfies GlobalOverlayId; -export const MANUAL_PAGE_OVERLAY_ID = 'manualPage' satisfies GlobalOverlayId; export const BASEMENT_CONSOLE_OVERLAY_ID = 'games' satisfies BasementOverlayId; - -export function isOverlayId(id: string | null | undefined): id is OverlayId { - return id !== null && id !== undefined && (OVERLAY_IDS as readonly string[]).includes(id); -} diff --git a/src/game/sceneLifecycle/SceneLifecycleController.test.ts b/src/game/sceneLifecycle/SceneLifecycleController.test.ts index 5be7356d..5589ad6c 100644 --- a/src/game/sceneLifecycle/SceneLifecycleController.test.ts +++ b/src/game/sceneLifecycle/SceneLifecycleController.test.ts @@ -38,6 +38,11 @@ function resetBridge() { bridgeActions.resetTouch(); } +async function settleAsyncTransitions(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + describe('SceneLifecycleController', () => { let controller: SceneLifecycleController; let eventBus: SceneLifecycleEventBus; @@ -50,7 +55,7 @@ describe('SceneLifecycleController', () => { resetBridge(); }); - beforeEach(() => { + beforeEach(async () => { resetBridge(); eventBus = new SceneLifecycleEventBus(); events = []; @@ -70,6 +75,7 @@ describe('SceneLifecycleController', () => { }); controller = new SceneLifecycleController(sceneManager, eventBus); controller.start(); + await settleAsyncTransitions(); }); it('emits PauseChanged on start', () => { @@ -137,7 +143,7 @@ describe('SceneLifecycleController', () => { const enterSpy = vi.spyOn(sceneManager, 'enter'); bridgeActions.enterScene('hobbies'); - await Promise.resolve(); + await settleAsyncTransitions(); const trans = events.find((e) => e.type === 'SceneTransitionRequested'); expect(trans).toBeDefined(); diff --git a/src/game/sceneLifecycle/SceneManager.ts b/src/game/sceneLifecycle/SceneManager.ts index 128b58ac..465fc606 100644 --- a/src/game/sceneLifecycle/SceneManager.ts +++ b/src/game/sceneLifecycle/SceneManager.ts @@ -36,41 +36,23 @@ export class SceneManager { } async enter(contextId: ContextId, guard?: SceneTransitionGuard): Promise { - const target = this.contexts.get(contextId); - if (!target) { - this.clearLoadingIfCurrent(guard); - return; - } + const target = await this.prepareContext(contextId, guard); + if (!target || this.isGuardStale(guard)) return; if (this.adapter.isSceneActive(target.sceneKey)) { this.clearLoadingIfCurrent(guard); return; } - const loadedScene = await this.ensureSceneRegistered(target, guard); - if (guard && !guard.isCurrent()) return; - if (!loadedScene) { - this.clearLoadingIfCurrent(guard); - } this.stopActiveContextsExcept(target.sceneKey); - - this.adapter.startScene(target.sceneKey, target.getStartData()); - target.onEnter?.(); - this.options.onSceneStarted?.(); + if (this.isGuardStale(guard)) return; + this.startContext(target); } async exitTo(contextId: ContextId, guard?: SceneTransitionGuard): Promise { - const target = this.contexts.get(contextId); - if (!target) { - this.clearLoadingIfCurrent(guard); - return; - } + const target = await this.prepareContext(contextId, guard); + if (!target || this.isGuardStale(guard)) return; - const loadedScene = await this.ensureSceneRegistered(target, guard); - if (guard && !guard.isCurrent()) return; - if (!loadedScene) { - this.clearLoadingIfCurrent(guard); - } const allKeys = this.adapter.listKnownSceneKeys(); for (const sceneKey of allKeys) { if (sceneKey === target.sceneKey) continue; @@ -81,10 +63,8 @@ export class SceneManager { context?.onExit?.(resumeSnapshot); } - if (!this.adapter.isSceneActive(target.sceneKey)) { - this.adapter.startScene(target.sceneKey, target.getStartData()); - target.onEnter?.(); - this.options.onSceneStarted?.(); + if (!this.adapter.isSceneActive(target.sceneKey) && !this.isGuardStale(guard)) { + this.startContext(target); } } @@ -112,6 +92,30 @@ export class SceneManager { this.contexts.forEach((context) => context.onDispose?.()); } + private async prepareContext( + contextId: ContextId, + guard?: SceneTransitionGuard + ): Promise { + const target = this.contexts.get(contextId); + if (!target) { + this.clearLoadingIfCurrent(guard); + return null; + } + + const loadedScene = await this.ensureSceneRegistered(target, guard); + if (guard && !guard.isCurrent()) return null; + if (!loadedScene) { + this.clearLoadingIfCurrent(guard); + } + return target; + } + + private startContext(target: SceneContextDefinition): void { + this.adapter.startScene(target.sceneKey, target.getStartData()); + target.onEnter?.(); + this.options.onSceneStarted?.(); + } + private findContextBySceneKey(sceneKey: string): SceneContextDefinition | undefined { for (const context of this.contexts.values()) { if (context.sceneKey === sceneKey) return context; @@ -145,6 +149,10 @@ export class SceneManager { this.options.onSceneLoadingChange?.(null); } + private isGuardStale(guard?: SceneTransitionGuard): boolean { + return guard !== undefined && !guard.isCurrent(); + } + private stopActiveContextsExcept(targetSceneKey: string): void { this.contexts.forEach((context) => { if (context.sceneKey === targetSceneKey) return; diff --git a/src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts b/src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts index 74ca2b47..889b8203 100644 --- a/src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts +++ b/src/game/sceneLifecycle/SceneTransitionCoordinator.test.ts @@ -65,6 +65,7 @@ function context( async function settleAsyncTransition(): Promise { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); } describe('SceneTransitionCoordinator', () => { diff --git a/src/game/sceneUi/registry.ts b/src/game/sceneUi/registry.ts index 5eba89c8..e6b9d2e9 100644 --- a/src/game/sceneUi/registry.ts +++ b/src/game/sceneUi/registry.ts @@ -18,7 +18,7 @@ export interface SceneUiSurfaceDefinition { panelChrome?: 'default' | 'overlay'; } -export const SCENE_UI_DEFINITIONS: readonly SceneUiSurfaceDefinition[] = [ +const SCENE_UI_DEFINITIONS: readonly SceneUiSurfaceDefinition[] = [ ...STAMPEDE_SCENE_UI_DEFINITIONS, ...POTASSIUM_SCENE_UI_DEFINITIONS ]; diff --git a/src/game/sceneUi/types.ts b/src/game/sceneUi/types.ts index 998cc299..b29346b8 100644 --- a/src/game/sceneUi/types.ts +++ b/src/game/sceneUi/types.ts @@ -1,15 +1,12 @@ import type { SceneId } from '@/game/scenes/sceneIds'; -export const SCENE_UI_SURFACE_IDS = [ - 'stampedeStatus', - 'stampedeStartPrompt', - 'stampedeUpgradeDraft', - 'stampedeResult', - 'potassiumUpgradeChoices', - 'potassiumTerminal' -] as const; - -export type SceneUiSurfaceId = (typeof SCENE_UI_SURFACE_IDS)[number]; +export type SceneUiSurfaceId = + | 'stampedeStatus' + | 'stampedeStartPrompt' + | 'stampedeUpgradeDraft' + | 'stampedeResult' + | 'potassiumUpgradeChoices' + | 'potassiumTerminal'; export type SceneUiActionId = | 'start' diff --git a/src/game/scenes/basement/index.ts b/src/game/scenes/basement/index.ts deleted file mode 100644 index 61ed9cdb..00000000 --- a/src/game/scenes/basement/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { BASEMENT_OVERLAY_DEFINITIONS } from './overlayDefinitions'; -export { BASEMENT_ROOM_INTERACTABLES, createBasementInteractionTargets } from './roomLayout'; -export { createBasementSceneContext } from './sceneContext'; -export type { - BasementInteractionEffect, - BasementInteractionTargetDefinition, - BasementInteractionTargetDeps, - BasementRoomInteractableId, - BasementRoomInteractableTypeObject -} from './roomLayout'; diff --git a/src/game/scenes/basement/runtime/index.ts b/src/game/scenes/basement/runtime/index.ts deleted file mode 100644 index af02546e..00000000 --- a/src/game/scenes/basement/runtime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BasementScene } from './BasementScene'; diff --git a/src/game/scenes/hobbies/index.ts b/src/game/scenes/hobbies/index.ts deleted file mode 100644 index d2797c2e..00000000 --- a/src/game/scenes/hobbies/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { HOBBIES_OVERLAY_DEFINITIONS } from './overlayDefinitions'; -export { HOBBIES_ROOM_INTERACTABLES, HOBBY_STATION_LAYOUT } from './roomLayout'; -export { createHobbiesSceneContext } from './sceneContext'; -export type { - HobbiesRoomInteractableId, - HobbiesRoomInteractableTypeObject, - HobbyStationTypeObject -} from './roomLayout'; diff --git a/src/game/scenes/hobbies/roomLayout.ts b/src/game/scenes/hobbies/roomLayout.ts index 6c3a4e96..26fe3fb2 100644 --- a/src/game/scenes/hobbies/roomLayout.ts +++ b/src/game/scenes/hobbies/roomLayout.ts @@ -30,13 +30,13 @@ export type HobbiesRoomInteractableTypeObject = }; /** Proximity anchor Y for hobby stations and exit (player feet region). */ -export const HOBBIES_PROXIMITY_ANCHOR_Y = 450; +const HOBBIES_PROXIMITY_ANCHOR_Y = 450; /** Exit door center X (left side of room — keeps center clear for five hobby stations). */ export const HOBBIES_EXIT_X = 100; /** Shared prompt Y for all room interactables. */ -export const HOBBIES_INTERACT_PROMPT_Y = 350; +const HOBBIES_INTERACT_PROMPT_Y = 350; /** * Hobby stations: sprite X aligns with interaction X. diff --git a/src/game/scenes/hobbies/runtime/index.ts b/src/game/scenes/hobbies/runtime/index.ts deleted file mode 100644 index 761196db..00000000 --- a/src/game/scenes/hobbies/runtime/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { HobbiesScene } from './HobbiesScene'; -export { buildHobbiesRoom } from './HobbiesRoom'; diff --git a/src/game/scenes/overworld/index.ts b/src/game/scenes/overworld/index.ts deleted file mode 100644 index 0f1c37fa..00000000 --- a/src/game/scenes/overworld/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { OVERWORLD_BUILDING_TRIGGERS, getOverworldBuildingTrigger } from './worldLayout'; -export { createOverworldSceneContext } from './sceneContext'; diff --git a/src/game/scenes/overworld/runtime/overworld/overworldInteractionState.ts b/src/game/scenes/overworld/runtime/overworld/overworldInteractionState.ts index ece6e915..9a38002c 100644 --- a/src/game/scenes/overworld/runtime/overworld/overworldInteractionState.ts +++ b/src/game/scenes/overworld/runtime/overworld/overworldInteractionState.ts @@ -67,98 +67,142 @@ export function decideOverworldInteraction( ): OverworldInteractionResult { const nextState = { ...state }; const effects: OverworldInteractionEffect[] = []; + + syncBananaPeelCancellation(nextState, effects, input); + + const basement = resolveBasementInteraction(nextState, effects, input); + if (basement) return basement; + + const peel = resolveBananaPeelInteraction(nextState, effects, input); + if (peel) return peel; + + const building = resolveBuildingInteraction(nextState, effects, input); + if (building) return building; + + return { state: nextState, effects, prompt: { visible: false } }; +} + +function syncBananaPeelCancellation( + state: OverworldInteractionState, + effects: OverworldInteractionEffect[], + input: OverworldInteractionInput +): void { const peelSlot = input.secretSlots[0]; + if (!peelSlot || (!input.bananaWarpScheduled && !state.bananaFirstPeelPending)) return; - if (peelSlot && (input.bananaWarpScheduled || nextState.bananaFirstPeelPending)) { - const distToPeel = Math.hypot(input.playerX - peelSlot.x, input.playerY - peelSlot.y); - if (distToPeel > peelSlot.radius + input.bananaCancelExtraDist) { - nextState.bananaFirstPeelPending = false; - effects.push({ type: 'cancelBananaPeel' }); - } + const distToPeel = Math.hypot(input.playerX - peelSlot.x, input.playerY - peelSlot.y); + if (distToPeel > peelSlot.radius + input.bananaCancelExtraDist) { + state.bananaFirstPeelPending = false; + effects.push({ type: 'cancelBananaPeel' }); } +} - if (isNearBasementHole(input.playerX, input.playerY, input.basementHole)) { - if (input.bananaWarpScheduled || nextState.bananaFirstPeelPending) { - nextState.bananaFirstPeelPending = false; - effects.push({ type: 'cancelBananaPeel' }); - } - if (input.interactRequested) { - effects.push({ type: 'enter', targetId: input.basementSceneId }); - } - return { - state: nextState, - effects, - prompt: { - visible: true, - text: input.texts.basement, - x: input.basementHole.x, - y: input.basementHole.promptY - } - }; +function resolveBasementInteraction( + state: OverworldInteractionState, + effects: OverworldInteractionEffect[], + input: OverworldInteractionInput +): OverworldInteractionResult | null { + if (!isNearBasementHole(input.playerX, input.playerY, input.basementHole)) return null; + + if (input.bananaWarpScheduled || state.bananaFirstPeelPending) { + state.bananaFirstPeelPending = false; + effects.push({ type: 'cancelBananaPeel' }); + } + if (input.interactRequested) { + effects.push({ type: 'enter', targetId: input.basementSceneId }); } + return { + state, + effects, + prompt: { + visible: true, + text: input.texts.basement, + x: input.basementHole.x, + y: input.basementHole.promptY + } + }; +} + +function resolveBananaPeelInteraction( + state: OverworldInteractionState, + effects: OverworldInteractionEffect[], + input: OverworldInteractionInput +): OverworldInteractionResult | null { const secret = pickGlassesSecretTarget( input.playerX, input.playerY, input.hasGlassesEquipped, input.secretSlots ); - const nearPeel = secret.secretId != null && secret.promptX != null && secret.promptY != null; - - if (nearPeel && secret.promptX != null && secret.promptY != null) { - if ( - input.interactRequested && - !nextState.bananaFirstPeelPending && - !input.bananaWarpScheduled - ) { - if (input.bananaDiscovered) { - effects.push({ type: 'enter', targetId: input.potassiumSceneId }); - } else { - nextState.bananaFirstPeelPending = true; - effects.push({ type: 'discoverBananaPeel' }); - } + if (secret.secretId == null || secret.promptX == null || secret.promptY == null) return null; + + if ( + input.interactRequested && + !state.bananaFirstPeelPending && + !input.bananaWarpScheduled + ) { + if (input.bananaDiscovered) { + effects.push({ type: 'enter', targetId: input.potassiumSceneId }); + } else { + state.bananaFirstPeelPending = true; + effects.push({ type: 'discoverBananaPeel' }); } + } + + return { + state, + effects, + prompt: buildBananaPeelPrompt(state, input, secret.promptX, secret.promptY) + }; +} - return { - state: nextState, - effects, - prompt: - input.bananaWarpScheduled || nextState.bananaFirstPeelPending - ? { visible: false } - : { - visible: true, - text: input.bananaDiscovered - ? input.texts.bananaDiscovered - : input.texts.bananaUndiscovered, - x: secret.promptX, - y: secret.promptY - } - }; +function buildBananaPeelPrompt( + state: OverworldInteractionState, + input: OverworldInteractionInput, + promptX: number, + promptY: number +): OverworldInteractionPrompt { + if (input.bananaWarpScheduled || state.bananaFirstPeelPending) { + return { visible: false }; } + return { + visible: true, + text: input.bananaDiscovered ? input.texts.bananaDiscovered : input.texts.bananaUndiscovered, + x: promptX, + y: promptY + }; +} +function resolveBuildingInteraction( + state: OverworldInteractionState, + effects: OverworldInteractionEffect[], + input: OverworldInteractionInput +): OverworldInteractionResult | null { const interact = pickOverworldInteractTarget( input.playerX, input.playerY, input.buildingSlots, input.buildingPickOptions ); - if (interact.buildingId != null && interact.promptX != null && interact.promptY != null) { - if (input.interactRequested) { - effects.push({ type: 'enter', targetId: interact.buildingId }); - } - return { - state: nextState, - effects, - prompt: { - visible: true, - text: input.texts.enter, - x: interact.promptX, - y: interact.promptY - } - }; + if (interact.buildingId == null || interact.promptX == null || interact.promptY == null) { + return null; } - return { state: nextState, effects, prompt: { visible: false } }; + if (input.interactRequested) { + effects.push({ type: 'enter', targetId: interact.buildingId }); + } + + return { + state, + effects, + prompt: { + visible: true, + text: input.texts.enter, + x: interact.promptX, + y: interact.promptY + } + }; } function isNearBasementHole( diff --git a/src/game/scenes/potassiumSlip/index.ts b/src/game/scenes/potassiumSlip/index.ts deleted file mode 100644 index 5327d4d7..00000000 --- a/src/game/scenes/potassiumSlip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createPotassiumSlipSceneContext } from './sceneContext'; diff --git a/src/game/scenes/potassiumSlip/runtime/boss.ts b/src/game/scenes/potassiumSlip/runtime/boss.ts index a76b8c27..e59b21f5 100644 --- a/src/game/scenes/potassiumSlip/runtime/boss.ts +++ b/src/game/scenes/potassiumSlip/runtime/boss.ts @@ -72,7 +72,7 @@ export interface PotassiumBossResult { export const POTASSIUM_BOSS_PATROL_SPEED = 54; export const POTASSIUM_BOSS_PHASE_1_DRIFT = 4; export const POTASSIUM_BOSS_PHASE_2_DRIFT = 6; -export const POTASSIUM_BOSS_PHASE_3_DRIFT = 3; +const POTASSIUM_BOSS_PHASE_3_DRIFT = 3; export const POTASSIUM_BOSS_STONE_DURATION_MS = 1350; export const POTASSIUM_BOSS_STONE_INTERVAL_MS = 4300; export const POTASSIUM_BOSS_SUMMON_INTERVAL_MS = 3600; diff --git a/src/game/scenes/potassiumSlip/runtime/combat.ts b/src/game/scenes/potassiumSlip/runtime/combat.ts index d6e6eb44..380ba171 100644 --- a/src/game/scenes/potassiumSlip/runtime/combat.ts +++ b/src/game/scenes/potassiumSlip/runtime/combat.ts @@ -139,11 +139,11 @@ export type PotassiumCombatCommand = | { type: 'spawnSplitterChildren'; enemyId: string } | { type: 'killEnemy'; enemyId: string }; -export const POTASSIUM_RECALL_DAMAGE = 0.65; -export const POTASSIUM_FIRE_TICK_COOLDOWN_MS = 420; +const POTASSIUM_RECALL_DAMAGE = 0.65; +const POTASSIUM_FIRE_TICK_COOLDOWN_MS = 420; export const POTASSIUM_POISON_TICK_INTERVAL_MS = 500; export const POTASSIUM_POISON_DURATION_MS = 2500; -export const POTASSIUM_DUPLICATE_CLONE_LIFETIME_MS = 3000; +const POTASSIUM_DUPLICATE_CLONE_LIFETIME_MS = 3000; export const POTASSIUM_FIRE_HIT_PATCH_LIFETIME_MS = 900; export const POTASSIUM_GHOST_STATUS_FIELD_LIFETIME_MS = 680; diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts index 8fdc1616..f5457d6c 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts @@ -1,654 +1,90 @@ +import type { PotassiumBossCommand } from './boss'; +import type { PotassiumCombatCommand, PotassiumDamageSource } from './combat'; +import type { PotassiumSessionCommand, PotassiumSessionResult } from './session'; +import { applyBossCommands as applyBossCommandsModule } from './commandAdapterBoss'; import { - POTASSIUM_POISON_DURATION_MS, - POTASSIUM_POISON_TICK_INTERVAL_MS, - resolvePotassiumDamage, - resolvePotassiumExplosion, - resolvePotassiumGhostBeam, - type PotassiumApplyPoisonCommand, - type PotassiumCombatCommand, - type PotassiumDamageSource, - type PotassiumEnemyCombatFacts, - type PotassiumGhostBeamDirection, - type PotassiumProjectileCombatFacts -} from './combat'; -import { getPotassiumEnemyConfig } from './enemyFactory'; + applyCombatCommands as applyCombatCommandsModule, + damageEnemy as damageEnemyModule +} from './commandAdapterCombat'; +import { PotassiumCommandAdapterInternals } from './commandAdapterInternals'; +import { applySessionCommands as applySessionCommandsModule } from './commandAdapterSession'; import type { - PotassiumRunRecord, - PotassiumRunOutcome -} from './leaderboard'; -import { - resolvePotassiumEnemyKilled, - type PotassiumSessionCommand, - type PotassiumSessionResult, - type PotassiumSessionState -} from './session'; -import type { - PotassiumBossCommand, - PotassiumBossFacts, - PotassiumBossSummonFacts -} from './boss'; -import type { - PotassiumDraftChoiceView, - PotassiumHudFacts -} from './session'; -import { - getPotassiumExplosionRadius, - type PotassiumGenericUpgradeKind, - type PotassiumScheduledWaveRow, - type PotassiumSkillRank, - type PotassiumUpgradeKind -} from './waves'; -import { - canPotassiumProjectileApplyHitProcs, - canPotassiumProjectileDuplicate, - getPotassiumCombatId, - getPotassiumData, - getPotassiumEnemyHp, - getPotassiumEnemyKind, - getPotassiumEnemyMaxHp, - getPotassiumProjectileEffectMultiplier, - getPotassiumShieldSide, - isPotassiumEnemyDying, - isPotassiumProjectileRecallVisual, - POTASSIUM_DATA_KEYS, - setPotassiumCombatId, - setPotassiumData, - setPotassiumEnemyDying, - setPotassiumEnemyHealth -} from './phaserData'; - -export interface PotassiumCommandBody { - velocity: { x: number; y: number }; - enable?: boolean; - setAllowGravity?: (allow: boolean) => unknown; - setImmovable?: (immovable: boolean) => unknown; - setSize?: (width: number, height: number, center?: boolean) => unknown; -} - -export interface PotassiumCommandObject { - x: number; - y: number; - angle: number; - active: boolean; - body: PotassiumCommandBody; - getData(key: string): unknown; - setData(key: string, value: unknown): unknown; - setTint(color: number): unknown; - clearTint(): unknown; - setVelocity(x: number, y: number): unknown; - setVelocityX(x: number): unknown; - setVelocityY(y: number): unknown; - setAngularVelocity?(velocity: number): unknown; - setPosition(x: number, y: number): unknown; - setX(x: number): unknown; - setAngle(angle: number): unknown; - setScale(scale: number): unknown; - destroy(): unknown; -} - -export interface PotassiumCombatContext { - enemies: Map; - projectiles: Map; -} - -export interface PotassiumCommandAdapterRuntimePorts { - getNow(): number; - getSession(): PotassiumSessionState; - applySessionResult(result: PotassiumSessionResult): void; - getSkillRank(upgrade: PotassiumUpgradeKind): number; - getGenericRank(upgrade: PotassiumGenericUpgradeKind): number; - setHint(text: string | null): void; - collectCircuit(): void; - saveRunRecord(record: PotassiumRunRecord): void; - closeScene(): void; -} - -export interface PotassiumCommandAdapterObjectPorts { - getEnemies(): PotassiumCommandObject[]; - getProjectiles(): PotassiumCommandObject[]; - getMainProjectile(): PotassiumCommandObject; - isMainProjectile(projectile: PotassiumCommandObject): boolean; - isMainProjectileRecalling(): boolean; - getProjectileExplosionRadiusMultiplier(projectile: PotassiumCommandObject): number; - getMaxMainProjectileSpeed(): number; - getExplosionRadiusMultiplier(): number; - getExplosionHits(x: number, y: number): Array<{ enemy: PotassiumCommandObject; distance: number }>; - getGhostBeamHits( - x: number, - y: number, - direction: PotassiumGhostBeamDirection - ): Array<{ enemy: PotassiumCommandObject; inBeam: boolean }>; -} - -export interface PotassiumCommandAdapterBoardPorts { - resetBoardObjects(): void; - spawnWave(wave: number): void; - spawnBossDelayed(): void; - scheduleWaveRows(schedule: readonly PotassiumScheduledWaveRow[]): void; - scheduleUpgradeChoices(): void; - advanceWaveAfterDelay(wave: number): void; - stopMainProjectile(): void; - clearBoardForOutcome(): void; - spawnFirePatch(x: number, y: number, effectMultiplier: number, lifetimeMs: number, scale: number): void; - spawnBananaClones(count: number, lifetimeMs: number): void; - spawnSplitterChildren(enemy: PotassiumCommandObject): void; - spawnBossOrbitBlockers(boss: PotassiumCommandObject): void; - updateBossOrbitBlockers(boss: PotassiumCommandObject, time: number): void; - setBossStoneVisual(boss: PotassiumCommandObject, active: boolean): void; - spawnBossSummons(summons: readonly PotassiumBossSummonFacts[]): void; - clearOrbitBlockers(): void; -} - -export interface PotassiumCommandAdapterRendererPorts { - hideMainOverlay(): void; - clearTerminalOverlay(): void; - clearUpgradeChoiceOverlay(): void; - showUpgradeChoices(choices: readonly PotassiumDraftChoiceView[]): void; - refreshAllProjectileVisuals(): void; - updateHud(hud: PotassiumHudFacts): void; - showOutcomeOverlay(input: { title: string; score: number; titleFontSize: number }): void; - showTerminal(outcome: PotassiumRunOutcome): void; - showDamageCue(enemy: PotassiumCommandObject, source: PotassiumDamageSource): void; - showExplosionVisual(x: number, y: number, radius: number): void; - shakeCamera(durationMs: number, intensity: number): void; - showGhostBeam(input: { - x: number; - y: number; - direction: PotassiumGhostBeamDirection; - durationMs: number; - }): void; - showGhostStatusField(input: { - x: number; - y: number; - direction: PotassiumGhostBeamDirection; - poisonActive: boolean; - durationMs: number; - }): void; - animateEnemyDeath(enemy: PotassiumCommandObject, onComplete: () => void): void; -} - -export interface PotassiumCommandAdapterPorts { - runtime: PotassiumCommandAdapterRuntimePorts; - objects: PotassiumCommandAdapterObjectPorts; - board: PotassiumCommandAdapterBoardPorts; - renderer: PotassiumCommandAdapterRendererPorts; -} - -interface PotassiumCommandAdapterFlatPorts - extends PotassiumCommandAdapterRuntimePorts, - PotassiumCommandAdapterObjectPorts, - PotassiumCommandAdapterBoardPorts, - PotassiumCommandAdapterRendererPorts {} - -export interface PotassiumCommandAdapterOptions { - arena: { - left: number; - right: number; - top: number; - bottom: number; - }; - poisonTint: number; - cloneRicochetMaxSpeed: number; - bananaRicochetMinSpeed: number; - bananaRicochetBoost: number; - poisonDeathSpreadRadius: number; - ghostStatusFieldLifetimeMs: number; - ghostBeamLifetimeMs: number; -} + PotassiumCombatContext, + PotassiumCommandAdapterOptions, + PotassiumCommandAdapterPorts, + PotassiumCommandObject +} from './commandAdapterTypes'; + +export type { + PotassiumCombatContext, + PotassiumCommandAdapterBoardPorts, + PotassiumCommandAdapterObjectPorts, + PotassiumCommandAdapterOptions, + PotassiumCommandAdapterPorts, + PotassiumCommandAdapterRendererPorts, + PotassiumCommandAdapterRuntimePorts, + PotassiumCommandBody, + PotassiumCommandObject +} from './commandAdapterTypes'; export class PotassiumCommandAdapter { - private readonly ports: PotassiumCommandAdapterFlatPorts; - private readonly options: PotassiumCommandAdapterOptions; - private combatIdCounter = 0; + private readonly internals: PotassiumCommandAdapterInternals; constructor(ports: PotassiumCommandAdapterPorts, options: PotassiumCommandAdapterOptions) { - this.ports = { - ...ports.runtime, - ...ports.objects, - ...ports.board, - ...ports.renderer + this.internals = new PotassiumCommandAdapterInternals(ports, options); + this.internals.delegates = { + applySessionResult: (result) => this.applySessionResult(result), + applyCombatCommands: (commands, context) => this.applyCombatCommands(commands, context) }; - this.options = options; } resetCombatIds(): void { - this.combatIdCounter = 0; + this.internals.resetCombatIds(); } applySessionResult(result: PotassiumSessionResult): void { - this.ports.applySessionResult({ + this.internals.ports.applySessionResult({ state: result.state, commands: [] }); - this.applySessionCommands(result.commands); + applySessionCommandsModule(this.internals, result.commands); } applySessionCommands(commands: readonly PotassiumSessionCommand[]): void { - commands.forEach((command) => { - if (command.type === 'resetBoard') { - this.ports.resetBoardObjects(); - } else if (command.type === 'hideMainOverlay') { - this.ports.hideMainOverlay(); - } else if (command.type === 'clearTerminal') { - this.ports.clearTerminalOverlay(); - } else if (command.type === 'clearUpgradeChoices') { - this.ports.clearUpgradeChoiceOverlay(); - } else if (command.type === 'setHint') { - this.ports.setHint(command.text); - } else if (command.type === 'spawnWave') { - this.ports.spawnWave(command.wave); - } else if (command.type === 'spawnBoss') { - this.ports.spawnBossDelayed(); - } else if (command.type === 'scheduleWaveRows') { - this.ports.scheduleWaveRows(command.schedule); - } else if (command.type === 'scheduleUpgradeChoices') { - this.ports.scheduleUpgradeChoices(); - } else if (command.type === 'showUpgradeChoices') { - this.ports.showUpgradeChoices(command.choices); - } else if (command.type === 'advanceWaveAfterDelay') { - this.ports.advanceWaveAfterDelay(command.wave); - } else if (command.type === 'refreshProjectileVisuals') { - this.ports.refreshAllProjectileVisuals(); - } else if (command.type === 'updateHud') { - this.ports.updateHud(command.hud); - } else if (command.type === 'collectCircuit') { - this.ports.collectCircuit(); - } else if (command.type === 'saveRunRecord') { - this.ports.saveRunRecord(command.record); - } else if (command.type === 'showOutcome') { - this.ports.showOutcomeOverlay(command); - } else if (command.type === 'showTerminal') { - this.ports.showTerminal(command.outcome); - } else if (command.type === 'closeScene') { - this.ports.closeScene(); - } else if (command.type === 'stopBanana') { - this.ports.stopMainProjectile(); - } else if (command.type === 'clearBoardForOutcome') { - this.ports.clearBoardForOutcome(); - } - }); + applySessionCommandsModule(this.internals, commands); } - getEnemyCombatFacts(enemy: PotassiumCommandObject): PotassiumEnemyCombatFacts { - return { - id: this.getCombatId(enemy, 'enemy'), - kind: getPotassiumEnemyKind(enemy), - active: enemy.active, - dying: isPotassiumEnemyDying(enemy), - hp: getPotassiumEnemyHp(enemy), - maxHp: getPotassiumEnemyMaxHp(enemy), - x: enemy.x, - y: enemy.y, - indestructible: Boolean(getPotassiumData(enemy, POTASSIUM_DATA_KEYS.indestructible)), - splitsOnDeath: Boolean(getPotassiumData(enemy, POTASSIUM_DATA_KEYS.splitsOnDeath)), - poisonExpiresAt: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt), - poisonMultiplier: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier), - poisonNextTickAt: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt), - fireTickUntil: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil), - shieldSide: getPotassiumShieldSide(enemy), - stoneUntil: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.stoneUntil) - }; + getEnemyCombatFacts(enemy: PotassiumCommandObject) { + return this.internals.getEnemyCombatFacts(enemy); } - getProjectileCombatFacts(projectile: PotassiumCommandObject): PotassiumProjectileCombatFacts { - return { - id: this.getCombatId(projectile, this.ports.isMainProjectile(projectile) ? 'main' : 'projectile'), - isMain: this.ports.isMainProjectile(projectile), - isRecall: this.ports.isMainProjectile(projectile) - && (this.ports.isMainProjectileRecalling() || isPotassiumProjectileRecallVisual(projectile)), - x: projectile.x, - y: projectile.y, - effectMultiplier: getPotassiumProjectileEffectMultiplier(projectile), - canApplyHitProcs: canPotassiumProjectileApplyHitProcs(projectile), - canDuplicate: canPotassiumProjectileDuplicate(projectile), - explosionRadiusMultiplier: this.ports.getProjectileExplosionRadiusMultiplier(projectile) - }; + getProjectileCombatFacts(projectile: PotassiumCommandObject) { + return this.internals.getProjectileCombatFacts(projectile); } - getBossFacts(boss: PotassiumCommandObject): PotassiumBossFacts { - return { - active: boss.active, - dying: isPotassiumEnemyDying(boss), - hp: getPotassiumEnemyHp(boss), - maxHp: getPotassiumEnemyMaxHp(boss), - x: boss.x, - y: boss.y, - velocityX: boss.body.velocity.x - }; + getBossFacts(boss: PotassiumCommandObject) { + return this.internals.getBossFacts(boss); } createCombatContext( - enemies: PotassiumCommandObject[] = this.ports.getEnemies(), - projectiles: PotassiumCommandObject[] = this.ports.getProjectiles() + enemies?: PotassiumCommandObject[], + projectiles?: PotassiumCommandObject[] ): PotassiumCombatContext { - return { - enemies: new Map(enemies.map((enemy) => [this.getCombatId(enemy, 'enemy'), enemy])), - projectiles: new Map(projectiles.map((projectile) => [ - this.getCombatId(projectile, this.ports.isMainProjectile(projectile) ? 'main' : 'projectile'), - projectile - ])) - }; + return this.internals.createCombatContext(enemies, projectiles); } applyCombatCommands( commands: readonly PotassiumCombatCommand[], - context = this.createCombatContext() + context?: PotassiumCombatContext ): void { - commands.forEach((command) => { - const enemy = 'enemyId' in command ? context.enemies.get(command.enemyId) : undefined; - const projectile = 'projectileId' in command ? context.projectiles.get(command.projectileId) : undefined; - if (command.type === 'damageEnemy') { - if (enemy) this.damageEnemy(enemy, command.amount, command.source); - } else if (command.type === 'setEnemyHp') { - if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); - } else if (command.type === 'setFireTickUntil') { - if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil, command.fireTickUntil); - } else if (command.type === 'setPoisonNextTickAt') { - if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.poisonNextTickAt); - } else if (command.type === 'showDamageCue') { - if (enemy) this.ports.showDamageCue(enemy, command.source); - } else if (command.type === 'ricochetProjectile') { - if (projectile && enemy) this.ricochetProjectileFromEnemy(projectile, enemy); - } else if (command.type === 'boostRecallVelocity') { - projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); - } else if (command.type === 'applyPoison') { - if (enemy) this.applyPoisonCommand(enemy, command); - } else if (command.type === 'clearPoison') { - if (enemy) this.clearPoison(enemy); - } else if (command.type === 'spawnFirePatch') { - this.ports.spawnFirePatch(command.x, command.y, command.effectMultiplier, command.lifetimeMs, command.scale); - } else if (command.type === 'explodeAt') { - this.explodeAt(command.x, command.y, command.effectMultiplier, command.radiusMultiplier); - } else if (command.type === 'spawnBananaClones') { - this.ports.spawnBananaClones(command.count, command.lifetimeMs); - } else if (command.type === 'spawnGhostBeam') { - this.spawnGhostBeam(command.x, command.y, command.direction, command.effectMultiplier); - } else if (command.type === 'spawnGhostStatusField') { - this.spawnGhostStatusField(command.x, command.y, command.direction, command.effectMultiplier); - } else if (command.type === 'spreadPoisonFrom') { - this.spreadPoisonFrom(command.x, command.y, command.effectMultiplier, context.enemies.get(command.sourceEnemyId)); - } else if (command.type === 'spawnSplitterChildren') { - if (enemy) this.ports.spawnSplitterChildren(enemy); - } else if (command.type === 'killEnemy') { - if (enemy) this.killEnemy(enemy); - } - }); + applyCombatCommandsModule(this.internals, commands, context); } damageEnemy(enemy: PotassiumCommandObject, amount: number, source: PotassiumDamageSource): void { - this.applyCombatCommands(resolvePotassiumDamage({ - now: this.ports.getNow(), - enemy: this.getEnemyCombatFacts(enemy), - amount, - source, - skillRanks: this.ports.getSession().skillRanks, - genericRanks: this.ports.getSession().genericRanks - }), this.createCombatContext()); + damageEnemyModule(this.internals, enemy, amount, source); } applyBossCommands(commands: readonly PotassiumBossCommand[], boss?: PotassiumCommandObject): void { - commands.forEach((command) => { - if (command.type === 'setBossPhase') { - if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.bossPhase, command.phase); - } else if (command.type === 'setBossVelocity') { - if (!boss) return; - if (command.x !== undefined) boss.setX(command.x); - if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); - boss.setVelocityY(command.velocityY); - } else if (command.type === 'setBossHint') { - this.ports.setHint(command.text); - } else if (command.type === 'shakeCamera') { - this.ports.shakeCamera(command.durationMs, command.intensity); - } else if (command.type === 'spawnOrbitBlockers') { - if (boss) this.ports.spawnBossOrbitBlockers(boss); - } else if (command.type === 'updateOrbitBlockers') { - if (boss) this.ports.updateBossOrbitBlockers(boss, command.now); - } else if (command.type === 'startStone') { - if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, command.stoneUntil); - } else if (command.type === 'endStone') { - if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, undefined); - } else if (command.type === 'setStoneVisual') { - if (boss) this.ports.setBossStoneVisual(boss, command.active); - } else if (command.type === 'spawnSummons') { - this.ports.spawnBossSummons(command.summons); - } else if (command.type === 'clearOrbitBlockers') { - this.ports.clearOrbitBlockers(); - } - }); - } - - private getCombatId(object: PotassiumCommandObject, prefix: string): string { - const existingId = getPotassiumCombatId(object); - if (existingId) return existingId; - const id = `${prefix}-${this.combatIdCounter}`; - this.combatIdCounter += 1; - setPotassiumCombatId(object, id); - return id; - } - - private ricochetProjectileFromEnemy(projectile: PotassiumCommandObject, enemy: PotassiumCommandObject): void { - if (!projectile.active || !projectile.body) return; - - const velocity = { x: projectile.body.velocity.x, y: projectile.body.velocity.y }; - const currentSpeed = vectorLength(velocity); - let normal = { x: projectile.x - enemy.x, y: projectile.y - enemy.y }; - if (vectorLengthSquared(normal) <= 0.001) { - normal = vectorLengthSquared(velocity) > 0.001 ? velocity : { x: 0, y: 1 }; - } - normal = normalize(normal); - - let ricochet = { ...velocity }; - const dot = dotProduct(ricochet, normal); - if (dot < 0) { - ricochet = { - x: ricochet.x - normal.x * (2 * dot), - y: ricochet.y - normal.y * (2 * dot) - }; - } else { - ricochet = { ...normal }; - } - if (vectorLengthSquared(ricochet) <= 0.001) { - ricochet = { ...normal }; - } - - const maxSpeed = this.ports.isMainProjectile(projectile) - ? this.ports.getMaxMainProjectileSpeed() - : this.options.cloneRicochetMaxSpeed; - const speed = clamp( - Math.max(currentSpeed * this.options.bananaRicochetBoost, this.options.bananaRicochetMinSpeed), - this.options.bananaRicochetMinSpeed, - maxSpeed - ); - const ricochetNormal = normalize(ricochet); - ricochet = { - x: ricochetNormal.x * speed, - y: ricochetNormal.y * speed - }; - - projectile.setPosition( - clamp(projectile.x + normal.x * 8, this.options.arena.left + 28, this.options.arena.right - 28), - clamp(projectile.y + normal.y * 8, this.options.arena.top + 28, this.options.arena.bottom - 28) - ); - projectile.setVelocity(ricochet.x, ricochet.y); - if ('setAngularVelocity' in projectile && typeof projectile.setAngularVelocity === 'function') { - projectile.setAngularVelocity(clamp(ricochet.x * 1.4, -720, 720)); - } - } - - private applyPoisonCommand(enemy: PotassiumCommandObject, command: PotassiumApplyPoisonCommand): void { - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt, command.expiresAt); - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisoned, true); - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier, command.poisonMultiplier); - enemy.setTint(this.options.poisonTint); - if (command.nextTickAt !== undefined) { - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.nextTickAt); - } + applyBossCommandsModule(this.internals, commands, boss); } - - private spreadPoisonFrom( - x: number, - y: number, - effectMultiplier: number, - sourceEnemy?: PotassiumCommandObject - ): void { - this.ports.getEnemies().forEach((enemy) => { - if (enemy === sourceEnemy || !enemy.active || isPotassiumEnemyDying(enemy)) return; - if (distance({ x, y }, enemy) <= this.options.poisonDeathSpreadRadius) { - this.applyPoisonCommand(enemy, { - type: 'applyPoison', - enemyId: this.getCombatId(enemy, 'enemy'), - effectMultiplier, - poisonMultiplier: Math.max( - getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier) ?? 0, - effectMultiplier - ), - expiresAt: Math.max( - getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt) ?? 0, - this.ports.getNow() + POTASSIUM_POISON_DURATION_MS * effectMultiplier - ), - nextTickAt: (getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt) ?? 0) < this.ports.getNow() - ? this.ports.getNow() + POTASSIUM_POISON_TICK_INTERVAL_MS - : undefined - }); - } - }); - } - - private clearPoison(enemy: PotassiumCommandObject): void { - if (!enemy.active) return; - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt, undefined); - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, undefined); - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier, undefined); - setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisoned, false); - if (getPotassiumData(enemy, POTASSIUM_DATA_KEYS.stoneActive)) { - enemy.setTint(0x78716c); - } else { - enemy.clearTint(); - } - } - - private killEnemy(enemy: PotassiumCommandObject): void { - const kind = getPotassiumEnemyKind(enemy); - const config = getPotassiumEnemyConfig(kind); - setPotassiumEnemyDying(enemy, true); - const damageCueTween = getPotassiumData<{ stop?: () => void }>(enemy, POTASSIUM_DATA_KEYS.damageCueTween); - damageCueTween?.stop?.(); - enemy.body.enable = false; - const killResult = resolvePotassiumEnemyKilled(this.ports.getSession(), config.score, kind); - if (kind !== 'boss') { - this.applySessionResult(killResult); - } - - this.ports.animateEnemyDeath(enemy, () => { - enemy.destroy(); - if (kind === 'boss') { - this.applySessionResult(killResult); - } - }); - } - - private explodeAt(x: number, y: number, effectMultiplier: number, radiusMultiplier: number): void { - const rank = this.ports.getSkillRank('explosion'); - const radius = getPotassiumExplosionRadius(rank as PotassiumSkillRank) * radiusMultiplier * this.ports.getExplosionRadiusMultiplier(); - this.ports.showExplosionVisual(x, y, radius); - const hits = this.ports.getExplosionHits(x, y); - this.applyCombatCommands(resolvePotassiumExplosion({ - now: this.ports.getNow(), - x, - y, - effectMultiplier, - radiusMultiplier, - hits: hits.map((hit) => ({ - enemy: this.getEnemyCombatFacts(hit.enemy), - distance: hit.distance - })), - skillRanks: this.ports.getSession().skillRanks, - genericRanks: this.ports.getSession().genericRanks - }), this.createCombatContext(hits.map((hit) => hit.enemy))); - this.ports.shakeCamera(130, 0.006); - } - - private spawnGhostBeam( - x: number, - y: number, - direction: PotassiumGhostBeamDirection, - effectMultiplier: number - ): void { - this.ports.showGhostBeam({ x, y, direction, durationMs: this.options.ghostBeamLifetimeMs }); - const hits = this.ports.getGhostBeamHits(x, y, direction); - this.applyCombatCommands(resolvePotassiumGhostBeam({ - now: this.ports.getNow(), - x, - y, - direction, - effectMultiplier, - hits: hits.map((hit) => ({ - enemy: this.getEnemyCombatFacts(hit.enemy), - inBeam: hit.inBeam - })), - skillRanks: this.ports.getSession().skillRanks, - genericRanks: this.ports.getSession().genericRanks - }), this.createCombatContext(hits.map((hit) => hit.enemy))); - } - - private spawnGhostStatusField( - x: number, - y: number, - direction: PotassiumGhostBeamDirection, - effectMultiplier: number - ): void { - const isHorizontal = direction === 'horizontal'; - if (this.ports.getSkillRank('fire') > 0) { - const patchCount = isHorizontal ? 5 : 7; - for (let index = 0; index < patchCount; index += 1) { - const t = patchCount <= 1 ? 0.5 : index / (patchCount - 1); - const patchX = isHorizontal - ? linear(this.options.arena.left + 46, this.options.arena.right - 46, t) - : x; - const patchY = isHorizontal - ? y - : linear(this.options.arena.top + 112, this.options.arena.bottom - 92, t); - this.ports.spawnFirePatch(patchX, patchY, effectMultiplier, this.options.ghostStatusFieldLifetimeMs, 0.46); - } - } - if (this.ports.getSkillRank('poison') > 0) { - this.ports.showGhostStatusField({ - x, - y, - direction, - poisonActive: true, - durationMs: this.options.ghostStatusFieldLifetimeMs - }); - } - } -} - -function vectorLength(point: { x: number; y: number }): number { - return Math.sqrt(vectorLengthSquared(point)); -} - -function vectorLengthSquared(point: { x: number; y: number }): number { - return point.x * point.x + point.y * point.y; -} - -function normalize(point: { x: number; y: number }): { x: number; y: number } { - const length = vectorLength(point); - if (length <= 0) return { x: 0, y: 0 }; - return { x: point.x / length, y: point.y / length }; -} - -function dotProduct(a: { x: number; y: number }, b: { x: number; y: number }): number { - return a.x * b.x + a.y * b.y; -} - -function distance(a: { x: number; y: number }, b: { x: number; y: number }): number { - return vectorLength({ x: a.x - b.x, y: a.y - b.y }); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function linear(from: number, to: number, t: number): number { - return from + (to - from) * t; } diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts new file mode 100644 index 00000000..b3010a25 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts @@ -0,0 +1,95 @@ +import type { PotassiumBossCommand } from './boss'; +import { POTASSIUM_DATA_KEYS, setPotassiumData } from './phaserData'; +import type { PotassiumCommandAdapterInternals } from './commandAdapterInternals'; +import type { PotassiumCommandObject } from './commandAdapterTypes'; + +export function applyBossCommands( + internals: PotassiumCommandAdapterInternals, + commands: readonly PotassiumBossCommand[], + boss?: PotassiumCommandObject +): void { + commands.forEach((command) => applyBossCommand(internals, command, boss)); +} + +function applyBossCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumBossCommand, + boss?: PotassiumCommandObject +): void { + if (applyBossMotionCommand(command, boss)) return; + if (applyBossPresentationCommand(internals, command, boss)) return; + applyBossSpawnCommand(internals, command, boss); +} + +function applyBossMotionCommand(command: PotassiumBossCommand, boss?: PotassiumCommandObject): boolean { + switch (command.type) { + case 'setBossPhase': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.bossPhase, command.phase); + return true; + case 'setBossVelocity': + if (boss) applyBossVelocity(boss, command); + return true; + case 'startStone': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, command.stoneUntil); + return true; + case 'endStone': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, undefined); + return true; + default: + return false; + } +} + +function applyBossVelocity( + boss: PotassiumCommandObject, + command: Extract +): void { + if (command.x !== undefined) boss.setX(command.x); + if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); + boss.setVelocityY(command.velocityY); +} + +function applyBossPresentationCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumBossCommand, + boss?: PotassiumCommandObject +): boolean { + const { ports } = internals; + switch (command.type) { + case 'setBossHint': + ports.setHint(command.text); + return true; + case 'shakeCamera': + ports.shakeCamera(command.durationMs, command.intensity); + return true; + case 'setStoneVisual': + if (boss) ports.setBossStoneVisual(boss, command.active); + return true; + case 'clearOrbitBlockers': + ports.clearOrbitBlockers(); + return true; + default: + return false; + } +} + +function applyBossSpawnCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumBossCommand, + boss?: PotassiumCommandObject +): void { + const { ports } = internals; + switch (command.type) { + case 'spawnOrbitBlockers': + if (boss) ports.spawnBossOrbitBlockers(boss); + break; + case 'updateOrbitBlockers': + if (boss) ports.updateBossOrbitBlockers(boss, command.now); + break; + case 'spawnSummons': + ports.spawnBossSummons(command.summons); + break; + default: + break; + } +} diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts new file mode 100644 index 00000000..ab59f206 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts @@ -0,0 +1,465 @@ +import { + POTASSIUM_POISON_DURATION_MS, + POTASSIUM_POISON_TICK_INTERVAL_MS, + resolvePotassiumDamage, + resolvePotassiumExplosion, + resolvePotassiumGhostBeam, + type PotassiumApplyPoisonCommand, + type PotassiumCombatCommand, + type PotassiumDamageSource, + type PotassiumGhostBeamDirection +} from './combat'; +import { getPotassiumEnemyConfig } from './enemyFactory'; +import { resolvePotassiumEnemyKilled } from './session'; +import { clamp, linear } from '@/shared/math'; +import { + getPotassiumExplosionRadius, + type PotassiumSkillRank +} from './waves'; +import { + getPotassiumData, + getPotassiumEnemyKind, + isPotassiumEnemyDying, + POTASSIUM_DATA_KEYS, + setPotassiumData, + setPotassiumEnemyDying, + setPotassiumEnemyHealth +} from './phaserData'; +import type { PotassiumCommandAdapterInternals } from './commandAdapterInternals'; +import type { PotassiumCombatContext, PotassiumCommandObject } from './commandAdapterTypes'; + +export function applyCombatCommands( + internals: PotassiumCommandAdapterInternals, + commands: readonly PotassiumCombatCommand[], + context = internals.createCombatContext() +): void { + commands.forEach((command) => applyCombatCommand(internals, command, context)); +} + +export function damageEnemy( + internals: PotassiumCommandAdapterInternals, + enemy: PotassiumCommandObject, + amount: number, + source: PotassiumDamageSource +): void { + applyCombatCommands(internals, resolvePotassiumDamage({ + now: internals.ports.getNow(), + enemy: internals.getEnemyCombatFacts(enemy), + amount, + source, + skillRanks: internals.ports.getSession().skillRanks, + genericRanks: internals.ports.getSession().genericRanks + }), internals.createCombatContext()); +} + +function applyCombatCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + context: PotassiumCombatContext +): void { + const enemy = 'enemyId' in command ? context.enemies.get(command.enemyId) : undefined; + const projectile = 'projectileId' in command + ? context.projectiles.get(command.projectileId) + : undefined; + + if (applyCombatEnemyCommand(internals, command, enemy)) return; + if (applyCombatProjectileCommand(internals, command, projectile, enemy)) return; + applyCombatWorldCommand(internals, command, context, enemy); +} + +function applyCombatEnemyCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined +): boolean { + if (applyCombatEnemyDamageCommand(internals, command, enemy)) return true; + if (applyCombatEnemyStatusCommand(internals, command, enemy)) return true; + return applyCombatEnemyLifecycleCommand(internals, command, enemy); +} + +function applyCombatEnemyDamageCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined +): boolean { + switch (command.type) { + case 'damageEnemy': + if (enemy) damageEnemy(internals, enemy, command.amount, command.source); + return true; + case 'setEnemyHp': + if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); + return true; + case 'showDamageCue': + if (enemy) internals.ports.showDamageCue(enemy, command.source); + return true; + default: + return false; + } +} + +function applyCombatEnemyStatusCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined +): boolean { + switch (command.type) { + case 'setFireTickUntil': + if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil, command.fireTickUntil); + return true; + case 'setPoisonNextTickAt': + if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.poisonNextTickAt); + return true; + case 'applyPoison': + if (enemy) applyPoisonCommand(internals, enemy, command); + return true; + case 'clearPoison': + if (enemy) clearPoison(enemy); + return true; + default: + return false; + } +} + +function applyCombatEnemyLifecycleCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined +): boolean { + switch (command.type) { + case 'spawnSplitterChildren': + if (enemy) internals.ports.spawnSplitterChildren(enemy); + return true; + case 'killEnemy': + if (enemy) killEnemy(internals, enemy); + return true; + default: + return false; + } +} + +function applyCombatProjectileCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + projectile: PotassiumCommandObject | undefined, + enemy: PotassiumCommandObject | undefined +): boolean { + switch (command.type) { + case 'ricochetProjectile': + if (projectile && enemy) ricochetProjectileFromEnemy(internals, projectile, enemy); + return true; + case 'boostRecallVelocity': + if (projectile?.body) { + projectile.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); + } + return true; + default: + return false; + } +} + +function applyCombatWorldCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumCombatCommand, + context: PotassiumCombatContext, + sourceEnemy: PotassiumCommandObject | undefined +): void { + switch (command.type) { + case 'spawnFirePatch': + internals.ports.spawnFirePatch( + command.x, + command.y, + command.effectMultiplier, + command.lifetimeMs, + command.scale + ); + break; + case 'explodeAt': + explodeAt(internals, command.x, command.y, command.effectMultiplier, command.radiusMultiplier); + break; + case 'spawnBananaClones': + internals.ports.spawnBananaClones(command.count, command.lifetimeMs); + break; + case 'spawnGhostBeam': + spawnGhostBeam(internals, command.x, command.y, command.direction, command.effectMultiplier); + break; + case 'spawnGhostStatusField': + spawnGhostStatusField(internals, command.x, command.y, command.direction, command.effectMultiplier); + break; + case 'spreadPoisonFrom': + spreadPoisonFrom( + internals, + command.x, + command.y, + command.effectMultiplier, + sourceEnemy ?? context.enemies.get(command.sourceEnemyId) + ); + break; + default: + break; + } +} + +function ricochetProjectileFromEnemy( + internals: PotassiumCommandAdapterInternals, + projectile: PotassiumCommandObject, + enemy: PotassiumCommandObject +): void { + if (!projectile.active || !projectile.body) return; + + const { options, ports } = internals; + const velocity = { x: projectile.body.velocity.x, y: projectile.body.velocity.y }; + const normal = collisionNormal(projectile, enemy, velocity); + const maxSpeed = ports.isMainProjectile(projectile) + ? ports.getMaxMainProjectileSpeed() + : options.cloneRicochetMaxSpeed; + const ricochet = scaledRicochetVelocity( + reflectVelocity(velocity, normal), + vectorLength(velocity), + maxSpeed, + options + ); + + projectile.setPosition( + clamp(projectile.x + normal.x * 8, options.arena.left + 28, options.arena.right - 28), + clamp(projectile.y + normal.y * 8, options.arena.top + 28, options.arena.bottom - 28) + ); + projectile.setVelocity(ricochet.x, ricochet.y); + if ('setAngularVelocity' in projectile && typeof projectile.setAngularVelocity === 'function') { + projectile.setAngularVelocity(clamp(ricochet.x * 1.4, -720, 720)); + } +} + +function applyPoisonCommand( + internals: PotassiumCommandAdapterInternals, + enemy: PotassiumCommandObject, + command: PotassiumApplyPoisonCommand +): void { + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt, command.expiresAt); + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisoned, true); + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier, command.poisonMultiplier); + enemy.setTint(internals.options.poisonTint); + if (command.nextTickAt !== undefined) { + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.nextTickAt); + } +} + +function spreadPoisonFrom( + internals: PotassiumCommandAdapterInternals, + x: number, + y: number, + effectMultiplier: number, + sourceEnemy?: PotassiumCommandObject +): void { + internals.ports.getEnemies().forEach((enemy) => { + if (enemy === sourceEnemy || !enemy.active || isPotassiumEnemyDying(enemy)) return; + if (distance({ x, y }, enemy) <= internals.options.poisonDeathSpreadRadius) { + applyPoisonCommand(internals, enemy, { + type: 'applyPoison', + enemyId: internals.getCombatId(enemy, 'enemy'), + effectMultiplier, + poisonMultiplier: Math.max( + getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier) ?? 0, + effectMultiplier + ), + expiresAt: Math.max( + getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt) ?? 0, + internals.ports.getNow() + POTASSIUM_POISON_DURATION_MS * effectMultiplier + ), + nextTickAt: (getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt) ?? 0) < internals.ports.getNow() + ? internals.ports.getNow() + POTASSIUM_POISON_TICK_INTERVAL_MS + : undefined + }); + } + }); +} + +function clearPoison(enemy: PotassiumCommandObject): void { + if (!enemy.active) return; + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt, undefined); + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, undefined); + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier, undefined); + setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisoned, false); + if (getPotassiumData(enemy, POTASSIUM_DATA_KEYS.stoneActive)) { + enemy.setTint(0x78716c); + } else { + enemy.clearTint(); + } +} + +function killEnemy(internals: PotassiumCommandAdapterInternals, enemy: PotassiumCommandObject): void { + const kind = getPotassiumEnemyKind(enemy); + const config = getPotassiumEnemyConfig(kind); + setPotassiumEnemyDying(enemy, true); + const damageCueTween = getPotassiumData<{ stop?: () => void }>(enemy, POTASSIUM_DATA_KEYS.damageCueTween); + damageCueTween?.stop?.(); + if (enemy.body) enemy.body.enable = false; + const killResult = resolvePotassiumEnemyKilled(internals.ports.getSession(), config.score, kind); + if (kind !== 'boss') { + internals.delegates.applySessionResult(killResult); + } + + internals.ports.animateEnemyDeath(enemy, () => { + enemy.destroy(); + if (kind === 'boss') { + internals.delegates.applySessionResult(killResult); + } + }); +} + +function explodeAt( + internals: PotassiumCommandAdapterInternals, + x: number, + y: number, + effectMultiplier: number, + radiusMultiplier: number +): void { + const rank = internals.ports.getSkillRank('explosion'); + const radius = getPotassiumExplosionRadius(rank as PotassiumSkillRank) + * radiusMultiplier + * internals.ports.getExplosionRadiusMultiplier(); + internals.ports.showExplosionVisual(x, y, radius); + const hits = internals.ports.getExplosionHits(x, y); + applyCombatCommands(internals, resolvePotassiumExplosion({ + now: internals.ports.getNow(), + x, + y, + effectMultiplier, + radiusMultiplier, + hits: hits.map((hit) => ({ + enemy: internals.getEnemyCombatFacts(hit.enemy), + distance: hit.distance + })), + skillRanks: internals.ports.getSession().skillRanks, + genericRanks: internals.ports.getSession().genericRanks + }), internals.createCombatContext(hits.map((hit) => hit.enemy))); + internals.ports.shakeCamera(130, 0.006); +} + +function spawnGhostBeam( + internals: PotassiumCommandAdapterInternals, + x: number, + y: number, + direction: PotassiumGhostBeamDirection, + effectMultiplier: number +): void { + internals.ports.showGhostBeam({ x, y, direction, durationMs: internals.options.ghostBeamLifetimeMs }); + const hits = internals.ports.getGhostBeamHits(x, y, direction); + applyCombatCommands(internals, resolvePotassiumGhostBeam({ + now: internals.ports.getNow(), + x, + y, + direction, + effectMultiplier, + hits: hits.map((hit) => ({ + enemy: internals.getEnemyCombatFacts(hit.enemy), + inBeam: hit.inBeam + })), + skillRanks: internals.ports.getSession().skillRanks, + genericRanks: internals.ports.getSession().genericRanks + }), internals.createCombatContext(hits.map((hit) => hit.enemy))); +} + +function spawnGhostStatusField( + internals: PotassiumCommandAdapterInternals, + x: number, + y: number, + direction: PotassiumGhostBeamDirection, + effectMultiplier: number +): void { + const { options, ports } = internals; + const isHorizontal = direction === 'horizontal'; + if (ports.getSkillRank('fire') > 0) { + const patchCount = isHorizontal ? 5 : 7; + for (let index = 0; index < patchCount; index += 1) { + const t = patchCount <= 1 ? 0.5 : index / (patchCount - 1); + const patchX = isHorizontal + ? linear(options.arena.left + 46, options.arena.right - 46, t) + : x; + const patchY = isHorizontal + ? y + : linear(options.arena.top + 112, options.arena.bottom - 92, t); + ports.spawnFirePatch(patchX, patchY, effectMultiplier, options.ghostStatusFieldLifetimeMs, 0.46); + } + } + if (ports.getSkillRank('poison') > 0) { + ports.showGhostStatusField({ + x, + y, + direction, + poisonActive: true, + durationMs: options.ghostStatusFieldLifetimeMs + }); + } +} + +function collisionNormal( + projectile: PotassiumCommandObject, + enemy: PotassiumCommandObject, + velocity: { x: number; y: number } +): { x: number; y: number } { + let normal = { x: projectile.x - enemy.x, y: projectile.y - enemy.y }; + if (vectorLengthSquared(normal) <= 0.001) { + normal = vectorLengthSquared(velocity) > 0.001 ? velocity : { x: 0, y: 1 }; + } + return normalize(normal); +} + +function reflectVelocity( + velocity: { x: number; y: number }, + normal: { x: number; y: number } +): { x: number; y: number } { + let ricochet = { ...velocity }; + const dot = dotProduct(ricochet, normal); + if (dot < 0) { + ricochet = { + x: ricochet.x - normal.x * (2 * dot), + y: ricochet.y - normal.y * (2 * dot) + }; + } else { + ricochet = { ...normal }; + } + if (vectorLengthSquared(ricochet) <= 0.001) { + return { ...normal }; + } + return ricochet; +} + +function scaledRicochetVelocity( + ricochet: { x: number; y: number }, + currentSpeed: number, + maxSpeed: number, + options: PotassiumCommandAdapterInternals['options'] +): { x: number; y: number } { + const speed = clamp( + Math.max(currentSpeed * options.bananaRicochetBoost, options.bananaRicochetMinSpeed), + options.bananaRicochetMinSpeed, + maxSpeed + ); + const ricochetNormal = normalize(ricochet); + return { + x: ricochetNormal.x * speed, + y: ricochetNormal.y * speed + }; +} + +function vectorLength(point: { x: number; y: number }): number { + return Math.sqrt(vectorLengthSquared(point)); +} + +function vectorLengthSquared(point: { x: number; y: number }): number { + return point.x * point.x + point.y * point.y; +} + +function normalize(point: { x: number; y: number }): { x: number; y: number } { + const length = vectorLength(point); + if (length <= 0) return { x: 0, y: 0 }; + return { x: point.x / length, y: point.y / length }; +} + +function dotProduct(a: { x: number; y: number }, b: { x: number; y: number }): number { + return a.x * b.x + a.y * b.y; +} + +function distance(a: { x: number; y: number }, b: { x: number; y: number }): number { + return vectorLength({ x: a.x - b.x, y: a.y - b.y }); +} diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts new file mode 100644 index 00000000..d34960ba --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts @@ -0,0 +1,126 @@ +import type { PotassiumEnemyCombatFacts, PotassiumProjectileCombatFacts } from './combat'; +import type { PotassiumBossFacts } from './boss'; +import type { PotassiumCombatCommand } from './combat'; +import type { PotassiumSessionResult } from './session'; +import { + canPotassiumProjectileApplyHitProcs, + canPotassiumProjectileDuplicate, + getPotassiumCombatId, + getPotassiumData, + getPotassiumEnemyHp, + getPotassiumEnemyKind, + getPotassiumEnemyMaxHp, + getPotassiumProjectileEffectMultiplier, + getPotassiumShieldSide, + isPotassiumEnemyDying, + isPotassiumProjectileRecallVisual, + POTASSIUM_DATA_KEYS, + setPotassiumCombatId +} from './phaserData'; +import type { + PotassiumCombatContext, + PotassiumCommandAdapterFlatPorts, + PotassiumCommandAdapterOptions, + PotassiumCommandAdapterPorts, + PotassiumCommandObject +} from './commandAdapterTypes'; + +export interface CommandAdapterDelegates { + applySessionResult(result: PotassiumSessionResult): void; + applyCombatCommands( + commands: readonly PotassiumCombatCommand[], + context?: PotassiumCombatContext + ): void; +} + +export class PotassiumCommandAdapterInternals { + combatIdCounter = 0; + delegates!: CommandAdapterDelegates; + + readonly ports: PotassiumCommandAdapterFlatPorts; + readonly options: PotassiumCommandAdapterOptions; + + constructor(ports: PotassiumCommandAdapterPorts, options: PotassiumCommandAdapterOptions) { + this.ports = { + ...ports.runtime, + ...ports.objects, + ...ports.board, + ...ports.renderer + }; + this.options = options; + } + + resetCombatIds(): void { + this.combatIdCounter = 0; + } + + getCombatId(object: PotassiumCommandObject, prefix: string): string { + const existingId = getPotassiumCombatId(object); + if (existingId) return existingId; + const id = `${prefix}-${this.combatIdCounter}`; + this.combatIdCounter += 1; + setPotassiumCombatId(object, id); + return id; + } + + getEnemyCombatFacts(enemy: PotassiumCommandObject): PotassiumEnemyCombatFacts { + return { + id: this.getCombatId(enemy, 'enemy'), + kind: getPotassiumEnemyKind(enemy), + active: enemy.active, + dying: isPotassiumEnemyDying(enemy), + hp: getPotassiumEnemyHp(enemy), + maxHp: getPotassiumEnemyMaxHp(enemy), + x: enemy.x, + y: enemy.y, + indestructible: Boolean(getPotassiumData(enemy, POTASSIUM_DATA_KEYS.indestructible)), + splitsOnDeath: Boolean(getPotassiumData(enemy, POTASSIUM_DATA_KEYS.splitsOnDeath)), + poisonExpiresAt: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonExpiresAt), + poisonMultiplier: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonMultiplier), + poisonNextTickAt: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt), + fireTickUntil: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil), + shieldSide: getPotassiumShieldSide(enemy), + stoneUntil: getPotassiumData(enemy, POTASSIUM_DATA_KEYS.stoneUntil) + }; + } + + getProjectileCombatFacts(projectile: PotassiumCommandObject): PotassiumProjectileCombatFacts { + return { + id: this.getCombatId(projectile, this.ports.isMainProjectile(projectile) ? 'main' : 'projectile'), + isMain: this.ports.isMainProjectile(projectile), + isRecall: this.ports.isMainProjectile(projectile) + && (this.ports.isMainProjectileRecalling() || isPotassiumProjectileRecallVisual(projectile)), + x: projectile.x, + y: projectile.y, + effectMultiplier: getPotassiumProjectileEffectMultiplier(projectile), + canApplyHitProcs: canPotassiumProjectileApplyHitProcs(projectile), + canDuplicate: canPotassiumProjectileDuplicate(projectile), + explosionRadiusMultiplier: this.ports.getProjectileExplosionRadiusMultiplier(projectile) + }; + } + + getBossFacts(boss: PotassiumCommandObject): PotassiumBossFacts { + return { + active: boss.active, + dying: isPotassiumEnemyDying(boss), + hp: getPotassiumEnemyHp(boss), + maxHp: getPotassiumEnemyMaxHp(boss), + x: boss.x, + y: boss.y, + velocityX: boss.body?.velocity?.x ?? 0 + }; + } + + createCombatContext( + enemies: PotassiumCommandObject[] = this.ports.getEnemies(), + projectiles: PotassiumCommandObject[] = this.ports.getProjectiles() + ): PotassiumCombatContext { + return { + enemies: new Map(enemies.map((enemy) => [this.getCombatId(enemy, 'enemy'), enemy])), + projectiles: new Map(projectiles.map((projectile) => [ + this.getCombatId(projectile, this.ports.isMainProjectile(projectile) ? 'main' : 'projectile'), + projectile + ])) + }; + } +} diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts new file mode 100644 index 00000000..473b7e93 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts @@ -0,0 +1,123 @@ +import type { PotassiumSessionCommand } from './session'; +import type { PotassiumCommandAdapterInternals } from './commandAdapterInternals'; + +export function applySessionCommands( + internals: PotassiumCommandAdapterInternals, + commands: readonly PotassiumSessionCommand[] +): void { + commands.forEach((command) => applySessionCommand(internals, command)); +} + +function applySessionCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): void { + if (applySessionBoardCommand(internals, command)) return; + if (applySessionWaveCommand(internals, command)) return; + if (applySessionHudCommand(internals, command)) return; + applySessionFlowCommand(internals, command); +} + +function applySessionBoardCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): boolean { + const { ports } = internals; + switch (command.type) { + case 'resetBoard': + ports.resetBoardObjects(); + return true; + case 'hideMainOverlay': + ports.hideMainOverlay(); + return true; + case 'clearTerminal': + ports.clearTerminalOverlay(); + return true; + case 'clearUpgradeChoices': + ports.clearUpgradeChoiceOverlay(); + return true; + case 'stopBanana': + ports.stopMainProjectile(); + return true; + case 'clearBoardForOutcome': + ports.clearBoardForOutcome(); + return true; + default: + return false; + } +} + +function applySessionWaveCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): boolean { + const { ports } = internals; + switch (command.type) { + case 'spawnWave': + ports.spawnWave(command.wave); + return true; + case 'spawnBoss': + ports.spawnBossDelayed(); + return true; + case 'scheduleWaveRows': + ports.scheduleWaveRows(command.schedule); + return true; + case 'scheduleUpgradeChoices': + ports.scheduleUpgradeChoices(); + return true; + case 'advanceWaveAfterDelay': + ports.advanceWaveAfterDelay(command.wave); + return true; + case 'refreshProjectileVisuals': + ports.refreshAllProjectileVisuals(); + return true; + default: + return false; + } +} + +function applySessionHudCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): boolean { + const { ports } = internals; + switch (command.type) { + case 'setHint': + ports.setHint(command.text); + return true; + case 'updateHud': + ports.updateHud(command.hud); + return true; + case 'collectCircuit': + ports.collectCircuit(); + return true; + case 'saveRunRecord': + ports.saveRunRecord(command.record); + return true; + default: + return false; + } +} + +function applySessionFlowCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): void { + const { ports } = internals; + switch (command.type) { + case 'showUpgradeChoices': + ports.showUpgradeChoices(command.choices); + break; + case 'showOutcome': + ports.showOutcomeOverlay(command); + break; + case 'showTerminal': + ports.showTerminal(command.outcome); + break; + case 'closeScene': + ports.closeScene(); + break; + default: + break; + } +} diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterTypes.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterTypes.ts new file mode 100644 index 00000000..eaadf7df --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterTypes.ts @@ -0,0 +1,149 @@ +import type { PotassiumRunRecord, PotassiumRunOutcome } from './leaderboard'; +import type { PotassiumBossSummonFacts } from './boss'; +import type { PotassiumDraftChoiceView, PotassiumHudFacts } from './session'; +import type { PotassiumSessionState, PotassiumSessionResult } from './session'; +import type { PotassiumDamageSource, PotassiumGhostBeamDirection } from './combat'; +import type { + PotassiumGenericUpgradeKind, + PotassiumScheduledWaveRow, + PotassiumUpgradeKind +} from './waves'; + +export interface PotassiumCommandBody { + velocity: { x: number; y: number }; + enable?: boolean; + setAllowGravity?: (allow: boolean) => unknown; + setImmovable?: (immovable: boolean) => unknown; + setSize?: (width: number, height: number, center?: boolean) => unknown; +} + +export interface PotassiumCommandObject { + x: number; + y: number; + angle: number; + active: boolean; + body: PotassiumCommandBody; + getData(key: string): unknown; + setData(key: string, value: unknown): unknown; + setTint(color: number): unknown; + clearTint(): unknown; + setVelocity(x: number, y: number): unknown; + setVelocityX(x: number): unknown; + setVelocityY(y: number): unknown; + setAngularVelocity?(velocity: number): unknown; + setPosition(x: number, y: number): unknown; + setX(x: number): unknown; + setAngle(angle: number): unknown; + setScale(scale: number): unknown; + destroy(): unknown; +} + +export interface PotassiumCombatContext { + enemies: Map; + projectiles: Map; +} + +export interface PotassiumCommandAdapterRuntimePorts { + getNow(): number; + getSession(): PotassiumSessionState; + applySessionResult(result: PotassiumSessionResult): void; + getSkillRank(upgrade: PotassiumUpgradeKind): number; + getGenericRank(upgrade: PotassiumGenericUpgradeKind): number; + setHint(text: string | null): void; + collectCircuit(): void; + saveRunRecord(record: PotassiumRunRecord): void; + closeScene(): void; +} + +export interface PotassiumCommandAdapterObjectPorts { + getEnemies(): PotassiumCommandObject[]; + getProjectiles(): PotassiumCommandObject[]; + getMainProjectile(): PotassiumCommandObject; + isMainProjectile(projectile: PotassiumCommandObject): boolean; + isMainProjectileRecalling(): boolean; + getProjectileExplosionRadiusMultiplier(projectile: PotassiumCommandObject): number; + getMaxMainProjectileSpeed(): number; + getExplosionRadiusMultiplier(): number; + getExplosionHits(x: number, y: number): Array<{ enemy: PotassiumCommandObject; distance: number }>; + getGhostBeamHits( + x: number, + y: number, + direction: PotassiumGhostBeamDirection + ): Array<{ enemy: PotassiumCommandObject; inBeam: boolean }>; +} + +export interface PotassiumCommandAdapterBoardPorts { + resetBoardObjects(): void; + spawnWave(wave: number): void; + spawnBossDelayed(): void; + scheduleWaveRows(schedule: readonly PotassiumScheduledWaveRow[]): void; + scheduleUpgradeChoices(): void; + advanceWaveAfterDelay(wave: number): void; + stopMainProjectile(): void; + clearBoardForOutcome(): void; + spawnFirePatch(x: number, y: number, effectMultiplier: number, lifetimeMs: number, scale: number): void; + spawnBananaClones(count: number, lifetimeMs: number): void; + spawnSplitterChildren(enemy: PotassiumCommandObject): void; + spawnBossOrbitBlockers(boss: PotassiumCommandObject): void; + updateBossOrbitBlockers(boss: PotassiumCommandObject, time: number): void; + setBossStoneVisual(boss: PotassiumCommandObject, active: boolean): void; + spawnBossSummons(summons: readonly PotassiumBossSummonFacts[]): void; + clearOrbitBlockers(): void; +} + +export interface PotassiumCommandAdapterRendererPorts { + hideMainOverlay(): void; + clearTerminalOverlay(): void; + clearUpgradeChoiceOverlay(): void; + showUpgradeChoices(choices: readonly PotassiumDraftChoiceView[]): void; + refreshAllProjectileVisuals(): void; + updateHud(hud: PotassiumHudFacts): void; + showOutcomeOverlay(input: { title: string; score: number; titleFontSize: number }): void; + showTerminal(outcome: PotassiumRunOutcome): void; + showDamageCue(enemy: PotassiumCommandObject, source: PotassiumDamageSource): void; + showExplosionVisual(x: number, y: number, radius: number): void; + shakeCamera(durationMs: number, intensity: number): void; + showGhostBeam(input: { + x: number; + y: number; + direction: PotassiumGhostBeamDirection; + durationMs: number; + }): void; + showGhostStatusField(input: { + x: number; + y: number; + direction: PotassiumGhostBeamDirection; + poisonActive: boolean; + durationMs: number; + }): void; + animateEnemyDeath(enemy: PotassiumCommandObject, onComplete: () => void): void; +} + +export interface PotassiumCommandAdapterPorts { + runtime: PotassiumCommandAdapterRuntimePorts; + objects: PotassiumCommandAdapterObjectPorts; + board: PotassiumCommandAdapterBoardPorts; + renderer: PotassiumCommandAdapterRendererPorts; +} + +export type PotassiumCommandAdapterFlatPorts = + PotassiumCommandAdapterRuntimePorts + & PotassiumCommandAdapterObjectPorts + & PotassiumCommandAdapterBoardPorts + & PotassiumCommandAdapterRendererPorts; + +export interface PotassiumCommandAdapterOptions { + arena: { + left: number; + right: number; + top: number; + bottom: number; + }; + poisonTint: number; + cloneRicochetMaxSpeed: number; + bananaRicochetMinSpeed: number; + bananaRicochetBoost: number; + poisonDeathSpreadRadius: number; + ghostStatusFieldLifetimeMs: number; + ghostBeamLifetimeMs: number; +} diff --git a/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts b/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts index 2b00720e..36113999 100644 --- a/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts +++ b/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts @@ -7,6 +7,7 @@ import { type PotassiumShieldSide } from './waves'; import { POTASSIUM_DATA_KEYS } from './phaserData'; +import { clamp, linear } from '@/shared/math'; import { getMessages } from '@/shared/i18n'; const messages = getMessages(); @@ -60,7 +61,7 @@ export interface PotassiumEnemySetupFacts { } export const POTASSIUM_NON_BOSS_ENEMY_SPEED = 54; -export const POTASSIUM_SPLITTER_CHILD_SPEED = POTASSIUM_NON_BOSS_ENEMY_SPEED + 18; +const POTASSIUM_SPLITTER_CHILD_SPEED = POTASSIUM_NON_BOSS_ENEMY_SPEED + 18; const ENEMY_CONFIGS: Record = { intern: { @@ -233,11 +234,3 @@ function getPotassiumEnemyBodyProfile(kind: PotassiumEnemyKind): PotassiumEnemyB if (kind === 'shield') return { width: 54, height: 54 }; return undefined; } - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function linear(from: number, to: number, t: number): number { - return from + (to - from) * t; -} diff --git a/src/game/scenes/potassiumSlip/runtime/index.ts b/src/game/scenes/potassiumSlip/runtime/index.ts deleted file mode 100644 index ec4bdad5..00000000 --- a/src/game/scenes/potassiumSlip/runtime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PotassiumSlipScene } from './PotassiumSlipScene'; diff --git a/src/game/scenes/potassiumSlip/runtime/waves.ts b/src/game/scenes/potassiumSlip/runtime/waves.ts index d142a077..b6c8f26a 100644 --- a/src/game/scenes/potassiumSlip/runtime/waves.ts +++ b/src/game/scenes/potassiumSlip/runtime/waves.ts @@ -64,8 +64,8 @@ export const POTASSIUM_COLUMN_COUNT = 5; export const POTASSIUM_NON_BOSS_WAVE_COUNT = 10; export const POTASSIUM_BOSS_WAVE = POTASSIUM_NON_BOSS_WAVE_COUNT + 1; export const POTASSIUM_ENDLESS_START_WAVE = POTASSIUM_BOSS_WAVE + 1; -export const POTASSIUM_ROW_SPAWN_DELAY_MS = 900; -export const POTASSIUM_MID_GAME_ROW_SPAWN_DELAY_MS = 1050; +const POTASSIUM_ROW_SPAWN_DELAY_MS = 900; +const POTASSIUM_MID_GAME_ROW_SPAWN_DELAY_MS = 1050; export const POTASSIUM_UPGRADES: readonly PotassiumUpgradeKind[] = [ 'fire', @@ -76,7 +76,7 @@ export const POTASSIUM_UPGRADES: readonly PotassiumUpgradeKind[] = [ 'ghostVertical' ] as const; -export const POTASSIUM_GENERIC_UPGRADES: readonly PotassiumGenericUpgradeKind[] = [ +const POTASSIUM_GENERIC_UPGRADES: readonly PotassiumGenericUpgradeKind[] = [ 'damage', 'poison', 'explosion', @@ -172,7 +172,7 @@ export function getPotassiumSkillRank( return skillRanks[upgrade] ?? 0; } -export function getPotassiumDraftPool(skillRanks: PotassiumSkillRanks): PotassiumSkillDraftOption[] { +function getPotassiumDraftPool(skillRanks: PotassiumSkillRanks): PotassiumSkillDraftOption[] { return POTASSIUM_UPGRADES.flatMap((upgrade) => { const rank = getPotassiumSkillRank(skillRanks, upgrade); if (rank === 0) return [{ kind: upgrade, action: 'unlock' as const }]; diff --git a/src/game/scenes/ridge/blockout/facts.test.ts b/src/game/scenes/ridge/blockout/facts.test.ts deleted file mode 100644 index 0cd6f727..00000000 --- a/src/game/scenes/ridge/blockout/facts.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { RIDGE_BLOCKOUT } from './ridgeBlockout'; -import { - compileRidgeBlockoutFacts, - findRidgeBlockoutFactAnchor -} from './facts'; - -describe('ridge blockout facts', () => { - it('compiles the first route order and route links', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const firstWalk = facts.routes.find((route) => route.id === 'first_walk'); - - expect(firstWalk?.roomIds.slice(0, 4)).toEqual([ - 'outskirts', - 'cicka_home', - 'work_artifact', - 'stampede_blanket' - ]); - expect(firstWalk?.links.slice(0, 3)).toEqual([ - { fromRoomId: 'outskirts', toRoomId: 'cicka_home' }, - { fromRoomId: 'cicka_home', toRoomId: 'work_artifact' }, - { fromRoomId: 'work_artifact', toRoomId: 'stampede_blanket' } - ]); - }); - - it('resolves spawn and Cicka Home anchors as typed facts', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const cicka = findRidgeBlockoutFactAnchor(facts, { - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - attrId: 'cicka' - }); - - expect(facts.spawn).toMatchObject({ - roomId: 'outskirts', - symbol: '1', - kind: 'player_spawn', - id: 'start' - }); - expect(cicka).toMatchObject({ - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - id: 'cicka' - }); - expect(cicka?.x).toBeGreaterThan(0); - expect(cicka?.y).toBeGreaterThan(0); - }); - - it('exposes Trail Card and Relay anchors without raw attr reads', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const stampede = findRidgeBlockoutFactAnchor(facts, { - roomId: 'stampede_blanket', - symbol: '*', - kind: 'minigame', - attrId: 'stampede_sketch' - }); - const relay = findRidgeBlockoutFactAnchor(facts, { - roomId: 'relay_gate', - symbol: '?', - kind: 'gate', - attrId: 'relay_proof_slots' - }); - const relayRoom = facts.rooms.find((room) => room.id === 'relay_gate'); - - expect(stampede).toMatchObject({ - roomId: 'stampede_blanket', - id: 'stampede_sketch' - }); - expect(relay).toMatchObject({ - roomId: 'relay_gate', - id: 'relay_proof_slots' - }); - expect(relay?.x).toBeGreaterThanOrEqual(relayRoom?.bounds.x ?? 0); - expect(relay?.x).toBeLessThanOrEqual( - relayRoom ? relayRoom.bounds.x + relayRoom.bounds.width : Number.POSITIVE_INFINITY - ); - }); - - it('resolves future-route promises as typed route facts', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const underpath = facts.futureRoutes.find((route) => route.id === 'cicka_underpath'); - const highLedge = facts.futureRoutes.find((route) => route.id === 'high_ledge'); - - expect(underpath).toMatchObject({ - kind: 'future_route', - roomIds: ['outskirts', 'underpath', 'cicka_home'] - }); - expect(underpath?.links).toContainEqual({ - fromRoomId: 'underpath', - toRoomId: 'cicka_home' - }); - expect(highLedge?.links).toEqual([ - { fromRoomId: 'domino_desk', toRoomId: 'high_ledge' } - ]); - }); - - it('resolves Stampede shortcut source, gate, and availability from progress', () => { - const lockedFacts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT, { stampIds: [] }); - const unlockedFacts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT, { - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }); - const lockedShortcut = lockedFacts.shortcuts.find((shortcut) => shortcut.id === 'stampede_sketch'); - const unlockedShortcut = unlockedFacts.shortcuts.find((shortcut) => shortcut.id === 'stampede_sketch'); - - expect(lockedShortcut).toMatchObject({ - id: 'stampede_sketch', - fromRoomId: 'switchback_shelf', - toRoomId: 'cicka_home', - kind: 'fall_steer_fold_drop', - movement: 'drop', - requiredStampId: STAMPEDE_SKETCH_RIDGE_STAMP_ID, - available: false - }); - expect(lockedShortcut?.from).toMatchObject({ - roomId: 'switchback_shelf', - targetRoomId: 'cicka_home', - requires: 'stampede_sketch', - movement: 'drop' - }); - expect(unlockedShortcut).toMatchObject({ - id: 'stampede_sketch', - movement: 'drop', - available: true - }); - }); - - it('preserves drop shortcut movement when geometry cannot build a connection', () => { - const facts = compileRidgeBlockoutFacts({ - ...RIDGE_BLOCKOUT, - shortcuts: [{ - id: 'missing_anchor_drop', - fromRoomId: 'outskirts', - toRoomId: 'cicka_home', - kind: 'fall_test_drop' - }] - }); - - expect(facts.shortcuts[0]).toMatchObject({ - id: 'missing_anchor_drop', - movement: 'drop', - toRoomId: 'cicka_home' - }); - expect(facts.shortcuts[0]?.to).not.toEqual({ x: 0, y: 0 }); - }); - - it('fails loudly when a shortcut target cannot resolve', () => { - expect(() => compileRidgeBlockoutFacts({ - ...RIDGE_BLOCKOUT, - shortcuts: [{ - id: 'missing_target', - fromRoomId: 'outskirts', - toRoomId: 'missing_room', - kind: 'ramp' - }] - })).toThrow( - 'Ridge blockout shortcut "missing_target" target room "missing_room" could not be compiled into facts' - ); - }); - - it('exposes Cicka Home mutation declarations as typed facts', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - - expect(facts.homeMutations).toContainEqual(expect.objectContaining({ - id: 'stampede_sketch', - adds: 'stampede_note', - opens: 'fold_drop_landing' - })); - expect(facts.homeMutations).toContainEqual(expect.objectContaining({ - id: 'work_artifact', - adds: 'work_display' - })); - }); -}); diff --git a/src/game/scenes/ridge/blockout/facts.ts b/src/game/scenes/ridge/blockout/facts.ts deleted file mode 100644 index 5ca50a39..00000000 --- a/src/game/scenes/ridge/blockout/facts.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { - RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - type RidgeBlockoutMap, - type RidgeBlockoutTraversalMovement -} from './parser'; -import { - deriveRidgeBlockoutGeometry, - type RidgeBlockoutAnchorPoint, - type RidgeBlockoutAnchorSelector, - type RidgeBlockoutBounds, - type RidgeBlockoutGeometry, - type RidgeBlockoutRoomBounds -} from './geometry'; -import { - getRidgeBlockoutShortcutRequiredStampId, - isRidgeBlockoutShortcutAvailable -} from './progress'; - -export type RidgeBlockoutRouteKind = 'route' | 'future_route'; - -export interface RidgeRouteLinkFact { - fromRoomId: string; - toRoomId: string; -} - -export interface RidgeAnchorFact extends RidgeBlockoutAnchorPoint { - id?: string; - label?: string; - targetRoomId?: string; - requires?: string; - movement?: RidgeBlockoutTraversalMovement; - reward?: string; -} - -export interface RidgeRoomBeatFact { - id: string; - title: string; - theme?: string; - mood?: string; - links: readonly string[]; - props: readonly string[]; - declarations: readonly string[]; - bounds: RidgeBlockoutRoomBounds; - anchors: readonly RidgeAnchorFact[]; -} - -export interface RidgeRouteFact { - id: string; - kind: RidgeBlockoutRouteKind; - roomIds: readonly string[]; - links: readonly RidgeRouteLinkFact[]; -} - -export interface RidgeShortcutFact { - id: string; - fromRoomId: string; - toRoomId: string; - kind?: string; - movement: RidgeBlockoutTraversalMovement; - requiredStampId?: string; - available: boolean; - from?: RidgeAnchorFact; - to: { - x: number; - y: number; - }; -} - -export interface RidgeHomeMutationFact { - id: string; - adds?: string; - opens?: string; - attrs: Readonly>; -} - -export interface RidgeBlockoutFacts { - worldId: string; - title: string; - cell: number; - bounds: RidgeBlockoutBounds; - spawn: RidgeAnchorFact; - rooms: readonly RidgeRoomBeatFact[]; - anchors: readonly RidgeAnchorFact[]; - routes: readonly RidgeRouteFact[]; - futureRoutes: readonly RidgeRouteFact[]; - shortcuts: readonly RidgeShortcutFact[]; - homeMutations: readonly RidgeHomeMutationFact[]; -} - -export interface RidgeBlockoutFactsOptions { - stampIds?: readonly string[]; - geometry?: RidgeBlockoutGeometry; -} - -export function compileRidgeBlockoutFacts( - map: RidgeBlockoutMap, - options: RidgeBlockoutFactsOptions = {} -): RidgeBlockoutFacts { - const geometry = options.geometry ?? deriveRidgeBlockoutGeometry(map, { - stampIds: options.stampIds - }); - const anchors = geometry.anchorPoints.map(toAnchorFact); - const anchorsByRoomId = groupAnchorsByRoomId(anchors); - const roomBoundsById = new Map( - geometry.roomBounds.map((bounds) => [bounds.roomId, bounds]) - ); - const shortcutConnectionsById = new Map( - geometry.shortcutConnections.map((connection) => [connection.id, connection]) - ); - const rooms = map.rooms.map((room) => { - const bounds = roomBoundsById.get(room.id); - if (!bounds) { - throw new Error(`Ridge blockout room "${room.id}" could not be compiled into facts`); - } - return { - id: room.id, - title: room.title, - theme: room.theme, - mood: room.mood, - links: room.links, - props: room.props, - declarations: room.declarations, - bounds, - anchors: anchorsByRoomId.get(room.id) ?? [] - }; - }); - const spawn = findRidgeBlockoutFactAnchor({ anchors }, { - roomId: map.spawn.roomId, - symbol: map.spawn.anchorSymbol - }); - if (!spawn) { - throw new Error( - `Ridge blockout spawn anchor "${map.spawn.anchorSymbol}" in room "${map.spawn.roomId}" could not be compiled into facts` - ); - } - - return { - worldId: map.worldId, - title: map.title, - cell: map.cell, - bounds: geometry.bounds, - spawn, - rooms, - anchors, - routes: map.routes.map((route) => toRouteFact(route, 'route')), - futureRoutes: map.futureRoutes.map((route) => toRouteFact(route, 'future_route')), - shortcuts: map.shortcuts.map((shortcut) => { - const connection = shortcutConnectionsById.get(shortcut.id); - const source = anchors.find((anchor) => - anchor.roomId === shortcut.fromRoomId && - anchor.targetRoomId === shortcut.toRoomId && - anchor.requires === shortcut.id - ); - const available = options.geometry && options.stampIds === undefined - ? connection?.unlocked ?? false - : isRidgeBlockoutShortcutAvailable(shortcut.id, { - stampIds: options.stampIds ?? [] - }); - return { - id: shortcut.id, - fromRoomId: shortcut.fromRoomId, - toRoomId: shortcut.toRoomId, - kind: shortcut.kind, - movement: connection?.movement ?? resolveShortcutFallbackMovement(shortcut.kind), - requiredStampId: getRidgeBlockoutShortcutRequiredStampId(shortcut.id), - available, - from: source, - to: connection?.to ?? getRequiredRoomCenter(geometry, shortcut.toRoomId, shortcut.id) - }; - }), - homeMutations: map.homeMutations.map((mutation) => ({ - id: mutation.id, - adds: mutation.attrs.adds, - opens: mutation.attrs.opens, - attrs: mutation.attrs - })) - }; -} - -export function findRidgeBlockoutFactAnchor( - facts: Pick, - selector: RidgeBlockoutAnchorSelector -): RidgeAnchorFact | undefined { - return facts.anchors.find((point) => - point.roomId === selector.roomId && - (!selector.symbol || point.symbol === selector.symbol) && - (!selector.kind || point.kind === selector.kind) && - (!selector.attrId || point.id === selector.attrId) - ); -} - -function toAnchorFact(point: RidgeBlockoutAnchorPoint): RidgeAnchorFact { - return { - ...point, - id: point.attrs.id, - label: point.attrs.label, - targetRoomId: point.attrs.to, - requires: point.attrs.requires, - movement: parseTraversalMovement(point.attrs.movement), - reward: point.attrs.reward - }; -} - -function toRouteFact( - route: { id: string; roomIds: readonly string[] }, - kind: RidgeBlockoutRouteKind -): RidgeRouteFact { - return { - id: route.id, - kind, - roomIds: route.roomIds, - links: route.roomIds.slice(0, -1).map((roomId, index) => ({ - fromRoomId: roomId, - toRoomId: route.roomIds[index + 1] - })) - }; -} - -function resolveShortcutFallbackMovement( - kind: string | undefined -): RidgeBlockoutTraversalMovement { - if (kind?.includes('drop') || kind?.includes('fall')) return 'drop'; - return parseTraversalMovement(kind) ?? 'ramp'; -} - -function parseTraversalMovement(value: string | undefined): RidgeBlockoutTraversalMovement | undefined { - return value && RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(value) - ? value as RidgeBlockoutTraversalMovement - : undefined; -} - -function groupAnchorsByRoomId( - anchors: readonly RidgeAnchorFact[] -): Map { - const anchorsByRoomId = new Map(); - anchors.forEach((anchor) => { - const roomAnchors = anchorsByRoomId.get(anchor.roomId) ?? []; - roomAnchors.push(anchor); - anchorsByRoomId.set(anchor.roomId, roomAnchors); - }); - return anchorsByRoomId; -} - -function getRequiredRoomCenter( - geometry: Pick, - roomId: string, - shortcutId: string -): { x: number; y: number } { - const center = getRoomCenter(geometry, roomId); - if (!center) { - throw new Error( - `Ridge blockout shortcut "${shortcutId}" target room "${roomId}" could not be compiled into facts` - ); - } - return center; -} - -function getRoomCenter( - geometry: Pick, - roomId: string -): { x: number; y: number } | undefined { - const room = geometry.roomBounds.find((candidate) => candidate.roomId === roomId); - return room - ? { - x: room.x + room.width / 2, - y: room.y + room.height / 2 - } - : undefined; -} diff --git a/src/game/scenes/ridge/blockout/geometry.test.ts b/src/game/scenes/ridge/blockout/geometry.test.ts deleted file mode 100644 index 24088a9b..00000000 --- a/src/game/scenes/ridge/blockout/geometry.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { RIDGE_BLOCKOUT } from './ridgeBlockout'; -import { - deriveRidgeBlockoutGeometry, - findRidgeBlockoutRoom, - getRidgeBlockoutAnchorPoint, - isRidgeBlockoutShortcutAvailable -} from './index'; -import { - isMantleTargetCollider, - isTraversalPathOccludedBySolid -} from '../runtime/traversalComfort'; - -describe('ridge blockout geometry', () => { - it('derives horizontal collider runs from solid and platform cells', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - - expect(geometry.gridColliders.some((collider) => collider.kind === 'solid')).toBe(true); - expect(geometry.gridColliders.some((collider) => collider.kind === 'platform')).toBe(true); - expect(geometry.gridColliders.find((collider) => - collider.roomId === 'outskirts' && - collider.kind === 'solid' && - collider.width > RIDGE_BLOCKOUT.cell - )).toBeDefined(); - }); - - it('generates first-walk traversal connectors from route exit anchors', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - - expect(geometry.routeConnectors.length).toBeGreaterThan(0); - expect(geometry.routeConnectors.every((connector) => connector.routeId === 'first_walk')).toBe(true); - }); - - it('derives typed traversal connectors from movement anchor metadata', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - - expect(geometry.routeConnectors.some((connector) => connector.movement === 'ramp')).toBe(true); - expect(geometry.routeConnectors.some((connector) => connector.movement === 'jump')).toBe(true); - expect(geometry.routeConnectors.some((connector) => connector.movement === 'climb')).toBe(true); - expect(geometry.assistZones.some((zone) => zone.kind === 'ramp')).toBe(true); - expect(geometry.assistZones.some((zone) => zone.kind === 'climb')).toBe(true); - }); - - it('keeps main-route generated jump platforms sparse and intentional', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const jumpConnectors = geometry.routeConnectors.filter( - (connector) => connector.movement === 'jump' - ); - - expect(jumpConnectors.length).toBeGreaterThan(0); - jumpConnectors.forEach((connector) => { - expect(connector.colliders.length).toBeGreaterThan(0); - expect(connector.colliders.length).toBeLessThanOrEqual(2); - }); - }); - - it('backs ramp traversal connectors with physical bridge strips and one assist zone', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const rampConnectors = geometry.routeConnectors.filter( - (connector) => connector.movement === 'ramp' - ); - - expect(rampConnectors.length).toBeGreaterThan(0); - rampConnectors.forEach((connector) => { - expect(connector.assistZones).toHaveLength(1); - expect(connector.assistZones[0]?.kind).toBe('ramp'); - expect(connector.colliders.length).toBeGreaterThan(0); - expect(connector.colliders.every((collider) => - collider.kind === 'route-connector' && - collider.movement === 'ramp' && - collider.routeId === 'first_walk' - )).toBe(true); - }); - }); - - it('keeps generated ramp bridge strips on the authored ramp line outside solid blockers', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const solidBlockers = geometry.gridColliders.filter((collider) => collider.kind === 'solid'); - const rampConnectors = geometry.routeConnectors.filter( - (connector) => connector.movement === 'ramp' - ); - - rampConnectors.forEach((connector) => { - const rampZone = connector.assistZones[0]; - connector.colliders.forEach((strip) => { - const surfaceY = rampZone - ? getSegmentYAtX(rampZone.from, rampZone.to, strip.x) - : undefined; - expect(surfaceY).toBeDefined(); - expect(getColliderTop(strip)).toBeGreaterThanOrEqual(surfaceY ?? 0); - expect(getColliderTop(strip) - (surfaceY ?? 0)).toBeLessThanOrEqual(1); - expect(solidBlockers.some((solid) => collidersOverlap(strip, solid))).toBe(false); - }); - }); - }); - - it('gives the Outskirts to Cicka Home ramp a collider-backed handoff', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const connector = geometry.routeConnectors.find( - (candidate) => candidate.id === 'route:first_walk:outskirts:cicka_home' - ); - const solidBlockers = geometry.gridColliders.filter((collider) => collider.kind === 'solid'); - - expect(connector?.movement).toBe('ramp'); - expect(connector?.colliders.length).toBeGreaterThan(3); - connector?.colliders.forEach((strip) => { - expect(solidBlockers.some((solid) => collidersOverlap(strip, solid))).toBe(false); - }); - const cickaHomeLeftEdgeX = 36 * RIDGE_BLOCKOUT.cell; - expect( - Math.max(...(connector?.colliders.map((collider) => collider.x + collider.width / 2) ?? [])) - ).toBeGreaterThanOrEqual(cickaHomeLeftEdgeX - 1); - }); - - it('does not generate collider platforms for future routes', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const futureRouteIds = new Set(RIDGE_BLOCKOUT.futureRoutes.map((route) => route.id)); - - expect(geometry.colliders.some((collider) => - collider.routeId !== undefined && futureRouteIds.has(collider.routeId) - )).toBe(false); - }); - - it('keeps the Stampede shortcut unavailable until the Stampede stamp exists', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT, { stampIds: [] }); - const stampedeShortcut = geometry.shortcutConnections.find( - (connection) => connection.id === 'stampede_sketch' - ); - - expect(isRidgeBlockoutShortcutAvailable('stampede_sketch', { stampIds: [] })).toBe(false); - expect(stampedeShortcut?.unlocked).toBe(false); - expect(stampedeShortcut?.colliders).toEqual([]); - expect(stampedeShortcut?.assistZones).toEqual([]); - expect(geometry.colliders.some((collider) => collider.shortcutId === 'stampede_sketch')).toBe(false); - }); - - it('creates Stampede shortcut drop assist from progress', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT, { - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }); - const stampedeShortcut = geometry.shortcutConnections.find( - (connection) => connection.id === 'stampede_sketch' - ); - - expect(isRidgeBlockoutShortcutAvailable('stampede_sketch', { - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - })).toBe(true); - expect(stampedeShortcut?.unlocked).toBe(true); - expect(stampedeShortcut?.movement).toBe('drop'); - expect(stampedeShortcut?.assistZones.some((zone) => zone.kind === 'drop')).toBe(true); - expect(geometry.assistZones.some((zone) => zone.shortcutId === 'stampede_sketch')).toBe(true); - }); - - it('resolves anchor positions from the matching grid symbol bounding box', () => { - const spawnRoom = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, 'outskirts'); - const spawnAnchor = spawnRoom?.anchors.find((anchor) => anchor.symbol === '1'); - if (!spawnRoom || !spawnAnchor) throw new Error('missing spawn anchor'); - - expect(getRidgeBlockoutAnchorPoint(RIDGE_BLOCKOUT, spawnRoom, spawnAnchor)).toMatchObject({ - roomId: 'outskirts', - symbol: '1', - kind: 'player_spawn', - x: 360, - y: 3768 - }); - }); - - it('keeps the Cicka Home secret chamber enclosed by solid wall colliders', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const chamberColliders = geometry.gridColliders.filter((collider) => - collider.roomId === 'cicka_home' && - collider.kind === 'solid' && - ( - collider.id === 'cicka_home:6:5:#' || - collider.id === 'cicka_home:10:5:#' || - collider.id === 'cicka_home:8:5:#' || - collider.id === 'cicka_home:8:26:#' - ) - ); - - expect(chamberColliders.map((collider) => collider.id).sort()).toEqual([ - 'cicka_home:10:5:#', - 'cicka_home:6:5:#', - 'cicka_home:8:26:#', - 'cicka_home:8:5:#' - ]); - expect(chamberColliders.every((collider) => !isMantleTargetCollider(collider))).toBe(true); - }); - - it('keeps Cicka reachable on the Cicka Home perch shelf in the greybox', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const cickaHome = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, 'cicka_home'); - const cickaAnchor = cickaHome?.anchors.find((anchor) => anchor.attrs.id === 'cicka'); - if (!cickaHome || !cickaAnchor) throw new Error('missing Cicka Home anchor'); - - const cickaPoint = getRidgeBlockoutAnchorPoint(RIDGE_BLOCKOUT, cickaHome, cickaAnchor); - const supportingFloor = geometry.gridColliders.find((collider) => - collider.roomId === 'cicka_home' && - collider.kind === 'solid' && - cickaPoint !== undefined && - cickaPoint.x >= collider.x - collider.width / 2 && - cickaPoint.x <= collider.x + collider.width / 2 && - cickaPoint.y < collider.y && - collider.y - cickaPoint.y <= RIDGE_BLOCKOUT.cell * 1.5 - ); - - expect(cickaPoint).toBeDefined(); - expect(supportingFloor?.id).toBe('cicka_home:6:5:#'); - }); - - it('uses a climb connector from Cicka Home toward Work Artifact instead of an unusable steep ramp', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const cickaToWork = geometry.routeConnectors.find( - (connector) => connector.id === 'route:first_walk:cicka_home:work_artifact' - ); - - expect(cickaToWork?.movement).toBe('climb'); - expect(cickaToWork?.assistZones).toHaveLength(1); - expect(cickaToWork?.assistZones[0]?.kind).toBe('climb'); - expect(cickaToWork?.assistZones[0]?.from.x).toBe(cickaToWork?.assistZones[0]?.to.x); - const cickaFloor = geometry.gridColliders.find((collider) => - collider.id === 'cicka_home:13:0:#' - ); - expect(cickaToWork?.assistZones[0]?.from.y).toBe( - cickaFloor ? cickaFloor.y - cickaFloor.height / 2 : undefined - ); - expect(isTraversalPathOccludedBySolid({ - from: { - x: cickaToWork?.assistZones[0]?.from.x ?? 0, - y: (cickaToWork?.assistZones[0]?.from.y ?? 0) - 31 - }, - to: { - x: cickaToWork?.assistZones[0]?.to.x ?? 0, - y: (cickaToWork?.assistZones[0]?.to.y ?? 0) - 31 - }, - bodySize: { width: 34, height: 62 }, - solidRects: geometry.gridColliders.filter((collider) => collider.kind === 'solid') - })).toBe(false); - expect(cickaToWork?.colliders).toEqual([]); - }); -}); - -function getColliderTop(collider: { y: number; height: number }): number { - return collider.y - collider.height / 2; -} - -function getSegmentYAtX( - from: { x: number; y: number }, - to: { x: number; y: number }, - x: number -): number | undefined { - const minX = Math.min(from.x, to.x); - const maxX = Math.max(from.x, to.x); - if (x < minX || x > maxX) return undefined; - const dx = to.x - from.x; - if (Math.abs(dx) < 0.001) return Math.min(from.y, to.y); - return from.y + (to.y - from.y) * ((x - from.x) / dx); -} - -function collidersOverlap( - left: { x: number; y: number; width: number; height: number }, - right: { x: number; y: number; width: number; height: number } -): boolean { - return ( - Math.abs(left.x - right.x) * 2 < left.width + right.width && - Math.abs(left.y - right.y) * 2 < left.height + right.height - ); -} diff --git a/src/game/scenes/ridge/blockout/geometry.ts b/src/game/scenes/ridge/blockout/geometry.ts deleted file mode 100644 index 0205b6de..00000000 --- a/src/game/scenes/ridge/blockout/geometry.ts +++ /dev/null @@ -1,811 +0,0 @@ -import { - RIDGE_BLOCKOUT_LADDER_SYMBOL, - RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - findRidgeBlockoutAnchor, - findRidgeBlockoutRoom, - type RidgeBlockoutAnchor, - type RidgeBlockoutTraversalMovement, - type RidgeBlockoutMap, - type RidgeBlockoutRect, - type RidgeBlockoutRoom -} from './parser'; -export { isRidgeBlockoutShortcutAvailable } from './progress'; -import { isRidgeBlockoutShortcutAvailable } from './progress'; - -export type RidgeBlockoutColliderKind = - | 'solid' - | 'platform' - | 'route-connector' - | 'shortcut-connector'; - -export type RidgeTraversalMovement = RidgeBlockoutTraversalMovement; - -export type RidgeBlockoutAssistZoneKind = - | 'ramp' - | 'climb' - | 'drop'; - -export interface RidgeBlockoutBounds { - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeBlockoutCollider { - id: string; - kind: RidgeBlockoutColliderKind; - movement?: RidgeTraversalMovement; - roomId?: string; - routeId?: string; - shortcutId?: string; - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeBlockoutAssistZone { - id: string; - kind: RidgeBlockoutAssistZoneKind; - movement: RidgeTraversalMovement; - routeId?: string; - shortcutId?: string; - from: { - x: number; - y: number; - }; - to: { - x: number; - y: number; - }; - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeBlockoutAnchorPoint { - roomId: string; - symbol: string; - kind: string; - attrs: Readonly>; - x: number; - y: number; -} - -export interface RidgeBlockoutAnchorSelector { - roomId: string; - symbol?: string; - kind?: string; - attrId?: string; -} - -export interface RidgeBlockoutRoomBounds { - roomId: string; - title: string; - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeBlockoutShortcutConnection { - id: string; - unlocked: boolean; - movement: RidgeTraversalMovement; - from: RidgeBlockoutAnchorPoint; - to: { - x: number; - y: number; - }; - colliders: readonly RidgeBlockoutCollider[]; - assistZones: readonly RidgeBlockoutAssistZone[]; - platforms: readonly RidgeBlockoutCollider[]; -} - -export interface RidgeBlockoutTraversalConnector { - id: string; - movement: RidgeTraversalMovement; - routeId?: string; - shortcutId?: string; - from: RidgeBlockoutAnchorPoint; - to: RidgeBlockoutAnchorPoint | { x: number; y: number }; - colliders: readonly RidgeBlockoutCollider[]; - assistZones: readonly RidgeBlockoutAssistZone[]; -} - -export interface RidgeBlockoutGeometry { - bounds: RidgeBlockoutBounds; - roomBounds: readonly RidgeBlockoutRoomBounds[]; - anchorPoints: readonly RidgeBlockoutAnchorPoint[]; - gridColliders: readonly RidgeBlockoutCollider[]; - routeConnectors: readonly RidgeBlockoutTraversalConnector[]; - shortcutConnections: readonly RidgeBlockoutShortcutConnection[]; - assistZones: readonly RidgeBlockoutAssistZone[]; - colliders: readonly RidgeBlockoutCollider[]; -} - -export interface RidgeBlockoutGeometryOptions { - stampIds?: readonly string[]; -} - -const COLLIDER_SYMBOLS = new Set(['#', '_']); -export function deriveRidgeBlockoutGeometry( - map: RidgeBlockoutMap, - options: RidgeBlockoutGeometryOptions = {} -): RidgeBlockoutGeometry { - const gridColliders = deriveGridColliders(map); - const solidBlockers = gridColliders.filter((collider) => collider.kind === 'solid'); - const routeConnectors = deriveFirstWalkRouteConnectors(map, solidBlockers); - const routeColliders = routeConnectors.flatMap((connection) => connection.colliders); - const shortcutConnections = deriveShortcutConnections(map, options, solidBlockers); - const shortcutColliders = shortcutConnections.flatMap((connection) => connection.colliders); - const assistZones = [ - ...routeConnectors.flatMap((connection) => connection.assistZones), - ...shortcutConnections.flatMap((connection) => connection.assistZones) - ]; - return { - bounds: deriveRidgeBlockoutBounds(map), - roomBounds: deriveRoomBounds(map), - anchorPoints: deriveAnchorPoints(map), - gridColliders, - routeConnectors, - shortcutConnections, - assistZones, - colliders: [...gridColliders, ...routeColliders, ...shortcutColliders] - }; -} - -export function deriveRidgeBlockoutBounds(map: RidgeBlockoutMap): RidgeBlockoutBounds { - const maxX = Math.max( - ...map.rooms.map((room) => (room.place.x + room.size.width) * map.cell), - map.cell - ); - const maxY = Math.max( - ...map.rooms.map((room) => (room.place.y + room.size.height) * map.cell), - map.cell - ); - return { - x: 0, - y: 0, - width: maxX + map.cell, - height: maxY + map.cell - }; -} - -export function getRidgeBlockoutAnchorPoint( - map: RidgeBlockoutMap, - room: RidgeBlockoutRoom, - anchor: RidgeBlockoutAnchor -): RidgeBlockoutAnchorPoint | undefined { - const cells: Array<{ column: number; row: number }> = []; - room.grid.forEach((row, rowIndex) => { - [...row].forEach((symbol, columnIndex) => { - if (symbol === anchor.symbol) { - cells.push({ column: columnIndex, row: rowIndex }); - } - }); - }); - - if (!cells.length) return undefined; - const minColumn = Math.min(...cells.map((cell) => cell.column)); - const maxColumn = Math.max(...cells.map((cell) => cell.column)); - const minRow = Math.min(...cells.map((cell) => cell.row)); - const maxRow = Math.max(...cells.map((cell) => cell.row)); - - return { - roomId: room.id, - symbol: anchor.symbol, - kind: anchor.kind, - attrs: anchor.attrs, - x: (room.place.x + (minColumn + maxColumn + 1) / 2) * map.cell, - y: (room.place.y + (minRow + maxRow + 1) / 2) * map.cell - }; -} - -export function findRidgeBlockoutAnchorPoint( - geometry: Pick, - selector: RidgeBlockoutAnchorSelector -): RidgeBlockoutAnchorPoint | undefined { - return geometry.anchorPoints.find((point) => - point.roomId === selector.roomId && - (!selector.symbol || point.symbol === selector.symbol) && - (!selector.kind || point.kind === selector.kind) && - (!selector.attrId || point.attrs.id === selector.attrId) - ); -} - -export function getRidgeBlockoutSpawnPoint(map: RidgeBlockoutMap): RidgeBlockoutAnchorPoint { - const spawnRoom = findRidgeBlockoutRoom(map, map.spawn.roomId); - const spawnAnchor = spawnRoom?.anchors.find( - (anchor) => anchor.symbol === map.spawn.anchorSymbol - ); - const point = spawnRoom && spawnAnchor - ? getRidgeBlockoutAnchorPoint(map, spawnRoom, spawnAnchor) - : undefined; - - if (!point) { - throw new Error( - `Ridge blockout spawn anchor "${map.spawn.anchorSymbol}" in room "${map.spawn.roomId}" could not be resolved` - ); - } - - return point; -} - -function deriveRoomBounds(map: RidgeBlockoutMap): readonly RidgeBlockoutRoomBounds[] { - return map.rooms.map((room) => ({ - roomId: room.id, - title: room.title, - x: room.place.x * map.cell, - y: room.place.y * map.cell, - width: room.size.width * map.cell, - height: room.size.height * map.cell - })); -} - -function deriveAnchorPoints(map: RidgeBlockoutMap): readonly RidgeBlockoutAnchorPoint[] { - return map.rooms.flatMap((room) => - room.anchors.flatMap((anchor) => { - const point = getRidgeBlockoutAnchorPoint(map, room, anchor); - return point ? [point] : []; - }) - ); -} - -function deriveGridColliders(map: RidgeBlockoutMap): readonly RidgeBlockoutCollider[] { - return map.rooms.flatMap((room) => { - const colliders: RidgeBlockoutCollider[] = []; - room.grid.forEach((row, rowIndex) => { - let columnIndex = 0; - while (columnIndex < row.length) { - const symbol = row[columnIndex]; - if (!COLLIDER_SYMBOLS.has(symbol)) { - columnIndex += 1; - continue; - } - - const runStart = columnIndex; - while (columnIndex < row.length && row[columnIndex] === symbol) { - columnIndex += 1; - } - const runLength = columnIndex - runStart; - const isPlatform = symbol === '_'; - const height = isPlatform ? Math.max(12, Math.round(map.cell * 0.28)) : map.cell; - colliders.push({ - id: `${room.id}:${rowIndex}:${runStart}:${symbol}`, - kind: isPlatform ? 'platform' : 'solid', - roomId: room.id, - x: (room.place.x + runStart) * map.cell + runLength * map.cell / 2, - y: (room.place.y + rowIndex) * map.cell + (isPlatform ? map.cell * 0.58 : map.cell / 2), - width: runLength * map.cell, - height - }); - } - }); - return colliders; - }); -} - -function deriveFirstWalkRouteConnectors( - map: RidgeBlockoutMap, - solidBlockers: readonly RidgeBlockoutCollider[] -): readonly RidgeBlockoutTraversalConnector[] { - const firstWalk = map.routes.find((route) => route.id === 'first_walk'); - if (!firstWalk) return []; - - return firstWalk.roomIds.flatMap((roomId, index) => { - const nextRoomId = firstWalk.roomIds[index + 1]; - if (!nextRoomId) return []; - const from = getRouteExit(map, roomId, nextRoomId); - const to = getRouteExit(map, nextRoomId, roomId); - const fromRoom = findRidgeBlockoutRoom(map, roomId); - const toRoom = findRidgeBlockoutRoom(map, nextRoomId); - if (!from || !to || !fromRoom || !toRoom) return []; - const movement = resolveTraversalMovement(from.anchor, to.anchor, from.point, to.point, map.cell); - const ladderSegment = movement === 'climb' - ? resolveLadderClimbSegment({ - map, - rooms: [fromRoom, toRoom], - start: from.point, - end: to.point - }) - : undefined; - return [createTraversalConnector({ - cell: map.cell, - idPrefix: `route:${firstWalk.id}:${roomId}:${nextRoomId}`, - colliderKind: 'route-connector', - routeId: firstWalk.id, - start: from.point, - end: to.point, - movement, - solidBlockers, - assistStart: ladderSegment?.start, - assistEnd: ladderSegment?.end - })]; - }); -} - -function deriveShortcutConnections( - map: RidgeBlockoutMap, - options: RidgeBlockoutGeometryOptions, - solidBlockers: readonly RidgeBlockoutCollider[] -): readonly RidgeBlockoutShortcutConnection[] { - return map.shortcuts.flatMap((shortcut) => { - const sourceRoom = findRidgeBlockoutRoom(map, shortcut.fromRoomId); - const targetRoom = findRidgeBlockoutRoom(map, shortcut.toRoomId); - if (!sourceRoom || !targetRoom) return []; - - const sourceAnchor = findRidgeBlockoutAnchor(sourceRoom, (anchor) => - anchor.attrs.to === shortcut.toRoomId && anchor.attrs.requires === shortcut.id - ); - if (!sourceAnchor) return []; - - const from = getRidgeBlockoutAnchorPoint(map, sourceRoom, sourceAnchor); - if (!from) return []; - - const target = getShortcutTargetPoint(map, targetRoom, shortcut.id); - const movement = resolveShortcutMovement(shortcut.kind); - const unlocked = isRidgeBlockoutShortcutAvailable(shortcut.id, { - stampIds: options.stampIds ?? [] - }); - const connection = unlocked - ? createTraversalConnector({ - cell: map.cell, - idPrefix: `shortcut:${shortcut.id}`, - colliderKind: 'shortcut-connector', - shortcutId: shortcut.id, - start: from, - end: target, - movement, - solidBlockers - }) - : createEmptyTraversalConnector({ - idPrefix: `shortcut:${shortcut.id}`, - shortcutId: shortcut.id, - start: from, - end: target, - movement - }); - - return [{ - id: shortcut.id, - unlocked, - movement, - from, - to: target, - colliders: connection.colliders, - assistZones: connection.assistZones, - platforms: connection.colliders - }]; - }); -} - -interface RidgeRouteExit { - anchor: RidgeBlockoutAnchor; - point: RidgeBlockoutAnchorPoint; -} - -function getRouteExit( - map: RidgeBlockoutMap, - roomId: string, - toRoomId: string -): RidgeRouteExit | undefined { - const room = findRidgeBlockoutRoom(map, roomId); - if (!room) return undefined; - const anchor = findRidgeBlockoutAnchor(room, (candidate) => - candidate.kind === 'exit' && candidate.attrs.to === toRoomId - ); - const point = anchor ? getRidgeBlockoutAnchorPoint(map, room, anchor) : undefined; - return anchor && point ? { anchor, point } : undefined; -} - -function getShortcutTargetPoint( - map: RidgeBlockoutMap, - room: RidgeBlockoutRoom, - shortcutId: string -): { x: number; y: number } { - const namedTarget = shortcutId === 'stampede_sketch' - ? room.rects.find((rect) => rect.id === 'stampede_return_landing') - : undefined; - const rect = namedTarget ?? room.rects[0]; - if (rect) { - return getRectCenterPoint(map, room, rect); - } - return { - x: (room.place.x + room.size.width / 2) * map.cell, - y: (room.place.y + room.size.height / 2) * map.cell - }; -} - -function getRectCenterPoint( - map: RidgeBlockoutMap, - room: RidgeBlockoutRoom, - rect: RidgeBlockoutRect -): { x: number; y: number } { - return { - x: (room.place.x + rect.x + rect.width / 2) * map.cell, - y: (room.place.y + rect.y + rect.height / 2) * map.cell - }; -} - -function createTraversalConnector({ - cell, - idPrefix, - colliderKind, - routeId, - shortcutId, - start, - end, - movement, - solidBlockers, - assistStart, - assistEnd -}: { - cell: number; - idPrefix: string; - colliderKind: Extract; - routeId?: string; - shortcutId?: string; - start: { x: number; y: number }; - end: { x: number; y: number }; - movement: RidgeTraversalMovement; - solidBlockers?: readonly RidgeBlockoutCollider[]; - assistStart?: { x: number; y: number }; - assistEnd?: { x: number; y: number }; -}): RidgeBlockoutTraversalConnector { - if (movement === 'jump') { - return { - id: idPrefix, - movement, - routeId, - shortcutId, - from: start as RidgeBlockoutAnchorPoint, - to: end, - colliders: createJumpPlatforms({ - cell, - idPrefix, - kind: colliderKind, - routeId, - shortcutId, - start, - end - }), - assistZones: [] - }; - } - - const surfaceStart = assistStart ?? toSurfacePoint(start, cell, movement); - const surfaceEnd = assistEnd ?? toSurfacePoint(end, cell, movement); - const zoneKind = movement === 'ramp' - ? 'ramp' - : movement === 'climb' - ? 'climb' - : 'drop'; - return { - id: idPrefix, - movement, - routeId, - shortcutId, - from: start as RidgeBlockoutAnchorPoint, - to: end, - colliders: movement === 'ramp' - ? createRampBridgePlatforms({ - cell, - idPrefix, - kind: colliderKind, - routeId, - shortcutId, - start: surfaceStart, - end: surfaceEnd, - solidBlockers: solidBlockers ?? [] - }) - : [], - assistZones: [createAssistZone({ - cell, - id: idPrefix, - kind: zoneKind, - movement, - routeId, - shortcutId, - start: surfaceStart, - end: surfaceEnd - })] - }; -} - -function resolveLadderClimbSegment({ - map, - rooms, - start, - end -}: { - map: RidgeBlockoutMap; - rooms: readonly RidgeBlockoutRoom[]; - start: { x: number; y: number }; - end: { x: number; y: number }; -}): { start: { x: number; y: number }; end: { x: number; y: number } } | undefined { - const cells = rooms.flatMap((room) => getLadderCells(map, room)); - if (cells.length === 0) return undefined; - - const midpointX = (start.x + end.x) / 2; - const x = cells.reduce((best, cell) => - Math.abs(cell.x - midpointX) < Math.abs(best.x - midpointX) ? cell : best - ).x; - - return { - start: { x, y: toSurfacePoint(start, map.cell, 'climb').y }, - end: { x, y: toSurfacePoint(end, map.cell, 'climb').y } - }; -} - -function getLadderCells( - map: RidgeBlockoutMap, - room: RidgeBlockoutRoom -): readonly { x: number; y: number }[] { - return room.grid.flatMap((row, rowIndex) => - [...row].flatMap((symbol, columnIndex) => - symbol === RIDGE_BLOCKOUT_LADDER_SYMBOL - ? [{ - x: (room.place.x + columnIndex + 0.5) * map.cell, - y: (room.place.y + rowIndex + 0.5) * map.cell - }] - : [] - ) - ); -} - -function createEmptyTraversalConnector({ - idPrefix, - routeId, - shortcutId, - start, - end, - movement -}: { - idPrefix: string; - routeId?: string; - shortcutId?: string; - start: RidgeBlockoutAnchorPoint; - end: { x: number; y: number }; - movement: RidgeTraversalMovement; -}): RidgeBlockoutTraversalConnector { - return { - id: idPrefix, - movement, - routeId, - shortcutId, - from: start, - to: end, - colliders: [], - assistZones: [] - }; -} - -function createJumpPlatforms({ - cell, - idPrefix, - kind, - routeId, - shortcutId, - start, - end -}: { - cell: number; - idPrefix: string; - kind: Extract; - routeId?: string; - shortcutId?: string; - start: { x: number; y: number }; - end: { x: number; y: number }; -}): readonly RidgeBlockoutCollider[] { - const distanceX = Math.abs(end.x - start.x); - const distanceY = Math.abs(end.y - start.y); - const steps = Math.max( - 1, - Math.min(2, Math.ceil(Math.max(distanceX / (cell * 11), distanceY / (cell * 4)))) - ); - - return Array.from({ length: steps }, (_, index) => { - const t = (index + 1) / (steps + 1); - return { - id: `${idPrefix}:${index + 1}`, - kind, - movement: 'jump', - routeId, - shortcutId, - x: lerp(start.x, end.x, t), - y: lerp(start.y, end.y, t) + cell * 0.56, - width: Math.max(Math.round(cell * 3.4), Math.min(Math.round(distanceX * 0.34), Math.round(cell * 7))), - height: Math.max(12, Math.round(cell * 0.24)) - }; - }); -} - -function createRampBridgePlatforms({ - cell, - idPrefix, - kind, - routeId, - shortcutId, - start, - end, - solidBlockers -}: { - cell: number; - idPrefix: string; - kind: Extract; - routeId?: string; - shortcutId?: string; - start: { x: number; y: number }; - end: { x: number; y: number }; - solidBlockers: readonly RidgeBlockoutCollider[]; -}): readonly RidgeBlockoutCollider[] { - const distanceX = Math.abs(end.x - start.x); - const distanceY = Math.abs(end.y - start.y); - if (distanceX < 0.001 && distanceY < 0.001) return []; - - const stripCount = Math.max( - 2, - Math.ceil(Math.max(distanceX / (cell * 0.9), distanceY / (cell * 0.3))) - ); - const width = Math.max( - Math.round(cell * 1.2), - Math.round(distanceX / stripCount + cell * 0.72) - ); - const height = Math.max(12, Math.round(cell * 0.24)); - const catchOffsetY = 0; - - return Array.from({ length: stripCount }, (_, index) => { - const t = (index + 0.5) / stripCount; - const surfaceY = lerp(start.y, end.y, t); - return { - id: `${idPrefix}:ramp:${index + 1}`, - kind, - movement: 'ramp' as const, - routeId, - shortcutId, - x: lerp(start.x, end.x, t), - y: surfaceY + catchOffsetY + height / 2, - width, - height - }; - }) - .map((collider) => trimColliderAgainstSolidBlockers(collider, solidBlockers, cell)) - .map((collider) => collider - ? { - ...collider, - y: getSegmentYAtX(start, end, collider.x) + catchOffsetY + height / 2 - } - : undefined) - .filter((collider): collider is RidgeBlockoutCollider => collider !== undefined); -} - -function createAssistZone({ - cell, - id, - kind, - movement, - routeId, - shortcutId, - start, - end -}: { - cell: number; - id: string; - kind: RidgeBlockoutAssistZoneKind; - movement: RidgeTraversalMovement; - routeId?: string; - shortcutId?: string; - start: { x: number; y: number }; - end: { x: number; y: number }; -}): RidgeBlockoutAssistZone { - const paddingX = kind === 'climb' ? cell * 1.25 : cell * 1.75; - const paddingY = kind === 'ramp' ? cell * 1.9 : cell * 2.4; - const minX = Math.min(start.x, end.x); - const maxX = Math.max(start.x, end.x); - const minY = Math.min(start.y, end.y); - const maxY = Math.max(start.y, end.y); - return { - id, - kind, - movement, - routeId, - shortcutId, - from: start, - to: end, - x: (minX + maxX) / 2, - y: (minY + maxY) / 2, - width: Math.max(cell * 2, maxX - minX + paddingX * 2), - height: Math.max(cell * 2, maxY - minY + paddingY * 2) - }; -} - -function resolveTraversalMovement( - fromAnchor: RidgeBlockoutAnchor, - toAnchor: RidgeBlockoutAnchor, - from: { x: number; y: number }, - to: { x: number; y: number }, - cell: number -): RidgeTraversalMovement { - const explicit = parseTraversalMovement(fromAnchor.attrs.movement ?? toAnchor.attrs.movement); - if (explicit) return explicit; - if (Math.abs(to.y - from.y) > cell * 4) return 'climb'; - if (Math.abs(to.x - from.x) > cell * 8 && Math.abs(to.y - from.y) < cell * 2) return 'jump'; - return 'ramp'; -} - -function resolveShortcutMovement(kind: string | undefined): RidgeTraversalMovement { - if (kind?.includes('drop') || kind?.includes('fall')) return 'drop'; - return parseTraversalMovement(kind) ?? 'ramp'; -} - -function parseTraversalMovement(value: string | undefined): RidgeTraversalMovement | undefined { - return value && RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(value) - ? value as RidgeTraversalMovement - : undefined; -} - -function toSurfacePoint( - point: { x: number; y: number }, - cell: number, - movement: RidgeTraversalMovement -): { x: number; y: number } { - if (movement === 'climb') return { x: point.x, y: point.y + cell * 0.5 }; - if (movement === 'drop') return { x: point.x, y: point.y + cell * 0.2 }; - return { x: point.x, y: point.y + cell * 0.58 }; -} - -function lerp(start: number, end: number, t: number): number { - return start + (end - start) * t; -} - -function getSegmentYAtX( - from: { x: number; y: number }, - to: { x: number; y: number }, - x: number -): number { - const dx = to.x - from.x; - if (Math.abs(dx) < 0.001) return Math.min(from.y, to.y); - return lerp(from.y, to.y, (x - from.x) / dx); -} - -function trimColliderAgainstSolidBlockers( - collider: RidgeBlockoutCollider, - solidBlockers: readonly RidgeBlockoutCollider[], - cell: number -): RidgeBlockoutCollider | undefined { - const clearance = 0.5; - let left = collider.x - collider.width / 2; - let right = collider.x + collider.width / 2; - const top = collider.y - collider.height / 2; - const bottom = collider.y + collider.height / 2; - - solidBlockers.forEach((solid) => { - const solidLeft = solid.x - solid.width / 2; - const solidRight = solid.x + solid.width / 2; - const solidTop = solid.y - solid.height / 2; - const solidBottom = solid.y + solid.height / 2; - const overlapsVertically = top < solidBottom && bottom > solidTop; - const overlapsHorizontally = left < solidRight && right > solidLeft; - if (!overlapsVertically || !overlapsHorizontally) return; - - if (collider.x < solid.x) { - right = Math.min(right, solidLeft - clearance); - } else if (collider.x > solid.x) { - left = Math.max(left, solidRight + clearance); - } else { - left = right; - } - }); - - const width = right - left; - if (width < cell * 0.45) return undefined; - - return { - ...collider, - x: (left + right) / 2, - width - }; -} diff --git a/src/game/scenes/ridge/blockout/index.ts b/src/game/scenes/ridge/blockout/index.ts deleted file mode 100644 index 9210b6a7..00000000 --- a/src/game/scenes/ridge/blockout/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './facts'; -export * from './geometry'; -export * from './parser'; -export * from './progress'; -export * from './ridgeBlockout'; -export * from './sourceCompiler'; -export * from './sourceContract'; diff --git a/src/game/scenes/ridge/blockout/parser.test.ts b/src/game/scenes/ridge/blockout/parser.test.ts deleted file mode 100644 index 6aea5857..00000000 --- a/src/game/scenes/ridge/blockout/parser.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { RIDGE_BLOCKOUT } from './ridgeBlockout'; -import { - RIDGE_BLOCKOUT_LADDER_SYMBOL, - findRidgeBlockoutRoom, - parseRidgeBlockout -} from './parser'; - -describe('ridge blockout runtime source', () => { - it('loads the generated real Ridge blockout without validation errors', () => { - expect(RIDGE_BLOCKOUT.validationErrors).toEqual([]); - expect(RIDGE_BLOCKOUT.language).toBe('ridge-v0'); - expect(RIDGE_BLOCKOUT.cell).toBe(48); - expect(RIDGE_BLOCKOUT.rooms).toHaveLength(12); - }); - - it('keeps the first-walk topology order from the source file', () => { - const firstWalk = RIDGE_BLOCKOUT.routes.find((route) => route.id === 'first_walk'); - - expect(firstWalk?.roomIds.slice(0, 4)).toEqual([ - 'outskirts', - 'cicka_home', - 'work_artifact', - 'stampede_blanket' - ]); - }); - - it('parses traversal movement metadata on exit anchors', () => { - const outskirts = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, 'outskirts'); - const cickaExit = outskirts?.anchors.find((anchor) => anchor.attrs.to === 'cicka_home'); - - expect(cickaExit?.attrs.movement).toBe('ramp'); - }); - - it('accepts the single-cell ladder marker in grids', () => { - expect(RIDGE_BLOCKOUT_LADDER_SYMBOL).toBe('L'); - const cickaHome = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, 'cicka_home'); - const cickaHomeGrid: readonly string[] = cickaHome?.grid ?? []; - const validationErrors: readonly string[] = RIDGE_BLOCKOUT.validationErrors; - - expect(cickaHomeGrid.some((row) => row.includes(RIDGE_BLOCKOUT_LADDER_SYMBOL))).toBe(true); - expect(validationErrors.some((error) => error.includes('unknown symbol "L"'))).toBe(false); - }); - - it('keeps the Cicka Home climb out of the lower floor explicit', () => { - const cickaHome = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, 'cicka_home'); - const workExit = cickaHome?.anchors.find((anchor) => anchor.attrs.to === 'work_artifact'); - - expect(workExit?.attrs.movement).toBe('climb'); - }); - - it('requires every room grid to match the declared size', () => { - RIDGE_BLOCKOUT.rooms.forEach((room) => { - expect(room.grid).toHaveLength(room.size.height); - room.grid.forEach((row) => { - expect(row).toHaveLength(room.size.width); - }); - }); - }); - - it('fails validation when the grid contains an unknown symbol', () => { - const parsed = parseRidgeBlockout(` -language ridge-v0 -cell 48 -world broken -title Broken -spawn room=test anchor=1 - -room test -title Test -place x=0 y=0 -size 2x1 - -grid -1X - -anchor 1 player_spawn id=start -`); - - expect(parsed.validationErrors).toContain('room "test" has unknown symbol "X" at 1,0'); - }); - - it('fails validation when an anchor uses an unknown traversal movement', () => { - const parsed = parseRidgeBlockout(` -language ridge-v0 -cell 48 -world broken -title Broken -spawn room=test anchor=1 - -room test -title Test -place x=0 y=0 -size 2x1 - -grid -12 - -anchor 1 player_spawn id=start -anchor 2 exit to=missing movement=teleport -`); - - expect(parsed.validationErrors).toContain( - 'room "test" anchor "2" has unknown movement "teleport"' - ); - }); - - it('rejects runtime-active overlaps between room beats', () => { - const parsed = parseRidgeBlockout(` -language ridge-v0 -cell 48 -world overlap -title Overlap -spawn room=a anchor=1 - -room a -title A -place x=0 y=0 -size 2x1 - -grid -1# - -anchor 1 player_spawn id=start - -room b -title B -place x=1 y=0 -size 1x1 - -grid -# -`); - - expect(parsed.validationErrors).toContain('runtime cell overlap at 1,0: a/# and b/#'); - }); - - it('resolves the declared spawn room and anchor', () => { - const spawnRoom = findRidgeBlockoutRoom(RIDGE_BLOCKOUT, RIDGE_BLOCKOUT.spawn.roomId); - const spawnAnchor = spawnRoom?.anchors.find( - (anchor) => anchor.symbol === RIDGE_BLOCKOUT.spawn.anchorSymbol - ); - - expect(RIDGE_BLOCKOUT.spawn).toEqual({ - roomId: 'outskirts', - anchorSymbol: '1' - }); - expect(spawnAnchor?.kind).toBe('player_spawn'); - }); -}); diff --git a/src/game/scenes/ridge/blockout/parser.ts b/src/game/scenes/ridge/blockout/parser.ts deleted file mode 100644 index cd1a92a2..00000000 --- a/src/game/scenes/ridge/blockout/parser.ts +++ /dev/null @@ -1,535 +0,0 @@ -export const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ - '.', - '#', - '_', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'C', - 'A', - '*', - '^', - '?' -]); - -export const RIDGE_BLOCKOUT_DESIGN_SYMBOLS = new Set(['=', '~', 'N', 'M']); -export const RIDGE_BLOCKOUT_LADDER_SYMBOL = 'L'; - -export const RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS = new Set([ - 'ramp', - 'jump', - 'climb', - 'drop' -]); - -export type RidgeBlockoutTraversalMovement = - | 'ramp' - | 'jump' - | 'climb' - | 'drop'; - -export interface RidgeBlockoutPoint { - x: number; - y: number; -} - -export interface RidgeBlockoutSize { - width: number; - height: number; -} - -export interface RidgeBlockoutAnchor { - symbol: string; - kind: string; - attrs: Readonly>; -} - -export interface RidgeBlockoutRect { - id: string; - x: number; - y: number; - width: number; - height: number; - attrs: Readonly>; -} - -export interface RidgeBlockoutRoom { - id: string; - title: string; - place: RidgeBlockoutPoint; - size: RidgeBlockoutSize; - theme?: string; - mood?: string; - links: readonly string[]; - props: readonly string[]; - grid: readonly string[]; - anchors: readonly RidgeBlockoutAnchor[]; - rects: readonly RidgeBlockoutRect[]; - declarations: readonly string[]; -} - -export interface RidgeBlockoutRoute { - id: string; - roomIds: readonly string[]; -} - -export interface RidgeBlockoutShortcut { - id: string; - fromRoomId: string; - toRoomId: string; - kind?: string; -} - -export interface RidgeBlockoutOptionalPocket { - id: string; - roomId?: string; - kind?: string; -} - -export interface RidgeBlockoutHomeMutation { - id: string; - attrs: Readonly>; -} - -export interface RidgeBlockoutMap { - language: string; - cell: number; - worldId: string; - title: string; - spawn: { - roomId: string; - anchorSymbol: string; - }; - routes: readonly RidgeBlockoutRoute[]; - futureRoutes: readonly RidgeBlockoutRoute[]; - shortcuts: readonly RidgeBlockoutShortcut[]; - optionalPockets: readonly RidgeBlockoutOptionalPocket[]; - homeMutations: readonly RidgeBlockoutHomeMutation[]; - rooms: readonly RidgeBlockoutRoom[]; - validationErrors: readonly string[]; -} - -interface MutableRoom { - id: string; - title: string; - place?: RidgeBlockoutPoint; - size?: RidgeBlockoutSize; - theme?: string; - mood?: string; - links: string[]; - props: string[]; - grid: string[]; - anchors: RidgeBlockoutAnchor[]; - rects: RidgeBlockoutRect[]; - declarations: string[]; -} - -const DEFAULT_MAP: Omit< - RidgeBlockoutMap, - 'rooms' | 'routes' | 'futureRoutes' | 'shortcuts' | 'optionalPockets' | 'homeMutations' | 'validationErrors' -> = { - language: '', - cell: 0, - worldId: '', - title: '', - spawn: { - roomId: '', - anchorSymbol: '' - } -}; - -const ROOM_DECLARATION_PREFIXES = [ - 'after ', - 'minigame ', - 'reward ', - 'future_reward ', - 'gate ', - 'future_shortcut ', - 'future ' -]; - -export function parseRidgeBlockout(source: string): RidgeBlockoutMap { - let language = DEFAULT_MAP.language; - let cell = DEFAULT_MAP.cell; - let worldId = DEFAULT_MAP.worldId; - let title = DEFAULT_MAP.title; - let spawn = DEFAULT_MAP.spawn; - const routes: RidgeBlockoutRoute[] = []; - const futureRoutes: RidgeBlockoutRoute[] = []; - const shortcuts: RidgeBlockoutShortcut[] = []; - const optionalPockets: RidgeBlockoutOptionalPocket[] = []; - const homeMutations: RidgeBlockoutHomeMutation[] = []; - const rooms: RidgeBlockoutRoom[] = []; - let currentRoom: MutableRoom | undefined; - let readingGrid = false; - - const finishCurrentRoom = () => { - if (!currentRoom) return; - rooms.push({ - id: currentRoom.id, - title: currentRoom.title || currentRoom.id, - place: currentRoom.place ?? { x: 0, y: 0 }, - size: currentRoom.size ?? { width: 0, height: 0 }, - theme: currentRoom.theme, - mood: currentRoom.mood, - links: currentRoom.links, - props: currentRoom.props, - grid: currentRoom.grid, - anchors: currentRoom.anchors, - rects: currentRoom.rects, - declarations: currentRoom.declarations - }); - currentRoom = undefined; - }; - - normalizeLines(source).forEach((line) => { - if (!line) { - readingGrid = false; - return; - } - - if (line.startsWith('room ')) { - finishCurrentRoom(); - currentRoom = { - id: line.slice('room '.length).trim(), - title: '', - links: [], - props: [], - grid: [], - anchors: [], - rects: [], - declarations: [] - }; - readingGrid = false; - return; - } - - if (!currentRoom) { - if (line.startsWith('language ')) { - language = line.slice('language '.length).trim(); - } else if (line.startsWith('cell ')) { - cell = Number(line.slice('cell '.length).trim()); - } else if (line.startsWith('world ')) { - worldId = line.slice('world '.length).trim(); - } else if (line.startsWith('title ')) { - title = line.slice('title '.length).trim(); - } else if (line.startsWith('spawn ')) { - const attrs = parseAttrs(line.slice('spawn '.length).split(/\s+/)); - spawn = { - roomId: attrs.room ?? '', - anchorSymbol: attrs.anchor ?? '' - }; - } else if (line.startsWith('route ')) { - routes.push(parseRoute(line, 'route')); - } else if (line.startsWith('future_route ')) { - futureRoutes.push(parseRoute(line, 'future_route')); - } else if (line.startsWith('shortcut ')) { - shortcuts.push(parseShortcut(line)); - } else if (line.startsWith('optional_pocket ')) { - optionalPockets.push(parseOptionalPocket(line)); - } else if (line.startsWith('home_mutation ')) { - homeMutations.push(parseHomeMutation(line)); - } - return; - } - - if (readingGrid) { - currentRoom.grid.push(line); - return; - } - - if (line === 'grid') { - readingGrid = true; - } else if (line.startsWith('title ')) { - currentRoom.title = line.slice('title '.length).trim(); - } else if (line.startsWith('place ')) { - currentRoom.place = parsePoint(line.slice('place '.length)); - } else if (line.startsWith('size ')) { - currentRoom.size = parseSize(line.slice('size '.length).trim()); - } else if (line.startsWith('theme ')) { - currentRoom.theme = line.slice('theme '.length).trim(); - } else if (line.startsWith('mood ')) { - currentRoom.mood = line.slice('mood '.length).trim(); - } else if (line.startsWith('links ')) { - currentRoom.links = line.slice('links '.length).split(/\s+/).filter(Boolean); - } else if (line.startsWith('props ')) { - currentRoom.props = line.slice('props '.length).split(',').map((prop) => prop.trim()).filter(Boolean); - } else if (line.startsWith('anchor ')) { - currentRoom.anchors.push(parseAnchor(line)); - } else if (line.startsWith('rect ')) { - currentRoom.rects.push(parseRect(line)); - } else if (ROOM_DECLARATION_PREFIXES.some((prefix) => line.startsWith(prefix))) { - currentRoom.declarations.push(line); - } - }); - - finishCurrentRoom(); - - const mapWithoutErrors: RidgeBlockoutMap = { - language, - cell, - worldId, - title, - spawn, - routes, - futureRoutes, - shortcuts, - optionalPockets, - homeMutations, - rooms, - validationErrors: [] - }; - - return { - ...mapWithoutErrors, - validationErrors: validateRidgeBlockout(mapWithoutErrors) - }; -} - -export function validateRidgeBlockout(map: RidgeBlockoutMap): readonly string[] { - const errors: string[] = []; - const roomIds = new Set(); - - if (map.language !== 'ridge-v0') { - errors.push(`unsupported language "${map.language}"`); - } - if (!Number.isFinite(map.cell) || map.cell <= 0) { - errors.push(`invalid cell size "${map.cell}"`); - } - if (!map.worldId) { - errors.push('missing world id'); - } - if (!map.spawn.roomId || !map.spawn.anchorSymbol) { - errors.push('missing spawn room or anchor'); - } - - map.rooms.forEach((room) => { - if (roomIds.has(room.id)) { - errors.push(`duplicate room "${room.id}"`); - } - roomIds.add(room.id); - - if (room.size.width <= 0 || room.size.height <= 0) { - errors.push(`room "${room.id}" has invalid size`); - } - if (room.grid.length !== room.size.height) { - errors.push(`room "${room.id}" grid height ${room.grid.length} does not match size ${room.size.height}`); - } - room.grid.forEach((row, rowIndex) => { - if (row.length !== room.size.width) { - errors.push(`room "${room.id}" row ${rowIndex + 1} width ${row.length} does not match size ${room.size.width}`); - } - [...row].forEach((symbol, columnIndex) => { - if (!isKnownRidgeBlockoutSymbol(symbol)) { - errors.push(`room "${room.id}" has unknown symbol "${symbol}" at ${columnIndex},${rowIndex}`); - } - }); - }); - - room.anchors.forEach((anchor) => { - if (!room.grid.some((row) => row.includes(anchor.symbol))) { - errors.push(`room "${room.id}" anchor "${anchor.symbol}" has no matching grid cell`); - } - if ( - anchor.attrs.movement !== undefined && - !RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(anchor.attrs.movement) - ) { - errors.push( - `room "${room.id}" anchor "${anchor.symbol}" has unknown movement "${anchor.attrs.movement}"` - ); - } - }); - }); - - if (!roomIds.has(map.spawn.roomId)) { - errors.push(`spawn room "${map.spawn.roomId}" does not exist`); - } else { - const spawnRoom = findRidgeBlockoutRoom(map, map.spawn.roomId); - const spawnAnchor = spawnRoom?.anchors.find((anchor) => anchor.symbol === map.spawn.anchorSymbol); - if (!spawnAnchor) { - errors.push(`spawn anchor "${map.spawn.anchorSymbol}" does not exist in room "${map.spawn.roomId}"`); - } - } - - [...map.routes, ...map.futureRoutes].forEach((route) => { - route.roomIds.forEach((roomId) => { - if (!roomIds.has(roomId)) { - errors.push(`route "${route.id}" references missing room "${roomId}"`); - } - }); - }); - - map.shortcuts.forEach((shortcut) => { - if (!roomIds.has(shortcut.fromRoomId)) { - errors.push(`shortcut "${shortcut.id}" references missing from room "${shortcut.fromRoomId}"`); - } - if (!roomIds.has(shortcut.toRoomId)) { - errors.push(`shortcut "${shortcut.id}" references missing to room "${shortcut.toRoomId}"`); - } - }); - - errors.push(...validateRuntimeCellOverlaps(map)); - return errors; -} - -export function findRidgeBlockoutRoom( - map: Pick, - roomId: string -): RidgeBlockoutRoom | undefined { - return map.rooms.find((room) => room.id === roomId); -} - -export function findRidgeBlockoutAnchor( - room: RidgeBlockoutRoom, - predicate: (anchor: RidgeBlockoutAnchor) => boolean -): RidgeBlockoutAnchor | undefined { - return room.anchors.find(predicate); -} - -export function isRuntimeActiveRidgeBlockoutSymbol(symbol: string): boolean { - return symbol !== '.' && RIDGE_BLOCKOUT_RUNTIME_SYMBOLS.has(symbol); -} - -function normalizeLines(source: string): readonly string[] { - return source - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => stripComment(line).trimEnd()); -} - -function stripComment(line: string): string { - const commentIndex = line.indexOf('//'); - return (commentIndex === -1 ? line : line.slice(0, commentIndex)).trim(); -} - -function parseRoute(line: string, prefix: 'route' | 'future_route'): RidgeBlockoutRoute { - const content = line.slice(prefix.length + 1).trim(); - const [id = '', ...rest] = content.split(/\s+/); - return { - id, - roomIds: rest.filter((part) => part !== '->') - }; -} - -function parseShortcut(line: string): RidgeBlockoutShortcut { - const [id = '', ...attrTokens] = line.slice('shortcut '.length).trim().split(/\s+/); - const attrs = parseAttrs(attrTokens); - return { - id, - fromRoomId: attrs.from ?? '', - toRoomId: attrs.to ?? '', - kind: attrs.kind - }; -} - -function parseOptionalPocket(line: string): RidgeBlockoutOptionalPocket { - const [id = '', ...attrTokens] = line.slice('optional_pocket '.length).trim().split(/\s+/); - const attrs = parseAttrs(attrTokens); - return { - id, - roomId: attrs.room, - kind: attrs.kind - }; -} - -function parseHomeMutation(line: string): RidgeBlockoutHomeMutation { - const [id = '', ...attrTokens] = line.slice('home_mutation '.length).trim().split(/\s+/); - return { - id, - attrs: parseAttrs(attrTokens) - }; -} - -function parseAnchor(line: string): RidgeBlockoutAnchor { - const [symbol = '', kind = '', ...attrTokens] = line.slice('anchor '.length).trim().split(/\s+/); - return { - symbol, - kind, - attrs: parseAttrs(attrTokens) - }; -} - -function parseRect(line: string): RidgeBlockoutRect { - const [id = '', ...attrTokens] = line.slice('rect '.length).trim().split(/\s+/); - const attrs = parseAttrs(attrTokens); - return { - id, - x: Number(attrs.x ?? 0), - y: Number(attrs.y ?? 0), - width: Number(attrs.w ?? 0), - height: Number(attrs.h ?? 0), - attrs: stripGeometryAttrs(attrs) - }; -} - -function parsePoint(content: string): RidgeBlockoutPoint { - const attrs = parseAttrs(content.split(/\s+/)); - return { - x: Number(attrs.x ?? 0), - y: Number(attrs.y ?? 0) - }; -} - -function parseSize(content: string): RidgeBlockoutSize { - const [width = '0', height = '0'] = content.split('x'); - return { - width: Number(width), - height: Number(height) - }; -} - -function parseAttrs(tokens: readonly string[]): Record { - return tokens.reduce>((attrs, token) => { - const separatorIndex = token.indexOf('='); - if (separatorIndex === -1) return attrs; - attrs[token.slice(0, separatorIndex)] = token.slice(separatorIndex + 1); - return attrs; - }, {}); -} - -function stripGeometryAttrs(attrs: Readonly>): Record { - const rest = { ...attrs }; - delete rest.x; - delete rest.y; - delete rest.w; - delete rest.h; - return rest; -} - -function isKnownRidgeBlockoutSymbol(symbol: string): boolean { - return ( - RIDGE_BLOCKOUT_RUNTIME_SYMBOLS.has(symbol) || - RIDGE_BLOCKOUT_DESIGN_SYMBOLS.has(symbol) || - symbol === RIDGE_BLOCKOUT_LADDER_SYMBOL - ); -} - -function validateRuntimeCellOverlaps(map: RidgeBlockoutMap): readonly string[] { - const errors: string[] = []; - const seen = new Map(); - - map.rooms.forEach((room) => { - room.grid.forEach((row, rowIndex) => { - [...row].forEach((symbol, columnIndex) => { - if (!isRuntimeActiveRidgeBlockoutSymbol(symbol)) return; - const key = `${room.place.x + columnIndex},${room.place.y + rowIndex}`; - const previous = seen.get(key); - if (previous && previous.roomId !== room.id) { - errors.push( - `runtime cell overlap at ${key}: ${previous.roomId}/${previous.symbol} and ${room.id}/${symbol}` - ); - return; - } - seen.set(key, { roomId: room.id, symbol }); - }); - }); - }); - - return errors; -} diff --git a/src/game/scenes/ridge/blockout/progress.ts b/src/game/scenes/ridge/blockout/progress.ts deleted file mode 100644 index aed3854a..00000000 --- a/src/game/scenes/ridge/blockout/progress.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; - -const SHORTCUT_STAMP_IDS: Readonly> = { - stampede_sketch: STAMPEDE_SKETCH_RIDGE_STAMP_ID -}; - -export function getRidgeBlockoutShortcutRequiredStampId(shortcutId: string): string | undefined { - return SHORTCUT_STAMP_IDS[shortcutId]; -} - -export function isRidgeBlockoutShortcutAvailable( - shortcutId: string, - progress: { stampIds: readonly string[] } -): boolean { - const stampId = getRidgeBlockoutShortcutRequiredStampId(shortcutId); - return stampId ? progress.stampIds.includes(stampId) : false; -} diff --git a/src/game/scenes/ridge/blockout/ridgeBlockout.ts b/src/game/scenes/ridge/blockout/ridgeBlockout.ts deleted file mode 100644 index 53cee24c..00000000 --- a/src/game/scenes/ridge/blockout/ridgeBlockout.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT } from './sources/folded-desk-ridge.generated'; - -export const RIDGE_BLOCKOUT_SOURCE = FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.source; -export const RIDGE_BLOCKOUT_TILE_REGISTRY = FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.tileRegistry; -export const RIDGE_BLOCKOUT_RUNTIME_TILE_ROOMS = - FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.runtimeTileRooms; -export const RIDGE_BLOCKOUT = FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.map; diff --git a/src/game/scenes/ridge/blockout/sourceCompiler.test.ts b/src/game/scenes/ridge/blockout/sourceCompiler.test.ts deleted file mode 100644 index 210a17cb..00000000 --- a/src/game/scenes/ridge/blockout/sourceCompiler.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - compileRidgeBlockoutSource, - serializeRidgeCompiledBlockout, - validateRidgeBlockoutSource -} from './sourceCompiler'; -import type { RidgeBlockoutSource } from './sourceContract'; -import { CONTRACT_FIXTURE_RIDGE_COMPILED_BLOCKOUT } from './sources/contract-fixture.generated'; -import { CONTRACT_FIXTURE_RIDGE_SOURCE } from './sources/contract-fixture.source'; -import { FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT } from './sources/folded-desk-ridge.generated'; -import { FOLDED_DESK_RIDGE_SOURCE } from './sources/folded-desk-ridge.source'; -import generatedFixtureSource from './sources/contract-fixture.generated.ts?raw'; -import generatedFoldedDeskSource from './sources/folded-desk-ridge.generated.ts?raw'; - -describe('ridge blockout source compiler', () => { - it('compiles a typed source into a RidgeBlockoutMap-compatible artifact', () => { - const compiled = compileRidgeBlockoutSource(CONTRACT_FIXTURE_RIDGE_SOURCE); - - expect(compiled.validationErrors).toEqual([]); - expect(compiled.map).toMatchObject({ - language: 'ridge-v0', - worldId: 'contract_fixture', - cell: 16, - spawn: { - roomId: 'start', - anchorSymbol: '1' - } - }); - expect(compiled.map.rooms).toHaveLength(3); - expect(compiled.runtimeTileRooms[0]?.runtimeTileRows).toEqual([ - [10, 0, 0, 11], - [0, 2, 2, 0], - [1, 1, 1, 1] - ]); - }); - - it('keeps the committed generated artifact in sync with deterministic serialization', () => { - const compiled = compileRidgeBlockoutSource(CONTRACT_FIXTURE_RIDGE_SOURCE); - - expect(CONTRACT_FIXTURE_RIDGE_COMPILED_BLOCKOUT.map.validationErrors).toEqual([]); - expect(serializeRidgeCompiledBlockout({ - exportName: 'CONTRACT_FIXTURE_RIDGE_COMPILED_BLOCKOUT', - compiled, - typeImportPath: '../sourceContract' - })).toBe(generatedFixtureSource); - }); - - it('compiles the folded desk Ridge source into the runtime blockout shape', () => { - const compiled = compileRidgeBlockoutSource(FOLDED_DESK_RIDGE_SOURCE); - const cickaHomeRuntimeTiles = compiled.runtimeTileRooms.find((room) => - room.id === 'cicka_home' - ); - - expect(compiled.map.validationErrors).toEqual([]); - expect(compiled.map).toEqual(FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.map); - expect(compiled.map.rooms).toHaveLength(12); - expect(compiled.map.routes.find((route) => route.id === 'first_walk')?.roomIds).toEqual([ - 'outskirts', - 'cicka_home', - 'work_artifact', - 'stampede_blanket', - 'switchback_shelf', - 'telegraph_terrace', - 'guide_overlook', - 'relay_gate', - 'domino_desk' - ]); - expect(compiled.map.homeMutations).toContainEqual({ - id: 'stampede_sketch', - attrs: { adds: 'stampede_note', opens: 'fold_drop_landing' } - }); - expect(cickaHomeRuntimeTiles?.runtimeTileRows[2]?.[27]).toBe(3); - }); - - it('keeps the committed folded desk generated artifact in sync', () => { - const compiled = compileRidgeBlockoutSource(FOLDED_DESK_RIDGE_SOURCE); - - expect(serializeRidgeCompiledBlockout({ - exportName: 'FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT', - compiled, - typeImportPath: '../sourceContract' - })).toBe(generatedFoldedDeskSource); - }); - - it('validates unknown symbols and bad grid dimensions', () => { - const errors = validateRidgeBlockoutSource(withSource({ - rooms: [{ - ...CONTRACT_FIXTURE_RIDGE_SOURCE.rooms[0], - grid: [ - '1X.2.', - '.__.', - '####' - ] - }, ...CONTRACT_FIXTURE_RIDGE_SOURCE.rooms.slice(1)] - })); - - expect(errors).toContain('room "start" row 1 width 5 does not match size 4'); - expect(errors).toContain('room "start" has unknown symbol "X" at 1,0'); - }); - - it('validates spawn anchors, route references, and traversal movements', () => { - const errors = validateRidgeBlockoutSource(withSource({ - spawn: { - roomId: 'start', - anchorSymbol: 'A' - }, - routes: [{ - id: 'first_walk', - roomIds: ['start', 'missing_room'] - }], - rooms: [{ - ...CONTRACT_FIXTURE_RIDGE_SOURCE.rooms[0], - anchors: [ - { symbol: '1', kind: 'player_spawn', attrs: { id: 'start' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'lookout', movement: 'teleport' } } - ] - }, ...CONTRACT_FIXTURE_RIDGE_SOURCE.rooms.slice(1)] - })); - - expect(errors).toContain('spawn anchor "A" does not exist in room "start"'); - expect(errors).toContain('route "first_walk" references missing room "missing_room"'); - expect(errors).toContain('room "start" anchor "2" has unknown movement "teleport"'); - }); - - it('validates duplicate tile symbols and runtime ids', () => { - const errors = validateRidgeBlockoutSource(withSource({ - tileRegistry: [ - { symbol: '.', id: 0, kind: 'empty', label: 'empty' }, - { symbol: '.', id: 1, kind: 'solid', label: 'duplicate symbol' }, - { symbol: '#', id: 1, kind: 'solid', label: 'duplicate id' }, - { symbol: 'AA', id: 2, kind: 'anchor', label: 'wide symbol' } - ] - })); - - expect(errors).toContain('duplicate tile symbol "."'); - expect(errors).toContain('duplicate runtime tile id "1"'); - expect(errors).toContain('tile symbol "AA" must be exactly one character'); - }); - - it('throws a grouped error when compilation receives invalid source', () => { - expect(() => compileRidgeBlockoutSource(withSource({ - routes: [{ - id: 'broken_route', - roomIds: ['start', 'missing_room'] - }] - }))).toThrow('Ridge blockout source validation failed'); - }); -}); - -function withSource(overrides: Partial): RidgeBlockoutSource { - return { - ...CONTRACT_FIXTURE_RIDGE_SOURCE, - ...overrides - }; -} diff --git a/src/game/scenes/ridge/blockout/sourceCompiler.ts b/src/game/scenes/ridge/blockout/sourceCompiler.ts deleted file mode 100644 index c3586d44..00000000 --- a/src/game/scenes/ridge/blockout/sourceCompiler.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { - RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - type RidgeBlockoutMap, - type RidgeBlockoutTraversalMovement -} from './parser'; -import type { - RidgeBlockoutSource, - RidgeBlockoutSourceAnchor, - RidgeBlockoutSourceRect, - RidgeBlockoutSourceRoom, - RidgeCompiledBlockout, - RidgeCompiledBlockoutRoom, - RidgeTileDefinition, - RidgeTileRegistry -} from './sourceContract'; - -export class RidgeBlockoutSourceValidationError extends Error { - readonly errors: readonly string[]; - - constructor(errors: readonly string[]) { - super(`Ridge blockout source validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); - this.name = 'RidgeBlockoutSourceValidationError'; - this.errors = errors; - } -} - -interface TileLookup { - bySymbol: ReadonlyMap; -} - -export function compileRidgeBlockoutSource(source: RidgeBlockoutSource): RidgeCompiledBlockout { - const validationErrors = validateRidgeBlockoutSource(source); - if (validationErrors.length > 0) { - throw new RidgeBlockoutSourceValidationError(validationErrors); - } - - const tileLookup = createTileLookup(source.tileRegistry); - const map = toRidgeBlockoutMap(source, validationErrors); - - return { - source, - map, - tileRegistry: source.tileRegistry, - runtimeTileRooms: source.rooms.map((room) => toRuntimeTileRoom(room, tileLookup)), - validationErrors - }; -} - -export function validateRidgeBlockoutSource(source: RidgeBlockoutSource): readonly string[] { - const errors: string[] = []; - const tileLookup = validateTileRegistry(source.tileRegistry, errors); - const roomIds = new Set(); - - if (source.language !== 'ridge-v0') { - errors.push(`unsupported language "${source.language}"`); - } - if (!Number.isFinite(source.cell) || source.cell <= 0) { - errors.push(`invalid cell size "${source.cell}"`); - } - if (!source.worldId) { - errors.push('missing world id'); - } - if (!source.spawn.roomId || !source.spawn.anchorSymbol) { - errors.push('missing spawn room or anchor'); - } - - source.rooms.forEach((room) => { - if (roomIds.has(room.id)) { - errors.push(`duplicate room "${room.id}"`); - } - roomIds.add(room.id); - - validateRoomGrid(room, tileLookup, errors); - validateRoomAnchors(room, tileLookup, errors); - }); - - validateSpawn(source, roomIds, errors); - validateRoutes(source, roomIds, errors); - validateShortcuts(source, roomIds, errors); - validateRuntimeCellOverlaps(source, tileLookup, errors); - - return errors; -} - -export function toRidgeBlockoutMap( - source: RidgeBlockoutSource, - validationErrors: readonly string[] = validateRidgeBlockoutSource(source) -): RidgeBlockoutMap { - return { - language: source.language, - cell: source.cell, - worldId: source.worldId, - title: source.title, - spawn: source.spawn, - routes: source.routes.map((route) => ({ - id: route.id, - roomIds: route.roomIds - })), - futureRoutes: (source.futureRoutes ?? []).map((route) => ({ - id: route.id, - roomIds: route.roomIds - })), - shortcuts: (source.shortcuts ?? []).map((shortcut) => ({ - id: shortcut.id, - fromRoomId: shortcut.fromRoomId, - toRoomId: shortcut.toRoomId, - kind: shortcut.kind - })), - optionalPockets: (source.optionalPockets ?? []).map((pocket) => ({ - id: pocket.id, - roomId: pocket.roomId, - kind: pocket.kind - })), - homeMutations: (source.homeMutations ?? []).map((mutation) => ({ - id: mutation.id, - attrs: mutation.attrs - })), - rooms: source.rooms.map((room) => ({ - id: room.id, - title: room.title, - place: room.place, - size: room.size, - theme: room.theme, - mood: room.mood, - links: room.links ?? [], - props: room.props ?? [], - grid: room.grid, - anchors: (room.anchors ?? []).map(toMapAnchor), - rects: (room.rects ?? []).map(toMapRect), - declarations: room.declarations ?? [] - })), - validationErrors - }; -} - -export function serializeRidgeCompiledBlockout({ - exportName, - compiled, - typeImportPath -}: { - exportName: string; - compiled: RidgeCompiledBlockout; - typeImportPath: string; -}): string { - return [ - `import type { RidgeCompiledBlockout } from '${typeImportPath}';`, - '', - '// This file is generated by scripts/generate-ridge-blockout-sources.mjs.', - '// Edit the matching .source.ts file instead.', - '', - `export const ${exportName} = ` + - JSON.stringify(compiled, null, 2) + - ' as const satisfies RidgeCompiledBlockout;', - '' - ].join('\n'); -} - -function validateTileRegistry( - registry: RidgeTileRegistry, - errors: string[] -): TileLookup { - const symbols = new Set(); - const ids = new Set(); - const bySymbol = new Map(); - - registry.forEach((tile) => { - if ([...tile.symbol].length !== 1) { - errors.push(`tile symbol "${tile.symbol}" must be exactly one character`); - } - if (symbols.has(tile.symbol)) { - errors.push(`duplicate tile symbol "${tile.symbol}"`); - } - symbols.add(tile.symbol); - bySymbol.set(tile.symbol, tile); - - if (!Number.isInteger(tile.id) || tile.id < 0) { - errors.push(`tile "${tile.symbol}" has invalid runtime tile id "${tile.id}"`); - } - if (ids.has(tile.id)) { - errors.push(`duplicate runtime tile id "${tile.id}"`); - } - ids.add(tile.id); - }); - - return { bySymbol }; -} - -function validateRoomGrid( - room: RidgeBlockoutSourceRoom, - tileLookup: TileLookup, - errors: string[] -): void { - if (room.size.width <= 0 || room.size.height <= 0) { - errors.push(`room "${room.id}" has invalid size`); - } - if (room.grid.length !== room.size.height) { - errors.push(`room "${room.id}" grid height ${room.grid.length} does not match size ${room.size.height}`); - } - room.grid.forEach((row, rowIndex) => { - if ([...row].length !== room.size.width) { - errors.push(`room "${room.id}" row ${rowIndex + 1} width ${[...row].length} does not match size ${room.size.width}`); - } - [...row].forEach((symbol, columnIndex) => { - if (!tileLookup.bySymbol.has(symbol)) { - errors.push(`room "${room.id}" has unknown symbol "${symbol}" at ${columnIndex},${rowIndex}`); - } - }); - }); -} - -function validateRoomAnchors( - room: RidgeBlockoutSourceRoom, - tileLookup: TileLookup, - errors: string[] -): void { - (room.anchors ?? []).forEach((anchor) => { - if (!tileLookup.bySymbol.has(anchor.symbol)) { - errors.push(`room "${room.id}" anchor "${anchor.symbol}" uses an unknown tile symbol`); - } - if (!room.grid.some((row) => [...row].includes(anchor.symbol))) { - errors.push(`room "${room.id}" anchor "${anchor.symbol}" has no matching grid cell`); - } - if ( - anchor.attrs?.movement !== undefined && - !RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(anchor.attrs.movement) - ) { - errors.push( - `room "${room.id}" anchor "${anchor.symbol}" has unknown movement "${anchor.attrs.movement}"` - ); - } - }); -} - -function validateSpawn( - source: RidgeBlockoutSource, - roomIds: ReadonlySet, - errors: string[] -): void { - if (!roomIds.has(source.spawn.roomId)) { - errors.push(`spawn room "${source.spawn.roomId}" does not exist`); - return; - } - - const spawnRoom = source.rooms.find((room) => room.id === source.spawn.roomId); - const spawnAnchor = spawnRoom?.anchors?.find((anchor) => - anchor.symbol === source.spawn.anchorSymbol - ); - if (!spawnAnchor) { - errors.push(`spawn anchor "${source.spawn.anchorSymbol}" does not exist in room "${source.spawn.roomId}"`); - } -} - -function validateRoutes( - source: RidgeBlockoutSource, - roomIds: ReadonlySet, - errors: string[] -): void { - [...source.routes, ...(source.futureRoutes ?? [])].forEach((route) => { - route.roomIds.forEach((roomId) => { - if (!roomIds.has(roomId)) { - errors.push(`route "${route.id}" references missing room "${roomId}"`); - } - }); - }); -} - -function validateShortcuts( - source: RidgeBlockoutSource, - roomIds: ReadonlySet, - errors: string[] -): void { - (source.shortcuts ?? []).forEach((shortcut) => { - if (!roomIds.has(shortcut.fromRoomId)) { - errors.push(`shortcut "${shortcut.id}" references missing from room "${shortcut.fromRoomId}"`); - } - if (!roomIds.has(shortcut.toRoomId)) { - errors.push(`shortcut "${shortcut.id}" references missing to room "${shortcut.toRoomId}"`); - } - }); -} - -function validateRuntimeCellOverlaps( - source: RidgeBlockoutSource, - tileLookup: TileLookup, - errors: string[] -): void { - const seen = new Map(); - - source.rooms.forEach((room) => { - room.grid.forEach((row, rowIndex) => { - [...row].forEach((symbol, columnIndex) => { - const tile = tileLookup.bySymbol.get(symbol); - if (!tile || !isRuntimeActiveTile(tile)) return; - - const key = `${room.place.x + columnIndex},${room.place.y + rowIndex}`; - const previous = seen.get(key); - if (previous && previous.roomId !== room.id) { - errors.push( - `runtime cell overlap at ${key}: ${previous.roomId}/${previous.symbol} and ${room.id}/${symbol}` - ); - return; - } - seen.set(key, { roomId: room.id, symbol }); - }); - }); - }); -} - -function createTileLookup(registry: RidgeTileRegistry): TileLookup { - return { - bySymbol: new Map(registry.map((tile) => [tile.symbol, tile])) - }; -} - -function toRuntimeTileRoom( - room: RidgeBlockoutSourceRoom, - tileLookup: TileLookup -): RidgeCompiledBlockoutRoom { - return { - id: room.id, - runtimeTileRows: room.grid.map((row) => - [...row].map((symbol) => tileLookup.bySymbol.get(symbol)?.id ?? -1) - ) - }; -} - -function toMapAnchor(anchor: RidgeBlockoutSourceAnchor) { - return { - symbol: anchor.symbol, - kind: anchor.kind, - attrs: anchor.attrs ?? {} - }; -} - -function toMapRect(rect: RidgeBlockoutSourceRect) { - return { - id: rect.id, - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - attrs: rect.attrs ?? {} - }; -} - -function isRuntimeActiveTile(tile: RidgeTileDefinition): boolean { - if (tile.runtimeActive !== undefined) return tile.runtimeActive; - return tile.kind !== 'empty' && tile.kind !== 'design' && tile.kind !== 'ladder'; -} - -export function isRidgeBlockoutSourceTraversalMovement( - value: string -): value is RidgeBlockoutTraversalMovement { - return RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(value); -} diff --git a/src/game/scenes/ridge/blockout/sourceContract.ts b/src/game/scenes/ridge/blockout/sourceContract.ts deleted file mode 100644 index c452728e..00000000 --- a/src/game/scenes/ridge/blockout/sourceContract.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { RidgeBlockoutMap, RidgeBlockoutTraversalMovement } from './parser'; - -export type AuthoringSymbol = string; -export type RuntimeTileId = number; - -export type RidgeTileKind = - | 'empty' - | 'solid' - | 'platform' - | 'ladder' - | 'anchor' - | 'design'; - -export interface RidgeTileDefinition { - symbol: AuthoringSymbol; - id: RuntimeTileId; - kind: RidgeTileKind; - label: string; - runtimeActive?: boolean; -} - -export type RidgeTileRegistry = readonly RidgeTileDefinition[]; - -export type RidgeBlockoutAttrs = Readonly>; - -export interface RidgeBlockoutSourcePoint { - x: number; - y: number; -} - -export interface RidgeBlockoutSourceSize { - width: number; - height: number; -} - -export interface RidgeBlockoutSourceAnchor { - symbol: AuthoringSymbol; - kind: string; - attrs?: RidgeBlockoutAttrs; -} - -export interface RidgeBlockoutSourceRect { - id: string; - x: number; - y: number; - width: number; - height: number; - attrs?: RidgeBlockoutAttrs; -} - -export interface RidgeBlockoutSourceRoom { - id: string; - title: string; - place: RidgeBlockoutSourcePoint; - size: RidgeBlockoutSourceSize; - theme?: string; - mood?: string; - links?: readonly string[]; - props?: readonly string[]; - grid: readonly string[]; - anchors?: readonly RidgeBlockoutSourceAnchor[]; - rects?: readonly RidgeBlockoutSourceRect[]; - declarations?: readonly string[]; -} - -export interface RidgeBlockoutSourceRoute { - id: string; - roomIds: readonly string[]; -} - -export interface RidgeBlockoutSourceShortcut { - id: string; - fromRoomId: string; - toRoomId: string; - kind?: string; -} - -export interface RidgeBlockoutSourceOptionalPocket { - id: string; - roomId?: string; - kind?: string; -} - -export interface RidgeBlockoutSourceHomeMutation { - id: string; - attrs: RidgeBlockoutAttrs; -} - -export interface RidgeBlockoutSource { - language: 'ridge-v0'; - cell: number; - worldId: string; - title: string; - tileRegistry: RidgeTileRegistry; - spawn: { - roomId: string; - anchorSymbol: AuthoringSymbol; - }; - routes: readonly RidgeBlockoutSourceRoute[]; - futureRoutes?: readonly RidgeBlockoutSourceRoute[]; - shortcuts?: readonly RidgeBlockoutSourceShortcut[]; - optionalPockets?: readonly RidgeBlockoutSourceOptionalPocket[]; - homeMutations?: readonly RidgeBlockoutSourceHomeMutation[]; - rooms: readonly RidgeBlockoutSourceRoom[]; -} - -export interface RidgeCompiledBlockoutRoom { - id: string; - runtimeTileRows: readonly (readonly RuntimeTileId[])[]; -} - -export interface RidgeCompiledBlockout { - source: RidgeBlockoutSource; - map: RidgeBlockoutMap; - tileRegistry: RidgeTileRegistry; - runtimeTileRooms: readonly RidgeCompiledBlockoutRoom[]; - validationErrors: readonly string[]; -} - -export type RidgeBlockoutSourceTraversalMovement = RidgeBlockoutTraversalMovement; diff --git a/src/game/scenes/ridge/blockout/sources/contract-fixture.generated.ts b/src/game/scenes/ridge/blockout/sources/contract-fixture.generated.ts deleted file mode 100644 index 8ddf8e31..00000000 --- a/src/game/scenes/ridge/blockout/sources/contract-fixture.generated.ts +++ /dev/null @@ -1,515 +0,0 @@ -import type { RidgeCompiledBlockout } from '../sourceContract'; - -// This file is generated by scripts/generate-ridge-blockout-sources.mjs. -// Edit the matching .source.ts file instead. - -export const CONTRACT_FIXTURE_RIDGE_COMPILED_BLOCKOUT = { - "source": { - "language": "ridge-v0", - "cell": 16, - "worldId": "contract_fixture", - "title": "Contract Fixture Ridge", - "tileRegistry": [ - { - "symbol": ".", - "id": 0, - "kind": "empty", - "label": "empty" - }, - { - "symbol": "#", - "id": 1, - "kind": "solid", - "label": "solid floor" - }, - { - "symbol": "_", - "id": 2, - "kind": "platform", - "label": "paper platform" - }, - { - "symbol": "L", - "id": 3, - "kind": "ladder", - "label": "ladder" - }, - { - "symbol": "1", - "id": 10, - "kind": "anchor", - "label": "spawn anchor" - }, - { - "symbol": "2", - "id": 11, - "kind": "anchor", - "label": "exit anchor" - }, - { - "symbol": "A", - "id": 20, - "kind": "anchor", - "label": "artifact anchor" - }, - { - "symbol": "~", - "id": 30, - "kind": "design", - "label": "wind guide", - "runtimeActive": false - } - ], - "spawn": { - "roomId": "start", - "anchorSymbol": "1" - }, - "routes": [ - { - "id": "first_walk", - "roomIds": [ - "start", - "lookout" - ] - } - ], - "futureRoutes": [ - { - "id": "future_ladder", - "roomIds": [ - "lookout", - "future_room" - ] - } - ], - "shortcuts": [ - { - "id": "fixture_drop", - "fromRoomId": "lookout", - "toRoomId": "start", - "kind": "drop" - } - ], - "optionalPockets": [ - { - "id": "side_note", - "roomId": "lookout", - "kind": "paper_fold" - } - ], - "homeMutations": [ - { - "id": "fixture_clear", - "attrs": { - "adds": "fixture_note" - } - } - ], - "rooms": [ - { - "id": "start", - "title": "Start", - "place": { - "x": 0, - "y": 0 - }, - "size": { - "width": 4, - "height": 3 - }, - "theme": "paper_start", - "mood": "curious", - "links": [ - "right:lookout" - ], - "props": [ - "paper_edge" - ], - "grid": [ - "1..2", - ".__.", - "####" - ], - "anchors": [ - { - "symbol": "1", - "kind": "player_spawn", - "attrs": { - "id": "start" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "lookout", - "movement": "ramp" - } - } - ], - "rects": [ - { - "id": "start_focus", - "x": 0, - "y": 0, - "width": 2, - "height": 1 - } - ], - "declarations": [ - "reward fixture_clear" - ] - }, - { - "id": "lookout", - "title": "Lookout", - "place": { - "x": 6, - "y": 0 - }, - "size": { - "width": 4, - "height": 3 - }, - "links": [ - "left:start" - ], - "props": [ - "paper_view" - ], - "grid": [ - "2.A~", - ".L_.", - "####" - ], - "anchors": [ - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "start", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "fixture_artifact" - } - } - ] - }, - { - "id": "future_room", - "title": "Future Room", - "place": { - "x": 12, - "y": 0 - }, - "size": { - "width": 4, - "height": 2 - }, - "grid": [ - "....", - "####" - ], - "declarations": [ - "future requires=fixture_reward" - ] - } - ] - }, - "map": { - "language": "ridge-v0", - "cell": 16, - "worldId": "contract_fixture", - "title": "Contract Fixture Ridge", - "spawn": { - "roomId": "start", - "anchorSymbol": "1" - }, - "routes": [ - { - "id": "first_walk", - "roomIds": [ - "start", - "lookout" - ] - } - ], - "futureRoutes": [ - { - "id": "future_ladder", - "roomIds": [ - "lookout", - "future_room" - ] - } - ], - "shortcuts": [ - { - "id": "fixture_drop", - "fromRoomId": "lookout", - "toRoomId": "start", - "kind": "drop" - } - ], - "optionalPockets": [ - { - "id": "side_note", - "roomId": "lookout", - "kind": "paper_fold" - } - ], - "homeMutations": [ - { - "id": "fixture_clear", - "attrs": { - "adds": "fixture_note" - } - } - ], - "rooms": [ - { - "id": "start", - "title": "Start", - "place": { - "x": 0, - "y": 0 - }, - "size": { - "width": 4, - "height": 3 - }, - "theme": "paper_start", - "mood": "curious", - "links": [ - "right:lookout" - ], - "props": [ - "paper_edge" - ], - "grid": [ - "1..2", - ".__.", - "####" - ], - "anchors": [ - { - "symbol": "1", - "kind": "player_spawn", - "attrs": { - "id": "start" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "lookout", - "movement": "ramp" - } - } - ], - "rects": [ - { - "id": "start_focus", - "x": 0, - "y": 0, - "width": 2, - "height": 1, - "attrs": {} - } - ], - "declarations": [ - "reward fixture_clear" - ] - }, - { - "id": "lookout", - "title": "Lookout", - "place": { - "x": 6, - "y": 0 - }, - "size": { - "width": 4, - "height": 3 - }, - "links": [ - "left:start" - ], - "props": [ - "paper_view" - ], - "grid": [ - "2.A~", - ".L_.", - "####" - ], - "anchors": [ - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "start", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "fixture_artifact" - } - } - ], - "rects": [], - "declarations": [] - }, - { - "id": "future_room", - "title": "Future Room", - "place": { - "x": 12, - "y": 0 - }, - "size": { - "width": 4, - "height": 2 - }, - "links": [], - "props": [], - "grid": [ - "....", - "####" - ], - "anchors": [], - "rects": [], - "declarations": [ - "future requires=fixture_reward" - ] - } - ], - "validationErrors": [] - }, - "tileRegistry": [ - { - "symbol": ".", - "id": 0, - "kind": "empty", - "label": "empty" - }, - { - "symbol": "#", - "id": 1, - "kind": "solid", - "label": "solid floor" - }, - { - "symbol": "_", - "id": 2, - "kind": "platform", - "label": "paper platform" - }, - { - "symbol": "L", - "id": 3, - "kind": "ladder", - "label": "ladder" - }, - { - "symbol": "1", - "id": 10, - "kind": "anchor", - "label": "spawn anchor" - }, - { - "symbol": "2", - "id": 11, - "kind": "anchor", - "label": "exit anchor" - }, - { - "symbol": "A", - "id": 20, - "kind": "anchor", - "label": "artifact anchor" - }, - { - "symbol": "~", - "id": 30, - "kind": "design", - "label": "wind guide", - "runtimeActive": false - } - ], - "runtimeTileRooms": [ - { - "id": "start", - "runtimeTileRows": [ - [ - 10, - 0, - 0, - 11 - ], - [ - 0, - 2, - 2, - 0 - ], - [ - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "lookout", - "runtimeTileRows": [ - [ - 11, - 0, - 20, - 30 - ], - [ - 0, - 3, - 2, - 0 - ], - [ - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "future_room", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1 - ] - ] - } - ], - "validationErrors": [] -} as const satisfies RidgeCompiledBlockout; diff --git a/src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts b/src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts deleted file mode 100644 index 9c5b7e4e..00000000 --- a/src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { RidgeBlockoutSource } from '../sourceContract'; - -export const CONTRACT_FIXTURE_RIDGE_SOURCE = { - language: 'ridge-v0', - cell: 16, - worldId: 'contract_fixture', - title: 'Contract Fixture Ridge', - tileRegistry: [ - { symbol: '.', id: 0, kind: 'empty', label: 'empty' }, - { symbol: '#', id: 1, kind: 'solid', label: 'solid floor' }, - { symbol: '_', id: 2, kind: 'platform', label: 'paper platform' }, - { symbol: 'L', id: 3, kind: 'ladder', label: 'ladder' }, - { symbol: '1', id: 10, kind: 'anchor', label: 'spawn anchor' }, - { symbol: '2', id: 11, kind: 'anchor', label: 'exit anchor' }, - { symbol: 'A', id: 20, kind: 'anchor', label: 'artifact anchor' }, - { symbol: '~', id: 30, kind: 'design', label: 'wind guide', runtimeActive: false } - ], - spawn: { - roomId: 'start', - anchorSymbol: '1' - }, - routes: [{ - id: 'first_walk', - roomIds: ['start', 'lookout'] - }], - futureRoutes: [{ - id: 'future_ladder', - roomIds: ['lookout', 'future_room'] - }], - shortcuts: [{ - id: 'fixture_drop', - fromRoomId: 'lookout', - toRoomId: 'start', - kind: 'drop' - }], - optionalPockets: [{ - id: 'side_note', - roomId: 'lookout', - kind: 'paper_fold' - }], - homeMutations: [{ - id: 'fixture_clear', - attrs: { - adds: 'fixture_note' - } - }], - rooms: [{ - id: 'start', - title: 'Start', - place: { x: 0, y: 0 }, - size: { width: 4, height: 3 }, - theme: 'paper_start', - mood: 'curious', - links: ['right:lookout'], - props: ['paper_edge'], - grid: [ - '1..2', - '.__.', - '####' - ], - anchors: [ - { symbol: '1', kind: 'player_spawn', attrs: { id: 'start' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'lookout', movement: 'ramp' } } - ], - rects: [{ - id: 'start_focus', - x: 0, - y: 0, - width: 2, - height: 1 - }], - declarations: ['reward fixture_clear'] - }, { - id: 'lookout', - title: 'Lookout', - place: { x: 6, y: 0 }, - size: { width: 4, height: 3 }, - links: ['left:start'], - props: ['paper_view'], - grid: [ - '2.A~', - '.L_.', - '####' - ], - anchors: [ - { symbol: '2', kind: 'exit', attrs: { to: 'start', movement: 'ramp' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'fixture_artifact' } } - ] - }, { - id: 'future_room', - title: 'Future Room', - place: { x: 12, y: 0 }, - size: { width: 4, height: 2 }, - grid: [ - '....', - '####' - ], - declarations: ['future requires=fixture_reward'] - }] -} as const satisfies RidgeBlockoutSource; - -export default CONTRACT_FIXTURE_RIDGE_SOURCE; diff --git a/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts b/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts deleted file mode 100644 index fca4d4d5..00000000 --- a/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts +++ /dev/null @@ -1,7693 +0,0 @@ -import type { RidgeCompiledBlockout } from '../sourceContract'; - -// This file is generated by scripts/generate-ridge-blockout-sources.mjs. -// Edit the matching .source.ts file instead. - -export const FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT = { - "source": { - "language": "ridge-v0", - "cell": 48, - "worldId": "folded_desk_ridge", - "title": "Folded Desk Ridge", - "tileRegistry": [ - { - "symbol": ".", - "id": 0, - "kind": "empty", - "label": "empty air" - }, - { - "symbol": "#", - "id": 1, - "kind": "solid", - "label": "solid ground or wall" - }, - { - "symbol": "_", - "id": 2, - "kind": "platform", - "label": "paper platform" - }, - { - "symbol": "L", - "id": 3, - "kind": "ladder", - "label": "ladder or cord guide" - }, - { - "symbol": "=", - "id": 4, - "kind": "design", - "label": "one-way platform promise", - "runtimeActive": false - }, - { - "symbol": "~", - "id": 5, - "kind": "design", - "label": "wind or drop guide", - "runtimeActive": false - }, - { - "symbol": "N", - "id": 6, - "kind": "design", - "label": "generic npc marker", - "runtimeActive": false - }, - { - "symbol": "M", - "id": 7, - "kind": "design", - "label": "memento board marker", - "runtimeActive": false - }, - { - "symbol": "1", - "id": 10, - "kind": "anchor", - "label": "numbered anchor 1" - }, - { - "symbol": "2", - "id": 11, - "kind": "anchor", - "label": "numbered anchor 2" - }, - { - "symbol": "3", - "id": 12, - "kind": "anchor", - "label": "numbered anchor 3" - }, - { - "symbol": "4", - "id": 13, - "kind": "anchor", - "label": "numbered anchor 4" - }, - { - "symbol": "5", - "id": 14, - "kind": "anchor", - "label": "numbered anchor 5" - }, - { - "symbol": "6", - "id": 15, - "kind": "anchor", - "label": "numbered anchor 6" - }, - { - "symbol": "7", - "id": 16, - "kind": "anchor", - "label": "numbered anchor 7" - }, - { - "symbol": "8", - "id": 17, - "kind": "anchor", - "label": "numbered anchor 8" - }, - { - "symbol": "9", - "id": 18, - "kind": "anchor", - "label": "numbered anchor 9" - }, - { - "symbol": "C", - "id": 20, - "kind": "anchor", - "label": "Cicka anchor" - }, - { - "symbol": "A", - "id": 21, - "kind": "anchor", - "label": "artifact anchor" - }, - { - "symbol": "*", - "id": 22, - "kind": "anchor", - "label": "mini-game anchor" - }, - { - "symbol": "^", - "id": 23, - "kind": "anchor", - "label": "lift anchor" - }, - { - "symbol": "?", - "id": 24, - "kind": "anchor", - "label": "secret or gate anchor" - } - ], - "spawn": { - "roomId": "outskirts", - "anchorSymbol": "1" - }, - "routes": [ - { - "id": "first_walk", - "roomIds": [ - "outskirts", - "cicka_home", - "work_artifact", - "stampede_blanket", - "switchback_shelf", - "telegraph_terrace", - "guide_overlook", - "relay_gate", - "domino_desk" - ] - } - ], - "optionalPockets": [ - { - "id": "early_skill_scrap", - "roomId": "work_artifact", - "kind": "side_shelf" - } - ], - "shortcuts": [ - { - "id": "stampede_sketch", - "fromRoomId": "switchback_shelf", - "toRoomId": "cicka_home", - "kind": "fall_steer_fold_drop" - }, - { - "id": "telegraph_clear", - "fromRoomId": "telegraph_terrace", - "toRoomId": "cicka_home", - "kind": "cord_drop" - } - ], - "futureRoutes": [ - { - "id": "cicka_underpath", - "roomIds": [ - "outskirts", - "underpath", - "cicka_home" - ] - }, - { - "id": "drop_pocket", - "roomIds": [ - "guide_overlook", - "drop_pocket", - "stampede_blanket" - ] - }, - { - "id": "high_ledge", - "roomIds": [ - "domino_desk", - "high_ledge" - ] - } - ], - "homeMutations": [ - { - "id": "work_artifact", - "attrs": { - "adds": "work_display" - } - }, - { - "id": "stampede_sketch", - "attrs": { - "adds": "stampede_note", - "opens": "fold_drop_landing" - } - }, - { - "id": "telegraph_clear", - "attrs": { - "adds": "signal_cord_drop" - } - }, - { - "id": "relay_gate_seen", - "attrs": { - "adds": "proof_slot_glow" - } - }, - { - "id": "domino_future", - "attrs": { - "adds": "desk_lift_switch" - } - } - ], - "rooms": [ - { - "id": "outskirts", - "title": "Outskirts", - "place": { - "x": 0, - "y": 72 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "city_edge_paper", - "mood": "curious", - "links": [ - "right:cicka_home", - "future_down:basement_hatch", - "future_up:underpath" - ], - "props": [ - "city_edge", - "basement_hatch_shadow", - "potassium_tape", - "glasses_hint", - "distant_relay" - ], - "grid": [ - "................................", - "................................", - ".........................A......", - "....................____........", - ".................___............", - "............___.................", - ".......1........................", - "......####...............2......", - "###########............####.....", - "###############......########...", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "player_spawn", - "attrs": { - "id": "start" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "paper_stairs", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "city_edge_memory" - } - } - ], - "rects": [ - { - "id": "sightline_to_relay", - "x": 21, - "y": 0, - "width": 9, - "height": 5 - }, - { - "id": "future_basement_hatch", - "x": 4, - "y": 9, - "width": 7, - "height": 2 - } - ] - }, - { - "id": "cicka_home", - "title": "Cicka Home", - "place": { - "x": 36, - "y": 64 - }, - "size": { - "width": 32, - "height": 14 - }, - "theme": "desk_nest", - "mood": "safe", - "links": [ - "left:outskirts", - "up:work_artifact", - "secret:underpath", - "shortcut_from:switchback_shelf", - "shortcut_from:telegraph_terrace" - ], - "props": [ - "desk", - "pinboard", - "memento_slots", - "paw_marks", - "paper_blanket", - "cicka_perch" - ], - "declarations": [ - "after stampede_sketch adds memento=stampede_note" - ], - "grid": [ - "................................", - "........................3.......", - "...................____....L....", - ".......M...................L....", - "...........................L....", - ".............____....C..A..L....", - ".....######################L....", - ".....#....................#L....", - ".....#....?...............#L....", - ".....#....................#L....", - ".....######################L....", - "...........................L....", - "..2........................L.1..", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "work_artifact", - "label": "paper_cord", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "outskirts", - "label": "stairs_down", - "movement": "ramp" - } - }, - { - "symbol": "3", - "kind": "shortcut_from=telegraph_terrace", - "attrs": { - "label": "cord_drop_home", - "movement": "drop" - } - }, - { - "symbol": "C", - "kind": "npc", - "attrs": { - "id": "cicka" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "home_memento_slot" - } - }, - { - "symbol": "?", - "kind": "secret", - "attrs": { - "to": "underpath", - "requires": "cicka_translator" - } - } - ], - "rects": [ - { - "id": "cicka_safe_zone", - "x": 7, - "y": 2, - "width": 18, - "height": 8 - }, - { - "id": "stampede_return_landing", - "x": 16, - "y": 11, - "width": 10, - "height": 2 - } - ] - }, - { - "id": "work_artifact", - "title": "Work Artifact Ledge", - "place": { - "x": 46, - "y": 50 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "desk_paper_artifacts", - "mood": "discovery", - "links": [ - "down:cicka_home", - "right:stampede_blanket" - ], - "props": [ - "saturn_sticker", - "hummingbird_feather", - "project_scraps", - "paper_steps" - ], - "grid": [ - "................................", - ".................2..............", - ".................L____..........", - "..............___L....3.........", - "....A............L.........1....", - ".....____........L..............", - ".................L...____.......", - "...#############.L..............", - ".............####L#######.......", - ".................L..#########...", - "#################L##############", - "#################L##############" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "wide_jump", - "movement": "jump" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "paper_steps_down", - "movement": "ramp" - } - }, - { - "symbol": "3", - "kind": "optional_pocket", - "attrs": { - "id": "early_skill_scrap", - "reward": "minor_skill_scrap" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "major_work_artifact" - } - } - ], - "rects": [ - { - "id": "artifact_focus", - "x": 2, - "y": 2, - "width": 10, - "height": 5 - }, - { - "id": "sightline_to_cicka", - "x": 0, - "y": 7, - "width": 9, - "height": 4 - } - ] - }, - { - "id": "stampede_blanket", - "title": "Stampede Blanket", - "place": { - "x": 84, - "y": 49 - }, - "size": { - "width": 32, - "height": 14 - }, - "theme": "picnic_ink_paper", - "mood": "tense_playful", - "links": [ - "left:work_artifact", - "up:switchback_shelf", - "optional:drop_pocket" - ], - "props": [ - "picnic_blanket", - "ink_swarm", - "crumbs", - "loose_pages", - "folded_paper_shortcut" - ], - "declarations": [ - "minigame stampede_sketch", - "reward stampede_sketch", - "after stampede_sketch opens fold_drop_chute via=switchback_shelf to=cicka_home" - ], - "grid": [ - "................................", - "....................2...........", - "................._______........", - "................................", - ".....____.................?.....", - ".1.........~~~~~~~..............", - "########...#######.......____...", - "########.................####...", - "............*...................", - "............____.....A..........", - "....................____........", - "......####################......", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "work_artifact", - "label": "wide_jump_back", - "movement": "jump" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "switchback_shelf", - "label": "climb_out", - "movement": "climb" - } - }, - { - "symbol": "?", - "kind": "shortcut_preview", - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "via": "switchback_shelf", - "movement": "drop" - } - }, - { - "symbol": "*", - "kind": "minigame", - "attrs": { - "id": "stampede_sketch" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "stampede_proof" - } - } - ], - "rects": [ - { - "id": "ink_swarm_zone", - "x": 10, - "y": 4, - "width": 10, - "height": 4 - }, - { - "id": "fold_shortcut_preview", - "x": 21, - "y": 5, - "width": 8, - "height": 4 - } - ] - }, - { - "id": "switchback_shelf", - "title": "Switchback Shelf", - "place": { - "x": 84, - "y": 35 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "folded_paper_shelf", - "mood": "aha", - "links": [ - "down:stampede_blanket", - "left:telegraph_terrace", - "shortcut:cicka_home" - ], - "props": [ - "paper_fold", - "paw_scratches", - "cicka_view_below", - "loose_page_bridge" - ], - "declarations": [ - "after stampede_sketch unlocks shortcut_to=cicka_home kind=fall_steer_fold_drop" - ], - "grid": [ - "................................", - ".........................2......", - ".....................____.......", - "................................", - "....1...........................", - "....____............A...........", - "................____............", - ".........____...................", - "..######################........", - ".............~~~~~..............", - "...............?................", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "climb_down", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "telegraph_terrace", - "label": "back_left", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "cicka_view_note" - } - }, - { - "symbol": "?", - "kind": "shortcut", - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "label": "fold_drop_home", - "movement": "drop" - } - } - ], - "rects": [ - { - "id": "cicka_view", - "x": 3, - "y": 4, - "width": 16, - "height": 5 - }, - { - "id": "fold_drop_chute", - "x": 12, - "y": 8, - "width": 7, - "height": 4, - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "kind": "fall_steer" - } - } - ] - }, - { - "id": "telegraph_terrace", - "title": "Telegraph Terrace", - "place": { - "x": 44, - "y": 32 - }, - "size": { - "width": 32, - "height": 13 - }, - "theme": "cord_terrace", - "mood": "focused", - "links": [ - "right:guide_overlook", - "down:switchback_shelf", - "shortcut:cicka_home" - ], - "props": [ - "hanging_cord", - "todo_ai_dummy", - "heavy_bag", - "tick_marks", - "signal_wire" - ], - "declarations": [ - "minigame telegraph_future", - "future_reward telegraph_clear" - ], - "grid": [ - "................................", - "............................2...", - "......................._____....", - "................................", - "....1...........N...............", - "....____........................", - "............____................", - ".....................____.......", - ".....*..........................", - ".....###########.......A........", - "...............############.....", - "................................", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "switchback_shelf", - "label": "back_right", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "cord_climb", - "movement": "climb" - } - }, - { - "symbol": "N", - "kind": "npc", - "attrs": { - "id": "todo_ai_dummy" - } - }, - { - "symbol": "*", - "kind": "minigame", - "attrs": { - "id": "telegraph_future" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "signal_note" - } - } - ], - "rects": [ - { - "id": "cord_drop", - "x": 4, - "y": 9, - "width": 5, - "height": 3, - "attrs": { - "to": "cicka_home", - "requires": "telegraph_clear" - } - }, - { - "id": "sightline_to_cicka", - "x": 0, - "y": 8, - "width": 10, - "height": 4 - } - ] - }, - { - "id": "guide_overlook", - "title": "Guide Overlook", - "place": { - "x": 84, - "y": 21 - }, - "size": { - "width": 32, - "height": 11 - }, - "theme": "windy_overlook", - "mood": "reorientation", - "links": [ - "left:telegraph_terrace", - "up:relay_gate", - "optional:drop_pocket" - ], - "props": [ - "ridge_guide", - "relay_silhouette", - "wind_lines", - "paper_bridge" - ], - "grid": [ - "................................", - ".....................2..........", - "..................______........", - "................................", - ".....1........N.................", - ".....____.......................", - "...............____.............", - ".......................____.....", - "....A...........................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "telegraph_terrace", - "label": "cord_down", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "relay_gate", - "label": "final_shelf", - "movement": "ramp" - } - }, - { - "symbol": "N", - "kind": "npc", - "attrs": { - "id": "ridge_guide" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "relay_view_memory" - } - } - ], - "rects": [ - { - "id": "sightline_to_relay", - "x": 17, - "y": 0, - "width": 12, - "height": 5 - }, - { - "id": "wind_hint", - "x": 4, - "y": 3, - "width": 22, - "height": 4 - } - ] - }, - { - "id": "relay_gate", - "title": "Relay Gate", - "place": { - "x": 44, - "y": 10 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "signal_gate", - "mood": "destination", - "links": [ - "down:guide_overlook", - "right:domino_desk" - ], - "props": [ - "proof_slots", - "signal_ink", - "locked_gate", - "view_back" - ], - "declarations": [ - "gate requires=proofs_future" - ], - "grid": [ - "................................", - "...................2............", - "...............________.........", - "................................", - "....1...........................", - "....____............A...........", - "............#####...............", - "............#???#...............", - "............#####...............", - "................................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "final_shelf_back", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "domino_desk", - "label": "lift_promise", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "proof_slot_marker" - } - }, - { - "symbol": "?", - "kind": "gate", - "attrs": { - "id": "relay_proof_slots" - } - } - ], - "rects": [ - { - "id": "sightline_view_back", - "x": 2, - "y": 1, - "width": 18, - "height": 5 - } - ] - }, - { - "id": "domino_desk", - "title": "Domino Desk", - "place": { - "x": 84, - "y": 8 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "desk_machine", - "mood": "mechanical", - "links": [ - "left:relay_gate", - "up:high_ledge", - "shortcut:cicka_home" - ], - "props": [ - "dominoes", - "coffee_rings", - "elevator_rails", - "deterministic_toys", - "printer_oracle_tease" - ], - "declarations": [ - "future_shortcut to=cicka_home kind=lift" - ], - "grid": [ - "................................", - "............................2...", - "......................______....", - "................................", - "....1...........................", - "....____.............^..........", - ".....................#..........", - "........A............#..........", - ".....########........#..........", - ".....................#..........", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "relay_gate", - "label": "lift_promise_back", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "high_ledge", - "label": "future_lift", - "movement": "climb" - } - }, - { - "symbol": "^", - "kind": "lift", - "attrs": { - "id": "desk_lift", - "to": "cicka_home", - "requires": "domino_future" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "domino_project_scrap" - } - } - ], - "rects": [ - { - "id": "lift_travel", - "x": 20, - "y": 4, - "width": 3, - "height": 7 - } - ] - }, - { - "id": "high_ledge", - "title": "High Ledge", - "place": { - "x": 118, - "y": 2 - }, - "size": { - "width": 32, - "height": 8 - }, - "theme": "moon_paper_high", - "mood": "aspirational", - "links": [ - "down:domino_desk" - ], - "props": [ - "unreachable_feather", - "syntax_scraps", - "moon_paper", - "glide_tease" - ], - "declarations": [ - "future requires=movement_reward" - ], - "grid": [ - "....................A...........", - "...............____.............", - "................................", - "...........____.................", - "................................", - ".....?..........................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "hidden_skill_scrap" - } - }, - { - "symbol": "?", - "kind": "gate", - "attrs": { - "id": "movement_reward_tease" - } - } - ], - "rects": [ - { - "id": "high_view", - "x": 0, - "y": 0, - "width": 32, - "height": 4 - } - ] - }, - { - "id": "underpath", - "title": "Paw Underpath", - "place": { - "x": 0, - "y": 60 - }, - "size": { - "width": 32, - "height": 10 - }, - "theme": "paw_scratch_secret", - "mood": "secret", - "links": [ - "left:outskirts", - "right:cicka_home" - ], - "props": [ - "paw_marks", - "dust", - "low_paper_tunnel", - "cicka_scratches" - ], - "declarations": [ - "future requires=cicka_translator" - ], - "grid": [ - "................................", - "......1...................2.....", - "....######.............######...", - "................................", - "...........?....................", - ".....____.............____......", - "................................", - "...##########################...", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "outskirts", - "label": "secret_start_return", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "secret_home_return", - "movement": "ramp" - } - }, - { - "symbol": "?", - "kind": "secret", - "attrs": { - "id": "cicka_translation_mark" - } - } - ], - "rects": [ - { - "id": "crawl_space", - "x": 3, - "y": 3, - "width": 25, - "height": 4 - } - ] - }, - { - "id": "drop_pocket", - "title": "Lucky Luna Drop Pocket", - "place": { - "x": 120, - "y": 38 - }, - "size": { - "width": 32, - "height": 16 - }, - "theme": "falling_scraps", - "mood": "playful_descent", - "links": [ - "entry:guide_overlook", - "exit:stampede_blanket" - ], - "props": [ - "falling_paper", - "wind_lines", - "keycap_scrap", - "safe_shaft" - ], - "declarations": [ - "future optional=true" - ], - "grid": [ - "................................", - "....1...........................", - "....____........................", - ".........~......................", - "...........~....................", - ".............~..................", - "...............~................", - ".................~..............", - "...................~............", - ".....................~..........", - ".......................2........", - "...........A....................", - ".......____............____.....", - "................................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "optional_descent", - "movement": "drop" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "return_near_stampede", - "movement": "drop" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "hidden_keycap_scrap" - } - } - ], - "rects": [ - { - "id": "fall_shaft", - "x": 8, - "y": 2, - "width": 16, - "height": 10 - } - ] - } - ] - }, - "map": { - "language": "ridge-v0", - "cell": 48, - "worldId": "folded_desk_ridge", - "title": "Folded Desk Ridge", - "spawn": { - "roomId": "outskirts", - "anchorSymbol": "1" - }, - "routes": [ - { - "id": "first_walk", - "roomIds": [ - "outskirts", - "cicka_home", - "work_artifact", - "stampede_blanket", - "switchback_shelf", - "telegraph_terrace", - "guide_overlook", - "relay_gate", - "domino_desk" - ] - } - ], - "futureRoutes": [ - { - "id": "cicka_underpath", - "roomIds": [ - "outskirts", - "underpath", - "cicka_home" - ] - }, - { - "id": "drop_pocket", - "roomIds": [ - "guide_overlook", - "drop_pocket", - "stampede_blanket" - ] - }, - { - "id": "high_ledge", - "roomIds": [ - "domino_desk", - "high_ledge" - ] - } - ], - "shortcuts": [ - { - "id": "stampede_sketch", - "fromRoomId": "switchback_shelf", - "toRoomId": "cicka_home", - "kind": "fall_steer_fold_drop" - }, - { - "id": "telegraph_clear", - "fromRoomId": "telegraph_terrace", - "toRoomId": "cicka_home", - "kind": "cord_drop" - } - ], - "optionalPockets": [ - { - "id": "early_skill_scrap", - "roomId": "work_artifact", - "kind": "side_shelf" - } - ], - "homeMutations": [ - { - "id": "work_artifact", - "attrs": { - "adds": "work_display" - } - }, - { - "id": "stampede_sketch", - "attrs": { - "adds": "stampede_note", - "opens": "fold_drop_landing" - } - }, - { - "id": "telegraph_clear", - "attrs": { - "adds": "signal_cord_drop" - } - }, - { - "id": "relay_gate_seen", - "attrs": { - "adds": "proof_slot_glow" - } - }, - { - "id": "domino_future", - "attrs": { - "adds": "desk_lift_switch" - } - } - ], - "rooms": [ - { - "id": "outskirts", - "title": "Outskirts", - "place": { - "x": 0, - "y": 72 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "city_edge_paper", - "mood": "curious", - "links": [ - "right:cicka_home", - "future_down:basement_hatch", - "future_up:underpath" - ], - "props": [ - "city_edge", - "basement_hatch_shadow", - "potassium_tape", - "glasses_hint", - "distant_relay" - ], - "grid": [ - "................................", - "................................", - ".........................A......", - "....................____........", - ".................___............", - "............___.................", - ".......1........................", - "......####...............2......", - "###########............####.....", - "###############......########...", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "player_spawn", - "attrs": { - "id": "start" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "paper_stairs", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "city_edge_memory" - } - } - ], - "rects": [ - { - "id": "sightline_to_relay", - "x": 21, - "y": 0, - "width": 9, - "height": 5, - "attrs": {} - }, - { - "id": "future_basement_hatch", - "x": 4, - "y": 9, - "width": 7, - "height": 2, - "attrs": {} - } - ], - "declarations": [] - }, - { - "id": "cicka_home", - "title": "Cicka Home", - "place": { - "x": 36, - "y": 64 - }, - "size": { - "width": 32, - "height": 14 - }, - "theme": "desk_nest", - "mood": "safe", - "links": [ - "left:outskirts", - "up:work_artifact", - "secret:underpath", - "shortcut_from:switchback_shelf", - "shortcut_from:telegraph_terrace" - ], - "props": [ - "desk", - "pinboard", - "memento_slots", - "paw_marks", - "paper_blanket", - "cicka_perch" - ], - "grid": [ - "................................", - "........................3.......", - "...................____....L....", - ".......M...................L....", - "...........................L....", - ".............____....C..A..L....", - ".....######################L....", - ".....#....................#L....", - ".....#....?...............#L....", - ".....#....................#L....", - ".....######################L....", - "...........................L....", - "..2........................L.1..", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "work_artifact", - "label": "paper_cord", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "outskirts", - "label": "stairs_down", - "movement": "ramp" - } - }, - { - "symbol": "3", - "kind": "shortcut_from=telegraph_terrace", - "attrs": { - "label": "cord_drop_home", - "movement": "drop" - } - }, - { - "symbol": "C", - "kind": "npc", - "attrs": { - "id": "cicka" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "home_memento_slot" - } - }, - { - "symbol": "?", - "kind": "secret", - "attrs": { - "to": "underpath", - "requires": "cicka_translator" - } - } - ], - "rects": [ - { - "id": "cicka_safe_zone", - "x": 7, - "y": 2, - "width": 18, - "height": 8, - "attrs": {} - }, - { - "id": "stampede_return_landing", - "x": 16, - "y": 11, - "width": 10, - "height": 2, - "attrs": {} - } - ], - "declarations": [ - "after stampede_sketch adds memento=stampede_note" - ] - }, - { - "id": "work_artifact", - "title": "Work Artifact Ledge", - "place": { - "x": 46, - "y": 50 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "desk_paper_artifacts", - "mood": "discovery", - "links": [ - "down:cicka_home", - "right:stampede_blanket" - ], - "props": [ - "saturn_sticker", - "hummingbird_feather", - "project_scraps", - "paper_steps" - ], - "grid": [ - "................................", - ".................2..............", - ".................L____..........", - "..............___L....3.........", - "....A............L.........1....", - ".....____........L..............", - ".................L...____.......", - "...#############.L..............", - ".............####L#######.......", - ".................L..#########...", - "#################L##############", - "#################L##############" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "wide_jump", - "movement": "jump" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "paper_steps_down", - "movement": "ramp" - } - }, - { - "symbol": "3", - "kind": "optional_pocket", - "attrs": { - "id": "early_skill_scrap", - "reward": "minor_skill_scrap" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "major_work_artifact" - } - } - ], - "rects": [ - { - "id": "artifact_focus", - "x": 2, - "y": 2, - "width": 10, - "height": 5, - "attrs": {} - }, - { - "id": "sightline_to_cicka", - "x": 0, - "y": 7, - "width": 9, - "height": 4, - "attrs": {} - } - ], - "declarations": [] - }, - { - "id": "stampede_blanket", - "title": "Stampede Blanket", - "place": { - "x": 84, - "y": 49 - }, - "size": { - "width": 32, - "height": 14 - }, - "theme": "picnic_ink_paper", - "mood": "tense_playful", - "links": [ - "left:work_artifact", - "up:switchback_shelf", - "optional:drop_pocket" - ], - "props": [ - "picnic_blanket", - "ink_swarm", - "crumbs", - "loose_pages", - "folded_paper_shortcut" - ], - "grid": [ - "................................", - "....................2...........", - "................._______........", - "................................", - ".....____.................?.....", - ".1.........~~~~~~~..............", - "########...#######.......____...", - "########.................####...", - "............*...................", - "............____.....A..........", - "....................____........", - "......####################......", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "work_artifact", - "label": "wide_jump_back", - "movement": "jump" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "switchback_shelf", - "label": "climb_out", - "movement": "climb" - } - }, - { - "symbol": "?", - "kind": "shortcut_preview", - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "via": "switchback_shelf", - "movement": "drop" - } - }, - { - "symbol": "*", - "kind": "minigame", - "attrs": { - "id": "stampede_sketch" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "stampede_proof" - } - } - ], - "rects": [ - { - "id": "ink_swarm_zone", - "x": 10, - "y": 4, - "width": 10, - "height": 4, - "attrs": {} - }, - { - "id": "fold_shortcut_preview", - "x": 21, - "y": 5, - "width": 8, - "height": 4, - "attrs": {} - } - ], - "declarations": [ - "minigame stampede_sketch", - "reward stampede_sketch", - "after stampede_sketch opens fold_drop_chute via=switchback_shelf to=cicka_home" - ] - }, - { - "id": "switchback_shelf", - "title": "Switchback Shelf", - "place": { - "x": 84, - "y": 35 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "folded_paper_shelf", - "mood": "aha", - "links": [ - "down:stampede_blanket", - "left:telegraph_terrace", - "shortcut:cicka_home" - ], - "props": [ - "paper_fold", - "paw_scratches", - "cicka_view_below", - "loose_page_bridge" - ], - "grid": [ - "................................", - ".........................2......", - ".....................____.......", - "................................", - "....1...........................", - "....____............A...........", - "................____............", - ".........____...................", - "..######################........", - ".............~~~~~..............", - "...............?................", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "climb_down", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "telegraph_terrace", - "label": "back_left", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "cicka_view_note" - } - }, - { - "symbol": "?", - "kind": "shortcut", - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "label": "fold_drop_home", - "movement": "drop" - } - } - ], - "rects": [ - { - "id": "cicka_view", - "x": 3, - "y": 4, - "width": 16, - "height": 5, - "attrs": {} - }, - { - "id": "fold_drop_chute", - "x": 12, - "y": 8, - "width": 7, - "height": 4, - "attrs": { - "to": "cicka_home", - "requires": "stampede_sketch", - "kind": "fall_steer" - } - } - ], - "declarations": [ - "after stampede_sketch unlocks shortcut_to=cicka_home kind=fall_steer_fold_drop" - ] - }, - { - "id": "telegraph_terrace", - "title": "Telegraph Terrace", - "place": { - "x": 44, - "y": 32 - }, - "size": { - "width": 32, - "height": 13 - }, - "theme": "cord_terrace", - "mood": "focused", - "links": [ - "right:guide_overlook", - "down:switchback_shelf", - "shortcut:cicka_home" - ], - "props": [ - "hanging_cord", - "todo_ai_dummy", - "heavy_bag", - "tick_marks", - "signal_wire" - ], - "grid": [ - "................................", - "............................2...", - "......................._____....", - "................................", - "....1...........N...............", - "....____........................", - "............____................", - ".....................____.......", - ".....*..........................", - ".....###########.......A........", - "...............############.....", - "................................", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "switchback_shelf", - "label": "back_right", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "cord_climb", - "movement": "climb" - } - }, - { - "symbol": "N", - "kind": "npc", - "attrs": { - "id": "todo_ai_dummy" - } - }, - { - "symbol": "*", - "kind": "minigame", - "attrs": { - "id": "telegraph_future" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "signal_note" - } - } - ], - "rects": [ - { - "id": "cord_drop", - "x": 4, - "y": 9, - "width": 5, - "height": 3, - "attrs": { - "to": "cicka_home", - "requires": "telegraph_clear" - } - }, - { - "id": "sightline_to_cicka", - "x": 0, - "y": 8, - "width": 10, - "height": 4, - "attrs": {} - } - ], - "declarations": [ - "minigame telegraph_future", - "future_reward telegraph_clear" - ] - }, - { - "id": "guide_overlook", - "title": "Guide Overlook", - "place": { - "x": 84, - "y": 21 - }, - "size": { - "width": 32, - "height": 11 - }, - "theme": "windy_overlook", - "mood": "reorientation", - "links": [ - "left:telegraph_terrace", - "up:relay_gate", - "optional:drop_pocket" - ], - "props": [ - "ridge_guide", - "relay_silhouette", - "wind_lines", - "paper_bridge" - ], - "grid": [ - "................................", - ".....................2..........", - "..................______........", - "................................", - ".....1........N.................", - ".....____.......................", - "...............____.............", - ".......................____.....", - "....A...........................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "telegraph_terrace", - "label": "cord_down", - "movement": "climb" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "relay_gate", - "label": "final_shelf", - "movement": "ramp" - } - }, - { - "symbol": "N", - "kind": "npc", - "attrs": { - "id": "ridge_guide" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "relay_view_memory" - } - } - ], - "rects": [ - { - "id": "sightline_to_relay", - "x": 17, - "y": 0, - "width": 12, - "height": 5, - "attrs": {} - }, - { - "id": "wind_hint", - "x": 4, - "y": 3, - "width": 22, - "height": 4, - "attrs": {} - } - ], - "declarations": [] - }, - { - "id": "relay_gate", - "title": "Relay Gate", - "place": { - "x": 44, - "y": 10 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "signal_gate", - "mood": "destination", - "links": [ - "down:guide_overlook", - "right:domino_desk" - ], - "props": [ - "proof_slots", - "signal_ink", - "locked_gate", - "view_back" - ], - "grid": [ - "................................", - "...................2............", - "...............________.........", - "................................", - "....1...........................", - "....____............A...........", - "............#####...............", - "............#???#...............", - "............#####...............", - "................................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "final_shelf_back", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "domino_desk", - "label": "lift_promise", - "movement": "ramp" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "proof_slot_marker" - } - }, - { - "symbol": "?", - "kind": "gate", - "attrs": { - "id": "relay_proof_slots" - } - } - ], - "rects": [ - { - "id": "sightline_view_back", - "x": 2, - "y": 1, - "width": 18, - "height": 5, - "attrs": {} - } - ], - "declarations": [ - "gate requires=proofs_future" - ] - }, - { - "id": "domino_desk", - "title": "Domino Desk", - "place": { - "x": 84, - "y": 8 - }, - "size": { - "width": 32, - "height": 12 - }, - "theme": "desk_machine", - "mood": "mechanical", - "links": [ - "left:relay_gate", - "up:high_ledge", - "shortcut:cicka_home" - ], - "props": [ - "dominoes", - "coffee_rings", - "elevator_rails", - "deterministic_toys", - "printer_oracle_tease" - ], - "grid": [ - "................................", - "............................2...", - "......................______....", - "................................", - "....1...........................", - "....____.............^..........", - ".....................#..........", - "........A............#..........", - ".....########........#..........", - ".....................#..........", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "relay_gate", - "label": "lift_promise_back", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "high_ledge", - "label": "future_lift", - "movement": "climb" - } - }, - { - "symbol": "^", - "kind": "lift", - "attrs": { - "id": "desk_lift", - "to": "cicka_home", - "requires": "domino_future" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "domino_project_scrap" - } - } - ], - "rects": [ - { - "id": "lift_travel", - "x": 20, - "y": 4, - "width": 3, - "height": 7, - "attrs": {} - } - ], - "declarations": [ - "future_shortcut to=cicka_home kind=lift" - ] - }, - { - "id": "high_ledge", - "title": "High Ledge", - "place": { - "x": 118, - "y": 2 - }, - "size": { - "width": 32, - "height": 8 - }, - "theme": "moon_paper_high", - "mood": "aspirational", - "links": [ - "down:domino_desk" - ], - "props": [ - "unreachable_feather", - "syntax_scraps", - "moon_paper", - "glide_tease" - ], - "grid": [ - "....................A...........", - "...............____.............", - "................................", - "...........____.................", - "................................", - ".....?..........................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "hidden_skill_scrap" - } - }, - { - "symbol": "?", - "kind": "gate", - "attrs": { - "id": "movement_reward_tease" - } - } - ], - "rects": [ - { - "id": "high_view", - "x": 0, - "y": 0, - "width": 32, - "height": 4, - "attrs": {} - } - ], - "declarations": [ - "future requires=movement_reward" - ] - }, - { - "id": "underpath", - "title": "Paw Underpath", - "place": { - "x": 0, - "y": 60 - }, - "size": { - "width": 32, - "height": 10 - }, - "theme": "paw_scratch_secret", - "mood": "secret", - "links": [ - "left:outskirts", - "right:cicka_home" - ], - "props": [ - "paw_marks", - "dust", - "low_paper_tunnel", - "cicka_scratches" - ], - "grid": [ - "................................", - "......1...................2.....", - "....######.............######...", - "................................", - "...........?....................", - ".....____.............____......", - "................................", - "...##########################...", - "################################", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "outskirts", - "label": "secret_start_return", - "movement": "ramp" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "cicka_home", - "label": "secret_home_return", - "movement": "ramp" - } - }, - { - "symbol": "?", - "kind": "secret", - "attrs": { - "id": "cicka_translation_mark" - } - } - ], - "rects": [ - { - "id": "crawl_space", - "x": 3, - "y": 3, - "width": 25, - "height": 4, - "attrs": {} - } - ], - "declarations": [ - "future requires=cicka_translator" - ] - }, - { - "id": "drop_pocket", - "title": "Lucky Luna Drop Pocket", - "place": { - "x": 120, - "y": 38 - }, - "size": { - "width": 32, - "height": 16 - }, - "theme": "falling_scraps", - "mood": "playful_descent", - "links": [ - "entry:guide_overlook", - "exit:stampede_blanket" - ], - "props": [ - "falling_paper", - "wind_lines", - "keycap_scrap", - "safe_shaft" - ], - "grid": [ - "................................", - "....1...........................", - "....____........................", - ".........~......................", - "...........~....................", - ".............~..................", - "...............~................", - ".................~..............", - "...................~............", - ".....................~..........", - ".......................2........", - "...........A....................", - ".......____............____.....", - "................................", - ".....######################.....", - "################################" - ], - "anchors": [ - { - "symbol": "1", - "kind": "exit", - "attrs": { - "to": "guide_overlook", - "label": "optional_descent", - "movement": "drop" - } - }, - { - "symbol": "2", - "kind": "exit", - "attrs": { - "to": "stampede_blanket", - "label": "return_near_stampede", - "movement": "drop" - } - }, - { - "symbol": "A", - "kind": "artifact", - "attrs": { - "id": "hidden_keycap_scrap" - } - } - ], - "rects": [ - { - "id": "fall_shaft", - "x": 8, - "y": 2, - "width": 16, - "height": 10, - "attrs": {} - } - ], - "declarations": [ - "future optional=true" - ] - } - ], - "validationErrors": [] - }, - "tileRegistry": [ - { - "symbol": ".", - "id": 0, - "kind": "empty", - "label": "empty air" - }, - { - "symbol": "#", - "id": 1, - "kind": "solid", - "label": "solid ground or wall" - }, - { - "symbol": "_", - "id": 2, - "kind": "platform", - "label": "paper platform" - }, - { - "symbol": "L", - "id": 3, - "kind": "ladder", - "label": "ladder or cord guide" - }, - { - "symbol": "=", - "id": 4, - "kind": "design", - "label": "one-way platform promise", - "runtimeActive": false - }, - { - "symbol": "~", - "id": 5, - "kind": "design", - "label": "wind or drop guide", - "runtimeActive": false - }, - { - "symbol": "N", - "id": 6, - "kind": "design", - "label": "generic npc marker", - "runtimeActive": false - }, - { - "symbol": "M", - "id": 7, - "kind": "design", - "label": "memento board marker", - "runtimeActive": false - }, - { - "symbol": "1", - "id": 10, - "kind": "anchor", - "label": "numbered anchor 1" - }, - { - "symbol": "2", - "id": 11, - "kind": "anchor", - "label": "numbered anchor 2" - }, - { - "symbol": "3", - "id": 12, - "kind": "anchor", - "label": "numbered anchor 3" - }, - { - "symbol": "4", - "id": 13, - "kind": "anchor", - "label": "numbered anchor 4" - }, - { - "symbol": "5", - "id": 14, - "kind": "anchor", - "label": "numbered anchor 5" - }, - { - "symbol": "6", - "id": 15, - "kind": "anchor", - "label": "numbered anchor 6" - }, - { - "symbol": "7", - "id": 16, - "kind": "anchor", - "label": "numbered anchor 7" - }, - { - "symbol": "8", - "id": 17, - "kind": "anchor", - "label": "numbered anchor 8" - }, - { - "symbol": "9", - "id": 18, - "kind": "anchor", - "label": "numbered anchor 9" - }, - { - "symbol": "C", - "id": 20, - "kind": "anchor", - "label": "Cicka anchor" - }, - { - "symbol": "A", - "id": 21, - "kind": "anchor", - "label": "artifact anchor" - }, - { - "symbol": "*", - "id": 22, - "kind": "anchor", - "label": "mini-game anchor" - }, - { - "symbol": "^", - "id": 23, - "kind": "anchor", - "label": "lift anchor" - }, - { - "symbol": "?", - "id": 24, - "kind": "anchor", - "label": "secret or gate anchor" - } - ], - "runtimeTileRooms": [ - { - "id": "outskirts", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "cicka_home", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 12, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 7, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 20, - 0, - 0, - 21, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 24, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 10, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "work_artifact", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 3, - 0, - 0, - 0, - 0, - 12, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 3, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 3, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 3, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "stampede_blanket", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 24, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 5, - 5, - 5, - 5, - 5, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 22, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "switchback_shelf", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 5, - 5, - 5, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 24, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "telegraph_terrace", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 22, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "guide_overlook", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "relay_gate", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 24, - 24, - 24, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "domino_desk", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 23, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "high_ledge", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 24, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "underpath", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 24, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - }, - { - "id": "drop_pocket", - "runtimeTileRows": [ - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 21, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ] - ] - } - ], - "validationErrors": [] -} as const satisfies RidgeCompiledBlockout; diff --git a/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts b/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts deleted file mode 100644 index da4e613c..00000000 --- a/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts +++ /dev/null @@ -1,593 +0,0 @@ -import type { RidgeBlockoutSource, RidgeTileRegistry } from '../sourceContract'; - -export const FOLDED_DESK_RIDGE_TILE_REGISTRY = [ - { symbol: '.', id: 0, kind: 'empty', label: 'empty air' }, - { symbol: '#', id: 1, kind: 'solid', label: 'solid ground or wall' }, - { symbol: '_', id: 2, kind: 'platform', label: 'paper platform' }, - { symbol: 'L', id: 3, kind: 'ladder', label: 'ladder or cord guide' }, - { symbol: '=', id: 4, kind: 'design', label: 'one-way platform promise', runtimeActive: false }, - { symbol: '~', id: 5, kind: 'design', label: 'wind or drop guide', runtimeActive: false }, - { symbol: 'N', id: 6, kind: 'design', label: 'generic npc marker', runtimeActive: false }, - { symbol: 'M', id: 7, kind: 'design', label: 'memento board marker', runtimeActive: false }, - { symbol: '1', id: 10, kind: 'anchor', label: 'numbered anchor 1' }, - { symbol: '2', id: 11, kind: 'anchor', label: 'numbered anchor 2' }, - { symbol: '3', id: 12, kind: 'anchor', label: 'numbered anchor 3' }, - { symbol: '4', id: 13, kind: 'anchor', label: 'numbered anchor 4' }, - { symbol: '5', id: 14, kind: 'anchor', label: 'numbered anchor 5' }, - { symbol: '6', id: 15, kind: 'anchor', label: 'numbered anchor 6' }, - { symbol: '7', id: 16, kind: 'anchor', label: 'numbered anchor 7' }, - { symbol: '8', id: 17, kind: 'anchor', label: 'numbered anchor 8' }, - { symbol: '9', id: 18, kind: 'anchor', label: 'numbered anchor 9' }, - { symbol: 'C', id: 20, kind: 'anchor', label: 'Cicka anchor' }, - { symbol: 'A', id: 21, kind: 'anchor', label: 'artifact anchor' }, - { symbol: '*', id: 22, kind: 'anchor', label: 'mini-game anchor' }, - { symbol: '^', id: 23, kind: 'anchor', label: 'lift anchor' }, - { symbol: '?', id: 24, kind: 'anchor', label: 'secret or gate anchor' } -] as const satisfies RidgeTileRegistry; - -export const FOLDED_DESK_RIDGE_SOURCE = { - language: 'ridge-v0', - cell: 48, - worldId: 'folded_desk_ridge', - title: 'Folded Desk Ridge', - tileRegistry: FOLDED_DESK_RIDGE_TILE_REGISTRY, - spawn: { - roomId: 'outskirts', - anchorSymbol: '1' - }, - routes: [{ - id: 'first_walk', - roomIds: [ - 'outskirts', - 'cicka_home', - 'work_artifact', - 'stampede_blanket', - 'switchback_shelf', - 'telegraph_terrace', - 'guide_overlook', - 'relay_gate', - 'domino_desk' - ] - }], - optionalPockets: [{ - id: 'early_skill_scrap', - roomId: 'work_artifact', - kind: 'side_shelf' - }], - shortcuts: [{ - id: 'stampede_sketch', - fromRoomId: 'switchback_shelf', - toRoomId: 'cicka_home', - kind: 'fall_steer_fold_drop' - }, { - id: 'telegraph_clear', - fromRoomId: 'telegraph_terrace', - toRoomId: 'cicka_home', - kind: 'cord_drop' - }], - futureRoutes: [{ - id: 'cicka_underpath', - roomIds: ['outskirts', 'underpath', 'cicka_home'] - }, { - id: 'drop_pocket', - roomIds: ['guide_overlook', 'drop_pocket', 'stampede_blanket'] - }, { - id: 'high_ledge', - roomIds: ['domino_desk', 'high_ledge'] - }], - homeMutations: [{ - id: 'work_artifact', - attrs: { adds: 'work_display' } - }, { - id: 'stampede_sketch', - attrs: { adds: 'stampede_note', opens: 'fold_drop_landing' } - }, { - id: 'telegraph_clear', - attrs: { adds: 'signal_cord_drop' } - }, { - id: 'relay_gate_seen', - attrs: { adds: 'proof_slot_glow' } - }, { - id: 'domino_future', - attrs: { adds: 'desk_lift_switch' } - }], - rooms: [{ - id: 'outskirts', - title: 'Outskirts', - place: { x: 0, y: 72 }, - size: { width: 32, height: 12 }, - theme: 'city_edge_paper', - mood: 'curious', - links: ['right:cicka_home', 'future_down:basement_hatch', 'future_up:underpath'], - props: ['city_edge', 'basement_hatch_shadow', 'potassium_tape', 'glasses_hint', 'distant_relay'], - grid: [ - '................................', - '................................', - '.........................A......', - '....................____........', - '.................___............', - '............___.................', - '.......1........................', - '......####...............2......', - '###########............####.....', - '###############......########...', - '################################', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'player_spawn', attrs: { id: 'start' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'cicka_home', label: 'paper_stairs', movement: 'ramp' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'city_edge_memory' } } - ], - rects: [{ - id: 'sightline_to_relay', - x: 21, - y: 0, - width: 9, - height: 5 - }, { - id: 'future_basement_hatch', - x: 4, - y: 9, - width: 7, - height: 2 - }] - }, { - id: 'cicka_home', - title: 'Cicka Home', - place: { x: 36, y: 64 }, - size: { width: 32, height: 14 }, - theme: 'desk_nest', - mood: 'safe', - links: [ - 'left:outskirts', - 'up:work_artifact', - 'secret:underpath', - 'shortcut_from:switchback_shelf', - 'shortcut_from:telegraph_terrace' - ], - props: ['desk', 'pinboard', 'memento_slots', 'paw_marks', 'paper_blanket', 'cicka_perch'], - declarations: ['after stampede_sketch adds memento=stampede_note'], - grid: [ - '................................', - '........................3.......', - '...................____....L....', - '.......M...................L....', - '...........................L....', - '.............____....C..A..L....', - '.....######################L....', - '.....#....................#L....', - '.....#....?...............#L....', - '.....#....................#L....', - '.....######################L....', - '...........................L....', - '..2........................L.1..', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'work_artifact', label: 'paper_cord', movement: 'climb' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'outskirts', label: 'stairs_down', movement: 'ramp' } }, - { symbol: '3', kind: 'shortcut_from=telegraph_terrace', attrs: { label: 'cord_drop_home', movement: 'drop' } }, - { symbol: 'C', kind: 'npc', attrs: { id: 'cicka' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'home_memento_slot' } }, - { symbol: '?', kind: 'secret', attrs: { to: 'underpath', requires: 'cicka_translator' } } - ], - rects: [{ - id: 'cicka_safe_zone', - x: 7, - y: 2, - width: 18, - height: 8 - }, { - id: 'stampede_return_landing', - x: 16, - y: 11, - width: 10, - height: 2 - }] - }, { - id: 'work_artifact', - title: 'Work Artifact Ledge', - place: { x: 46, y: 50 }, - size: { width: 32, height: 12 }, - theme: 'desk_paper_artifacts', - mood: 'discovery', - links: ['down:cicka_home', 'right:stampede_blanket'], - props: ['saturn_sticker', 'hummingbird_feather', 'project_scraps', 'paper_steps'], - grid: [ - '................................', - '.................2..............', - '.................L____..........', - '..............___L....3.........', - '....A............L.........1....', - '.....____........L..............', - '.................L...____.......', - '...#############.L..............', - '.............####L#######.......', - '.................L..#########...', - '#################L##############', - '#################L##############' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'stampede_blanket', label: 'wide_jump', movement: 'jump' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'cicka_home', label: 'paper_steps_down', movement: 'ramp' } }, - { symbol: '3', kind: 'optional_pocket', attrs: { id: 'early_skill_scrap', reward: 'minor_skill_scrap' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'major_work_artifact' } } - ], - rects: [{ - id: 'artifact_focus', - x: 2, - y: 2, - width: 10, - height: 5 - }, { - id: 'sightline_to_cicka', - x: 0, - y: 7, - width: 9, - height: 4 - }] - }, { - id: 'stampede_blanket', - title: 'Stampede Blanket', - place: { x: 84, y: 49 }, - size: { width: 32, height: 14 }, - theme: 'picnic_ink_paper', - mood: 'tense_playful', - links: ['left:work_artifact', 'up:switchback_shelf', 'optional:drop_pocket'], - props: ['picnic_blanket', 'ink_swarm', 'crumbs', 'loose_pages', 'folded_paper_shortcut'], - declarations: [ - 'minigame stampede_sketch', - 'reward stampede_sketch', - 'after stampede_sketch opens fold_drop_chute via=switchback_shelf to=cicka_home' - ], - grid: [ - '................................', - '....................2...........', - '................._______........', - '................................', - '.....____.................?.....', - '.1.........~~~~~~~..............', - '########...#######.......____...', - '########.................####...', - '............*...................', - '............____.....A..........', - '....................____........', - '......####################......', - '################################', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'work_artifact', label: 'wide_jump_back', movement: 'jump' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'switchback_shelf', label: 'climb_out', movement: 'climb' } }, - { symbol: '?', kind: 'shortcut_preview', attrs: { to: 'cicka_home', requires: 'stampede_sketch', via: 'switchback_shelf', movement: 'drop' } }, - { symbol: '*', kind: 'minigame', attrs: { id: 'stampede_sketch' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'stampede_proof' } } - ], - rects: [{ - id: 'ink_swarm_zone', - x: 10, - y: 4, - width: 10, - height: 4 - }, { - id: 'fold_shortcut_preview', - x: 21, - y: 5, - width: 8, - height: 4 - }] - }, { - id: 'switchback_shelf', - title: 'Switchback Shelf', - place: { x: 84, y: 35 }, - size: { width: 32, height: 12 }, - theme: 'folded_paper_shelf', - mood: 'aha', - links: ['down:stampede_blanket', 'left:telegraph_terrace', 'shortcut:cicka_home'], - props: ['paper_fold', 'paw_scratches', 'cicka_view_below', 'loose_page_bridge'], - declarations: ['after stampede_sketch unlocks shortcut_to=cicka_home kind=fall_steer_fold_drop'], - grid: [ - '................................', - '.........................2......', - '.....................____.......', - '................................', - '....1...........................', - '....____............A...........', - '................____............', - '.........____...................', - '..######################........', - '.............~~~~~..............', - '...............?................', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'stampede_blanket', label: 'climb_down', movement: 'climb' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'telegraph_terrace', label: 'back_left', movement: 'ramp' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'cicka_view_note' } }, - { symbol: '?', kind: 'shortcut', attrs: { to: 'cicka_home', requires: 'stampede_sketch', label: 'fold_drop_home', movement: 'drop' } } - ], - rects: [{ - id: 'cicka_view', - x: 3, - y: 4, - width: 16, - height: 5 - }, { - id: 'fold_drop_chute', - x: 12, - y: 8, - width: 7, - height: 4, - attrs: { to: 'cicka_home', requires: 'stampede_sketch', kind: 'fall_steer' } - }] - }, { - id: 'telegraph_terrace', - title: 'Telegraph Terrace', - place: { x: 44, y: 32 }, - size: { width: 32, height: 13 }, - theme: 'cord_terrace', - mood: 'focused', - links: ['right:guide_overlook', 'down:switchback_shelf', 'shortcut:cicka_home'], - props: ['hanging_cord', 'todo_ai_dummy', 'heavy_bag', 'tick_marks', 'signal_wire'], - declarations: ['minigame telegraph_future', 'future_reward telegraph_clear'], - grid: [ - '................................', - '............................2...', - '......................._____....', - '................................', - '....1...........N...............', - '....____........................', - '............____................', - '.....................____.......', - '.....*..........................', - '.....###########.......A........', - '...............############.....', - '................................', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'switchback_shelf', label: 'back_right', movement: 'ramp' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'guide_overlook', label: 'cord_climb', movement: 'climb' } }, - { symbol: 'N', kind: 'npc', attrs: { id: 'todo_ai_dummy' } }, - { symbol: '*', kind: 'minigame', attrs: { id: 'telegraph_future' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'signal_note' } } - ], - rects: [{ - id: 'cord_drop', - x: 4, - y: 9, - width: 5, - height: 3, - attrs: { to: 'cicka_home', requires: 'telegraph_clear' } - }, { - id: 'sightline_to_cicka', - x: 0, - y: 8, - width: 10, - height: 4 - }] - }, { - id: 'guide_overlook', - title: 'Guide Overlook', - place: { x: 84, y: 21 }, - size: { width: 32, height: 11 }, - theme: 'windy_overlook', - mood: 'reorientation', - links: ['left:telegraph_terrace', 'up:relay_gate', 'optional:drop_pocket'], - props: ['ridge_guide', 'relay_silhouette', 'wind_lines', 'paper_bridge'], - grid: [ - '................................', - '.....................2..........', - '..................______........', - '................................', - '.....1........N.................', - '.....____.......................', - '...............____.............', - '.......................____.....', - '....A...........................', - '.....######################.....', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'telegraph_terrace', label: 'cord_down', movement: 'climb' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'relay_gate', label: 'final_shelf', movement: 'ramp' } }, - { symbol: 'N', kind: 'npc', attrs: { id: 'ridge_guide' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'relay_view_memory' } } - ], - rects: [{ - id: 'sightline_to_relay', - x: 17, - y: 0, - width: 12, - height: 5 - }, { - id: 'wind_hint', - x: 4, - y: 3, - width: 22, - height: 4 - }] - }, { - id: 'relay_gate', - title: 'Relay Gate', - place: { x: 44, y: 10 }, - size: { width: 32, height: 12 }, - theme: 'signal_gate', - mood: 'destination', - links: ['down:guide_overlook', 'right:domino_desk'], - props: ['proof_slots', 'signal_ink', 'locked_gate', 'view_back'], - declarations: ['gate requires=proofs_future'], - grid: [ - '................................', - '...................2............', - '...............________.........', - '................................', - '....1...........................', - '....____............A...........', - '............#####...............', - '............#???#...............', - '............#####...............', - '................................', - '.....######################.....', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'guide_overlook', label: 'final_shelf_back', movement: 'ramp' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'domino_desk', label: 'lift_promise', movement: 'ramp' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'proof_slot_marker' } }, - { symbol: '?', kind: 'gate', attrs: { id: 'relay_proof_slots' } } - ], - rects: [{ - id: 'sightline_view_back', - x: 2, - y: 1, - width: 18, - height: 5 - }] - }, { - id: 'domino_desk', - title: 'Domino Desk', - place: { x: 84, y: 8 }, - size: { width: 32, height: 12 }, - theme: 'desk_machine', - mood: 'mechanical', - links: ['left:relay_gate', 'up:high_ledge', 'shortcut:cicka_home'], - props: ['dominoes', 'coffee_rings', 'elevator_rails', 'deterministic_toys', 'printer_oracle_tease'], - declarations: ['future_shortcut to=cicka_home kind=lift'], - grid: [ - '................................', - '............................2...', - '......................______....', - '................................', - '....1...........................', - '....____.............^..........', - '.....................#..........', - '........A............#..........', - '.....########........#..........', - '.....................#..........', - '.....######################.....', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'relay_gate', label: 'lift_promise_back', movement: 'ramp' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'high_ledge', label: 'future_lift', movement: 'climb' } }, - { symbol: '^', kind: 'lift', attrs: { id: 'desk_lift', to: 'cicka_home', requires: 'domino_future' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'domino_project_scrap' } } - ], - rects: [{ - id: 'lift_travel', - x: 20, - y: 4, - width: 3, - height: 7 - }] - }, { - id: 'high_ledge', - title: 'High Ledge', - place: { x: 118, y: 2 }, - size: { width: 32, height: 8 }, - theme: 'moon_paper_high', - mood: 'aspirational', - links: ['down:domino_desk'], - props: ['unreachable_feather', 'syntax_scraps', 'moon_paper', 'glide_tease'], - declarations: ['future requires=movement_reward'], - grid: [ - '....................A...........', - '...............____.............', - '................................', - '...........____.................', - '................................', - '.....?..........................', - '.....######################.....', - '################################' - ], - anchors: [ - { symbol: 'A', kind: 'artifact', attrs: { id: 'hidden_skill_scrap' } }, - { symbol: '?', kind: 'gate', attrs: { id: 'movement_reward_tease' } } - ], - rects: [{ - id: 'high_view', - x: 0, - y: 0, - width: 32, - height: 4 - }] - }, { - id: 'underpath', - title: 'Paw Underpath', - place: { x: 0, y: 60 }, - size: { width: 32, height: 10 }, - theme: 'paw_scratch_secret', - mood: 'secret', - links: ['left:outskirts', 'right:cicka_home'], - props: ['paw_marks', 'dust', 'low_paper_tunnel', 'cicka_scratches'], - declarations: ['future requires=cicka_translator'], - grid: [ - '................................', - '......1...................2.....', - '....######.............######...', - '................................', - '...........?....................', - '.....____.............____......', - '................................', - '...##########################...', - '################################', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'outskirts', label: 'secret_start_return', movement: 'ramp' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'cicka_home', label: 'secret_home_return', movement: 'ramp' } }, - { symbol: '?', kind: 'secret', attrs: { id: 'cicka_translation_mark' } } - ], - rects: [{ - id: 'crawl_space', - x: 3, - y: 3, - width: 25, - height: 4 - }] - }, { - id: 'drop_pocket', - title: 'Lucky Luna Drop Pocket', - place: { x: 120, y: 38 }, - size: { width: 32, height: 16 }, - theme: 'falling_scraps', - mood: 'playful_descent', - links: ['entry:guide_overlook', 'exit:stampede_blanket'], - props: ['falling_paper', 'wind_lines', 'keycap_scrap', 'safe_shaft'], - declarations: ['future optional=true'], - grid: [ - '................................', - '....1...........................', - '....____........................', - '.........~......................', - '...........~....................', - '.............~..................', - '...............~................', - '.................~..............', - '...................~............', - '.....................~..........', - '.......................2........', - '...........A....................', - '.......____............____.....', - '................................', - '.....######################.....', - '################################' - ], - anchors: [ - { symbol: '1', kind: 'exit', attrs: { to: 'guide_overlook', label: 'optional_descent', movement: 'drop' } }, - { symbol: '2', kind: 'exit', attrs: { to: 'stampede_blanket', label: 'return_near_stampede', movement: 'drop' } }, - { symbol: 'A', kind: 'artifact', attrs: { id: 'hidden_keycap_scrap' } } - ], - rects: [{ - id: 'fall_shaft', - x: 8, - y: 2, - width: 16, - height: 10 - }] - }] -} as const satisfies RidgeBlockoutSource; - -export default FOLDED_DESK_RIDGE_SOURCE; diff --git a/src/game/scenes/ridge/bridge/bridgeTracerSlice.ts b/src/game/scenes/ridge/bridge/bridgeTracerSlice.ts index 312e5bc9..4dc25f72 100644 --- a/src/game/scenes/ridge/bridge/bridgeTracerSlice.ts +++ b/src/game/scenes/ridge/bridge/bridgeTracerSlice.ts @@ -50,8 +50,6 @@ export const BRIDGE_TRACER_WORLD = { } } as const; -export const BRIDGE_TRACER_CAMERA_BOUNDS = BRIDGE_STAGE_SOURCE.cameraBounds; - export const BRIDGE_TRACER_INTERACT_RADIUS = BRIDGE_STAGE_DEFAULT_INTERACT_RADIUS; export type BridgeTracerTargetId = 'cicka' | 'draftsperson' | 'concert-exit'; diff --git a/src/game/scenes/ridge/cicka/CickaPerch.ts b/src/game/scenes/ridge/cicka/CickaPerch.ts deleted file mode 100644 index c75c6eae..00000000 --- a/src/game/scenes/ridge/cicka/CickaPerch.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type * as Phaser from 'phaser'; -import { - CICKA_ANIMATION_KEYS, - CICKA_FRAME_INDEX, - CICKA_ORIGIN, - CICKA_RUNTIME_SCALE, - CICKA_TEXTURE_KEY -} from './assets'; -import { - CICKA_INTERACTION_RESPONSE_DURATION_MS, - CICKA_INTERACTION_TARGET_ID -} from './interaction'; -import { - createCickaWalkByState, - updateCickaWalkBy, - type CickaWalkByState -} from './walkBy'; - -const CICKA_INTERACT_RADIUS = 78; -const CICKA_PROMPT_OFFSET_Y = -86; -const CICKA_SPEECH_BUBBLE_OFFSET = { x: -20, y: -98 } as const; -const CICKA_SPRITE_OFFSET = { x: 14, y: -14 } as const; - -export interface CickaPerchInteractionFacts { - id: typeof CICKA_INTERACTION_TARGET_ID; - kind: 'cicka'; - x: number; - distanceAnchorY: number; - prompt: { - x: number; - y: number; - }; - interactRadius: number; -} - -export interface CickaPerchUpdateFrame { - playerX: number; - playerY: number; - nowMs: number; -} - -export interface CickaPerch { - interactionFacts: CickaPerchInteractionFacts; - trackOwnedObject( - gameObject: GameObject - ): GameObject; - update(frame: CickaPerchUpdateFrame): void; - showLine(line: string, nowMs: number, durationMs?: number): void; - isSpeechVisible(nowMs: number): boolean; - destroy(): void; -} - -export interface CreateCickaPerchOptions { - scene: Phaser.Scene; - landmark: { x: number; y: number }; - hasStampedeNoteMutation: boolean; - walkByLine: string; -} - -export function createCickaPerch(options: CreateCickaPerchOptions): CickaPerch { - const { scene, landmark, hasStampedeNoteMutation, walkByLine } = options; - const x = landmark.x; - const y = landmark.y; - const ownedObjects: Phaser.GameObjects.GameObject[] = []; - let walkByState: CickaWalkByState = createCickaWalkByState(); - let speechVisibleUntilMs = 0; - let isDestroyed = false; - - const track = ( - gameObject: GameObject - ): GameObject => { - ownedObjects.push(gameObject); - return gameObject; - }; - - track(scene.add.rectangle(x, y + 78, 156, 20, 0xd7c78f, 0.96) - .setStrokeStyle(3, 0x1f1f1d, 0.72)); - track(scene.add.rectangle(x - 58, y + 104, 12, 52, 0x1f1f1d, 0.66)); - track(scene.add.rectangle(x + 58, y + 104, 12, 52, 0x1f1f1d, 0.66)); - track(scene.add.rectangle(x + 82, y - 58, 82, 64, 0xf7f1df, 0.9) - .setStrokeStyle(3, 0x1f1f1d, 0.28) - .setAngle(2)); - track(scene.add.circle(x + 54, y - 78, 4, 0x1f1f1d, 0.52)); - track(scene.add.circle(x + 105, y - 74, 4, 0x1f1f1d, 0.52)); - track(scene.add.rectangle(x + 72, y - 52, 24, 18, 0xf0d35f, 0.38) - .setStrokeStyle(1, 0x1f1f1d, 0.24) - .setAngle(-6)); - track(scene.add.rectangle(x + 100, y - 39, 22, 16, 0xb85f5a, 0.28) - .setStrokeStyle(1, 0x1f1f1d, 0.2) - .setAngle(8)); - track(scene.add.rectangle(x - 46, y + 55, 38, 22, 0xf7f1df, 0.88) - .setStrokeStyle(2, 0x1f1f1d, 0.34) - .setAngle(4)); - track(scene.add.rectangle(x, y + 28, 10, 86, 0x1f1f1d, 0.88)); - track(scene.add.rectangle(x, y - 12, 86, 10, 0x1f1f1d, 0.88)); - track(scene.add.rectangle(x + 18, y - 32, 72, 32, 0xf7f1df, 0.94) - .setStrokeStyle(2, 0x1f1f1d, 0.22) - .setAngle(-2)); - track(scene.add.line(x + 8, y - 42, -20, 0, 20, 0, 0x1f1f1d, 0.16).setLineWidth(2)); - track(scene.add.line(x + 8, y - 31, -22, 0, 18, 0, 0x1f1f1d, 0.12).setLineWidth(2)); - - const sprite = track(scene.add.sprite( - x + CICKA_SPRITE_OFFSET.x, - y + CICKA_SPRITE_OFFSET.y, - CICKA_TEXTURE_KEY, - CICKA_FRAME_INDEX.perchSit - ) - .setOrigin(CICKA_ORIGIN.x, CICKA_ORIGIN.y) - .setScale(CICKA_RUNTIME_SCALE) - .setDepth(26)); - sprite.play(CICKA_ANIMATION_KEYS.perchIdle); - - const speechBubble = createCickaSpeechBubble( - scene, - track, - x + CICKA_SPEECH_BUBBLE_OFFSET.x, - y + CICKA_SPEECH_BUBBLE_OFFSET.y - ); - - const interactionFacts: CickaPerchInteractionFacts = { - id: CICKA_INTERACTION_TARGET_ID, - kind: 'cicka', - x, - distanceAnchorY: y, - prompt: { - x, - y: y + CICKA_PROMPT_OFFSET_Y - }, - interactRadius: CICKA_INTERACT_RADIUS - }; - - function isSpeechVisible(nowMs: number): boolean { - return nowMs < speechVisibleUntilMs; - } - - function showLine( - line: string, - nowMs: number, - durationMs: number = CICKA_INTERACTION_RESPONSE_DURATION_MS - ): void { - speechBubble.label.setText(line); - speechVisibleUntilMs = nowMs + durationMs; - speechBubble.container.setVisible(true); - sprite.play(CICKA_ANIMATION_KEYS.notice, true); - } - - function update(frame: CickaPerchUpdateFrame): void { - const walkByUpdate = updateCickaWalkBy(walkByState, { - enabled: hasStampedeNoteMutation, - playerX: frame.playerX, - playerY: frame.playerY, - cickaX: x, - cickaY: y, - nowMs: frame.nowMs - }); - - walkByState = walkByUpdate.state; - if (walkByUpdate.triggered) { - showLine( - walkByLine, - frame.nowMs, - Math.max(0, walkByUpdate.state.visibleUntilMs - frame.nowMs) - ); - } - - const visible = isSpeechVisible(frame.nowMs); - speechBubble.container.setVisible(visible); - if (!visible) { - sprite.play(CICKA_ANIMATION_KEYS.perchIdle, true); - } - } - - function destroy(): void { - if (isDestroyed) return; - isDestroyed = true; - ownedObjects.forEach((gameObject) => gameObject.destroy()); - ownedObjects.length = 0; - } - - return { - interactionFacts, - trackOwnedObject: track, - update, - showLine, - isSpeechVisible, - destroy - }; -} - -function createCickaSpeechBubble( - scene: Phaser.Scene, - track: (gameObject: GameObject) => GameObject, - x: number, - y: number -): { container: Phaser.GameObjects.Container; label: Phaser.GameObjects.Text } { - const container = track(scene.add.container(x, y).setDepth(45).setVisible(false)); - const panel = scene.add.rectangle(0, 0, 66, 28, 0xf7f1df, 1) - .setStrokeStyle(2, 0x1f1f1d, 0.88); - const tail = scene.add.line(22, 14, 0, 0, 13, 13, 0x1f1f1d, 0.5).setLineWidth(2); - const label = scene.add.text(0, -1, '', { - fontFamily: 'monospace', - fontSize: '12px', - color: '#1f1f1d' - }).setOrigin(0.5); - - container.add([panel, tail, label]); - return { container, label }; -} - diff --git a/src/game/scenes/ridge/cicka/assets.ts b/src/game/scenes/ridge/cicka/assets.ts index a98b64aa..26f7578d 100644 --- a/src/game/scenes/ridge/cicka/assets.ts +++ b/src/game/scenes/ridge/cicka/assets.ts @@ -1,9 +1,9 @@ import type * as Phaser from 'phaser'; export const CICKA_TEXTURE_KEY = 'ridge-cicka'; -export const CICKA_ASSET_PATH = '/assets/ridge/cicka/cicka-spritesheet.png'; -export const CICKA_FRAME_WIDTH = 128; -export const CICKA_FRAME_HEIGHT = 96; +const CICKA_ASSET_PATH = '/assets/ridge/cicka/cicka-spritesheet.png'; +const CICKA_FRAME_WIDTH = 128; +const CICKA_FRAME_HEIGHT = 96; export const CICKA_RUNTIME_SCALE = 0.58; export const CICKA_ORIGIN = { x: 0.5, y: 1 } as const; diff --git a/src/game/scenes/ridge/cicka/homeMutationPresentation.test.ts b/src/game/scenes/ridge/cicka/homeMutationPresentation.test.ts deleted file mode 100644 index 60201bf4..00000000 --- a/src/game/scenes/ridge/cicka/homeMutationPresentation.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type * as Phaser from 'phaser'; -import { describe, expect, it, vi } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { addCickaHomeMutationPresentation } from './homeMutationPresentation'; -import type { CickaHomeMutation } from './homeMutations'; - -describe('Cicka Home mutation presentation', () => { - it('tracks Stampede note visuals for perch lifecycle cleanup', () => { - const scene = createScene(); - const trackedObjects: Phaser.GameObjects.GameObject[] = []; - - addCickaHomeMutationPresentation({ - scene: scene.scene, - landmark: { x: 100, y: 200 }, - mutations: [createStampedeNoteMutation()], - track: (gameObject) => { - trackedObjects.push(gameObject); - return gameObject; - } - }); - - expect(trackedObjects).toHaveLength(8); - expect(scene.add.rectangle).toHaveBeenCalledTimes(1); - expect(scene.add.line).toHaveBeenCalledTimes(3); - expect(scene.add.circle).toHaveBeenCalledTimes(4); - }); -}); - -function createStampedeNoteMutation(): CickaHomeMutation { - return { - id: 'stampede_sketch', - adds: 'stampede_note', - opens: 'fold_drop_landing', - attrs: { - adds: 'stampede_note', - opens: 'fold_drop_landing' - }, - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - }; -} - -function createScene(): { - scene: Phaser.Scene; - add: { - rectangle: ReturnType; - line: ReturnType; - circle: ReturnType; - }; -} { - const add = { - rectangle: vi.fn(() => createGameObject()), - line: vi.fn(() => createGameObject()), - circle: vi.fn(() => createGameObject()) - }; - - return { - scene: { - add - } as unknown as Phaser.Scene, - add - }; -} - -function createGameObject(): Phaser.GameObjects.GameObject { - const gameObject = { - setStrokeStyle: vi.fn(() => gameObject), - setAngle: vi.fn(() => gameObject), - setLineWidth: vi.fn(() => gameObject), - destroy: vi.fn() - }; - - return gameObject as unknown as Phaser.GameObjects.GameObject; -} diff --git a/src/game/scenes/ridge/cicka/homeMutationPresentation.ts b/src/game/scenes/ridge/cicka/homeMutationPresentation.ts deleted file mode 100644 index fba5ccdd..00000000 --- a/src/game/scenes/ridge/cicka/homeMutationPresentation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type * as Phaser from 'phaser'; -import type { CickaHomeMutation } from './homeMutations'; -import { hasCickaHomeMutationAdd } from './homeMutations'; - -export type TrackCickaHomeMutationObject = < - GameObject extends Phaser.GameObjects.GameObject ->( - gameObject: GameObject -) => GameObject; - -export interface CickaHomeMutationPresentationOptions { - scene: Phaser.Scene; - landmark: { x: number; y: number }; - mutations: readonly CickaHomeMutation[]; - track: TrackCickaHomeMutationObject; -} - -export function addCickaHomeMutationPresentation( - options: CickaHomeMutationPresentationOptions -): void { - const { scene, landmark, mutations, track } = options; - - if (hasCickaHomeMutationAdd(mutations, 'stampede_note')) { - addCickaStampedeNoteMutation(scene, track, landmark.x, landmark.y); - } -} - -function addCickaStampedeNoteMutation( - scene: Phaser.Scene, - track: TrackCickaHomeMutationObject, - x: number, - y: number -): void { - track(scene.add.rectangle(x + 62, y - 72, 42, 30, 0xf7f1df, 1) - .setStrokeStyle(2, 0x1f1f1d, 0.88) - .setAngle(5)); - track(scene.add.line(x + 42, y - 63, -8, 5, 8, -5, 0x1f1f1d, 0.3).setLineWidth(2)); - track(scene.add.circle(x + 59, y - 70, 5, 0x1f1f1d, 0.72)); - track(scene.add.circle(x + 51, y - 81, 3, 0x1f1f1d, 0.72)); - track(scene.add.circle(x + 60, y - 84, 3, 0x1f1f1d, 0.72)); - track(scene.add.circle(x + 69, y - 81, 3, 0x1f1f1d, 0.72)); - track(scene.add.line(x + 78, y - 70, -7, 5, 10, -5, 0x1f1f1d, 0.48).setLineWidth(2)); - track(scene.add.line(x + 79, y - 63, -6, 4, 9, -4, 0x1f1f1d, 0.38).setLineWidth(2)); -} diff --git a/src/game/scenes/ridge/cicka/homeMutations.test.ts b/src/game/scenes/ridge/cicka/homeMutations.test.ts deleted file mode 100644 index 4b329aa9..00000000 --- a/src/game/scenes/ridge/cicka/homeMutations.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import type { BridgeRidgeProgressState } from '@/game/bridge/store'; -import { - RIDGE_BLOCKOUT, - compileRidgeBlockoutFacts -} from '../blockout'; -import { - hasCickaHomeMutationAdd, - resolveCickaHomeMutations -} from './homeMutations'; - -function createRidgeProgress( - overrides: Partial = {} -): BridgeRidgeProgressState { - return { - stampIds: [], - manualPageIds: [], - mobility: { - glidePips: 0 - }, - shortcutIds: [], - firstPlayableRoute: { - activeAreaId: 'bridge', - bridgeBeat: 'intro' - }, - ...overrides - }; -} - -describe('Cicka Home mutations', () => { - it('activates no mutations for empty Ridge progress', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const resolution = resolveCickaHomeMutations( - facts.homeMutations, - createRidgeProgress() - ); - - expect(resolution.active).toEqual([]); - }); - - it('activates the compiled Stampede mutation from the durable Stampede stamp', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const resolution = resolveCickaHomeMutations( - facts.homeMutations, - createRidgeProgress({ - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }) - ); - - expect(resolution.active).toContainEqual(expect.objectContaining({ - id: 'stampede_sketch', - adds: 'stampede_note', - opens: 'fold_drop_landing', - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - })); - expect(hasCickaHomeMutationAdd(resolution.active, 'stampede_note')).toBe(true); - }); - - it('does not activate Cicka Home mutations from unrelated progress', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const resolution = resolveCickaHomeMutations( - facts.homeMutations, - createRidgeProgress({ - stampIds: ['future-stamp'], - manualPageIds: ['future-manual-page'], - shortcutIds: ['telegraph_clear'], - mobility: { - glidePips: 3 - } - }) - ); - - expect(resolution.active).toEqual([]); - }); - - it('keeps non-Stampede mutations as typed future promises', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const resolution = resolveCickaHomeMutations( - facts.homeMutations, - createRidgeProgress({ - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }) - ); - - expect(resolution.promises).toContainEqual(expect.objectContaining({ - id: 'work_artifact', - adds: 'work_display', - source: { - kind: 'future-progress' - } - })); - expect(resolution.active).not.toContainEqual(expect.objectContaining({ - id: 'work_artifact' - })); - }); -}); diff --git a/src/game/scenes/ridge/cicka/homeMutations.ts b/src/game/scenes/ridge/cicka/homeMutations.ts deleted file mode 100644 index 5a6398e7..00000000 --- a/src/game/scenes/ridge/cicka/homeMutations.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import type { - BridgeRidgeProgressState, - RidgeStampId -} from '@/game/bridge/store'; -import type { RidgeHomeMutationFact } from '../blockout'; - -export type CickaHomeMutationProgressSource = - | { - kind: 'stamp'; - id: RidgeStampId; - }; - -export interface CickaHomeMutation extends RidgeHomeMutationFact { - source: CickaHomeMutationProgressSource; -} - -export interface CickaHomeMutationPromise extends RidgeHomeMutationFact { - source: { - kind: 'future-progress'; - }; -} - -export interface CickaHomeMutationResolution { - active: readonly CickaHomeMutation[]; - promises: readonly CickaHomeMutationPromise[]; -} - -type CickaHomeMutationProgressSourceActiveChecks = { - [Kind in CickaHomeMutationProgressSource['kind']]: ( - source: Extract, - ridgeProgress: BridgeRidgeProgressState - ) => boolean; -}; - -const CICKA_HOME_MUTATION_PROGRESS_SOURCES: Readonly> = { - stampede_sketch: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } -}; - -const CICKA_HOME_MUTATION_PROGRESS_SOURCE_ACTIVE_CHECKS = { - stamp: (source, ridgeProgress) => ridgeProgress.stampIds.includes(source.id) -} satisfies CickaHomeMutationProgressSourceActiveChecks; - -export function resolveCickaHomeMutations( - homeMutations: readonly RidgeHomeMutationFact[], - ridgeProgress: BridgeRidgeProgressState -): CickaHomeMutationResolution { - const active: CickaHomeMutation[] = []; - const promises: CickaHomeMutationPromise[] = []; - - homeMutations.forEach((mutation) => { - const source = CICKA_HOME_MUTATION_PROGRESS_SOURCES[mutation.id]; - if (!source) { - promises.push({ - ...mutation, - source: { - kind: 'future-progress' - } - }); - return; - } - - if (isCickaHomeMutationSourceActive(source, ridgeProgress)) { - active.push({ - ...mutation, - source - }); - } - }); - - return { active, promises }; -} - -function isCickaHomeMutationSourceActive( - source: CickaHomeMutationProgressSource, - ridgeProgress: BridgeRidgeProgressState -): boolean { - switch (source.kind) { - case 'stamp': - return CICKA_HOME_MUTATION_PROGRESS_SOURCE_ACTIVE_CHECKS.stamp(source, ridgeProgress); - } -} - -export function hasCickaHomeMutationAdd( - mutations: readonly Pick[], - addId: string -): boolean { - return mutations.some((mutation) => mutation.adds === addId); -} diff --git a/src/game/scenes/ridge/cicka/interaction.test.ts b/src/game/scenes/ridge/cicka/interaction.test.ts deleted file mode 100644 index ede3628d..00000000 --- a/src/game/scenes/ridge/cicka/interaction.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - CICKA_INTERACTION_TARGET_ID, - getCickaInteractionResponse, - shouldShowCickaInteractionPrompt -} from './interaction'; -import { getRidgeWorldMemories } from '../worldMemory'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; - -const copy = { - fresh: 'meow.', - stampedeMemory: 'mrrp!' -}; - -describe('cicka interaction', () => { - it('returns the first-meeting line without Ridge memories', () => { - expect(getCickaInteractionResponse([], copy)).toEqual({ - id: 'fresh', - line: 'meow.' - }); - }); - - it('returns the post-Stampede line when the Cicka memory exists', () => { - const memories = getRidgeWorldMemories({ - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }); - - expect(getCickaInteractionResponse(memories, copy)).toEqual({ - id: 'stampede-memory', - line: 'mrrp!' - }); - }); - - it('keeps unrelated memories on the first-meeting line', () => { - expect(getCickaInteractionResponse([{ id: 'stampede-held-sticker' }], copy)).toEqual({ - id: 'fresh', - line: 'meow.' - }); - }); - - it('replaces the Cicka interact prompt while her response is visible', () => { - expect( - shouldShowCickaInteractionPrompt({ - activeTargetId: CICKA_INTERACTION_TARGET_ID, - responseVisible: true - }) - ).toBe(false); - - expect( - shouldShowCickaInteractionPrompt({ - activeTargetId: CICKA_INTERACTION_TARGET_ID, - responseVisible: false - }) - ).toBe(true); - - expect( - shouldShowCickaInteractionPrompt({ - activeTargetId: 'trail-card-sketchbook', - responseVisible: true - }) - ).toBe(true); - }); -}); diff --git a/src/game/scenes/ridge/cicka/interaction.ts b/src/game/scenes/ridge/cicka/interaction.ts deleted file mode 100644 index 419ff1e5..00000000 --- a/src/game/scenes/ridge/cicka/interaction.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { RidgeWorldMemory, RidgeWorldMemoryId } from '../worldMemory'; - -export const CICKA_INTERACTION_TARGET_ID = 'cicka-perch' as const; -export const CICKA_INTERACTION_RESPONSE_DURATION_MS = 2600; - -export type CickaInteractionResponseId = 'fresh' | 'stampede-memory'; - -export interface CickaInteractionCopy { - fresh: string; - stampedeMemory: string; -} - -export interface CickaInteractionResponse { - id: CickaInteractionResponseId; - line: string; -} - -export function shouldShowCickaInteractionPrompt(input: { - activeTargetId: string | null; - responseVisible: boolean; -}): boolean { - return input.activeTargetId !== CICKA_INTERACTION_TARGET_ID || !input.responseVisible; -} - -export function getCickaInteractionResponse( - memories: readonly Pick[], - copy: CickaInteractionCopy -): CickaInteractionResponse { - if (hasMemory(memories, 'cicka-stampede-note')) { - return { - id: 'stampede-memory', - line: copy.stampedeMemory - }; - } - - return { - id: 'fresh', - line: copy.fresh - }; -} - -function hasMemory( - memories: readonly Pick[], - memoryId: RidgeWorldMemoryId -): boolean { - return memories.some((memory) => memory.id === memoryId); -} diff --git a/src/game/scenes/ridge/cicka/walkBy.test.ts b/src/game/scenes/ridge/cicka/walkBy.test.ts deleted file mode 100644 index 94013d71..00000000 --- a/src/game/scenes/ridge/cicka/walkBy.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - CICKA_WALK_BY_BARK_DURATION_MS, - createCickaWalkByState, - updateCickaWalkBy, - type CickaWalkByFrame -} from './walkBy'; - -function createFrame(overrides: Partial = {}): CickaWalkByFrame { - return { - enabled: true, - playerX: 220, - playerY: 446, - cickaX: 220, - cickaY: 446, - nowMs: 1000, - ...overrides - }; -} - -describe('cicka walk-by memory', () => { - it('does not trigger when the memory is unavailable', () => { - const initialState = createCickaWalkByState(); - - const update = updateCickaWalkBy(initialState, createFrame({ enabled: false })); - - expect(update).toEqual({ - state: initialState, - triggered: false, - visible: false - }); - }); - - it('does not trigger outside the walk-by radius', () => { - const initialState = createCickaWalkByState(); - - const update = updateCickaWalkBy(initialState, createFrame({ playerX: 360 })); - - expect(update).toEqual({ - state: initialState, - triggered: false, - visible: false - }); - }); - - it('triggers once when the player enters range with the memory available', () => { - const update = updateCickaWalkBy(createCickaWalkByState(), createFrame()); - - expect(update.triggered).toBe(true); - expect(update.visible).toBe(true); - expect(update.state).toEqual({ - hasBarked: true, - visibleUntilMs: 1000 + CICKA_WALK_BY_BARK_DURATION_MS - }); - }); - - it('does not duplicate on repeated in-range frames', () => { - const firstUpdate = updateCickaWalkBy(createCickaWalkByState(), createFrame()); - const secondUpdate = updateCickaWalkBy(firstUpdate.state, createFrame({ nowMs: 1200 })); - - expect(secondUpdate.triggered).toBe(false); - expect(secondUpdate.visible).toBe(true); - expect(secondUpdate.state).toBe(firstUpdate.state); - }); - - it('hides after the bark duration elapses', () => { - const firstUpdate = updateCickaWalkBy(createCickaWalkByState(), createFrame()); - const hiddenUpdate = updateCickaWalkBy( - firstUpdate.state, - createFrame({ nowMs: 1000 + CICKA_WALK_BY_BARK_DURATION_MS }) - ); - - expect(hiddenUpdate.triggered).toBe(false); - expect(hiddenUpdate.visible).toBe(false); - }); -}); diff --git a/src/game/scenes/ridge/cicka/walkBy.ts b/src/game/scenes/ridge/cicka/walkBy.ts deleted file mode 100644 index 67ec7089..00000000 --- a/src/game/scenes/ridge/cicka/walkBy.ts +++ /dev/null @@ -1,73 +0,0 @@ -export const CICKA_WALK_BY_RADIUS = 88; -export const CICKA_WALK_BY_BARK_DURATION_MS = 2600; - -export interface CickaWalkByState { - hasBarked: boolean; - visibleUntilMs: number; -} - -export interface CickaWalkByFrame { - enabled: boolean; - playerX: number; - playerY: number; - cickaX: number; - cickaY: number; - nowMs: number; -} - -export interface CickaWalkByUpdate { - state: CickaWalkByState; - triggered: boolean; - visible: boolean; -} - -export function createCickaWalkByState(): CickaWalkByState { - return { - hasBarked: false, - visibleUntilMs: 0 - }; -} - -export function updateCickaWalkBy( - state: CickaWalkByState, - frame: CickaWalkByFrame -): CickaWalkByUpdate { - const visible = frame.nowMs < state.visibleUntilMs; - if (!frame.enabled) { - return { - state, - triggered: false, - visible: false - }; - } - - if (state.hasBarked) { - return { - state, - triggered: false, - visible - }; - } - - const dx = frame.playerX - frame.cickaX; - const dy = frame.playerY - frame.cickaY; - const isNearCicka = dx * dx + dy * dy <= CICKA_WALK_BY_RADIUS * CICKA_WALK_BY_RADIUS; - if (!isNearCicka) { - return { - state, - triggered: false, - visible: false - }; - } - - const nextState = { - hasBarked: true, - visibleUntilMs: frame.nowMs + CICKA_WALK_BY_BARK_DURATION_MS - }; - - return { - state: nextState, - triggered: true, - visible: true - }; -} diff --git a/src/game/scenes/ridge/index.ts b/src/game/scenes/ridge/index.ts deleted file mode 100644 index 167d0820..00000000 --- a/src/game/scenes/ridge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createRidgeSceneContext } from './sceneContext'; diff --git a/src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts b/src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts deleted file mode 100644 index 58077f33..00000000 --- a/src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts +++ /dev/null @@ -1,361 +0,0 @@ -import * as Phaser from 'phaser'; -import { - type RidgeBlockoutAnchorPoint, - type RidgeBlockoutAssistZone, - type RidgeBlockoutBounds, - type RidgeBlockoutCollider, - type RidgeBlockoutFacts, - type RidgeBlockoutGeometry, - type RidgeBlockoutRoomBounds -} from '../blockout'; -import { getRidgeBlockoutRoomCenter } from './ridgePresentationFacts'; - -const BACKDROP_BASE_DEPTH = -100; -const BACKDROP_SKETCH_DEPTH = -90; -const BACKDROP_RULED_LINE = { - startY: 160, - spacingY: 420, - width: 3, - color: 0x1f1f1d, - alpha: 0.06 -} as const; -const BACKDROP_DIAGONAL_SCRAPS = { - startX: 180, - spacingX: 420, - waveBaseY: 260, - waveDivisor: 180, - waveAmplitude: 34, - primary: { - width: 4, - color: 0x1f1f1d, - alpha: 0.12, - from: { x: -150, y: 34 }, - to: { x: 150, y: -34 } - }, - secondary: { - width: 3, - color: 0x1f1f1d, - alpha: 0.09, - from: { x: -52, y: 60 }, - to: { x: 140, y: 24 } - } -} as const; - -export interface RidgeBlockoutPresentationOptions { - scene: Phaser.Scene; - facts: RidgeBlockoutFacts; - geometry: RidgeBlockoutGeometry; -} - -export interface RidgeBlockoutPresentation { - platforms: Phaser.GameObjects.Zone[]; -} - -export function createRidgeBlockoutPresentation( - options: RidgeBlockoutPresentationOptions -): RidgeBlockoutPresentation { - const { scene, facts, geometry } = options; - - addBackdrop(scene, geometry.bounds); - addRoomBounds(scene, geometry.roomBounds); - addFutureRoutePromises(scene, facts); - addTraversalConnectorVisuals(scene, geometry); - const platforms = addBlockoutColliders(scene, geometry); - addShortcutPromises(scene, facts); - addAnchorMarkers(scene, geometry); - - return { platforms }; -} - -function addBackdrop(scene: Phaser.Scene, bounds: RidgeBlockoutBounds): void { - scene.add.rectangle( - bounds.width / 2, - bounds.height / 2, - bounds.width, - bounds.height, - 0xf7f1df, - 1 - ).setDepth(BACKDROP_BASE_DEPTH); - - const graphics = scene.add.graphics().setDepth(BACKDROP_SKETCH_DEPTH); - graphics.lineStyle( - BACKDROP_RULED_LINE.width, - BACKDROP_RULED_LINE.color, - BACKDROP_RULED_LINE.alpha - ); - for (let y = BACKDROP_RULED_LINE.startY; y < bounds.height; y += BACKDROP_RULED_LINE.spacingY) { - graphics.strokeLineShape(new Phaser.Geom.Line(0, y, bounds.width, y)); - } - - for ( - let x = BACKDROP_DIAGONAL_SCRAPS.startX; - x < bounds.width; - x += BACKDROP_DIAGONAL_SCRAPS.spacingX - ) { - const y = BACKDROP_DIAGONAL_SCRAPS.waveBaseY + - Math.sin(x / BACKDROP_DIAGONAL_SCRAPS.waveDivisor) * - BACKDROP_DIAGONAL_SCRAPS.waveAmplitude; - graphics.lineStyle( - BACKDROP_DIAGONAL_SCRAPS.primary.width, - BACKDROP_DIAGONAL_SCRAPS.primary.color, - BACKDROP_DIAGONAL_SCRAPS.primary.alpha - ); - graphics.strokeLineShape(new Phaser.Geom.Line( - x + BACKDROP_DIAGONAL_SCRAPS.primary.from.x, - y + BACKDROP_DIAGONAL_SCRAPS.primary.from.y, - x + BACKDROP_DIAGONAL_SCRAPS.primary.to.x, - y + BACKDROP_DIAGONAL_SCRAPS.primary.to.y - )); - graphics.lineStyle( - BACKDROP_DIAGONAL_SCRAPS.secondary.width, - BACKDROP_DIAGONAL_SCRAPS.secondary.color, - BACKDROP_DIAGONAL_SCRAPS.secondary.alpha - ); - graphics.strokeLineShape(new Phaser.Geom.Line( - x + BACKDROP_DIAGONAL_SCRAPS.secondary.from.x, - y + BACKDROP_DIAGONAL_SCRAPS.secondary.from.y, - x + BACKDROP_DIAGONAL_SCRAPS.secondary.to.x, - y + BACKDROP_DIAGONAL_SCRAPS.secondary.to.y - )); - } -} - -function addRoomBounds( - scene: Phaser.Scene, - roomBounds: readonly RidgeBlockoutRoomBounds[] -): void { - roomBounds.forEach((room) => { - scene.add.rectangle( - room.x + room.width / 2, - room.y + room.height / 2, - room.width, - room.height, - 0xf7f1df, - 0.03 - ) - .setStrokeStyle(2, 0x1f1f1d, 0.12) - .setDepth(-20); - scene.add.text(room.x + 12, room.y + 12, room.title, { - fontFamily: 'monospace', - fontSize: '16px', - color: '#4b4337', - backgroundColor: '#f7f1dfcc', - padding: { x: 4, y: 2 } - }).setDepth(20); - }); -} - -function addFutureRoutePromises( - scene: Phaser.Scene, - facts: RidgeBlockoutFacts -): void { - const graphics = scene.add.graphics().setDepth(-8); - graphics.lineStyle(3, 0x596f8f, 0.18); - - facts.futureRoutes.forEach((route) => { - route.links.forEach((link) => { - const from = getRidgeBlockoutRoomCenter(facts, link.fromRoomId); - const to = getRidgeBlockoutRoomCenter(facts, link.toRoomId); - if (!from || !to) return; - graphics.strokeLineShape(new Phaser.Geom.Line(from.x, from.y, to.x, to.y)); - }); - }); -} - -function addTraversalConnectorVisuals( - scene: Phaser.Scene, - geometry: RidgeBlockoutGeometry -): void { - const graphics = scene.add.graphics().setDepth(3); - geometry.routeConnectors.forEach((connector) => { - connector.assistZones.forEach((zone) => { - const color = getAssistZoneColor(zone); - if (zone.kind === 'climb') { - drawLadderVisual(graphics, zone, color); - scene.add.rectangle(zone.x, zone.y, 24, zone.height, 0x1f1f1d, 0.08) - .setStrokeStyle(2, color, 0.28) - .setDepth(2); - return; - } - - graphics.lineStyle(8, color, zone.kind === 'drop' ? 0.24 : 0.38); - graphics.strokeLineShape(new Phaser.Geom.Line( - zone.from.x, - zone.from.y, - zone.to.x, - zone.to.y - )); - }); - }); - - geometry.shortcutConnections.forEach((connection) => { - connection.assistZones.forEach((zone) => { - graphics.lineStyle(6, getAssistZoneColor(zone), 0.3); - graphics.strokeLineShape(new Phaser.Geom.Line( - zone.from.x, - zone.from.y, - zone.to.x, - zone.to.y - )); - }); - }); -} - -function addBlockoutColliders( - scene: Phaser.Scene, - geometry: RidgeBlockoutGeometry -): Phaser.GameObjects.Zone[] { - return geometry.colliders.map((collider) => { - addColliderVisual(scene, collider); - const zone = scene.add.zone(collider.x, collider.y, collider.width, collider.height); - scene.physics.add.existing(zone, true); - configureColliderBody(zone, collider); - return zone; - }); -} - -function configureColliderBody( - zone: Phaser.GameObjects.Zone, - collider: RidgeBlockoutCollider -): void { - if (collider.movement !== 'ramp') return; - const body = zone.body as Phaser.Physics.Arcade.StaticBody | undefined; - if (!body) return; - - body.checkCollision.left = false; - body.checkCollision.right = false; - body.checkCollision.down = false; - body.checkCollision.up = true; -} - -function addColliderVisual(scene: Phaser.Scene, collider: RidgeBlockoutCollider): void { - const style = getColliderStyle(collider); - scene.add.rectangle( - collider.x, - collider.y, - collider.width, - collider.height, - style.fill, - style.alpha - ) - .setStrokeStyle(style.strokeWidth, 0x1f1f1d, style.strokeAlpha) - .setDepth(style.depth); -} - -function addShortcutPromises(scene: Phaser.Scene, facts: RidgeBlockoutFacts): void { - facts.shortcuts.forEach((connection) => { - if (!connection.from) return; - const graphics = scene.add.graphics().setDepth(6); - graphics.lineStyle(4, connection.available ? 0xf0d35f : 0x1f1f1d, connection.available ? 0.34 : 0.16); - graphics.strokeLineShape(new Phaser.Geom.Line( - connection.from.x, - connection.from.y, - connection.to.x, - connection.to.y - )); - - if (!connection.available) { - const centerX = (connection.from.x + connection.to.x) / 2; - const centerY = (connection.from.y + connection.to.y) / 2; - scene.add.rectangle(centerX, centerY, 96, 18, 0xf7f1df, 0.24) - .setStrokeStyle(2, 0x1f1f1d, 0.18) - .setAngle(-4) - .setDepth(7); - } - }); -} - -function addAnchorMarkers(scene: Phaser.Scene, geometry: RidgeBlockoutGeometry): void { - geometry.anchorPoints.forEach((anchor) => { - if (!['A', '*', '?', '^', 'N', 'M'].includes(anchor.symbol)) return; - scene.add.circle(anchor.x, anchor.y, 9, getAnchorColor(anchor), 0.58) - .setStrokeStyle(2, 0x1f1f1d, 0.45) - .setDepth(14); - scene.add.text(anchor.x + 12, anchor.y - 14, anchor.symbol, { - fontFamily: 'monospace', - fontSize: '13px', - color: '#1f1f1d', - backgroundColor: '#f7f1df99', - padding: { x: 2, y: 1 } - }).setDepth(15); - }); -} - -function getColliderStyle(collider: RidgeBlockoutCollider): { - fill: number; - alpha: number; - strokeWidth: number; - strokeAlpha: number; - depth: number; -} { - switch (collider.kind) { - case 'solid': - return { fill: 0x2f4736, alpha: 0.94, strokeWidth: 1, strokeAlpha: 0.16, depth: 1 }; - case 'platform': - return { fill: 0xd7c78f, alpha: 0.94, strokeWidth: 2, strokeAlpha: 0.42, depth: 4 }; - case 'route-connector': - return { fill: 0xf7f1df, alpha: 0.96, strokeWidth: 2, strokeAlpha: 0.52, depth: 5 }; - case 'shortcut-connector': - return { fill: 0xf0d35f, alpha: 0.82, strokeWidth: 2, strokeAlpha: 0.6, depth: 6 }; - } -} - -function getAnchorColor(anchor: RidgeBlockoutAnchorPoint): number { - switch (anchor.symbol) { - case '*': - return 0xb85f5a; - case '?': - return 0x596f8f; - case '^': - return 0xf0d35f; - case 'A': - return 0xd7c78f; - default: - return 0xf7f1df; - } -} - -function getAssistZoneColor(zone: RidgeBlockoutAssistZone): number { - switch (zone.kind) { - case 'ramp': - return 0xd7c78f; - case 'climb': - return 0x596f8f; - case 'drop': - return 0xf0d35f; - } -} - -function drawLadderVisual( - graphics: Phaser.GameObjects.Graphics, - zone: RidgeBlockoutAssistZone, - color: number -): void { - const railOffset = 9; - const minY = Math.min(zone.from.y, zone.to.y); - const maxY = Math.max(zone.from.y, zone.to.y); - - graphics.lineStyle(4, color, 0.5); - graphics.strokeLineShape(new Phaser.Geom.Line( - zone.from.x - railOffset, - zone.from.y, - zone.to.x - railOffset, - zone.to.y - )); - graphics.strokeLineShape(new Phaser.Geom.Line( - zone.from.x + railOffset, - zone.from.y, - zone.to.x + railOffset, - zone.to.y - )); - - graphics.lineStyle(3, 0x1f1f1d, 0.22); - for (let y = minY + 20; y < maxY; y += 34) { - graphics.strokeLineShape(new Phaser.Geom.Line( - zone.from.x - railOffset - 4, - y, - zone.from.x + railOffset + 4, - y - )); - } -} diff --git a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.test.ts b/src/game/scenes/ridge/runtime/ridgeDebugOverlay.test.ts deleted file mode 100644 index 5a42848f..00000000 --- a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - RIDGE_BLOCKOUT, - compileRidgeBlockoutFacts, - deriveRidgeBlockoutGeometry -} from '../blockout'; -import type { CickaInteractionCopy } from '../cicka/interaction'; -import { createRidgeInteractionTargets } from './ridgeInteractionTargets'; -import { createRidgeDebugDrawCommands } from './ridgeDebugOverlay'; - -const cickaCopy: CickaInteractionCopy = { - fresh: 'fresh', - stampedeMemory: 'memory' -}; - -const debugOff = { - graybox: false, - showColliders: false, - showPlayerBody: false, - showInteractZones: false, - showTraversalAssists: false -}; - -describe('ridge debug overlay draw commands', () => { - it('returns no commands when all debug settings are disabled', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - - expect(createRidgeDebugDrawCommands({ - geometry, - interactionTargets: [], - settings: debugOff - })).toEqual([]); - }); - - it('creates source-backed collider, player, interaction, and traversal commands', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT, { geometry }); - const interactionTargets = createRidgeInteractionTargets({ - facts, - cickaInteractionCopy: cickaCopy, - getWorldMemories: () => [] - }); - - const commands = createRidgeDebugDrawCommands({ - geometry, - interactionTargets, - player: { - x: 300, - y: 400, - body: { - x: 280, - y: 350, - width: 40, - height: 90 - } - }, - settings: { - graybox: false, - showColliders: true, - showPlayerBody: true, - showInteractZones: true, - showTraversalAssists: true - } - }); - - expect(commands.some((command) => command.id.startsWith('collider:'))).toBe(true); - expect(commands.some((command) => command.id === 'player-body')).toBe(true); - expect(commands.some((command) => command.id.startsWith('interact:'))).toBe(true); - expect(commands.some((command) => command.id.startsWith('assist-line:'))).toBe(true); - }); - - it('treats graybox mode as a composite geometry overlay', () => { - const geometry = deriveRidgeBlockoutGeometry(RIDGE_BLOCKOUT); - - const commands = createRidgeDebugDrawCommands({ - geometry, - interactionTargets: [], - player: { - x: 300, - y: 400, - body: { - x: 280, - y: 350, - width: 40, - height: 90 - } - }, - settings: { - ...debugOff, - graybox: true - } - }); - - expect(commands.some((command) => command.id === 'graybox-wash')).toBe(true); - expect(commands.some((command) => command.id.startsWith('room:'))).toBe(true); - expect(commands.some((command) => command.id.startsWith('anchor:'))).toBe(true); - expect(commands.some((command) => command.id.startsWith('collider:'))).toBe(true); - expect(commands.some((command) => command.id === 'player-body')).toBe(true); - }); -}); diff --git a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts b/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts deleted file mode 100644 index feaa253f..00000000 --- a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts +++ /dev/null @@ -1,313 +0,0 @@ -import type * as Phaser from 'phaser'; -import { - renderRidgeDebugDrawCommands, - type RidgeDebugDrawCommand -} from '../debugDrawCommands'; -import type { - RidgeBlockoutGeometry, - RidgeBlockoutAssistZone, - RidgeBlockoutCollider -} from '../blockout'; -import type { RidgeDevDebugSettings } from './ridgeDevControls'; -import type { RidgeInteractionTarget } from './ridgeInteractionTargets'; -import type { RidgeTraversalRuntimeState } from './ridgeTraversalRuntime'; - -export interface RidgeDebugOverlayPlayer { - x: number; - y: number; - body?: { - x: number; - y: number; - width: number; - height: number; - }; -} - -export type { RidgeDebugDrawCommand } from '../debugDrawCommands'; - -export interface RidgeDebugDrawCommandOptions { - geometry: RidgeBlockoutGeometry; - interactionTargets: readonly RidgeInteractionTarget[]; - player?: RidgeDebugOverlayPlayer; - settings: RidgeDevDebugSettings; - traversalState?: RidgeTraversalRuntimeState; - defaultInteractRadius?: number; -} - -export type RidgeDebugOverlayFrame = RidgeDebugDrawCommandOptions; - -export interface RidgeDebugOverlay { - render(frame: RidgeDebugOverlayFrame): void; - destroy(): void; -} - -const RIDGE_DEBUG_OVERLAY_DEPTH = 500; -const DEFAULT_INTERACT_RADIUS = 72; - -export function createRidgeDebugDrawCommands( - options: RidgeDebugDrawCommandOptions -): RidgeDebugDrawCommand[] { - const commands: RidgeDebugDrawCommand[] = []; - const enabled = getEffectiveDebugSettings(options.settings); - if (!isAnyDebugSettingEnabled(enabled)) return commands; - - if (options.settings.graybox) { - commands.push(createGrayboxWash(options.geometry)); - options.geometry.roomBounds.forEach((room) => { - commands.push({ - kind: 'rect', - id: `room:${room.roomId}`, - x: room.x + room.width / 2, - y: room.y + room.height / 2, - width: room.width, - height: room.height, - fillColor: 0xfbfbf9, - fillAlpha: 0.04, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.24, - lineWidth: 3 - }); - }); - options.geometry.anchorPoints.forEach((anchor) => { - commands.push({ - kind: 'circle', - id: `anchor:${anchor.roomId}:${anchor.symbol}:${anchor.attrs.id ?? anchor.kind}`, - x: anchor.x, - y: anchor.y, - radius: 11, - fillColor: 0xfbfbf9, - fillAlpha: 0.45, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.62, - lineWidth: 2 - }); - }); - } - - if (enabled.showColliders) { - options.geometry.colliders.forEach((collider) => { - commands.push(createColliderCommand(collider)); - }); - } - - if (enabled.showTraversalAssists) { - options.geometry.assistZones.forEach((zone) => { - commands.push(createAssistZoneLineCommand(zone, options.traversalState)); - commands.push(createAssistZoneBoundsCommand(zone, options.traversalState)); - }); - } - - if (enabled.showInteractZones) { - const defaultRadius = options.defaultInteractRadius ?? DEFAULT_INTERACT_RADIUS; - options.interactionTargets.forEach((target) => { - const radius = target.interactRadius ?? defaultRadius; - commands.push({ - kind: 'circle', - id: `interact:${target.id}`, - x: target.x, - y: target.distanceAnchorY, - radius, - fillColor: 0xf0d35f, - fillAlpha: 0.08, - strokeColor: 0xb85f5a, - strokeAlpha: 0.72, - lineWidth: 3 - }); - commands.push({ - kind: 'rect', - id: `interact-prompt:${target.id}`, - x: target.prompt.x, - y: target.prompt.y, - width: 18, - height: 18, - fillColor: 0xb85f5a, - fillAlpha: 0.24, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.55, - lineWidth: 2 - }); - }); - } - - if (enabled.showPlayerBody && options.player?.body) { - const body = options.player.body; - commands.push({ - kind: 'rect', - id: 'player-body', - x: body.x + body.width / 2, - y: body.y + body.height / 2, - width: body.width, - height: body.height, - fillColor: 0x596f8f, - fillAlpha: 0.12, - strokeColor: 0x596f8f, - strokeAlpha: 0.92, - lineWidth: 3 - }); - commands.push({ - kind: 'circle', - id: 'player-origin', - x: options.player.x, - y: options.player.y, - radius: 6, - fillColor: 0x596f8f, - fillAlpha: 0.65, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.6, - lineWidth: 2 - }); - } - - return commands; -} - -export function createRidgeDebugOverlay(scene: Phaser.Scene): RidgeDebugOverlay { - const graphics = scene.add.graphics().setDepth(RIDGE_DEBUG_OVERLAY_DEPTH); - - return { - render(frame) { - const commands = createRidgeDebugDrawCommands(frame); - renderRidgeDebugDrawCommands(graphics, commands); - }, - destroy() { - graphics.destroy(); - } - }; -} - -function getEffectiveDebugSettings(settings: RidgeDevDebugSettings): RidgeDevDebugSettings { - if (!settings.graybox) return settings; - return { - ...settings, - showColliders: true, - showPlayerBody: true, - showInteractZones: true, - showTraversalAssists: true - }; -} - -function isAnyDebugSettingEnabled(settings: RidgeDevDebugSettings): boolean { - return settings.graybox || - settings.showColliders || - settings.showPlayerBody || - settings.showInteractZones || - settings.showTraversalAssists; -} - -function createGrayboxWash( - geometry: RidgeBlockoutGeometry -): RidgeDebugDrawCommand { - return { - kind: 'rect', - id: 'graybox-wash', - x: geometry.bounds.x + geometry.bounds.width / 2, - y: geometry.bounds.y + geometry.bounds.height / 2, - width: geometry.bounds.width, - height: geometry.bounds.height, - fillColor: 0xfbfbf9, - fillAlpha: 0.18, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.08, - lineWidth: 2 - }; -} - -function createColliderCommand(collider: RidgeBlockoutCollider): RidgeDebugDrawCommand { - const style = getColliderStyle(collider); - return { - kind: 'rect', - id: `collider:${collider.id}`, - x: collider.x, - y: collider.y, - width: collider.width, - height: collider.height, - ...style - }; -} - -function getColliderStyle(collider: RidgeBlockoutCollider): Omit< - Extract, - 'kind' | 'id' | 'x' | 'y' | 'width' | 'height' -> { - switch (collider.kind) { - case 'platform': - return { - fillColor: 0xf0d35f, - fillAlpha: 0.15, - strokeColor: 0xf0d35f, - strokeAlpha: 0.86, - lineWidth: 3 - }; - case 'route-connector': - return { - fillColor: 0x596f8f, - fillAlpha: 0.13, - strokeColor: 0x596f8f, - strokeAlpha: 0.84, - lineWidth: 3 - }; - case 'shortcut-connector': - return { - fillColor: 0xb85f5a, - fillAlpha: 0.13, - strokeColor: 0xb85f5a, - strokeAlpha: 0.84, - lineWidth: 3 - }; - case 'solid': - return { - fillColor: 0x1a1a1a, - fillAlpha: 0.12, - strokeColor: 0x1a1a1a, - strokeAlpha: 0.78, - lineWidth: 2 - }; - } -} - -function createAssistZoneLineCommand( - zone: RidgeBlockoutAssistZone, - traversalState: RidgeTraversalRuntimeState | undefined -): RidgeDebugDrawCommand { - const active = traversalState?.activeClimbZoneId === zone.id; - return { - kind: 'line', - id: `assist-line:${zone.id}`, - from: zone.from, - to: zone.to, - strokeColor: getAssistZoneColor(zone), - strokeAlpha: active ? 0.95 : 0.68, - lineWidth: active ? 8 : 5 - }; -} - -function createAssistZoneBoundsCommand( - zone: RidgeBlockoutAssistZone, - traversalState: RidgeTraversalRuntimeState | undefined -): RidgeDebugDrawCommand { - const active = traversalState?.activeClimbZoneId === zone.id; - return { - kind: 'rect', - id: `assist-bounds:${zone.id}`, - x: zone.x, - y: zone.y, - width: zone.width, - height: zone.height, - fillColor: getAssistZoneColor(zone), - fillAlpha: active ? 0.16 : 0.08, - strokeColor: getAssistZoneColor(zone), - strokeAlpha: active ? 0.82 : 0.46, - lineWidth: active ? 3 : 2 - }; -} - -function getAssistZoneColor(zone: RidgeBlockoutAssistZone): number { - switch (zone.kind) { - case 'ramp': - return 0xd7c78f; - case 'climb': - return 0x596f8f; - case 'drop': - return 0xb85f5a; - } -} diff --git a/src/game/scenes/ridge/runtime/ridgeDevControls.ts b/src/game/scenes/ridge/runtime/ridgeDevControls.ts index 56f65f12..bfb05722 100644 --- a/src/game/scenes/ridge/runtime/ridgeDevControls.ts +++ b/src/game/scenes/ridge/runtime/ridgeDevControls.ts @@ -62,10 +62,10 @@ export interface RidgeDevTeleportPlayer { export const RIDGE_PLAYER_SPAWN_OFFSET_Y = -80; export const RIDGE_DEFAULT_CAMERA_ZOOM = 1; -export const RIDGE_DEV_MIN_CAMERA_ZOOM = 0.65; -export const RIDGE_DEV_MAX_CAMERA_ZOOM = 1.6; +const RIDGE_DEV_MIN_CAMERA_ZOOM = 0.65; +const RIDGE_DEV_MAX_CAMERA_ZOOM = 1.6; -export const RIDGE_DEV_DEFAULT_DEBUG_SETTINGS: RidgeDevDebugSettings = { +const RIDGE_DEV_DEFAULT_DEBUG_SETTINGS: RidgeDevDebugSettings = { graybox: false, showColliders: false, showPlayerBody: false, diff --git a/src/game/scenes/ridge/runtime/ridgeInteractionTargets.test.ts b/src/game/scenes/ridge/runtime/ridgeInteractionTargets.test.ts deleted file mode 100644 index ccd91a3b..00000000 --- a/src/game/scenes/ridge/runtime/ridgeInteractionTargets.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { STAMPEDE_SKETCH_SCENE_ID } from '@/game/scenes/sceneIds'; -import { - RIDGE_BLOCKOUT, - compileRidgeBlockoutFacts -} from '../blockout'; -import { - CICKA_INTERACTION_TARGET_ID, - type CickaInteractionCopy -} from '../cicka/interaction'; -import type { CickaPerch } from '../cicka/CickaPerch'; -import { getRidgeWorldMemories } from '../worldMemory'; -import { - createRidgeInteractionTargets, - type RidgeInteractionEffect -} from './ridgeInteractionTargets'; - -const cickaCopy: CickaInteractionCopy = { - fresh: 'meow.', - stampedeMemory: 'mrrp!' -}; - -function createTestCickaPerch(): CickaPerch { - return { - interactionFacts: { - id: CICKA_INTERACTION_TARGET_ID, - kind: 'cicka', - x: 120, - distanceAnchorY: 130, - prompt: { x: 120, y: 80 }, - interactRadius: 78 - }, - trackOwnedObject: (gameObject) => gameObject, - update: () => {}, - showLine: () => {}, - isSpeechVisible: () => false, - destroy: () => {} - }; -} - -describe('ridge interaction targets', () => { - it('builds Cicka and Trail Card targets from compiled facts', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const targets = createRidgeInteractionTargets({ - facts, - cickaPerch: createTestCickaPerch(), - cickaInteractionCopy: cickaCopy, - getWorldMemories: () => [] - }); - - expect(targets.map((target) => target.id)).toEqual([ - CICKA_INTERACTION_TARGET_ID, - STAMPEDE_SKETCH_RIDGE_STAMP_ID, - 'telegraph-terrace', - 'domino-desk' - ]); - expect(targets.every((target) => Number.isFinite(target.prompt.y))).toBe(true); - }); - - it('preserves Trail Card params when creating open effects', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const targets = createRidgeInteractionTargets({ - facts, - cickaInteractionCopy: cickaCopy, - getWorldMemories: () => [] - }); - const stampede = targets.find((target) => target.id === STAMPEDE_SKETCH_RIDGE_STAMP_ID); - const telegraph = targets.find((target) => target.id === 'telegraph-terrace'); - const domino = targets.find((target) => target.id === 'domino-desk'); - - expect(stampede?.effect).toEqual({ - kind: 'openTrailCard', - params: expect.objectContaining({ - title: 'Stampede Sketch', - enterSceneId: STAMPEDE_SKETCH_SCENE_ID - }) - }); - expect(telegraph?.effect).toEqual({ - kind: 'openTrailCard', - params: expect.objectContaining({ - title: 'Telegraph Terrace', - unavailableReason: 'Telegraph Terrace is a later prototype.' - }) - }); - expect(domino?.effect).toEqual({ - kind: 'openTrailCard', - params: expect.objectContaining({ - title: 'Domino Desk', - unavailableReason: 'Domino Desk is future scope.' - }) - }); - }); - - it('reads fresh world memories when resolving the Cicka response effect', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - let hasStampedeMemory = false; - const targets = createRidgeInteractionTargets({ - facts, - cickaPerch: createTestCickaPerch(), - cickaInteractionCopy: cickaCopy, - getWorldMemories: () => - getRidgeWorldMemories({ - stampIds: hasStampedeMemory ? [STAMPEDE_SKETCH_RIDGE_STAMP_ID] : [] - }) - }); - const cicka = targets.find((target) => target.id === CICKA_INTERACTION_TARGET_ID); - if (typeof cicka?.effect !== 'function') { - throw new Error('missing Cicka response effect'); - } - - expect((cicka.effect() as RidgeInteractionEffect)).toEqual({ - kind: 'showCickaResponse', - response: { id: 'fresh', line: 'meow.' } - }); - - hasStampedeMemory = true; - - expect((cicka.effect() as RidgeInteractionEffect)).toEqual({ - kind: 'showCickaResponse', - response: { id: 'stampede-memory', line: 'mrrp!' } - }); - }); -}); diff --git a/src/game/scenes/ridge/runtime/ridgeInteractionTargets.ts b/src/game/scenes/ridge/runtime/ridgeInteractionTargets.ts deleted file mode 100644 index 28e08980..00000000 --- a/src/game/scenes/ridge/runtime/ridgeInteractionTargets.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { InteriorInteractionTarget } from '@/game/sharedSceneRuntime/interactions/InteriorInteractionRuntime'; -import type { TrailCardOverlayParams } from '@/game/overlays/trailCard/types'; -import { - RIDGE_TRAIL_CARD_TARGETS, - type RidgeTrailCardTargetId -} from '../worldLayout'; -import { - CICKA_INTERACTION_TARGET_ID, - getCickaInteractionResponse, - type CickaInteractionCopy, - type CickaInteractionResponse -} from '../cicka/interaction'; -import type { CickaPerch } from '../cicka/CickaPerch'; -import type { RidgeBlockoutFacts } from '../blockout'; -import type { RidgeWorldMemory } from '../worldMemory'; -import { requireRidgeBlockoutFactAnchor } from './ridgePresentationFacts'; - -const TRAIL_CARD_PROMPT_OFFSET_Y = -86; - -export type RidgeInteractionEffect = - | { kind: 'close' } - | { kind: 'openTrailCard'; params: TrailCardOverlayParams } - | { kind: 'showCickaResponse'; response: CickaInteractionResponse }; - -export type RidgeInteractionTargetId = - | RidgeTrailCardTargetId - | typeof CICKA_INTERACTION_TARGET_ID; - -export type RidgeInteractionTarget = InteriorInteractionTarget< - RidgeInteractionTargetId, - RidgeInteractionEffect ->; - -export interface RidgeInteractionTargetOptions { - facts: RidgeBlockoutFacts; - cickaPerch?: CickaPerch; - cickaInteractionCopy: CickaInteractionCopy; - getWorldMemories: () => readonly RidgeWorldMemory[]; -} - -export function createRidgeInteractionTargets( - options: RidgeInteractionTargetOptions -): RidgeInteractionTarget[] { - const { facts, cickaPerch, cickaInteractionCopy, getWorldMemories } = options; - return [ - ...(cickaPerch ? [{ - ...cickaPerch.interactionFacts, - effect: (): RidgeInteractionEffect => ({ - kind: 'showCickaResponse', - response: getCickaInteractionResponse( - getWorldMemories(), - cickaInteractionCopy - ) - }) - }] : []), - ...createTrailCardTargets(facts) - ]; -} - -function createTrailCardTargets( - facts: RidgeBlockoutFacts -): RidgeInteractionTarget[] { - return RIDGE_TRAIL_CARD_TARGETS.map((target) => { - const anchorPoint = requireRidgeBlockoutFactAnchor( - facts, - target.anchor, - `${target.id} Trail Card` - ); - return { - id: target.id, - kind: 'trail-card', - x: anchorPoint.x, - distanceAnchorY: anchorPoint.y, - prompt: { - x: anchorPoint.x, - y: anchorPoint.y + TRAIL_CARD_PROMPT_OFFSET_Y - }, - effect: { - kind: 'openTrailCard', - params: target.card - } - }; - }); -} diff --git a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.test.ts b/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.test.ts deleted file mode 100644 index 1b1e776c..00000000 --- a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - RIDGE_BLOCKOUT, - compileRidgeBlockoutFacts -} from '../blockout'; -import { - RIDGE_LANDMARK_ANCHOR_SELECTORS, - resolveRidgeLandmarkAnchors -} from './ridgeLandmarkPresentation'; - -describe('ridge landmark presentation', () => { - it('resolves the landmark anchors used by Ridge presentation', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const anchors = resolveRidgeLandmarkAnchors(facts); - - expect(Object.keys(anchors)).toEqual(Object.keys(RIDGE_LANDMARK_ANCHOR_SELECTORS)); - expect(anchors.cicka).toMatchObject({ - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - id: 'cicka' - }); - expect(anchors.stampede).toMatchObject({ - roomId: 'stampede_blanket', - symbol: '*', - kind: 'minigame', - id: 'stampede_sketch' - }); - expect(anchors.telegraph).toMatchObject({ - roomId: 'telegraph_terrace', - symbol: '*', - kind: 'minigame', - id: 'telegraph_future' - }); - expect(anchors.relay).toMatchObject({ - roomId: 'relay_gate', - symbol: '?', - kind: 'gate', - id: 'relay_proof_slots' - }); - expect(anchors.domino).toMatchObject({ - roomId: 'domino_desk', - symbol: 'A', - id: 'domino_project_scrap' - }); - expect(anchors.guide).toMatchObject({ - roomId: 'guide_overlook', - symbol: 'N', - kind: 'npc', - id: 'ridge_guide' - }); - expect(anchors.highLedge).toMatchObject({ - roomId: 'high_ledge', - symbol: '?', - kind: 'gate', - id: 'movement_reward_tease' - }); - expect(Object.values(anchors).every((anchor) => - Number.isFinite(anchor.x) && Number.isFinite(anchor.y) - )).toBe(true); - }); -}); diff --git a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts b/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts deleted file mode 100644 index c9f124b4..00000000 --- a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type * as Phaser from 'phaser'; -import { - type RidgeAnchorFact, - type RidgeBlockoutAnchorSelector, - type RidgeBlockoutFacts -} from '../blockout'; -import { - createCickaPerch, - type CickaPerch -} from '../cicka/CickaPerch'; -import { addCickaHomeMutationPresentation } from '../cicka/homeMutationPresentation'; -import { - hasCickaHomeMutationAdd, - type CickaHomeMutation -} from '../cicka/homeMutations'; -import { - hasRidgeWorldMemory, - type RidgeWorldMemory -} from '../worldMemory'; -import { requireRidgeBlockoutFactAnchor } from './ridgePresentationFacts'; - -const CICKA_PERCH_OFFSET_Y = -10; -const RELAY_SPIRE_OFFSET = { x: 230, y: -180 } as const; - -export const RIDGE_LANDMARK_ANCHOR_SELECTORS = { - cicka: { - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - attrId: 'cicka' - }, - outskirtsArtifact: { - roomId: 'outskirts', - symbol: 'A', - attrId: 'city_edge_memory' - }, - stampede: { - roomId: 'stampede_blanket', - symbol: '*', - kind: 'minigame', - attrId: 'stampede_sketch' - }, - telegraph: { - roomId: 'telegraph_terrace', - symbol: '*', - kind: 'minigame', - attrId: 'telegraph_future' - }, - guide: { - roomId: 'guide_overlook', - symbol: 'N', - kind: 'npc', - attrId: 'ridge_guide' - }, - relay: { - roomId: 'relay_gate', - symbol: '?', - kind: 'gate', - attrId: 'relay_proof_slots' - }, - domino: { - roomId: 'domino_desk', - symbol: 'A', - attrId: 'domino_project_scrap' - }, - highLedge: { - roomId: 'high_ledge', - symbol: '?', - kind: 'gate', - attrId: 'movement_reward_tease' - } -} as const satisfies Record; - -export type RidgeLandmarkAnchorId = keyof typeof RIDGE_LANDMARK_ANCHOR_SELECTORS; - -export type RidgeLandmarkAnchors = Record; - -export interface RidgeLandmarkPresentationCopy { - title: string; - stampedeFirstClearLabel: string; - cickaWalkByLine: string; -} - -export interface RidgeLandmarkPresentationOptions { - scene: Phaser.Scene; - facts: RidgeBlockoutFacts; - copy: RidgeLandmarkPresentationCopy; - worldMemories: readonly RidgeWorldMemory[]; - cickaHomeMutations: readonly CickaHomeMutation[]; -} - -export interface RidgeLandmarkPresentation { - cickaPerch: CickaPerch; -} - -export function createRidgeLandmarkPresentation( - options: RidgeLandmarkPresentationOptions -): RidgeLandmarkPresentation { - const { scene, facts, copy, worldMemories, cickaHomeMutations } = options; - const anchors = resolveRidgeLandmarkAnchors(facts); - const cickaLandmark = { - x: anchors.cicka.x, - y: anchors.cicka.y + CICKA_PERCH_OFFSET_Y - }; - - const cickaPerch = createCickaPerch({ - scene, - landmark: cickaLandmark, - hasStampedeNoteMutation: hasCickaHomeMutationAdd(cickaHomeMutations, 'stampede_note'), - walkByLine: copy.cickaWalkByLine - }); - addCickaHomeMutationPresentation({ - scene, - landmark: cickaLandmark, - mutations: cickaHomeMutations, - track: cickaPerch.trackOwnedObject - }); - - addOutskirtsArtifactSlot(scene, anchors.outskirtsArtifact.x, anchors.outskirtsArtifact.y); - addStampedeBlanket( - scene, - anchors.stampede.x, - anchors.stampede.y, - copy.stampedeFirstClearLabel, - worldMemories.filter((memory) => memory.landmarkKind === 'stampede-blanket') - ); - addTelegraphBag(scene, anchors.telegraph.x, anchors.telegraph.y); - addRidgeGuide(scene, anchors.guide.x, anchors.guide.y); - addRelayGate(scene, anchors.relay.x, anchors.relay.y); - addRelaySpire( - scene, - anchors.relay.x + RELAY_SPIRE_OFFSET.x, - anchors.relay.y + RELAY_SPIRE_OFFSET.y - ); - addDominoDesk(scene, anchors.domino.x, anchors.domino.y); - addHighLedgeTeaser(scene, anchors.highLedge.x, anchors.highLedge.y); - addPlaceholderCopy(scene, facts.spawn, copy.title); - - return { cickaPerch }; -} - -export function resolveRidgeLandmarkAnchors( - facts: RidgeBlockoutFacts -): RidgeLandmarkAnchors { - return { - cicka: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.cicka, - 'Cicka Home perch' - ), - outskirtsArtifact: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.outskirtsArtifact, - 'Outskirts artifact' - ), - stampede: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.stampede, - 'Stampede blanket' - ), - telegraph: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.telegraph, - 'Telegraph Terrace bag' - ), - guide: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.guide, - 'Ridge guide' - ), - relay: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.relay, - 'Relay Gate' - ), - domino: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.domino, - 'Domino Desk' - ), - highLedge: requireRidgeBlockoutFactAnchor( - facts, - RIDGE_LANDMARK_ANCHOR_SELECTORS.highLedge, - 'High Ledge teaser' - ) - }; -} - -function addOutskirtsArtifactSlot(scene: Phaser.Scene, x: number, y: number): void { - scene.add.rectangle(x - 44, y + 22, 92, 8, 0x1f1f1d, 0.2).setDepth(18); - scene.add.rectangle(x - 18, y - 4, 58, 36, 0xf7f1df, 0.94) - .setStrokeStyle(3, 0x1f1f1d, 0.72) - .setAngle(-5) - .setDepth(18); - scene.add.rectangle(x + 34, y - 16, 34, 24, 0xf0d35f, 0.82) - .setStrokeStyle(2, 0x1f1f1d, 0.72) - .setAngle(7) - .setDepth(18); - scene.add.circle(x + 34, y - 16, 4, 0x1f1f1d, 0.76).setDepth(19); - scene.add.circle(x + 44, y - 10, 3, 0x1f1f1d, 0.76).setDepth(19); -} - -function addStampedeBlanket( - scene: Phaser.Scene, - x: number, - y: number, - stampedeFirstClearLabel: string, - memories: readonly RidgeWorldMemory[] -): void { - scene.add.rectangle(x, y + 22, 104, 32, 0xb85f5a, 0.9).setDepth(16); - scene.add.rectangle(x - 26, y + 22, 18, 32, 0xf7f1df, 0.7).setDepth(17); - scene.add.rectangle(x + 26, y + 22, 18, 32, 0xf7f1df, 0.7).setDepth(17); - scene.add.circle(x - 22, y - 4, 11, 0x1f1f1d, 0.92).setDepth(17); - scene.add.circle(x + 28, y + 2, 9, 0x1f1f1d, 0.75).setDepth(17); - if (memories.length) { - addStampedeBlanketMemories(scene, x, y + 22, stampedeFirstClearLabel, memories); - } -} - -function addStampedeBlanketMemories( - scene: Phaser.Scene, - x: number, - y: number, - stampedeFirstClearLabel: string, - memories: readonly RidgeWorldMemory[] -): void { - if (hasRidgeWorldMemory(memories, 'stampede-settled-swarm')) { - [ - { x: -55, y: -36, radius: 4 }, - { x: -42, y: -49, radius: 3 }, - { x: -30, y: -39, radius: 2 }, - { x: 54, y: -30, radius: 3 }, - { x: 66, y: -43, radius: 2 } - ].forEach((dot) => { - scene.add.circle(x + dot.x, y + dot.y, dot.radius, 0x1f1f1d, 0.24).setDepth(18); - }); - } - if (hasRidgeWorldMemory(memories, 'stampede-held-sticker')) { - scene.add.rectangle(x + 42, y - 48, 58, 26, 0xf7f1df, 1) - .setStrokeStyle(3, 0x1f1f1d, 0.95) - .setAngle(-8) - .setDepth(19); - scene.add.text(x + 20, y - 57, stampedeFirstClearLabel, { - fontFamily: 'monospace', - fontSize: '12px', - color: '#1f1f1d' - }).setAngle(-8).setDepth(20); - } - if (hasRidgeWorldMemory(memories, 'stampede-glide-pip-decal')) { - scene.add.circle(x + 74, y - 18, 11, 0xf7f1df, 1) - .setStrokeStyle(2, 0x1f1f1d, 0.9) - .setDepth(19); - scene.add.line(x + 74, y - 18, -6, 6, 6, -6, 0x1f1f1d, 0.85).setLineWidth(2).setDepth(20); - } -} - -function addTelegraphBag(scene: Phaser.Scene, x: number, y: number): void { - scene.add.rectangle(x, y, 70, 54, 0x596f8f, 0.84).setDepth(16); - scene.add.rectangle(x, y - 34, 48, 16, 0x1f1f1d, 0.88).setDepth(17); - scene.add.arc(x, y - 40, 36, Math.PI, Math.PI * 2, false, 0x1f1f1d, 0.2) - .setStrokeStyle(4, 0x1f1f1d, 0.7) - .setDepth(17); - scene.add.line(x + 62, y - 18, -30, -20, 30, 20, 0x1f1f1d, 0.7).setLineWidth(4).setDepth(18); -} - -function addRidgeGuide(scene: Phaser.Scene, x: number, y: number): void { - scene.add.circle(x, y - 28, 16, 0xf7f1df, 1).setStrokeStyle(3, 0x1f1f1d, 1).setDepth(18); - scene.add.rectangle(x, y + 8, 32, 58, 0xf7f1df, 1).setStrokeStyle(3, 0x1f1f1d, 1).setDepth(18); - scene.add.line(x - 8, y + 2, -30, -16, -52, -28, 0x1f1f1d, 1).setLineWidth(4).setDepth(19); - scene.add.rectangle(x + 42, y - 8, 42, 28, 0xf0d35f, 0.9).setStrokeStyle(3, 0x1f1f1d, 1).setDepth(18); - scene.add.text(x + 29, y - 17, '?', { - fontFamily: 'monospace', - fontSize: '22px', - color: '#1f1f1d' - }).setDepth(19); -} - -function addRelayGate(scene: Phaser.Scene, x: number, y: number): void { - scene.add.rectangle(x, y, 108, 128, 0xf7f1df, 0.86) - .setStrokeStyle(5, 0x1f1f1d, 0.86) - .setDepth(16); - scene.add.rectangle(x, y + 6, 66, 98, 0x1f1f1d, 0.18) - .setStrokeStyle(3, 0x1f1f1d, 0.58) - .setDepth(17); - scene.add.circle(x, y - 62, 16, 0xf0d35f, 0.86) - .setStrokeStyle(3, 0x1f1f1d, 0.72) - .setDepth(18); -} - -function addRelaySpire(scene: Phaser.Scene, x: number, y: number): void { - scene.add.triangle(x, y, 0, 180, 46, 0, 92, 180, 0x1c1a18, 0.56).setDepth(9); - scene.add.rectangle(x + 46, y + 125, 34, 250, 0x1c1a18, 0.56).setDepth(9); - scene.add.line(x + 46, y, -80, 35, 80, 35, 0x1c1a18, 0.34).setLineWidth(3).setDepth(9); - scene.add.circle(x + 46, y - 39, 12, 0xf0d35f, 0.84).setDepth(10); -} - -function addDominoDesk(scene: Phaser.Scene, x: number, y: number): void { - scene.add.rectangle(x, y, 108, 50, 0xd7c78f, 0.96).setStrokeStyle(4, 0x1f1f1d, 1).setDepth(16); - scene.add.rectangle(x + 70, y - 40, 46, 94, 0xf7f1df, 1).setStrokeStyle(4, 0x1f1f1d, 1).setDepth(16); - scene.add.circle(x - 28, y - 16, 7, 0x1f1f1d, 1).setDepth(17); - scene.add.circle(x, y - 16, 7, 0x1f1f1d, 1).setDepth(17); - scene.add.circle(x + 28, y - 16, 7, 0x1f1f1d, 1).setDepth(17); -} - -function addHighLedgeTeaser(scene: Phaser.Scene, x: number, y: number): void { - scene.add.rectangle(x, y, 142, 18, 0xf7f1df, 0.92) - .setStrokeStyle(3, 0x1f1f1d, 0.72) - .setDepth(16); - scene.add.rectangle(x - 42, y - 18, 48, 26, 0xf0d35f, 0.76) - .setStrokeStyle(2, 0x1f1f1d, 0.62) - .setAngle(-7) - .setDepth(17); - scene.add.circle(x + 44, y - 32, 6, 0x1f1f1d, 0.68).setDepth(17); - scene.add.circle(x + 60, y - 37, 4, 0x1f1f1d, 0.58).setDepth(17); -} - -function addPlaceholderCopy( - scene: Phaser.Scene, - spawn: RidgeAnchorFact, - title: string -): void { - scene.add.text(spawn.x, spawn.y - 260, title, { - fontFamily: 'monospace', - fontSize: '34px', - color: '#1f1f1d' - }).setOrigin(0.5).setDepth(60); -} diff --git a/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts b/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts deleted file mode 100644 index 24aea21b..00000000 --- a/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - findRidgeBlockoutFactAnchor, - type RidgeAnchorFact, - type RidgeBlockoutAnchorSelector, - type RidgeBlockoutFacts -} from '../blockout'; - -export function requireRidgeBlockoutFactAnchor( - facts: RidgeBlockoutFacts, - selector: RidgeBlockoutAnchorSelector, - label: string -): RidgeAnchorFact { - const point = findRidgeBlockoutFactAnchor(facts, selector); - if (!point) { - throw new Error( - `Ridge blockout anchor for ${label} could not be resolved in room "${selector.roomId}"` - ); - } - return point; -} - -export function getRidgeBlockoutRoomCenter( - facts: Pick, - roomId: string -): { x: number; y: number } | undefined { - const room = facts.rooms.find((candidate) => candidate.id === roomId); - return room - ? { - x: room.bounds.x + room.bounds.width / 2, - y: room.bounds.y + room.bounds.height / 2 - } - : undefined; -} diff --git a/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts b/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts deleted file mode 100644 index 7457d1af..00000000 --- a/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { InputCommandFrame } from '@/game/core/input/commands'; -import type { - RidgeBlockoutAssistZone, - RidgeBlockoutCollider, - RidgeBlockoutGeometry -} from '../blockout'; -import { - createRidgeTraversalRuntime, - type RidgeTraversalBody, - type RidgeTraversalPlayer -} from './ridgeTraversalRuntime'; - -interface TestTraversalBody extends RidgeTraversalBody { - allowGravity: boolean; -} - -interface TestTraversalPlayer extends RidgeTraversalPlayer { - readonly body: TestTraversalBody; -} - -const baseBounds = { - x: 0, - y: 0, - width: 1000, - height: 1000 -}; - -describe('Ridge traversal runtime', () => { - it('primes grounding before the player controller reads ramp and climb state', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - assistZones: [ - makeAssistZone({ - id: 'outskirts-ramp', - kind: 'ramp', - from: { x: 0, y: 100 }, - to: { x: 100, y: 100 } - }) - ] - }) - }); - const player = makePlayer({ x: 50, y: 80, height: 40 }); - - const result = runtime.primeGrounding(player); - - expect(result.grounded).toBe(true); - expect(result.appliedAssists).toEqual(['grounding']); - expect(player.body.touching.down).toBe(true); - expect(player.body.blocked.down).toBe(true); - }); - - it('snaps walkable ramps and captures the new safe position', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - assistZones: [ - makeAssistZone({ - id: 'stair-ramp', - kind: 'ramp', - from: { x: 0, y: 100 }, - to: { x: 100, y: 100 } - }) - ] - }) - }); - const player = makePlayer({ x: 50, y: 75, height: 40, velocityY: 20 }); - - const result = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).toContain('ramp'); - expect(result.appliedAssists).toContain('safe-capture'); - expect(player.y).toBe(80); - expect(player.body.velocity.y).toBe(0); - expect(result.state.lastSafePosition).toEqual({ x: 50, y: 80 }); - }); - - it('allows small ramp step-ups without pulling from far below', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - assistZones: [ - makeAssistZone({ - id: 'paper-stair-ramp', - kind: 'ramp', - from: { x: 0, y: 100 }, - to: { x: 100, y: 100 } - }) - ] - }) - }); - const nearStep = makePlayer({ x: 50, y: 104, height: 40, velocityY: 20 }); - const farBelow = makePlayer({ x: 50, y: 120, height: 40, velocityY: 20 }); - - const nearResult = runtime.update({ - player: nearStep, - commands: command(), - deltaMs: 1000 / 60 - }); - const farResult = runtime.update({ - player: farBelow, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(nearResult.appliedAssists).toContain('ramp'); - expect(nearStep.y).toBe(80); - expect(farResult.appliedAssists).not.toContain('ramp'); - expect(farBelow.y).toBe(120); - }); - - it('runs climb before other assists and releases attachment when climb intent ends', () => { - const climb = makeAssistZone({ - id: 'cord-climb', - kind: 'climb', - from: { x: 100, y: 200 }, - to: { x: 100, y: 100 }, - width: 80, - height: 160 - }); - const ramp = makeAssistZone({ - id: 'overlapping-ramp', - kind: 'ramp', - from: { x: 0, y: 200 }, - to: { x: 200, y: 200 }, - width: 260, - height: 120 - }); - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ assistZones: [climb, ramp] }) - }); - const player = makePlayer({ x: 100, y: 180, height: 40 }); - - const climbResult = runtime.update({ - player, - commands: command({ verticalAxis: -1 }), - deltaMs: 1000 / 60 - }); - - expect(climbResult.appliedAssists).toContain('climb'); - expect(climbResult.appliedAssists).not.toContain('ramp'); - expect(climbResult.state.activeClimbZoneId).toBe('cord-climb'); - expect(player.body.allowGravity).toBe(false); - expect(player.y).toBeCloseTo(179.4); - - const releaseResult = runtime.update({ - player, - commands: command({ jump: true }), - deltaMs: 1000 / 60 - }); - - expect(releaseResult.appliedAssists).toContain('climb-release'); - expect(releaseResult.state.activeClimbZoneId).toBeNull(); - expect(player.body.allowGravity).toBe(true); - }); - - it('steers falling players toward authored drop lines', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - assistZones: [ - makeAssistZone({ - id: 'fold-drop', - kind: 'drop', - from: { x: 100, y: 50 }, - to: { x: 100, y: 150 }, - width: 100, - height: 160 - }) - ] - }) - }); - const player = makePlayer({ x: 80, y: 100, velocityY: 40 }); - - const result = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).toContain('drop'); - expect(player.x).toBeCloseTo(81.6); - }); - - it('steps onto small grounded ledges', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - colliders: [ - makeCollider({ - id: 'short-step', - kind: 'platform', - x: 72, - y: 100, - width: 20, - height: 20 - }) - ] - }) - }); - const player = makePlayer({ - x: 50, - y: 80, - height: 40, - touchingDown: true - }); - - const result = runtime.update({ - player, - commands: command({ moveAxis: 1 }), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).toContain('step-up'); - expect(player.x).toBe(68); - expect(player.y).toBe(70); - }); - - it('mantles onto reachable platform targets while airborne', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - colliders: [ - makeCollider({ - id: 'reachable-platform', - kind: 'platform', - x: 100, - y: 90, - width: 40, - height: 20 - }) - ] - }) - }); - const player = makePlayer({ x: 50, y: 110, height: 40, velocityY: 10 }); - - const result = runtime.update({ - player, - commands: command({ moveAxis: 1 }), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).toContain('mantle'); - expect(player.x).toBe(96); - expect(player.y).toBe(60); - }); - - it('rejects assist paths that cross solid walls', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - gridColliders: [ - makeCollider({ - id: 'solid-wall', - kind: 'solid', - x: 50, - y: 77.5, - width: 5, - height: 5 - }) - ], - assistZones: [ - makeAssistZone({ - id: 'blocked-ramp', - kind: 'ramp', - from: { x: 0, y: 100 }, - to: { x: 100, y: 100 } - }) - ] - }) - }); - const player = makePlayer({ x: 50, y: 75, height: 40, velocityY: 20 }); - - const result = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).not.toContain('ramp'); - expect(player.y).toBe(75); - expect(result.state.solidBlockerCount).toBe(1); - }); - - it('recovers from the bottom band using the last captured safe position', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry() - }); - const player = makePlayer({ - x: 20, - y: 20, - touchingDown: true - }); - - const captureResult = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - expect(captureResult.appliedAssists).toContain('safe-capture'); - expect(captureResult.state.lastSafePosition).toEqual({ x: 20, y: 20 }); - - player.x = 300; - player.y = 980; - player.body.touching.down = false; - player.body.blocked.down = false; - player.body.velocity.y = 300; - - const recoveryResult = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(recoveryResult.appliedAssists).toContain('fall-recovery'); - expect(player.x).toBe(20); - expect(player.y).toBe(20); - expect(player.body.velocity.x).toBe(0); - expect(player.body.velocity.y).toBe(0); - }); - - it('does not capture world-bottom contact as the last safe position', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry() - }); - const player = makePlayer({ - x: 20, - y: 20, - touchingDown: true - }); - - const safeCaptureResult = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - expect(safeCaptureResult.state.lastSafePosition).toEqual({ x: 20, y: 20 }); - - player.x = 300; - player.y = 940; - player.body.touching.down = true; - player.body.blocked.down = true; - player.body.velocity.y = 0; - - const bottomContactResult = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - expect(bottomContactResult.appliedAssists).not.toContain('fall-recovery'); - expect(bottomContactResult.appliedAssists).not.toContain('safe-capture'); - expect(bottomContactResult.state.lastSafePosition).toEqual({ x: 20, y: 20 }); - - player.y = 980; - player.body.touching.down = false; - player.body.blocked.down = false; - player.body.velocity.y = 300; - - const recoveryResult = runtime.update({ - player, - commands: command(), - deltaMs: 1000 / 60 - }); - - expect(recoveryResult.appliedAssists).toContain('fall-recovery'); - expect(player.x).toBe(20); - expect(player.y).toBe(20); - }); - - it('keeps solid terrain out of mantle targets', () => { - const runtime = createRidgeTraversalRuntime({ - geometry: makeGeometry({ - gridColliders: [ - makeCollider({ - id: 'solid-hash-ledge', - kind: 'solid', - x: 100, - y: 90, - width: 40, - height: 20 - }) - ] - }) - }); - const player = makePlayer({ x: 50, y: 110, height: 40, velocityY: 10 }); - - const result = runtime.update({ - player, - commands: command({ moveAxis: 1 }), - deltaMs: 1000 / 60 - }); - - expect(result.appliedAssists).not.toContain('mantle'); - expect(result.state.mantleTargetCount).toBe(0); - expect(player.x).toBe(50); - expect(player.y).toBe(110); - }); -}); - -function command(overrides: Partial = {}): InputCommandFrame { - return { - moveAxis: 0, - verticalAxis: 0, - sprint: false, - jump: false, - interact: false, - exitContext: false, - ...overrides - }; -} - -function makeGeometry(options: { - assistZones?: readonly RidgeBlockoutAssistZone[]; - colliders?: readonly RidgeBlockoutCollider[]; - gridColliders?: readonly RidgeBlockoutCollider[]; -} = {}): RidgeBlockoutGeometry { - const gridColliders = options.gridColliders ?? []; - return { - bounds: baseBounds, - roomBounds: [], - anchorPoints: [], - gridColliders, - routeConnectors: [], - shortcutConnections: [], - assistZones: options.assistZones ?? [], - colliders: [ - ...gridColliders, - ...(options.colliders ?? []) - ] - }; -} - -function makeAssistZone(options: { - id: string; - kind: RidgeBlockoutAssistZone['kind']; - from: { x: number; y: number }; - to: { x: number; y: number }; - width?: number; - height?: number; -}): RidgeBlockoutAssistZone { - return { - id: options.id, - kind: options.kind, - movement: options.kind, - from: options.from, - to: options.to, - x: (options.from.x + options.to.x) / 2, - y: (options.from.y + options.to.y) / 2, - width: options.width ?? 160, - height: options.height ?? 100 - }; -} - -function makeCollider(options: { - id: string; - kind: RidgeBlockoutCollider['kind']; - x: number; - y: number; - width: number; - height: number; -}): RidgeBlockoutCollider { - return { - id: options.id, - kind: options.kind, - x: options.x, - y: options.y, - width: options.width, - height: options.height - }; -} - -function makePlayer(options: { - x: number; - y: number; - width?: number; - height?: number; - velocityX?: number; - velocityY?: number; - touchingDown?: boolean; - blockedDown?: boolean; -}): TestTraversalPlayer { - let playerY = options.y; - const body: TestTraversalBody = { - width: options.width ?? 20, - height: options.height ?? 40, - get bottom() { - return playerY + body.height / 2; - }, - velocity: { - x: options.velocityX ?? 0, - y: options.velocityY ?? 0 - }, - touching: { - down: options.touchingDown ?? false - }, - blocked: { - down: options.blockedDown ?? false - }, - allowGravity: true, - setAllowGravity(allowGravity: boolean) { - body.allowGravity = allowGravity; - } - }; - - const player: TestTraversalPlayer = { - x: options.x, - get y() { - return playerY; - }, - set y(value: number) { - playerY = value; - }, - body, - setVelocityX(velocityX: number) { - body.velocity.x = velocityX; - }, - setVelocityY(velocityY: number) { - body.velocity.y = velocityY; - } - }; - - return player; -} diff --git a/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.ts b/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.ts deleted file mode 100644 index 5c03d8de..00000000 --- a/src/game/scenes/ridge/runtime/ridgeTraversalRuntime.ts +++ /dev/null @@ -1,513 +0,0 @@ -import type { InputCommandFrame } from '@/game/core/input/commands'; -import type { - RidgeBlockoutCollider, - RidgeBlockoutGeometry -} from '../blockout'; -import { - canMantleLedge, - canStepUp, - chooseFallRecovery, - clamp, - getClimbProgressDelta, - getFrameRateIndependentLerpAlpha, - getLedgeTop, - getPointOnTraversalSegment, - isMantleTargetCollider, - isPointNearTraversalLine, - isTraversalPathOccludedBySolid, - lerp, - projectPointToSegmentT, - resolveWalkableRampSurfaceY, - shouldMaintainClimbAttachment, - type RidgeSafePosition -} from './traversalComfort'; - -export interface RidgeTraversalBody { - readonly width: number; - readonly height: number; - readonly bottom: number; - readonly velocity: { - x: number; - y: number; - }; - readonly touching: { - down: boolean; - }; - readonly blocked: { - down: boolean; - }; - setAllowGravity(allowGravity: boolean): void; -} - -export interface RidgeTraversalPlayer { - x: number; - y: number; - readonly body: RidgeTraversalBody; - setVelocityX(velocityX: number): void; - setVelocityY(velocityY: number): void; -} - -export type RidgeTraversalAssistName = - | 'grounding' - | 'climb' - | 'climb-release' - | 'ramp' - | 'drop' - | 'step-up' - | 'mantle' - | 'fall-recovery' - | 'safe-capture'; - -export interface RidgeTraversalRuntimeState { - activeClimbZoneId: string | null; - lastSafePosition?: RidgeSafePosition; - mantleTargetCount: number; - solidBlockerCount: number; -} - -export interface RidgeTraversalPrimeGroundingResult { - grounded: boolean; - appliedAssists: readonly RidgeTraversalAssistName[]; - state: RidgeTraversalRuntimeState; -} - -export interface RidgeTraversalUpdateFrame { - player: RidgeTraversalPlayer; - commands: InputCommandFrame; - deltaMs: number; -} - -export interface RidgeTraversalFrameResult { - appliedAssists: readonly RidgeTraversalAssistName[]; - state: RidgeTraversalRuntimeState; -} - -export interface RidgeTraversalRuntime { - primeGrounding(player: RidgeTraversalPlayer): RidgeTraversalPrimeGroundingResult; - update(frame: RidgeTraversalUpdateFrame): RidgeTraversalFrameResult; - getState(): RidgeTraversalRuntimeState; -} - -export interface RidgeTraversalRuntimeOptions { - geometry: RidgeBlockoutGeometry; - initialSafePosition?: RidgeSafePosition; -} - -const RIDGE_RAMP_SNAP = { - maxSnapDownY: 28, - maxSnapUpY: 32 -} as const; -const RIDGE_CLIMB_ATTACH_DISTANCE = 22; -const RIDGE_DROP_ATTACH_DISTANCE = 28; -const RIDGE_MANTLE_HORIZONTAL_DISTANCE = 32; -const RIDGE_MANTLE_MIN_VERTICAL_DELTA = 24; -const RIDGE_MANTLE_MAX_VERTICAL_DELTA = 96; -const RIDGE_MANTLE_MIN_VELOCITY_Y = -20; -const RIDGE_STEP_UP_MAX_HEIGHT = 18; -const RIDGE_STEP_UP_HORIZONTAL_DISTANCE = 22; -const RIDGE_CLIMB_PROGRESS_PER_SECOND = 0.36; -const RIDGE_DROP_LERP_ALPHA_AT_60FPS = 0.08; -const RIDGE_FALL_RECOVERY_THRESHOLD_Y = 48; - -export function createRidgeTraversalRuntime( - options: RidgeTraversalRuntimeOptions -): RidgeTraversalRuntime { - return new RidgeTraversalRuntimeImpl(options); -} - -class RidgeTraversalRuntimeImpl implements RidgeTraversalRuntime { - private readonly geometry: RidgeBlockoutGeometry; - private readonly solidBlockers: readonly RidgeBlockoutCollider[]; - private readonly mantleTargets: readonly RidgeBlockoutCollider[]; - private activeClimbZoneId: string | null = null; - private lastSafePosition?: RidgeSafePosition; - - constructor(options: RidgeTraversalRuntimeOptions) { - this.geometry = options.geometry; - this.solidBlockers = options.geometry.gridColliders.filter(isSolidCollider); - this.mantleTargets = options.geometry.colliders.filter(isMantleTargetCollider); - this.lastSafePosition = options.initialSafePosition; - } - - primeGrounding(player: RidgeTraversalPlayer): RidgeTraversalPrimeGroundingResult { - const bottomY = getPlayerBottomY(player); - const ramp = resolveWalkableRampSurfaceY( - this.geometry.assistZones, - { x: player.x, y: bottomY }, - RIDGE_RAMP_SNAP - ); - const climb = this.geometry.assistZones.find((zone) => - zone.kind === 'climb' && - isPointNearTraversalLine(zone, { x: player.x, y: bottomY }, RIDGE_CLIMB_ATTACH_DISTANCE) - ); - - if (!ramp && !climb) { - return { - grounded: false, - appliedAssists: [], - state: this.getState() - }; - } - - markBodyGrounded(player.body); - return { - grounded: true, - appliedAssists: ['grounding'], - state: this.getState() - }; - } - - update(frame: RidgeTraversalUpdateFrame): RidgeTraversalFrameResult { - const appliedAssists: RidgeTraversalAssistName[] = []; - const didClimb = this.applyClimbAssist(frame, appliedAssists); - - if (!didClimb) { - this.releaseClimbAttachment(frame.player, appliedAssists); - this.applyRampAssist(frame.player, frame.commands, appliedAssists); - this.applyDropAssist(frame.player, frame.deltaMs, appliedAssists); - this.applyStepUpAssist(frame.player, frame.commands, appliedAssists); - this.applyMantleAssist(frame.player, frame.commands, appliedAssists); - } - - this.recoverFromWorldBottom(frame.player, appliedAssists); - this.captureLastSafePosition(frame.player, appliedAssists); - - return { - appliedAssists, - state: this.getState() - }; - } - - getState(): RidgeTraversalRuntimeState { - return { - activeClimbZoneId: this.activeClimbZoneId, - lastSafePosition: this.lastSafePosition - ? { ...this.lastSafePosition } - : undefined, - mantleTargetCount: this.mantleTargets.length, - solidBlockerCount: this.solidBlockers.length - }; - } - - private applyRampAssist( - player: RidgeTraversalPlayer, - commands: InputCommandFrame, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - if (commands.jump) return false; - - const body = player.body; - const bottomY = getPlayerBottomY(player); - const ramp = resolveWalkableRampSurfaceY( - this.geometry.assistZones, - { x: player.x, y: bottomY }, - RIDGE_RAMP_SNAP - ); - if (!ramp || body.velocity.y < -60) return false; - - const target = { x: player.x, y: ramp.y - body.height / 2 }; - if (this.isAssistPathOccluded(player, target)) return false; - - player.y = target.y; - player.setVelocityY(0); - markBodyGrounded(body); - appliedAssists.push('ramp'); - return true; - } - - private applyClimbAssist( - frame: RidgeTraversalUpdateFrame, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - const { player, commands, deltaMs } = frame; - const zone = this.geometry.assistZones.find((candidate) => - candidate.kind === 'climb' && - isPointNearTraversalLine( - candidate, - { x: player.x, y: getPlayerBottomY(player) }, - RIDGE_CLIMB_ATTACH_DISTANCE - ) - ); - const isAttached = this.activeClimbZoneId !== null; - if (!zone) return false; - if ( - commands.verticalAxis === 0 && - !shouldMaintainClimbAttachment({ - attached: isAttached, - jump: commands.jump, - horizontalAxis: commands.moveAxis, - nearClimbLine: true - }) - ) { - return false; - } - if (commands.jump) return false; - - const currentT = projectPointToSegmentT( - { x: player.x, y: getPlayerBottomY(player) }, - zone - ); - const nextT = clamp( - currentT + getClimbProgressDelta({ - verticalAxis: commands.verticalAxis, - fromY: zone.from.y, - toY: zone.to.y, - progressPerSecond: RIDGE_CLIMB_PROGRESS_PER_SECOND, - deltaMs - }), - 0, - 1 - ); - const point = getPointOnTraversalSegment(zone, nextT); - const target = { x: point.x, y: point.y - player.body.height / 2 }; - if (this.isAssistPathOccluded(player, target)) return false; - - this.activeClimbZoneId = zone.id; - player.body.setAllowGravity(false); - player.x = target.x; - player.y = target.y; - player.setVelocityX(0); - player.setVelocityY(0); - markBodyGrounded(player.body); - appliedAssists.push('climb'); - return true; - } - - private releaseClimbAttachment( - player: RidgeTraversalPlayer, - appliedAssists: RidgeTraversalAssistName[] - ): void { - if (!this.activeClimbZoneId) return; - this.activeClimbZoneId = null; - player.body.setAllowGravity(true); - appliedAssists.push('climb-release'); - } - - private applyDropAssist( - player: RidgeTraversalPlayer, - deltaMs: number, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - if (player.body.velocity.y <= 0) return false; - - const zone = this.geometry.assistZones.find((candidate) => - candidate.kind === 'drop' && - isPointNearTraversalLine(candidate, { x: player.x, y: player.y }, RIDGE_DROP_ATTACH_DISTANCE) - ); - if (!zone) return false; - - const t = projectPointToSegmentT({ x: player.x, y: player.y }, zone); - const point = getPointOnTraversalSegment(zone, t); - const target = { - x: lerp( - player.x, - point.x, - getFrameRateIndependentLerpAlpha(RIDGE_DROP_LERP_ALPHA_AT_60FPS, deltaMs) - ), - y: player.y - }; - if (this.isAssistPathOccluded(player, target)) return false; - - player.x = target.x; - appliedAssists.push('drop'); - return true; - } - - private applyStepUpAssist( - player: RidgeTraversalPlayer, - commands: InputCommandFrame, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - if (commands.moveAxis === 0 || !isBodyGrounded(player.body)) return false; - - const direction = Math.sign(commands.moveAxis); - const bottomY = getPlayerBottomY(player); - const candidate = findClosestLedge( - this.geometry.colliders, - player.x, - direction, - RIDGE_STEP_UP_HORIZONTAL_DISTANCE - )?.collider; - if (!candidate) return false; - - const topY = getColliderTop(candidate); - if (!canStepUp({ - playerBottomY: bottomY, - obstacleTopY: topY, - maxStepHeight: RIDGE_STEP_UP_MAX_HEIGHT - })) { - return false; - } - - const target = { - x: player.x + direction * 18, - y: topY - player.body.height / 2 - }; - if (this.isAssistPathOccluded(player, target)) return false; - - player.x = target.x; - player.y = target.y; - player.setVelocityY(0); - markBodyGrounded(player.body); - appliedAssists.push('step-up'); - return true; - } - - private applyMantleAssist( - player: RidgeTraversalPlayer, - commands: InputCommandFrame, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - if (commands.moveAxis === 0) return false; - if (isBodyGrounded(player.body)) return false; - if (player.body.velocity.y < RIDGE_MANTLE_MIN_VELOCITY_Y) return false; - - const direction = Math.sign(commands.moveAxis); - const bottomY = getPlayerBottomY(player); - let closest: - | { collider: RidgeBlockoutCollider; distance: number } - | undefined; - for (const collider of this.mantleTargets) { - const distance = getHorizontalLedgeDistance(collider, player.x, direction); - if (distance < 0 || (closest && distance >= closest.distance)) continue; - if (!canMantleLedge({ - playerX: player.x, - playerBottomY: bottomY, - moveAxis: commands.moveAxis, - ledge: collider, - maxHorizontalDistance: RIDGE_MANTLE_HORIZONTAL_DISTANCE, - minVerticalDelta: RIDGE_MANTLE_MIN_VERTICAL_DELTA, - maxVerticalDelta: RIDGE_MANTLE_MAX_VERTICAL_DELTA - })) { - continue; - } - - closest = { collider, distance }; - } - - const target = closest?.collider; - if (!target) return false; - - const topY = getLedgeTop(target); - const nextPosition = { - x: direction > 0 - ? target.x - target.width / 2 + player.body.width / 2 + 6 - : target.x + target.width / 2 - player.body.width / 2 - 6, - y: topY - player.body.height / 2 - }; - if (this.isAssistPathOccluded(player, nextPosition)) return false; - - player.x = nextPosition.x; - player.y = nextPosition.y; - player.setVelocityY(0); - markBodyGrounded(player.body); - appliedAssists.push('mantle'); - return true; - } - - private isAssistPathOccluded( - player: RidgeTraversalPlayer, - target: { x: number; y: number } - ): boolean { - return isTraversalPathOccludedBySolid({ - from: { x: player.x, y: player.y }, - to: target, - bodySize: { - width: player.body.width, - height: player.body.height - }, - solidRects: this.solidBlockers - }); - } - - private recoverFromWorldBottom( - player: RidgeTraversalPlayer, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - const recovery = chooseFallRecovery({ - playerY: player.y, - worldBottomY: this.geometry.bounds.y + this.geometry.bounds.height, - thresholdY: RIDGE_FALL_RECOVERY_THRESHOLD_Y, - lastSafePosition: this.lastSafePosition - }); - if (!recovery) return false; - - player.x = recovery.x; - player.y = recovery.y; - player.setVelocityX(0); - player.setVelocityY(0); - appliedAssists.push('fall-recovery'); - return true; - } - - private captureLastSafePosition( - player: RidgeTraversalPlayer, - appliedAssists: RidgeTraversalAssistName[] - ): boolean { - const bottomBandY = this.geometry.bounds.y + - this.geometry.bounds.height - - RIDGE_FALL_RECOVERY_THRESHOLD_Y; - const bottomY = getPlayerBottomY(player); - if (bottomY >= bottomBandY) return false; - - const ramp = resolveWalkableRampSurfaceY( - this.geometry.assistZones, - { x: player.x, y: bottomY }, - RIDGE_RAMP_SNAP - ); - if (!isBodyGrounded(player.body) && !ramp) return false; - - this.lastSafePosition = { x: player.x, y: player.y }; - appliedAssists.push('safe-capture'); - return true; - } -} - -function isSolidCollider(collider: RidgeBlockoutCollider): boolean { - return collider.kind === 'solid'; -} - -function getPlayerBottomY(player: RidgeTraversalPlayer): number { - return player.body.bottom; -} - -function markBodyGrounded(body: RidgeTraversalBody): void { - body.touching.down = true; - body.blocked.down = true; -} - -function isBodyGrounded(body: RidgeTraversalBody): boolean { - return body.touching.down || body.blocked.down; -} - -function findClosestLedge( - colliders: readonly RidgeBlockoutCollider[], - playerX: number, - direction: number, - maxDistance: number -): { collider: RidgeBlockoutCollider; distance: number } | undefined { - let closest: { collider: RidgeBlockoutCollider; distance: number } | undefined; - for (const collider of colliders) { - const distance = getHorizontalLedgeDistance(collider, playerX, direction); - if (distance < 0 || distance > maxDistance) continue; - if (!closest || distance < closest.distance) { - closest = { collider, distance }; - } - } - return closest; -} - -function getHorizontalLedgeDistance( - collider: RidgeBlockoutCollider, - playerX: number, - direction: number -): number { - const left = collider.x - collider.width / 2; - const right = collider.x + collider.width / 2; - if (direction > 0) return left - playerX; - return playerX - right; -} - -function getColliderTop(collider: RidgeBlockoutCollider): number { - return collider.y - collider.height / 2; -} diff --git a/src/game/scenes/ridge/runtime/traversalComfort.test.ts b/src/game/scenes/ridge/runtime/traversalComfort.test.ts deleted file mode 100644 index 9e03d0cb..00000000 --- a/src/game/scenes/ridge/runtime/traversalComfort.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - canMantleLedge, - canStepUp, - chooseFallRecovery, - getClimbProgressDelta, - getDistanceToTraversalSegment, - getFrameRateIndependentLerpAlpha, - getPointOnTraversalSegment, - getRampSurfaceYAtX, - isMantleTargetCollider, - isPointNearTraversalLine, - isTraversalPathOccludedBySolid, - projectPointToSegmentT, - resolveRampSurfaceY, - resolveWalkableRampSurfaceY, - shouldMaintainClimbAttachment -} from './traversalComfort'; - -const ramp = { - id: 'ramp', - kind: 'ramp' as const, - from: { x: 100, y: 300 }, - to: { x: 300, y: 200 }, - x: 200, - y: 250, - width: 260, - height: 180 -}; - -describe('Ridge traversal comfort helpers', () => { - it('resolves ramp surface y from the connector line', () => { - expect(getRampSurfaceYAtX(ramp, 200)).toBe(250); - expect(getRampSurfaceYAtX(ramp, 80)).toBeUndefined(); - }); - - it('selects the closest ramp surface within tolerance', () => { - expect(resolveRampSurfaceY([ramp], { x: 200, y: 258 }, 16)).toMatchObject({ - zone: ramp, - y: 250 - }); - expect(resolveRampSurfaceY([ramp], { x: 200, y: 310 }, 16)).toBeUndefined(); - }); - - it('keeps ramp snap mostly downward instead of pulling the player up from below', () => { - expect(resolveWalkableRampSurfaceY([ramp], { x: 200, y: 230 }, { - maxSnapDownY: 24, - maxSnapUpY: 6 - })).toMatchObject({ - zone: ramp, - y: 250 - }); - expect(resolveWalkableRampSurfaceY([ramp], { x: 200, y: 262 }, { - maxSnapDownY: 24, - maxSnapUpY: 6 - })).toBeUndefined(); - }); - - it('allows ledge mantle only when moving toward a reachable ledge', () => { - const ledge = { x: 300, y: 250, width: 120, height: 16 }; - - expect(canMantleLedge({ - playerX: 210, - playerBottomY: 330, - moveAxis: 1, - ledge, - maxHorizontalDistance: 96, - minVerticalDelta: 16, - maxVerticalDelta: 140 - })).toBe(true); - expect(canMantleLedge({ - playerX: 210, - playerBottomY: 330, - moveAxis: -1, - ledge, - maxHorizontalDistance: 96, - minVerticalDelta: 16, - maxVerticalDelta: 140 - })).toBe(false); - }); - - it('allows mantle targets only for platforms and connector platforms', () => { - expect(isMantleTargetCollider({ kind: 'solid' })).toBe(false); - expect(isMantleTargetCollider({ kind: 'platform' })).toBe(true); - expect(isMantleTargetCollider({ kind: 'route-connector' })).toBe(true); - expect(isMantleTargetCollider({ kind: 'shortcut-connector' })).toBe(true); - }); - - it('allows small step-ups but rejects tall walls', () => { - expect(canStepUp({ playerBottomY: 300, obstacleTopY: 256, maxStepHeight: 56 })).toBe(true); - expect(canStepUp({ playerBottomY: 300, obstacleTopY: 220, maxStepHeight: 56 })).toBe(false); - }); - - it('returns the last safe position when falling into the world bottom band', () => { - const safe = { x: 120, y: 300 }; - - expect(chooseFallRecovery({ - playerY: 980, - worldBottomY: 1000, - thresholdY: 48, - lastSafePosition: safe - })).toBe(safe); - expect(chooseFallRecovery({ - playerY: 900, - worldBottomY: 1000, - thresholdY: 48, - lastSafePosition: safe - })).toBeUndefined(); - }); - - it('projects points along climb and drop segments', () => { - const segment = { from: { x: 0, y: 100 }, to: { x: 100, y: 0 } }; - const t = projectPointToSegmentT({ x: 50, y: 50 }, segment); - - expect(t).toBeCloseTo(0.5); - expect(getPointOnTraversalSegment(segment, t)).toEqual({ x: 50, y: 50 }); - expect(getDistanceToTraversalSegment({ x: 50, y: 50 }, segment)).toBeCloseTo(0); - expect(getDistanceToTraversalSegment({ x: 50, y: 80 }, segment)).toBeCloseTo(21.21, 1); - }); - - it('requires traversal line attachment to stay close to the authored line', () => { - const segment = { - id: 'climb', - kind: 'climb' as const, - from: { x: 0, y: 100 }, - to: { x: 100, y: 0 }, - x: 50, - y: 50, - width: 220, - height: 220 - }; - - expect(isPointNearTraversalLine(segment, { x: 50, y: 50 }, 18)).toBe(true); - expect(isPointNearTraversalLine(segment, { x: 50, y: 80 }, 18)).toBe(false); - }); - - it('moves ladder progress with vertical input instead of horizontal input', () => { - expect(getClimbProgressDelta({ - verticalAxis: -1, - fromY: 300, - toY: 100, - progressPerSecond: 1.2, - deltaMs: 1000 / 60 - })).toBeCloseTo(0.02); - expect(getClimbProgressDelta({ - verticalAxis: 1, - fromY: 300, - toY: 100, - progressPerSecond: 1.2, - deltaMs: 1000 / 60 - })).toBeCloseTo(-0.02); - }); - - it('keeps ladder attachment while input is released but detaches on jump, movement, or distance', () => { - expect(shouldMaintainClimbAttachment({ - attached: true, - jump: false, - horizontalAxis: 0, - nearClimbLine: true - })).toBe(true); - expect(shouldMaintainClimbAttachment({ - attached: true, - jump: true, - horizontalAxis: 0, - nearClimbLine: true - })).toBe(false); - expect(shouldMaintainClimbAttachment({ - attached: true, - jump: false, - horizontalAxis: 1, - nearClimbLine: true - })).toBe(false); - expect(shouldMaintainClimbAttachment({ - attached: true, - jump: false, - horizontalAxis: 0, - nearClimbLine: false - })).toBe(false); - }); - - it('converts per-frame lerp into stable delta-scaled alpha', () => { - const frameAlpha = getFrameRateIndependentLerpAlpha(0.08, 1000 / 60); - const halfFrameAlpha = getFrameRateIndependentLerpAlpha(0.08, 1000 / 120); - - expect(frameAlpha).toBeCloseTo(0.08); - expect(1 - ((1 - halfFrameAlpha) ** 2)).toBeCloseTo(frameAlpha); - }); - - it('rejects assist paths that cross solid walls', () => { - const wall = { x: 50, y: 50, width: 10, height: 100 }; - - expect(isTraversalPathOccludedBySolid({ - from: { x: 0, y: 50 }, - to: { x: 100, y: 50 }, - bodySize: { width: 10, height: 10 }, - solidRects: [wall], - skin: 0 - })).toBe(true); - expect(isTraversalPathOccludedBySolid({ - from: { x: 0, y: -20 }, - to: { x: 100, y: -20 }, - bodySize: { width: 10, height: 10 }, - solidRects: [wall], - skin: 0 - })).toBe(false); - }); - - it('does not treat standing on top of a solid floor as an occluded assist path', () => { - expect(isTraversalPathOccludedBySolid({ - from: { x: 0, y: 80 }, - to: { x: 100, y: 80 }, - bodySize: { width: 20, height: 20 }, - solidRects: [{ x: 50, y: 100, width: 120, height: 20 }] - })).toBe(false); - }); -}); diff --git a/src/game/scenes/ridge/runtime/traversalComfort.ts b/src/game/scenes/ridge/runtime/traversalComfort.ts deleted file mode 100644 index 38bf1bf7..00000000 --- a/src/game/scenes/ridge/runtime/traversalComfort.ts +++ /dev/null @@ -1,344 +0,0 @@ -export interface RidgeTraversalPoint { - x: number; - y: number; -} - -export interface RidgeTraversalLineZone { - id: string; - kind: 'ramp' | 'climb' | 'drop'; - from: RidgeTraversalPoint; - to: RidgeTraversalPoint; - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeLedgeCandidate { - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeSafePosition { - x: number; - y: number; -} - -export interface RidgeTraversalSize { - width: number; - height: number; -} - -export interface RidgeTraversalRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface RidgeRampSnapOptions { - maxSnapDownY: number; - maxSnapUpY: number; -} - -export function getRampSurfaceYAtX( - zone: Pick, - x: number -): number | undefined { - const minX = Math.min(zone.from.x, zone.to.x); - const maxX = Math.max(zone.from.x, zone.to.x); - if (x < minX || x > maxX) return undefined; - const dx = zone.to.x - zone.from.x; - if (Math.abs(dx) < 0.001) return Math.min(zone.from.y, zone.to.y); - const t = (x - zone.from.x) / dx; - return lerp(zone.from.y, zone.to.y, t); -} - -export function isPointInsideTraversalZone( - zone: Pick, - point: RidgeTraversalPoint -): boolean { - return ( - point.x >= zone.x - zone.width / 2 && - point.x <= zone.x + zone.width / 2 && - point.y >= zone.y - zone.height / 2 && - point.y <= zone.y + zone.height / 2 - ); -} - -export function resolveRampSurfaceY( - zones: readonly RidgeTraversalLineZone[], - point: RidgeTraversalPoint, - toleranceY: number -): { zone: RidgeTraversalLineZone; y: number } | undefined { - return zones.reduce<{ zone: RidgeTraversalLineZone; y: number; distance: number } | undefined>( - (best, zone) => { - if (zone.kind !== 'ramp' || !isPointInsideTraversalZone(zone, point)) return best; - const y = getRampSurfaceYAtX(zone, point.x); - if (y === undefined) return best; - const distance = Math.abs(point.y - y); - if (distance > toleranceY) return best; - return !best || distance < best.distance ? { zone, y, distance } : best; - }, - undefined - ); -} - -export function resolveWalkableRampSurfaceY( - zones: readonly RidgeTraversalLineZone[], - point: RidgeTraversalPoint, - options: RidgeRampSnapOptions -): { zone: RidgeTraversalLineZone; y: number } | undefined { - return zones.reduce<{ zone: RidgeTraversalLineZone; y: number; distance: number } | undefined>( - (best, zone) => { - if (zone.kind !== 'ramp' || !isPointInsideTraversalZone(zone, point)) return best; - const y = getRampSurfaceYAtX(zone, point.x); - if (y === undefined) return best; - - const snapDownDistance = y - point.y; - const snapUpDistance = point.y - y; - const canSnapDown = snapDownDistance >= 0 && snapDownDistance <= options.maxSnapDownY; - const canSnapUp = snapUpDistance >= 0 && snapUpDistance <= options.maxSnapUpY; - if (!canSnapDown && !canSnapUp) return best; - - const distance = Math.abs(point.y - y); - return !best || distance < best.distance ? { zone, y, distance } : best; - }, - undefined - ); -} - -export function canMantleLedge(input: { - playerX: number; - playerBottomY: number; - moveAxis: number; - ledge: RidgeLedgeCandidate; - maxHorizontalDistance: number; - minVerticalDelta: number; - maxVerticalDelta: number; -}): boolean { - if (input.moveAxis === 0) return false; - const ledgeLeft = input.ledge.x - input.ledge.width / 2; - const ledgeRight = input.ledge.x + input.ledge.width / 2; - const targetX = input.moveAxis > 0 ? ledgeLeft : ledgeRight; - const horizontalDistance = Math.abs(targetX - input.playerX); - const movingToward = - input.moveAxis > 0 - ? input.playerX <= ledgeRight - : input.playerX >= ledgeLeft; - const verticalDelta = input.playerBottomY - getLedgeTop(input.ledge); - - return ( - movingToward && - horizontalDistance <= input.maxHorizontalDistance && - verticalDelta >= input.minVerticalDelta && - verticalDelta <= input.maxVerticalDelta - ); -} - -export function isMantleTargetCollider(collider: { kind: string }): boolean { - return ( - collider.kind === 'platform' || - collider.kind === 'route-connector' || - collider.kind === 'shortcut-connector' - ); -} - -export function canStepUp(input: { - playerBottomY: number; - obstacleTopY: number; - maxStepHeight: number; -}): boolean { - const stepHeight = input.playerBottomY - input.obstacleTopY; - return stepHeight > 0 && stepHeight <= input.maxStepHeight; -} - -export function chooseFallRecovery(input: { - playerY: number; - worldBottomY: number; - thresholdY: number; - lastSafePosition?: RidgeSafePosition; -}): RidgeSafePosition | undefined { - if (!input.lastSafePosition) return undefined; - return input.playerY >= input.worldBottomY - input.thresholdY - ? input.lastSafePosition - : undefined; -} - -export function projectPointToSegmentT( - point: RidgeTraversalPoint, - segment: Pick -): number { - const dx = segment.to.x - segment.from.x; - const dy = segment.to.y - segment.from.y; - const lengthSquared = dx * dx + dy * dy; - if (lengthSquared <= 0.001) return 0; - return clamp( - ((point.x - segment.from.x) * dx + (point.y - segment.from.y) * dy) / lengthSquared, - 0, - 1 - ); -} - -export function getDistanceToTraversalSegment( - point: RidgeTraversalPoint, - segment: Pick -): number { - const t = projectPointToSegmentT(point, segment); - const projected = getPointOnTraversalSegment(segment, t); - return Math.hypot(point.x - projected.x, point.y - projected.y); -} - -export function isPointNearTraversalLine( - zone: RidgeTraversalLineZone, - point: RidgeTraversalPoint, - maxDistance: number -): boolean { - return ( - isPointInsideTraversalZone(zone, point) && - getDistanceToTraversalSegment(point, zone) <= maxDistance - ); -} - -export function getClimbProgressDelta(input: { - verticalAxis: number; - fromY: number; - toY: number; - progressPerSecond: number; - deltaMs: number; -}): number { - if (input.verticalAxis === 0) return 0; - const directionY = Math.sign(input.toY - input.fromY) || 1; - return input.verticalAxis * directionY * input.progressPerSecond * getClampedDeltaSeconds(input.deltaMs); -} - -export function shouldMaintainClimbAttachment(input: { - attached: boolean; - jump: boolean; - horizontalAxis: number; - nearClimbLine: boolean; -}): boolean { - return input.attached && !input.jump && input.horizontalAxis === 0 && input.nearClimbLine; -} - -export function getFrameRateIndependentLerpAlpha( - referenceFrameAlpha: number, - deltaMs: number -): number { - const clampedAlpha = clamp(referenceFrameAlpha, 0, 1); - if (clampedAlpha === 0 || clampedAlpha === 1) return clampedAlpha; - const referenceFrames = getClampedDeltaSeconds(deltaMs) / (1 / 60); - return 1 - ((1 - clampedAlpha) ** referenceFrames); -} - -export function isTraversalPathOccludedBySolid(input: { - from: RidgeTraversalPoint; - to: RidgeTraversalPoint; - bodySize: RidgeTraversalSize; - solidRects: readonly RidgeTraversalRect[]; - skin?: number; -}): boolean { - const skin = input.skin ?? 4; - const inflateX = Math.max(0, input.bodySize.width / 2 - skin); - const inflateY = Math.max(0, input.bodySize.height / 2 - skin); - - return input.solidRects.some((rect) => - doesSegmentIntersectRect(input.from, input.to, inflateRect(rect, inflateX, inflateY)) - ); -} - -export function getPointOnTraversalSegment( - segment: Pick, - t: number -): RidgeTraversalPoint { - return { - x: lerp(segment.from.x, segment.to.x, clamp(t, 0, 1)), - y: lerp(segment.from.y, segment.to.y, clamp(t, 0, 1)) - }; -} - -export function getLedgeTop(ledge: RidgeLedgeCandidate): number { - return ledge.y - ledge.height / 2; -} - -export function lerp(start: number, end: number, t: number): number { - return start + (end - start) * t; -} - -export function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -function getClampedDeltaSeconds(deltaMs: number): number { - return clamp(deltaMs, 0, 50) / 1000; -} - -function inflateRect( - rect: RidgeTraversalRect, - inflateX: number, - inflateY: number -): RidgeTraversalRect { - return { - x: rect.x, - y: rect.y, - width: rect.width + inflateX * 2, - height: rect.height + inflateY * 2 - }; -} - -function doesSegmentIntersectRect( - from: RidgeTraversalPoint, - to: RidgeTraversalPoint, - rect: RidgeTraversalRect -): boolean { - if (isPointInsideRect(from, rect) || isPointInsideRect(to, rect)) return true; - - const left = rect.x - rect.width / 2; - const right = rect.x + rect.width / 2; - const top = rect.y - rect.height / 2; - const bottom = rect.y + rect.height / 2; - const dx = to.x - from.x; - const dy = to.y - from.y; - let tMin = 0; - let tMax = 1; - - const clippedX = clipSegmentAxis(from.x, dx, left, right, tMin, tMax); - if (!clippedX) return false; - tMin = clippedX.tMin; - tMax = clippedX.tMax; - - const clippedY = clipSegmentAxis(from.y, dy, top, bottom, tMin, tMax); - return clippedY !== undefined; -} - -function clipSegmentAxis( - start: number, - delta: number, - min: number, - max: number, - tMin: number, - tMax: number -): { tMin: number; tMax: number } | undefined { - if (Math.abs(delta) < 0.001) { - return start >= min && start <= max ? { tMin, tMax } : undefined; - } - - const t1 = (min - start) / delta; - const t2 = (max - start) / delta; - const axisMin = Math.min(t1, t2); - const axisMax = Math.max(t1, t2); - const nextMin = Math.max(tMin, axisMin); - const nextMax = Math.min(tMax, axisMax); - return nextMin <= nextMax ? { tMin: nextMin, tMax: nextMax } : undefined; -} - -function isPointInsideRect(point: RidgeTraversalPoint, rect: RidgeTraversalRect): boolean { - return ( - point.x >= rect.x - rect.width / 2 && - point.x <= rect.x + rect.width / 2 && - point.y >= rect.y - rect.height / 2 && - point.y <= rect.y + rect.height / 2 - ); -} diff --git a/src/game/scenes/ridge/sceneContext.ts b/src/game/scenes/ridge/sceneContext.ts index d76f4c98..4e4b7040 100644 --- a/src/game/scenes/ridge/sceneContext.ts +++ b/src/game/scenes/ridge/sceneContext.ts @@ -13,10 +13,7 @@ interface RidgeSceneContextOptions { } /** - * Scene lifecycle contract for the placeholder Sketchbook Ridge scene. - * - * Ridge starts as a separate loadable scene so it can be tested directly before - * replacing or absorbing the current overworld route. + * Scene lifecycle contract for the Bridge Tracer Ridge scene. */ export function createRidgeSceneContext( options: RidgeSceneContextOptions diff --git a/src/game/scenes/ridge/worldLayout.test.ts b/src/game/scenes/ridge/worldLayout.test.ts deleted file mode 100644 index 050d31a9..00000000 --- a/src/game/scenes/ridge/worldLayout.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { STAMPEDE_SKETCH_SCENE_ID } from '@/game/scenes/sceneIds'; -import { CICKA_INTERACTION_TARGET_ID } from './cicka/interaction'; -import { - RIDGE_BLOCKOUT, - compileRidgeBlockoutFacts, - findRidgeBlockoutFactAnchor -} from './blockout'; -import { RIDGE_TRAIL_CARD_TARGETS } from './worldLayout'; - -describe('ridge world layout', () => { - it('uses the Ridge Blockout source for first route order and spawn', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const firstWalk = facts.routes.find((route) => route.id === 'first_walk'); - - expect(firstWalk?.roomIds.slice(0, 4)).toEqual([ - 'outskirts', - 'cicka_home', - 'work_artifact', - 'stampede_blanket' - ]); - expect(facts.spawn).toMatchObject({ - roomId: 'outskirts', - symbol: '1', - kind: 'player_spawn', - id: 'start' - }); - }); - - it('resolves Cicka Home from the compiled blockout anchor', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const cickaPoint = findRidgeBlockoutFactAnchor(facts, { - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - attrId: 'cicka' - }); - - expect(cickaPoint).toMatchObject({ - roomId: 'cicka_home', - symbol: 'C', - kind: 'npc', - id: 'cicka' - }); - expect(cickaPoint?.x).toBeGreaterThan(0); - expect(cickaPoint?.y).toBeGreaterThan(0); - }); - - it('resolves Trail Card targets from their blockout anchors while keeping card content non-spatial', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const stampede = RIDGE_TRAIL_CARD_TARGETS.find((target) => target.id === 'stampede-sketch'); - const unavailable = RIDGE_TRAIL_CARD_TARGETS.filter((target) => target.id !== 'stampede-sketch'); - - expect(RIDGE_TRAIL_CARD_TARGETS.map((target) => target.id)).toEqual([ - 'stampede-sketch', - 'telegraph-terrace', - 'domino-desk' - ]); - expect(RIDGE_TRAIL_CARD_TARGETS.map((target) => target.id)).not.toContain( - CICKA_INTERACTION_TARGET_ID - ); - expect(stampede?.card.enterSceneId).toBe(STAMPEDE_SKETCH_SCENE_ID); - expect(stampede?.card.unavailableReason).toBeUndefined(); - expect(unavailable.every((target) => target.card.unavailableReason && !target.card.enterSceneId)).toBe(true); - - if (!stampede) throw new Error('missing Stampede Trail Card target'); - expect(findRidgeBlockoutFactAnchor(facts, stampede.anchor)).toMatchObject({ - roomId: 'stampede_blanket', - symbol: '*', - kind: 'minigame', - id: 'stampede_sketch' - }); - }); - - it('resolves the Relay promise from the Relay Gate blockout anchor', () => { - const facts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT); - const relayRoom = facts.rooms.find((room) => room.id === 'relay_gate'); - const relayPoint = findRidgeBlockoutFactAnchor(facts, { - roomId: 'relay_gate', - symbol: '?', - kind: 'gate', - attrId: 'relay_proof_slots' - }); - - expect(relayRoom).toBeDefined(); - expect(relayPoint).toMatchObject({ - roomId: 'relay_gate', - symbol: '?', - kind: 'gate', - id: 'relay_proof_slots' - }); - expect(relayPoint?.x).toBeGreaterThanOrEqual(relayRoom?.bounds.x ?? 0); - expect(relayPoint?.x).toBeLessThanOrEqual( - relayRoom ? relayRoom.bounds.x + relayRoom.bounds.width : Number.POSITIVE_INFINITY - ); - }); - - it('derives the Stampede shortcut source and availability from blockout facts', () => { - const lockedFacts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT, { stampIds: [] }); - const unlockedFacts = compileRidgeBlockoutFacts(RIDGE_BLOCKOUT, { - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }); - const lockedConnection = lockedFacts.shortcuts.find((shortcut) => shortcut.id === 'stampede_sketch'); - const unlockedConnection = unlockedFacts.shortcuts.find((shortcut) => shortcut.id === 'stampede_sketch'); - - expect(lockedConnection).toMatchObject({ - id: 'stampede_sketch', - fromRoomId: 'switchback_shelf', - toRoomId: 'cicka_home', - kind: 'fall_steer_fold_drop', - available: false, - movement: 'drop' - }); - expect(lockedConnection?.from).toMatchObject({ - roomId: 'switchback_shelf', - targetRoomId: 'cicka_home', - requires: 'stampede_sketch' - }); - expect(unlockedConnection).toMatchObject({ - id: 'stampede_sketch', - available: true, - movement: 'drop' - }); - }); -}); diff --git a/src/game/scenes/ridge/worldLayout.ts b/src/game/scenes/ridge/worldLayout.ts deleted file mode 100644 index af527d0d..00000000 --- a/src/game/scenes/ridge/worldLayout.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { TrailCardOverlayParams } from '@/game/overlays/trailCard/types'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { STAMPEDE_SKETCH_SCENE_ID } from '@/game/scenes/sceneIds'; -import type { RidgeBlockoutAnchorSelector } from './blockout'; - -export type RidgeLandmarkKind = - | 'outskirts-artifact' - | 'cicka-perch' - | 'stampede-blanket' - | 'telegraph-bag' - | 'ridge-guide' - | 'domino-desk' - | 'relay-spire' - | 'high-ledge-teaser' - | 'relay-gate'; - -export type RidgeTrailCardTargetId = - | typeof STAMPEDE_SKETCH_RIDGE_STAMP_ID - | 'telegraph-terrace' - | 'domino-desk'; - -export interface RidgeTrailCardTarget { - id: RidgeTrailCardTargetId; - landmarkKind: Extract; - anchor: RidgeBlockoutAnchorSelector; - card: TrailCardOverlayParams; -} - -export const RIDGE_TRAIL_CARD_TARGETS: readonly RidgeTrailCardTarget[] = [ - { - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID, - landmarkKind: 'stampede-blanket', - anchor: { - roomId: 'stampede_blanket', - symbol: '*', - kind: 'minigame', - attrId: 'stampede_sketch' - }, - card: { - title: 'Stampede Sketch', - mood: 'Kite overexcited ink ideas around a picnic blanket.', - timeEstimate: '60-90 seconds', - rewardPreview: 'Stamp + glide pip', - enterSceneId: STAMPEDE_SKETCH_SCENE_ID - } - }, - { - id: 'telegraph-terrace', - landmarkKind: 'telegraph-bag', - anchor: { - roomId: 'telegraph_terrace', - symbol: '*', - kind: 'minigame', - attrId: 'telegraph_future' - }, - card: { - title: 'Telegraph Terrace', - mood: 'One-button timing practice with clean parry reads.', - timeEstimate: 'Prototype TBD', - rewardPreview: 'Manual page', - unavailableReason: 'Telegraph Terrace is a later prototype.' - } - }, - { - id: 'domino-desk', - landmarkKind: 'domino-desk', - anchor: { - roomId: 'domino_desk', - symbol: 'A', - attrId: 'domino_project_scrap' - }, - card: { - title: 'Domino Desk', - mood: 'A careful little deterministic puzzle desk.', - timeEstimate: 'Future slice', - rewardPreview: 'Shortcut sticker', - unavailableReason: 'Domino Desk is future scope.' - } - } -]; diff --git a/src/game/scenes/ridge/worldMemory.test.ts b/src/game/scenes/ridge/worldMemory.test.ts deleted file mode 100644 index 7a45636f..00000000 --- a/src/game/scenes/ridge/worldMemory.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import { - getRidgeLandmarkMemories, - getRidgeWorldMemories, - hasRidgeWorldMemory -} from './worldMemory'; - -describe('ridge world memory', () => { - it('returns no derived memories for empty Ridge progress', () => { - expect(getRidgeWorldMemories({ stampIds: [] })).toEqual([]); - }); - - it('ignores unrelated stamp ids', () => { - expect(getRidgeWorldMemories({ stampIds: ['future-stamp'] })).toEqual([]); - }); - - it('derives the Stampede blanket memory layer from the first-clear stamp', () => { - const memories = getRidgeWorldMemories({ - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }); - - expect(memories.map((memory) => memory.id)).toEqual([ - 'stampede-held-sticker', - 'stampede-settled-swarm', - 'stampede-glide-pip-decal', - 'cicka-stampede-note' - ]); - expect(hasRidgeWorldMemory(memories, 'stampede-held-sticker')).toBe(true); - expect(hasRidgeWorldMemory(memories, 'cicka-stampede-note')).toBe(true); - }); - - it('anchors Stampede memories to the blanket and Cicka perch without changing unrelated landmarks', () => { - const ridgeProgress = { - stampIds: [STAMPEDE_SKETCH_RIDGE_STAMP_ID] - }; - - expect(getRidgeLandmarkMemories('stampede-blanket', ridgeProgress).map((memory) => memory.id)).toEqual([ - 'stampede-held-sticker', - 'stampede-settled-swarm', - 'stampede-glide-pip-decal' - ]); - expect(getRidgeLandmarkMemories('cicka-perch', ridgeProgress).map((memory) => memory.id)).toEqual([ - 'cicka-stampede-note' - ]); - expect(getRidgeLandmarkMemories('telegraph-bag', ridgeProgress)).toEqual([]); - }); -}); diff --git a/src/game/scenes/ridge/worldMemory.ts b/src/game/scenes/ridge/worldMemory.ts deleted file mode 100644 index acebcc75..00000000 --- a/src/game/scenes/ridge/worldMemory.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; -import type { BridgeRidgeProgressState, RidgeStampId } from '@/game/bridge/store'; -import type { RidgeLandmarkKind } from './worldLayout'; - -type RidgeMemorySource = - | { - kind: 'stamp'; - id: RidgeStampId; - }; - -export type RidgeWorldMemoryId = - | 'stampede-held-sticker' - | 'stampede-settled-swarm' - | 'stampede-glide-pip-decal' - | 'cicka-stampede-note'; - -export type RidgeWorldMemoryKind = - | 'reward-sticker' - | 'settled-swarm' - | 'glide-pip-decal' - | 'cicka-note'; - -export interface RidgeWorldMemory { - id: RidgeWorldMemoryId; - kind: RidgeWorldMemoryKind; - landmarkKind: Extract; - source: RidgeMemorySource; -} - -const RIDGE_WORLD_MEMORIES: readonly RidgeWorldMemory[] = [ - { - id: 'stampede-held-sticker', - kind: 'reward-sticker', - landmarkKind: 'stampede-blanket', - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - }, - { - id: 'stampede-settled-swarm', - kind: 'settled-swarm', - landmarkKind: 'stampede-blanket', - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - }, - { - id: 'stampede-glide-pip-decal', - kind: 'glide-pip-decal', - landmarkKind: 'stampede-blanket', - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - }, - { - id: 'cicka-stampede-note', - kind: 'cicka-note', - landmarkKind: 'cicka-perch', - source: { - kind: 'stamp', - id: STAMPEDE_SKETCH_RIDGE_STAMP_ID - } - } -]; - -export function getRidgeWorldMemories( - ridgeProgress: Pick -): readonly RidgeWorldMemory[] { - return RIDGE_WORLD_MEMORIES.filter((memory) => - ridgeProgress.stampIds.includes(memory.source.id) - ); -} - -export function getRidgeLandmarkMemories( - landmarkKind: RidgeLandmarkKind, - ridgeProgress: Pick -): readonly RidgeWorldMemory[] { - return getRidgeWorldMemories(ridgeProgress).filter( - (memory) => memory.landmarkKind === landmarkKind - ); -} - -export function hasRidgeWorldMemory( - memories: readonly RidgeWorldMemory[], - memoryId: RidgeWorldMemoryId -): boolean { - return memories.some((memory) => memory.id === memoryId); -} diff --git a/src/game/scenes/stampedeSketch/index.ts b/src/game/scenes/stampedeSketch/index.ts deleted file mode 100644 index d73e1013..00000000 --- a/src/game/scenes/stampedeSketch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createStampedeSketchSceneContext } from './sceneContext'; diff --git a/src/game/scenes/stampedeSketch/runtime/autoAttack.ts b/src/game/scenes/stampedeSketch/runtime/autoAttack.ts index 7d82a12f..bd93f621 100644 --- a/src/game/scenes/stampedeSketch/runtime/autoAttack.ts +++ b/src/game/scenes/stampedeSketch/runtime/autoAttack.ts @@ -1,8 +1,8 @@ export const STAMPEDE_AUTO_ATTACK_COOLDOWN_MS = 2_600; -export const STAMPEDE_AUTO_ATTACK_RANGE = 170; +const STAMPEDE_AUTO_ATTACK_RANGE = 170; export const STAMPEDE_AUTO_ATTACK_LENGTH = 120; export const STAMPEDE_AUTO_ATTACK_HALF_WIDTH = 22; -export const STAMPEDE_AUTO_ATTACK_MAX_HITS = 2; +const STAMPEDE_AUTO_ATTACK_MAX_HITS = 2; export interface StampedeAutoAttackProfile { cooldownMs: number; diff --git a/src/game/scenes/stampedeSketch/runtime/index.ts b/src/game/scenes/stampedeSketch/runtime/index.ts deleted file mode 100644 index a4ecca70..00000000 --- a/src/game/scenes/stampedeSketch/runtime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { StampedeSketchScene } from './StampedeSketchScene'; diff --git a/src/game/scenes/stampedeSketch/runtime/movement.ts b/src/game/scenes/stampedeSketch/runtime/movement.ts index f3f8bc50..853937d1 100644 --- a/src/game/scenes/stampedeSketch/runtime/movement.ts +++ b/src/game/scenes/stampedeSketch/runtime/movement.ts @@ -15,8 +15,8 @@ export const STAMPEDE_ARENA = { } as const; export const STAMPEDE_PLAYER_SPEED = 210; -export const STAMPEDE_STICK_DEAD_ZONE = 10; -export const STAMPEDE_STICK_FULL_DISTANCE = 60; +const STAMPEDE_STICK_DEAD_ZONE = 10; +const STAMPEDE_STICK_FULL_DISTANCE = 60; export interface StampedeAxisInput { x: number; diff --git a/src/game/scenes/stampedeSketch/runtime/progression.ts b/src/game/scenes/stampedeSketch/runtime/progression.ts index 0658ec0f..8fea51b1 100644 --- a/src/game/scenes/stampedeSketch/runtime/progression.ts +++ b/src/game/scenes/stampedeSketch/runtime/progression.ts @@ -1,8 +1,8 @@ import type { StampedeUpgradeId } from './upgrades'; -export const STAMPEDE_SCRAP_PICKUP_RADIUS = 10; +const STAMPEDE_SCRAP_PICKUP_RADIUS = 10; export const STAMPEDE_SCRAP_COLLECTION_RADIUS = 34; -export const STAMPEDE_UPGRADE_SCRAP_GOAL = 3; +const STAMPEDE_UPGRADE_SCRAP_GOAL = 3; export interface StampedeClearedMarkPoint { id: string; diff --git a/src/game/scenes/stampedeSketch/runtime/upgrades.ts b/src/game/scenes/stampedeSketch/runtime/upgrades.ts index 18bc7ace..502162d4 100644 --- a/src/game/scenes/stampedeSketch/runtime/upgrades.ts +++ b/src/game/scenes/stampedeSketch/runtime/upgrades.ts @@ -4,7 +4,7 @@ import { } from './autoAttack'; import { STAMPEDE_PLAYER_SPEED } from './movement'; -export const STAMPEDE_UPGRADE_IDS = [ +const STAMPEDE_UPGRADE_IDS = [ 'quickPencil', 'wideSwipe', 'swiftGuardian' diff --git a/src/game/sharedSceneRuntime/config.ts b/src/game/sharedSceneRuntime/config.ts index d3a22293..5e2110e3 100644 --- a/src/game/sharedSceneRuntime/config.ts +++ b/src/game/sharedSceneRuntime/config.ts @@ -21,7 +21,6 @@ export const HOBBIES_RESUME_CLAMP = { minY: 120, maxY: HOBBIES_FLOOR_Y - 20 } as const; -export const HOBBIES_WALK_SPEED = 300; /** Proximity radius for hobby stations / exit (Phaser distance check). */ export const HOBBIES_INTERACT_RADIUS = 60; /** Ground collider zone relative to floor. */ diff --git a/src/game/shell/InteractiveApp.tsx b/src/game/shell/InteractiveApp.tsx index db53f72f..81068c87 100644 --- a/src/game/shell/InteractiveApp.tsx +++ b/src/game/shell/InteractiveApp.tsx @@ -1,68 +1,25 @@ -import { ArrowLeft, BookOpen, Bug } from 'lucide-react'; -import Game from './Game'; -import { bridgeActions, useBridgeState, type OpenOverlayOptions } from '@/game/bridge/store'; -import { - getPhaserScenePresentationMode, - type PhaserScenePresentationMode -} from '@/game/sharedSceneRuntime/phaserScenePresentation'; -import { DEV_SWITCHER_OVERLAY_ID, type OverlayId } from '@/game/overlays/overlayIds'; -import { OverlayHost } from '@/game/overlays/OverlayHost'; -import { SceneUiHost } from '@/game/sceneUi/SceneUiHost'; -import { - isSceneId, - OVERWORLD_SCENE_ID, - POTASSIUM_SCENE_ID, - STAMPEDE_SKETCH_SCENE_ID, - type SceneId -} from '@/game/scenes/sceneIds'; -import { Button, Card, Panel } from '@/shared/ui'; -import { getInteractiveGameShellLayout } from './gameShellLayout'; -import { getSceneHeaderChrome } from './sceneHeaderChrome'; -import { getNotebookRuntimeShellProfile } from './notebookShellProfile'; +import { POTASSIUM_SCENE_ID, STAMPEDE_SKETCH_SCENE_ID } from '@/game/scenes/sceneIds'; import { PotassiumNotebookShell } from './PotassiumNotebookShell'; import { StampedeNotebookShell } from './StampedeNotebookShell'; -import { useResizeObserver } from '@/shared/hooks/useResizeObserver'; -import { useMessages } from '@/shared/i18n'; +import { InteractiveAppDefaultShell } from './InteractiveAppDefaultShell'; +import { useInteractiveAppShell } from './useInteractiveAppShell'; interface InteractiveAppProps { onSwitchToStatic: () => void; } export default function InteractiveApp({ onSwitchToStatic }: InteractiveAppProps) { - const bridge = useBridgeState(); - const messages = useMessages(); - const { ref: contentRowRef, height: contentRowHeight } = useResizeObserver(); - - const startSceneParam = new URLSearchParams(window.location.search).get('startScene'); - const initialStartSceneId = startSceneParam && isSceneId(startSceneParam) ? startSceneParam : null; - const presentationSceneId = - bridge.activeSceneId === OVERWORLD_SCENE_ID && initialStartSceneId - ? initialStartSceneId - : bridge.activeSceneId; - const presentationMode: PhaserScenePresentationMode = - getPhaserScenePresentationMode(presentationSceneId); - const gameShellLayout = getInteractiveGameShellLayout(presentationMode, contentRowHeight); - const isGameInputBlocked = bridge.isPaused || bridge.loadingSceneId !== null; - const shouldReserveSceneHint = presentationSceneId === POTASSIUM_SCENE_ID; - const sceneHintText = bridge.sceneHintText ?? messages.potassiumSlip.hints.start; - const shouldShowNavigationHint = - bridge.activeSceneId === OVERWORLD_SCENE_ID && bridge.activeOverlayId === null; - const shouldShowSceneUiStatus = bridge.sceneUi.status !== null; - const shouldShowFooterHint = shouldShowSceneUiStatus || shouldReserveSceneHint || shouldShowNavigationHint; - const sceneHeaderChrome = getSceneHeaderChrome(presentationSceneId); - const notebookShellProfile = getNotebookRuntimeShellProfile(presentationSceneId); - - const enterScene = (sceneId: SceneId) => bridgeActions.enterScene(sceneId); - const openOverlay = (overlayId: OverlayId, options?: OpenOverlayOptions) => bridgeActions.openOverlay(overlayId, options); - const returnToOverworld = () => bridgeActions.returnToOverworld(); - const returnToScene = (sceneId: SceneId) => { - if (sceneId === OVERWORLD_SCENE_ID) { - bridgeActions.returnToOverworld(); - return; - } - - bridgeActions.enterScene(sceneId); - }; + const shell = useInteractiveAppShell(); + const { + bridge, + notebookShellProfile, + isGameInputBlocked, + presentationMode, + sceneHintText, + enterScene, + openOverlay, + returnToOverworld + } = shell; if (notebookShellProfile?.ownerSceneId === POTASSIUM_SCENE_ID) { return ( @@ -99,128 +56,5 @@ export default function InteractiveApp({ onSwitchToStatic }: InteractiveAppProps ); } - return ( -
-
- -
- -
-
-
-
-
- -
-
- -
-
- - {shouldShowFooterHint && ( -
- - {shouldShowSceneUiStatus ? ( - - ) : shouldReserveSceneHint ? ( -

- {sceneHintText} -

- ) : ( - <> -

- {messages.navigation.hintsCompact} -

-

- {messages.navigation.hints} -

- - )} -
-
- )} - - - - {bridge.loadingSceneId && ( -
- -

- {messages.gameShell.loadingScene} -

-
-
- )} -
- ); + return ; } diff --git a/src/game/shell/InteractiveAppDefaultShell.tsx b/src/game/shell/InteractiveAppDefaultShell.tsx new file mode 100644 index 00000000..785a8227 --- /dev/null +++ b/src/game/shell/InteractiveAppDefaultShell.tsx @@ -0,0 +1,159 @@ +import { ArrowLeft, BookOpen, Bug } from 'lucide-react'; +import Game from './Game'; +import { bridgeActions } from '@/game/bridge/store'; +import { DEV_SWITCHER_OVERLAY_ID } from '@/game/overlays/overlayIds'; +import { OverlayHost } from '@/game/overlays/OverlayHost'; +import { SceneUiHost } from '@/game/sceneUi/SceneUiHost'; +import { Button, Card, Panel } from '@/shared/ui'; +import type { InteractiveAppShellState } from './useInteractiveAppShell'; + +interface InteractiveAppDefaultShellProps { + shell: InteractiveAppShellState; + onSwitchToStatic: () => void; +} + +export function InteractiveAppDefaultShell({ + shell, + onSwitchToStatic +}: InteractiveAppDefaultShellProps) { + const { + bridge, + messages, + contentRowRef, + presentationMode, + gameShellLayout, + isGameInputBlocked, + sceneHintText, + shouldShowFooterHint, + shouldShowSceneUiStatus, + shouldReserveSceneHint, + sceneHeaderChrome, + enterScene, + openOverlay, + returnToOverworld, + returnToScene + } = shell; + + return ( +
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+ + {shouldShowFooterHint && ( +
+ + {shouldShowSceneUiStatus ? ( + + ) : shouldReserveSceneHint ? ( +

+ {sceneHintText} +

+ ) : ( + <> +

+ {messages.navigation.hintsCompact} +

+

+ {messages.navigation.hints} +

+ + )} +
+
+ )} + + + + {bridge.loadingSceneId && ( +
+ +

+ {messages.gameShell.loadingScene} +

+
+
+ )} +
+ ); +} diff --git a/src/game/shell/devRidgeProgressParams.ts b/src/game/shell/devRidgeProgressParams.ts index df8a2051..22cbd9da 100644 --- a/src/game/shell/devRidgeProgressParams.ts +++ b/src/game/shell/devRidgeProgressParams.ts @@ -2,7 +2,7 @@ import { STAMPEDE_SKETCH_RIDGE_STAMP_ID } from '@/game/bridge/ridgeProgressIds'; export const DEV_RIDGE_STAMP_QUERY_PARAM = 'devRidgeStamp'; -export const DEV_RIDGE_STAMP_IDS = [ +const DEV_RIDGE_STAMP_IDS = [ STAMPEDE_SKETCH_RIDGE_STAMP_ID ] as const; diff --git a/src/game/shell/notebookFocusLayout.ts b/src/game/shell/notebookFocusLayout.ts index 83647d7b..47dfe5d8 100644 --- a/src/game/shell/notebookFocusLayout.ts +++ b/src/game/shell/notebookFocusLayout.ts @@ -1,6 +1,6 @@ import { useEffect, useState, type CSSProperties } from 'react'; -export const NOTEBOOK_STAGE_MAX_WIDTH = 1180; +const NOTEBOOK_STAGE_MAX_WIDTH = 1180; export const NOTEBOOK_CONTROL_MAT_TOP_OFFSET_PX = 64; export interface NotebookFocusFrameLayout { diff --git a/src/game/shell/useInteractiveAppShell.ts b/src/game/shell/useInteractiveAppShell.ts new file mode 100644 index 00000000..111bb35d --- /dev/null +++ b/src/game/shell/useInteractiveAppShell.ts @@ -0,0 +1,77 @@ +import { useMemo } from 'react'; +import { bridgeActions, useBridgeState, type OpenOverlayOptions } from '@/game/bridge/store'; +import { + getPhaserScenePresentationMode, + type PhaserScenePresentationMode +} from '@/game/sharedSceneRuntime/phaserScenePresentation'; +import type { OverlayId } from '@/game/overlays/overlayIds'; +import { + isSceneId, + OVERWORLD_SCENE_ID, + POTASSIUM_SCENE_ID, + type SceneId +} from '@/game/scenes/sceneIds'; +import { getInteractiveGameShellLayout } from './gameShellLayout'; +import { getSceneHeaderChrome } from './sceneHeaderChrome'; +import { getNotebookRuntimeShellProfile } from './notebookShellProfile'; +import { useResizeObserver } from '@/shared/hooks/useResizeObserver'; +import { useMessages } from '@/shared/i18n'; + +export function useInteractiveAppShell() { + const bridge = useBridgeState(); + const messages = useMessages(); + const { ref: contentRowRef, height: contentRowHeight } = useResizeObserver(); + + const startSceneParam = new URLSearchParams(window.location.search).get('startScene'); + const initialStartSceneId = startSceneParam && isSceneId(startSceneParam) ? startSceneParam : null; + const presentationSceneId = + bridge.activeSceneId === OVERWORLD_SCENE_ID && initialStartSceneId + ? initialStartSceneId + : bridge.activeSceneId; + const presentationMode: PhaserScenePresentationMode = + getPhaserScenePresentationMode(presentationSceneId); + const gameShellLayout = getInteractiveGameShellLayout(presentationMode, contentRowHeight); + const isGameInputBlocked = bridge.isPaused || bridge.loadingSceneId !== null; + const shouldReserveSceneHint = presentationSceneId === POTASSIUM_SCENE_ID; + const sceneHintText = bridge.sceneHintText ?? messages.potassiumSlip.hints.start; + const shouldShowNavigationHint = + bridge.activeSceneId === OVERWORLD_SCENE_ID && bridge.activeOverlayId === null; + const shouldShowSceneUiStatus = bridge.sceneUi.status !== null; + const shouldShowFooterHint = + shouldShowSceneUiStatus || shouldReserveSceneHint || shouldShowNavigationHint; + const sceneHeaderChrome = getSceneHeaderChrome(presentationSceneId); + const notebookShellProfile = getNotebookRuntimeShellProfile(presentationSceneId); + + const actions = useMemo(() => ({ + enterScene: (sceneId: SceneId) => bridgeActions.enterScene(sceneId), + openOverlay: (overlayId: OverlayId, options?: OpenOverlayOptions) => + bridgeActions.openOverlay(overlayId, options), + returnToOverworld: () => bridgeActions.returnToOverworld(), + returnToScene: (sceneId: SceneId) => { + if (sceneId === OVERWORLD_SCENE_ID) { + bridgeActions.returnToOverworld(); + return; + } + bridgeActions.enterScene(sceneId); + } + }), []); + + return { + bridge, + messages, + contentRowRef, + presentationMode, + gameShellLayout, + isGameInputBlocked, + sceneHintText, + shouldShowFooterHint, + shouldShowSceneUiStatus, + shouldReserveSceneHint, + shouldShowNavigationHint, + sceneHeaderChrome, + notebookShellProfile, + ...actions + }; +} + +export type InteractiveAppShellState = ReturnType; diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts index 5270155c..33338815 100644 --- a/src/shared/i18n/index.ts +++ b/src/shared/i18n/index.ts @@ -1,14 +1,9 @@ -export { enMessages } from './messages/en'; export { I18nProvider } from './I18nProvider'; -export { getCurrentLocale, getMessages, setCurrentLocale } from './getMessages'; +export { getMessages } from './getMessages'; export { DEFAULT_LOCALE, - LOCALE_QUERY_PARAM, LOCALE_STORAGE_KEY, - SUPPORTED_LOCALES, - isLocale, readLocaleFromBrowser, - readStoredLocale, resolveLocale, writeLocaleToBrowserUrl, writeStoredLocale, diff --git a/src/shared/i18n/locale.ts b/src/shared/i18n/locale.ts index 281b6d87..696b4303 100644 --- a/src/shared/i18n/locale.ts +++ b/src/shared/i18n/locale.ts @@ -1,8 +1,8 @@ -export const SUPPORTED_LOCALES = ['en'] as const; +const SUPPORTED_LOCALES = ['en'] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const DEFAULT_LOCALE: Locale = 'en'; -export const LOCALE_QUERY_PARAM = 'lang'; +const LOCALE_QUERY_PARAM = 'lang'; export const LOCALE_STORAGE_KEY = 'portfolio:locale'; export interface LocaleStorageLike { @@ -10,7 +10,7 @@ export interface LocaleStorageLike { setItem(key: string, value: string): void; } -export function isLocale(value: string | null | undefined): value is Locale { +function isLocale(value: string | null | undefined): value is Locale { return SUPPORTED_LOCALES.includes(value as Locale); } @@ -25,7 +25,7 @@ export function resolveLocale(input: { return DEFAULT_LOCALE; } -export function readStoredLocale(storage: LocaleStorageLike | null = getBrowserStorage()): Locale | null { +function readStoredLocale(storage: LocaleStorageLike | null = getBrowserStorage()): Locale | null { if (!storage) return null; try { const stored = storage.getItem(LOCALE_STORAGE_KEY); diff --git a/src/shared/math/index.ts b/src/shared/math/index.ts new file mode 100644 index 00000000..1f59946e --- /dev/null +++ b/src/shared/math/index.ts @@ -0,0 +1,9 @@ +/** Clamp `value` to the inclusive range `[min, max]`. */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** Linear interpolation from `from` to `to` at parameter `t` (typically 0–1). */ +export function linear(from: number, to: number, t: number): number { + return from + (to - from) * t; +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 6dafdaeb..3f678179 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -30,7 +30,4 @@ export type { NotebookSceneProfile, NotebookShellLayout } from './NotebookShell'; -export { SectionHeader } from './SectionHeader'; export { SketchSection } from './SketchSection'; -export { notebookShadowRoles, sketchBorders, sketchColors, sketchShadows } from './tokens'; -export { cn } from './utils'; diff --git a/src/shared/ui/tokens.ts b/src/shared/ui/tokens.ts index f1534a3f..d9f45311 100644 --- a/src/shared/ui/tokens.ts +++ b/src/shared/ui/tokens.ts @@ -1,10 +1,3 @@ -export const sketchColors = { - ink: '#1a1a1a', - paper: '#fbfbf9', - paperMuted: '#f4f1ea', - paperHover: '#e8e5df' -} as const; - export const sketchShadows = { xs: 'shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]', sm: 'shadow-[3px_3px_0px_0px_rgba(26,26,26,1)]',