primeiro commit

This commit is contained in:
2026-02-12 15:25:40 -03:00
commit 0dcfb9c9f1
15 changed files with 2082 additions and 0 deletions

4
AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
## Working agreements
- Prefer importing and using libraries rather than writing code

3
BUILD.md Normal file
View File

@@ -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

174
README.md Normal file
View File

@@ -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.).

35
cmd/vnengine/main.go Normal file
View File

@@ -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)
}
}

View File

@@ -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

4
default/script.yaml Normal file
View File

@@ -0,0 +1,4 @@
title: "Ecos ao Amanhecer"
start: intro
imports:
- chapters/act1.yaml

View File

@@ -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

49
default/ui.yaml Normal file
View File

@@ -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"

18
go.mod Normal file
View File

@@ -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
)

26
go.sum Normal file
View File

@@ -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=

734
internal/vn/engine.go Normal file
View File

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

120
internal/vn/flightcheck.go Normal file
View File

@@ -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
}

599
internal/vn/game.go Normal file
View File

@@ -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
}

144
internal/vn/script.go Normal file
View File

@@ -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, &current); err != nil {
return nil, fmt.Errorf("parse yaml %s: %w", path, err)
}
visited[path] = true
stack[path] = true
*loaded = append(*loaded, path)
mergedScenes := make([]Scene, 0, len(current.Scenes))
for _, imp := range current.Imports {
resolved := imp
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(filepath.Dir(path), imp)
}
resolved, err = filepath.Abs(resolved)
if err != nil {
delete(stack, path)
return nil, fmt.Errorf("resolve import %s from %s: %w", imp, path, err)
}
child, err := loadScriptRecursive(resolved, depth+1, visited, stack, loaded)
if err != nil {
delete(stack, path)
return nil, err
}
mergedScenes = append(mergedScenes, child.Scenes...)
}
delete(stack, path)
mergedScenes = append(mergedScenes, current.Scenes...)
current.Scenes = mergedScenes
return &current, nil
}

67
internal/vn/ui.go Normal file
View File

@@ -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
}