diff --git a/src/ui/StoryProseLog.tsx b/src/ui/StoryProseLog.tsx new file mode 100644 index 0000000..7cf8fd9 --- /dev/null +++ b/src/ui/StoryProseLog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import type { StoryLogEntry } from '../state/store'; + +interface StoryProseLogProps { + log: StoryLogEntry[]; + selectedId: string | null; +} + +export function StoryProseLog({ log, selectedId }: StoryProseLogProps) { + const scrollRef = useRef(null); + const selectedEntryRef = useRef(null); + const prevLogCountRef = useRef(log.length); + + // Auto-scroll to bottom when new entries arrive + useEffect(() => { + if (log.length > prevLogCountRef.current && !selectedId && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + prevLogCountRef.current = log.length; + }, [log.length, selectedId]); + + // Scroll to selected entry when selectedId changes + useEffect(() => { + if (selectedId && selectedEntryRef.current) { + selectedEntryRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [selectedId]); + + if (log.length === 0) { + return ( +
+

The chronicle is empty…

+
+ ); + } + + return ( +
+

+ Prose Log +

+
    + {log.map((entry, i) => { + const isHighlighted = selectedId != null && entry.nodeId === selectedId; + // Find first matching entry for the scroll-into-view ref + const isFirstMatch = isHighlighted && log.findIndex((e) => e.nodeId === selectedId) === i; + + return ( +
  • + {entry.choiceLabel ? ( + + {entry.choiceLabel} + + ) : null} + {entry.prose} +
  • + ); + })} +
+
+ ); +} diff --git a/src/ui/StoryTree.tsx b/src/ui/StoryTree.tsx new file mode 100644 index 0000000..f855594 --- /dev/null +++ b/src/ui/StoryTree.tsx @@ -0,0 +1,92 @@ +import type { StoryTreeNodeView } from '../state/viewModel'; + +interface StoryTreeProps { + nodes: StoryTreeNodeView[]; + selectedId: string | null; + onSelect: (id: string) => void; +} + +interface StoryTreeNodeProps { + node: StoryTreeNodeView; + depth: number; + selectedId: string | null; + onSelect: (id: string) => void; +} + +function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps) { + const isSelected = node.id === selectedId; + + let labelClasses = + 'w-full cursor-pointer rounded px-2 py-1 text-left text-sm transition-colors duration-150'; + + if (isSelected) { + labelClasses += ' bg-amber-500/15 border border-amber-500/40 text-amber-200'; + } else if (node.active) { + labelClasses += ' text-amber-400 hover:bg-slate-800/60 border border-transparent'; + } else if (!node.seen) { + labelClasses += ' text-slate-600 hover:bg-slate-800/40 border border-transparent'; + } else { + labelClasses += ' text-slate-300 hover:bg-slate-800/60 border border-transparent'; + } + + return ( +
  • + + {node.children.length > 0 && ( +
      + {node.children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +export function StoryTree({ nodes, selectedId, onSelect }: StoryTreeProps) { + if (nodes.length === 0) { + return ( +
    +

    No story branches yet…

    +
    + ); + } + + return ( +
    +

    + Story Tree +

    +
      + {nodes.map((node) => ( + + ))} +
    +
    + ); +} diff --git a/src/ui/StoryView.tsx b/src/ui/StoryView.tsx index 609e50b..332e116 100644 --- a/src/ui/StoryView.tsx +++ b/src/ui/StoryView.tsx @@ -1,76 +1,43 @@ import { gameRuntime } from '../state/runtime'; import { useGameStore } from '../state/store'; +import { StoryProseLog } from './StoryProseLog'; +import { StoryTree } from './StoryTree'; export function StoryView() { - const story = useGameStore((s) => s.story); - const storyLog = useGameStore((s) => s.storyLog); + const tree = useGameStore((s) => s.story.tree); + const log = useGameStore((s) => s.storyLog); + const selectedId = useGameStore((s) => s.selectedStoryNodeId); + const currentProse = useGameStore((s) => s.story.currentProse); + const choicesLength = useGameStore((s) => s.story.choices.length); - const hasChoices = story.choices.length > 0; + // Boot intro: show Continue button when there's prose, no choices, and no tree yet + const isBootIntro = currentProse != null && choicesLength === 0 && tree.length === 0; + + if (isBootIntro) { + return ( +
    +

    + {currentProse} +

    + +
    + ); + } return ( -
    - {/* Story Log Section */} -
    -

    - Story Log -

    - {storyLog.length === 0 ? ( -

    The chronicle is empty...

    - ) : ( -
      - {storyLog.map((entry) => ( -
    • - {entry.choiceLabel ? ( - [{entry.choiceLabel}] - ) : null} - {entry.prose} -
    • - ))} -
    - )} -
    - - {/* Active Node Section */} -
    -
    -

    - {story.currentProse || - 'The path ahead is shrouded in mist. Begin an action to unveil your story.'} -

    -
    - - {story.currentProse && ( -
    - {hasChoices ? ( -
    - {story.choices.map((choice) => ( - - ))} -
    - ) : ( - - )} -
    - )} -
    +
    + useGameStore.getState().setSelectedStoryNodeId(id)} + /> +
    ); }