commit 0dcfb9c9f18271938845b5a494103c2b41109229 Author: Glauber Ferreira Date: Thu Feb 12 15:25:40 2026 -0300 primeiro commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..69723c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ + +## Working agreements + +- Prefer importing and using libraries rather than writing code diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..8a501f2 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,3 @@ +Linux: go build -o build/vnengine-linux ./cmd/vnengine +Windows: GOOS=windows GOARCH=amd64 go build -o build/vnengine-windows.exe ./cmd/vnengine +Linux ARM64: GOOS=linux GOARCH=arm64 go build -o build/vnengine-linux-arm64 ./cmd/vnengine diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6ccfb --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Uma Certa Engine de Visual Novel Programável (Go) + +Uma engine de visual novel implementada em Go usando `ebiten`, com scripts YAML para dialogos, ramificacoes, imports, variaveis tipadas, salvar/carregar, fundos e sprites de personagem. + +## Recursos + +- Tela de carregamento na inicializacao + validação automatica +- A tela de titulo fica bloqueada ate o validação terminar +- Imports YAML recursivos para dividir a historia em varios arquivos +- Profundidade maxima de import configuravel (`MaxImportNesting` em `internal/vn/script.go`) +- Execucao de script por cenas com ramificacoes +- Variaveis tipadas: `string`, `bool`, `int`, `float` +- Comando `if` com blocos `then`/`else` +- Salvamento/carregamento do estado do jogo em JSON +- Modo debug (`F2`) para inspecionar variaveis e pular para cenas +- Fundos por chave de cor ou caminho de imagem +- Sprites de personagem por caminho de imagem + +## Inicio rapido + +```bash +go mod tidy +go run ./cmd/vnengine +``` + +Por padrao, a engine sempre carrega `default/script.yaml`. +Todos os textos da interface/debug visiveis ao usuario sao carregados de `default/ui.yaml`. + +## Controles + +- Tela de carregamento: validação automatica ao iniciar +- Tela de titulo: `Up`/`Down` + `Enter` +- Historia `Enter`: avancar dialogo / confirmar escolha selecionada +- Historia `Up`/`Down`: navegar pelas escolhas +- Historia `Left Click`: avancar dialogo (quando nao estiver em escolhas) +- `F2`: alterna modo debug (mostra variaveis, digita ID da cena e aperta `Enter` para pular) +- `F5`: salvar +- `F9`: carregar +- `Esc`: voltar para a tela de titulo + +## Imports de script + +Use `imports` em qualquer arquivo de script. Os caminhos sao relativos ao arquivo que declara o import. + +```yaml +title: "Minha Historia" +start: intro +imports: + - chapters/act1.yaml +``` + +Os imports podem ser aninhados recursivamente ate `MaxImportNesting`. + +## Variaveis + +Defina variaveis com valores YAML tipados: + +```yaml +- type: set + key: player_name + value: "Mira" # string + +- type: set + key: trust + value: true # bool + +- type: set + key: coins + value: 3 # int + +- type: set + key: luck + value: 1.5 # float +``` + +Interpolacao em campos de texto/sprite/fundo usa `{var_name}`. + +## Condicoes e fluxo if/else + +Condicao inline (guarda no nivel do comando): + +```yaml +- type: say + if: "coins >= 2" + speaker: Narrador + text: "Voce consegue pagar o bonde." +``` + +`if/else` estruturado: + +```yaml +- type: if + if: "trust == true" + then: + - type: say + speaker: Mira + text: "Entao me siga." + else: + - type: say + speaker: Mira + text: "Mantenha distancia." +``` + +Operadores suportados: `==`, `!=`, `>`, `<`, `>=`, `<=`. + +## Validação + +Na inicializacao, a engine verifica: + +- se todos os arquivos de script carregados existem +- se todos os alvos de `goto` e `choice` existem +- se os assets de fundo/sprite referenciados existem (para caminhos estaticos) + +Caminhos dinamicos de asset contendo variaveis (ex.: `{mood}.png`) sao ignorados na verificacao estatica de existencia. + +## Referencia de comandos + +- `say` +- `background` (`background` ou `background_image`) +- `character` (`character`, `emotion`, `sprite` opcional) +- `set` +- `if` (`if`, `then`, `else`) +- `goto` +- `choice` +- `end` + +## Cena de menu interativo (`menu`) + +Voce pode criar um menu em cena com sprites posicionados e navegacao por setas (`Left`/`Right`/`Up`/`Down`) + `Enter`. + +```yaml +- type: menu + background_image: assets/bg_hub.png + menu_items: + - id: oficina + sprite: assets/menu_oficina.png + x: 0.25 + y: 0.45 + selectable: true + commands: + - type: set + key: destino + value: oficina + - type: goto + target: cena_oficina + + - id: mercado + sprite: assets/menu_mercado.png + x: 0.65 + y: 0.45 + if: "coins >= 2" + selectable: true + commands: + - type: say + speaker: Narrador + text: "Voce vai para o mercado." + + - id: decoracao + sprite: assets/menu_estatua.png + x: 0.45 + y: 0.2 + selectable: false + commands: + - type: say + speaker: Narrador + text: "Isto nao deve executar porque nao e selecionavel." +``` + +Notas: +- `x`/`y` entre `0` e `1` sao tratados como proporcao da tela. +- `x`/`y` acima de `1` sao tratados como pixels absolutos. +- Itens com `if` falso nao aparecem. +- Itens com `selectable: false` aparecem, mas nao recebem destaque nem selecao. +- `commands` de um item aceitam qualquer fluxo suportado (`set`, `if`, `goto`, `say`, etc.). diff --git a/cmd/vnengine/main.go b/cmd/vnengine/main.go new file mode 100644 index 0000000..a9747cf --- /dev/null +++ b/cmd/vnengine/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + "log" + + "batata/vnengine/internal/vn" + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + const scriptPath = "default/script.yaml" + const textsPath = "default/ui.yaml" + savePath := flag.String("save", "default/save.json", "path to save file") + flag.Parse() + + texts, err := vn.LoadTexts(textsPath) + if err != nil { + log.Fatalf("load ui texts: %v", err) + } + script, err := vn.LoadScript(scriptPath) + if err != nil { + log.Fatalf("load script: %v", err) + } + engine, err := vn.NewEngine(script, texts) + if err != nil { + log.Fatalf("init engine: %v", err) + } + + ebiten.SetWindowTitle(script.Title) + ebiten.SetWindowSize(1280, 720) + if err := ebiten.RunGame(vn.NewGame(engine, script, texts, *savePath)); err != nil && err != ebiten.Termination { + log.Fatalf("run game: %v", err) + } +} diff --git a/default/chapters/act1.yaml b/default/chapters/act1.yaml new file mode 100644 index 0000000..55e423c --- /dev/null +++ b/default/chapters/act1.yaml @@ -0,0 +1,86 @@ +imports: + - ../shared/finale.yaml + +scenes: + - id: intro + commands: + - type: set + key: trust + value: false + - type: set + key: coins + value: 2 + - type: background + background: night + - type: character + character: Mira + emotion: calma + - type: say + speaker: Mira + text: "Você acordou. A cidade fica em silêncio antes do amanhecer." + - type: choice + choices: + - text: "Ler o bilhete" + target: read_note + - text: "Ignorar e sair" + target: outside + + - id: read_note + commands: + - type: set + key: trust + value: true + - type: set + key: luck + value: 1.5 + - type: say + speaker: Narrador + text: "No bilhete está escrito: 'Me encontre na primeira luz. Venha só.'" + - type: goto + target: outside + + - id: outside + commands: + - type: background + background: dawn + - type: character + character: Mira + emotion: séria + - type: if + if: "trust == true" + then: + - type: say + speaker: Mira + text: "Você leu. Ótimo." + else: + - type: say + speaker: Mira + text: "Você ignorou o bilhete. Arriscado." + - type: if + if: "coins >= 2" + then: + - type: say + speaker: Narrador + text: "Você ainda tem moedas suficientes para o bonde." + - type: choice + choices: + - text: "Confiar em Mira" + target: trust_path + - text: "Manter distância" + target: doubt_path + + - id: trust_path + commands: + - type: set + key: route + value: "confiando" + - type: goto + target: finale + + - id: doubt_path + commands: + - type: set + key: route + value: "cautelosa" + - type: goto + target: finale diff --git a/default/script.yaml b/default/script.yaml new file mode 100644 index 0000000..e0b1898 --- /dev/null +++ b/default/script.yaml @@ -0,0 +1,4 @@ +title: "Ecos ao Amanhecer" +start: intro +imports: + - chapters/act1.yaml diff --git a/default/shared/finale.yaml b/default/shared/finale.yaml new file mode 100644 index 0000000..a736752 --- /dev/null +++ b/default/shared/finale.yaml @@ -0,0 +1,19 @@ +scenes: + - id: finale + commands: + - type: background + background: city + - type: say + speaker: Narrador + text: "Você caminha pela cidade despertando, rota: {route}." + - type: if + if: "trust == true" + then: + - type: say + speaker: Narrador + text: "O bilhete no seu bolso parece mais quente do que deveria." + else: + - type: say + speaker: Narrador + text: "Você se pergunta se deixou algo passar no escuro." + - type: end diff --git a/default/ui.yaml b/default/ui.yaml new file mode 100644 index 0000000..29a32c1 --- /dev/null +++ b/default/ui.yaml @@ -0,0 +1,49 @@ +end_text: "[Fim]" +engine_error_speaker: "Erro de Engine" +character_label_fmt: "Personagem: %s [%s]" + +loading_title: "Carregando..." +loading_message: "Validandoo arquivos" +loading_wait: "Aguarde..." + +title_subtitle: "Uma Visual Novel Scriptavel" +title_menu_start: "Iniciar" +title_menu_load: "Carregar" +title_menu_quit: "Sair" +title_controls: "Cima/Baixo + Enter" +title_controls_2: "Enter: Selecionar Cima/Baixo: Navegar" + +hint_advance: "[Enter / Clique esquerdo]" +hint_ended: "[Esc: Titulo]" +story_controls_1: "Enter: Avancar/Confirmar Esquerda/Direita/Cima/Baixo: Menu Cima/Baixo: Escolhas" +story_controls_2: "F5: Salvar F9: Carregar F2: Debug Esc: Titulo" + +notice_new_game: "novo jogo" +notice_loaded: "carregado" +notice_saved: "salvo" +notice_load_failed: "falha ao carregar: %s" +notice_save_failed: "falha ao salvar: %s" +notice_jump_failed: "falha no salto: %s" +notice_jumped: "saltou para %s" +notice_debug_on: "debug ligado" +notice_debug_off: "debug desligado" + +flight_check_passed: "carregado com sucesso" +flight_check_one_fmt: "validação falhou: %s" +flight_check_many_fmt: "validação falhou: %d problemas" +flight_missing_script_fmt: "arquivo de script ausente: %s" +flight_duplicate_scene_fmt: "id de cena duplicado: %s" +flight_start_missing_fmt: "cena inicial nao encontrada: %s" +flight_goto_missing_fmt: "cena %s: alvo de goto ausente: %s" +flight_choice_missing_fmt: "cena %s: alvo de escolha ausente: %s" +flight_missing_asset_fmt: "asset ausente: %s" + +debug_header: "[MODO DEBUG]" +debug_current_scene: "Cena atual: %s" +debug_jump_prefix: "ID da cena para salto: " +debug_jump_help: "Para pular a uma cena: digite o ID + Enter" +debug_variables: "Variaveis:" +debug_scenes: "Cenas:" +debug_more_fmt: "... +%d mais" +debug_var_fmt: "%s (%s) = %s" +error_banner_fmt: "erro: %v" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90622a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module batata/vnengine + +go 1.22.0 + +require ( + github.com/hajimehoshi/ebiten/v2 v2.8.6 + golang.org/x/image v0.20.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.8.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c828b9a --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= +github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/bitmapfont/v3 v3.2.0 h1:0DISQM/rseKIJhdF29AkhvdzIULqNIIlXAGWit4ez1Q= +github.com/hajimehoshi/bitmapfont/v3 v3.2.0/go.mod h1:8gLqGatKVu0pwcNCJguW3Igg9WQqVXF0zg/RvrGQWyg= +github.com/hajimehoshi/ebiten/v2 v2.8.6 h1:Dkd/sYI0TYyZRCE7GVxV59XC+WCi2BbGAbIBjXeVC1U= +github.com/hajimehoshi/ebiten/v2 v2.8.6/go.mod h1:cCQ3np7rdmaJa1ZnvslraVlpxNb3wCjEnAP1LHNyXNA= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/vn/engine.go b/internal/vn/engine.go new file mode 100644 index 0000000..0d6fe61 --- /dev/null +++ b/internal/vn/engine.go @@ -0,0 +1,734 @@ +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: ") +} diff --git a/internal/vn/flightcheck.go b/internal/vn/flightcheck.go new file mode 100644 index 0000000..f597a4a --- /dev/null +++ b/internal/vn/flightcheck.go @@ -0,0 +1,120 @@ +package vn + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// FlightCheckResult contains script integrity validation results. +type FlightCheckResult struct { + Errors []string +} + +func (r *FlightCheckResult) OK() bool { + return len(r.Errors) == 0 +} + +func (r *FlightCheckResult) Summary(texts *Texts) string { + if r.OK() { + return texts.FlightCheckPassed + } + if len(r.Errors) == 1 { + return fmt.Sprintf(texts.FlightCheckOneFmt, r.Errors[0]) + } + return fmt.Sprintf(texts.FlightCheckManyFmt, len(r.Errors)) +} + +func RunFlightCheck(script *Script, texts *Texts) *FlightCheckResult { + result := &FlightCheckResult{} + + seenFiles := map[string]bool{} + for _, file := range script.LoadedFiles { + if seenFiles[file] { + continue + } + seenFiles[file] = true + if _, err := os.Stat(file); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightMissingScriptFmt, file)) + } + } + + sceneByID := map[string]Scene{} + for _, scene := range script.Scenes { + if _, ok := sceneByID[scene.ID]; ok { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightDuplicateSceneFmt, scene.ID)) + continue + } + sceneByID[scene.ID] = scene + } + if _, ok := sceneByID[script.Start]; !ok { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightStartMissingFmt, script.Start)) + } + + assets := map[string]bool{} + var walk func(sceneID string, cmds []Command) + walk = func(sceneID string, cmds []Command) { + for _, cmd := range cmds { + switch strings.ToLower(cmd.Type) { + case "goto": + if cmd.Target != "" { + if _, ok := sceneByID[cmd.Target]; !ok { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightGotoMissingFmt, sceneID, cmd.Target)) + } + } + case "choice": + for _, c := range cmd.Choices { + if _, ok := sceneByID[c.Target]; !ok { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightChoiceMissingFmt, sceneID, c.Target)) + } + } + case "if": + walk(sceneID, cmd.Then) + walk(sceneID, cmd.Else) + case "menu": + for _, item := range cmd.MenuItems { + if item.Sprite != "" { + assets[item.Sprite] = true + } + walk(sceneID, item.Commands) + } + } + + if cmd.BackgroundImage != "" { + assets[cmd.BackgroundImage] = true + } + if looksLikeImagePath(cmd.Background) { + assets[cmd.Background] = true + } + if cmd.Sprite != "" { + assets[cmd.Sprite] = true + } + } + } + + for _, scene := range script.Scenes { + walk(scene.ID, scene.Commands) + } + + keys := make([]string, 0, len(assets)) + for a := range assets { + keys = append(keys, a) + } + sort.Strings(keys) + for _, rel := range keys { + if strings.Contains(rel, "{") { + continue + } + path := rel + if !filepath.IsAbs(path) { + path = filepath.Join(script.BaseDir, rel) + } + if _, err := os.Stat(path); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf(texts.FlightMissingAssetFmt, rel)) + } + } + + return result +} diff --git a/internal/vn/game.go b/internal/vn/game.go new file mode 100644 index 0000000..493d3bf --- /dev/null +++ b/internal/vn/game.go @@ -0,0 +1,599 @@ +package vn + +import ( + "fmt" + "image/color" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "path/filepath" + "sort" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" + "golang.org/x/image/font/basicfont" +) + +const ( + screenWidth = 1280 + screenHeight = 720 +) + +type gameMode int + +const ( + modeTitle gameMode = iota + modePlaying + modeLoading +) + +var colorByName = map[string]color.RGBA{ + "night": {R: 20, G: 30, B: 60, A: 255}, + "dawn": {R: 238, G: 178, B: 97, A: 255}, + "library": {R: 95, G: 62, B: 39, A: 255}, + "city": {R: 36, G: 36, B: 36, A: 255}, + "default": {R: 46, G: 86, B: 105, A: 255}, +} + +// Game is the Ebiten runtime adapter for Engine. +type Game struct { + engine *Engine + script *Script + texts *Texts + + scriptBaseDir string + savePath string + mode gameMode + titleSelected int + + notice string + noticeFrames int + + imageCache map[string]*ebiten.Image + imageErrs map[string]error + dialogBox *ebiten.Image + + flightCheckDone chan struct{} + flightCheckResult *FlightCheckResult + + debugEnabled bool + debugInput string +} + +func NewGame(engine *Engine, script *Script, texts *Texts, savePath string) *Game { + dialogBox := ebiten.NewImage(screenWidth-80, 220) + dialogBox.Fill(color.RGBA{R: 10, G: 10, B: 10, A: 190}) + + g := &Game{ + engine: engine, + script: script, + texts: texts, + scriptBaseDir: script.BaseDir, + savePath: savePath, + mode: modeLoading, + imageCache: map[string]*ebiten.Image{}, + imageErrs: map[string]error{}, + dialogBox: dialogBox, + flightCheckDone: make(chan struct{}), + } + go func() { + g.flightCheckResult = RunFlightCheck(script, texts) + close(g.flightCheckDone) + }() + return g +} + +func (g *Game) Update() error { + if g.noticeFrames > 0 { + g.noticeFrames-- + } + + if g.mode == modeLoading { + select { + case <-g.flightCheckDone: + g.mode = modeTitle + if g.flightCheckResult != nil && !g.flightCheckResult.OK() { + g.setNotice(g.flightCheckResult.Summary(g.texts)) + } else { + g.setNotice(g.texts.FlightCheckPassed) + } + default: + } + return nil + } + + if g.mode == modeTitle { + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) { + g.titleSelected = (g.titleSelected + 2) % 3 + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) { + g.titleSelected = (g.titleSelected + 1) % 3 + } + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + switch g.titleSelected { + case 0: + g.engine.Reset() + g.mode = modePlaying + g.setNotice(g.texts.NoticeNewGame) + case 1: + if err := g.engine.LoadFromFile(g.savePath); err != nil { + g.setNotice(fmt.Sprintf(g.texts.NoticeLoadFailed, err.Error())) + } else { + g.mode = modePlaying + g.setNotice(g.texts.NoticeLoaded) + } + case 2: + return ebiten.Termination + } + } + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + return ebiten.Termination + } + return nil + } + + st := g.engine.State() + if inpututil.IsKeyJustPressed(ebiten.KeyF2) { + g.debugEnabled = !g.debugEnabled + if g.debugEnabled { + g.setNotice(g.texts.NoticeDebugOn) + } else { + g.debugInput = "" + g.setNotice(g.texts.NoticeDebugOff) + } + } + + if inpututil.IsKeyJustPressed(ebiten.KeyF5) { + if err := g.engine.SaveToFile(g.savePath); err != nil { + g.setNotice(fmt.Sprintf(g.texts.NoticeSaveFailed, err.Error())) + } else { + g.setNotice(g.texts.NoticeSaved) + } + } + if inpututil.IsKeyJustPressed(ebiten.KeyF9) { + if err := g.engine.LoadFromFile(g.savePath); err != nil { + g.setNotice(fmt.Sprintf(g.texts.NoticeLoadFailed, err.Error())) + } else { + g.setNotice(g.texts.NoticeLoaded) + } + } + + if st.Ended { + if g.debugEnabled { + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) && strings.TrimSpace(g.debugInput) != "" { + target := strings.TrimSpace(g.debugInput) + if err := g.engine.JumpToScene(target); err != nil { + g.setNotice(fmt.Sprintf(g.texts.NoticeJumpFailed, err.Error())) + } else { + g.setNotice(fmt.Sprintf(g.texts.NoticeJumped, target)) + g.debugInput = "" + } + return nil + } + g.captureDebugInput() + } + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + g.mode = modeTitle + } + return nil + } + + if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + g.mode = modeTitle + return nil + } + + if st.MenuActive { + if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) { + g.engine.MoveMenu(-1, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) { + g.engine.MoveMenu(1, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) { + g.engine.MoveMenu(0, -1) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) { + g.engine.MoveMenu(0, 1) + } + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + g.engine.ConfirmMenu() + } + } else { + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) { + g.engine.MoveChoice(-1) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) { + g.engine.MoveChoice(1) + } + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + if g.debugEnabled && strings.TrimSpace(g.debugInput) != "" { + target := strings.TrimSpace(g.debugInput) + if err := g.engine.JumpToScene(target); err != nil { + g.setNotice(fmt.Sprintf(g.texts.NoticeJumpFailed, err.Error())) + } else { + g.setNotice(fmt.Sprintf(g.texts.NoticeJumped, target)) + g.debugInput = "" + } + return nil + } + if len(st.Choices) > 0 { + g.engine.ConfirmChoice() + } else { + g.engine.Advance() + } + } + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + if len(st.Choices) == 0 { + g.engine.Advance() + } + } + } + + if g.debugEnabled { + g.captureDebugInput() + } + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + if g.mode == modeLoading { + g.drawLoading(screen) + return + } + + if g.mode == modeTitle { + g.drawTitle(screen) + return + } + + st := g.engine.State() + g.drawBackground(screen, st) + + face := basicfont.Face7x13 + + if st.CharacterSprite != "" { + if sprite, err := g.loadImage(st.CharacterSprite); err == nil { + drawImageContain(sprite, screen, 0.5, 0.8, 0.55, 0.55) + } + } + + if st.MenuActive { + g.drawMenu(screen, st) + } + + if st.Character != "" { + text.Draw(screen, fmt.Sprintf(g.texts.CharacterLabelFmt, st.Character, st.Emotion), face, 30, 40, color.White) + } + + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(40, screenHeight-260) + screen.DrawImage(g.dialogBox, op) + + if st.Dialogue.Speaker != "" { + text.Draw(screen, st.Dialogue.Speaker, face, 60, screenHeight-225, color.RGBA{R: 255, G: 220, B: 120, A: 255}) + } + for i, line := range wrap(st.Dialogue.Text, 90) { + text.Draw(screen, line, face, 60, screenHeight-190+i*20, color.White) + } + + if len(st.Choices) > 0 { + for i, c := range st.Choices { + prefix := " " + clr := color.RGBA{R: 210, G: 210, B: 210, A: 255} + if i == st.Selected { + prefix = "> " + clr = color.RGBA{R: 120, G: 255, B: 180, A: 255} + } + text.Draw(screen, prefix+c.Text, face, 70, screenHeight-120+i*24, clr) + } + } else { + hint := g.texts.HintAdvance + if st.Ended { + hint = g.texts.HintEnded + } + text.Draw(screen, hint, face, screenWidth-220, screenHeight-35, color.RGBA{R: 220, G: 220, B: 220, A: 255}) + } + + text.Draw(screen, g.texts.StoryControls1, face, 20, screenHeight-40, color.RGBA{R: 210, G: 210, B: 210, A: 255}) + text.Draw(screen, g.texts.StoryControls2, face, 20, screenHeight-20, color.RGBA{R: 220, G: 220, B: 220, A: 255}) + + if err := g.engine.Error(); err != nil { + text.Draw(screen, fmt.Sprintf(g.texts.ErrorBannerFmt, err), face, 20, 20, color.RGBA{R: 255, G: 80, B: 80, A: 255}) + } + if g.noticeFrames > 0 { + text.Draw(screen, g.notice, face, screenWidth-260, 30, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + } + if g.debugEnabled { + g.drawDebugOverlay(screen) + } +} + +func (g *Game) drawMenu(screen *ebiten.Image, st RenderState) { + for i, item := range st.MenuItems { + img, err := g.loadImage(item.Sprite) + if err != nil { + continue + } + x, y := menuPositionToPixels(item.X, item.Y) + w, h := drawImageCenteredBounded(img, screen, x, y, 260, 260) + if i == st.MenuSelected { + pad := 8.0 + ebitenutil.DrawRect(screen, x-w/2-pad, y-h/2-pad, w+pad*2, 2, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + ebitenutil.DrawRect(screen, x-w/2-pad, y+h/2+pad-2, w+pad*2, 2, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + ebitenutil.DrawRect(screen, x-w/2-pad, y-h/2-pad, 2, h+pad*2, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + ebitenutil.DrawRect(screen, x+w/2+pad-2, y-h/2-pad, 2, h+pad*2, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + } + } +} + +func menuPositionToPixels(x, y float64) (float64, float64) { + px := x + py := y + if x >= 0 && x <= 1 { + px = x * screenWidth + } + if y >= 0 && y <= 1 { + py = y * screenHeight + } + return px, py +} + +func drawImageCenteredBounded(src, dst *ebiten.Image, cx, cy, maxW, maxH float64) (drawW, drawH float64) { + sw, sh := src.Bounds().Dx(), src.Bounds().Dy() + if sw == 0 || sh == 0 { + return 0, 0 + } + scale := min(maxW/float64(sw), maxH/float64(sh)) + drawW = float64(sw) * scale + drawH = float64(sh) * scale + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(cx-drawW/2, cy-drawH/2) + dst.DrawImage(src, op) + return drawW, drawH +} + +func (g *Game) Layout(_, _ int) (int, int) { + return screenWidth, screenHeight +} + +func (g *Game) drawTitle(screen *ebiten.Image) { + screen.Fill(color.RGBA{R: 14, G: 20, B: 32, A: 255}) + face := basicfont.Face7x13 + st := g.engine.State() + + text.Draw(screen, st.Title, face, screenWidth/2-70, 180, color.RGBA{R: 240, G: 240, B: 255, A: 255}) + text.Draw(screen, g.texts.TitleSubtitle, face, screenWidth/2-80, 210, color.RGBA{R: 170, G: 180, B: 210, A: 255}) + + items := []string{g.texts.TitleMenuStart, g.texts.TitleMenuLoad, g.texts.TitleMenuQuit} + for i, item := range items { + prefix := " " + clr := color.RGBA{R: 200, G: 200, B: 200, A: 255} + if i == g.titleSelected { + prefix = "> " + clr = color.RGBA{R: 120, G: 255, B: 180, A: 255} + } + text.Draw(screen, prefix+item, face, screenWidth/2-40, 300+i*36, clr) + } + + text.Draw(screen, g.texts.TitleControls, face, screenWidth/2-50, 460, color.RGBA{R: 160, G: 170, B: 190, A: 255}) + text.Draw(screen, g.texts.TitleControls2, face, screenWidth/2-110, 490, color.RGBA{R: 160, G: 170, B: 190, A: 255}) + if g.noticeFrames > 0 { + text.Draw(screen, g.notice, face, screenWidth/2-120, 520, color.RGBA{R: 255, G: 180, B: 120, A: 255}) + } +} + +func (g *Game) drawLoading(screen *ebiten.Image) { + screen.Fill(color.RGBA{R: 12, G: 16, B: 24, A: 255}) + face := basicfont.Face7x13 + text.Draw(screen, g.script.Title, face, screenWidth/2-70, 220, color.RGBA{R: 230, G: 230, B: 245, A: 255}) + text.Draw(screen, g.texts.LoadingTitle, face, screenWidth/2-30, 300, color.RGBA{R: 140, G: 240, B: 180, A: 255}) + text.Draw(screen, g.texts.LoadingMessage, face, screenWidth/2-130, 330, color.RGBA{R: 170, G: 180, B: 200, A: 255}) + text.Draw(screen, g.texts.LoadingWait, face, screenWidth/2-40, 360, color.RGBA{R: 170, G: 180, B: 200, A: 255}) +} + +func (g *Game) captureDebugInput() { + if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(g.debugInput) > 0 { + g.debugInput = g.debugInput[:len(g.debugInput)-1] + } + keys := inpututil.AppendJustPressedKeys(nil) + for _, k := range keys { + if ch, ok := keyToSceneChar(k); ok { + g.debugInput += ch + } + } +} + +func keyToSceneChar(k ebiten.Key) (string, bool) { + switch { + case k >= ebiten.KeyA && k <= ebiten.KeyZ: + return strings.ToLower(string(rune('A' + (k - ebiten.KeyA)))), true + case k >= ebiten.Key0 && k <= ebiten.Key9: + return string(rune('0' + (k - ebiten.Key0))), true + case k == ebiten.KeyMinus: + if ebiten.IsKeyPressed(ebiten.KeyShiftLeft) || ebiten.IsKeyPressed(ebiten.KeyShiftRight) { + return "_", true + } + return "-", true + } + return "", false +} + +func (g *Game) drawDebugOverlay(screen *ebiten.Image) { + face := basicfont.Face7x13 + ebitenutil.DrawRect(screen, 20, 60, 420, 600, color.RGBA{R: 0, G: 0, B: 0, A: 180}) + text.Draw(screen, g.texts.DebugHeader, face, 32, 84, color.RGBA{R: 120, G: 255, B: 180, A: 255}) + text.Draw(screen, fmt.Sprintf(g.texts.DebugCurrentScene, g.engine.CurrentSceneID()), face, 32, 106, color.White) + text.Draw(screen, g.texts.DebugJumpPrefix+g.debugInput, face, 32, 128, color.White) + text.Draw(screen, g.texts.DebugJumpHelp, face, 32, 150, color.RGBA{R: 200, G: 200, B: 200, A: 255}) + + vars := g.engine.VarsSnapshot() + names := make([]string, 0, len(vars)) + for k := range vars { + names = append(names, k) + } + sort.Strings(names) + + text.Draw(screen, g.texts.DebugVariables, face, 32, 178, color.RGBA{R: 255, G: 220, B: 120, A: 255}) + y := 198 + maxLines := 14 + for i, name := range names { + if i >= maxLines { + text.Draw(screen, fmt.Sprintf(g.texts.DebugMoreFmt, len(names)-maxLines), face, 32, y, color.RGBA{R: 180, G: 180, B: 180, A: 255}) + break + } + v := vars[name] + text.Draw(screen, fmt.Sprintf(g.texts.DebugVarFmt, name, v.Type, v.String()), face, 32, y, color.White) + y += 18 + } + + ids := g.engine.SceneIDs() + prefix := strings.TrimSpace(g.debugInput) + filtered := make([]string, 0, len(ids)) + for _, id := range ids { + if prefix == "" || strings.HasPrefix(id, prefix) { + filtered = append(filtered, id) + } + } + text.Draw(screen, g.texts.DebugScenes, face, 32, 480, color.RGBA{R: 255, G: 220, B: 120, A: 255}) + y = 500 + for i, id := range filtered { + if i >= 8 { + text.Draw(screen, fmt.Sprintf(g.texts.DebugMoreFmt, len(filtered)-8), face, 32, y, color.RGBA{R: 180, G: 180, B: 180, A: 255}) + break + } + text.Draw(screen, id, face, 32, y, color.White) + y += 18 + } +} + +func (g *Game) drawBackground(screen *ebiten.Image, st RenderState) { + if st.BackgroundImage != "" { + if bg, err := g.loadImage(st.BackgroundImage); err == nil { + drawImageCover(bg, screen) + return + } + } + if looksLikeImagePath(st.Background) { + if bg, err := g.loadImage(st.Background); err == nil { + drawImageCover(bg, screen) + return + } + } + + bg := colorByName["default"] + if c, ok := colorByName[strings.ToLower(st.Background)]; ok { + bg = c + } + screen.Fill(bg) +} + +func (g *Game) loadImage(path string) (*ebiten.Image, error) { + resolved := path + if !filepath.IsAbs(path) { + resolved = filepath.Join(g.scriptBaseDir, path) + } + if img, ok := g.imageCache[resolved]; ok { + return img, nil + } + if err, ok := g.imageErrs[resolved]; ok { + return nil, err + } + img, _, err := ebitenutil.NewImageFromFile(resolved) + if err != nil { + g.imageErrs[resolved] = err + return nil, err + } + g.imageCache[resolved] = img + return img, nil +} + +func (g *Game) setNotice(msg string) { + g.notice = msg + g.noticeFrames = 240 +} + +func looksLikeImagePath(v string) bool { + v = strings.ToLower(v) + return strings.HasSuffix(v, ".png") || strings.HasSuffix(v, ".jpg") || strings.HasSuffix(v, ".jpeg") || strings.HasSuffix(v, ".gif") +} + +func drawImageCover(src, dst *ebiten.Image) { + sw, sh := src.Bounds().Dx(), src.Bounds().Dy() + dw, dh := dst.Bounds().Dx(), dst.Bounds().Dy() + if sw == 0 || sh == 0 { + return + } + scale := max(float64(dw)/float64(sw), float64(dh)/float64(sh)) + w := float64(sw) * scale + h := float64(sh) * scale + x := (float64(dw) - w) / 2 + y := (float64(dh) - h) / 2 + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(x, y) + dst.DrawImage(src, op) +} + +func drawImageContain(src, dst *ebiten.Image, xRatio, yRatio, maxW, maxH float64) { + sw, sh := src.Bounds().Dx(), src.Bounds().Dy() + dw, dh := dst.Bounds().Dx(), dst.Bounds().Dy() + if sw == 0 || sh == 0 { + return + } + maxWpx := float64(dw) * maxW + maxHpx := float64(dh) * maxH + scale := min(maxWpx/float64(sw), maxHpx/float64(sh)) + w := float64(sw) * scale + h := float64(sh) * scale + x := float64(dw)*xRatio - w/2 + y := float64(dh)*yRatio - h/2 + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(x, y) + dst.DrawImage(src, op) +} + +func max(a, b float64) float64 { + if a > b { + return a + } + return b +} + +func min(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func wrap(s string, max int) []string { + if len(s) <= max { + return []string{s} + } + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + var lines []string + line := words[0] + for _, w := range words[1:] { + candidate := line + " " + w + if len(candidate) > max { + lines = append(lines, line) + line = w + } else { + line = candidate + } + } + lines = append(lines, line) + return lines +} diff --git a/internal/vn/script.go b/internal/vn/script.go new file mode 100644 index 0000000..d7be592 --- /dev/null +++ b/internal/vn/script.go @@ -0,0 +1,144 @@ +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 +} diff --git a/internal/vn/ui.go b/internal/vn/ui.go new file mode 100644 index 0000000..e4884e8 --- /dev/null +++ b/internal/vn/ui.go @@ -0,0 +1,67 @@ +package vn + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Texts contains all user-facing strings loaded from YAML. +type Texts struct { + EndText string `yaml:"end_text"` + EngineErrorSpeaker string `yaml:"engine_error_speaker"` + CharacterLabelFmt string `yaml:"character_label_fmt"` + LoadingTitle string `yaml:"loading_title"` + LoadingMessage string `yaml:"loading_message"` + LoadingWait string `yaml:"loading_wait"` + TitleSubtitle string `yaml:"title_subtitle"` + TitleMenuStart string `yaml:"title_menu_start"` + TitleMenuLoad string `yaml:"title_menu_load"` + TitleMenuQuit string `yaml:"title_menu_quit"` + TitleControls string `yaml:"title_controls"` + TitleControls2 string `yaml:"title_controls_2"` + HintAdvance string `yaml:"hint_advance"` + HintEnded string `yaml:"hint_ended"` + StoryControls1 string `yaml:"story_controls_1"` + StoryControls2 string `yaml:"story_controls_2"` + NoticeNewGame string `yaml:"notice_new_game"` + NoticeLoaded string `yaml:"notice_loaded"` + NoticeSaved string `yaml:"notice_saved"` + NoticeLoadFailed string `yaml:"notice_load_failed"` + NoticeSaveFailed string `yaml:"notice_save_failed"` + NoticeJumpFailed string `yaml:"notice_jump_failed"` + NoticeJumped string `yaml:"notice_jumped"` + NoticeDebugOn string `yaml:"notice_debug_on"` + NoticeDebugOff string `yaml:"notice_debug_off"` + FlightCheckPassed string `yaml:"flight_check_passed"` + FlightCheckOneFmt string `yaml:"flight_check_one_fmt"` + FlightCheckManyFmt string `yaml:"flight_check_many_fmt"` + FlightMissingScriptFmt string `yaml:"flight_missing_script_fmt"` + FlightDuplicateSceneFmt string `yaml:"flight_duplicate_scene_fmt"` + FlightStartMissingFmt string `yaml:"flight_start_missing_fmt"` + FlightGotoMissingFmt string `yaml:"flight_goto_missing_fmt"` + FlightChoiceMissingFmt string `yaml:"flight_choice_missing_fmt"` + FlightMissingAssetFmt string `yaml:"flight_missing_asset_fmt"` + DebugHeader string `yaml:"debug_header"` + DebugCurrentScene string `yaml:"debug_current_scene"` + DebugJumpPrefix string `yaml:"debug_jump_prefix"` + DebugJumpHelp string `yaml:"debug_jump_help"` + DebugVariables string `yaml:"debug_variables"` + DebugScenes string `yaml:"debug_scenes"` + DebugMoreFmt string `yaml:"debug_more_fmt"` + DebugVarFmt string `yaml:"debug_var_fmt"` + ErrorBannerFmt string `yaml:"error_banner_fmt"` +} + +func LoadTexts(path string) (*Texts, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read texts: %w", err) + } + var t Texts + if err := yaml.Unmarshal(data, &t); err != nil { + return nil, fmt.Errorf("parse texts yaml: %w", err) + } + return &t, nil +}