yarn-spinner-runner-ts 0.1.2 → 0.1.4-a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -88
- package/dist/compile/compiler.js +4 -4
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markup/parser.d.ts +3 -0
- package/dist/markup/parser.js +332 -0
- package/dist/markup/parser.js.map +1 -0
- package/dist/markup/types.d.ts +17 -0
- package/dist/markup/types.js +2 -0
- package/dist/markup/types.js.map +1 -0
- package/dist/model/ast.d.ts +3 -0
- package/dist/parse/parser.js +57 -8
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +13 -10
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueScene.d.ts +2 -1
- package/dist/react/DialogueScene.js +95 -26
- package/dist/react/DialogueScene.js.map +1 -1
- package/dist/react/DialogueView.d.ts +18 -4
- package/dist/react/DialogueView.js +84 -7
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.d.ts +8 -0
- package/dist/react/MarkupRenderer.js +64 -0
- package/dist/react/MarkupRenderer.js.map +1 -0
- package/dist/react/TypingText.d.ts +14 -0
- package/dist/react/TypingText.js +78 -0
- package/dist/react/TypingText.js.map +1 -0
- package/dist/react/useYarnRunner.js +10 -1
- package/dist/react/useYarnRunner.js.map +1 -1
- package/dist/runtime/commands.js +12 -1
- package/dist/runtime/commands.js.map +1 -1
- package/dist/runtime/results.d.ts +3 -0
- package/dist/runtime/runner.d.ts +7 -0
- package/dist/runtime/runner.js +161 -14
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/custom_functions.test.d.ts +1 -0
- package/dist/tests/custom_functions.test.js +129 -0
- package/dist/tests/custom_functions.test.js.map +1 -0
- package/dist/tests/markup.test.d.ts +1 -0
- package/dist/tests/markup.test.js +46 -0
- package/dist/tests/markup.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.js +25 -1
- package/dist/tests/nodes_lines.test.js.map +1 -1
- package/dist/tests/options.test.js +30 -1
- package/dist/tests/options.test.js.map +1 -1
- package/dist/tests/story_end.test.d.ts +1 -0
- package/dist/tests/story_end.test.js +37 -0
- package/dist/tests/story_end.test.js.map +1 -0
- package/dist/tests/typing-text.test.d.ts +1 -0
- package/dist/tests/typing-text.test.js +12 -0
- package/dist/tests/typing-text.test.js.map +1 -0
- package/docs/actor-transition.md +34 -0
- package/docs/markup.md +34 -19
- package/docs/scenes-actors-setup.md +1 -0
- package/docs/typing-animation.md +44 -0
- package/eslint.config.cjs +3 -0
- package/examples/browser/index.html +1 -1
- package/examples/browser/main.tsx +0 -2
- package/package.json +1 -1
- package/src/compile/compiler.ts +4 -4
- package/src/compile/ir.ts +3 -2
- package/src/index.ts +3 -0
- package/src/markup/parser.ts +372 -0
- package/src/markup/types.ts +22 -0
- package/src/model/ast.ts +17 -13
- package/src/parse/parser.ts +60 -8
- package/src/react/DialogueExample.tsx +27 -51
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +150 -14
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +127 -0
- package/src/react/dialogue.css +26 -13
- package/src/react/useYarnRunner.tsx +13 -1
- package/src/runtime/commands.ts +14 -1
- package/src/runtime/results.ts +3 -1
- package/src/runtime/runner.ts +170 -14
- package/src/tests/custom_functions.test.ts +140 -0
- package/src/tests/markup.test.ts +62 -0
- package/src/tests/nodes_lines.test.ts +35 -1
- package/src/tests/options.test.ts +39 -1
- package/src/tests/story_end.test.ts +42 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import type { SceneCollection, SceneConfig } from "../scene/types.js";
|
|
3
3
|
// Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
|
|
4
4
|
// This prevents Node.js from trying to resolve CSS imports during tests
|
|
@@ -8,25 +8,59 @@ export interface DialogueSceneProps {
|
|
|
8
8
|
speaker?: string;
|
|
9
9
|
scenes: SceneCollection;
|
|
10
10
|
className?: string;
|
|
11
|
+
actorTransitionDuration?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Visual scene component that displays background and actor images
|
|
15
16
|
*/
|
|
16
|
-
export function DialogueScene({
|
|
17
|
+
export function DialogueScene({
|
|
18
|
+
sceneName,
|
|
19
|
+
speaker,
|
|
20
|
+
scenes,
|
|
21
|
+
className,
|
|
22
|
+
actorTransitionDuration = 350,
|
|
23
|
+
}: DialogueSceneProps) {
|
|
24
|
+
|
|
17
25
|
const [currentBackground, setCurrentBackground] = useState<string | null>(null);
|
|
18
26
|
const [backgroundOpacity, setBackgroundOpacity] = useState(1);
|
|
19
27
|
const [nextBackground, setNextBackground] = useState<string | null>(null);
|
|
20
28
|
const [lastSceneName, setLastSceneName] = useState<string | undefined>(undefined);
|
|
21
29
|
const [lastSpeaker, setLastSpeaker] = useState<string | undefined>(undefined);
|
|
30
|
+
const [activeActor, setActiveActor] = useState<{ name: string; image: string } | null>(null);
|
|
31
|
+
const [previousActor, setPreviousActor] = useState<{ name: string; image: string } | null>(null);
|
|
32
|
+
const [currentActorVisible, setCurrentActorVisible] = useState(false);
|
|
33
|
+
const [previousActorVisible, setPreviousActorVisible] = useState(false);
|
|
34
|
+
const previousActorTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
35
|
|
|
23
36
|
// Get scene config - use last scene if current node has no scene
|
|
24
37
|
const activeSceneName = sceneName || lastSceneName;
|
|
25
38
|
const sceneConfig: SceneConfig | undefined = activeSceneName ? scenes.scenes[activeSceneName] : undefined;
|
|
26
39
|
const backgroundImage = sceneConfig?.background;
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
const activeSpeakerName = speaker || lastSpeaker;
|
|
42
|
+
|
|
43
|
+
const resolvedActor = useMemo(() => {
|
|
44
|
+
if (!sceneConfig || !activeSpeakerName) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const actorEntries = Object.entries(sceneConfig.actors);
|
|
49
|
+
const matchingActor = actorEntries.find(
|
|
50
|
+
([actorName]) => actorName.toLowerCase() === activeSpeakerName.toLowerCase()
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (!matchingActor) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [actorName, actorConfig] = matchingActor;
|
|
58
|
+
if (!actorConfig?.image) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { name: actorName, image: actorConfig.image };
|
|
63
|
+
}, [sceneConfig, activeSpeakerName]);
|
|
30
64
|
|
|
31
65
|
// Track last speaker - update when speaker is provided, keep when undefined
|
|
32
66
|
useEffect(() => {
|
|
@@ -64,18 +98,95 @@ export function DialogueScene({ sceneName, speaker, scenes, className }: Dialogu
|
|
|
64
98
|
// Never clear background - keep it until a new one is explicitly set
|
|
65
99
|
}, [backgroundImage, currentBackground, sceneName, lastSceneName]);
|
|
66
100
|
|
|
101
|
+
// Handle actor portrait transitions (cross-fade between speakers)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
let fadeOutTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
|
|
105
|
+
setActiveActor((currentActor) => {
|
|
106
|
+
const currentImage = currentActor?.image ?? null;
|
|
107
|
+
const currentName = currentActor?.name ?? null;
|
|
108
|
+
const nextImage = resolvedActor?.image ?? null;
|
|
109
|
+
const nextName = resolvedActor?.name ?? null;
|
|
110
|
+
|
|
111
|
+
if (currentImage === nextImage && currentName === nextName) {
|
|
112
|
+
return currentActor;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (currentActor) {
|
|
116
|
+
setPreviousActor(currentActor);
|
|
117
|
+
setPreviousActorVisible(true);
|
|
118
|
+
fadeOutTimeout = setTimeout(() => {
|
|
119
|
+
setPreviousActorVisible(false);
|
|
120
|
+
}, 0);
|
|
121
|
+
} else {
|
|
122
|
+
setPreviousActor(null);
|
|
123
|
+
setPreviousActorVisible(false);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setCurrentActorVisible(false);
|
|
127
|
+
|
|
128
|
+
return resolvedActor;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
if (fadeOutTimeout !== null) {
|
|
133
|
+
clearTimeout(fadeOutTimeout);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, [resolvedActor]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!activeActor) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fadeInTimeout = setTimeout(() => {
|
|
144
|
+
setCurrentActorVisible(true);
|
|
145
|
+
}, 0);
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
clearTimeout(fadeInTimeout);
|
|
149
|
+
};
|
|
150
|
+
}, [activeActor]);
|
|
151
|
+
|
|
152
|
+
// Remove previous actor once fade-out completes
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!previousActor) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (previousActorTimeoutRef.current) {
|
|
159
|
+
clearTimeout(previousActorTimeoutRef.current);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
previousActorTimeoutRef.current = setTimeout(() => {
|
|
163
|
+
setPreviousActor(null);
|
|
164
|
+
previousActorTimeoutRef.current = null;
|
|
165
|
+
}, actorTransitionDuration);
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (previousActorTimeoutRef.current) {
|
|
169
|
+
clearTimeout(previousActorTimeoutRef.current);
|
|
170
|
+
previousActorTimeoutRef.current = null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}, [previousActor, actorTransitionDuration]);
|
|
174
|
+
|
|
67
175
|
// Default background color when no scene
|
|
68
176
|
const defaultBgColor = "rgba(26, 26, 46, 1)"; // Dark blue-purple
|
|
177
|
+
const handleActorImageError = (actorName: string, imageUrl: string) => () => {
|
|
178
|
+
console.error(`Failed to load actor image for ${actorName}:`, imageUrl);
|
|
179
|
+
};
|
|
69
180
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
>
|
|
181
|
+
const sceneStyle: React.CSSProperties & { ["--yd-actor-transition"]: string } = {
|
|
182
|
+
backgroundColor: currentBackground ? undefined : defaultBgColor,
|
|
183
|
+
backgroundImage: currentBackground ? `url(${currentBackground})` : undefined,
|
|
184
|
+
opacity: backgroundOpacity,
|
|
185
|
+
["--yd-actor-transition"]: `${Math.max(actorTransitionDuration, 0)}ms`,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className={`yd-scene ${className || ""}`} style={sceneStyle}>
|
|
79
190
|
{/* Next background (during transition) */}
|
|
80
191
|
{nextBackground && (
|
|
81
192
|
<div
|
|
@@ -87,37 +198,25 @@ export function DialogueScene({ sceneName, speaker, scenes, className }: Dialogu
|
|
|
87
198
|
/>
|
|
88
199
|
)}
|
|
89
200
|
|
|
90
|
-
{/* Actor
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<img
|
|
110
|
-
key={speakingActorName}
|
|
111
|
-
className="yd-actor"
|
|
112
|
-
src={actorConfig.image}
|
|
113
|
-
alt={speakingActorName}
|
|
114
|
-
onError={(e) => {
|
|
115
|
-
console.error(`Failed to load actor image for ${speakingActorName}:`, actorConfig.image, e);
|
|
116
|
-
}}
|
|
117
|
-
/>
|
|
118
|
-
);
|
|
119
|
-
})()}
|
|
201
|
+
{/* Actor portraits with cross-fade */}
|
|
202
|
+
{previousActor && (
|
|
203
|
+
<img
|
|
204
|
+
key={`${previousActor.name}-previous`}
|
|
205
|
+
className={`yd-actor yd-actor--previous ${previousActorVisible ? "yd-actor--visible" : ""}`}
|
|
206
|
+
src={previousActor.image}
|
|
207
|
+
alt={previousActor.name}
|
|
208
|
+
onError={handleActorImageError(previousActor.name, previousActor.image)}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
{activeActor && (
|
|
212
|
+
<img
|
|
213
|
+
key={`${activeActor.name}-current`}
|
|
214
|
+
className={`yd-actor yd-actor--current ${currentActorVisible ? "yd-actor--visible" : ""}`}
|
|
215
|
+
src={activeActor.image}
|
|
216
|
+
alt={activeActor.name}
|
|
217
|
+
onError={handleActorImageError(activeActor.name, activeActor.image)}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
120
220
|
</div>
|
|
121
221
|
);
|
|
122
222
|
}
|
|
123
|
-
|
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import type { RuntimeResult } from "../runtime/results.js";
|
|
1
|
+
import React, { useRef, useEffect, useState } from "react";
|
|
3
2
|
import { DialogueScene } from "./DialogueScene.js";
|
|
4
3
|
import type { SceneCollection } from "../scene/types.js";
|
|
4
|
+
import { TypingText } from "./TypingText.js";
|
|
5
|
+
import { useYarnRunner } from "./useYarnRunner.js";
|
|
6
|
+
import { MarkupRenderer } from "./MarkupRenderer.js";
|
|
5
7
|
// Note: CSS is imported in the browser demo entry point (examples/browser/main.tsx)
|
|
6
8
|
// This prevents Node.js from trying to resolve CSS imports during tests
|
|
7
9
|
|
|
10
|
+
import type { IRProgram } from "../compile/ir.js";
|
|
11
|
+
|
|
8
12
|
export interface DialogueViewProps {
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
program: IRProgram;
|
|
14
|
+
startNode?: string;
|
|
11
15
|
className?: string;
|
|
12
16
|
scenes?: SceneCollection;
|
|
17
|
+
actorTransitionDuration?: number;
|
|
18
|
+
// Custom functions and callbacks
|
|
19
|
+
functions?: Record<string, (...args: unknown[]) => unknown>;
|
|
20
|
+
onStoryEnd?: (info: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
|
21
|
+
// Typing animation options
|
|
22
|
+
enableTypingAnimation?: boolean;
|
|
23
|
+
typingSpeed?: number;
|
|
24
|
+
showTypingCursor?: boolean;
|
|
25
|
+
cursorCharacter?: string;
|
|
26
|
+
// Auto-advance after typing completes
|
|
27
|
+
autoAdvanceAfterTyping?: boolean;
|
|
28
|
+
autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
|
|
29
|
+
// Pause before advance
|
|
30
|
+
pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
|
|
13
31
|
}
|
|
14
32
|
|
|
15
33
|
// Helper to parse CSS string into object
|
|
@@ -69,11 +87,68 @@ function parseCss(cssStr: string | undefined): React.CSSProperties {
|
|
|
69
87
|
return styles;
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
export function DialogueView({
|
|
90
|
+
export function DialogueView({
|
|
91
|
+
program,
|
|
92
|
+
startNode = "Start",
|
|
93
|
+
className,
|
|
94
|
+
scenes,
|
|
95
|
+
actorTransitionDuration = 350,
|
|
96
|
+
functions,
|
|
97
|
+
onStoryEnd,
|
|
98
|
+
enableTypingAnimation = false,
|
|
99
|
+
typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
|
|
100
|
+
showTypingCursor = true,
|
|
101
|
+
cursorCharacter = "|",
|
|
102
|
+
autoAdvanceAfterTyping = false,
|
|
103
|
+
autoAdvanceDelay = 500,
|
|
104
|
+
pauseBeforeAdvance = 0,
|
|
105
|
+
}: DialogueViewProps) {
|
|
106
|
+
const { result, advance } = useYarnRunner(program, {
|
|
107
|
+
startAt: startNode,
|
|
108
|
+
functions,
|
|
109
|
+
variables: {},
|
|
110
|
+
onStoryEnd,
|
|
111
|
+
});
|
|
73
112
|
const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
|
|
74
113
|
const speaker = result?.type === "text" ? result.speaker : undefined;
|
|
75
114
|
const sceneCollection = scenes || { scenes: {} };
|
|
76
115
|
|
|
116
|
+
const [typingComplete, setTypingComplete] = useState(false);
|
|
117
|
+
const [currentTextKey, setCurrentTextKey] = useState(0);
|
|
118
|
+
const [skipTyping, setSkipTyping] = useState(false);
|
|
119
|
+
const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
120
|
+
|
|
121
|
+
// Reset typing completion when text changes
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (result?.type === "text") {
|
|
124
|
+
setTypingComplete(false);
|
|
125
|
+
setSkipTyping(false);
|
|
126
|
+
setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
|
|
127
|
+
}
|
|
128
|
+
// Cleanup any pending advance timeouts when text changes
|
|
129
|
+
return () => {
|
|
130
|
+
if (advanceTimeoutRef.current) {
|
|
131
|
+
clearTimeout(advanceTimeoutRef.current);
|
|
132
|
+
advanceTimeoutRef.current = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}, [result?.type === "text" ? result.text : null]);
|
|
136
|
+
|
|
137
|
+
// Handle auto-advance after typing completes
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (
|
|
140
|
+
autoAdvanceAfterTyping &&
|
|
141
|
+
typingComplete &&
|
|
142
|
+
result?.type === "text" &&
|
|
143
|
+
!result.isDialogueEnd
|
|
144
|
+
) {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
advance();
|
|
147
|
+
}, autoAdvanceDelay);
|
|
148
|
+
return () => clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}, [autoAdvanceAfterTyping, typingComplete, result, advance, autoAdvanceDelay]);
|
|
151
|
+
|
|
77
152
|
if (!result) {
|
|
78
153
|
return (
|
|
79
154
|
<div className={`yd-empty ${className || ""}`}>
|
|
@@ -84,13 +159,56 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
84
159
|
|
|
85
160
|
if (result.type === "text") {
|
|
86
161
|
const nodeStyles = parseCss(result.nodeCss);
|
|
162
|
+
const displayText = result.text || "\u00A0";
|
|
163
|
+
const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
|
|
164
|
+
|
|
165
|
+
// Handle story end and call onStoryEnd if provided
|
|
166
|
+
if (result.isDialogueEnd && onStoryEnd && 'variables' in result) {
|
|
167
|
+
onStoryEnd({
|
|
168
|
+
variables: result.variables as Readonly<Record<string, unknown>>,
|
|
169
|
+
storyEnd: true
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const handleClick = () => {
|
|
174
|
+
if (result.isDialogueEnd) return;
|
|
175
|
+
|
|
176
|
+
// If typing is in progress, skip it; otherwise advance
|
|
177
|
+
if (enableTypingAnimation && !typingComplete) {
|
|
178
|
+
// Skip typing animation
|
|
179
|
+
setSkipTyping(true);
|
|
180
|
+
setTypingComplete(true);
|
|
181
|
+
} else {
|
|
182
|
+
// Clear any pending timeout
|
|
183
|
+
if (advanceTimeoutRef.current) {
|
|
184
|
+
clearTimeout(advanceTimeoutRef.current);
|
|
185
|
+
advanceTimeoutRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Apply pause before advance if configured
|
|
189
|
+
if (pauseBeforeAdvance > 0) {
|
|
190
|
+
advanceTimeoutRef.current = setTimeout(() => {
|
|
191
|
+
advance();
|
|
192
|
+
advanceTimeoutRef.current = null;
|
|
193
|
+
}, pauseBeforeAdvance);
|
|
194
|
+
} else {
|
|
195
|
+
advance();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
87
200
|
return (
|
|
88
201
|
<div className="yd-container">
|
|
89
|
-
<DialogueScene
|
|
202
|
+
<DialogueScene
|
|
203
|
+
sceneName={sceneName}
|
|
204
|
+
speaker={speaker}
|
|
205
|
+
scenes={sceneCollection}
|
|
206
|
+
actorTransitionDuration={actorTransitionDuration}
|
|
207
|
+
/>
|
|
90
208
|
<div
|
|
91
209
|
className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
|
|
92
210
|
style={nodeStyles} // Only apply dynamic node CSS
|
|
93
|
-
onClick={
|
|
211
|
+
onClick={handleClick}
|
|
94
212
|
>
|
|
95
213
|
<div className="yd-text-box">
|
|
96
214
|
{result.speaker && (
|
|
@@ -99,9 +217,22 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
99
217
|
</div>
|
|
100
218
|
)}
|
|
101
219
|
<p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
|
|
102
|
-
{
|
|
220
|
+
{enableTypingAnimation ? (
|
|
221
|
+
<TypingText
|
|
222
|
+
key={currentTextKey}
|
|
223
|
+
text={displayText}
|
|
224
|
+
markup={result.markup}
|
|
225
|
+
typingSpeed={typingSpeed}
|
|
226
|
+
showCursor={showTypingCursor}
|
|
227
|
+
cursorCharacter={cursorCharacter}
|
|
228
|
+
disabled={skipTyping}
|
|
229
|
+
onComplete={() => setTypingComplete(true)}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<MarkupRenderer text={displayText} markup={result.markup} />
|
|
233
|
+
)}
|
|
103
234
|
</p>
|
|
104
|
-
{
|
|
235
|
+
{shouldShowContinue && (
|
|
105
236
|
<div className="yd-continue">
|
|
106
237
|
▼
|
|
107
238
|
</div>
|
|
@@ -116,7 +247,12 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
116
247
|
const nodeStyles = parseCss(result.nodeCss);
|
|
117
248
|
return (
|
|
118
249
|
<div className="yd-container">
|
|
119
|
-
<DialogueScene
|
|
250
|
+
<DialogueScene
|
|
251
|
+
sceneName={sceneName}
|
|
252
|
+
speaker={speaker}
|
|
253
|
+
scenes={sceneCollection}
|
|
254
|
+
actorTransitionDuration={actorTransitionDuration}
|
|
255
|
+
/>
|
|
120
256
|
<div className={`yd-options-container ${className || ""}`}>
|
|
121
257
|
<div className="yd-options-box" style={nodeStyles}>
|
|
122
258
|
<div className="yd-options-title">Choose an option:</div>
|
|
@@ -127,10 +263,10 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
127
263
|
<button
|
|
128
264
|
key={index}
|
|
129
265
|
className="yd-option-button"
|
|
130
|
-
onClick={() =>
|
|
266
|
+
onClick={() => advance(index)}
|
|
131
267
|
style={optionStyles} // Only apply dynamic option CSS
|
|
132
268
|
>
|
|
133
|
-
{option.text}
|
|
269
|
+
<MarkupRenderer text={option.text} markup={option.markup} />
|
|
134
270
|
</button>
|
|
135
271
|
);
|
|
136
272
|
})}
|
|
@@ -145,9 +281,9 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
145
281
|
if (result.type === "command") {
|
|
146
282
|
// Auto-advance commands after a brief moment
|
|
147
283
|
React.useEffect(() => {
|
|
148
|
-
const timer = setTimeout(() =>
|
|
284
|
+
const timer = setTimeout(() => advance(), 50);
|
|
149
285
|
return () => clearTimeout(timer);
|
|
150
|
-
}, [result.command,
|
|
286
|
+
}, [result.command, advance]);
|
|
151
287
|
|
|
152
288
|
return (
|
|
153
289
|
<div className={`yd-command ${className || ""}`}>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
|
|
5
|
+
|
|
6
|
+
interface RenderPiece {
|
|
7
|
+
text: string;
|
|
8
|
+
wrappers: MarkupWrapper[];
|
|
9
|
+
key: string;
|
|
10
|
+
selfClosing?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MarkupRendererProps {
|
|
14
|
+
text: string;
|
|
15
|
+
markup?: MarkupParseResult;
|
|
16
|
+
length?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MarkupRenderer({ text, markup, length }: MarkupRendererProps) {
|
|
20
|
+
const maxLength = length ?? text.length;
|
|
21
|
+
if (!markup || markup.segments.length === 0) {
|
|
22
|
+
return <>{text.slice(0, maxLength)}</>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pieces: RenderPiece[] = [];
|
|
26
|
+
const limit = Math.max(0, Math.min(maxLength, markup.text.length));
|
|
27
|
+
|
|
28
|
+
markup.segments.forEach((segment, index) => {
|
|
29
|
+
if (segment.selfClosing) {
|
|
30
|
+
if (segment.start <= limit) {
|
|
31
|
+
pieces.push({
|
|
32
|
+
text: "",
|
|
33
|
+
wrappers: segment.wrappers,
|
|
34
|
+
selfClosing: true,
|
|
35
|
+
key: `self-${index}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const start = Math.max(0, Math.min(segment.start, limit));
|
|
41
|
+
const end = Math.max(start, Math.min(segment.end, limit));
|
|
42
|
+
if (end <= start) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const segmentText = markup.text.slice(start, end);
|
|
46
|
+
pieces.push({
|
|
47
|
+
text: segmentText,
|
|
48
|
+
wrappers: segment.wrappers,
|
|
49
|
+
key: `seg-${index}-${start}-${end}`,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (pieces.length === 0) {
|
|
54
|
+
return <>{text.slice(0, maxLength)}</>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{pieces.map((piece, pieceIndex) => renderPiece(piece, pieceIndex))}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderPiece(piece: RenderPiece, pieceIndex: number): React.ReactNode {
|
|
65
|
+
const baseKey = `${piece.key}-${pieceIndex}`;
|
|
66
|
+
|
|
67
|
+
if (piece.selfClosing) {
|
|
68
|
+
return piece.wrappers.reduceRight<React.ReactNode>(
|
|
69
|
+
(child, wrapper, wrapperIndex) => createWrapperElement(wrapper, `${baseKey}-wrapper-${wrapperIndex}`, child),
|
|
70
|
+
null
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const content = piece.wrappers.reduceRight<React.ReactNode>(
|
|
75
|
+
(child, wrapper, wrapperIndex) => createWrapperElement(wrapper, `${baseKey}-wrapper-${wrapperIndex}`, child),
|
|
76
|
+
piece.text
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return <React.Fragment key={baseKey}>{content}</React.Fragment>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createWrapperElement(
|
|
83
|
+
wrapper: MarkupWrapper,
|
|
84
|
+
key: string,
|
|
85
|
+
children: React.ReactNode
|
|
86
|
+
): React.ReactElement {
|
|
87
|
+
const tagName = DEFAULT_HTML_TAGS.has(wrapper.name) ? wrapper.name : "span";
|
|
88
|
+
const className =
|
|
89
|
+
wrapper.type === "custom" ? `yd-markup-${sanitizeClassName(wrapper.name)}` : undefined;
|
|
90
|
+
|
|
91
|
+
const dataAttributes: Record<string, string> = {};
|
|
92
|
+
for (const [propertyName, value] of Object.entries(wrapper.properties)) {
|
|
93
|
+
dataAttributes[`data-markup-${propertyName}`] = String(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return React.createElement(
|
|
97
|
+
tagName,
|
|
98
|
+
{
|
|
99
|
+
key,
|
|
100
|
+
className,
|
|
101
|
+
...dataAttributes,
|
|
102
|
+
},
|
|
103
|
+
children
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizeClassName(name: string): string {
|
|
108
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
109
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import type { MarkupParseResult } from "../markup/types.js";
|
|
3
|
+
import { MarkupRenderer } from "./MarkupRenderer.js";
|
|
4
|
+
|
|
5
|
+
export interface TypingTextProps {
|
|
6
|
+
text: string;
|
|
7
|
+
markup?: MarkupParseResult;
|
|
8
|
+
typingSpeed?: number;
|
|
9
|
+
showCursor?: boolean;
|
|
10
|
+
cursorCharacter?: string;
|
|
11
|
+
cursorBlinkDuration?: number;
|
|
12
|
+
cursorClassName?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
onComplete?: () => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TypingText({
|
|
19
|
+
text,
|
|
20
|
+
markup,
|
|
21
|
+
typingSpeed = 100,
|
|
22
|
+
showCursor = true,
|
|
23
|
+
cursorCharacter = "|",
|
|
24
|
+
cursorBlinkDuration = 530,
|
|
25
|
+
cursorClassName = "",
|
|
26
|
+
className = "",
|
|
27
|
+
onComplete,
|
|
28
|
+
disabled = false,
|
|
29
|
+
}: TypingTextProps) {
|
|
30
|
+
const [displayedLength, setDisplayedLength] = useState(disabled ? text.length : 0);
|
|
31
|
+
const [cursorVisible, setCursorVisible] = useState(true);
|
|
32
|
+
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
33
|
+
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
const onCompleteRef = useRef(onComplete);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
onCompleteRef.current = onComplete;
|
|
38
|
+
}, [onComplete]);
|
|
39
|
+
|
|
40
|
+
// Handle cursor blinking
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!showCursor || disabled) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
cursorIntervalRef.current = setInterval(() => {
|
|
46
|
+
setCursorVisible((prev) => !prev);
|
|
47
|
+
}, cursorBlinkDuration);
|
|
48
|
+
return () => {
|
|
49
|
+
if (cursorIntervalRef.current) {
|
|
50
|
+
clearInterval(cursorIntervalRef.current);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, [showCursor, cursorBlinkDuration, disabled]);
|
|
54
|
+
|
|
55
|
+
// Handle typing animation
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (disabled) {
|
|
58
|
+
setDisplayedLength(text.length);
|
|
59
|
+
if (onCompleteRef.current && text.length > 0) {
|
|
60
|
+
onCompleteRef.current();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reset when text changes
|
|
66
|
+
setDisplayedLength(0);
|
|
67
|
+
|
|
68
|
+
if (text.length === 0) {
|
|
69
|
+
if (onCompleteRef.current) {
|
|
70
|
+
onCompleteRef.current();
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let index = 0;
|
|
76
|
+
const typeNextCharacter = () => {
|
|
77
|
+
if (index < text.length) {
|
|
78
|
+
index += 1;
|
|
79
|
+
setDisplayedLength(index);
|
|
80
|
+
if (typingSpeed <= 0) {
|
|
81
|
+
requestAnimationFrame(typeNextCharacter);
|
|
82
|
+
} else {
|
|
83
|
+
typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
|
|
84
|
+
}
|
|
85
|
+
} else if (onCompleteRef.current) {
|
|
86
|
+
onCompleteRef.current();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (typingSpeed <= 0) {
|
|
91
|
+
requestAnimationFrame(typeNextCharacter);
|
|
92
|
+
} else {
|
|
93
|
+
typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
if (typingTimeoutRef.current) {
|
|
98
|
+
clearTimeout(typingTimeoutRef.current);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}, [text, disabled, typingSpeed]);
|
|
102
|
+
|
|
103
|
+
const visibleLength = markup ? Math.min(displayedLength, markup.text.length) : Math.min(displayedLength, text.length);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<span className={className}>
|
|
107
|
+
<span>
|
|
108
|
+
{markup ? (
|
|
109
|
+
<MarkupRenderer text={text} markup={markup} length={visibleLength} />
|
|
110
|
+
) : (
|
|
111
|
+
text.slice(0, visibleLength)
|
|
112
|
+
)}
|
|
113
|
+
</span>
|
|
114
|
+
{showCursor && !disabled && (
|
|
115
|
+
<span
|
|
116
|
+
className={`yd-typing-cursor ${cursorClassName}`}
|
|
117
|
+
style={{
|
|
118
|
+
opacity: cursorVisible ? 1 : 0,
|
|
119
|
+
transition: `opacity ${cursorBlinkDuration / 2}ms ease-in-out`,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{cursorCharacter}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</span>
|
|
126
|
+
);
|
|
127
|
+
}
|