Utterance award + requirement, and branching dialogue seeding
A dialogue line can award an achievement when reached (granted server-side, validated against the player's current node so it can't be forged) and an option can require an achievement to be offered (filtered out of the resolved tree otherwise). authorGraph gains a branching form (utterance key/parent/ terminal) so option trees — not just linear lines — can be seeded. This is gaps 1 & 2 for the Barricelli Dobby/Glitch-Hunter dialogues; phone dialing stays stubbed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||
import { audio } from './audio'
|
||||
|
||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null }
|
||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||
@@ -81,9 +81,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
||||
|
||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||
export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) {
|
||||
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||
const onAwardRef = useRef(onAward)
|
||||
onAwardRef.current = onAward
|
||||
// In preview, clicking an utterance card jumps the walk to that line.
|
||||
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
||||
const [charCount, setCharCount] = useState(0)
|
||||
@@ -110,8 +112,16 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
|
||||
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
||||
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Grant a line's authored achievement when it becomes current (play mode only).
|
||||
useEffect(() => {
|
||||
if (inline || !currentId) return
|
||||
const utterance = byId.get(currentId)
|
||||
if (utterance?.awardsFlag) onAwardRef.current?.(utterance.id)
|
||||
}, [currentId, inline, byId])
|
||||
|
||||
const pick = (choice: RuntimeUtterance) => {
|
||||
if (!inline) audio.sfx('choice')
|
||||
if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id)
|
||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||
else onExit(choice.terminalKey ?? undefined)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user