A cross-cutting pass, each fix with a test that failed first. Veils: MyGo still hands a key to a base that turned off this frame, so every element key now goes through pixel.Shortcut, which an archtest enforces. Input handlers record and the build applies them only while enabled; the editor turns away a veil-frame letter or copy; value bases get a copy and keep only enabled turns. Popups give a held focus back to their opener when the veil lifts. Motion: S5 is enforced at the clock. Every Tick, Since, Step, Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps, fast animations step at 15, and drawn time is quantized to frames. Clocks stepping back: Since holds at zero without overflow, and k.Elapsed moves a stored start to now, so nothing goes negative, runs backward or indexes out of range. Also: logs count typed text instead of holding it, gallery names use glyphs the font has, removable rows are keyed, components take their own Options (archtest), and sweeps over all 508 demos check veiled keys, clicks, wheels, the clock stepping back and names.
323 lines
8.2 KiB
Go
323 lines
8.2 KiB
Go
package desk
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
|
|
)
|
|
|
|
type MinesweeperOptions struct {
|
|
// Cols, Rows, Mines: zeros mean 9, 9 and 10.
|
|
Cols, Rows, Mines int
|
|
// Seed lays the mines; zero takes the clock.
|
|
Seed uint64
|
|
Disabled bool
|
|
}
|
|
|
|
type MinesweeperResult struct {
|
|
Element *ui.Element
|
|
// Won or Lost is the game ending this frame.
|
|
Won, Lost bool
|
|
// Flagged is a flag set or lifted; Restarted, the face.
|
|
Flagged, Restarted bool
|
|
}
|
|
|
|
type mineKey struct{}
|
|
|
|
// field is a game: mines, opened and flagged cells, clock.
|
|
type field struct {
|
|
mine, open, flag []bool
|
|
laid, over, won bool
|
|
boom, cur int
|
|
start, end time.Time
|
|
}
|
|
|
|
const mcell = 16
|
|
|
|
// Minesweeper: Enter or click opens; F or right click flags.
|
|
func Minesweeper(c *ui.Context, label string, o MinesweeperOptions) MinesweeperResult {
|
|
k := pixel.K(c)
|
|
cols, rows, n := pixel.Or("Minesweeper Cols", o.Cols, 9), pixel.Or("Minesweeper Rows", o.Rows, 9), pixel.Or("Minesweeper Mines", o.Mines, 10)
|
|
if n >= cols*rows {
|
|
panic(fmt.Sprintf("desk: Minesweeper %d mines in %d cells", n, cols*rows))
|
|
}
|
|
col := ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label)
|
|
f := ui.Local(col, mineKey{}, func() field { return newField(cols * rows) })
|
|
if len(f.mine) != cols*rows {
|
|
*f = newField(cols * rows)
|
|
}
|
|
res := MinesweeperResult{}
|
|
res.Element = col.Children(func() {
|
|
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
|
flags := 0
|
|
for _, on := range f.flag {
|
|
if on {
|
|
flags++
|
|
}
|
|
}
|
|
text.SevenSegment(c, fmt.Sprintf("%03d", max(-99, n-flags)), text.SevenSegmentOptions{})
|
|
face := smile
|
|
switch {
|
|
case f.over && f.won:
|
|
face = cool
|
|
case f.over:
|
|
face = dead
|
|
}
|
|
if input.IconButton(c, face, k.Msg("new_game"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked {
|
|
*f, res.Restarted = newField(cols*rows), true
|
|
slog.Debug("8bit: minesweeper reset", "label", label)
|
|
}
|
|
text.SevenSegment(c, fmt.Sprintf("%03d", min(999, f.seconds(c, k))), text.SevenSegmentOptions{})
|
|
})
|
|
shell, base, st, picked := pixel.Pad(c, k.Msg("minefield"), cols, cols*rows, mcell, mcell, &f.cur, o.Disabled)
|
|
if !base.IsDisabled() && !f.over {
|
|
if !pixel.Typing(c) && pixel.Shortcut(base, 0, ui.KeyF) {
|
|
res.Flagged = f.toggle(f.cur)
|
|
}
|
|
if base.RightClicked() {
|
|
if x, y, _ := base.PointerPosition(); x >= 0 && y >= 0 {
|
|
if cx, cy := int(x/k.D(mcell)), int(y/k.D(mcell)); cx < cols && cy < rows {
|
|
f.cur = cy*cols + cx
|
|
res.Flagged = f.toggle(f.cur)
|
|
}
|
|
}
|
|
}
|
|
if picked >= 0 && !f.flag[picked] {
|
|
if !f.laid {
|
|
f.lay(cols, rows, n, picked, o.Seed, k.Now())
|
|
}
|
|
f.reveal(cols, rows, picked, k.Now())
|
|
res.Won, res.Lost = f.over && f.won, f.over && !f.won
|
|
}
|
|
}
|
|
base.Description(f.say(k, cols, f.cur))
|
|
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
g := k.Canvas(p, r)
|
|
for i := range f.mine {
|
|
f.drawCell(g, cols, rows, i%cols*mcell, i/cols*mcell, i)
|
|
}
|
|
if st.Focus {
|
|
x, y := f.cur%cols*mcell, f.cur/cols*mcell
|
|
g.Rect(x, y, mcell, 1, pixel.Accent)
|
|
g.Rect(x, y+mcell-1, mcell, 1, pixel.Accent)
|
|
g.Rect(x, y, 1, mcell, pixel.Accent)
|
|
g.Rect(x+mcell-1, y, 1, mcell, pixel.Accent)
|
|
}
|
|
g.Finish(st)
|
|
})
|
|
switch {
|
|
case f.over && f.won:
|
|
pixel.Text(c, k.Msg("stage_clear"), pixel.TextOptions{Slot: pixel.Good})
|
|
case f.over:
|
|
// GAME OVER, and the face is the CONTINUE.
|
|
pixel.Text(c, k.Msg("game_over"), pixel.TextOptions{Slot: pixel.Bad})
|
|
}
|
|
})
|
|
if res.Flagged {
|
|
slog.Debug("8bit: minesweeper flag", "label", label, "cell", f.cur, "on", f.flag[f.cur])
|
|
}
|
|
if res.Won || res.Lost {
|
|
slog.Debug("8bit: minesweeper over", "label", label, "won", res.Won)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func newField(cells int) field {
|
|
return field{mine: make([]bool, cells), open: make([]bool, cells), flag: make([]bool, cells), boom: -1}
|
|
}
|
|
|
|
// seconds since the first click, frozen at the end.
|
|
func (f *field) seconds(c *ui.Context, k pixel.Kit) int {
|
|
switch {
|
|
case !f.laid:
|
|
return 0
|
|
case f.over:
|
|
return max(0, int(f.end.Sub(f.start)/time.Second))
|
|
}
|
|
// A clock stepping back holds the count at zero.
|
|
s := max(0, int(k.Now().Sub(f.start)/time.Second))
|
|
k.At(c, f.start.Add(time.Duration(s+1)*time.Second))
|
|
return s
|
|
}
|
|
|
|
// toggle flags or unflags a shut cell, reporting a change.
|
|
func (f *field) toggle(i int) bool {
|
|
if f.open[i] {
|
|
return false
|
|
}
|
|
f.flag[i] = !f.flag[i]
|
|
return true
|
|
}
|
|
|
|
// lay hides mines, none at the first click or beside.
|
|
func (f *field) lay(cols, rows, n, first int, seed uint64, now time.Time) {
|
|
if seed == 0 {
|
|
seed = uint64(now.UnixNano())
|
|
}
|
|
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
|
var spots []int
|
|
for i := range f.mine {
|
|
if abs(i%cols-first%cols) > 1 || abs(i/cols-first/cols) > 1 {
|
|
spots = append(spots, i)
|
|
}
|
|
}
|
|
// Too few spots left? Then only the click is safe.
|
|
if len(spots) < n {
|
|
spots = spots[:0]
|
|
for i := range f.mine {
|
|
if i != first {
|
|
spots = append(spots, i)
|
|
}
|
|
}
|
|
}
|
|
rng.Shuffle(len(spots), func(a, b int) { spots[a], spots[b] = spots[b], spots[a] })
|
|
for _, i := range spots[:n] {
|
|
f.mine[i] = true
|
|
}
|
|
f.laid, f.start = true, now
|
|
}
|
|
|
|
// near counts the mines around a cell.
|
|
func (f *field) near(cols, rows, i int) int {
|
|
n := 0
|
|
for _, j := range around(cols, rows, i) {
|
|
if f.mine[j] {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// around lists a cell's neighbors on the board.
|
|
func around(cols, rows, i int) []int {
|
|
var out []int
|
|
for dy := -1; dy <= 1; dy++ {
|
|
for dx := -1; dx <= 1; dx++ {
|
|
x, y := i%cols+dx, i/cols+dy
|
|
if (dx != 0 || dy != 0) && x >= 0 && x < cols && y >= 0 && y < rows {
|
|
out = append(out, y*cols+x)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reveal opens a cell; empties spread, a mine ends it.
|
|
func (f *field) reveal(cols, rows, i int, now time.Time) {
|
|
if f.mine[i] {
|
|
f.over, f.boom, f.end = true, i, now
|
|
return
|
|
}
|
|
todo := []int{i}
|
|
for len(todo) > 0 {
|
|
j := todo[len(todo)-1]
|
|
todo = todo[:len(todo)-1]
|
|
if f.open[j] || f.flag[j] {
|
|
continue
|
|
}
|
|
f.open[j] = true
|
|
if f.near(cols, rows, j) == 0 {
|
|
todo = append(todo, around(cols, rows, j)...)
|
|
}
|
|
}
|
|
for j := range f.mine {
|
|
if !f.mine[j] && !f.open[j] {
|
|
return
|
|
}
|
|
}
|
|
f.over, f.won, f.end = true, true, now
|
|
copy(f.flag, f.mine)
|
|
}
|
|
|
|
// say reads a cell out: where, and what shows.
|
|
func (f *field) say(k pixel.Kit, cols, i int) string {
|
|
at := fmt.Sprintf("%d,%d ", i%cols+1, i/cols+1)
|
|
switch {
|
|
case f.open[i]:
|
|
return at + fmt.Sprint(f.near(cols, len(f.mine)/cols, i))
|
|
case f.flag[i]:
|
|
return at + k.Msg("flag")
|
|
case f.over && f.mine[i]:
|
|
return at + k.Msg("mine")
|
|
}
|
|
return at + k.Msg("unknown")
|
|
}
|
|
|
|
// numbers ink the mine counts, one to eight.
|
|
var numbers = [...]pixel.Slot{pixel.Info, pixel.Good, pixel.Bad, pixel.Magic, pixel.Warn, pixel.Accent, pixel.Ink, pixel.Faint}
|
|
|
|
// drawCell draws a cell: raised, flagged, counted or mined.
|
|
func (f *field) drawCell(g pixel.Canvas, cols, rows, x, y, i int) {
|
|
switch {
|
|
case f.open[i]:
|
|
g.Rect(x, y, mcell, mcell, pixel.Well)
|
|
g.Rect(x, y, mcell, 1, pixel.Shade)
|
|
g.Rect(x, y, 1, mcell, pixel.Shade)
|
|
if n := f.near(cols, rows, i); n > 0 {
|
|
g.Micro(x+7, y+6, fmt.Sprint(n), numbers[n-1], 1)
|
|
}
|
|
case f.over && f.mine[i] && !f.flag[i]:
|
|
back := pixel.Well
|
|
if i == f.boom {
|
|
back = pixel.Bad
|
|
}
|
|
g.Rect(x, y, mcell, mcell, back)
|
|
g.Sprite(x+2, y+2, icon.Bomb, 0, pixel.Warn, 1)
|
|
default:
|
|
g.Frame(x, y, mcell, mcell, pixel.Raised, pixel.Face)
|
|
if f.flag[i] {
|
|
g.Sprite(x+2, y+2, icon.Flag, 0, pixel.Bad, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
var (
|
|
smile = pixel.MustSprite(`
|
|
...eeeeee...
|
|
..eyyyyyye..
|
|
.eyyyyyyyye.
|
|
eyyykyykyyye
|
|
eyyykyykyyye
|
|
eyyyyyyyyyye
|
|
eykyyyyyykye
|
|
eyykyyyykyye
|
|
eyyykkkkyyye
|
|
.eyyyyyyyye.
|
|
..eyyyyyye..
|
|
...eeeeee...`)
|
|
dead = pixel.MustSprite(`
|
|
...eeeeee...
|
|
..eyyyyyye..
|
|
.eyyyyyyyye.
|
|
eykykyykykye
|
|
eyykyyyykyye
|
|
eykykyykykye
|
|
eyyyyyyyyyye
|
|
eyyykkkkyyye
|
|
eyykyyyykyye
|
|
.eyyyyyyyye.
|
|
..eyyyyyye..
|
|
...eeeeee...`)
|
|
cool = pixel.MustSprite(`
|
|
...eeeeee...
|
|
..eyyyyyye..
|
|
.eyyyyyyyye.
|
|
ekkkkkkkkkke
|
|
eykkkyykkkye
|
|
eyykyyyykyye
|
|
eyyyyyyyyyye
|
|
eykyyyyyykye
|
|
eyykkkkkkyye
|
|
.eyyyyyyyye.
|
|
..eyyyyyye..
|
|
...eeeeee...`)
|
|
)
|