Files
vnengine/internal/vn/script.go
2026-02-12 15:25:40 -03:00

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, &current); 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 &current, nil
}