initial
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Retro VGA Font
|
||||
|
||||
Credits:
|
||||
|
||||
- [VileR](http://int10h.org/).
|
||||
- [fly_indiz](http://old-dos.ru/index.php?page=files&mode=files&do=show&id=102798).
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌
|
||||
▐ ▌
|
||||
▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐ ▌
|
||||
▐▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▌
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
|
||||
|
||||
( )
|
||||
|
||||
|
||||
O O
|
||||
|
||||
|
||||
|
||||
oO Oo
|
||||
@@ -0,0 +1,12 @@
|
||||
´.-::::::-.´
|
||||
.:-::::::::::::::-:.
|
||||
´_:: ::_´
|
||||
.: :.
|
||||
´:: ::.
|
||||
´::::::: :::::::´
|
||||
.::::::::::::::::.
|
||||
::::::::::::::::
|
||||
-::::::::::::::::-
|
||||
´::::::::::::::::´
|
||||
.::::::::::::::.
|
||||
:::::::
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
..
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
: :: :
|
||||
( ^ :: ^ )
|
||||
: :
|
||||
UU
|
||||
@@ -0,0 +1,16 @@
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ -> │
|
||||
└─────────────────────────────┘
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package puzzle
|
||||
|
||||
import "time"
|
||||
|
||||
type debug struct {
|
||||
idx int
|
||||
debugMsg []string
|
||||
active bool
|
||||
}
|
||||
|
||||
func newDebugOverlay() (*debug, func(string)) {
|
||||
d := &debug{
|
||||
debugMsg: make([]string, 10),
|
||||
}
|
||||
return d, d.AddMessage
|
||||
}
|
||||
|
||||
func (d *debug) AddMessage(msg string) {
|
||||
d.debugMsg[d.idx] = msg
|
||||
d.idx = (d.idx + 1) % len(d.debugMsg)
|
||||
}
|
||||
|
||||
func (d *debug) Draw(s Screen) {
|
||||
if !d.active || len(d.debugMsg) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range d.debugMsg {
|
||||
s.PrintDebug(d.debugMsg[i], i)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *debug) Activate() { d.active = true }
|
||||
func (d *debug) Interact(Audio, int, int, time.Duration) actionResult { return resultNone }
|
||||
func (d *debug) Tick(ticker) {}
|
||||
func (d *debug) SetLang(langCode) {}
|
||||
@@ -0,0 +1,113 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/model"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dials = 10
|
||||
codeInputTemplate = `[%s] [%s] [%s] [%s]`
|
||||
)
|
||||
|
||||
type form struct {
|
||||
dials [dials]*button
|
||||
request func(string)
|
||||
requestSent bool
|
||||
authFail bool
|
||||
idx int
|
||||
input [4]byte
|
||||
mon atomic.Value
|
||||
}
|
||||
|
||||
func intFn(i int) func() int { return func() int { return i } }
|
||||
func stringFn(s string) func() string { return func() string { return s } }
|
||||
func codeBox(b byte) string {
|
||||
if b > 0 {
|
||||
return "*"
|
||||
}
|
||||
return "_"
|
||||
}
|
||||
|
||||
func newForm(request func(string)) *form {
|
||||
col := func(i int) int { return (i%3)*puzzleTileSymW + 5 }
|
||||
row := func(i int) int { return (i/3)*puzzleTileSymH + 3 }
|
||||
f := &form{request: request}
|
||||
for i := 1; i < dials; i++ {
|
||||
f.dials[i] = NewButton(stringFn(strconv.Itoa(i)), intFn(col(i-1)), intFn(row(i-1)))
|
||||
}
|
||||
f.dials[0] = NewButton(stringFn("0"), intFn(col(10)), intFn(row(10)))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *form) ApiMonitoringHandler(m model.Monitoring) {
|
||||
f.mon.Store(m)
|
||||
f.authFail = false
|
||||
}
|
||||
|
||||
func (f *form) Tick(t ticker) {
|
||||
if t == ticker1Hz && f.requestSent {
|
||||
f.authFail = f.mon.Load() == nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *form) Draw(s Screen) {
|
||||
drawGameField(s)
|
||||
if v := f.mon.Load(); v != nil {
|
||||
m := v.(model.Monitoring)
|
||||
printHeader(s, "Usage Statistics", 0)
|
||||
s.Print(fmt.Sprintf("Players: %d\nGames: %d\nSolved: %d", m.Users, m.GamesStarted, m.GamesSolved),
|
||||
image.Point{3, 4},
|
||||
color.RGBA{0, 0xFF, 0xFF, 0xFF})
|
||||
} else {
|
||||
r := image.Rect(8, 1, len(codeInputTemplate)+4, 2)
|
||||
if f.authFail {
|
||||
s.Fill(r, color.RGBA{0xFF, 0, 0, 0xFF})
|
||||
} else if f.idx == len(f.input) {
|
||||
s.Fill(r, color.RGBA{0x80, 0x80, 0x80, 0xFF})
|
||||
}
|
||||
printHeader(s, fmt.Sprintf(codeInputTemplate, codeBox(f.input[0]), codeBox(f.input[1]), codeBox(f.input[2]), codeBox(f.input[3])), 0)
|
||||
for i := range f.dials {
|
||||
f.dials[i].Draw(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *form) Interact(a Audio, col, row int, t time.Duration) actionResult {
|
||||
if (image.Point{col, row}).In(image.Rect(2, 1, puzzleSymX-2, 2)) {
|
||||
f.input = [4]byte{}
|
||||
f.authFail = false
|
||||
f.requestSent = false
|
||||
f.idx = 0
|
||||
return resultSwitchGame
|
||||
}
|
||||
if f.mon.Load() == nil {
|
||||
for i := range f.dials {
|
||||
if f.dials[i].Interact(col, row) {
|
||||
f.pressNextButton('0' + byte(i))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return resultNone
|
||||
}
|
||||
|
||||
func (f *form) pressNextButton(digit byte) {
|
||||
if f.idx >= len(f.input) {
|
||||
return
|
||||
}
|
||||
f.input[f.idx] = digit
|
||||
f.idx++
|
||||
if f.idx == len(f.input) {
|
||||
f.request(string(f.input[:]))
|
||||
f.requestSent = true
|
||||
}
|
||||
}
|
||||
|
||||
func (f *form) Activate() {}
|
||||
func (f *form) SetLang(langCode) {}
|
||||
@@ -0,0 +1,44 @@
|
||||
package puzzle
|
||||
|
||||
import "15-puzzle/internal/model"
|
||||
|
||||
func (c *Controller) ApiResponseHandler(u model.ApiResponse) {
|
||||
switch {
|
||||
case u.Err != nil:
|
||||
c.ApiErrorHandler(*u.Err)
|
||||
case u.Info != nil:
|
||||
c.ApiInfoHandler(*u.Info)
|
||||
case u.Stats != nil:
|
||||
c.ApiStatsHandler(*u.Stats)
|
||||
case u.Monitoring != nil:
|
||||
c.ApiMonitoringHandler(*u.Monitoring)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ApiInfoHandler(s model.Info) {
|
||||
for _, h := range c.screens {
|
||||
if i, ok := h.(interface{ ApiInfoHandler(model.Info) }); ok {
|
||||
i.ApiInfoHandler(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ApiStatsHandler(s model.Stats) {
|
||||
for _, h := range c.screens {
|
||||
if i, ok := h.(interface{ ApiStatsHandler(model.Stats) }); ok {
|
||||
i.ApiStatsHandler(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ApiMonitoringHandler(m model.Monitoring) {
|
||||
for _, h := range c.screens {
|
||||
if i, ok := h.(interface{ ApiMonitoringHandler(model.Monitoring) }); ok {
|
||||
i.ApiMonitoringHandler(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ApiErrorHandler(e string) {
|
||||
c.Debug("api error: %s", e)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package puzzle
|
||||
|
||||
type langCode string
|
||||
|
||||
const (
|
||||
langCodeEn = "en"
|
||||
langCodeRu = "ru"
|
||||
)
|
||||
|
||||
func l10nCode(lc string) langCode {
|
||||
switch langCode(lc) {
|
||||
case langCodeRu:
|
||||
return langCodeRu
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return langCodeEn
|
||||
}
|
||||
}
|
||||
|
||||
func l10nGameTitle(lc langCode) string {
|
||||
switch lc {
|
||||
case langCodeRu:
|
||||
return "Пятнашки"
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return "15 Puzzle"
|
||||
}
|
||||
}
|
||||
|
||||
func l10nSilent(lc langCode) string {
|
||||
switch lc {
|
||||
case langCodeRu:
|
||||
return "Тишина"
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return "Silent"
|
||||
}
|
||||
}
|
||||
|
||||
func l10nMoves(lc langCode) string {
|
||||
switch lc {
|
||||
case langCodeRu:
|
||||
return "Ходы"
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return "Moves"
|
||||
}
|
||||
}
|
||||
|
||||
func l10nRank(lc langCode) string {
|
||||
switch lc {
|
||||
case langCodeRu:
|
||||
return "Рейтинг"
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return "Rank"
|
||||
}
|
||||
}
|
||||
|
||||
func l10nWins(lc langCode) string {
|
||||
switch lc {
|
||||
case langCodeRu:
|
||||
return "Побед"
|
||||
case langCodeEn:
|
||||
fallthrough
|
||||
default:
|
||||
return "Wins"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/model"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math/rand/v2"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tiles = 16
|
||||
fieldSymX = 29
|
||||
fieldSymY = 12
|
||||
)
|
||||
|
||||
var (
|
||||
checkbox = map[bool]string{true: "[x]", false: "[ ]"}
|
||||
frames = [...]byte{'|', '/', '-', '\\'}
|
||||
)
|
||||
|
||||
type game struct {
|
||||
langCode langCode
|
||||
tiles [tiles]*tile
|
||||
moves int
|
||||
solved bool
|
||||
muted bool
|
||||
onStart func()
|
||||
onSolve func(int)
|
||||
requestStats func()
|
||||
|
||||
stats atomic.Value
|
||||
blinkCoef []float64
|
||||
|
||||
blink [fieldSymX][fieldSymY]int
|
||||
color [fieldSymX][fieldSymY]color.RGBA
|
||||
}
|
||||
|
||||
func newGame(onStart func(), onSolve func(int), request func()) *game {
|
||||
p := &game{
|
||||
langCode: langCodeEn,
|
||||
solved: true,
|
||||
onStart: onStart,
|
||||
onSolve: onSolve,
|
||||
requestStats: request,
|
||||
blinkCoef: []float64{1, .8, .6, .4, .2, 0, 0, .2, .4, .6, .8, 1},
|
||||
}
|
||||
|
||||
for i := range tiles {
|
||||
var pos int
|
||||
if i == 0 {
|
||||
pos = tiles - 1
|
||||
} else {
|
||||
pos = i - 1
|
||||
}
|
||||
p.tiles[i] = NewTile(i, pos)
|
||||
}
|
||||
|
||||
for x := range fieldSymX {
|
||||
for y := range fieldSymY {
|
||||
p.blink[x][y] = rand.IntN(len(frames))
|
||||
}
|
||||
}
|
||||
|
||||
p.requestStats()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (g *game) Tick(t ticker) {
|
||||
if t == ticker10Hz && g.solved {
|
||||
b := g.blinkCoef[0]
|
||||
for i := 1; i < len(g.blinkCoef); i++ {
|
||||
g.blinkCoef[i-1] = g.blinkCoef[i]
|
||||
}
|
||||
g.blinkCoef[len(g.blinkCoef)-1] = b
|
||||
if g.showCongrats() {
|
||||
for x := range fieldSymX {
|
||||
for y := range fieldSymY {
|
||||
g.blink[x][y] = (g.blink[x][y] + 1) % len(frames)
|
||||
g.color[x][y] = color.RGBA{uint8(rand.IntN(256)), uint8(rand.IntN(256)), uint8(rand.IntN(256)), 0xFF}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *game) SetLang(lc langCode) {
|
||||
g.langCode = lc
|
||||
}
|
||||
|
||||
func (g *game) ApiStatsHandler(s model.Stats) {
|
||||
g.stats.Store(s)
|
||||
}
|
||||
|
||||
func (g *game) Draw(s Screen) {
|
||||
drawGameField(s)
|
||||
if g.isSolved() {
|
||||
rating := l10nRank(g.langCode) + ": "
|
||||
wins := l10nWins(g.langCode) + ": "
|
||||
if !g.withStats(func(s model.Stats) {
|
||||
if s.Rank > 0 && s.GamesSolved > 0 {
|
||||
rating += fmt.Sprint(s.Rank)
|
||||
} else {
|
||||
rating = ""
|
||||
}
|
||||
wins += fmt.Sprint(s.GamesSolved)
|
||||
}) {
|
||||
rating, wins = "_", "_"
|
||||
}
|
||||
printHeader(s, rating, -1)
|
||||
printHeader(s, wins, 1)
|
||||
} else {
|
||||
printHeader(s, fmt.Sprintf(l10nSilent(g.langCode)+" %s", checkbox[g.muted]), 1)
|
||||
if !g.muted {
|
||||
printHeader(s, fmt.Sprintf(l10nMoves(g.langCode)+": %d", g.moves), -1)
|
||||
}
|
||||
}
|
||||
|
||||
if g.showCongrats() {
|
||||
for x := range fieldSymX {
|
||||
for y := range fieldSymY {
|
||||
s.Print(string(frames[g.blink[x][y]]), image.Point{x + 1, y + 3}, g.color[x][y])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
boardBottomColor := color.Black
|
||||
// boardBottomColor := color.RGBA{0xFF, 0x00, 0xFF, 0xFF}
|
||||
|
||||
for i := range tiles {
|
||||
var tileBackground color.Color = color.RGBA{0, 0, 0xA0, 0xFF} // background: 0x0000A0 (lighter) / 0x00006B (darker)
|
||||
var tileForeground color.Color = color.RGBA{0, 0xFF, 0xFF, 0xFF} // text: cyan 0x00FFFF
|
||||
fillRect := image.Rect(g.tiles[i].X(), g.tiles[i].Y(), g.tiles[i].X()+puzzleTileSymW, g.tiles[i].Y()+puzzleTileSymH)
|
||||
if i == 0 {
|
||||
if g.solved {
|
||||
cr, cg, cb, ca := tileForeground.RGBA()
|
||||
tileForeground = color.RGBA{byte(g.blinkCoef[0] * float64(cr)),
|
||||
byte(g.blinkCoef[0] * float64(cg)),
|
||||
byte(g.blinkCoef[0] * float64(cb)),
|
||||
byte(g.blinkCoef[0] * float64(ca))}
|
||||
} else {
|
||||
tileBackground = boardBottomColor
|
||||
fillRect.Max.X--
|
||||
}
|
||||
}
|
||||
if g.tiles[i].moving {
|
||||
// fillRect contains unshifted yet coordinates, time to fix font gallucinations when animating adjacent tiles
|
||||
fillRect.Max.X-- // right border: skip last column to justify on move
|
||||
s.Fill(fillRect, boardBottomColor) // partial "board bottom" from both sides
|
||||
fillRect.Max.X++ // right border: restore
|
||||
fillRect.Min.X-- // left border: extend to 1 col left to justify on move
|
||||
}
|
||||
s.Fill(fillRect, tileBackground)
|
||||
if i == 0 && g.solved || i > 0 {
|
||||
s.Print(tileTemplate.Format(g.tiles[i].Title()), image.Point{g.tiles[i].X(), g.tiles[i].Y()}, tileForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *game) Interact(a Audio, col, row int, t time.Duration) (result actionResult) {
|
||||
result = resultNone
|
||||
if !g.isSolved() && row == 1 && col < puzzleSymX-2 && col > puzzleSymX-13 {
|
||||
g.muted = !g.muted
|
||||
return
|
||||
}
|
||||
if g.showCongrats() {
|
||||
g.moves = 0
|
||||
return
|
||||
}
|
||||
for i := range tiles {
|
||||
if g.tiles[i].CanInteract(col, row) {
|
||||
if g.tiles[i].num == 0 && t > time.Second*3 {
|
||||
return resultSwitchForm
|
||||
}
|
||||
if g.press(a, g.tiles[i]) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (g *game) shuffle() {
|
||||
rand.Shuffle(tiles, func(i, j int) { g.tiles[i].pos, g.tiles[j].pos = g.tiles[j].pos, g.tiles[i].pos })
|
||||
var puzzle [tiles]byte
|
||||
for i := range g.tiles {
|
||||
puzzle[i] = byte(g.tiles[i].pos)
|
||||
}
|
||||
if solvable, err := IsSolvable(puzzle); err == nil && !solvable {
|
||||
g.tiles[1].pos, g.tiles[2].pos = g.tiles[2].pos, g.tiles[1].pos
|
||||
}
|
||||
g.moves = 0
|
||||
g.solved = g.isSolved()
|
||||
}
|
||||
|
||||
func (g *game) press(a Audio, t *tile) bool {
|
||||
if g.solved {
|
||||
if g.tiles[0].num == t.num {
|
||||
g.shuffle()
|
||||
}
|
||||
return true
|
||||
}
|
||||
exchange := func() { t.pos, g.tiles[0].pos = g.tiles[0].pos, t.pos; g.onMove(a) }
|
||||
switch {
|
||||
case t.Col() != g.tiles[0].Col() && t.Row() != g.tiles[0].Row():
|
||||
return false
|
||||
case t.Col() == g.tiles[0].Col() && g.tiles[0].Row()-t.Row() == 1:
|
||||
t.MoveDown(exchange)
|
||||
case t.Col() == g.tiles[0].Col() && t.Row()-g.tiles[0].Row() == 1:
|
||||
t.MoveUp(exchange)
|
||||
case t.Row() == g.tiles[0].Row() && g.tiles[0].Col()-t.Col() == 1:
|
||||
t.MoveRight(exchange)
|
||||
case t.Row() == g.tiles[0].Row() && t.Col()-g.tiles[0].Col() == 1:
|
||||
t.MoveLeft(exchange)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *game) onMove(a Audio) {
|
||||
if !g.muted {
|
||||
a.PlaySound()
|
||||
}
|
||||
if g.moves == 0 {
|
||||
g.onStart()
|
||||
}
|
||||
g.moves++
|
||||
solved := g.isSolved()
|
||||
if solved && !g.solved {
|
||||
g.onSolve(g.moves)
|
||||
}
|
||||
g.solved = solved
|
||||
}
|
||||
|
||||
func (g *game) isSolved() bool {
|
||||
for i := 1; i < tiles; i++ {
|
||||
if g.tiles[i].pos != i-1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return g.moves > 0
|
||||
}
|
||||
|
||||
func (g *game) isTopRated() bool {
|
||||
firstPlace := false
|
||||
firstPlace = g.withStats(func(s model.Stats) { firstPlace = s.Rank == 1 })
|
||||
return firstPlace
|
||||
}
|
||||
|
||||
func (g *game) showCongrats() bool {
|
||||
return g.isSolved() && g.isTopRated()
|
||||
}
|
||||
|
||||
func (g *game) withStats(consumer func(model.Stats)) bool {
|
||||
if stats := g.stats.Load(); stats != nil {
|
||||
consumer(stats.(model.Stats))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *game) Activate() {}
|
||||
|
||||
func IsSolvable(puzzle [tiles]byte) (bool, error) {
|
||||
var x *int
|
||||
for i := range puzzle {
|
||||
if puzzle[i] == 0 {
|
||||
if x != nil {
|
||||
return false, errors.New("only one blank position allowed")
|
||||
}
|
||||
x = &i
|
||||
}
|
||||
}
|
||||
if x == nil {
|
||||
return false, errors.New("blank position required to be set")
|
||||
}
|
||||
blank := *x
|
||||
invCount := 0
|
||||
for i := range tiles - 1 {
|
||||
for j := i + 1; j < tiles; j++ {
|
||||
if puzzle[j] > 0 && puzzle[i] > 0 && puzzle[i] > puzzle[j] {
|
||||
invCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blank/4+1)%2 == 0 {
|
||||
return invCount%2 == 0, nil
|
||||
} else {
|
||||
return invCount%2 == 1, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package puzzle_test
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/puzzle"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsSolvable(t *testing.T) {
|
||||
table := []struct {
|
||||
puzzle [16]byte
|
||||
solvable bool
|
||||
}{
|
||||
{[16]byte{
|
||||
13, 2, 10, 3,
|
||||
1, 12, 8, 4,
|
||||
5, 0, 9, 6,
|
||||
15, 14, 11, 7},
|
||||
true},
|
||||
{[16]byte{
|
||||
6, 13, 7, 10,
|
||||
8, 9, 11, 0,
|
||||
15, 2, 12, 5,
|
||||
14, 3, 1, 4},
|
||||
true},
|
||||
{[16]byte{
|
||||
12, 1, 10, 2,
|
||||
7, 11, 4, 14,
|
||||
5, 0, 9, 15,
|
||||
8, 13, 6, 3},
|
||||
true},
|
||||
{[16]byte{ // not solvable permutation
|
||||
3, 9, 1, 15,
|
||||
14, 11, 4, 6,
|
||||
13, 0, 10, 12,
|
||||
2, 7, 8, 5},
|
||||
false},
|
||||
{[16]byte{ // same as before but tiles 1 and 2 are switched
|
||||
3, 9, 2, 15,
|
||||
14, 11, 4, 6,
|
||||
13, 0, 10, 12,
|
||||
1, 7, 8, 5},
|
||||
true},
|
||||
}
|
||||
for i := range table {
|
||||
t.Run(fmt.Sprintf("case_%d", i), func(t *testing.T) {
|
||||
solvable, err := puzzle.IsSolvable(table[i].puzzle)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, table[i].solvable, solvable)
|
||||
})
|
||||
}
|
||||
_, err := puzzle.IsSolvable([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 11, 13, 14, 15, 16})
|
||||
assert.Error(t, err)
|
||||
_, err = puzzle.IsSolvable([16]byte{0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/model"
|
||||
"image"
|
||||
"image/color"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gopherX = 8
|
||||
gopherY = 1
|
||||
)
|
||||
|
||||
type splash struct {
|
||||
langCode langCode
|
||||
urlOpener func(string)
|
||||
gopherDx int
|
||||
info atomic.Value
|
||||
}
|
||||
|
||||
func newSplash(urlOpener func(string)) *splash {
|
||||
f := &splash{langCode: langCodeEn, urlOpener: urlOpener, gopherDx: 22}
|
||||
return f
|
||||
}
|
||||
|
||||
func (sp *splash) SetLang(lc langCode) {
|
||||
sp.langCode = lc
|
||||
}
|
||||
|
||||
func (sp *splash) Tick(t ticker) {
|
||||
if t == ticker25Hz && sp.gopherDx > 0 {
|
||||
sp.gopherDx--
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *splash) ApiInfoHandler(i model.Info) {
|
||||
sp.info.Store(i)
|
||||
}
|
||||
|
||||
func (sp *splash) Interact(a Audio, col, row int, t time.Duration) actionResult {
|
||||
if sp.gopherDx > 0 {
|
||||
return resultNone
|
||||
}
|
||||
if info := sp.info.Load(); info != nil && row == puzzleSymY-2 && col < puzzleSymX-2 && col > puzzleSymX-9 {
|
||||
sp.urlOpener(info.(model.Info).ProjectLink)
|
||||
return resultNone
|
||||
}
|
||||
if t > time.Second*10 {
|
||||
return resultSwitchDebug
|
||||
}
|
||||
return resultSwitchGame
|
||||
}
|
||||
|
||||
func (sp *splash) Draw(s Screen) {
|
||||
s.Fill(image.Rect(0, 0, puzzleSymX, puzzleSymY), color.RGBA{0x0A, 0x23, 0x4E, 0xFF})
|
||||
sp.drawGopher(s)
|
||||
s.Print(splashScreenTemplate, image.Point{0, 0}, color.RGBA{0x80, 0x80, 0x80, 0xFF})
|
||||
s.Print(`"`+l10nGameTitle(sp.langCode)+`"`, image.Point{1, 1}, color.White)
|
||||
sp.drawProjectLink(s)
|
||||
}
|
||||
|
||||
func (sp *splash) drawGopher(s Screen) {
|
||||
x := gopherX + sp.gopherDx
|
||||
s.Print(gopherCyan, image.Point{x, gopherY}, color.RGBA{0x00, 0xFF, 0xFF, 0xFF})
|
||||
s.Print(gopherWhite, image.Point{x, gopherY}, color.White)
|
||||
s.Print(gopherBeige, image.Point{x, gopherY}, color.RGBA{0xF4, 0xB3, 0x6D, 0xFF})
|
||||
s.Print(gopherGray, image.Point{x, gopherY}, color.RGBA{0x80, 0x80, 0x80, 0xFF})
|
||||
s.Fill(image.Rect(puzzleSymX-1, 0, puzzleSymX, puzzleSymY), color.RGBA{0x0A, 0x23, 0x4E, 0xFF})
|
||||
}
|
||||
|
||||
func (sp *splash) drawProjectLink(s Screen) {
|
||||
if sp.info.Load() != nil {
|
||||
s.Print("______", image.Point{puzzleSymX - 8, puzzleSymY - 2}, color.RGBA{0x00, 0xFF, 0xFF, 0xFF})
|
||||
}
|
||||
s.Print("GitHub", image.Point{puzzleSymX - 8, puzzleSymY - 2}, color.RGBA{0xFB, 0xE6, 0x8E, 0xFF})
|
||||
}
|
||||
|
||||
func (sp *splash) Activate() {}
|
||||
@@ -0,0 +1,139 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tileFormatter string
|
||||
|
||||
func (t tileFormatter) Format(c string) string {
|
||||
return fmt.Sprintf(string(t), c[:int(math.Min(float64(len(c)), 2))])
|
||||
}
|
||||
|
||||
const tileTemplate tileFormatter = `╔════╗
|
||||
║ %2s ║
|
||||
╚════╝`
|
||||
|
||||
type button struct {
|
||||
title func() string
|
||||
x, y func() int
|
||||
}
|
||||
|
||||
func (t *button) Interact(x, y int) bool {
|
||||
if (image.Point{x, y}).In(image.Rect(t.x(), t.y(), t.x()+puzzleTileSymW, t.y()+puzzleTileSymH)) {
|
||||
t.onTrigger()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *button) Title() string {
|
||||
return t.title()
|
||||
}
|
||||
|
||||
func (t *button) Draw(s Screen) {
|
||||
s.Print(tileTemplate.Format(t.Title()),
|
||||
image.Point{t.x(), t.y()},
|
||||
color.RGBA{0, 0xFF, 0xFF, 0xFF},
|
||||
)
|
||||
}
|
||||
|
||||
func (t *button) onTrigger() {
|
||||
}
|
||||
|
||||
func NewButton(title func() string, x, y func() int) *button {
|
||||
return &button{
|
||||
title: title,
|
||||
x: x,
|
||||
y: y,
|
||||
}
|
||||
}
|
||||
|
||||
type tile struct {
|
||||
button
|
||||
num int
|
||||
pos int
|
||||
|
||||
moving bool
|
||||
dx, dy float64
|
||||
}
|
||||
|
||||
func NewTile(num, pos int) *tile {
|
||||
t := &tile{
|
||||
num: num,
|
||||
pos: pos,
|
||||
}
|
||||
t.button = *NewButton(t.title, t.X, t.Y)
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *tile) Title() string {
|
||||
if t.num == 0 {
|
||||
return "Go"
|
||||
}
|
||||
return strconv.Itoa(t.num)
|
||||
}
|
||||
|
||||
func (t *tile) CanInteract(x, y int) bool {
|
||||
return !t.moving && t.button.Interact(x, y)
|
||||
}
|
||||
|
||||
func (t *tile) MoveUp(finish func()) {
|
||||
t.shift(&t.dy, -1, finish)
|
||||
}
|
||||
|
||||
func (t *tile) MoveDown(finish func()) {
|
||||
t.shift(&t.dy, 1, finish)
|
||||
}
|
||||
|
||||
func (t *tile) MoveLeft(finish func()) {
|
||||
t.shift(&t.dx, -1, finish)
|
||||
}
|
||||
|
||||
func (t *tile) MoveRight(finish func()) {
|
||||
t.shift(&t.dx, 1, finish)
|
||||
}
|
||||
|
||||
func (t *tile) shift(src *float64, target float64, finish func()) {
|
||||
if t.moving {
|
||||
return
|
||||
}
|
||||
t.moving = true
|
||||
*src = 0
|
||||
go t.shifter(src, target, func() { finish(); t.moving = false })
|
||||
}
|
||||
|
||||
func (t *tile) shifter(src *float64, target float64, finish func()) {
|
||||
step := target / 10
|
||||
ticker := time.NewTicker(time.Millisecond * 10)
|
||||
for {
|
||||
<-ticker.C
|
||||
*src += step
|
||||
if math.Abs(*src) >= math.Abs(target) {
|
||||
*src = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
func (t *tile) Col() int {
|
||||
return t.pos % 4
|
||||
}
|
||||
|
||||
func (t *tile) Row() int {
|
||||
return t.pos / 4
|
||||
}
|
||||
|
||||
func (t *tile) X() int {
|
||||
return t.Col()*puzzleTileSymW + int(t.dx*puzzleTileSymW) + 2
|
||||
}
|
||||
|
||||
func (t *tile) Y() int {
|
||||
return t.Row()*puzzleTileSymH + int(t.dy*puzzleTileSymH) + 3
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package puzzle
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openURL(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "rundll32"
|
||||
args = []string{"url.dll,FileProtocolHandler", url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
default:
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
}
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
Reference in New Issue
Block a user