296 lines
6.8 KiB
Go
296 lines
6.8 KiB
Go
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
|
|
}
|
|
}
|