package vn import ( "encoding/json" "fmt" "math" "os" "path/filepath" "sort" "strconv" "strings" ) type waitingMode int const ( waitingNone waitingMode = iota waitingAdvance waitingChoice waitingMenu waitingEnd ) // Dialogue represents the current spoken line. type Dialogue struct { Speaker string Text string } // ActiveChoice is a rendered selectable choice. type ActiveChoice struct { Text string Target string } // ActiveMenuItem is an interactive positioned sprite entry in a menu command. type ActiveMenuItem struct { ID string `json:"id"` Sprite string `json:"sprite"` X float64 `json:"x"` Y float64 `json:"y"` Selectable bool `json:"selectable"` Commands []Command `json:"commands"` } // VarValue stores script variables as strongly-typed values. type VarValue struct { Type string `json:"type"` Str string `json:"str,omitempty"` Bool bool `json:"bool,omitempty"` Int int64 `json:"int,omitempty"` Float float64 `json:"float,omitempty"` } func NewVarValue(v any) (VarValue, error) { switch t := v.(type) { case string: return VarValue{Type: "string", Str: t}, nil case bool: return VarValue{Type: "bool", Bool: t}, nil case int: return VarValue{Type: "int", Int: int64(t)}, nil case int8: return VarValue{Type: "int", Int: int64(t)}, nil case int16: return VarValue{Type: "int", Int: int64(t)}, nil case int32: return VarValue{Type: "int", Int: int64(t)}, nil case int64: return VarValue{Type: "int", Int: t}, nil case uint: return VarValue{Type: "int", Int: int64(t)}, nil case uint8: return VarValue{Type: "int", Int: int64(t)}, nil case uint16: return VarValue{Type: "int", Int: int64(t)}, nil case uint32: return VarValue{Type: "int", Int: int64(t)}, nil case uint64: if t > uint64(^uint64(0)>>1) { return VarValue{}, fmt.Errorf("uint64 out of range: %d", t) } return VarValue{Type: "int", Int: int64(t)}, nil case float32: return VarValue{Type: "float", Float: float64(t)}, nil case float64: return VarValue{Type: "float", Float: t}, nil case nil: return VarValue{Type: "string", Str: ""}, nil default: return VarValue{}, fmt.Errorf("unsupported variable type %T", v) } } func (v VarValue) String() string { switch v.Type { case "string": return v.Str case "bool": return strconv.FormatBool(v.Bool) case "int": return strconv.FormatInt(v.Int, 10) case "float": return strconv.FormatFloat(v.Float, 'f', -1, 64) default: return "" } } func (v VarValue) asFloat() (float64, bool) { switch v.Type { case "int": return float64(v.Int), true case "float": return v.Float, true default: return 0, false } } func parseLiteral(raw string) VarValue { raw = strings.TrimSpace(raw) if uq, err := strconv.Unquote(raw); err == nil { return VarValue{Type: "string", Str: uq} } if raw == "true" { return VarValue{Type: "bool", Bool: true} } if raw == "false" { return VarValue{Type: "bool", Bool: false} } if i, err := strconv.ParseInt(raw, 10, 64); err == nil { return VarValue{Type: "int", Int: i} } if f, err := strconv.ParseFloat(raw, 64); err == nil { return VarValue{Type: "float", Float: f} } return VarValue{Type: "string", Str: raw} } // RenderState is the current state needed by the renderer. type RenderState struct { Title string Background string BackgroundImage string Character string CharacterSprite string Emotion string Dialogue Dialogue Choices []ActiveChoice Selected int MenuItems []ActiveMenuItem MenuSelected int MenuActive bool Ended bool } // Engine executes script commands and exposes render-ready state. type Engine struct { script *Script texts *Texts sceneByID map[string]Scene sceneID string index int pending []Command wait waitingMode state RenderState vars map[string]VarValue onEndError error } type saveData struct { SceneID string `json:"scene_id"` Index int `json:"index"` Pending []Command `json:"pending"` Wait waitingMode `json:"wait"` State RenderState `json:"state"` Vars map[string]VarValue `json:"vars"` } func NewEngine(script *Script, texts *Texts) (*Engine, error) { scenes := make(map[string]Scene, len(script.Scenes)) for _, sc := range script.Scenes { if sc.ID == "" { return nil, fmt.Errorf("scene with empty id") } if _, exists := scenes[sc.ID]; exists { return nil, fmt.Errorf("duplicate scene id: %s", sc.ID) } scenes[sc.ID] = sc } if _, ok := scenes[script.Start]; !ok { return nil, fmt.Errorf("start scene %q not found", script.Start) } e := &Engine{ script: script, texts: texts, sceneByID: scenes, sceneID: script.Start, } e.Reset() return e, nil } func (e *Engine) State() RenderState { return e.state } func (e *Engine) CurrentSceneID() string { return e.sceneID } func (e *Engine) SceneIDs() []string { ids := make([]string, 0, len(e.sceneByID)) for id := range e.sceneByID { ids = append(ids, id) } sort.Strings(ids) return ids } func (e *Engine) VarsSnapshot() map[string]VarValue { out := make(map[string]VarValue, len(e.vars)) for k, v := range e.vars { out[k] = v } return out } func (e *Engine) Advance() { switch e.wait { case waitingAdvance: e.wait = waitingNone e.runUntilWait() case waitingEnd: e.state.Ended = true } } func (e *Engine) MoveChoice(delta int) { if e.wait != waitingChoice || len(e.state.Choices) == 0 { return } n := len(e.state.Choices) e.state.Selected = (e.state.Selected + delta + n) % n } func (e *Engine) ConfirmChoice() { if e.wait != waitingChoice || len(e.state.Choices) == 0 { return } choice := e.state.Choices[e.state.Selected] e.jumpTo(choice.Target) e.wait = waitingNone e.runUntilWait() } func (e *Engine) MoveMenu(dx, dy int) { if e.wait != waitingMenu || !e.state.MenuActive || len(e.state.MenuItems) == 0 { return } if dx == 0 && dy == 0 { return } current := e.state.MenuSelected if current < 0 || current >= len(e.state.MenuItems) || !e.state.MenuItems[current].Selectable { e.state.MenuSelected = e.findFirstSelectableMenu() return } bestIdx := current bestScore := math.MaxFloat64 cur := e.state.MenuItems[current] for i, item := range e.state.MenuItems { if i == current || !item.Selectable { continue } vx := item.X - cur.X vy := item.Y - cur.Y if dx > 0 && vx <= 0 { continue } if dx < 0 && vx >= 0 { continue } if dy > 0 && vy <= 0 { continue } if dy < 0 && vy >= 0 { continue } // Prefer closer items in the intended direction. score := math.Hypot(vx, vy) if dx != 0 { score += math.Abs(vy) * 0.5 } if dy != 0 { score += math.Abs(vx) * 0.5 } if score < bestScore { bestScore = score bestIdx = i } } if bestIdx != current { e.state.MenuSelected = bestIdx } } func (e *Engine) ConfirmMenu() { if e.wait != waitingMenu || !e.state.MenuActive || len(e.state.MenuItems) == 0 { return } sel := e.state.MenuSelected if sel < 0 || sel >= len(e.state.MenuItems) { return } item := e.state.MenuItems[sel] if !item.Selectable { return } e.clearMenuState() e.prependCommands(item.Commands) e.wait = waitingNone e.runUntilWait() } func (e *Engine) Error() error { return e.onEndError } func (e *Engine) Reset() { e.sceneID = e.script.Start e.index = 0 e.pending = nil e.wait = waitingNone e.vars = map[string]VarValue{} e.onEndError = nil e.state = RenderState{Title: e.script.Title, MenuSelected: -1} e.runUntilWait() } func (e *Engine) SaveToFile(path string) error { data := saveData{ SceneID: e.sceneID, Index: e.index, Pending: e.pending, Wait: e.wait, State: e.state, Vars: e.vars, } if data.Vars == nil { data.Vars = map[string]VarValue{} } raw, err := json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("encode save: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create save dir: %w", err) } if err := os.WriteFile(path, raw, 0o644); err != nil { return fmt.Errorf("write save file: %w", err) } return nil } func (e *Engine) LoadFromFile(path string) error { raw, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read save file: %w", err) } var data saveData if err := json.Unmarshal(raw, &data); err != nil { return fmt.Errorf("parse save file: %w", err) } if _, ok := e.sceneByID[data.SceneID]; !ok { return fmt.Errorf("save references unknown scene: %s", data.SceneID) } if data.Index < 0 { return fmt.Errorf("invalid save index: %d", data.Index) } if data.Vars == nil { data.Vars = map[string]VarValue{} } e.sceneID = data.SceneID e.index = data.Index e.pending = data.Pending e.wait = data.Wait e.state = data.State if e.state.Title == "" { e.state.Title = e.script.Title } e.vars = data.Vars e.onEndError = nil return nil } func (e *Engine) JumpToScene(sceneID string) error { if sceneID == "" { return fmt.Errorf("scene id is empty") } if _, ok := e.sceneByID[sceneID]; !ok { return fmt.Errorf("scene does not exist: %s", sceneID) } e.sceneID = sceneID e.index = 0 e.pending = nil e.wait = waitingNone e.state.Choices = nil e.state.Selected = 0 e.clearMenuState() e.state.Ended = false e.onEndError = nil e.runUntilWait() return nil } func (e *Engine) runUntilWait() { for e.wait == waitingNone { cmd, ok := e.nextCommand() if !ok { e.wait = waitingEnd e.state.Dialogue = Dialogue{Speaker: "", Text: e.texts.EndText} return } e.execCommand(cmd) } } func (e *Engine) nextCommand() (Command, bool) { if len(e.pending) > 0 { cmd := e.pending[0] e.pending = e.pending[1:] return cmd, true } scene, ok := e.sceneByID[e.sceneID] if !ok { e.fail(fmt.Errorf("unknown scene: %s", e.sceneID)) return Command{}, false } if e.index >= len(scene.Commands) { return Command{}, false } cmd := scene.Commands[e.index] e.index++ return cmd, true } func (e *Engine) prependCommands(cmds []Command) { if len(cmds) == 0 { return } copyCmds := make([]Command, len(cmds)) copy(copyCmds, cmds) e.pending = append(copyCmds, e.pending...) } func (e *Engine) execCommand(cmd Command) { if cmd.Type == "" { e.fail(fmt.Errorf("command missing type")) return } if cmd.If != "" && strings.ToLower(cmd.Type) != "if" { ok, err := e.evalCondition(cmd.If) if err != nil { e.fail(fmt.Errorf("invalid condition %q: %w", cmd.If, err)) return } if !ok { return } } switch strings.ToLower(cmd.Type) { case "say": e.clearMenuState() e.state.Dialogue = Dialogue{Speaker: cmd.Speaker, Text: e.interpolate(cmd.Text)} e.wait = waitingAdvance case "background": e.state.Background = e.interpolate(cmd.Background) e.state.BackgroundImage = e.interpolate(cmd.BackgroundImage) case "character": e.state.Character = e.interpolate(cmd.Character) e.state.Emotion = e.interpolate(cmd.Emotion) e.state.CharacterSprite = e.interpolate(cmd.Sprite) case "set": if cmd.Key == "" { e.fail(fmt.Errorf("set command missing key")) return } value, err := e.convertSetValue(cmd.Value) if err != nil { e.fail(fmt.Errorf("set command invalid value for %q: %w", cmd.Key, err)) return } e.vars[cmd.Key] = value case "goto": e.jumpTo(e.interpolate(cmd.Target)) case "choice": e.clearMenuState() e.execChoice(cmd) case "menu": e.execMenu(cmd) case "if": if cmd.If == "" { e.fail(fmt.Errorf("if command missing condition")) return } ok, err := e.evalCondition(cmd.If) if err != nil { e.fail(fmt.Errorf("invalid condition %q: %w", cmd.If, err)) return } if ok { e.prependCommands(cmd.Then) } else { e.prependCommands(cmd.Else) } case "end": e.clearMenuState() e.wait = waitingEnd e.state.Dialogue = Dialogue{Speaker: "", Text: e.texts.EndText} default: e.fail(fmt.Errorf("unknown command type: %s", cmd.Type)) } } func (e *Engine) execChoice(cmd Command) { choices := make([]ActiveChoice, 0, len(cmd.Choices)) for _, c := range cmd.Choices { if c.If != "" { ok, err := e.evalCondition(c.If) if err != nil { e.fail(fmt.Errorf("invalid choice condition %q: %w", c.If, err)) return } if !ok { continue } } choices = append(choices, ActiveChoice{Text: e.interpolate(c.Text), Target: e.interpolate(c.Target)}) } if len(choices) == 0 { e.fail(fmt.Errorf("choice command has no available options")) return } e.state.Choices = choices e.state.Selected = 0 e.wait = waitingChoice } func (e *Engine) execMenu(cmd Command) { if cmd.Background != "" || cmd.BackgroundImage != "" { e.state.Background = e.interpolate(cmd.Background) e.state.BackgroundImage = e.interpolate(cmd.BackgroundImage) } activeItems := make([]ActiveMenuItem, 0, len(cmd.MenuItems)) for i, item := range cmd.MenuItems { if item.If != "" { ok, err := e.evalCondition(item.If) if err != nil { e.fail(fmt.Errorf("invalid menu item condition %q: %w", item.If, err)) return } if !ok { continue } } selectable := true if item.Selectable != nil { selectable = *item.Selectable } id := item.ID if id == "" { id = fmt.Sprintf("menu_item_%d", i) } activeItems = append(activeItems, ActiveMenuItem{ ID: e.interpolate(id), Sprite: e.interpolate(item.Sprite), X: item.X, Y: item.Y, Selectable: selectable, Commands: item.Commands, }) } if len(activeItems) == 0 { e.fail(fmt.Errorf("menu command has no visible items")) return } selected := firstSelectable(activeItems) if selected == -1 { e.fail(fmt.Errorf("menu command has no selectable items")) return } e.state.Choices = nil e.state.Selected = 0 e.state.MenuItems = activeItems e.state.MenuSelected = selected e.state.MenuActive = true e.wait = waitingMenu } func firstSelectable(items []ActiveMenuItem) int { for i, item := range items { if item.Selectable { return i } } return -1 } func (e *Engine) findFirstSelectableMenu() int { return firstSelectable(e.state.MenuItems) } func (e *Engine) convertSetValue(raw any) (VarValue, error) { if s, ok := raw.(string); ok { return NewVarValue(e.interpolate(s)) } return NewVarValue(raw) } func (e *Engine) jumpTo(sceneID string) { if sceneID == "" { e.fail(fmt.Errorf("goto target is empty")) return } if _, ok := e.sceneByID[sceneID]; !ok { e.fail(fmt.Errorf("goto target scene does not exist: %s", sceneID)) return } e.sceneID = sceneID e.index = 0 e.pending = nil e.state.Choices = nil e.state.Selected = 0 e.clearMenuState() } func (e *Engine) clearMenuState() { e.state.MenuItems = nil e.state.MenuSelected = -1 e.state.MenuActive = false } func (e *Engine) fail(err error) { e.onEndError = err e.state.Dialogue = Dialogue{Speaker: e.texts.EngineErrorSpeaker, Text: err.Error()} e.state.Choices = nil e.clearMenuState() e.wait = waitingEnd } func (e *Engine) interpolate(s string) string { result := s for k, v := range e.vars { token := "{" + k + "}" result = strings.ReplaceAll(result, token, v.String()) } return result } func (e *Engine) evalCondition(cond string) (bool, error) { key, op, rawRight, err := parseCondition(cond) if err != nil { return false, err } left, ok := e.vars[key] if !ok { left = VarValue{Type: "string", Str: ""} } right := parseLiteral(rawRight) lf, lok := left.asFloat() rf, rok := right.asFloat() numeric := lok && rok switch op { case "==": if numeric { return lf == rf, nil } if left.Type != right.Type { return false, nil } return left.String() == right.String(), nil case "!=": if numeric { return lf != rf, nil } if left.Type != right.Type { return true, nil } return left.String() != right.String(), nil case ">": if !numeric { return false, fmt.Errorf("operator > requires int/float operands") } return lf > rf, nil case "<": if !numeric { return false, fmt.Errorf("operator < requires int/float operands") } return lf < rf, nil case ">=": if !numeric { return false, fmt.Errorf("operator >= requires int/float operands") } return lf >= rf, nil case "<=": if !numeric { return false, fmt.Errorf("operator <= requires int/float operands") } return lf <= rf, nil default: return false, fmt.Errorf("unknown operator %q", op) } } func parseCondition(cond string) (key, op, value string, err error) { ops := []string{"==", "!=", ">=", "<=", ">", "<"} for _, candidate := range ops { if idx := strings.Index(cond, candidate); idx >= 0 { left := strings.TrimSpace(cond[:idx]) right := strings.TrimSpace(cond[idx+len(candidate):]) if left == "" || right == "" { return "", "", "", fmt.Errorf("expected format: ") } return left, candidate, right, nil } } return "", "", "", fmt.Errorf("expected format: ") }