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 }