Elements are now pass-scoped value handles: tests that read bounds after a frame bind a ui.Handle. pixel tells frames apart by the build pass's root, since the Tester clock no longer moves on its own. Fix the regressions the upgrade brought: - Clipboard reads in input handlers go through c.Services(). - MyGo notes an overlay's opener after building it, so AutoFocus inside lost it; pixel.AutoFocus focuses through a Handle, and archtest bans the raw call. Dialogs hand the focus back again. - Elements around a press stay hovered: Tooltip tells a press-given focus by focus arriving under the pointer. - The web host moves the Tester clock with real time, at most 500 ms a call, and gives components the real clock. The gallery's source index also accepts CRLF checkouts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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...`)
|
|
)
|