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

735 lines
17 KiB
Go

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: <key> <op> <value>")
}
return left, candidate, right, nil
}
}
return "", "", "", fmt.Errorf("expected format: <key> <op> <value>")
}