initial
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/audio"
|
||||
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
"github.com/hajimehoshi/ebiten/v2/text/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
puzzleTileSymW = 7
|
||||
puzzleTileSymH = 3
|
||||
puzzleSymW = 9
|
||||
puzzleSymH = 16
|
||||
puzzleSymX = 31
|
||||
puzzleSymY = 16
|
||||
puzzlePixX = puzzleSymX * puzzleSymW
|
||||
puzzlePixY = puzzleSymY * puzzleSymH
|
||||
|
||||
logLineH = 18
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed assets/fonts/font9x16.ttf
|
||||
DosFont []byte
|
||||
//go:embed assets/audio/click.mp3
|
||||
Click_mp3 []byte
|
||||
//go:embed assets/text/game_field.txt
|
||||
playFieldTemplate string
|
||||
//go:embed assets/text/splash_screen.txt
|
||||
splashScreenTemplate string
|
||||
//go:embed assets/text/gopher_cyan.txt
|
||||
gopherCyan string
|
||||
//go:embed assets/text/gopher_white.txt
|
||||
gopherWhite string
|
||||
//go:embed assets/text/gopher_beige.txt
|
||||
gopherBeige string
|
||||
//go:embed assets/text/gopher_gray.txt
|
||||
gopherGray string
|
||||
|
||||
defaultBgColor = color.Black
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenGame screen = iota
|
||||
screenForm
|
||||
screenSplash
|
||||
screenDebug
|
||||
)
|
||||
|
||||
type ticker int
|
||||
|
||||
const (
|
||||
ticker1Hz ticker = iota
|
||||
ticker2Hz
|
||||
ticker4Hz
|
||||
ticker10Hz
|
||||
ticker25Hz
|
||||
)
|
||||
|
||||
type actionResult int
|
||||
|
||||
const (
|
||||
resultNone actionResult = iota
|
||||
resultSwitchGame
|
||||
resultSwitchForm
|
||||
resultSwitchDebug
|
||||
)
|
||||
|
||||
type Audio interface {
|
||||
PlaySound()
|
||||
}
|
||||
|
||||
type Screen interface {
|
||||
Fill(rect image.Rectangle, clr color.Color)
|
||||
Print(txt string, coord image.Point, clr color.Color)
|
||||
PrintDebug(msg string, line int)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
Draw(Screen)
|
||||
Tick(ticker)
|
||||
Interact(Audio, int, int, time.Duration) actionResult
|
||||
SetLang(langCode)
|
||||
Activate()
|
||||
}
|
||||
|
||||
type Controller struct {
|
||||
screen image.Rectangle
|
||||
bgColor color.Color
|
||||
scr *ebiten.Image
|
||||
font *text.GoTextFaceSource
|
||||
|
||||
activeState atomic.Bool
|
||||
activeScreen screen
|
||||
screens map[screen]Handler
|
||||
|
||||
debugFn func(string)
|
||||
|
||||
btnPressed time.Time
|
||||
touchTapped map[ebiten.TouchID]time.Time
|
||||
OnGameStart func()
|
||||
OnGameSolve func(int)
|
||||
InfoRequest func()
|
||||
UserStatsRequest func()
|
||||
MonitoringRequest func(string)
|
||||
UrlOpener func(string)
|
||||
|
||||
audioCtx *audio.Context
|
||||
player *audio.Player
|
||||
}
|
||||
|
||||
func Init(init ...func(*Controller)) error {
|
||||
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
|
||||
ebiten.SetWindowSize(640, 480)
|
||||
ebiten.SetWindowTitle(l10nGameTitle(langCodeEn))
|
||||
c, err := newController()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range init {
|
||||
init[i](c)
|
||||
}
|
||||
c.screens[screenGame] = newGame(c.OnGameStart, c.OnGameSolve, c.UserStatsRequest)
|
||||
c.screens[screenForm] = newForm(c.MonitoringRequest)
|
||||
c.screens[screenSplash] = newSplash(c.UrlOpener)
|
||||
c.screens[screenDebug], c.debugFn = newDebugOverlay()
|
||||
|
||||
c.SetLangCode(langCodeRu)
|
||||
|
||||
return ebiten.RunGame(c)
|
||||
}
|
||||
|
||||
func newController() (*Controller, error) {
|
||||
tfs, err := text.NewGoTextFaceSource(bytes.NewReader(DosFont))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
audioCtx := audio.NewContext(44100)
|
||||
s, err := mp3.DecodeWithoutResampling(bytes.NewReader(Click_mp3))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
player, err := audioCtx.NewPlayer(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Controller{
|
||||
scr: ebiten.NewImage(puzzlePixX, puzzlePixY),
|
||||
font: tfs,
|
||||
activeScreen: screenSplash,
|
||||
screens: make(map[screen]Handler),
|
||||
touchTapped: make(map[ebiten.TouchID]time.Time),
|
||||
player: player,
|
||||
audioCtx: audioCtx,
|
||||
activeState: atomic.Bool{},
|
||||
}
|
||||
p.OnGameStart = func() {}
|
||||
p.OnGameSolve = func(int) {}
|
||||
p.InfoRequest = func() {}
|
||||
p.UserStatsRequest = func() {}
|
||||
p.MonitoringRequest = func(string) {}
|
||||
p.UrlOpener = func(url string) {
|
||||
if err := openURL(url); err != nil {
|
||||
p.Debug("url open error: %v", err)
|
||||
}
|
||||
}
|
||||
go p.tick()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Controller) tick() {
|
||||
t1Hz := time.NewTicker(time.Second)
|
||||
t2Hz := time.NewTicker(time.Millisecond * 500)
|
||||
t4Hz := time.NewTicker(time.Millisecond * 250)
|
||||
t10Hz := time.NewTicker(time.Millisecond * 100)
|
||||
t25Hz := time.NewTicker(time.Millisecond * 40)
|
||||
for {
|
||||
select {
|
||||
case <-t1Hz.C:
|
||||
c.TickEvent(ticker1Hz)
|
||||
case <-t2Hz.C:
|
||||
c.TickEvent(ticker2Hz)
|
||||
case <-t4Hz.C:
|
||||
c.TickEvent(ticker4Hz)
|
||||
case <-t10Hz.C:
|
||||
c.TickEvent(ticker10Hz)
|
||||
case <-t25Hz.C:
|
||||
c.TickEvent(ticker25Hz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) SetActive(active bool) {
|
||||
c.activeState.Store(active)
|
||||
}
|
||||
|
||||
func (c *Controller) TickEvent(t ticker) {
|
||||
c.screens[c.activeScreen].Tick(t)
|
||||
}
|
||||
|
||||
func (c *Controller) Update() error {
|
||||
if !c.activeState.Load() {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case inpututil.IsMouseButtonJustPressed(ebiten.MouseButton0):
|
||||
c.btnPressed = time.Now()
|
||||
case inpututil.IsMouseButtonJustReleased(ebiten.MouseButton0):
|
||||
x, y := ebiten.CursorPosition()
|
||||
c.interact(x, y, time.Since(c.btnPressed))
|
||||
c.btnPressed = time.Time{}
|
||||
}
|
||||
|
||||
for _, id := range inpututil.AppendJustPressedTouchIDs(make([]ebiten.TouchID, 0)) {
|
||||
c.touchTapped[id] = time.Now()
|
||||
}
|
||||
|
||||
for id, v := range c.touchTapped {
|
||||
if v.IsZero() || v.Add(time.Second*5).Before(time.Now()) {
|
||||
delete(c.touchTapped, id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range inpututil.AppendJustReleasedTouchIDs(make([]ebiten.TouchID, 0)) {
|
||||
if v, ok := c.touchTapped[id]; ok {
|
||||
x, y := inpututil.TouchPositionInPreviousTick(id)
|
||||
c.interact(x, y, time.Since(v))
|
||||
}
|
||||
delete(c.touchTapped, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) Draw(screen *ebiten.Image) {
|
||||
if !c.activeState.Load() {
|
||||
return
|
||||
}
|
||||
screen.Fill(c.backgroundColor())
|
||||
|
||||
c.drawMatrixScreen(c.calcBounds(screen))
|
||||
}
|
||||
|
||||
func (c *Controller) PrintDebug(msg string, line int) {
|
||||
ebitenutil.DebugPrintAt(c.scr, msg, 0, c.scr.Bounds().Dy()-logLineH*(line+1)-12)
|
||||
}
|
||||
|
||||
func (c *Controller) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
|
||||
return outsideWidth, outsideHeight
|
||||
}
|
||||
|
||||
func (c *Controller) PlaySound() {
|
||||
c.player.SetVolume(0.2)
|
||||
c.player.SetPosition(0)
|
||||
c.player.Play()
|
||||
}
|
||||
|
||||
func (c *Controller) interact(x, y int, t time.Duration) {
|
||||
col := int(math.Floor(float64(x-c.screen.Min.X) / (float64(puzzleSymW) * (float64(c.screen.Bounds().Dx()) / float64(c.scr.Bounds().Dx())))))
|
||||
row := int(math.Floor(float64(y-c.screen.Min.Y) / (float64(puzzleSymH) * (float64(c.screen.Bounds().Dy()) / float64(c.scr.Bounds().Dy())))))
|
||||
|
||||
switch c.screens[c.activeScreen].Interact(c, col, row, t) {
|
||||
case resultSwitchDebug:
|
||||
c.screens[screenDebug].Activate()
|
||||
fallthrough
|
||||
case resultSwitchGame:
|
||||
c.switchScreen(screenGame)
|
||||
case resultSwitchForm:
|
||||
c.switchScreen(screenForm)
|
||||
default:
|
||||
// NOOP
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) switchScreen(s screen) {
|
||||
c.btnPressed = time.Time{}
|
||||
c.activeScreen = s
|
||||
c.screens[c.activeScreen].Activate()
|
||||
}
|
||||
|
||||
func (c *Controller) Debug(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
slog.Default().Debug(msg)
|
||||
c.debugFn(msg)
|
||||
}
|
||||
|
||||
func (c *Controller) calcBounds(screen *ebiten.Image) *ebiten.Image {
|
||||
bounds := screen.Bounds()
|
||||
var rec = &image.Rectangle{Min: image.Point{}, Max: image.Point{bounds.Dx(), bounds.Dy()}}
|
||||
|
||||
kefX := float64(puzzlePixX) / float64(puzzlePixY)
|
||||
kefY := float64(puzzlePixY) / float64(puzzlePixX)
|
||||
|
||||
screenX := math.Floor(kefX * float64(bounds.Dy()))
|
||||
screenY := math.Floor(kefY * float64(bounds.Dx()))
|
||||
|
||||
if int(screenX) > bounds.Dx() {
|
||||
screenX = kefX * screenY
|
||||
}
|
||||
if int(screenY) > bounds.Dy() {
|
||||
screenY = kefY * screenX
|
||||
}
|
||||
|
||||
rec.Min.X = (bounds.Dx() - int(screenX)) / 2
|
||||
rec.Max.X = bounds.Dx() - rec.Min.X
|
||||
rec.Min.Y = (bounds.Dy() - int(screenY)) / 2
|
||||
rec.Max.Y = bounds.Dy() - rec.Min.Y
|
||||
|
||||
c.screen = *rec
|
||||
|
||||
return screen.SubImage(*rec).(*ebiten.Image)
|
||||
}
|
||||
|
||||
func (c *Controller) drawMatrixScreen(scr *ebiten.Image) {
|
||||
c.scr.Clear()
|
||||
|
||||
c.screens[c.activeScreen].Draw(c)
|
||||
c.screens[screenDebug].Draw(c) // appears as overlay when active
|
||||
|
||||
opts := &ebiten.DrawImageOptions{}
|
||||
opts.GeoM.Scale(float64(scr.Bounds().Dx())/float64(c.scr.Bounds().Dx()), float64(scr.Bounds().Dy())/float64(c.scr.Bounds().Dy()))
|
||||
opts.GeoM.Translate(float64(scr.Bounds().Min.X), float64(scr.Bounds().Min.Y))
|
||||
scr.DrawImage(c.scr, opts)
|
||||
}
|
||||
|
||||
func drawGameField(s Screen) {
|
||||
s.Fill(image.Rect(0, 0, puzzleSymX, puzzleSymY), // header and all field
|
||||
nil) // background: outside background (screen)
|
||||
|
||||
s.Fill(image.Rect(1, 0, puzzleSymX-1, 2), // header only
|
||||
color.RGBA{0x40, 0xA0, 0x40, 0xFF}) // background: yellow
|
||||
|
||||
s.Fill(image.Rect(1, 2, puzzleSymX-1, puzzleSymY), // play field
|
||||
color.RGBA{0, 0x00, 0xA0, 0xFF}) // background: ALWAYS same as tile
|
||||
|
||||
s.Print(playFieldTemplate, // border
|
||||
image.Point{0, 0},
|
||||
color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}) // text: white
|
||||
}
|
||||
|
||||
func (c *Controller) backgroundColor() color.Color {
|
||||
if c.bgColor == nil {
|
||||
return defaultBgColor
|
||||
}
|
||||
return c.bgColor
|
||||
}
|
||||
|
||||
func (c *Controller) Fill(rect image.Rectangle, color color.Color) {
|
||||
if color == nil {
|
||||
color = c.backgroundColor()
|
||||
}
|
||||
(c.scr.SubImage(image.Rect(
|
||||
rect.Min.X*puzzleSymW,
|
||||
rect.Min.Y*puzzleSymH,
|
||||
int(math.Min(float64(rect.Max.X), float64(puzzleSymX)))*puzzleSymW,
|
||||
int(math.Min(float64(rect.Max.Y), float64(puzzleSymY)))*puzzleSymH))).(*ebiten.Image).Fill(color)
|
||||
}
|
||||
|
||||
func (c *Controller) Print(txt string, coord image.Point, clr color.Color) {
|
||||
op := &text.DrawOptions{}
|
||||
op.GeoM.Translate(float64(coord.X*puzzleSymW), float64(coord.Y*puzzleSymH))
|
||||
op.ColorScale.ScaleWithColor(clr)
|
||||
op.LineSpacing = float64(puzzleSymH)
|
||||
op.PrimaryAlign = text.AlignStart
|
||||
op.SecondaryAlign = text.AlignStart
|
||||
text.Draw(c.scr, txt, &text.GoTextFace{
|
||||
Source: c.font,
|
||||
Size: float64(puzzleSymH),
|
||||
}, op)
|
||||
}
|
||||
|
||||
func printHeader(s Screen, text string, align int) {
|
||||
var p image.Point
|
||||
l := utf8.RuneCountInString(text)
|
||||
switch {
|
||||
case align < 0:
|
||||
p = image.Point{2, 1}
|
||||
case align > 0:
|
||||
p = image.Point{puzzleSymX - 2 - l, 1}
|
||||
default:
|
||||
p = image.Point{(puzzleSymX - l) / 2, 1}
|
||||
}
|
||||
s.Print(text, p, color.White)
|
||||
}
|
||||
|
||||
func (c *Controller) OnLoad(loadSec float64, bgColor, langCode string) {
|
||||
c.InfoRequest()
|
||||
c.SetLangCode(langCode)
|
||||
c.SetBgColor(bgColor)
|
||||
}
|
||||
|
||||
func (c *Controller) SetLangCode(lc string) {
|
||||
for _, s := range c.screens {
|
||||
s.SetLang(l10nCode(lc))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) SetBgColor(clr string) {
|
||||
colorStr, err := normalize(clr)
|
||||
if err != nil {
|
||||
c.Debug(fmt.Sprintf("ERROR: %s", err))
|
||||
} else {
|
||||
b, err := hex.DecodeString(colorStr)
|
||||
switch {
|
||||
case err != nil:
|
||||
c.Debug(fmt.Sprintf("ERROR: %s", err))
|
||||
case c.bgColor == nil:
|
||||
fallthrough
|
||||
default:
|
||||
c.bgColor = color.RGBA{b[0], b[1], b[2], b[3]}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(colorStr string) (string, error) {
|
||||
offset := 0
|
||||
if strings.HasPrefix(colorStr, "#") {
|
||||
offset = 1
|
||||
}
|
||||
b := colorStr[offset:]
|
||||
if len(b) == 8 {
|
||||
return b, nil
|
||||
}
|
||||
if len(b) != 6 {
|
||||
return "", fmt.Errorf("normalize: not enough symbols: '%s'", colorStr)
|
||||
}
|
||||
return b + "FF", nil
|
||||
}
|
||||
Reference in New Issue
Block a user