import { customElement, noShadowDOM } from 'solid-element'; import { createSignal, createMemo, createResource, createEffect, For, Show } from 'solid-js'; import { parseYarn, compile, YarnRunner } from '../yarn-spinner'; import type { RunnerOptions } from '../yarn-spinner/runtime/runner'; import type { RuntimeResult, TextResult, OptionsResult, CommandResult } from '../yarn-spinner/runtime/results'; export interface YarnSpinnerProps extends RunnerOptions { startNode?: string; multipleFiles?: boolean; } interface DialogueEntry { id: number; result: RuntimeResult; selectedOptionIndex?: number; } // 缓存已加载的 yarn 文件内容 const yarnCache = new Map(); // 加载 yarn 文件内容 async function loadYarnFile(path: string): Promise { if (yarnCache.has(path)) { return yarnCache.get(path)!; } const response = await fetch(path); const content = await response.text(); yarnCache.set(path, content); return content; } // 加载多个 yarn 文件并拼接 async function loadYarnFiles(paths: string[]): Promise { const contents = await Promise.all(paths.map(path => loadYarnFile(path))); return contents.join('\n'); } customElement('md-yarn-spinner', { startNode: undefined as string | undefined, multipleFiles: false }, (props, { element }) => { noShadowDOM(); const [dialogueHistory, setDialogueHistory] = createSignal([]); const [currentOptions, setCurrentOptions] = createSignal(null); const [isEnded, setIsEnded] = createSignal(false); const [entryIdCounter, setEntryIdCounter] = createSignal(0); const [runnerInstance, setRunnerInstance] = createSignal(null); // 获取文件路径 const rawSrc = element?.textContent?.trim() || ''; const articleEl = element?.closest('article[data-src]'); const articlePath = articleEl?.getAttribute('data-src') || ''; // 隐藏原始文本内容 if (element) { element.textContent = ''; } // 解析路径列表(支持多文件) const yarnPaths = createMemo(() => { const src = rawSrc; if (!src) return []; if (props.multipleFiles) { return src.split('\n').map((s: string) => s.trim()).filter(Boolean); } return [src]; }); // 解析相对路径 const resolvedPaths = createMemo(() => { return yarnPaths().map((path: string) => { if (path.startsWith('/')) return path; if (path.startsWith('http://') || path.startsWith('https://')) return path; const baseParts = articlePath.split('/').filter(Boolean); if (baseParts.length > 0 && !articlePath.endsWith('/')) { baseParts.pop(); } const relativeParts = path.split('/'); for (const part of relativeParts) { if (part === '.' || part === '') continue; else if (part === '..') baseParts.pop(); else baseParts.push(part); } return '/' + baseParts.join('/'); }); }); // 加载 yarn 内容 const [yarnContent] = createResource( () => resolvedPaths(), async (paths) => { if (paths.length === 0) return ''; if (paths.length === 1) { return await loadYarnFile(paths[0]); } return await loadYarnFiles(paths); } ); // 创建 runner const createRunner = (content: string, startNode?: string) => { if (!content) return null; try { const ast = parseYarn(content); const program = compile(ast); const runnerOptions: RunnerOptions = { startAt: startNode || 'Start', variables: (props as YarnSpinnerProps).variables, functions: (props as YarnSpinnerProps).functions, handleCommand: (props as YarnSpinnerProps).handleCommand, onStoryEnd: (props as YarnSpinnerProps).onStoryEnd, }; return new YarnRunner(program, runnerOptions); } catch (error) { console.error('Failed to initialize YarnRunner:', error); return null; } }; // 初始化 runner createEffect(() => { const content = yarnContent(); if (content && !runnerInstance()) { const startNode = (props as YarnSpinnerProps).startNode; const runner = createRunner(content, startNode); if (runner) { setRunnerInstance(runner); } } }); // 处理 runner 输出 const processRunnerOutput = (runner: YarnRunner) => { const result = runner.currentResult; if (!result) return; const newEntry: DialogueEntry = { id: entryIdCounter(), result, }; setDialogueHistory(prev => [...prev, newEntry]); setEntryIdCounter(prev => prev + 1); if (result.type === 'options') { setCurrentOptions(result as OptionsResult); } else if (result.type === 'text' && (result as TextResult).isDialogueEnd) { // 检查是否是对话结束 const lastOptions = runner.history.slice().reverse().find(r => r.type === 'options') as OptionsResult | undefined; if (lastOptions && runner.currentResult?.type !== 'options') { // 之前有选项但没有选择,等待用户输入 setCurrentOptions(lastOptions); return; } } // 检查故事是否结束 if ((result as TextResult).isDialogueEnd && result.type !== 'options') { setIsEnded(true); setCurrentOptions(null); } }; // 监听 runner 变化 createEffect(() => { const runner = runnerInstance(); if (runner && runner.currentResult) { processRunnerOutput(runner); } }); // 选择选项 const selectOption = (index: number) => { const runner = runnerInstance(); if (!runner || !currentOptions()) return; // 更新历史记录,标记已选择的选项 setDialogueHistory(prev => { const lastOptionsIndex = prev.slice().reverse().findIndex(e => e.result.type === 'options'); if (lastOptionsIndex === -1) return prev; const actualIndex = prev.length - 1 - lastOptionsIndex; return prev.map((entry, i) => i === actualIndex ? { ...entry, selectedOptionIndex: index } : entry ); }); setCurrentOptions(null); runner.advance(index); // 处理选择后的输出 setTimeout(() => { processRunnerOutput(runner); }, 0); }; // 重新开始 const restart = () => { setDialogueHistory([]); setCurrentOptions(null); setIsEnded(false); setEntryIdCounter(0); // 重新创建 runner const content = yarnContent(); if (!content) return; const runner = createRunner(content, (props as YarnSpinnerProps).startNode); if (runner) { setRunnerInstance(runner); } }; // 渲染对话历史项 const renderEntry = (entry: DialogueEntry) => { const result = entry.result; if (result.type === 'text') { const textResult = result as TextResult; return (
{textResult.speaker}: {textResult.text}
); } if (result.type === 'command') { return (
[{result.command}]
); } if (result.type === 'options') { const optionsResult = result as OptionsResult; return (
→ {optionsResult.options[entry.selectedOptionIndex!].text}
{(option, index) => ( )}
); } return null; }; return (
{/* 对话历史 */}
点击重新开始开始对话
加载对话中...
{entry => renderEntry(entry)}
{/* 当前选项 */}
{(option, index) => ( )}
{/* 工具栏 */}
); });