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 }