145 lines
4.0 KiB
Go
145 lines
4.0 KiB
Go
package vn
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// MaxImportNesting defines the maximum recursive import depth.
|
|
const MaxImportNesting = 8
|
|
|
|
// Script is the root structure for a visual novel script file.
|
|
type Script struct {
|
|
Title string `yaml:"title"`
|
|
Start string `yaml:"start"`
|
|
Imports []string `yaml:"imports"`
|
|
Scenes []Scene `yaml:"scenes"`
|
|
BaseDir string `yaml:"-"`
|
|
LoadedFiles []string `yaml:"-"`
|
|
}
|
|
|
|
// Scene is a labeled sequence of commands.
|
|
type Scene struct {
|
|
ID string `yaml:"id"`
|
|
Commands []Command `yaml:"commands"`
|
|
}
|
|
|
|
// Command is a single script instruction.
|
|
type Command struct {
|
|
Type string `yaml:"type"`
|
|
Speaker string `yaml:"speaker,omitempty"`
|
|
Text string `yaml:"text,omitempty"`
|
|
Background string `yaml:"background,omitempty"`
|
|
BackgroundImage string `yaml:"background_image,omitempty"`
|
|
Character string `yaml:"character,omitempty"`
|
|
Emotion string `yaml:"emotion,omitempty"`
|
|
Sprite string `yaml:"sprite,omitempty"`
|
|
Key string `yaml:"key,omitempty"`
|
|
Value any `yaml:"value,omitempty"`
|
|
Target string `yaml:"target,omitempty"`
|
|
If string `yaml:"if,omitempty"`
|
|
Then []Command `yaml:"then,omitempty"`
|
|
Else []Command `yaml:"else,omitempty"`
|
|
Choices []Choice `yaml:"choices,omitempty"`
|
|
MenuItems []MenuItem `yaml:"menu_items,omitempty"`
|
|
}
|
|
|
|
// Choice is a branching option shown to the player.
|
|
type Choice struct {
|
|
Text string `yaml:"text"`
|
|
Target string `yaml:"target"`
|
|
If string `yaml:"if,omitempty"`
|
|
}
|
|
|
|
// MenuItem is a positioned interactive sprite option for a menu command.
|
|
type MenuItem struct {
|
|
ID string `yaml:"id,omitempty"`
|
|
Sprite string `yaml:"sprite"`
|
|
X float64 `yaml:"x"`
|
|
Y float64 `yaml:"y"`
|
|
If string `yaml:"if,omitempty"`
|
|
Selectable *bool `yaml:"selectable,omitempty"`
|
|
Commands []Command `yaml:"commands"`
|
|
}
|
|
|
|
func LoadScript(path string) (*Script, error) {
|
|
rootPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve script path: %w", err)
|
|
}
|
|
|
|
visited := map[string]bool{}
|
|
stack := map[string]bool{}
|
|
loaded := []string{}
|
|
root, err := loadScriptRecursive(rootPath, 0, visited, stack, &loaded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if root.Start == "" {
|
|
return nil, fmt.Errorf("script missing start scene")
|
|
}
|
|
if len(root.Scenes) == 0 {
|
|
return nil, fmt.Errorf("script has no scenes")
|
|
}
|
|
root.BaseDir = filepath.Dir(rootPath)
|
|
root.LoadedFiles = loaded
|
|
return root, nil
|
|
}
|
|
|
|
func loadScriptRecursive(path string, depth int, visited, stack map[string]bool, loaded *[]string) (*Script, error) {
|
|
if depth > MaxImportNesting {
|
|
return nil, fmt.Errorf("import depth exceeded max (%d) at %s", MaxImportNesting, path)
|
|
}
|
|
|
|
if stack[path] {
|
|
return nil, fmt.Errorf("cyclic import detected at %s", path)
|
|
}
|
|
|
|
if visited[path] {
|
|
return &Script{}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read script %s: %w", path, err)
|
|
}
|
|
|
|
var current Script
|
|
if err := yaml.Unmarshal(data, ¤t); err != nil {
|
|
return nil, fmt.Errorf("parse yaml %s: %w", path, err)
|
|
}
|
|
|
|
visited[path] = true
|
|
stack[path] = true
|
|
*loaded = append(*loaded, path)
|
|
|
|
mergedScenes := make([]Scene, 0, len(current.Scenes))
|
|
for _, imp := range current.Imports {
|
|
resolved := imp
|
|
if !filepath.IsAbs(resolved) {
|
|
resolved = filepath.Join(filepath.Dir(path), imp)
|
|
}
|
|
resolved, err = filepath.Abs(resolved)
|
|
if err != nil {
|
|
delete(stack, path)
|
|
return nil, fmt.Errorf("resolve import %s from %s: %w", imp, path, err)
|
|
}
|
|
|
|
child, err := loadScriptRecursive(resolved, depth+1, visited, stack, loaded)
|
|
if err != nil {
|
|
delete(stack, path)
|
|
return nil, err
|
|
}
|
|
mergedScenes = append(mergedScenes, child.Scenes...)
|
|
}
|
|
|
|
delete(stack, path)
|
|
mergedScenes = append(mergedScenes, current.Scenes...)
|
|
current.Scenes = mergedScenes
|
|
return ¤t, nil
|
|
}
|