From fba428ca1c4c0d3222d97c49e2a888fd754063b8 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 20:52:56 +0200 Subject: [PATCH 1/9] phase 1 of cleanups --- .fallowrc.jsonc | 28 +- fallow-baselines/dead-code.json | 98 +---- fallow-baselines/dupes.json | 13 +- fallow-baselines/health.json | 8 - src/game/bridge/store.ts | 6 +- src/game/overlays/overlayIds.ts | 13 +- src/game/sceneUi/registry.ts | 2 +- src/game/sceneUi/types.ts | 17 +- src/game/scenes/basement/index.ts | 10 - src/game/scenes/basement/runtime/index.ts | 1 - src/game/scenes/hobbies/index.ts | 8 - src/game/scenes/hobbies/roomLayout.ts | 4 +- src/game/scenes/hobbies/runtime/index.ts | 2 - src/game/scenes/overworld/index.ts | 2 - src/game/scenes/potassiumSlip/index.ts | 1 - src/game/scenes/potassiumSlip/runtime/boss.ts | 2 +- .../scenes/potassiumSlip/runtime/combat.ts | 6 +- .../potassiumSlip/runtime/enemyFactory.ts | 2 +- .../scenes/potassiumSlip/runtime/index.ts | 1 - .../scenes/potassiumSlip/runtime/waves.ts | 8 +- src/game/scenes/ridge/blockout/geometry.ts | 32 +- src/game/scenes/ridge/blockout/parser.ts | 8 +- .../scenes/ridge/blockout/ridgeBlockout.ts | 4 - .../scenes/ridge/blockout/sourceCompiler.ts | 13 +- .../sources/folded-desk-ridge.source.ts | 2 +- .../scenes/ridge/bridge/bridgeTracerSlice.ts | 2 - src/game/scenes/ridge/cicka/assets.ts | 6 +- src/game/scenes/ridge/cicka/walkBy.ts | 2 +- src/game/scenes/ridge/index.ts | 1 - .../runtime/ridgeBlockoutPresentation.ts | 361 ------------------ .../scenes/ridge/runtime/ridgeDebugOverlay.ts | 24 -- .../scenes/ridge/runtime/ridgeDevControls.ts | 6 +- .../runtime/ridgeLandmarkPresentation.ts | 221 ----------- .../ridge/runtime/ridgePresentationFacts.ts | 13 - .../scenes/ridge/runtime/traversalComfort.ts | 2 +- src/game/scenes/stampedeSketch/index.ts | 1 - .../stampedeSketch/runtime/autoAttack.ts | 4 +- .../scenes/stampedeSketch/runtime/index.ts | 1 - .../scenes/stampedeSketch/runtime/movement.ts | 4 +- .../stampedeSketch/runtime/progression.ts | 4 +- .../scenes/stampedeSketch/runtime/upgrades.ts | 2 +- src/game/sharedSceneRuntime/config.ts | 1 - src/game/shell/devRidgeProgressParams.ts | 2 +- src/game/shell/notebookFocusLayout.ts | 2 +- src/shared/i18n/index.ts | 7 +- src/shared/i18n/locale.ts | 8 +- src/shared/ui/index.ts | 3 - src/shared/ui/tokens.ts | 7 - 48 files changed, 92 insertions(+), 883 deletions(-) delete mode 100644 src/game/scenes/basement/index.ts delete mode 100644 src/game/scenes/basement/runtime/index.ts delete mode 100644 src/game/scenes/hobbies/index.ts delete mode 100644 src/game/scenes/hobbies/runtime/index.ts delete mode 100644 src/game/scenes/overworld/index.ts delete mode 100644 src/game/scenes/potassiumSlip/index.ts delete mode 100644 src/game/scenes/potassiumSlip/runtime/index.ts delete mode 100644 src/game/scenes/ridge/index.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeBlockoutPresentation.ts delete mode 100644 src/game/scenes/stampedeSketch/index.ts delete mode 100644 src/game/scenes/stampedeSketch/runtime/index.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index fa1b98e6..68355c84 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -77,5 +77,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/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..9d63c4de 100644 --- a/fallow-baselines/dupes.json +++ b/fallow-baselines/dupes.json @@ -24,18 +24,17 @@ "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/blockout/parser.ts:318-327|src/game/scenes/ridge/blockout/sourceCompiler.ts:192-201", + "src/game/scenes/ridge/blockout/parser.ts:336-347|src/game/scenes/ridge/blockout/sourceCompiler.ts:219-230", + "src/game/scenes/ridge/blockout/parser.ts:360-366|src/game/scenes/ridge/blockout/sourceCompiler.ts:257-263", + "src/game/scenes/ridge/blockout/parser.ts:368-375|src/game/scenes/ridge/blockout/sourceCompiler.ts:271-278", + "src/game/scenes/ridge/blockout/parser.ts:520-532|src/game/scenes/ridge/blockout/sourceCompiler.ts:292-305", "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..d3ae3e31 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -150,11 +150,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 +204,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/src/game/bridge/store.ts b/src/game/bridge/store.ts index 0cdf50b1..ddfcd3fd 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[]; 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/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/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/enemyFactory.ts b/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts index 2b00720e..1fbd5602 100644 --- a/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts +++ b/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts @@ -60,7 +60,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: { 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/geometry.ts b/src/game/scenes/ridge/blockout/geometry.ts index 0205b6de..aa681f90 100644 --- a/src/game/scenes/ridge/blockout/geometry.ts +++ b/src/game/scenes/ridge/blockout/geometry.ts @@ -157,7 +157,7 @@ export function deriveRidgeBlockoutGeometry( }; } -export function deriveRidgeBlockoutBounds(map: RidgeBlockoutMap): RidgeBlockoutBounds { +function deriveRidgeBlockoutBounds(map: RidgeBlockoutMap): RidgeBlockoutBounds { const maxX = Math.max( ...map.rooms.map((room) => (room.place.x + room.size.width) * map.cell), map.cell @@ -204,36 +204,6 @@ export function getRidgeBlockoutAnchorPoint( }; } -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, diff --git a/src/game/scenes/ridge/blockout/parser.ts b/src/game/scenes/ridge/blockout/parser.ts index cd1a92a2..24fe8022 100644 --- a/src/game/scenes/ridge/blockout/parser.ts +++ b/src/game/scenes/ridge/blockout/parser.ts @@ -1,4 +1,4 @@ -export const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ +const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ '.', '#', '_', @@ -18,7 +18,7 @@ export const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ '?' ]); -export const RIDGE_BLOCKOUT_DESIGN_SYMBOLS = new Set(['=', '~', 'N', 'M']); +const RIDGE_BLOCKOUT_DESIGN_SYMBOLS = new Set(['=', '~', 'N', 'M']); export const RIDGE_BLOCKOUT_LADDER_SYMBOL = 'L'; export const RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS = new Set([ @@ -292,7 +292,7 @@ export function parseRidgeBlockout(source: string): RidgeBlockoutMap { }; } -export function validateRidgeBlockout(map: RidgeBlockoutMap): readonly string[] { +function validateRidgeBlockout(map: RidgeBlockoutMap): readonly string[] { const errors: string[] = []; const roomIds = new Set(); @@ -392,7 +392,7 @@ export function findRidgeBlockoutAnchor( return room.anchors.find(predicate); } -export function isRuntimeActiveRidgeBlockoutSymbol(symbol: string): boolean { +function isRuntimeActiveRidgeBlockoutSymbol(symbol: string): boolean { return symbol !== '.' && RIDGE_BLOCKOUT_RUNTIME_SYMBOLS.has(symbol); } diff --git a/src/game/scenes/ridge/blockout/ridgeBlockout.ts b/src/game/scenes/ridge/blockout/ridgeBlockout.ts index 53cee24c..f4db3e2e 100644 --- a/src/game/scenes/ridge/blockout/ridgeBlockout.ts +++ b/src/game/scenes/ridge/blockout/ridgeBlockout.ts @@ -1,7 +1,3 @@ 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.ts b/src/game/scenes/ridge/blockout/sourceCompiler.ts index c3586d44..9a8a3c04 100644 --- a/src/game/scenes/ridge/blockout/sourceCompiler.ts +++ b/src/game/scenes/ridge/blockout/sourceCompiler.ts @@ -1,7 +1,6 @@ import { RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - type RidgeBlockoutMap, - type RidgeBlockoutTraversalMovement + type RidgeBlockoutMap } from './parser'; import type { RidgeBlockoutSource, @@ -14,7 +13,7 @@ import type { RidgeTileRegistry } from './sourceContract'; -export class RidgeBlockoutSourceValidationError extends Error { +class RidgeBlockoutSourceValidationError extends Error { readonly errors: readonly string[]; constructor(errors: readonly string[]) { @@ -82,7 +81,7 @@ export function validateRidgeBlockoutSource(source: RidgeBlockoutSource): readon return errors; } -export function toRidgeBlockoutMap( +function toRidgeBlockoutMap( source: RidgeBlockoutSource, validationErrors: readonly string[] = validateRidgeBlockoutSource(source) ): RidgeBlockoutMap { @@ -347,9 +346,3 @@ 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/sources/folded-desk-ridge.source.ts b/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts index da4e613c..bb516def 100644 --- a/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts +++ b/src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts @@ -1,6 +1,6 @@ import type { RidgeBlockoutSource, RidgeTileRegistry } from '../sourceContract'; -export const FOLDED_DESK_RIDGE_TILE_REGISTRY = [ +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' }, 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/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/walkBy.ts b/src/game/scenes/ridge/cicka/walkBy.ts index 67ec7089..d89b884e 100644 --- a/src/game/scenes/ridge/cicka/walkBy.ts +++ b/src/game/scenes/ridge/cicka/walkBy.ts @@ -1,4 +1,4 @@ -export const CICKA_WALK_BY_RADIUS = 88; +const CICKA_WALK_BY_RADIUS = 88; export const CICKA_WALK_BY_BARK_DURATION_MS = 2600; export interface CickaWalkByState { 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.ts b/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts index feaa253f..96d6c3eb 100644 --- a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts +++ b/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts @@ -1,6 +1,4 @@ -import type * as Phaser from 'phaser'; import { - renderRidgeDebugDrawCommands, type RidgeDebugDrawCommand } from '../debugDrawCommands'; import type { @@ -34,14 +32,6 @@ export interface RidgeDebugDrawCommandOptions { 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( @@ -161,20 +151,6 @@ export function createRidgeDebugDrawCommands( 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 { 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/ridgeLandmarkPresentation.ts b/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts index c9f124b4..d89df77a 100644 --- a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts +++ b/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts @@ -1,27 +1,10 @@ -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', @@ -75,70 +58,6 @@ 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 { @@ -185,143 +104,3 @@ export function resolveRidgeLandmarkAnchors( ) }; } - -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 index 24aea21b..cdbed19d 100644 --- a/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts +++ b/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts @@ -18,16 +18,3 @@ export function requireRidgeBlockoutFactAnchor( } 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/traversalComfort.ts b/src/game/scenes/ridge/runtime/traversalComfort.ts index 38bf1bf7..bd136ada 100644 --- a/src/game/scenes/ridge/runtime/traversalComfort.ts +++ b/src/game/scenes/ridge/runtime/traversalComfort.ts @@ -56,7 +56,7 @@ export function getRampSurfaceYAtX( return lerp(zone.from.y, zone.to.y, t); } -export function isPointInsideTraversalZone( +function isPointInsideTraversalZone( zone: Pick, point: RidgeTraversalPoint ): boolean { 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/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/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/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)]', From 591b588e9408caef6186a7b75af44f0b8ac67adb Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:05:40 +0200 Subject: [PATCH 2/9] refactor: lower Fallow complexity in core runtime paths Split overworld interaction, bridge store updates, potassium command dispatch, and ridge blockout parsing into smaller units so cyclomatic complexity drops without behavior changes. Co-authored-by: Cursor --- src/game/bridge/store.ts | 51 ++- .../overworld/overworldInteractionState.ts | 180 +++++---- .../potassiumSlip/runtime/commandAdapter.ts | 356 +++++++++++++----- .../potassiumSlip/runtime/enemyFactory.ts | 9 +- src/game/scenes/ridge/blockout/parser.ts | 322 ++++++++++------ src/shared/math/index.ts | 9 + 6 files changed, 645 insertions(+), 282 deletions(-) create mode 100644 src/shared/math/index.ts diff --git a/src/game/bridge/store.ts b/src/game/bridge/store.ts index ddfcd3fd..a880f263 100644 --- a/src/game/bridge/store.ts +++ b/src/game/bridge/store.ts @@ -259,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: { @@ -271,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 && @@ -280,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) && @@ -291,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 && @@ -302,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/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/runtime/commandAdapter.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts index 8fdc1616..43cc5a72 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts @@ -31,6 +31,7 @@ import type { PotassiumDraftChoiceView, PotassiumHudFacts } from './session'; +import { clamp, linear } from '@/shared/math'; import { getPotassiumExplosionRadius, type PotassiumGenericUpgradeKind, @@ -224,49 +225,102 @@ export class PotassiumCommandAdapter { } applySessionCommands(commands: readonly PotassiumSessionCommand[]): void { - commands.forEach((command) => { - if (command.type === 'resetBoard') { + commands.forEach((command) => this.applySessionCommand(command)); + } + + private applySessionCommand(command: PotassiumSessionCommand): void { + if (this.tryApplySessionBoardCommand(command)) return; + if (this.tryApplySessionWaveCommand(command)) return; + if (this.tryApplySessionHudCommand(command)) return; + this.applySessionFlowCommand(command); + } + + private tryApplySessionBoardCommand(command: PotassiumSessionCommand): boolean { + switch (command.type) { + case 'resetBoard': this.ports.resetBoardObjects(); - } else if (command.type === 'hideMainOverlay') { + return true; + case 'hideMainOverlay': this.ports.hideMainOverlay(); - } else if (command.type === 'clearTerminal') { + return true; + case 'clearTerminal': this.ports.clearTerminalOverlay(); - } else if (command.type === 'clearUpgradeChoices') { + return true; + case 'clearUpgradeChoices': this.ports.clearUpgradeChoiceOverlay(); - } else if (command.type === 'setHint') { - this.ports.setHint(command.text); - } else if (command.type === 'spawnWave') { + return true; + case 'stopBanana': + this.ports.stopMainProjectile(); + return true; + case 'clearBoardForOutcome': + this.ports.clearBoardForOutcome(); + return true; + default: + return false; + } + } + + private tryApplySessionWaveCommand(command: PotassiumSessionCommand): boolean { + switch (command.type) { + case 'spawnWave': this.ports.spawnWave(command.wave); - } else if (command.type === 'spawnBoss') { + return true; + case 'spawnBoss': this.ports.spawnBossDelayed(); - } else if (command.type === 'scheduleWaveRows') { + return true; + case 'scheduleWaveRows': this.ports.scheduleWaveRows(command.schedule); - } else if (command.type === 'scheduleUpgradeChoices') { + return true; + case 'scheduleUpgradeChoices': this.ports.scheduleUpgradeChoices(); - } else if (command.type === 'showUpgradeChoices') { - this.ports.showUpgradeChoices(command.choices); - } else if (command.type === 'advanceWaveAfterDelay') { + return true; + case 'advanceWaveAfterDelay': this.ports.advanceWaveAfterDelay(command.wave); - } else if (command.type === 'refreshProjectileVisuals') { + return true; + case 'refreshProjectileVisuals': this.ports.refreshAllProjectileVisuals(); - } else if (command.type === 'updateHud') { + return true; + default: + return false; + } + } + + private tryApplySessionHudCommand(command: PotassiumSessionCommand): boolean { + switch (command.type) { + case 'setHint': + this.ports.setHint(command.text); + return true; + case 'updateHud': this.ports.updateHud(command.hud); - } else if (command.type === 'collectCircuit') { + return true; + case 'collectCircuit': this.ports.collectCircuit(); - } else if (command.type === 'saveRunRecord') { + return true; + case 'saveRunRecord': this.ports.saveRunRecord(command.record); - } else if (command.type === 'showOutcome') { + return true; + default: + return false; + } + } + + private applySessionFlowCommand(command: PotassiumSessionCommand): void { + switch (command.type) { + case 'showUpgradeChoices': + this.ports.showUpgradeChoices(command.choices); + break; + case 'showOutcome': this.ports.showOutcomeOverlay(command); - } else if (command.type === 'showTerminal') { + break; + case 'showTerminal': this.ports.showTerminal(command.outcome); - } else if (command.type === 'closeScene') { + break; + case 'closeScene': this.ports.closeScene(); - } else if (command.type === 'stopBanana') { - this.ports.stopMainProjectile(); - } else if (command.type === 'clearBoardForOutcome') { - this.ports.clearBoardForOutcome(); - } - }); + break; + default: + break; + } } getEnemyCombatFacts(enemy: PotassiumCommandObject): PotassiumEnemyCombatFacts { @@ -334,45 +388,141 @@ export class PotassiumCommandAdapter { commands: readonly PotassiumCombatCommand[], context = this.createCombatContext() ): 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') { + commands.forEach((command) => this.applyCombatCommand(command, context)); + } + + private applyCombatCommand(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 (this.tryApplyCombatEnemyCommand(command, enemy)) return; + if (this.tryApplyCombatProjectileCommand(command, projectile, enemy)) return; + this.applyCombatWorldCommand(command, context, enemy); + } + + private tryApplyCombatEnemyCommand( + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined + ): boolean { + if (this.tryApplyCombatEnemyDamageCommand(command, enemy)) return true; + if (this.tryApplyCombatEnemyStatusCommand(command, enemy)) return true; + return this.tryApplyCombatEnemyLifecycleCommand(command, enemy); + } + + private tryApplyCombatEnemyDamageCommand( + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined + ): boolean { + switch (command.type) { + case 'damageEnemy': if (enemy) this.damageEnemy(enemy, command.amount, command.source); - } else if (command.type === 'setEnemyHp') { + return true; + case 'setEnemyHp': if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); - } else if (command.type === 'setFireTickUntil') { + return true; + case 'showDamageCue': + if (enemy) this.ports.showDamageCue(enemy, command.source); + return true; + default: + return false; + } + } + + private tryApplyCombatEnemyStatusCommand( + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined + ): boolean { + switch (command.type) { + case 'setFireTickUntil': if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil, command.fireTickUntil); - } else if (command.type === 'setPoisonNextTickAt') { + return true; + case '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') { + return true; + case 'applyPoison': if (enemy) this.applyPoisonCommand(enemy, command); - } else if (command.type === 'clearPoison') { + return true; + case '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') { + return true; + default: + return false; + } + } + + private tryApplyCombatEnemyLifecycleCommand( + command: PotassiumCombatCommand, + enemy: PotassiumCommandObject | undefined + ): boolean { + switch (command.type) { + case 'spawnSplitterChildren': + if (enemy) this.ports.spawnSplitterChildren(enemy); + return true; + case 'killEnemy': + if (enemy) this.killEnemy(enemy); + return true; + default: + return false; + } + } + + private tryApplyCombatProjectileCommand( + command: PotassiumCombatCommand, + projectile: PotassiumCommandObject | undefined, + enemy: PotassiumCommandObject | undefined + ): boolean { + switch (command.type) { + case 'ricochetProjectile': + if (projectile && enemy) this.ricochetProjectileFromEnemy(projectile, enemy); + return true; + case 'boostRecallVelocity': + projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); + return true; + default: + return false; + } + } + + private applyCombatWorldCommand( + command: PotassiumCombatCommand, + context: PotassiumCombatContext, + sourceEnemy: PotassiumCommandObject | undefined + ): void { + switch (command.type) { + case 'spawnFirePatch': + this.ports.spawnFirePatch( + command.x, + command.y, + command.effectMultiplier, + command.lifetimeMs, + command.scale + ); + break; + case 'explodeAt': this.explodeAt(command.x, command.y, command.effectMultiplier, command.radiusMultiplier); - } else if (command.type === 'spawnBananaClones') { + break; + case 'spawnBananaClones': this.ports.spawnBananaClones(command.count, command.lifetimeMs); - } else if (command.type === 'spawnGhostBeam') { + break; + case 'spawnGhostBeam': this.spawnGhostBeam(command.x, command.y, command.direction, command.effectMultiplier); - } else if (command.type === 'spawnGhostStatusField') { + break; + case '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); - } - }); + break; + case 'spreadPoisonFrom': + this.spreadPoisonFrom( + command.x, + command.y, + command.effectMultiplier, + sourceEnemy ?? context.enemies.get(command.sourceEnemyId) + ); + break; + default: + break; + } } damageEnemy(enemy: PotassiumCommandObject, amount: number, source: PotassiumDamageSource): void { @@ -387,34 +537,77 @@ export class PotassiumCommandAdapter { } applyBossCommands(commands: readonly PotassiumBossCommand[], boss?: PotassiumCommandObject): void { - commands.forEach((command) => { - if (command.type === 'setBossPhase') { + commands.forEach((command) => this.applyBossCommand(command, boss)); + } + + private applyBossCommand(command: PotassiumBossCommand, boss?: PotassiumCommandObject): void { + if (this.tryApplyBossMotionCommand(command, boss)) return; + if (this.tryApplyBossPresentationCommand(command, boss)) return; + this.applyBossSpawnCommand(command, boss); + } + + private tryApplyBossMotionCommand( + command: PotassiumBossCommand, + boss?: PotassiumCommandObject + ): boolean { + switch (command.type) { + case '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') { + return true; + case 'setBossVelocity': + if (boss) { + if (command.x !== undefined) boss.setX(command.x); + if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); + boss.setVelocityY(command.velocityY); + } + 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; + } + } + + private tryApplyBossPresentationCommand( + command: PotassiumBossCommand, + boss?: PotassiumCommandObject + ): boolean { + switch (command.type) { + case 'setBossHint': this.ports.setHint(command.text); - } else if (command.type === 'shakeCamera') { + return true; + case 'shakeCamera': this.ports.shakeCamera(command.durationMs, command.intensity); - } else if (command.type === 'spawnOrbitBlockers') { + return true; + case 'setStoneVisual': + if (boss) this.ports.setBossStoneVisual(boss, command.active); + return true; + case 'clearOrbitBlockers': + this.ports.clearOrbitBlockers(); + return true; + default: + return false; + } + } + + private applyBossSpawnCommand(command: PotassiumBossCommand, boss?: PotassiumCommandObject): void { + switch (command.type) { + case 'spawnOrbitBlockers': if (boss) this.ports.spawnBossOrbitBlockers(boss); - } else if (command.type === 'updateOrbitBlockers') { + break; + case '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') { + break; + case 'spawnSummons': this.ports.spawnBossSummons(command.summons); - } else if (command.type === 'clearOrbitBlockers') { - this.ports.clearOrbitBlockers(); - } - }); + break; + default: + break; + } } private getCombatId(object: PotassiumCommandObject, prefix: string): string { @@ -645,10 +838,3 @@ function distance(a: { x: number; y: number }, b: { x: number; y: number }): num 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/enemyFactory.ts b/src/game/scenes/potassiumSlip/runtime/enemyFactory.ts index 1fbd5602..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(); @@ -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/ridge/blockout/parser.ts b/src/game/scenes/ridge/blockout/parser.ts index 24fe8022..ae6f07ed 100644 --- a/src/game/scenes/ridge/blockout/parser.ts +++ b/src/game/scenes/ridge/blockout/parser.ts @@ -154,135 +154,235 @@ const ROOM_DECLARATION_PREFIXES = [ '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; +interface RidgeBlockoutParseContext { + language: string; + cell: number; + worldId: string; + title: string; + spawn: RidgeBlockoutMap['spawn']; + routes: RidgeBlockoutRoute[]; + futureRoutes: RidgeBlockoutRoute[]; + shortcuts: RidgeBlockoutShortcut[]; + optionalPockets: RidgeBlockoutOptionalPocket[]; + homeMutations: RidgeBlockoutHomeMutation[]; + rooms: RidgeBlockoutRoom[]; + currentRoom?: MutableRoom; + readingGrid: boolean; +} + +function createRidgeBlockoutParseContext(): RidgeBlockoutParseContext { + return { + language: DEFAULT_MAP.language, + cell: DEFAULT_MAP.cell, + worldId: DEFAULT_MAP.worldId, + title: DEFAULT_MAP.title, + spawn: DEFAULT_MAP.spawn, + routes: [], + futureRoutes: [], + shortcuts: [], + optionalPockets: [], + homeMutations: [], + rooms: [], + readingGrid: false + }; +} + +function finishRidgeBlockoutRoom(ctx: RidgeBlockoutParseContext): void { + if (!ctx.currentRoom) return; + ctx.rooms.push({ + id: ctx.currentRoom.id, + title: ctx.currentRoom.title || ctx.currentRoom.id, + place: ctx.currentRoom.place ?? { x: 0, y: 0 }, + size: ctx.currentRoom.size ?? { width: 0, height: 0 }, + theme: ctx.currentRoom.theme, + mood: ctx.currentRoom.mood, + links: ctx.currentRoom.links, + props: ctx.currentRoom.props, + grid: ctx.currentRoom.grid, + anchors: ctx.currentRoom.anchors, + rects: ctx.currentRoom.rects, + declarations: ctx.currentRoom.declarations + }); + ctx.currentRoom = undefined; +} + +function startRidgeBlockoutRoom(ctx: RidgeBlockoutParseContext, line: string): void { + finishRidgeBlockoutRoom(ctx); + ctx.currentRoom = { + id: line.slice('room '.length).trim(), + title: '', + links: [], + props: [], + grid: [], + anchors: [], + rects: [], + declarations: [] }; + ctx.readingGrid = false; +} + +function applyRidgeBlockoutMapHeaderLine(ctx: RidgeBlockoutParseContext, line: string): boolean { + if (line.startsWith('language ')) { + ctx.language = line.slice('language '.length).trim(); + return true; + } + if (line.startsWith('cell ')) { + ctx.cell = Number(line.slice('cell '.length).trim()); + return true; + } + if (line.startsWith('world ')) { + ctx.worldId = line.slice('world '.length).trim(); + return true; + } + if (line.startsWith('title ')) { + ctx.title = line.slice('title '.length).trim(); + return true; + } + if (line.startsWith('spawn ')) { + const attrs = parseAttrs(line.slice('spawn '.length).split(/\s+/)); + ctx.spawn = { + roomId: attrs.room ?? '', + anchorSymbol: attrs.anchor ?? '' + }; + return true; + } + return false; +} + +function applyRidgeBlockoutMapCollectionLine(ctx: RidgeBlockoutParseContext, line: string): boolean { + if (line.startsWith('route ')) { + ctx.routes.push(parseRoute(line, 'route')); + return true; + } + if (line.startsWith('future_route ')) { + ctx.futureRoutes.push(parseRoute(line, 'future_route')); + return true; + } + if (line.startsWith('shortcut ')) { + ctx.shortcuts.push(parseShortcut(line)); + return true; + } + if (line.startsWith('optional_pocket ')) { + ctx.optionalPockets.push(parseOptionalPocket(line)); + return true; + } + if (line.startsWith('home_mutation ')) { + ctx.homeMutations.push(parseHomeMutation(line)); + return true; + } + return false; +} + +function parseRidgeBlockoutMapLine(ctx: RidgeBlockoutParseContext, line: string): void { + if (applyRidgeBlockoutMapHeaderLine(ctx, line)) return; + applyRidgeBlockoutMapCollectionLine(ctx, line); +} + +function applyRidgeBlockoutRoomLayoutLine(room: MutableRoom, line: string): boolean { + if (line.startsWith('title ')) { + room.title = line.slice('title '.length).trim(); + return true; + } + if (line.startsWith('place ')) { + room.place = parsePoint(line.slice('place '.length)); + return true; + } + if (line.startsWith('size ')) { + room.size = parseSize(line.slice('size '.length).trim()); + return true; + } + if (line.startsWith('theme ')) { + room.theme = line.slice('theme '.length).trim(); + return true; + } + if (line.startsWith('mood ')) { + room.mood = line.slice('mood '.length).trim(); + return true; + } + return false; +} + +function applyRidgeBlockoutRoomContentLine(room: MutableRoom, line: string): boolean { + if (line.startsWith('links ')) { + room.links = line.slice('links '.length).split(/\s+/).filter(Boolean); + return true; + } + if (line.startsWith('props ')) { + room.props = line.slice('props '.length).split(',').map((prop) => prop.trim()).filter(Boolean); + return true; + } + if (line.startsWith('anchor ')) { + room.anchors.push(parseAnchor(line)); + return true; + } + if (line.startsWith('rect ')) { + room.rects.push(parseRect(line)); + return true; + } + return false; +} + +function applyRidgeBlockoutRoomScalarLine(room: MutableRoom, line: string): boolean { + return applyRidgeBlockoutRoomLayoutLine(room, line) || applyRidgeBlockoutRoomContentLine(room, line); +} + +function parseRidgeBlockoutRoomLine(ctx: RidgeBlockoutParseContext, line: string): void { + const room = ctx.currentRoom; + if (!room) return; + + if (ctx.readingGrid) { + room.grid.push(line); + return; + } + + if (line === 'grid') { + ctx.readingGrid = true; + return; + } + + if (applyRidgeBlockoutRoomScalarLine(room, line)) return; + + if (ROOM_DECLARATION_PREFIXES.some((prefix) => line.startsWith(prefix))) { + room.declarations.push(line); + } +} + +export function parseRidgeBlockout(source: string): RidgeBlockoutMap { + const ctx = createRidgeBlockoutParseContext(); normalizeLines(source).forEach((line) => { if (!line) { - readingGrid = false; + ctx.readingGrid = false; return; } if (line.startsWith('room ')) { - finishCurrentRoom(); - currentRoom = { - id: line.slice('room '.length).trim(), - title: '', - links: [], - props: [], - grid: [], - anchors: [], - rects: [], - declarations: [] - }; - readingGrid = false; + startRidgeBlockoutRoom(ctx, line); 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)); - } + if (!ctx.currentRoom) { + parseRidgeBlockoutMapLine(ctx, 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); - } + parseRidgeBlockoutRoomLine(ctx, line); }); - finishCurrentRoom(); + finishRidgeBlockoutRoom(ctx); const mapWithoutErrors: RidgeBlockoutMap = { - language, - cell, - worldId, - title, - spawn, - routes, - futureRoutes, - shortcuts, - optionalPockets, - homeMutations, - rooms, + language: ctx.language, + cell: ctx.cell, + worldId: ctx.worldId, + title: ctx.title, + spawn: ctx.spawn, + routes: ctx.routes, + futureRoutes: ctx.futureRoutes, + shortcuts: ctx.shortcuts, + optionalPockets: ctx.optionalPockets, + homeMutations: ctx.homeMutations, + rooms: ctx.rooms, validationErrors: [] }; 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; +} From 92fc06097c0fa186e39a0cb28978520f88ddb0ff Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:05:43 +0200 Subject: [PATCH 3/9] refactor: split InteractiveApp shell into hook and layout Move derived bridge/layout state into useInteractiveAppShell and render the default overworld shell from InteractiveAppDefaultShell so the entry component stays a thin scene router. Co-authored-by: Cursor --- src/game/shell/InteractiveApp.tsx | 196 ++---------------- src/game/shell/InteractiveAppDefaultShell.tsx | 159 ++++++++++++++ src/game/shell/useInteractiveAppShell.ts | 77 +++++++ 3 files changed, 251 insertions(+), 181 deletions(-) create mode 100644 src/game/shell/InteractiveAppDefaultShell.tsx create mode 100644 src/game/shell/useInteractiveAppShell.ts 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/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; From 9c4dfd3fee4ec5ef2a0666ce07751984b5b55a89 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:06:04 +0200 Subject: [PATCH 4/9] refactor: dedupe SceneManager transition prep Share context load/guard handling between enter and exitTo via prepareContext and startContext. Co-authored-by: Cursor --- src/game/sceneLifecycle/SceneManager.ts | 57 +++++++++++++------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/game/sceneLifecycle/SceneManager.ts b/src/game/sceneLifecycle/SceneManager.ts index 128b58ac..26cd1ff0 100644 --- a/src/game/sceneLifecycle/SceneManager.ts +++ b/src/game/sceneLifecycle/SceneManager.ts @@ -36,41 +36,22 @@ 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) 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?.(); + 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) 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; @@ -82,9 +63,7 @@ export class SceneManager { } if (!this.adapter.isSceneActive(target.sceneKey)) { - this.adapter.startScene(target.sceneKey, target.getStartData()); - target.onEnter?.(); - this.options.onSceneStarted?.(); + this.startContext(target); } } @@ -112,6 +91,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; From a4d5816b404949c04e89d9618cbf140b7be32a03 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:15:30 +0200 Subject: [PATCH 5/9] refactor: share Ridge blockout validation and fix scene guards Extract parser/sourceCompiler validation into ridgeBlockoutValidation with domain constants in ridgeBlockoutConstants. Ignore stale transition guards before starting scenes and settle async lifecycle tests. Co-authored-by: Cursor --- fallow-baselines/dupes.json | 16 +- .../SceneLifecycleController.test.ts | 10 +- src/game/sceneLifecycle/SceneManager.ts | 11 +- .../SceneTransitionCoordinator.test.ts | 1 + src/game/scenes/ridge/blockout/parser.ts | 151 +++----------- .../ridge/blockout/ridgeBlockoutConstants.ts | 14 ++ .../ridge/blockout/ridgeBlockoutValidation.ts | 177 ++++++++++++++++ .../scenes/ridge/blockout/sourceCompiler.ts | 196 +++++------------- 8 files changed, 295 insertions(+), 281 deletions(-) create mode 100644 src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts create mode 100644 src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts diff --git a/fallow-baselines/dupes.json b/fallow-baselines/dupes.json index 9d63c4de..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,22 +14,14 @@ "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/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:192-201", - "src/game/scenes/ridge/blockout/parser.ts:336-347|src/game/scenes/ridge/blockout/sourceCompiler.ts:219-230", - "src/game/scenes/ridge/blockout/parser.ts:360-366|src/game/scenes/ridge/blockout/sourceCompiler.ts:257-263", - "src/game/scenes/ridge/blockout/parser.ts:368-375|src/game/scenes/ridge/blockout/sourceCompiler.ts:271-278", - "src/game/scenes/ridge/blockout/parser.ts:520-532|src/game/scenes/ridge/blockout/sourceCompiler.ts:292-305", "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", 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 26cd1ff0..465fc606 100644 --- a/src/game/sceneLifecycle/SceneManager.ts +++ b/src/game/sceneLifecycle/SceneManager.ts @@ -37,7 +37,7 @@ export class SceneManager { async enter(contextId: ContextId, guard?: SceneTransitionGuard): Promise { const target = await this.prepareContext(contextId, guard); - if (!target) return; + if (!target || this.isGuardStale(guard)) return; if (this.adapter.isSceneActive(target.sceneKey)) { this.clearLoadingIfCurrent(guard); @@ -45,12 +45,13 @@ export class SceneManager { } this.stopActiveContextsExcept(target.sceneKey); + if (this.isGuardStale(guard)) return; this.startContext(target); } async exitTo(contextId: ContextId, guard?: SceneTransitionGuard): Promise { const target = await this.prepareContext(contextId, guard); - if (!target) return; + if (!target || this.isGuardStale(guard)) return; const allKeys = this.adapter.listKnownSceneKeys(); for (const sceneKey of allKeys) { @@ -62,7 +63,7 @@ export class SceneManager { context?.onExit?.(resumeSnapshot); } - if (!this.adapter.isSceneActive(target.sceneKey)) { + if (!this.adapter.isSceneActive(target.sceneKey) && !this.isGuardStale(guard)) { this.startContext(target); } } @@ -148,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/scenes/ridge/blockout/parser.ts b/src/game/scenes/ridge/blockout/parser.ts index ae6f07ed..f2d74393 100644 --- a/src/game/scenes/ridge/blockout/parser.ts +++ b/src/game/scenes/ridge/blockout/parser.ts @@ -19,20 +19,23 @@ const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ ]); 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 { + RIDGE_BLOCKOUT_LADDER_SYMBOL, + RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, + type RidgeBlockoutTraversalMovement +} from './ridgeBlockoutConstants'; +import { RIDGE_BLOCKOUT_LADDER_SYMBOL } from './ridgeBlockoutConstants'; +import { + appendDuplicateRidgeBlockoutRoomId, + appendRidgeBlockoutHeaderErrors, + appendRidgeBlockoutRoomAnchorErrors, + appendRidgeBlockoutRoomGridErrors, + appendRidgeBlockoutRouteErrors, + appendRidgeBlockoutRuntimeCellOverlapErrors, + appendRidgeBlockoutShortcutErrors, + appendRidgeBlockoutSpawnErrors +} from './ridgeBlockoutValidation'; export interface RidgeBlockoutPoint { x: number; @@ -59,13 +62,16 @@ export interface RidgeBlockoutRect { attrs: Readonly>; } -export interface RidgeBlockoutRoom { +interface RidgeBlockoutRoomCore { id: string; title: string; - place: RidgeBlockoutPoint; - size: RidgeBlockoutSize; theme?: string; mood?: string; +} + +export interface RidgeBlockoutRoom extends RidgeBlockoutRoomCore { + place: RidgeBlockoutPoint; + size: RidgeBlockoutSize; links: readonly string[]; props: readonly string[]; grid: readonly string[]; @@ -115,13 +121,9 @@ export interface RidgeBlockoutMap { validationErrors: readonly string[]; } -interface MutableRoom { - id: string; - title: string; +interface MutableRoom extends RidgeBlockoutRoomCore { place?: RidgeBlockoutPoint; size?: RidgeBlockoutSize; - theme?: string; - mood?: string; links: string[]; props: string[]; grid: string[]; @@ -396,85 +398,24 @@ 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'); - } + appendRidgeBlockoutHeaderErrors(errors, map); 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}"`); - } - } + appendDuplicateRidgeBlockoutRoomId(room.id, roomIds, errors); - [...map.routes, ...map.futureRoutes].forEach((route) => { - route.roomIds.forEach((roomId) => { - if (!roomIds.has(roomId)) { - errors.push(`route "${route.id}" references missing room "${roomId}"`); - } + appendRidgeBlockoutRoomGridErrors(room, errors, isKnownRidgeBlockoutSymbol); + appendRidgeBlockoutRoomAnchorErrors(room, errors, { + gridContainsSymbol: (symbol) => room.grid.some((row) => row.includes(symbol)) }); }); - 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}"`); - } - }); + appendRidgeBlockoutSpawnErrors(errors, map.spawn, roomIds, (roomId) => + findRidgeBlockoutRoom(map, roomId) + ); + appendRidgeBlockoutRouteErrors(errors, [...map.routes, ...map.futureRoutes], roomIds); + appendRidgeBlockoutShortcutErrors(errors, map.shortcuts, roomIds); + appendRidgeBlockoutRuntimeCellOverlapErrors(errors, map.rooms, isRuntimeActiveRidgeBlockoutSymbol); - errors.push(...validateRuntimeCellOverlaps(map)); return errors; } @@ -609,27 +550,3 @@ function isKnownRidgeBlockoutSymbol(symbol: string): boolean { 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/ridgeBlockoutConstants.ts b/src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts new file mode 100644 index 00000000..b3996b79 --- /dev/null +++ b/src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts @@ -0,0 +1,14 @@ +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'; diff --git a/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts b/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts new file mode 100644 index 00000000..c4c11b8a --- /dev/null +++ b/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts @@ -0,0 +1,177 @@ +import { RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS } from './ridgeBlockoutConstants'; + +export interface RidgeBlockoutValidationHeader { + language: string; + cell: number; + worldId: string; + spawn: { + roomId: string; + anchorSymbol: string; + }; +} + +export interface RidgeBlockoutValidationRoom { + id: string; + size: { width: number; height: number }; + grid: readonly string[]; +} + +export interface RidgeBlockoutValidationAnchor { + symbol: string; + attrs: Readonly>; +} + +export interface RidgeBlockoutPlacedRoom extends RidgeBlockoutValidationRoom { + place: { x: number; y: number }; +} + +export function appendDuplicateRidgeBlockoutRoomId( + roomId: string, + roomIds: Set, + errors: string[] +): void { + if (roomIds.has(roomId)) { + errors.push(`duplicate room "${roomId}"`); + } + roomIds.add(roomId); +} + +export function appendRidgeBlockoutHeaderErrors( + errors: string[], + header: RidgeBlockoutValidationHeader +): void { + if (header.language !== 'ridge-v0') { + errors.push(`unsupported language "${header.language}"`); + } + if (!Number.isFinite(header.cell) || header.cell <= 0) { + errors.push(`invalid cell size "${header.cell}"`); + } + if (!header.worldId) { + errors.push('missing world id'); + } + if (!header.spawn.roomId || !header.spawn.anchorSymbol) { + errors.push('missing spawn room or anchor'); + } +} + +export function appendRidgeBlockoutRoomGridErrors( + room: RidgeBlockoutValidationRoom, + errors: string[], + isSymbolKnown: (symbol: string) => boolean +): 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) => { + const rowSymbols = [...row]; + if (rowSymbols.length !== room.size.width) { + errors.push( + `room "${room.id}" row ${rowIndex + 1} width ${rowSymbols.length} does not match size ${room.size.width}` + ); + } + rowSymbols.forEach((symbol, columnIndex) => { + if (!isSymbolKnown(symbol)) { + errors.push(`room "${room.id}" has unknown symbol "${symbol}" at ${columnIndex},${rowIndex}`); + } + }); + }); +} + +export function appendRidgeBlockoutRoomAnchorErrors( + room: RidgeBlockoutValidationRoom & { anchors: readonly RidgeBlockoutValidationAnchor[] }, + errors: string[], + options: { + gridContainsSymbol: (symbol: string) => boolean; + anchorSymbolErrors?: (anchor: RidgeBlockoutValidationAnchor) => readonly string[]; + } +): void { + room.anchors.forEach((anchor) => { + for (const message of options.anchorSymbolErrors?.(anchor) ?? []) { + errors.push(message); + } + if (!options.gridContainsSymbol(anchor.symbol)) { + errors.push(`room "${room.id}" anchor "${anchor.symbol}" has no matching grid cell`); + } + const movement = anchor.attrs.movement; + if (movement !== undefined && !RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(movement)) { + errors.push(`room "${room.id}" anchor "${anchor.symbol}" has unknown movement "${movement}"`); + } + }); +} + +export function appendRidgeBlockoutSpawnErrors( + errors: string[], + spawn: RidgeBlockoutValidationHeader['spawn'], + roomIds: ReadonlySet, + findRoom: (roomId: string) => { anchors: readonly { symbol: string }[] } | undefined +): void { + if (!roomIds.has(spawn.roomId)) { + errors.push(`spawn room "${spawn.roomId}" does not exist`); + return; + } + + const spawnRoom = findRoom(spawn.roomId); + const spawnAnchor = spawnRoom?.anchors.find((anchor) => anchor.symbol === spawn.anchorSymbol); + if (!spawnAnchor) { + errors.push(`spawn anchor "${spawn.anchorSymbol}" does not exist in room "${spawn.roomId}"`); + } +} + +export function appendRidgeBlockoutRouteErrors( + errors: string[], + routes: readonly { id: string; roomIds: readonly string[] }[], + roomIds: ReadonlySet +): void { + routes.forEach((route) => { + route.roomIds.forEach((roomId) => { + if (!roomIds.has(roomId)) { + errors.push(`route "${route.id}" references missing room "${roomId}"`); + } + }); + }); +} + +export function appendRidgeBlockoutShortcutErrors( + errors: string[], + shortcuts: readonly { id: string; fromRoomId: string; toRoomId: string }[], + roomIds: ReadonlySet +): void { + 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}"`); + } + }); +} + +export function appendRidgeBlockoutRuntimeCellOverlapErrors( + errors: string[], + rooms: readonly RidgeBlockoutPlacedRoom[], + isRuntimeActiveSymbol: (symbol: string) => boolean +): void { + const seen = new Map(); + + rooms.forEach((room) => { + room.grid.forEach((row, rowIndex) => { + [...row].forEach((symbol, columnIndex) => { + if (!isRuntimeActiveSymbol(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 }); + }); + }); + }); +} diff --git a/src/game/scenes/ridge/blockout/sourceCompiler.ts b/src/game/scenes/ridge/blockout/sourceCompiler.ts index 9a8a3c04..c59c271f 100644 --- a/src/game/scenes/ridge/blockout/sourceCompiler.ts +++ b/src/game/scenes/ridge/blockout/sourceCompiler.ts @@ -1,7 +1,14 @@ +import type { RidgeBlockoutMap } from './parser'; import { - RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - type RidgeBlockoutMap -} from './parser'; + appendDuplicateRidgeBlockoutRoomId, + appendRidgeBlockoutHeaderErrors, + appendRidgeBlockoutRoomAnchorErrors, + appendRidgeBlockoutRoomGridErrors, + appendRidgeBlockoutRouteErrors, + appendRidgeBlockoutRuntimeCellOverlapErrors, + appendRidgeBlockoutShortcutErrors, + appendRidgeBlockoutSpawnErrors +} from './ridgeBlockoutValidation'; import type { RidgeBlockoutSource, RidgeBlockoutSourceAnchor, @@ -50,33 +57,49 @@ export function validateRidgeBlockoutSource(source: RidgeBlockoutSource): readon 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'); - } + appendRidgeBlockoutHeaderErrors(errors, source); 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); + appendDuplicateRidgeBlockoutRoomId(room.id, roomIds, errors); + + appendRidgeBlockoutRoomGridErrors(room, errors, (symbol) => tileLookup.bySymbol.has(symbol)); + appendRidgeBlockoutRoomAnchorErrors( + { + ...room, + anchors: (room.anchors ?? []).map((anchor) => ({ + symbol: anchor.symbol, + attrs: anchor.attrs ?? {} + })) + }, + errors, + { + gridContainsSymbol: (symbol) => room.grid.some((row) => [...row].includes(symbol)), + anchorSymbolErrors: (anchor) => + tileLookup.bySymbol.has(anchor.symbol) + ? [] + : [`room "${room.id}" anchor "${anchor.symbol}" uses an unknown tile symbol`] + } + ); }); - validateSpawn(source, roomIds, errors); - validateRoutes(source, roomIds, errors); - validateShortcuts(source, roomIds, errors); - validateRuntimeCellOverlaps(source, tileLookup, errors); + appendRidgeBlockoutSpawnErrors(errors, source.spawn, roomIds, (roomId) => { + const room = source.rooms.find((candidate) => candidate.id === roomId); + return room + ? { + anchors: (room.anchors ?? []).map((anchor) => ({ symbol: anchor.symbol })) + } + : undefined; + }); + appendRidgeBlockoutRouteErrors( + errors, + [...source.routes, ...(source.futureRoutes ?? [])], + roomIds + ); + appendRidgeBlockoutShortcutErrors(errors, source.shortcuts ?? [], roomIds); + appendRidgeBlockoutRuntimeCellOverlapErrors(errors, source.rooms, (symbol) => { + const tile = tileLookup.bySymbol.get(symbol); + return tile !== undefined && isRuntimeActiveTile(tile); + }); return errors; } @@ -184,127 +207,6 @@ function validateTileRegistry( 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])) From 16eed147fa0562655793f9a83f57d4f06ed7574b Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:31:17 +0200 Subject: [PATCH 6/9] chore(ridge): remove unused folded blockout prototype Delete the legacy blockout compiler, generated map, and prototype runtime (traversal, trail cards, Cicka Home mutations). Bridge tracer and Stampede Sketch stay; docs and CI no longer reference ridge:source. Co-authored-by: Cursor --- .fallowrc.jsonc | 4 - README.md | 8 +- ...idge-blockout-as-exploration-map-source.md | 10 +- docs/adr/README.md | 4 +- docs/architecture-direction.md | 15 +- docs/game-design/README.md | 21 +- docs/game-design/ridge/README.md | 8 +- .../legacy/reviews/blockout-fun-review.md | 4 +- docs/game-design/ridge/map-language.md | 58 +- docs/game-design/ridge/ridge-snapshot.md | 42 +- docs/runtime-architecture.md | 48 +- fallow-baselines/health.json | 5 - package.json | 4 +- scripts/generate-ridge-blockout-sources.mjs | 143 - .../generate-ridge-blockout-sources.test.mjs | 22 - src/game/scenes/ridge/blockout/facts.test.ts | 176 - src/game/scenes/ridge/blockout/facts.ts | 269 - .../scenes/ridge/blockout/geometry.test.ts | 269 - src/game/scenes/ridge/blockout/geometry.ts | 781 -- src/game/scenes/ridge/blockout/index.ts | 7 - src/game/scenes/ridge/blockout/parser.test.ts | 150 - src/game/scenes/ridge/blockout/parser.ts | 552 -- src/game/scenes/ridge/blockout/progress.ts | 17 - .../scenes/ridge/blockout/ridgeBlockout.ts | 3 - .../ridge/blockout/ridgeBlockoutConstants.ts | 14 - .../ridge/blockout/ridgeBlockoutValidation.ts | 177 - .../ridge/blockout/sourceCompiler.test.ts | 155 - .../scenes/ridge/blockout/sourceCompiler.ts | 250 - .../scenes/ridge/blockout/sourceContract.ts | 120 - .../sources/contract-fixture.generated.ts | 515 -- .../sources/contract-fixture.source.ts | 102 - .../sources/folded-desk-ridge.generated.ts | 7693 ----------------- .../sources/folded-desk-ridge.source.ts | 593 -- src/game/scenes/ridge/cicka/CickaPerch.ts | 209 - .../cicka/homeMutationPresentation.test.ts | 76 - .../ridge/cicka/homeMutationPresentation.ts | 44 - .../scenes/ridge/cicka/homeMutations.test.ts | 100 - src/game/scenes/ridge/cicka/homeMutations.ts | 92 - .../scenes/ridge/cicka/interaction.test.ts | 63 - src/game/scenes/ridge/cicka/interaction.ts | 47 - src/game/scenes/ridge/cicka/walkBy.test.ts | 76 - src/game/scenes/ridge/cicka/walkBy.ts | 73 - .../ridge/runtime/ridgeDebugOverlay.test.ts | 100 - .../scenes/ridge/runtime/ridgeDebugOverlay.ts | 289 - .../runtime/ridgeInteractionTargets.test.ts | 124 - .../ridge/runtime/ridgeInteractionTargets.ts | 84 - .../runtime/ridgeLandmarkPresentation.test.ts | 62 - .../runtime/ridgeLandmarkPresentation.ts | 106 - .../ridge/runtime/ridgePresentationFacts.ts | 20 - .../runtime/ridgeTraversalRuntime.test.ts | 521 -- .../ridge/runtime/ridgeTraversalRuntime.ts | 513 -- .../ridge/runtime/traversalComfort.test.ts | 216 - .../scenes/ridge/runtime/traversalComfort.ts | 344 - src/game/scenes/ridge/sceneContext.ts | 5 +- src/game/scenes/ridge/worldLayout.test.ts | 126 - src/game/scenes/ridge/worldLayout.ts | 80 - src/game/scenes/ridge/worldMemory.test.ts | 48 - src/game/scenes/ridge/worldMemory.ts | 91 - 58 files changed, 90 insertions(+), 15658 deletions(-) delete mode 100644 scripts/generate-ridge-blockout-sources.mjs delete mode 100644 scripts/generate-ridge-blockout-sources.test.mjs delete mode 100644 src/game/scenes/ridge/blockout/facts.test.ts delete mode 100644 src/game/scenes/ridge/blockout/facts.ts delete mode 100644 src/game/scenes/ridge/blockout/geometry.test.ts delete mode 100644 src/game/scenes/ridge/blockout/geometry.ts delete mode 100644 src/game/scenes/ridge/blockout/index.ts delete mode 100644 src/game/scenes/ridge/blockout/parser.test.ts delete mode 100644 src/game/scenes/ridge/blockout/parser.ts delete mode 100644 src/game/scenes/ridge/blockout/progress.ts delete mode 100644 src/game/scenes/ridge/blockout/ridgeBlockout.ts delete mode 100644 src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts delete mode 100644 src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts delete mode 100644 src/game/scenes/ridge/blockout/sourceCompiler.test.ts delete mode 100644 src/game/scenes/ridge/blockout/sourceCompiler.ts delete mode 100644 src/game/scenes/ridge/blockout/sourceContract.ts delete mode 100644 src/game/scenes/ridge/blockout/sources/contract-fixture.generated.ts delete mode 100644 src/game/scenes/ridge/blockout/sources/contract-fixture.source.ts delete mode 100644 src/game/scenes/ridge/blockout/sources/folded-desk-ridge.generated.ts delete mode 100644 src/game/scenes/ridge/blockout/sources/folded-desk-ridge.source.ts delete mode 100644 src/game/scenes/ridge/cicka/CickaPerch.ts delete mode 100644 src/game/scenes/ridge/cicka/homeMutationPresentation.test.ts delete mode 100644 src/game/scenes/ridge/cicka/homeMutationPresentation.ts delete mode 100644 src/game/scenes/ridge/cicka/homeMutations.test.ts delete mode 100644 src/game/scenes/ridge/cicka/homeMutations.ts delete mode 100644 src/game/scenes/ridge/cicka/interaction.test.ts delete mode 100644 src/game/scenes/ridge/cicka/interaction.ts delete mode 100644 src/game/scenes/ridge/cicka/walkBy.test.ts delete mode 100644 src/game/scenes/ridge/cicka/walkBy.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeDebugOverlay.test.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeInteractionTargets.test.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeInteractionTargets.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.test.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgePresentationFacts.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeTraversalRuntime.test.ts delete mode 100644 src/game/scenes/ridge/runtime/ridgeTraversalRuntime.ts delete mode 100644 src/game/scenes/ridge/runtime/traversalComfort.test.ts delete mode 100644 src/game/scenes/ridge/runtime/traversalComfort.ts delete mode 100644 src/game/scenes/ridge/worldLayout.test.ts delete mode 100644 src/game/scenes/ridge/worldLayout.ts delete mode 100644 src/game/scenes/ridge/worldMemory.test.ts delete mode 100644 src/game/scenes/ridge/worldMemory.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 68355c84..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/**"] }, 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/health.json b/fallow-baselines/health.json index d3ae3e31..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 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/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 aa681f90..00000000 --- a/src/game/scenes/ridge/blockout/geometry.ts +++ /dev/null @@ -1,781 +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] - }; -} - -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 - }; -} - -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 f2d74393..00000000 --- a/src/game/scenes/ridge/blockout/parser.ts +++ /dev/null @@ -1,552 +0,0 @@ -const RIDGE_BLOCKOUT_RUNTIME_SYMBOLS = new Set([ - '.', - '#', - '_', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'C', - 'A', - '*', - '^', - '?' -]); - -const RIDGE_BLOCKOUT_DESIGN_SYMBOLS = new Set(['=', '~', 'N', 'M']); - -export { - RIDGE_BLOCKOUT_LADDER_SYMBOL, - RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS, - type RidgeBlockoutTraversalMovement -} from './ridgeBlockoutConstants'; -import { RIDGE_BLOCKOUT_LADDER_SYMBOL } from './ridgeBlockoutConstants'; -import { - appendDuplicateRidgeBlockoutRoomId, - appendRidgeBlockoutHeaderErrors, - appendRidgeBlockoutRoomAnchorErrors, - appendRidgeBlockoutRoomGridErrors, - appendRidgeBlockoutRouteErrors, - appendRidgeBlockoutRuntimeCellOverlapErrors, - appendRidgeBlockoutShortcutErrors, - appendRidgeBlockoutSpawnErrors -} from './ridgeBlockoutValidation'; - -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>; -} - -interface RidgeBlockoutRoomCore { - id: string; - title: string; - theme?: string; - mood?: string; -} - -export interface RidgeBlockoutRoom extends RidgeBlockoutRoomCore { - place: RidgeBlockoutPoint; - size: RidgeBlockoutSize; - 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 extends RidgeBlockoutRoomCore { - place?: RidgeBlockoutPoint; - size?: RidgeBlockoutSize; - 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 ' -]; - -interface RidgeBlockoutParseContext { - language: string; - cell: number; - worldId: string; - title: string; - spawn: RidgeBlockoutMap['spawn']; - routes: RidgeBlockoutRoute[]; - futureRoutes: RidgeBlockoutRoute[]; - shortcuts: RidgeBlockoutShortcut[]; - optionalPockets: RidgeBlockoutOptionalPocket[]; - homeMutations: RidgeBlockoutHomeMutation[]; - rooms: RidgeBlockoutRoom[]; - currentRoom?: MutableRoom; - readingGrid: boolean; -} - -function createRidgeBlockoutParseContext(): RidgeBlockoutParseContext { - return { - language: DEFAULT_MAP.language, - cell: DEFAULT_MAP.cell, - worldId: DEFAULT_MAP.worldId, - title: DEFAULT_MAP.title, - spawn: DEFAULT_MAP.spawn, - routes: [], - futureRoutes: [], - shortcuts: [], - optionalPockets: [], - homeMutations: [], - rooms: [], - readingGrid: false - }; -} - -function finishRidgeBlockoutRoom(ctx: RidgeBlockoutParseContext): void { - if (!ctx.currentRoom) return; - ctx.rooms.push({ - id: ctx.currentRoom.id, - title: ctx.currentRoom.title || ctx.currentRoom.id, - place: ctx.currentRoom.place ?? { x: 0, y: 0 }, - size: ctx.currentRoom.size ?? { width: 0, height: 0 }, - theme: ctx.currentRoom.theme, - mood: ctx.currentRoom.mood, - links: ctx.currentRoom.links, - props: ctx.currentRoom.props, - grid: ctx.currentRoom.grid, - anchors: ctx.currentRoom.anchors, - rects: ctx.currentRoom.rects, - declarations: ctx.currentRoom.declarations - }); - ctx.currentRoom = undefined; -} - -function startRidgeBlockoutRoom(ctx: RidgeBlockoutParseContext, line: string): void { - finishRidgeBlockoutRoom(ctx); - ctx.currentRoom = { - id: line.slice('room '.length).trim(), - title: '', - links: [], - props: [], - grid: [], - anchors: [], - rects: [], - declarations: [] - }; - ctx.readingGrid = false; -} - -function applyRidgeBlockoutMapHeaderLine(ctx: RidgeBlockoutParseContext, line: string): boolean { - if (line.startsWith('language ')) { - ctx.language = line.slice('language '.length).trim(); - return true; - } - if (line.startsWith('cell ')) { - ctx.cell = Number(line.slice('cell '.length).trim()); - return true; - } - if (line.startsWith('world ')) { - ctx.worldId = line.slice('world '.length).trim(); - return true; - } - if (line.startsWith('title ')) { - ctx.title = line.slice('title '.length).trim(); - return true; - } - if (line.startsWith('spawn ')) { - const attrs = parseAttrs(line.slice('spawn '.length).split(/\s+/)); - ctx.spawn = { - roomId: attrs.room ?? '', - anchorSymbol: attrs.anchor ?? '' - }; - return true; - } - return false; -} - -function applyRidgeBlockoutMapCollectionLine(ctx: RidgeBlockoutParseContext, line: string): boolean { - if (line.startsWith('route ')) { - ctx.routes.push(parseRoute(line, 'route')); - return true; - } - if (line.startsWith('future_route ')) { - ctx.futureRoutes.push(parseRoute(line, 'future_route')); - return true; - } - if (line.startsWith('shortcut ')) { - ctx.shortcuts.push(parseShortcut(line)); - return true; - } - if (line.startsWith('optional_pocket ')) { - ctx.optionalPockets.push(parseOptionalPocket(line)); - return true; - } - if (line.startsWith('home_mutation ')) { - ctx.homeMutations.push(parseHomeMutation(line)); - return true; - } - return false; -} - -function parseRidgeBlockoutMapLine(ctx: RidgeBlockoutParseContext, line: string): void { - if (applyRidgeBlockoutMapHeaderLine(ctx, line)) return; - applyRidgeBlockoutMapCollectionLine(ctx, line); -} - -function applyRidgeBlockoutRoomLayoutLine(room: MutableRoom, line: string): boolean { - if (line.startsWith('title ')) { - room.title = line.slice('title '.length).trim(); - return true; - } - if (line.startsWith('place ')) { - room.place = parsePoint(line.slice('place '.length)); - return true; - } - if (line.startsWith('size ')) { - room.size = parseSize(line.slice('size '.length).trim()); - return true; - } - if (line.startsWith('theme ')) { - room.theme = line.slice('theme '.length).trim(); - return true; - } - if (line.startsWith('mood ')) { - room.mood = line.slice('mood '.length).trim(); - return true; - } - return false; -} - -function applyRidgeBlockoutRoomContentLine(room: MutableRoom, line: string): boolean { - if (line.startsWith('links ')) { - room.links = line.slice('links '.length).split(/\s+/).filter(Boolean); - return true; - } - if (line.startsWith('props ')) { - room.props = line.slice('props '.length).split(',').map((prop) => prop.trim()).filter(Boolean); - return true; - } - if (line.startsWith('anchor ')) { - room.anchors.push(parseAnchor(line)); - return true; - } - if (line.startsWith('rect ')) { - room.rects.push(parseRect(line)); - return true; - } - return false; -} - -function applyRidgeBlockoutRoomScalarLine(room: MutableRoom, line: string): boolean { - return applyRidgeBlockoutRoomLayoutLine(room, line) || applyRidgeBlockoutRoomContentLine(room, line); -} - -function parseRidgeBlockoutRoomLine(ctx: RidgeBlockoutParseContext, line: string): void { - const room = ctx.currentRoom; - if (!room) return; - - if (ctx.readingGrid) { - room.grid.push(line); - return; - } - - if (line === 'grid') { - ctx.readingGrid = true; - return; - } - - if (applyRidgeBlockoutRoomScalarLine(room, line)) return; - - if (ROOM_DECLARATION_PREFIXES.some((prefix) => line.startsWith(prefix))) { - room.declarations.push(line); - } -} - -export function parseRidgeBlockout(source: string): RidgeBlockoutMap { - const ctx = createRidgeBlockoutParseContext(); - - normalizeLines(source).forEach((line) => { - if (!line) { - ctx.readingGrid = false; - return; - } - - if (line.startsWith('room ')) { - startRidgeBlockoutRoom(ctx, line); - return; - } - - if (!ctx.currentRoom) { - parseRidgeBlockoutMapLine(ctx, line); - return; - } - - parseRidgeBlockoutRoomLine(ctx, line); - }); - - finishRidgeBlockoutRoom(ctx); - - const mapWithoutErrors: RidgeBlockoutMap = { - language: ctx.language, - cell: ctx.cell, - worldId: ctx.worldId, - title: ctx.title, - spawn: ctx.spawn, - routes: ctx.routes, - futureRoutes: ctx.futureRoutes, - shortcuts: ctx.shortcuts, - optionalPockets: ctx.optionalPockets, - homeMutations: ctx.homeMutations, - rooms: ctx.rooms, - validationErrors: [] - }; - - return { - ...mapWithoutErrors, - validationErrors: validateRidgeBlockout(mapWithoutErrors) - }; -} - -function validateRidgeBlockout(map: RidgeBlockoutMap): readonly string[] { - const errors: string[] = []; - const roomIds = new Set(); - - appendRidgeBlockoutHeaderErrors(errors, map); - - map.rooms.forEach((room) => { - appendDuplicateRidgeBlockoutRoomId(room.id, roomIds, errors); - - appendRidgeBlockoutRoomGridErrors(room, errors, isKnownRidgeBlockoutSymbol); - appendRidgeBlockoutRoomAnchorErrors(room, errors, { - gridContainsSymbol: (symbol) => room.grid.some((row) => row.includes(symbol)) - }); - }); - - appendRidgeBlockoutSpawnErrors(errors, map.spawn, roomIds, (roomId) => - findRidgeBlockoutRoom(map, roomId) - ); - appendRidgeBlockoutRouteErrors(errors, [...map.routes, ...map.futureRoutes], roomIds); - appendRidgeBlockoutShortcutErrors(errors, map.shortcuts, roomIds); - appendRidgeBlockoutRuntimeCellOverlapErrors(errors, map.rooms, isRuntimeActiveRidgeBlockoutSymbol); - - 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); -} - -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 - ); -} 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 f4db3e2e..00000000 --- a/src/game/scenes/ridge/blockout/ridgeBlockout.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT } from './sources/folded-desk-ridge.generated'; - -export const RIDGE_BLOCKOUT = FOLDED_DESK_RIDGE_COMPILED_BLOCKOUT.map; diff --git a/src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts b/src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts deleted file mode 100644 index b3996b79..00000000 --- a/src/game/scenes/ridge/blockout/ridgeBlockoutConstants.ts +++ /dev/null @@ -1,14 +0,0 @@ -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'; diff --git a/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts b/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts deleted file mode 100644 index c4c11b8a..00000000 --- a/src/game/scenes/ridge/blockout/ridgeBlockoutValidation.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS } from './ridgeBlockoutConstants'; - -export interface RidgeBlockoutValidationHeader { - language: string; - cell: number; - worldId: string; - spawn: { - roomId: string; - anchorSymbol: string; - }; -} - -export interface RidgeBlockoutValidationRoom { - id: string; - size: { width: number; height: number }; - grid: readonly string[]; -} - -export interface RidgeBlockoutValidationAnchor { - symbol: string; - attrs: Readonly>; -} - -export interface RidgeBlockoutPlacedRoom extends RidgeBlockoutValidationRoom { - place: { x: number; y: number }; -} - -export function appendDuplicateRidgeBlockoutRoomId( - roomId: string, - roomIds: Set, - errors: string[] -): void { - if (roomIds.has(roomId)) { - errors.push(`duplicate room "${roomId}"`); - } - roomIds.add(roomId); -} - -export function appendRidgeBlockoutHeaderErrors( - errors: string[], - header: RidgeBlockoutValidationHeader -): void { - if (header.language !== 'ridge-v0') { - errors.push(`unsupported language "${header.language}"`); - } - if (!Number.isFinite(header.cell) || header.cell <= 0) { - errors.push(`invalid cell size "${header.cell}"`); - } - if (!header.worldId) { - errors.push('missing world id'); - } - if (!header.spawn.roomId || !header.spawn.anchorSymbol) { - errors.push('missing spawn room or anchor'); - } -} - -export function appendRidgeBlockoutRoomGridErrors( - room: RidgeBlockoutValidationRoom, - errors: string[], - isSymbolKnown: (symbol: string) => boolean -): 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) => { - const rowSymbols = [...row]; - if (rowSymbols.length !== room.size.width) { - errors.push( - `room "${room.id}" row ${rowIndex + 1} width ${rowSymbols.length} does not match size ${room.size.width}` - ); - } - rowSymbols.forEach((symbol, columnIndex) => { - if (!isSymbolKnown(symbol)) { - errors.push(`room "${room.id}" has unknown symbol "${symbol}" at ${columnIndex},${rowIndex}`); - } - }); - }); -} - -export function appendRidgeBlockoutRoomAnchorErrors( - room: RidgeBlockoutValidationRoom & { anchors: readonly RidgeBlockoutValidationAnchor[] }, - errors: string[], - options: { - gridContainsSymbol: (symbol: string) => boolean; - anchorSymbolErrors?: (anchor: RidgeBlockoutValidationAnchor) => readonly string[]; - } -): void { - room.anchors.forEach((anchor) => { - for (const message of options.anchorSymbolErrors?.(anchor) ?? []) { - errors.push(message); - } - if (!options.gridContainsSymbol(anchor.symbol)) { - errors.push(`room "${room.id}" anchor "${anchor.symbol}" has no matching grid cell`); - } - const movement = anchor.attrs.movement; - if (movement !== undefined && !RIDGE_BLOCKOUT_TRAVERSAL_MOVEMENTS.has(movement)) { - errors.push(`room "${room.id}" anchor "${anchor.symbol}" has unknown movement "${movement}"`); - } - }); -} - -export function appendRidgeBlockoutSpawnErrors( - errors: string[], - spawn: RidgeBlockoutValidationHeader['spawn'], - roomIds: ReadonlySet, - findRoom: (roomId: string) => { anchors: readonly { symbol: string }[] } | undefined -): void { - if (!roomIds.has(spawn.roomId)) { - errors.push(`spawn room "${spawn.roomId}" does not exist`); - return; - } - - const spawnRoom = findRoom(spawn.roomId); - const spawnAnchor = spawnRoom?.anchors.find((anchor) => anchor.symbol === spawn.anchorSymbol); - if (!spawnAnchor) { - errors.push(`spawn anchor "${spawn.anchorSymbol}" does not exist in room "${spawn.roomId}"`); - } -} - -export function appendRidgeBlockoutRouteErrors( - errors: string[], - routes: readonly { id: string; roomIds: readonly string[] }[], - roomIds: ReadonlySet -): void { - routes.forEach((route) => { - route.roomIds.forEach((roomId) => { - if (!roomIds.has(roomId)) { - errors.push(`route "${route.id}" references missing room "${roomId}"`); - } - }); - }); -} - -export function appendRidgeBlockoutShortcutErrors( - errors: string[], - shortcuts: readonly { id: string; fromRoomId: string; toRoomId: string }[], - roomIds: ReadonlySet -): void { - 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}"`); - } - }); -} - -export function appendRidgeBlockoutRuntimeCellOverlapErrors( - errors: string[], - rooms: readonly RidgeBlockoutPlacedRoom[], - isRuntimeActiveSymbol: (symbol: string) => boolean -): void { - const seen = new Map(); - - rooms.forEach((room) => { - room.grid.forEach((row, rowIndex) => { - [...row].forEach((symbol, columnIndex) => { - if (!isRuntimeActiveSymbol(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 }); - }); - }); - }); -} 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 c59c271f..00000000 --- a/src/game/scenes/ridge/blockout/sourceCompiler.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { RidgeBlockoutMap } from './parser'; -import { - appendDuplicateRidgeBlockoutRoomId, - appendRidgeBlockoutHeaderErrors, - appendRidgeBlockoutRoomAnchorErrors, - appendRidgeBlockoutRoomGridErrors, - appendRidgeBlockoutRouteErrors, - appendRidgeBlockoutRuntimeCellOverlapErrors, - appendRidgeBlockoutShortcutErrors, - appendRidgeBlockoutSpawnErrors -} from './ridgeBlockoutValidation'; -import type { - RidgeBlockoutSource, - RidgeBlockoutSourceAnchor, - RidgeBlockoutSourceRect, - RidgeBlockoutSourceRoom, - RidgeCompiledBlockout, - RidgeCompiledBlockoutRoom, - RidgeTileDefinition, - RidgeTileRegistry -} from './sourceContract'; - -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(); - - appendRidgeBlockoutHeaderErrors(errors, source); - - source.rooms.forEach((room) => { - appendDuplicateRidgeBlockoutRoomId(room.id, roomIds, errors); - - appendRidgeBlockoutRoomGridErrors(room, errors, (symbol) => tileLookup.bySymbol.has(symbol)); - appendRidgeBlockoutRoomAnchorErrors( - { - ...room, - anchors: (room.anchors ?? []).map((anchor) => ({ - symbol: anchor.symbol, - attrs: anchor.attrs ?? {} - })) - }, - errors, - { - gridContainsSymbol: (symbol) => room.grid.some((row) => [...row].includes(symbol)), - anchorSymbolErrors: (anchor) => - tileLookup.bySymbol.has(anchor.symbol) - ? [] - : [`room "${room.id}" anchor "${anchor.symbol}" uses an unknown tile symbol`] - } - ); - }); - - appendRidgeBlockoutSpawnErrors(errors, source.spawn, roomIds, (roomId) => { - const room = source.rooms.find((candidate) => candidate.id === roomId); - return room - ? { - anchors: (room.anchors ?? []).map((anchor) => ({ symbol: anchor.symbol })) - } - : undefined; - }); - appendRidgeBlockoutRouteErrors( - errors, - [...source.routes, ...(source.futureRoutes ?? [])], - roomIds - ); - appendRidgeBlockoutShortcutErrors(errors, source.shortcuts ?? [], roomIds); - appendRidgeBlockoutRuntimeCellOverlapErrors(errors, source.rooms, (symbol) => { - const tile = tileLookup.bySymbol.get(symbol); - return tile !== undefined && isRuntimeActiveTile(tile); - }); - - return errors; -} - -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 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'; -} 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 bb516def..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'; - -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/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/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 d89b884e..00000000 --- a/src/game/scenes/ridge/cicka/walkBy.ts +++ /dev/null @@ -1,73 +0,0 @@ -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/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 96d6c3eb..00000000 --- a/src/game/scenes/ridge/runtime/ridgeDebugOverlay.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { - 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; -} - -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; -} - -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/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 d89df77a..00000000 --- a/src/game/scenes/ridge/runtime/ridgeLandmarkPresentation.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - type RidgeAnchorFact, - type RidgeBlockoutAnchorSelector, - type RidgeBlockoutFacts -} from '../blockout'; -import { requireRidgeBlockoutFactAnchor } from './ridgePresentationFacts'; - -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 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' - ) - }; -} diff --git a/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts b/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts deleted file mode 100644 index cdbed19d..00000000 --- a/src/game/scenes/ridge/runtime/ridgePresentationFacts.ts +++ /dev/null @@ -1,20 +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; -} 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 bd136ada..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); -} - -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); -} From c5ecbe62d722c97b65911d938e6741a105b972be Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:44:07 +0200 Subject: [PATCH 7/9] refactor: split Potassium command adapter by domain Replace try/fail dispatch chains with focused session, combat, and boss modules so the adapter stays a thin facade without changing scene behavior. Co-authored-by: Cursor --- .../potassiumSlip/runtime/commandAdapter.ts | 846 +----------------- .../runtime/commandAdapterBoss.ts | 62 ++ .../runtime/commandAdapterCombat.ts | 371 ++++++++ .../runtime/commandAdapterInternals.ts | 126 +++ .../runtime/commandAdapterSession.ts | 81 ++ .../runtime/commandAdapterTypes.ts | 149 +++ 6 files changed, 837 insertions(+), 798 deletions(-) create mode 100644 src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts create mode 100644 src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts create mode 100644 src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts create mode 100644 src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts create mode 100644 src/game/scenes/potassiumSlip/runtime/commandAdapterTypes.ts diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts index 43cc5a72..f5457d6c 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapter.ts @@ -1,840 +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 { clamp, linear } from '@/shared/math'; -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) => this.applySessionCommand(command)); - } - - private applySessionCommand(command: PotassiumSessionCommand): void { - if (this.tryApplySessionBoardCommand(command)) return; - if (this.tryApplySessionWaveCommand(command)) return; - if (this.tryApplySessionHudCommand(command)) return; - this.applySessionFlowCommand(command); + applySessionCommandsModule(this.internals, commands); } - private tryApplySessionBoardCommand(command: PotassiumSessionCommand): boolean { - switch (command.type) { - case 'resetBoard': - this.ports.resetBoardObjects(); - return true; - case 'hideMainOverlay': - this.ports.hideMainOverlay(); - return true; - case 'clearTerminal': - this.ports.clearTerminalOverlay(); - return true; - case 'clearUpgradeChoices': - this.ports.clearUpgradeChoiceOverlay(); - return true; - case 'stopBanana': - this.ports.stopMainProjectile(); - return true; - case 'clearBoardForOutcome': - this.ports.clearBoardForOutcome(); - return true; - default: - return false; - } + getEnemyCombatFacts(enemy: PotassiumCommandObject) { + return this.internals.getEnemyCombatFacts(enemy); } - private tryApplySessionWaveCommand(command: PotassiumSessionCommand): boolean { - switch (command.type) { - case 'spawnWave': - this.ports.spawnWave(command.wave); - return true; - case 'spawnBoss': - this.ports.spawnBossDelayed(); - return true; - case 'scheduleWaveRows': - this.ports.scheduleWaveRows(command.schedule); - return true; - case 'scheduleUpgradeChoices': - this.ports.scheduleUpgradeChoices(); - return true; - case 'advanceWaveAfterDelay': - this.ports.advanceWaveAfterDelay(command.wave); - return true; - case 'refreshProjectileVisuals': - this.ports.refreshAllProjectileVisuals(); - return true; - default: - return false; - } + getProjectileCombatFacts(projectile: PotassiumCommandObject) { + return this.internals.getProjectileCombatFacts(projectile); } - private tryApplySessionHudCommand(command: PotassiumSessionCommand): boolean { - switch (command.type) { - case 'setHint': - this.ports.setHint(command.text); - return true; - case 'updateHud': - this.ports.updateHud(command.hud); - return true; - case 'collectCircuit': - this.ports.collectCircuit(); - return true; - case 'saveRunRecord': - this.ports.saveRunRecord(command.record); - return true; - default: - return false; - } - } - - private applySessionFlowCommand(command: PotassiumSessionCommand): void { - switch (command.type) { - case 'showUpgradeChoices': - this.ports.showUpgradeChoices(command.choices); - break; - case 'showOutcome': - this.ports.showOutcomeOverlay(command); - break; - case 'showTerminal': - this.ports.showTerminal(command.outcome); - break; - case 'closeScene': - this.ports.closeScene(); - break; - default: - break; - } - } - - 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 - }; + 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() - ): void { - commands.forEach((command) => this.applyCombatCommand(command, context)); - } - - private applyCombatCommand(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 (this.tryApplyCombatEnemyCommand(command, enemy)) return; - if (this.tryApplyCombatProjectileCommand(command, projectile, enemy)) return; - this.applyCombatWorldCommand(command, context, enemy); - } - - private tryApplyCombatEnemyCommand( - command: PotassiumCombatCommand, - enemy: PotassiumCommandObject | undefined - ): boolean { - if (this.tryApplyCombatEnemyDamageCommand(command, enemy)) return true; - if (this.tryApplyCombatEnemyStatusCommand(command, enemy)) return true; - return this.tryApplyCombatEnemyLifecycleCommand(command, enemy); - } - - private tryApplyCombatEnemyDamageCommand( - command: PotassiumCombatCommand, - enemy: PotassiumCommandObject | undefined - ): boolean { - switch (command.type) { - case 'damageEnemy': - if (enemy) this.damageEnemy(enemy, command.amount, command.source); - return true; - case 'setEnemyHp': - if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); - return true; - case 'showDamageCue': - if (enemy) this.ports.showDamageCue(enemy, command.source); - return true; - default: - return false; - } - } - - private tryApplyCombatEnemyStatusCommand( - 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) this.applyPoisonCommand(enemy, command); - return true; - case 'clearPoison': - if (enemy) this.clearPoison(enemy); - return true; - default: - return false; - } - } - - private tryApplyCombatEnemyLifecycleCommand( - command: PotassiumCombatCommand, - enemy: PotassiumCommandObject | undefined - ): boolean { - switch (command.type) { - case 'spawnSplitterChildren': - if (enemy) this.ports.spawnSplitterChildren(enemy); - return true; - case 'killEnemy': - if (enemy) this.killEnemy(enemy); - return true; - default: - return false; - } - } - - private tryApplyCombatProjectileCommand( - command: PotassiumCombatCommand, - projectile: PotassiumCommandObject | undefined, - enemy: PotassiumCommandObject | undefined - ): boolean { - switch (command.type) { - case 'ricochetProjectile': - if (projectile && enemy) this.ricochetProjectileFromEnemy(projectile, enemy); - return true; - case 'boostRecallVelocity': - projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); - return true; - default: - return false; - } - } - - private applyCombatWorldCommand( - command: PotassiumCombatCommand, - context: PotassiumCombatContext, - sourceEnemy: PotassiumCommandObject | undefined + context?: PotassiumCombatContext ): void { - switch (command.type) { - case 'spawnFirePatch': - this.ports.spawnFirePatch( - command.x, - command.y, - command.effectMultiplier, - command.lifetimeMs, - command.scale - ); - break; - case 'explodeAt': - this.explodeAt(command.x, command.y, command.effectMultiplier, command.radiusMultiplier); - break; - case 'spawnBananaClones': - this.ports.spawnBananaClones(command.count, command.lifetimeMs); - break; - case 'spawnGhostBeam': - this.spawnGhostBeam(command.x, command.y, command.direction, command.effectMultiplier); - break; - case 'spawnGhostStatusField': - this.spawnGhostStatusField(command.x, command.y, command.direction, command.effectMultiplier); - break; - case 'spreadPoisonFrom': - this.spreadPoisonFrom( - command.x, - command.y, - command.effectMultiplier, - sourceEnemy ?? context.enemies.get(command.sourceEnemyId) - ); - break; - default: - break; - } + 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) => this.applyBossCommand(command, boss)); - } - - private applyBossCommand(command: PotassiumBossCommand, boss?: PotassiumCommandObject): void { - if (this.tryApplyBossMotionCommand(command, boss)) return; - if (this.tryApplyBossPresentationCommand(command, boss)) return; - this.applyBossSpawnCommand(command, boss); - } - - private tryApplyBossMotionCommand( - 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) { - if (command.x !== undefined) boss.setX(command.x); - if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); - boss.setVelocityY(command.velocityY); - } - 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; - } - } - - private tryApplyBossPresentationCommand( - command: PotassiumBossCommand, - boss?: PotassiumCommandObject - ): boolean { - switch (command.type) { - case 'setBossHint': - this.ports.setHint(command.text); - return true; - case 'shakeCamera': - this.ports.shakeCamera(command.durationMs, command.intensity); - return true; - case 'setStoneVisual': - if (boss) this.ports.setBossStoneVisual(boss, command.active); - return true; - case 'clearOrbitBlockers': - this.ports.clearOrbitBlockers(); - return true; - default: - return false; - } - } - - private applyBossSpawnCommand(command: PotassiumBossCommand, boss?: PotassiumCommandObject): void { - switch (command.type) { - case 'spawnOrbitBlockers': - if (boss) this.ports.spawnBossOrbitBlockers(boss); - break; - case 'updateOrbitBlockers': - if (boss) this.ports.updateBossOrbitBlockers(boss, command.now); - break; - case 'spawnSummons': - this.ports.spawnBossSummons(command.summons); - break; - default: - break; - } - } - - 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); - } - } - - 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 - }); - } - }); + applyBossCommandsModule(this.internals, commands, boss); } - - 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 }); -} - diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts new file mode 100644 index 00000000..a2a39b24 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts @@ -0,0 +1,62 @@ +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 { + const { ports } = internals; + + switch (command.type) { + case 'setBossPhase': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.bossPhase, command.phase); + return; + case 'setBossVelocity': + if (boss) { + if (command.x !== undefined) boss.setX(command.x); + if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); + boss.setVelocityY(command.velocityY); + } + return; + case 'startStone': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, command.stoneUntil); + return; + case 'endStone': + if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, undefined); + return; + case 'setBossHint': + ports.setHint(command.text); + return; + case 'shakeCamera': + ports.shakeCamera(command.durationMs, command.intensity); + return; + case 'setStoneVisual': + if (boss) ports.setBossStoneVisual(boss, command.active); + return; + case 'clearOrbitBlockers': + ports.clearOrbitBlockers(); + return; + case 'spawnOrbitBlockers': + if (boss) ports.spawnBossOrbitBlockers(boss); + return; + case 'updateOrbitBlockers': + if (boss) ports.updateBossOrbitBlockers(boss, command.now); + return; + case 'spawnSummons': + ports.spawnBossSummons(command.summons); + return; + 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..abe87485 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts @@ -0,0 +1,371 @@ +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; + + switch (command.type) { + case 'damageEnemy': + if (enemy) damageEnemy(internals, enemy, command.amount, command.source); + return; + case 'setEnemyHp': + if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); + return; + case 'showDamageCue': + if (enemy) internals.ports.showDamageCue(enemy, command.source); + return; + case 'setFireTickUntil': + if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.fireTickUntil, command.fireTickUntil); + return; + case 'setPoisonNextTickAt': + if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.poisonNextTickAt); + return; + case 'applyPoison': + if (enemy) applyPoisonCommand(internals, enemy, command); + return; + case 'clearPoison': + if (enemy) clearPoison(enemy); + return; + case 'spawnSplitterChildren': + if (enemy) internals.ports.spawnSplitterChildren(enemy); + return; + case 'killEnemy': + if (enemy) killEnemy(internals, enemy); + return; + case 'ricochetProjectile': + if (projectile && enemy) ricochetProjectileFromEnemy(internals, projectile, enemy); + return; + case 'boostRecallVelocity': + projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); + return; + case 'spawnFirePatch': + internals.ports.spawnFirePatch( + command.x, + command.y, + command.effectMultiplier, + command.lifetimeMs, + command.scale + ); + return; + case 'explodeAt': + explodeAt(internals, command.x, command.y, command.effectMultiplier, command.radiusMultiplier); + return; + case 'spawnBananaClones': + internals.ports.spawnBananaClones(command.count, command.lifetimeMs); + return; + case 'spawnGhostBeam': + spawnGhostBeam(internals, command.x, command.y, command.direction, command.effectMultiplier); + return; + case 'spawnGhostStatusField': + spawnGhostStatusField(internals, command.x, command.y, command.direction, command.effectMultiplier); + return; + case 'spreadPoisonFrom': + spreadPoisonFrom( + internals, + command.x, + command.y, + command.effectMultiplier, + enemy ?? context.enemies.get(command.sourceEnemyId) + ); + return; + 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 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 = ports.isMainProjectile(projectile) + ? ports.getMaxMainProjectileSpeed() + : options.cloneRicochetMaxSpeed; + const speed = clamp( + Math.max(currentSpeed * options.bananaRicochetBoost, options.bananaRicochetMinSpeed), + options.bananaRicochetMinSpeed, + maxSpeed + ); + const ricochetNormal = normalize(ricochet); + ricochet = { + x: ricochetNormal.x * speed, + y: ricochetNormal.y * speed + }; + + 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?.(); + 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 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..9499325d --- /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 + }; + } + + 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..c726c408 --- /dev/null +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts @@ -0,0 +1,81 @@ +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 { + const { ports } = internals; + + switch (command.type) { + case 'resetBoard': + ports.resetBoardObjects(); + return; + case 'hideMainOverlay': + ports.hideMainOverlay(); + return; + case 'clearTerminal': + ports.clearTerminalOverlay(); + return; + case 'clearUpgradeChoices': + ports.clearUpgradeChoiceOverlay(); + return; + case 'stopBanana': + ports.stopMainProjectile(); + return; + case 'clearBoardForOutcome': + ports.clearBoardForOutcome(); + return; + case 'spawnWave': + ports.spawnWave(command.wave); + return; + case 'spawnBoss': + ports.spawnBossDelayed(); + return; + case 'scheduleWaveRows': + ports.scheduleWaveRows(command.schedule); + return; + case 'scheduleUpgradeChoices': + ports.scheduleUpgradeChoices(); + return; + case 'advanceWaveAfterDelay': + ports.advanceWaveAfterDelay(command.wave); + return; + case 'refreshProjectileVisuals': + ports.refreshAllProjectileVisuals(); + return; + case 'setHint': + ports.setHint(command.text); + return; + case 'updateHud': + ports.updateHud(command.hud); + return; + case 'collectCircuit': + ports.collectCircuit(); + return; + case 'saveRunRecord': + ports.saveRunRecord(command.record); + return; + case 'showUpgradeChoices': + ports.showUpgradeChoices(command.choices); + return; + case 'showOutcome': + ports.showOutcomeOverlay(command); + return; + case 'showTerminal': + ports.showTerminal(command.outcome); + return; + case 'closeScene': + ports.closeScene(); + return; + 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; +} From 91b9859b3f7b5c525c3dc476c5c20aabbcedc8cb Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:49:44 +0200 Subject: [PATCH 8/9] fix: satisfy Fallow complexity on command adapter split Shard large switch handlers and extract ricochet math so changed-file audit stays under cyclomatic/cognitive thresholds. Co-authored-by: Cursor --- .../runtime/commandAdapterBoss.ts | 67 +++++-- .../runtime/commandAdapterCombat.ts | 188 +++++++++++++----- .../runtime/commandAdapterSession.ts | 84 ++++++-- 3 files changed, 253 insertions(+), 86 deletions(-) diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts index a2a39b24..b3010a25 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterBoss.ts @@ -16,46 +16,79 @@ function applyBossCommand( command: PotassiumBossCommand, boss?: PotassiumCommandObject ): void { - const { ports } = internals; + 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; + return true; case 'setBossVelocity': - if (boss) { - if (command.x !== undefined) boss.setX(command.x); - if (command.velocityX !== undefined) boss.setVelocityX(command.velocityX); - boss.setVelocityY(command.velocityY); - } - return; + if (boss) applyBossVelocity(boss, command); + return true; case 'startStone': if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, command.stoneUntil); - return; + return true; case 'endStone': if (boss) setPotassiumData(boss, POTASSIUM_DATA_KEYS.stoneUntil, undefined); - return; + 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; + return true; case 'shakeCamera': ports.shakeCamera(command.durationMs, command.intensity); - return; + return true; case 'setStoneVisual': if (boss) ports.setBossStoneVisual(boss, command.active); - return; + return true; case 'clearOrbitBlockers': ports.clearOrbitBlockers(); - return; + 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); - return; + break; case 'updateOrbitBlockers': if (boss) ports.updateBossOrbitBlockers(boss, command.now); - return; + break; case 'spawnSummons': ports.spawnBossSummons(command.summons); - return; + break; default: break; } diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts index abe87485..b2b690dc 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts @@ -62,40 +62,106 @@ function applyCombatCommand( ? 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; + return true; case 'setEnemyHp': if (enemy) setPotassiumEnemyHealth(enemy, command.hp, command.damageState); - return; + return true; case 'showDamageCue': if (enemy) internals.ports.showDamageCue(enemy, command.source); - return; + 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; + return true; case 'setPoisonNextTickAt': if (enemy) setPotassiumData(enemy, POTASSIUM_DATA_KEYS.poisonNextTickAt, command.poisonNextTickAt); - return; + return true; case 'applyPoison': if (enemy) applyPoisonCommand(internals, enemy, command); - return; + return true; case 'clearPoison': if (enemy) clearPoison(enemy); - return; + 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; + return true; case 'killEnemy': if (enemy) killEnemy(internals, enemy); - return; + 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; + return true; case 'boostRecallVelocity': projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); - return; + 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, @@ -104,28 +170,28 @@ function applyCombatCommand( command.lifetimeMs, command.scale ); - return; + break; case 'explodeAt': explodeAt(internals, command.x, command.y, command.effectMultiplier, command.radiusMultiplier); - return; + break; case 'spawnBananaClones': internals.ports.spawnBananaClones(command.count, command.lifetimeMs); - return; + break; case 'spawnGhostBeam': spawnGhostBeam(internals, command.x, command.y, command.direction, command.effectMultiplier); - return; + break; case 'spawnGhostStatusField': spawnGhostStatusField(internals, command.x, command.y, command.direction, command.effectMultiplier); - return; + break; case 'spreadPoisonFrom': spreadPoisonFrom( internals, command.x, command.y, command.effectMultiplier, - enemy ?? context.enemies.get(command.sourceEnemyId) + sourceEnemy ?? context.enemies.get(command.sourceEnemyId) ); - return; + break; default: break; } @@ -140,40 +206,16 @@ function ricochetProjectileFromEnemy( const { options, ports } = internals; 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 normal = collisionNormal(projectile, enemy, velocity); const maxSpeed = ports.isMainProjectile(projectile) ? ports.getMaxMainProjectileSpeed() : options.cloneRicochetMaxSpeed; - const speed = clamp( - Math.max(currentSpeed * options.bananaRicochetBoost, options.bananaRicochetMinSpeed), - options.bananaRicochetMinSpeed, - maxSpeed + const ricochet = scaledRicochetVelocity( + reflectVelocity(velocity, normal), + vectorLength(velocity), + maxSpeed, + options ); - const ricochetNormal = normalize(ricochet); - ricochet = { - x: ricochetNormal.x * speed, - y: ricochetNormal.y * speed - }; projectile.setPosition( clamp(projectile.x + normal.x * 8, options.arena.left + 28, options.arena.right - 28), @@ -348,6 +390,56 @@ function spawnGhostStatusField( } } +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)); } diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts index c726c408..473b7e93 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterSession.ts @@ -12,69 +12,111 @@ function applySessionCommand( internals: PotassiumCommandAdapterInternals, command: PotassiumSessionCommand ): void { - const { ports } = internals; + 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; + return true; case 'hideMainOverlay': ports.hideMainOverlay(); - return; + return true; case 'clearTerminal': ports.clearTerminalOverlay(); - return; + return true; case 'clearUpgradeChoices': ports.clearUpgradeChoiceOverlay(); - return; + return true; case 'stopBanana': ports.stopMainProjectile(); - return; + return true; case 'clearBoardForOutcome': ports.clearBoardForOutcome(); - return; + 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; + return true; case 'spawnBoss': ports.spawnBossDelayed(); - return; + return true; case 'scheduleWaveRows': ports.scheduleWaveRows(command.schedule); - return; + return true; case 'scheduleUpgradeChoices': ports.scheduleUpgradeChoices(); - return; + return true; case 'advanceWaveAfterDelay': ports.advanceWaveAfterDelay(command.wave); - return; + return true; case 'refreshProjectileVisuals': ports.refreshAllProjectileVisuals(); - return; + 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; + return true; case 'updateHud': ports.updateHud(command.hud); - return; + return true; case 'collectCircuit': ports.collectCircuit(); - return; + return true; case 'saveRunRecord': ports.saveRunRecord(command.record); - return; + return true; + default: + return false; + } +} + +function applySessionFlowCommand( + internals: PotassiumCommandAdapterInternals, + command: PotassiumSessionCommand +): void { + const { ports } = internals; + switch (command.type) { case 'showUpgradeChoices': ports.showUpgradeChoices(command.choices); - return; + break; case 'showOutcome': ports.showOutcomeOverlay(command); - return; + break; case 'showTerminal': ports.showTerminal(command.outcome); - return; + break; case 'closeScene': ports.closeScene(); - return; + break; default: break; } From 7ef8b8f844db089916b5abcaa3facea98eb40426 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 2 Jun 2026 21:58:47 +0200 Subject: [PATCH 9/9] fix: guard Potassium adapter physics body access Avoid TypeErrors when boss, enemy, or projectile bodies are missing during kill, recall boost, and boss fact reads. Co-authored-by: Cursor --- .../scenes/potassiumSlip/runtime/commandAdapterCombat.ts | 6 ++++-- .../scenes/potassiumSlip/runtime/commandAdapterInternals.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts index b2b690dc..ab59f206 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterCombat.ts @@ -148,7 +148,9 @@ function applyCombatProjectileCommand( if (projectile && enemy) ricochetProjectileFromEnemy(internals, projectile, enemy); return true; case 'boostRecallVelocity': - projectile?.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); + if (projectile?.body) { + projectile.setVelocity(projectile.body.velocity.x * 1.01, projectile.body.velocity.y * 1.01); + } return true; default: return false; @@ -290,7 +292,7 @@ function killEnemy(internals: PotassiumCommandAdapterInternals, enemy: Potassium setPotassiumEnemyDying(enemy, true); const damageCueTween = getPotassiumData<{ stop?: () => void }>(enemy, POTASSIUM_DATA_KEYS.damageCueTween); damageCueTween?.stop?.(); - enemy.body.enable = false; + if (enemy.body) enemy.body.enable = false; const killResult = resolvePotassiumEnemyKilled(internals.ports.getSession(), config.score, kind); if (kind !== 'boss') { internals.delegates.applySessionResult(killResult); diff --git a/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts b/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts index 9499325d..d34960ba 100644 --- a/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts +++ b/src/game/scenes/potassiumSlip/runtime/commandAdapterInternals.ts @@ -107,7 +107,7 @@ export class PotassiumCommandAdapterInternals { maxHp: getPotassiumEnemyMaxHp(boss), x: boss.x, y: boss.y, - velocityX: boss.body.velocity.x + velocityX: boss.body?.velocity?.x ?? 0 }; }