primeiro commit
This commit is contained in:
4
AGENTS.md
Normal file
4
AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
## Working agreements
|
||||||
|
|
||||||
|
- Prefer importing and using libraries rather than writing code
|
||||||
3
BUILD.md
Normal file
3
BUILD.md
Normal 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
174
README.md
Normal 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
35
cmd/vnengine/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
default/chapters/act1.yaml
Normal file
86
default/chapters/act1.yaml
Normal 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
4
default/script.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
title: "Ecos ao Amanhecer"
|
||||||
|
start: intro
|
||||||
|
imports:
|
||||||
|
- chapters/act1.yaml
|
||||||
19
default/shared/finale.yaml
Normal file
19
default/shared/finale.yaml
Normal 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
49
default/ui.yaml
Normal 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
18
go.mod
Normal 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
26
go.sum
Normal 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
734
internal/vn/engine.go
Normal 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
120
internal/vn/flightcheck.go
Normal 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
599
internal/vn/game.go
Normal 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
144
internal/vn/script.go
Normal 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, ¤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
|
||||||
|
}
|
||||||
67
internal/vn/ui.go
Normal file
67
internal/vn/ui.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user