Add merit node type (Scene 8 ceremony + achievement award)
A merit node awards a configured achievement (awards_flag) to the player the moment the playthrough arrives on it, with node provenance, then presents a ceremony. Migration 028 extends node_type + the component_key CHECK and adds awards_flag; the runtime grants the flag in advance/goto/ new-game write paths; MeritHost renders the ceremony (a component_key can supply a bespoke one, e.g. a 3D model). Verified: teleport to a merit node auto-grants its achievement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -2,7 +2,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 RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: 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 }
|
||||
|
||||
@@ -48,6 +48,25 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
|
||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||
|
||||
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
|
||||
// award model). The achievement itself is granted server-side on arrival; this is
|
||||
// purely the presentation of receiving it.
|
||||
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {}
|
||||
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY)
|
||||
|
||||
export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
|
||||
const Component = componentKey ? MERIT_REGISTRY[componentKey] : undefined
|
||||
if (Component) return <Component label={label} onComplete={onComplete} />
|
||||
return <div className="cutscene-card merit-card" onClick={onComplete}>
|
||||
<div className="title-card-inner">
|
||||
<small className="merit-eyebrow">◆ MERIT AWARDED ◆</small>
|
||||
<h1>{label}</h1>
|
||||
{awardsFlag && <p className="merit-flag">🏅 {awardsFlag}</p>}
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||
if (Component) return <Component onComplete={onComplete} />
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { CutsceneHost, DialoguePlayer, type PlaythroughState } from './narrative'
|
||||
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||
|
||||
type MysterySummary = { slug: string; title: string }
|
||||
|
||||
@@ -93,6 +93,7 @@ export function Play() {
|
||||
const node = state?.node
|
||||
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||
}
|
||||
|
||||
@@ -520,6 +520,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
|
||||
/* Story-graph runtime: cutscene title card + report-back */
|
||||
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
||||
.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); }
|
||||
.merit-card .merit-eyebrow { color: #cdea6a; }
|
||||
.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; }
|
||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||
|
||||
Reference in New Issue
Block a user