600 lines
16 KiB
Go
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
|
|
}
|