diff --git a/CONTEXT.md b/CONTEXT.md index 7d94f912..e7d59568 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,6 +43,30 @@ Ridge Stage Composition Source. It is not the legacy Ridge Blockout Viewer or a final art map editor. _Avoid_: Ridge Blockout Viewer, final art editor, generic map editor +**Stage Authoring Draft**: +Ephemeral in-debugger adjustments to Ridge Stage Composition Source spatial +data. A draft updates the live preview immediately, shows copyable typed-source +snippets for review, and is applied to git only when Danilo or an agent pastes +the change into the Ridge Stage Composition Source. It is not a second persisted +editor format or runtime data store. +_Avoid_: map editor save file, localStorage canonical source, auto-patched source file + +**Stage Authoring Mode**: +An explicit Ridge Stage Debugger state for spatial tweaking. While active, player +movement is frozen, all source-backed Walk Rail points, Stage Spots, and Stage +Object contact markers are shown regardless of route-beat visibility, preview +clicks select draft targets, and the sidebar exposes nudge controls plus +copyable typed-source replacement snippets. Outside this mode, the debugger +keeps its normal live-preview QA behavior. +_Avoid_: always-on pick mode, modifier-only pick mode, beat-filtered edit targets, separate map editor app + +**Stage Authoring Camera**: +The preview camera behavior used during Stage Authoring Mode. It stops following +the frozen player, scrolls to center the selected spatial target when selection +changes, and allows free panning across the authored stage canvas so distant +rail points, spots, and objects remain easy to inspect. +_Avoid_: player-follow camera, locked-on-target camera with no pan, separate editor viewport + **Grid Cell**: A configurable unit in the typed Ridge Blockout Source that converts text-grid positions into world-space pixels. _Avoid_: hard-coded tile size, permanent pixel size diff --git a/fallow-baselines/dupes.json b/fallow-baselines/dupes.json index 2c263025..c059bf6c 100644 --- a/fallow-baselines/dupes.json +++ b/fallow-baselines/dupes.json @@ -21,10 +21,6 @@ "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/waves.test.ts:175-182|src/game/scenes/potassiumSlip/runtime/waves.test.ts:55-62", - "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/stampedeSketch/runtime/rewards.test.ts:16-22|src/game/scenes/stampedeSketch/runtime/rewards.test.ts:24-30", "src/game/scenes/stampedeSketch/runtime/runFlow.test.ts:121-129|src/game/scenes/stampedeSketch/runtime/runFlow.test.ts:94-102", "src/game/scenes/stampedeSketch/runtime/runFlow.test.ts:261-271|src/game/scenes/stampedeSketch/runtime/runFlow.test.ts:97-103", diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 72782435..c643ac46 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -14,7 +14,7 @@ } }, "src/dev/ridgeStageDebugger/RidgeRuntimePreview.tsx": { - "complexity_moderate": { + "complexity_high": { "count": 1 } }, @@ -23,6 +23,11 @@ "count": 1 } }, + "src/dev/ridgeStageDebugger/hooks/useStageAuthoring.ts": { + "crap_high": { + "count": 1 + } + }, "src/game/bridge/store.ts": { "crap_moderate": { "count": 2 @@ -90,12 +95,23 @@ "count": 1 } }, - "src/game/scenes/ridge/bridge/BridgeWalkRailPlayerRuntime.ts": { - "crap_moderate": { + "src/game/scenes/ridge/bridge/bridgeStageDebugOverlay.ts": { + "complexity_moderate": { + "count": 1 + } + }, + "src/game/scenes/ridge/bridge/stageAuthoring.ts": { + "complexity_high": { + "count": 1 + }, + "crap_high": { "count": 1 } }, "src/game/scenes/ridge/runtime/RidgeScene.ts": { + "crap_high": { + "count": 2 + }, "crap_moderate": { "count": 3 } @@ -118,11 +134,6 @@ "count": 1 } }, - "src/game/sharedSceneRuntime/player/SideViewPlayerRuntime.ts": { - "crap_high": { - "count": 1 - } - }, "src/game/shell/usePhaserGameBoot.ts": { "crap_moderate": { "count": 1 diff --git a/src/dev/ridgeStageDebugger/RidgeRuntimePreview.tsx b/src/dev/ridgeStageDebugger/RidgeRuntimePreview.tsx index 2481dccd..e83b39f1 100644 --- a/src/dev/ridgeStageDebugger/RidgeRuntimePreview.tsx +++ b/src/dev/ridgeStageDebugger/RidgeRuntimePreview.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { resolveRidgePreviewStatus } from './resolveRidgePreviewStatus'; import Game from '@/game/shell/Game'; import { bridgeActions, useBridgeState } from '@/game/bridge/store'; import { OverlayHost } from '@/game/overlays/OverlayHost'; @@ -12,12 +13,14 @@ function ignorePreviewSceneClose(): void {} export type RidgePreviewInputOwner = 'game' | 'panel'; export function RidgeRuntimePreview({ + authoringActive = false, inputOwner, isActive, onClaimGameInput, previewZoom, ridgeDevControls }: { + authoringActive?: boolean; inputOwner: RidgePreviewInputOwner; isActive: boolean; onClaimGameInput: () => void; @@ -26,22 +29,25 @@ export function RidgeRuntimePreview({ }) { const bridge = useBridgeState(); const previewRef = useRef(null); - const isInputPaused = - !isActive || bridge.isPaused || bridge.loadingSceneId !== null || inputOwner === 'panel'; - const inputStatusLabel = !isActive - ? 'Debugger inactive' - : inputOwner === 'panel' - ? 'Panel focus' - : bridge.isPaused || bridge.loadingSceneId !== null - ? 'Runtime paused' - : 'Game input'; - const pauseReason = !isActive - ? 'Debugger inactive' - : inputOwner === 'panel' - ? 'Panel focus' - : 'Runtime paused'; - const showPausedOverlay = - isActive && isInputPaused && bridge.loadingSceneId === null && bridge.activeOverlayId === null; + const previewStatus = useMemo( + () => resolveRidgePreviewStatus({ + activeOverlayId: bridge.activeOverlayId, + authoringActive, + inputOwner, + isActive, + isPaused: bridge.isPaused, + loadingSceneId: bridge.loadingSceneId + }), + [ + authoringActive, + bridge.activeOverlayId, + bridge.isPaused, + bridge.loadingSceneId, + inputOwner, + isActive + ] + ); + const { inputStatusLabel, isInputPaused, pauseReason, showPausedOverlay } = previewStatus; const presentationMode = getPhaserScenePresentationMode(bridge.activeSceneId); const isRidgeSceneActive = bridge.activeSceneId === RIDGE_SCENE_ID; @@ -84,6 +90,7 @@ export function RidgeRuntimePreview({ aria-hidden={!isActive} className={[ 'relative h-full min-h-0 min-w-0 overflow-hidden border-4 border-[#1a1a1a] bg-[#fbfbf9] shadow-[7px_7px_0_rgba(26,26,26,1)]', + authoringActive ? 'cursor-grab active:cursor-grabbing' : '', isActive ? '' : 'invisible pointer-events-none' ].join(' ')} data-testid="ridge-runtime-preview" diff --git a/src/dev/ridgeStageDebugger/RidgeStageDebugger.test.tsx b/src/dev/ridgeStageDebugger/RidgeStageDebugger.test.tsx index 0fa863c5..877d84bc 100644 --- a/src/dev/ridgeStageDebugger/RidgeStageDebugger.test.tsx +++ b/src/dev/ridgeStageDebugger/RidgeStageDebugger.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bridgeActions, bridgeStore } from '@/game/bridge/store'; @@ -148,6 +148,51 @@ describe('RidgeStageDebugger', () => { }); }); + it('exposes Stage Authoring controls and draft source wiring', async () => { + render(); + + expect(screen.getByLabelText('Stage Authoring Mode')).toHaveProperty('checked', false); + expect(lastRidgeDevControls?.resolveAuthoringState?.()).toEqual({ + active: false, + selection: null + }); + expect(lastRidgeDevControls?.resolveCompositionSource?.()).toBeUndefined(); + + await userEvent.click(screen.getByLabelText('Stage Authoring Mode')); + + expect(screen.getByLabelText('Stage Authoring Mode')).toHaveProperty('checked', true); + expect(lastRidgeDevControls?.resolveAuthoringState?.()).toEqual({ + active: true, + selection: null + }); + expect(lastRidgeDevControls?.resolveCompositionSource?.()?.id).toBe('bridge'); + expect(lastRidgeDevControls?.resolveDebugSettings?.()).toMatchObject({ + showTraversalAssists: true + }); + }); + + it('updates the authoring draft and snippet from sidebar field edits', async () => { + render(); + + await userEvent.click(screen.getByLabelText('Stage Authoring Mode')); + await userEvent.selectOptions( + screen.getByLabelText('Authoring target'), + 'Spot draftsperson' + ); + + expect(screen.getByText('Stage Spot draftsperson')).not.toBeNull(); + + const offsetYInput = screen.getByLabelText('offset.y'); + fireEvent.change(offsetYInput, { target: { value: '-6' } }); + + expect(lastRidgeDevControls?.resolveCompositionSource?.()?.spots.find((spot) => spot.id === 'draftsperson')?.offset) + .toEqual({ x: 0, y: -6 }); + expect(screen.getByLabelText('Copyable Ridge Stage Source replacement snippet').textContent) + .toContain("id: 'draftsperson'"); + expect(screen.getByLabelText('Copyable Ridge Stage Source replacement snippet').textContent) + .toContain('offset: { x: 0, y: -6 }'); + }); + it('renders the latest rail snapshot and copyable source snippet', () => { render(); diff --git a/src/dev/ridgeStageDebugger/RidgeStageDebugger.tsx b/src/dev/ridgeStageDebugger/RidgeStageDebugger.tsx index ff0af63b..76ac5591 100644 --- a/src/dev/ridgeStageDebugger/RidgeStageDebugger.tsx +++ b/src/dev/ridgeStageDebugger/RidgeStageDebugger.tsx @@ -1,5 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; import { Bug, Crosshair, @@ -32,7 +31,9 @@ import { import { IconButton } from './components/IconButton'; import { Detail } from './components/Detail'; import { PREVIEW_ZOOM_OPTIONS } from './constants'; +import { AuthoringPanel } from './components/AuthoringPanel'; import { useRidgePreviewControls } from './hooks/useRidgePreviewControls'; +import { useStageAuthoring } from './hooks/useStageAuthoring'; const BRIDGE_BEAT_OPTIONS = [ { id: 'intro', label: 'Intro' }, @@ -61,6 +62,7 @@ export default function RidgeStageDebugger() { const bridge = useBridgeState(); const [previewInputOwner, setPreviewInputOwner] = useState('game'); const previewControls = useRidgePreviewControls(); + const authoring = useStageAuthoring(); const claimPanelInput = useCallback(() => setPreviewInputOwner('panel'), []); const claimGameInput = useCallback(() => setPreviewInputOwner('game'), []); const routeState = bridge.progress.ridge.firstPlayableRoute; @@ -74,6 +76,29 @@ export default function RidgeStageDebugger() { return next; }, [resolvedSpots]); + const ridgeDevControls = useMemo(() => ({ + ...previewControls.ridgeDevControls, + resolveCompositionSource: authoring.resolveCompositionSource, + resolveAuthoringState: () => ({ + active: authoring.active, + selection: authoring.selection + }), + publishAuthoringPick: authoring.handlePick, + publishAuthoringDrag: authoring.handleDrag, + resolveDebugSettings: () => ({ + ...previewControls.debugSettings, + showTraversalAssists: previewControls.debugSettings.showTraversalAssists || authoring.active + }) + }), [ + authoring.active, + authoring.handleDrag, + authoring.handlePick, + authoring.resolveCompositionSource, + authoring.selection, + previewControls.debugSettings, + previewControls.ridgeDevControls + ]); + return (
@@ -101,11 +126,12 @@ export default function RidgeStageDebugger() {
@@ -114,6 +140,23 @@ export default function RidgeStageDebugger() { onFocusCapture={claimPanelInput} onPointerDownCapture={claimPanelInput} > + { + authoring.setAuthoringActive(nextActive); + if (nextActive) claimGameInput(); + }} + onUpdateField={authoring.updateField} + selection={authoring.selection} + selectionLabel={authoring.selectionLabel} + snippet={authoring.snippet} + targetOptions={authoring.targetOptions} + /> void; + onResetSelection: () => void; + onSelectTarget: (selection: StageAuthoringSelection) => void; + onToggleActive: (active: boolean) => void; + onUpdateField: (field: string, value: number) => void; + selection: StageAuthoringSelection | null; + selectionLabel: string; + snippet: string; + targetOptions: readonly StageAuthoringTargetOption[]; +}) { + return ( +
+
+

+ + Authoring +

+ +
+ +

+ {active + ? 'Drag empty space to pan. Drag markers to move. Shift+drag a spot for offset-only nudges.' + : 'Turn on Authoring Mode to select and nudge source-backed markers.'} +

+ + {active ? ( +
+ + +

+ {selectionLabel} +

+ + {fields.length > 0 ? ( +
+ {fields.map((field) => ( + onUpdateField(field.field, value)} + step={field.step} + value={field.value} + /> + ))} +
+ ) : null} + + {snippet ? ( +