@flare-engine/dialogue 0.1.0
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/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/index.d.mts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +257 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +223 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grzegorz Otto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @flare-engine/dialogue
|
|
2
|
+
|
|
3
|
+
Branching dialogue graphs — `LineNode` / `ChoiceNode` / `BranchNode` / `ActionNode` / `EndNode`. `DialogueRunner` advances the graph with caller-supplied `ConditionResolver` + `ActionHandler` + `VariableBag`. `interpolate` substitutes `{var}` tokens. `createTextReveal` is a typewriter state machine. No rendering — pair with `@flare-engine/ui` `DialoguePanel` for the view layer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @flare-engine/dialogue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createDialogueGraph, createDialogueRunner, createTextReveal } from '@flare-engine/dialogue';
|
|
15
|
+
|
|
16
|
+
const graph = createDialogueGraph('start', new Map([
|
|
17
|
+
['start', { kind: 'line', speaker: 'mage', text: 'Hello {playerName}.', next: 'choice' }],
|
|
18
|
+
['choice', { kind: 'choice', options: [/* ... */] }],
|
|
19
|
+
]));
|
|
20
|
+
|
|
21
|
+
const runner = createDialogueRunner(graph, {
|
|
22
|
+
variables: { playerName: 'Twardowski' },
|
|
23
|
+
conditions: () => true,
|
|
24
|
+
actions: (id, args) => { /* ... */ },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const reveal = createTextReveal('Hello.', { cps: 30 });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See [API spec](../../docs/design/api-specs.md#flare-enginedialogue--5kb-gzip) and [skill reference](../../.github/skills/flare-dialogue/SKILL.md).
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Signal } from '@flare-engine/events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dialogue graph data types.
|
|
5
|
+
*
|
|
6
|
+
* A dialogue graph is a directed graph of nodes. Each node is one of:
|
|
7
|
+
* - `line`: NPC/character speaks. Optional `next` pointer.
|
|
8
|
+
* - `choice`: Player picks from options. Each option points to a node.
|
|
9
|
+
* - `branch`: Condition-based routing. First truthy condition wins.
|
|
10
|
+
* - `action`: Fires a callback, then advances to `next`.
|
|
11
|
+
* - `end`: Terminal node. Conversation stops.
|
|
12
|
+
*
|
|
13
|
+
* Graphs are plain JSON — no classes, no runtime coupling.
|
|
14
|
+
*/
|
|
15
|
+
interface LineNode {
|
|
16
|
+
readonly kind: 'line';
|
|
17
|
+
/** Speaker name / id. */
|
|
18
|
+
readonly speaker: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional portrait key resolved by the UI layer to an avatar image /
|
|
21
|
+
* shape / atlas frame. The dialogue runtime is portrait-agnostic — it
|
|
22
|
+
* forwards the key via `DialogueLineEvent.speakerPortraitKey`.
|
|
23
|
+
*/
|
|
24
|
+
readonly speakerPortraitKey?: string;
|
|
25
|
+
/** Text template. Supports `{var}` interpolation via the runner's variable bag. */
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** Next node id, or null to end. */
|
|
28
|
+
readonly next: string | null;
|
|
29
|
+
}
|
|
30
|
+
interface ChoiceOption {
|
|
31
|
+
/** Display text. Supports `{var}` interpolation. */
|
|
32
|
+
readonly text: string;
|
|
33
|
+
/** Node id to jump to when selected. */
|
|
34
|
+
readonly next: string;
|
|
35
|
+
/** Optional condition key — option is hidden when condition returns false. */
|
|
36
|
+
readonly condition?: string;
|
|
37
|
+
}
|
|
38
|
+
interface ChoiceNode {
|
|
39
|
+
readonly kind: 'choice';
|
|
40
|
+
/** Optional prompt text before the choices. */
|
|
41
|
+
readonly prompt?: string;
|
|
42
|
+
/** Speaker for the prompt, if any. */
|
|
43
|
+
readonly speaker?: string;
|
|
44
|
+
readonly options: readonly ChoiceOption[];
|
|
45
|
+
}
|
|
46
|
+
interface BranchArm {
|
|
47
|
+
/** Condition key evaluated by the runner's condition resolver. */
|
|
48
|
+
readonly condition: string;
|
|
49
|
+
/** Node id to jump to when condition is true. */
|
|
50
|
+
readonly next: string;
|
|
51
|
+
}
|
|
52
|
+
interface BranchNode {
|
|
53
|
+
readonly kind: 'branch';
|
|
54
|
+
readonly arms: readonly BranchArm[];
|
|
55
|
+
/** Fallback node id if no arm matches. Null → end. */
|
|
56
|
+
readonly fallback: string | null;
|
|
57
|
+
}
|
|
58
|
+
interface ActionNode {
|
|
59
|
+
readonly kind: 'action';
|
|
60
|
+
/** Callback key. Runner looks this up in the action registry. */
|
|
61
|
+
readonly action: string;
|
|
62
|
+
/** Next node id after the action fires. */
|
|
63
|
+
readonly next: string | null;
|
|
64
|
+
}
|
|
65
|
+
interface EndNode {
|
|
66
|
+
readonly kind: 'end';
|
|
67
|
+
}
|
|
68
|
+
type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;
|
|
69
|
+
interface DialogueGraph {
|
|
70
|
+
/** Unique graph identifier. */
|
|
71
|
+
readonly id: string;
|
|
72
|
+
/** Node id to start from. */
|
|
73
|
+
readonly start: string;
|
|
74
|
+
/** All nodes keyed by id. */
|
|
75
|
+
readonly nodes: Record<string, DialogueNode>;
|
|
76
|
+
}
|
|
77
|
+
declare function createDialogueGraph(id: string, start: string, nodes: Record<string, DialogueNode>): DialogueGraph;
|
|
78
|
+
declare function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dialogue runner — walks a DialogueGraph, evaluating conditions,
|
|
82
|
+
* interpolating variables, and emitting signals for each step.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
type ConditionResolver = (key: string) => boolean;
|
|
86
|
+
type ActionHandler = (key: string) => void | Promise<void>;
|
|
87
|
+
type VariableBag = Record<string, string | number>;
|
|
88
|
+
interface DialogueLineEvent {
|
|
89
|
+
readonly speaker: string;
|
|
90
|
+
/** Optional portrait key forwarded verbatim from the source LineNode. */
|
|
91
|
+
readonly speakerPortraitKey?: string;
|
|
92
|
+
readonly text: string;
|
|
93
|
+
readonly nodeId: string;
|
|
94
|
+
}
|
|
95
|
+
interface DialogueChoiceEvent {
|
|
96
|
+
readonly prompt: string | undefined;
|
|
97
|
+
readonly speaker: string | undefined;
|
|
98
|
+
readonly options: readonly ActiveOption[];
|
|
99
|
+
readonly nodeId: string;
|
|
100
|
+
}
|
|
101
|
+
interface ActiveOption {
|
|
102
|
+
readonly index: number;
|
|
103
|
+
readonly text: string;
|
|
104
|
+
readonly next: string;
|
|
105
|
+
}
|
|
106
|
+
type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';
|
|
107
|
+
declare function interpolate(template: string, vars: VariableBag): string;
|
|
108
|
+
interface DialogueRunner {
|
|
109
|
+
readonly onLine: Signal<DialogueLineEvent>;
|
|
110
|
+
readonly onChoice: Signal<DialogueChoiceEvent>;
|
|
111
|
+
readonly onAction: Signal<string>;
|
|
112
|
+
readonly onEnd: Signal<string>;
|
|
113
|
+
readonly state: DialogueRunnerState;
|
|
114
|
+
readonly currentNodeId: string | null;
|
|
115
|
+
readonly graphId: string | null;
|
|
116
|
+
start(graph: DialogueGraph, variables?: VariableBag): void;
|
|
117
|
+
advance(): void;
|
|
118
|
+
selectChoice(index: number): void;
|
|
119
|
+
stop(): void;
|
|
120
|
+
}
|
|
121
|
+
interface DialogueRunnerOptions {
|
|
122
|
+
conditions?: ConditionResolver;
|
|
123
|
+
actions?: ActionHandler;
|
|
124
|
+
}
|
|
125
|
+
declare function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Text-over-time reveal hook for dialogue lines.
|
|
129
|
+
*
|
|
130
|
+
* Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame
|
|
131
|
+
* and read `state.visibleText` for the currently revealed substring.
|
|
132
|
+
* Supports per-character speed, instant-complete, and pause-on-punctuation.
|
|
133
|
+
*/
|
|
134
|
+
interface TextRevealState {
|
|
135
|
+
/** Full text being revealed. */
|
|
136
|
+
readonly fullText: string;
|
|
137
|
+
/** Characters revealed so far. */
|
|
138
|
+
visibleText: string;
|
|
139
|
+
/** Whether the full text has been revealed. */
|
|
140
|
+
complete: boolean;
|
|
141
|
+
/** Internal fractional character cursor. */
|
|
142
|
+
_cursor: number;
|
|
143
|
+
}
|
|
144
|
+
interface TextRevealOptions {
|
|
145
|
+
/** Characters per second. Default: 30. */
|
|
146
|
+
speed?: number;
|
|
147
|
+
/** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */
|
|
148
|
+
punctuationDelay?: number;
|
|
149
|
+
}
|
|
150
|
+
declare function createTextReveal(text: string): TextRevealState;
|
|
151
|
+
declare function updateTextReveal(state: TextRevealState, dt: number, options?: TextRevealOptions): void;
|
|
152
|
+
declare function completeTextReveal(state: TextRevealState): void;
|
|
153
|
+
declare function resetTextReveal(state: TextRevealState, newText?: string): void;
|
|
154
|
+
|
|
155
|
+
export { type ActionHandler, type ActionNode, type ActiveOption, type BranchArm, type BranchNode, type ChoiceNode, type ChoiceOption, type ConditionResolver, type DialogueChoiceEvent, type DialogueGraph, type DialogueLineEvent, type DialogueNode, type DialogueRunner, type DialogueRunnerOptions, type DialogueRunnerState, type EndNode, type LineNode, type TextRevealOptions, type TextRevealState, type VariableBag, completeTextReveal, createDialogueGraph, createDialogueRunner, createTextReveal, getNode, interpolate, resetTextReveal, updateTextReveal };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Signal } from '@flare-engine/events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dialogue graph data types.
|
|
5
|
+
*
|
|
6
|
+
* A dialogue graph is a directed graph of nodes. Each node is one of:
|
|
7
|
+
* - `line`: NPC/character speaks. Optional `next` pointer.
|
|
8
|
+
* - `choice`: Player picks from options. Each option points to a node.
|
|
9
|
+
* - `branch`: Condition-based routing. First truthy condition wins.
|
|
10
|
+
* - `action`: Fires a callback, then advances to `next`.
|
|
11
|
+
* - `end`: Terminal node. Conversation stops.
|
|
12
|
+
*
|
|
13
|
+
* Graphs are plain JSON — no classes, no runtime coupling.
|
|
14
|
+
*/
|
|
15
|
+
interface LineNode {
|
|
16
|
+
readonly kind: 'line';
|
|
17
|
+
/** Speaker name / id. */
|
|
18
|
+
readonly speaker: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional portrait key resolved by the UI layer to an avatar image /
|
|
21
|
+
* shape / atlas frame. The dialogue runtime is portrait-agnostic — it
|
|
22
|
+
* forwards the key via `DialogueLineEvent.speakerPortraitKey`.
|
|
23
|
+
*/
|
|
24
|
+
readonly speakerPortraitKey?: string;
|
|
25
|
+
/** Text template. Supports `{var}` interpolation via the runner's variable bag. */
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** Next node id, or null to end. */
|
|
28
|
+
readonly next: string | null;
|
|
29
|
+
}
|
|
30
|
+
interface ChoiceOption {
|
|
31
|
+
/** Display text. Supports `{var}` interpolation. */
|
|
32
|
+
readonly text: string;
|
|
33
|
+
/** Node id to jump to when selected. */
|
|
34
|
+
readonly next: string;
|
|
35
|
+
/** Optional condition key — option is hidden when condition returns false. */
|
|
36
|
+
readonly condition?: string;
|
|
37
|
+
}
|
|
38
|
+
interface ChoiceNode {
|
|
39
|
+
readonly kind: 'choice';
|
|
40
|
+
/** Optional prompt text before the choices. */
|
|
41
|
+
readonly prompt?: string;
|
|
42
|
+
/** Speaker for the prompt, if any. */
|
|
43
|
+
readonly speaker?: string;
|
|
44
|
+
readonly options: readonly ChoiceOption[];
|
|
45
|
+
}
|
|
46
|
+
interface BranchArm {
|
|
47
|
+
/** Condition key evaluated by the runner's condition resolver. */
|
|
48
|
+
readonly condition: string;
|
|
49
|
+
/** Node id to jump to when condition is true. */
|
|
50
|
+
readonly next: string;
|
|
51
|
+
}
|
|
52
|
+
interface BranchNode {
|
|
53
|
+
readonly kind: 'branch';
|
|
54
|
+
readonly arms: readonly BranchArm[];
|
|
55
|
+
/** Fallback node id if no arm matches. Null → end. */
|
|
56
|
+
readonly fallback: string | null;
|
|
57
|
+
}
|
|
58
|
+
interface ActionNode {
|
|
59
|
+
readonly kind: 'action';
|
|
60
|
+
/** Callback key. Runner looks this up in the action registry. */
|
|
61
|
+
readonly action: string;
|
|
62
|
+
/** Next node id after the action fires. */
|
|
63
|
+
readonly next: string | null;
|
|
64
|
+
}
|
|
65
|
+
interface EndNode {
|
|
66
|
+
readonly kind: 'end';
|
|
67
|
+
}
|
|
68
|
+
type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;
|
|
69
|
+
interface DialogueGraph {
|
|
70
|
+
/** Unique graph identifier. */
|
|
71
|
+
readonly id: string;
|
|
72
|
+
/** Node id to start from. */
|
|
73
|
+
readonly start: string;
|
|
74
|
+
/** All nodes keyed by id. */
|
|
75
|
+
readonly nodes: Record<string, DialogueNode>;
|
|
76
|
+
}
|
|
77
|
+
declare function createDialogueGraph(id: string, start: string, nodes: Record<string, DialogueNode>): DialogueGraph;
|
|
78
|
+
declare function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dialogue runner — walks a DialogueGraph, evaluating conditions,
|
|
82
|
+
* interpolating variables, and emitting signals for each step.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
type ConditionResolver = (key: string) => boolean;
|
|
86
|
+
type ActionHandler = (key: string) => void | Promise<void>;
|
|
87
|
+
type VariableBag = Record<string, string | number>;
|
|
88
|
+
interface DialogueLineEvent {
|
|
89
|
+
readonly speaker: string;
|
|
90
|
+
/** Optional portrait key forwarded verbatim from the source LineNode. */
|
|
91
|
+
readonly speakerPortraitKey?: string;
|
|
92
|
+
readonly text: string;
|
|
93
|
+
readonly nodeId: string;
|
|
94
|
+
}
|
|
95
|
+
interface DialogueChoiceEvent {
|
|
96
|
+
readonly prompt: string | undefined;
|
|
97
|
+
readonly speaker: string | undefined;
|
|
98
|
+
readonly options: readonly ActiveOption[];
|
|
99
|
+
readonly nodeId: string;
|
|
100
|
+
}
|
|
101
|
+
interface ActiveOption {
|
|
102
|
+
readonly index: number;
|
|
103
|
+
readonly text: string;
|
|
104
|
+
readonly next: string;
|
|
105
|
+
}
|
|
106
|
+
type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';
|
|
107
|
+
declare function interpolate(template: string, vars: VariableBag): string;
|
|
108
|
+
interface DialogueRunner {
|
|
109
|
+
readonly onLine: Signal<DialogueLineEvent>;
|
|
110
|
+
readonly onChoice: Signal<DialogueChoiceEvent>;
|
|
111
|
+
readonly onAction: Signal<string>;
|
|
112
|
+
readonly onEnd: Signal<string>;
|
|
113
|
+
readonly state: DialogueRunnerState;
|
|
114
|
+
readonly currentNodeId: string | null;
|
|
115
|
+
readonly graphId: string | null;
|
|
116
|
+
start(graph: DialogueGraph, variables?: VariableBag): void;
|
|
117
|
+
advance(): void;
|
|
118
|
+
selectChoice(index: number): void;
|
|
119
|
+
stop(): void;
|
|
120
|
+
}
|
|
121
|
+
interface DialogueRunnerOptions {
|
|
122
|
+
conditions?: ConditionResolver;
|
|
123
|
+
actions?: ActionHandler;
|
|
124
|
+
}
|
|
125
|
+
declare function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Text-over-time reveal hook for dialogue lines.
|
|
129
|
+
*
|
|
130
|
+
* Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame
|
|
131
|
+
* and read `state.visibleText` for the currently revealed substring.
|
|
132
|
+
* Supports per-character speed, instant-complete, and pause-on-punctuation.
|
|
133
|
+
*/
|
|
134
|
+
interface TextRevealState {
|
|
135
|
+
/** Full text being revealed. */
|
|
136
|
+
readonly fullText: string;
|
|
137
|
+
/** Characters revealed so far. */
|
|
138
|
+
visibleText: string;
|
|
139
|
+
/** Whether the full text has been revealed. */
|
|
140
|
+
complete: boolean;
|
|
141
|
+
/** Internal fractional character cursor. */
|
|
142
|
+
_cursor: number;
|
|
143
|
+
}
|
|
144
|
+
interface TextRevealOptions {
|
|
145
|
+
/** Characters per second. Default: 30. */
|
|
146
|
+
speed?: number;
|
|
147
|
+
/** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */
|
|
148
|
+
punctuationDelay?: number;
|
|
149
|
+
}
|
|
150
|
+
declare function createTextReveal(text: string): TextRevealState;
|
|
151
|
+
declare function updateTextReveal(state: TextRevealState, dt: number, options?: TextRevealOptions): void;
|
|
152
|
+
declare function completeTextReveal(state: TextRevealState): void;
|
|
153
|
+
declare function resetTextReveal(state: TextRevealState, newText?: string): void;
|
|
154
|
+
|
|
155
|
+
export { type ActionHandler, type ActionNode, type ActiveOption, type BranchArm, type BranchNode, type ChoiceNode, type ChoiceOption, type ConditionResolver, type DialogueChoiceEvent, type DialogueGraph, type DialogueLineEvent, type DialogueNode, type DialogueRunner, type DialogueRunnerOptions, type DialogueRunnerState, type EndNode, type LineNode, type TextRevealOptions, type TextRevealState, type VariableBag, completeTextReveal, createDialogueGraph, createDialogueRunner, createTextReveal, getNode, interpolate, resetTextReveal, updateTextReveal };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
completeTextReveal: () => completeTextReveal,
|
|
24
|
+
createDialogueGraph: () => createDialogueGraph,
|
|
25
|
+
createDialogueRunner: () => createDialogueRunner,
|
|
26
|
+
createTextReveal: () => createTextReveal,
|
|
27
|
+
getNode: () => getNode,
|
|
28
|
+
interpolate: () => interpolate,
|
|
29
|
+
resetTextReveal: () => resetTextReveal,
|
|
30
|
+
updateTextReveal: () => updateTextReveal
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/graph.ts
|
|
35
|
+
function createDialogueGraph(id, start, nodes) {
|
|
36
|
+
return { id, start, nodes };
|
|
37
|
+
}
|
|
38
|
+
function getNode(graph, nodeId) {
|
|
39
|
+
return graph.nodes[nodeId];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/runner.ts
|
|
43
|
+
var import_events = require("@flare-engine/events");
|
|
44
|
+
function interpolate(template, vars) {
|
|
45
|
+
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|
46
|
+
const val = vars[key];
|
|
47
|
+
return val !== void 0 ? String(val) : `{${key}}`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function createDialogueRunner(options) {
|
|
51
|
+
const onLine = new import_events.Signal();
|
|
52
|
+
const onChoice = new import_events.Signal();
|
|
53
|
+
const onAction = new import_events.Signal();
|
|
54
|
+
const onEnd = new import_events.Signal();
|
|
55
|
+
const conditions = options?.conditions ?? (() => true);
|
|
56
|
+
const actions = options?.actions;
|
|
57
|
+
let _state = "idle";
|
|
58
|
+
let _graph = null;
|
|
59
|
+
let _currentNodeId = null;
|
|
60
|
+
let _vars = {};
|
|
61
|
+
let _activeOptions = [];
|
|
62
|
+
function setState(s) {
|
|
63
|
+
_state = s;
|
|
64
|
+
}
|
|
65
|
+
function processNode(nodeId) {
|
|
66
|
+
if (!_graph) return;
|
|
67
|
+
const node = getNode(_graph, nodeId);
|
|
68
|
+
if (!node) {
|
|
69
|
+
end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
_currentNodeId = nodeId;
|
|
73
|
+
switch (node.kind) {
|
|
74
|
+
case "line":
|
|
75
|
+
processLine(node, nodeId);
|
|
76
|
+
break;
|
|
77
|
+
case "choice":
|
|
78
|
+
processChoice(node, nodeId);
|
|
79
|
+
break;
|
|
80
|
+
case "branch":
|
|
81
|
+
processBranch(node);
|
|
82
|
+
break;
|
|
83
|
+
case "action":
|
|
84
|
+
processAction(node);
|
|
85
|
+
break;
|
|
86
|
+
case "end":
|
|
87
|
+
end();
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function processLine(node, nodeId) {
|
|
92
|
+
setState("running");
|
|
93
|
+
const text = interpolate(node.text, _vars);
|
|
94
|
+
onLine.emit({
|
|
95
|
+
speaker: node.speaker,
|
|
96
|
+
speakerPortraitKey: node.speakerPortraitKey,
|
|
97
|
+
text,
|
|
98
|
+
nodeId
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function processChoice(node, nodeId) {
|
|
102
|
+
_activeOptions = [];
|
|
103
|
+
for (let i = 0; i < node.options.length; i++) {
|
|
104
|
+
const opt = node.options[i];
|
|
105
|
+
if (opt.condition && !conditions(opt.condition)) continue;
|
|
106
|
+
_activeOptions.push({
|
|
107
|
+
index: _activeOptions.length,
|
|
108
|
+
text: interpolate(opt.text, _vars),
|
|
109
|
+
next: opt.next
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (_activeOptions.length === 0) {
|
|
113
|
+
end();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setState("waiting-choice");
|
|
117
|
+
onChoice.emit({
|
|
118
|
+
prompt: node.prompt ? interpolate(node.prompt, _vars) : void 0,
|
|
119
|
+
speaker: node.speaker,
|
|
120
|
+
options: _activeOptions,
|
|
121
|
+
nodeId
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function processBranch(node) {
|
|
125
|
+
for (let i = 0; i < node.arms.length; i++) {
|
|
126
|
+
const arm = node.arms[i];
|
|
127
|
+
if (conditions(arm.condition)) {
|
|
128
|
+
processNode(arm.next);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (node.fallback) {
|
|
133
|
+
processNode(node.fallback);
|
|
134
|
+
} else {
|
|
135
|
+
end();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function processAction(node) {
|
|
139
|
+
onAction.emit(node.action);
|
|
140
|
+
actions?.(node.action);
|
|
141
|
+
if (node.next) {
|
|
142
|
+
processNode(node.next);
|
|
143
|
+
} else {
|
|
144
|
+
end();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function end() {
|
|
148
|
+
setState("ended");
|
|
149
|
+
const graphId = _graph?.id ?? "";
|
|
150
|
+
_currentNodeId = null;
|
|
151
|
+
onEnd.emit(graphId);
|
|
152
|
+
}
|
|
153
|
+
const runner = {
|
|
154
|
+
onLine,
|
|
155
|
+
onChoice,
|
|
156
|
+
onAction,
|
|
157
|
+
onEnd,
|
|
158
|
+
get state() {
|
|
159
|
+
return _state;
|
|
160
|
+
},
|
|
161
|
+
get currentNodeId() {
|
|
162
|
+
return _currentNodeId;
|
|
163
|
+
},
|
|
164
|
+
get graphId() {
|
|
165
|
+
return _graph?.id ?? null;
|
|
166
|
+
},
|
|
167
|
+
start(graph, variables) {
|
|
168
|
+
_graph = graph;
|
|
169
|
+
_vars = variables ?? {};
|
|
170
|
+
_activeOptions = [];
|
|
171
|
+
setState("running");
|
|
172
|
+
processNode(graph.start);
|
|
173
|
+
},
|
|
174
|
+
advance() {
|
|
175
|
+
if (_state !== "running" || !_graph || !_currentNodeId) return;
|
|
176
|
+
const node = getNode(_graph, _currentNodeId);
|
|
177
|
+
if (!node || node.kind !== "line") return;
|
|
178
|
+
if (node.next) {
|
|
179
|
+
processNode(node.next);
|
|
180
|
+
} else {
|
|
181
|
+
end();
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
selectChoice(index) {
|
|
185
|
+
if (_state !== "waiting-choice") return;
|
|
186
|
+
const opt = _activeOptions[index];
|
|
187
|
+
if (!opt) return;
|
|
188
|
+
_activeOptions = [];
|
|
189
|
+
setState("running");
|
|
190
|
+
processNode(opt.next);
|
|
191
|
+
},
|
|
192
|
+
stop() {
|
|
193
|
+
setState("idle");
|
|
194
|
+
_graph = null;
|
|
195
|
+
_currentNodeId = null;
|
|
196
|
+
_vars = {};
|
|
197
|
+
_activeOptions = [];
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return runner;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/text-reveal.ts
|
|
204
|
+
var DEFAULT_SPEED = 30;
|
|
205
|
+
var DEFAULT_PUNCT_DELAY = 0.15;
|
|
206
|
+
var PUNCTUATION = /* @__PURE__ */ new Set([".", "!", "?", ":", ";"]);
|
|
207
|
+
function createTextReveal(text) {
|
|
208
|
+
if (text.length === 0) {
|
|
209
|
+
return { fullText: text, visibleText: text, complete: true, _cursor: 0 };
|
|
210
|
+
}
|
|
211
|
+
return { fullText: text, visibleText: "", complete: false, _cursor: 0 };
|
|
212
|
+
}
|
|
213
|
+
function updateTextReveal(state, dt, options) {
|
|
214
|
+
if (state.complete) return;
|
|
215
|
+
const speed = options?.speed ?? DEFAULT_SPEED;
|
|
216
|
+
const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;
|
|
217
|
+
const len = state.fullText.length;
|
|
218
|
+
const prevCharIdx = Math.floor(state._cursor) - 1;
|
|
219
|
+
let effectiveSpeed = speed;
|
|
220
|
+
if (prevCharIdx >= 0 && prevCharIdx < len) {
|
|
221
|
+
const prevChar = state.fullText[prevCharIdx];
|
|
222
|
+
if (PUNCTUATION.has(prevChar)) {
|
|
223
|
+
effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
state._cursor += effectiveSpeed * dt;
|
|
227
|
+
const charCount = Math.min(Math.floor(state._cursor), len);
|
|
228
|
+
state.visibleText = state.fullText.slice(0, charCount);
|
|
229
|
+
if (charCount >= len) {
|
|
230
|
+
state.visibleText = state.fullText;
|
|
231
|
+
state.complete = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function completeTextReveal(state) {
|
|
235
|
+
state.visibleText = state.fullText;
|
|
236
|
+
state._cursor = state.fullText.length;
|
|
237
|
+
state.complete = true;
|
|
238
|
+
}
|
|
239
|
+
function resetTextReveal(state, newText) {
|
|
240
|
+
const text = newText ?? state.fullText;
|
|
241
|
+
state.fullText = text;
|
|
242
|
+
state.visibleText = "";
|
|
243
|
+
state._cursor = 0;
|
|
244
|
+
state.complete = text.length === 0;
|
|
245
|
+
}
|
|
246
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
247
|
+
0 && (module.exports = {
|
|
248
|
+
completeTextReveal,
|
|
249
|
+
createDialogueGraph,
|
|
250
|
+
createDialogueRunner,
|
|
251
|
+
createTextReveal,
|
|
252
|
+
getNode,
|
|
253
|
+
interpolate,
|
|
254
|
+
resetTextReveal,
|
|
255
|
+
updateTextReveal
|
|
256
|
+
});
|
|
257
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/graph.ts","../src/runner.ts","../src/text-reveal.ts"],"sourcesContent":["export {\n type LineNode,\n type ChoiceOption,\n type ChoiceNode,\n type BranchArm,\n type BranchNode,\n type ActionNode,\n type EndNode,\n type DialogueNode,\n type DialogueGraph,\n createDialogueGraph,\n getNode,\n} from './graph.js';\n\nexport {\n type ConditionResolver,\n type ActionHandler,\n type VariableBag,\n type DialogueLineEvent,\n type DialogueChoiceEvent,\n type ActiveOption,\n type DialogueRunnerState,\n type DialogueRunner,\n type DialogueRunnerOptions,\n createDialogueRunner,\n interpolate,\n} from './runner.js';\n\nexport {\n type TextRevealState,\n type TextRevealOptions,\n createTextReveal,\n updateTextReveal,\n completeTextReveal,\n resetTextReveal,\n} from './text-reveal.js';\n","/**\n * Dialogue graph data types.\n *\n * A dialogue graph is a directed graph of nodes. Each node is one of:\n * - `line`: NPC/character speaks. Optional `next` pointer.\n * - `choice`: Player picks from options. Each option points to a node.\n * - `branch`: Condition-based routing. First truthy condition wins.\n * - `action`: Fires a callback, then advances to `next`.\n * - `end`: Terminal node. Conversation stops.\n *\n * Graphs are plain JSON — no classes, no runtime coupling.\n */\n\n// ─── Node types ──────────────────────────────────────────────────\n\nexport interface LineNode {\n readonly kind: 'line';\n /** Speaker name / id. */\n readonly speaker: string;\n /**\n * Optional portrait key resolved by the UI layer to an avatar image /\n * shape / atlas frame. The dialogue runtime is portrait-agnostic — it\n * forwards the key via `DialogueLineEvent.speakerPortraitKey`.\n */\n readonly speakerPortraitKey?: string;\n /** Text template. Supports `{var}` interpolation via the runner's variable bag. */\n readonly text: string;\n /** Next node id, or null to end. */\n readonly next: string | null;\n}\n\nexport interface ChoiceOption {\n /** Display text. Supports `{var}` interpolation. */\n readonly text: string;\n /** Node id to jump to when selected. */\n readonly next: string;\n /** Optional condition key — option is hidden when condition returns false. */\n readonly condition?: string;\n}\n\nexport interface ChoiceNode {\n readonly kind: 'choice';\n /** Optional prompt text before the choices. */\n readonly prompt?: string;\n /** Speaker for the prompt, if any. */\n readonly speaker?: string;\n readonly options: readonly ChoiceOption[];\n}\n\nexport interface BranchArm {\n /** Condition key evaluated by the runner's condition resolver. */\n readonly condition: string;\n /** Node id to jump to when condition is true. */\n readonly next: string;\n}\n\nexport interface BranchNode {\n readonly kind: 'branch';\n readonly arms: readonly BranchArm[];\n /** Fallback node id if no arm matches. Null → end. */\n readonly fallback: string | null;\n}\n\nexport interface ActionNode {\n readonly kind: 'action';\n /** Callback key. Runner looks this up in the action registry. */\n readonly action: string;\n /** Next node id after the action fires. */\n readonly next: string | null;\n}\n\nexport interface EndNode {\n readonly kind: 'end';\n}\n\nexport type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;\n\n// ─── Graph ───────────────────────────────────────────────────────\n\nexport interface DialogueGraph {\n /** Unique graph identifier. */\n readonly id: string;\n /** Node id to start from. */\n readonly start: string;\n /** All nodes keyed by id. */\n readonly nodes: Record<string, DialogueNode>;\n}\n\nexport function createDialogueGraph(\n id: string,\n start: string,\n nodes: Record<string, DialogueNode>,\n): DialogueGraph {\n return { id, start, nodes };\n}\n\nexport function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined {\n return graph.nodes[nodeId];\n}\n","/**\n * Dialogue runner — walks a DialogueGraph, evaluating conditions,\n * interpolating variables, and emitting signals for each step.\n */\n\nimport { Signal } from '@flare-engine/events';\nimport type { ChoiceNode, DialogueGraph, LineNode } from './graph.js';\nimport { getNode } from './graph.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type ConditionResolver = (key: string) => boolean;\nexport type ActionHandler = (key: string) => void | Promise<void>;\nexport type VariableBag = Record<string, string | number>;\n\nexport interface DialogueLineEvent {\n readonly speaker: string;\n /** Optional portrait key forwarded verbatim from the source LineNode. */\n readonly speakerPortraitKey?: string;\n readonly text: string;\n readonly nodeId: string;\n}\n\nexport interface DialogueChoiceEvent {\n readonly prompt: string | undefined;\n readonly speaker: string | undefined;\n readonly options: readonly ActiveOption[];\n readonly nodeId: string;\n}\n\nexport interface ActiveOption {\n readonly index: number;\n readonly text: string;\n readonly next: string;\n}\n\nexport type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';\n\n// ─── Interpolation ───────────────────────────────────────────────\n\nexport function interpolate(template: string, vars: VariableBag): string {\n return template.replace(/\\{(\\w+)\\}/g, (_match, key: string) => {\n const val = vars[key];\n return val !== undefined ? String(val) : `{${key}}`;\n });\n}\n\n// ─── Runner ──────────────────────────────────────────────────────\n\nexport interface DialogueRunner {\n readonly onLine: Signal<DialogueLineEvent>;\n readonly onChoice: Signal<DialogueChoiceEvent>;\n readonly onAction: Signal<string>;\n readonly onEnd: Signal<string>;\n\n readonly state: DialogueRunnerState;\n readonly currentNodeId: string | null;\n readonly graphId: string | null;\n\n start(graph: DialogueGraph, variables?: VariableBag): void;\n advance(): void;\n selectChoice(index: number): void;\n stop(): void;\n}\n\nexport interface DialogueRunnerOptions {\n conditions?: ConditionResolver;\n actions?: ActionHandler;\n}\n\nexport function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner {\n const onLine = new Signal<DialogueLineEvent>();\n const onChoice = new Signal<DialogueChoiceEvent>();\n const onAction = new Signal<string>();\n const onEnd = new Signal<string>();\n\n const conditions = options?.conditions ?? (() => true);\n const actions = options?.actions;\n\n let _state: DialogueRunnerState = 'idle';\n let _graph: DialogueGraph | null = null;\n let _currentNodeId: string | null = null;\n let _vars: VariableBag = {};\n let _activeOptions: ActiveOption[] = [];\n\n function setState(s: DialogueRunnerState): void {\n _state = s;\n }\n\n function processNode(nodeId: string): void {\n if (!_graph) return;\n const node = getNode(_graph, nodeId);\n if (!node) {\n end();\n return;\n }\n _currentNodeId = nodeId;\n\n switch (node.kind) {\n case 'line':\n processLine(node, nodeId);\n break;\n case 'choice':\n processChoice(node, nodeId);\n break;\n case 'branch':\n processBranch(node);\n break;\n case 'action':\n processAction(node);\n break;\n case 'end':\n end();\n break;\n }\n }\n\n function processLine(node: LineNode, nodeId: string): void {\n setState('running');\n const text = interpolate(node.text, _vars);\n onLine.emit({\n speaker: node.speaker,\n speakerPortraitKey: node.speakerPortraitKey,\n text,\n nodeId,\n });\n }\n\n function processChoice(node: ChoiceNode, nodeId: string): void {\n // Filter options by condition\n _activeOptions = [];\n for (let i = 0; i < node.options.length; i++) {\n const opt = node.options[i]!;\n if (opt.condition && !conditions(opt.condition)) continue;\n _activeOptions.push({\n index: _activeOptions.length,\n text: interpolate(opt.text, _vars),\n next: opt.next,\n });\n }\n\n if (_activeOptions.length === 0) {\n // No valid options — treat as end\n end();\n return;\n }\n\n setState('waiting-choice');\n onChoice.emit({\n prompt: node.prompt ? interpolate(node.prompt, _vars) : undefined,\n speaker: node.speaker,\n options: _activeOptions,\n nodeId,\n });\n }\n\n function processBranch(node: {\n arms: readonly { condition: string; next: string }[];\n fallback: string | null;\n }): void {\n for (let i = 0; i < node.arms.length; i++) {\n const arm = node.arms[i]!;\n if (conditions(arm.condition)) {\n processNode(arm.next);\n return;\n }\n }\n if (node.fallback) {\n processNode(node.fallback);\n } else {\n end();\n }\n }\n\n function processAction(node: { action: string; next: string | null }): void {\n onAction.emit(node.action);\n actions?.(node.action);\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n }\n\n function end(): void {\n setState('ended');\n const graphId = _graph?.id ?? '';\n _currentNodeId = null;\n onEnd.emit(graphId);\n }\n\n const runner: DialogueRunner = {\n onLine,\n onChoice,\n onAction,\n onEnd,\n\n get state() {\n return _state;\n },\n get currentNodeId() {\n return _currentNodeId;\n },\n get graphId() {\n return _graph?.id ?? null;\n },\n\n start(graph: DialogueGraph, variables?: VariableBag): void {\n _graph = graph;\n _vars = variables ?? {};\n _activeOptions = [];\n setState('running');\n processNode(graph.start);\n },\n\n advance(): void {\n if (_state !== 'running' || !_graph || !_currentNodeId) return;\n const node = getNode(_graph, _currentNodeId);\n if (!node || node.kind !== 'line') return;\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n },\n\n selectChoice(index: number): void {\n if (_state !== 'waiting-choice') return;\n const opt = _activeOptions[index];\n if (!opt) return;\n _activeOptions = [];\n setState('running');\n processNode(opt.next);\n },\n\n stop(): void {\n setState('idle');\n _graph = null;\n _currentNodeId = null;\n _vars = {};\n _activeOptions = [];\n },\n };\n\n return runner;\n}\n","/**\n * Text-over-time reveal hook for dialogue lines.\n *\n * Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame\n * and read `state.visibleText` for the currently revealed substring.\n * Supports per-character speed, instant-complete, and pause-on-punctuation.\n */\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TextRevealState {\n /** Full text being revealed. */\n readonly fullText: string;\n /** Characters revealed so far. */\n visibleText: string;\n /** Whether the full text has been revealed. */\n complete: boolean;\n /** Internal fractional character cursor. */\n _cursor: number;\n}\n\nexport interface TextRevealOptions {\n /** Characters per second. Default: 30. */\n speed?: number;\n /** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */\n punctuationDelay?: number;\n}\n\nconst DEFAULT_SPEED = 30;\nconst DEFAULT_PUNCT_DELAY = 0.15;\n\nconst PUNCTUATION = new Set(['.', '!', '?', ':', ';']);\n\n// ─── API ─────────────────────────────────────────────────────────\n\nexport function createTextReveal(text: string): TextRevealState {\n if (text.length === 0) {\n return { fullText: text, visibleText: text, complete: true, _cursor: 0 };\n }\n return { fullText: text, visibleText: '', complete: false, _cursor: 0 };\n}\n\nexport function updateTextReveal(\n state: TextRevealState,\n dt: number,\n options?: TextRevealOptions,\n): void {\n if (state.complete) return;\n\n const speed = options?.speed ?? DEFAULT_SPEED;\n const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;\n const len = state.fullText.length;\n\n // Determine effective speed — slow down after punctuation\n const prevCharIdx = Math.floor(state._cursor) - 1;\n let effectiveSpeed = speed;\n if (prevCharIdx >= 0 && prevCharIdx < len) {\n const prevChar = state.fullText[prevCharIdx]!;\n if (PUNCTUATION.has(prevChar)) {\n // Slow down: treat the punctuation gap as taking `punctDelay` seconds\n // Effective speed = 1 / punctDelay so cursor advances ~1 char in punctDelay time\n effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;\n }\n }\n\n state._cursor += effectiveSpeed * dt;\n const charCount = Math.min(Math.floor(state._cursor), len);\n state.visibleText = state.fullText.slice(0, charCount);\n\n if (charCount >= len) {\n state.visibleText = state.fullText;\n state.complete = true;\n }\n}\n\nexport function completeTextReveal(state: TextRevealState): void {\n state.visibleText = state.fullText;\n state._cursor = state.fullText.length;\n state.complete = true;\n}\n\nexport function resetTextReveal(state: TextRevealState, newText?: string): void {\n const text = newText ?? state.fullText;\n (state as { fullText: string }).fullText = text;\n state.visibleText = '';\n state._cursor = 0;\n state.complete = text.length === 0;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// src/graph.ts
|
|
2
|
+
function createDialogueGraph(id, start, nodes) {
|
|
3
|
+
return { id, start, nodes };
|
|
4
|
+
}
|
|
5
|
+
function getNode(graph, nodeId) {
|
|
6
|
+
return graph.nodes[nodeId];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/runner.ts
|
|
10
|
+
import { Signal } from "@flare-engine/events";
|
|
11
|
+
function interpolate(template, vars) {
|
|
12
|
+
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|
13
|
+
const val = vars[key];
|
|
14
|
+
return val !== void 0 ? String(val) : `{${key}}`;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function createDialogueRunner(options) {
|
|
18
|
+
const onLine = new Signal();
|
|
19
|
+
const onChoice = new Signal();
|
|
20
|
+
const onAction = new Signal();
|
|
21
|
+
const onEnd = new Signal();
|
|
22
|
+
const conditions = options?.conditions ?? (() => true);
|
|
23
|
+
const actions = options?.actions;
|
|
24
|
+
let _state = "idle";
|
|
25
|
+
let _graph = null;
|
|
26
|
+
let _currentNodeId = null;
|
|
27
|
+
let _vars = {};
|
|
28
|
+
let _activeOptions = [];
|
|
29
|
+
function setState(s) {
|
|
30
|
+
_state = s;
|
|
31
|
+
}
|
|
32
|
+
function processNode(nodeId) {
|
|
33
|
+
if (!_graph) return;
|
|
34
|
+
const node = getNode(_graph, nodeId);
|
|
35
|
+
if (!node) {
|
|
36
|
+
end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
_currentNodeId = nodeId;
|
|
40
|
+
switch (node.kind) {
|
|
41
|
+
case "line":
|
|
42
|
+
processLine(node, nodeId);
|
|
43
|
+
break;
|
|
44
|
+
case "choice":
|
|
45
|
+
processChoice(node, nodeId);
|
|
46
|
+
break;
|
|
47
|
+
case "branch":
|
|
48
|
+
processBranch(node);
|
|
49
|
+
break;
|
|
50
|
+
case "action":
|
|
51
|
+
processAction(node);
|
|
52
|
+
break;
|
|
53
|
+
case "end":
|
|
54
|
+
end();
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function processLine(node, nodeId) {
|
|
59
|
+
setState("running");
|
|
60
|
+
const text = interpolate(node.text, _vars);
|
|
61
|
+
onLine.emit({
|
|
62
|
+
speaker: node.speaker,
|
|
63
|
+
speakerPortraitKey: node.speakerPortraitKey,
|
|
64
|
+
text,
|
|
65
|
+
nodeId
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function processChoice(node, nodeId) {
|
|
69
|
+
_activeOptions = [];
|
|
70
|
+
for (let i = 0; i < node.options.length; i++) {
|
|
71
|
+
const opt = node.options[i];
|
|
72
|
+
if (opt.condition && !conditions(opt.condition)) continue;
|
|
73
|
+
_activeOptions.push({
|
|
74
|
+
index: _activeOptions.length,
|
|
75
|
+
text: interpolate(opt.text, _vars),
|
|
76
|
+
next: opt.next
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (_activeOptions.length === 0) {
|
|
80
|
+
end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setState("waiting-choice");
|
|
84
|
+
onChoice.emit({
|
|
85
|
+
prompt: node.prompt ? interpolate(node.prompt, _vars) : void 0,
|
|
86
|
+
speaker: node.speaker,
|
|
87
|
+
options: _activeOptions,
|
|
88
|
+
nodeId
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function processBranch(node) {
|
|
92
|
+
for (let i = 0; i < node.arms.length; i++) {
|
|
93
|
+
const arm = node.arms[i];
|
|
94
|
+
if (conditions(arm.condition)) {
|
|
95
|
+
processNode(arm.next);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (node.fallback) {
|
|
100
|
+
processNode(node.fallback);
|
|
101
|
+
} else {
|
|
102
|
+
end();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function processAction(node) {
|
|
106
|
+
onAction.emit(node.action);
|
|
107
|
+
actions?.(node.action);
|
|
108
|
+
if (node.next) {
|
|
109
|
+
processNode(node.next);
|
|
110
|
+
} else {
|
|
111
|
+
end();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function end() {
|
|
115
|
+
setState("ended");
|
|
116
|
+
const graphId = _graph?.id ?? "";
|
|
117
|
+
_currentNodeId = null;
|
|
118
|
+
onEnd.emit(graphId);
|
|
119
|
+
}
|
|
120
|
+
const runner = {
|
|
121
|
+
onLine,
|
|
122
|
+
onChoice,
|
|
123
|
+
onAction,
|
|
124
|
+
onEnd,
|
|
125
|
+
get state() {
|
|
126
|
+
return _state;
|
|
127
|
+
},
|
|
128
|
+
get currentNodeId() {
|
|
129
|
+
return _currentNodeId;
|
|
130
|
+
},
|
|
131
|
+
get graphId() {
|
|
132
|
+
return _graph?.id ?? null;
|
|
133
|
+
},
|
|
134
|
+
start(graph, variables) {
|
|
135
|
+
_graph = graph;
|
|
136
|
+
_vars = variables ?? {};
|
|
137
|
+
_activeOptions = [];
|
|
138
|
+
setState("running");
|
|
139
|
+
processNode(graph.start);
|
|
140
|
+
},
|
|
141
|
+
advance() {
|
|
142
|
+
if (_state !== "running" || !_graph || !_currentNodeId) return;
|
|
143
|
+
const node = getNode(_graph, _currentNodeId);
|
|
144
|
+
if (!node || node.kind !== "line") return;
|
|
145
|
+
if (node.next) {
|
|
146
|
+
processNode(node.next);
|
|
147
|
+
} else {
|
|
148
|
+
end();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
selectChoice(index) {
|
|
152
|
+
if (_state !== "waiting-choice") return;
|
|
153
|
+
const opt = _activeOptions[index];
|
|
154
|
+
if (!opt) return;
|
|
155
|
+
_activeOptions = [];
|
|
156
|
+
setState("running");
|
|
157
|
+
processNode(opt.next);
|
|
158
|
+
},
|
|
159
|
+
stop() {
|
|
160
|
+
setState("idle");
|
|
161
|
+
_graph = null;
|
|
162
|
+
_currentNodeId = null;
|
|
163
|
+
_vars = {};
|
|
164
|
+
_activeOptions = [];
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return runner;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/text-reveal.ts
|
|
171
|
+
var DEFAULT_SPEED = 30;
|
|
172
|
+
var DEFAULT_PUNCT_DELAY = 0.15;
|
|
173
|
+
var PUNCTUATION = /* @__PURE__ */ new Set([".", "!", "?", ":", ";"]);
|
|
174
|
+
function createTextReveal(text) {
|
|
175
|
+
if (text.length === 0) {
|
|
176
|
+
return { fullText: text, visibleText: text, complete: true, _cursor: 0 };
|
|
177
|
+
}
|
|
178
|
+
return { fullText: text, visibleText: "", complete: false, _cursor: 0 };
|
|
179
|
+
}
|
|
180
|
+
function updateTextReveal(state, dt, options) {
|
|
181
|
+
if (state.complete) return;
|
|
182
|
+
const speed = options?.speed ?? DEFAULT_SPEED;
|
|
183
|
+
const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;
|
|
184
|
+
const len = state.fullText.length;
|
|
185
|
+
const prevCharIdx = Math.floor(state._cursor) - 1;
|
|
186
|
+
let effectiveSpeed = speed;
|
|
187
|
+
if (prevCharIdx >= 0 && prevCharIdx < len) {
|
|
188
|
+
const prevChar = state.fullText[prevCharIdx];
|
|
189
|
+
if (PUNCTUATION.has(prevChar)) {
|
|
190
|
+
effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
state._cursor += effectiveSpeed * dt;
|
|
194
|
+
const charCount = Math.min(Math.floor(state._cursor), len);
|
|
195
|
+
state.visibleText = state.fullText.slice(0, charCount);
|
|
196
|
+
if (charCount >= len) {
|
|
197
|
+
state.visibleText = state.fullText;
|
|
198
|
+
state.complete = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function completeTextReveal(state) {
|
|
202
|
+
state.visibleText = state.fullText;
|
|
203
|
+
state._cursor = state.fullText.length;
|
|
204
|
+
state.complete = true;
|
|
205
|
+
}
|
|
206
|
+
function resetTextReveal(state, newText) {
|
|
207
|
+
const text = newText ?? state.fullText;
|
|
208
|
+
state.fullText = text;
|
|
209
|
+
state.visibleText = "";
|
|
210
|
+
state._cursor = 0;
|
|
211
|
+
state.complete = text.length === 0;
|
|
212
|
+
}
|
|
213
|
+
export {
|
|
214
|
+
completeTextReveal,
|
|
215
|
+
createDialogueGraph,
|
|
216
|
+
createDialogueRunner,
|
|
217
|
+
createTextReveal,
|
|
218
|
+
getNode,
|
|
219
|
+
interpolate,
|
|
220
|
+
resetTextReveal,
|
|
221
|
+
updateTextReveal
|
|
222
|
+
};
|
|
223
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/graph.ts","../src/runner.ts","../src/text-reveal.ts"],"sourcesContent":["/**\n * Dialogue graph data types.\n *\n * A dialogue graph is a directed graph of nodes. Each node is one of:\n * - `line`: NPC/character speaks. Optional `next` pointer.\n * - `choice`: Player picks from options. Each option points to a node.\n * - `branch`: Condition-based routing. First truthy condition wins.\n * - `action`: Fires a callback, then advances to `next`.\n * - `end`: Terminal node. Conversation stops.\n *\n * Graphs are plain JSON — no classes, no runtime coupling.\n */\n\n// ─── Node types ──────────────────────────────────────────────────\n\nexport interface LineNode {\n readonly kind: 'line';\n /** Speaker name / id. */\n readonly speaker: string;\n /**\n * Optional portrait key resolved by the UI layer to an avatar image /\n * shape / atlas frame. The dialogue runtime is portrait-agnostic — it\n * forwards the key via `DialogueLineEvent.speakerPortraitKey`.\n */\n readonly speakerPortraitKey?: string;\n /** Text template. Supports `{var}` interpolation via the runner's variable bag. */\n readonly text: string;\n /** Next node id, or null to end. */\n readonly next: string | null;\n}\n\nexport interface ChoiceOption {\n /** Display text. Supports `{var}` interpolation. */\n readonly text: string;\n /** Node id to jump to when selected. */\n readonly next: string;\n /** Optional condition key — option is hidden when condition returns false. */\n readonly condition?: string;\n}\n\nexport interface ChoiceNode {\n readonly kind: 'choice';\n /** Optional prompt text before the choices. */\n readonly prompt?: string;\n /** Speaker for the prompt, if any. */\n readonly speaker?: string;\n readonly options: readonly ChoiceOption[];\n}\n\nexport interface BranchArm {\n /** Condition key evaluated by the runner's condition resolver. */\n readonly condition: string;\n /** Node id to jump to when condition is true. */\n readonly next: string;\n}\n\nexport interface BranchNode {\n readonly kind: 'branch';\n readonly arms: readonly BranchArm[];\n /** Fallback node id if no arm matches. Null → end. */\n readonly fallback: string | null;\n}\n\nexport interface ActionNode {\n readonly kind: 'action';\n /** Callback key. Runner looks this up in the action registry. */\n readonly action: string;\n /** Next node id after the action fires. */\n readonly next: string | null;\n}\n\nexport interface EndNode {\n readonly kind: 'end';\n}\n\nexport type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;\n\n// ─── Graph ───────────────────────────────────────────────────────\n\nexport interface DialogueGraph {\n /** Unique graph identifier. */\n readonly id: string;\n /** Node id to start from. */\n readonly start: string;\n /** All nodes keyed by id. */\n readonly nodes: Record<string, DialogueNode>;\n}\n\nexport function createDialogueGraph(\n id: string,\n start: string,\n nodes: Record<string, DialogueNode>,\n): DialogueGraph {\n return { id, start, nodes };\n}\n\nexport function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined {\n return graph.nodes[nodeId];\n}\n","/**\n * Dialogue runner — walks a DialogueGraph, evaluating conditions,\n * interpolating variables, and emitting signals for each step.\n */\n\nimport { Signal } from '@flare-engine/events';\nimport type { ChoiceNode, DialogueGraph, LineNode } from './graph.js';\nimport { getNode } from './graph.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type ConditionResolver = (key: string) => boolean;\nexport type ActionHandler = (key: string) => void | Promise<void>;\nexport type VariableBag = Record<string, string | number>;\n\nexport interface DialogueLineEvent {\n readonly speaker: string;\n /** Optional portrait key forwarded verbatim from the source LineNode. */\n readonly speakerPortraitKey?: string;\n readonly text: string;\n readonly nodeId: string;\n}\n\nexport interface DialogueChoiceEvent {\n readonly prompt: string | undefined;\n readonly speaker: string | undefined;\n readonly options: readonly ActiveOption[];\n readonly nodeId: string;\n}\n\nexport interface ActiveOption {\n readonly index: number;\n readonly text: string;\n readonly next: string;\n}\n\nexport type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';\n\n// ─── Interpolation ───────────────────────────────────────────────\n\nexport function interpolate(template: string, vars: VariableBag): string {\n return template.replace(/\\{(\\w+)\\}/g, (_match, key: string) => {\n const val = vars[key];\n return val !== undefined ? String(val) : `{${key}}`;\n });\n}\n\n// ─── Runner ──────────────────────────────────────────────────────\n\nexport interface DialogueRunner {\n readonly onLine: Signal<DialogueLineEvent>;\n readonly onChoice: Signal<DialogueChoiceEvent>;\n readonly onAction: Signal<string>;\n readonly onEnd: Signal<string>;\n\n readonly state: DialogueRunnerState;\n readonly currentNodeId: string | null;\n readonly graphId: string | null;\n\n start(graph: DialogueGraph, variables?: VariableBag): void;\n advance(): void;\n selectChoice(index: number): void;\n stop(): void;\n}\n\nexport interface DialogueRunnerOptions {\n conditions?: ConditionResolver;\n actions?: ActionHandler;\n}\n\nexport function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner {\n const onLine = new Signal<DialogueLineEvent>();\n const onChoice = new Signal<DialogueChoiceEvent>();\n const onAction = new Signal<string>();\n const onEnd = new Signal<string>();\n\n const conditions = options?.conditions ?? (() => true);\n const actions = options?.actions;\n\n let _state: DialogueRunnerState = 'idle';\n let _graph: DialogueGraph | null = null;\n let _currentNodeId: string | null = null;\n let _vars: VariableBag = {};\n let _activeOptions: ActiveOption[] = [];\n\n function setState(s: DialogueRunnerState): void {\n _state = s;\n }\n\n function processNode(nodeId: string): void {\n if (!_graph) return;\n const node = getNode(_graph, nodeId);\n if (!node) {\n end();\n return;\n }\n _currentNodeId = nodeId;\n\n switch (node.kind) {\n case 'line':\n processLine(node, nodeId);\n break;\n case 'choice':\n processChoice(node, nodeId);\n break;\n case 'branch':\n processBranch(node);\n break;\n case 'action':\n processAction(node);\n break;\n case 'end':\n end();\n break;\n }\n }\n\n function processLine(node: LineNode, nodeId: string): void {\n setState('running');\n const text = interpolate(node.text, _vars);\n onLine.emit({\n speaker: node.speaker,\n speakerPortraitKey: node.speakerPortraitKey,\n text,\n nodeId,\n });\n }\n\n function processChoice(node: ChoiceNode, nodeId: string): void {\n // Filter options by condition\n _activeOptions = [];\n for (let i = 0; i < node.options.length; i++) {\n const opt = node.options[i]!;\n if (opt.condition && !conditions(opt.condition)) continue;\n _activeOptions.push({\n index: _activeOptions.length,\n text: interpolate(opt.text, _vars),\n next: opt.next,\n });\n }\n\n if (_activeOptions.length === 0) {\n // No valid options — treat as end\n end();\n return;\n }\n\n setState('waiting-choice');\n onChoice.emit({\n prompt: node.prompt ? interpolate(node.prompt, _vars) : undefined,\n speaker: node.speaker,\n options: _activeOptions,\n nodeId,\n });\n }\n\n function processBranch(node: {\n arms: readonly { condition: string; next: string }[];\n fallback: string | null;\n }): void {\n for (let i = 0; i < node.arms.length; i++) {\n const arm = node.arms[i]!;\n if (conditions(arm.condition)) {\n processNode(arm.next);\n return;\n }\n }\n if (node.fallback) {\n processNode(node.fallback);\n } else {\n end();\n }\n }\n\n function processAction(node: { action: string; next: string | null }): void {\n onAction.emit(node.action);\n actions?.(node.action);\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n }\n\n function end(): void {\n setState('ended');\n const graphId = _graph?.id ?? '';\n _currentNodeId = null;\n onEnd.emit(graphId);\n }\n\n const runner: DialogueRunner = {\n onLine,\n onChoice,\n onAction,\n onEnd,\n\n get state() {\n return _state;\n },\n get currentNodeId() {\n return _currentNodeId;\n },\n get graphId() {\n return _graph?.id ?? null;\n },\n\n start(graph: DialogueGraph, variables?: VariableBag): void {\n _graph = graph;\n _vars = variables ?? {};\n _activeOptions = [];\n setState('running');\n processNode(graph.start);\n },\n\n advance(): void {\n if (_state !== 'running' || !_graph || !_currentNodeId) return;\n const node = getNode(_graph, _currentNodeId);\n if (!node || node.kind !== 'line') return;\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n },\n\n selectChoice(index: number): void {\n if (_state !== 'waiting-choice') return;\n const opt = _activeOptions[index];\n if (!opt) return;\n _activeOptions = [];\n setState('running');\n processNode(opt.next);\n },\n\n stop(): void {\n setState('idle');\n _graph = null;\n _currentNodeId = null;\n _vars = {};\n _activeOptions = [];\n },\n };\n\n return runner;\n}\n","/**\n * Text-over-time reveal hook for dialogue lines.\n *\n * Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame\n * and read `state.visibleText` for the currently revealed substring.\n * Supports per-character speed, instant-complete, and pause-on-punctuation.\n */\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TextRevealState {\n /** Full text being revealed. */\n readonly fullText: string;\n /** Characters revealed so far. */\n visibleText: string;\n /** Whether the full text has been revealed. */\n complete: boolean;\n /** Internal fractional character cursor. */\n _cursor: number;\n}\n\nexport interface TextRevealOptions {\n /** Characters per second. Default: 30. */\n speed?: number;\n /** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */\n punctuationDelay?: number;\n}\n\nconst DEFAULT_SPEED = 30;\nconst DEFAULT_PUNCT_DELAY = 0.15;\n\nconst PUNCTUATION = new Set(['.', '!', '?', ':', ';']);\n\n// ─── API ─────────────────────────────────────────────────────────\n\nexport function createTextReveal(text: string): TextRevealState {\n if (text.length === 0) {\n return { fullText: text, visibleText: text, complete: true, _cursor: 0 };\n }\n return { fullText: text, visibleText: '', complete: false, _cursor: 0 };\n}\n\nexport function updateTextReveal(\n state: TextRevealState,\n dt: number,\n options?: TextRevealOptions,\n): void {\n if (state.complete) return;\n\n const speed = options?.speed ?? DEFAULT_SPEED;\n const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;\n const len = state.fullText.length;\n\n // Determine effective speed — slow down after punctuation\n const prevCharIdx = Math.floor(state._cursor) - 1;\n let effectiveSpeed = speed;\n if (prevCharIdx >= 0 && prevCharIdx < len) {\n const prevChar = state.fullText[prevCharIdx]!;\n if (PUNCTUATION.has(prevChar)) {\n // Slow down: treat the punctuation gap as taking `punctDelay` seconds\n // Effective speed = 1 / punctDelay so cursor advances ~1 char in punctDelay time\n effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;\n }\n }\n\n state._cursor += effectiveSpeed * dt;\n const charCount = Math.min(Math.floor(state._cursor), len);\n state.visibleText = state.fullText.slice(0, charCount);\n\n if (charCount >= len) {\n state.visibleText = state.fullText;\n state.complete = true;\n }\n}\n\nexport function completeTextReveal(state: TextRevealState): void {\n state.visibleText = state.fullText;\n state._cursor = state.fullText.length;\n state.complete = true;\n}\n\nexport function resetTextReveal(state: TextRevealState, newText?: string): void {\n const text = newText ?? state.fullText;\n (state as { fullText: string }).fullText = text;\n state.visibleText = '';\n state._cursor = 0;\n state.complete = text.length === 0;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flare-engine/dialogue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dialogue graph runtime — lines, choices, conditions, variables, text-over-time reveal",
|
|
5
|
+
"keywords": ["flare-engine", "dialogue", "narrative", "branching", "rpg"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Grzegorz Otto",
|
|
8
|
+
"homepage": "https://github.com/grzott/flare-engine#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/grzott/flare-engine/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/grzott/flare-engine.git",
|
|
15
|
+
"directory": "packages/dialogue"
|
|
16
|
+
},
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"module": "dist/index.mjs",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.mjs",
|
|
24
|
+
"require": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"lint": "biome check src/",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@flare-engine/events": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "restricted",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
41
|
+
"sideEffects": false
|
|
42
|
+
}
|