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

600 lines
16 KiB
Go

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
}