yarn-spinner-runner-ts 0.1.2 → 0.1.4
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 +6 -4
- 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 +10 -1
- package/dist/react/DialogueView.js +68 -5
- 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/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 +6 -6
- 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 +18 -42
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +122 -8
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +127 -0
- package/src/react/dialogue.css +26 -13
- 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,7 +1,9 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useRef, useEffect, useState } from "react";
|
|
2
2
|
import type { RuntimeResult } from "../runtime/results.js";
|
|
3
3
|
import { DialogueScene } from "./DialogueScene.js";
|
|
4
4
|
import type { SceneCollection } from "../scene/types.js";
|
|
5
|
+
import { TypingText } from "./TypingText.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,6 +12,17 @@ export interface DialogueViewProps {
|
|
|
10
12
|
onAdvance: (optionIndex?: number) => void;
|
|
11
13
|
className?: string;
|
|
12
14
|
scenes?: SceneCollection;
|
|
15
|
+
actorTransitionDuration?: number;
|
|
16
|
+
// Typing animation options
|
|
17
|
+
enableTypingAnimation?: boolean;
|
|
18
|
+
typingSpeed?: number;
|
|
19
|
+
showTypingCursor?: boolean;
|
|
20
|
+
cursorCharacter?: string;
|
|
21
|
+
// Auto-advance after typing completes
|
|
22
|
+
autoAdvanceAfterTyping?: boolean;
|
|
23
|
+
autoAdvanceDelay?: number; // Delay in ms after typing completes before auto-advancing
|
|
24
|
+
// Pause before advance
|
|
25
|
+
pauseBeforeAdvance?: number; // Delay in ms before advancing when clicking (0 = no pause)
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
// Helper to parse CSS string into object
|
|
@@ -69,10 +82,58 @@ function parseCss(cssStr: string | undefined): React.CSSProperties {
|
|
|
69
82
|
return styles;
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
export function DialogueView({
|
|
85
|
+
export function DialogueView({
|
|
86
|
+
result,
|
|
87
|
+
onAdvance,
|
|
88
|
+
className,
|
|
89
|
+
scenes,
|
|
90
|
+
actorTransitionDuration = 350,
|
|
91
|
+
enableTypingAnimation = false,
|
|
92
|
+
typingSpeed = 50, // Characters per second (50 cps = ~20ms per character)
|
|
93
|
+
showTypingCursor = true,
|
|
94
|
+
cursorCharacter = "|",
|
|
95
|
+
autoAdvanceAfterTyping = false,
|
|
96
|
+
autoAdvanceDelay = 500,
|
|
97
|
+
pauseBeforeAdvance = 0,
|
|
98
|
+
}: DialogueViewProps) {
|
|
73
99
|
const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
|
|
74
100
|
const speaker = result?.type === "text" ? result.speaker : undefined;
|
|
75
101
|
const sceneCollection = scenes || { scenes: {} };
|
|
102
|
+
const [typingComplete, setTypingComplete] = useState(false);
|
|
103
|
+
const [currentTextKey, setCurrentTextKey] = useState(0);
|
|
104
|
+
const [skipTyping, setSkipTyping] = useState(false);
|
|
105
|
+
const advanceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
106
|
+
|
|
107
|
+
// Reset typing completion when text changes
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (result?.type === "text") {
|
|
110
|
+
setTypingComplete(false);
|
|
111
|
+
setSkipTyping(false);
|
|
112
|
+
setCurrentTextKey((prev) => prev + 1); // Force re-render of TypingText
|
|
113
|
+
}
|
|
114
|
+
// Cleanup any pending advance timeouts when text changes
|
|
115
|
+
return () => {
|
|
116
|
+
if (advanceTimeoutRef.current) {
|
|
117
|
+
clearTimeout(advanceTimeoutRef.current);
|
|
118
|
+
advanceTimeoutRef.current = null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}, [result?.type === "text" ? result.text : null]);
|
|
122
|
+
|
|
123
|
+
// Handle auto-advance after typing completes
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (
|
|
126
|
+
autoAdvanceAfterTyping &&
|
|
127
|
+
typingComplete &&
|
|
128
|
+
result?.type === "text" &&
|
|
129
|
+
!result.isDialogueEnd
|
|
130
|
+
) {
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
onAdvance();
|
|
133
|
+
}, autoAdvanceDelay);
|
|
134
|
+
return () => clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}, [autoAdvanceAfterTyping, typingComplete, result, onAdvance, autoAdvanceDelay]);
|
|
76
137
|
|
|
77
138
|
if (!result) {
|
|
78
139
|
return (
|
|
@@ -84,13 +145,48 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
84
145
|
|
|
85
146
|
if (result.type === "text") {
|
|
86
147
|
const nodeStyles = parseCss(result.nodeCss);
|
|
148
|
+
const displayText = result.text || "\u00A0";
|
|
149
|
+
const shouldShowContinue = !result.isDialogueEnd && !enableTypingAnimation;
|
|
150
|
+
|
|
151
|
+
const handleClick = () => {
|
|
152
|
+
if (result.isDialogueEnd) return;
|
|
153
|
+
|
|
154
|
+
// If typing is in progress, skip it; otherwise advance
|
|
155
|
+
if (enableTypingAnimation && !typingComplete) {
|
|
156
|
+
// Skip typing animation
|
|
157
|
+
setSkipTyping(true);
|
|
158
|
+
setTypingComplete(true);
|
|
159
|
+
} else {
|
|
160
|
+
// Clear any pending timeout
|
|
161
|
+
if (advanceTimeoutRef.current) {
|
|
162
|
+
clearTimeout(advanceTimeoutRef.current);
|
|
163
|
+
advanceTimeoutRef.current = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Apply pause before advance if configured
|
|
167
|
+
if (pauseBeforeAdvance > 0) {
|
|
168
|
+
advanceTimeoutRef.current = setTimeout(() => {
|
|
169
|
+
onAdvance();
|
|
170
|
+
advanceTimeoutRef.current = null;
|
|
171
|
+
}, pauseBeforeAdvance);
|
|
172
|
+
} else {
|
|
173
|
+
onAdvance();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
87
178
|
return (
|
|
88
179
|
<div className="yd-container">
|
|
89
|
-
<DialogueScene
|
|
180
|
+
<DialogueScene
|
|
181
|
+
sceneName={sceneName}
|
|
182
|
+
speaker={speaker}
|
|
183
|
+
scenes={sceneCollection}
|
|
184
|
+
actorTransitionDuration={actorTransitionDuration}
|
|
185
|
+
/>
|
|
90
186
|
<div
|
|
91
187
|
className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
|
|
92
188
|
style={nodeStyles} // Only apply dynamic node CSS
|
|
93
|
-
onClick={
|
|
189
|
+
onClick={handleClick}
|
|
94
190
|
>
|
|
95
191
|
<div className="yd-text-box">
|
|
96
192
|
{result.speaker && (
|
|
@@ -99,9 +195,22 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
99
195
|
</div>
|
|
100
196
|
)}
|
|
101
197
|
<p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
|
|
102
|
-
{
|
|
198
|
+
{enableTypingAnimation ? (
|
|
199
|
+
<TypingText
|
|
200
|
+
key={currentTextKey}
|
|
201
|
+
text={displayText}
|
|
202
|
+
markup={result.markup}
|
|
203
|
+
typingSpeed={typingSpeed}
|
|
204
|
+
showCursor={showTypingCursor}
|
|
205
|
+
cursorCharacter={cursorCharacter}
|
|
206
|
+
disabled={skipTyping}
|
|
207
|
+
onComplete={() => setTypingComplete(true)}
|
|
208
|
+
/>
|
|
209
|
+
) : (
|
|
210
|
+
<MarkupRenderer text={displayText} markup={result.markup} />
|
|
211
|
+
)}
|
|
103
212
|
</p>
|
|
104
|
-
{
|
|
213
|
+
{shouldShowContinue && (
|
|
105
214
|
<div className="yd-continue">
|
|
106
215
|
▼
|
|
107
216
|
</div>
|
|
@@ -116,7 +225,12 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
116
225
|
const nodeStyles = parseCss(result.nodeCss);
|
|
117
226
|
return (
|
|
118
227
|
<div className="yd-container">
|
|
119
|
-
<DialogueScene
|
|
228
|
+
<DialogueScene
|
|
229
|
+
sceneName={sceneName}
|
|
230
|
+
speaker={speaker}
|
|
231
|
+
scenes={sceneCollection}
|
|
232
|
+
actorTransitionDuration={actorTransitionDuration}
|
|
233
|
+
/>
|
|
120
234
|
<div className={`yd-options-container ${className || ""}`}>
|
|
121
235
|
<div className="yd-options-box" style={nodeStyles}>
|
|
122
236
|
<div className="yd-options-title">Choose an option:</div>
|
|
@@ -130,7 +244,7 @@ export function DialogueView({ result, onAdvance, className, scenes }: DialogueV
|
|
|
130
244
|
onClick={() => onAdvance(index)}
|
|
131
245
|
style={optionStyles} // Only apply dynamic option CSS
|
|
132
246
|
>
|
|
133
|
-
{option.text}
|
|
247
|
+
<MarkupRenderer text={option.text} markup={option.markup} />
|
|
134
248
|
</button>
|
|
135
249
|
);
|
|
136
250
|
})}
|
|
@@ -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
|
+
}
|
package/src/react/dialogue.css
CHANGED
|
@@ -43,19 +43,32 @@
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/* Actor image */
|
|
46
|
-
.yd-actor {
|
|
47
|
-
position: absolute;
|
|
48
|
-
top: 0;
|
|
49
|
-
left: 50%;
|
|
50
|
-
transform: translateX(-50%);
|
|
51
|
-
max-height: 70%;
|
|
52
|
-
max-width: 40%;
|
|
53
|
-
object-fit: contain;
|
|
54
|
-
|
|
55
|
-
transition: opacity 0.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
.yd-actor {
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 0;
|
|
49
|
+
left: 50%;
|
|
50
|
+
transform: translateX(-50%);
|
|
51
|
+
max-height: 70%;
|
|
52
|
+
max-width: 40%;
|
|
53
|
+
object-fit: contain;
|
|
54
|
+
opacity: 0;
|
|
55
|
+
transition: opacity var(--yd-actor-transition, 0.35s) ease-in-out;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
z-index: 1;
|
|
58
|
+
will-change: opacity;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.yd-actor--current {
|
|
62
|
+
z-index: 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.yd-actor--previous {
|
|
66
|
+
z-index: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.yd-actor--visible {
|
|
70
|
+
opacity: 1;
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
/* Dialogue box container */
|
|
61
74
|
.yd-dialogue-box {
|