Add tabletop components 486-500 and ten game icons

Fifteen pieces of a games night: Dice, DiceTray, PlayingCard, CardHand,
Deck, Chessboard, GoBoard, MahjongTile, Domino, Roulette, CoinFlip,
BingoCard, TicTacToe, ScoreTrack and Meeple. Component number 500 is
a meeple, which seems right.

Dice tumble, coins turn and wheels spin on a cue, a frame at a time,
and land at once under Reduce Motion. Hands lift the card you point
at and play the one drawn on top; pieces and stones pick the lighter
of Lit and Ink for White, so they read in every palette; the computer
plays a fair O; FREE is a star in every language.

New 12x12 icons: Spade, Club, Diamond, Meeple, King, Queen, Rook,
Bishop, Knight and Pawn.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 14:03:58 -04:00
1 parent 7800003418
commit 81003b21ee
32 files changed
+2781 -1

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
package gallery
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
var parlorEntries = []Entry{
{494, "Domino", "多米诺", "tabletop", "Two halves, zero to six pips. Lay them flat or on end.", demoDomino},
{495, "Roulette", "轮盘", "tabletop", "SPIN and the wheel runs a lap before it stops. The house is green.", demoRoulette},
{496, "CoinFlip", "抛硬币", "tabletop", "Heads or tails, turned in the air frame by frame.", demoCoinFlip},
{497, "BingoCard", "宾果卡", "tabletop", "Mark your numbers; five in a line shouts BINGO!", demoBingoCard},
{498, "TicTacToe", "井字棋", "tabletop", "You are X. The computer is a decent O, not a genius.", demoTicTacToe},
{499, "ScoreTrack", "计分轨道", "tabletop", "A winding track of spaces; each player's meeple marks the score.", demoScoreTrack},
{500, "Meeple", "棋子", "tabletop", "A player's token in its slot. Number five hundred: thank you for scrolling!", demoMeeple},
}
type parlorDemo struct {
spins, result, flips int
heads bool
marked [25]bool
scores []int
turn int
}
// prizes ring the demo wheel; the house takes the first.
var prizes = []string{"HOUSE", "50", "100", "1UP", "200", "LOSE", "500", "x2", "300", "BOMB", "1000", "?"}
func parlorState(c *ui.Context) *parlorDemo {
return ui.Local(c.Root(), demoKey("parlor"), func() parlorDemo {
return parlorDemo{result: 3, heads: true, scores: []int{12, 7, 12}}
})
}
func demoDomino(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
tabletop.Domino(c, 6, 6, tabletop.DominoOptions{})
tabletop.Domino(c, 3, 5, tabletop.DominoOptions{})
tabletop.Domino(c, 0, 1, tabletop.DominoOptions{Vertical: true})
tabletop.Domino(c, 2, 4, tabletop.DominoOptions{Vertical: true})
})
}
func demoRoulette(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := parlorState(c)
rng := tableState(c).rng
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
tabletop.Roulette(c, "Wheel", prizes, s.result, tabletop.RouletteOptions{Cue: s.spins})
if input.Button(c, "SPIN", input.ButtonOptions{Size: input.Small}).Clicked {
s.result, s.spins = rng.IntN(len(prizes)), s.spins+1
log("landed on " + prizes[s.result])
}
})
}
func demoCoinFlip(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := parlorState(c)
rng := tableState(c).rng
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
tabletop.CoinFlip(c, "Coin", s.heads, tabletop.CoinFlipOptions{Cue: s.flips})
if input.Button(c, "FLIP", input.ButtonOptions{Size: input.Small}).Clicked {
s.heads, s.flips = rng.IntN(2) == 0, s.flips+1
log(fmt.Sprintf("heads %v", s.heads))
}
})
}
func demoBingoCard(c *ui.Context, log func(string)) {
s := parlorState(c)
numbers := [25]int{}
for i := range numbers {
numbers[i] = i%5*15 + 1 + (i/5*7)%15
}
r := tabletop.BingoCard(c, "Card", numbers, &s.marked, tabletop.BingoCardOptions{})
if r.Changed {
log("marked")
}
if r.Bingo {
log("BINGO!")
}
}
func demoTicTacToe(c *ui.Context, log func(string)) {
r := tabletop.TicTacToe(c, "Grid", tabletop.TicTacToeOptions{})
if r.Played >= 0 {
log(fmt.Sprintf("X takes %d", r.Played))
}
if r.Over {
log([]string{"a draw", "you won", "the computer won"}[r.Winner])
}
if r.Restarted {
log("a new game")
}
}
func demoScoreTrack(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := parlorState(c)
rng := tableState(c).rng
ui.Column(c).Gap(k.D(6)).Children(func() {
tabletop.ScoreTrack(c, "Scores", s.scores, tabletop.ScoreTrackOptions{})
if input.Button(c, "TAKE A TURN", input.ButtonOptions{Size: input.Small}).Clicked {
gain := rng.IntN(6) + 1
s.scores[s.turn] += gain
log(fmt.Sprintf("player %d +%d", s.turn+1, gain))
s.turn = (s.turn + 1) % len(s.scores)
}
})
}
func demoMeeple(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(4)).Children(func() {
for i := range 6 {
tabletop.Meeple(c, fmt.Sprintf("Player %d", i+1), tabletop.MeepleOptions{Player: i})
}
tabletop.Meeple(c, "Open seat", tabletop.MeepleOptions{Empty: true})
})
}
+167
View File
@@ -0,0 +1,167 @@
package gallery
import (
"fmt"
"math/rand/v2"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
var tabletopEntries = []Entry{
{486, "Dice", "骰子", "tabletop", "A d6 with pips. ROLL and it tumbles before it lands.", demoDice},
{487, "DiceTray", "骰盘", "tabletop", "Several dice on felt, and what they add up to.", demoDiceTray},
{488, "PlayingCard", "扑克牌", "tabletop", "Rank and suit, court cards with their pieces, or the back.", demoPlayingCard},
{489, "CardHand", "手牌", "tabletop", "A fanned hand. The card you point at or choose lifts; a click plays it.", demoCardHand},
{490, "Deck", "牌堆", "tabletop", "A face-down pile with its count. Click to draw.", demoDeck},
{491, "Chessboard", "棋盘", "tabletop", "Click a piece, then a square. The rules are on the honor system.", demoChessboard},
{492, "GoBoard", "围棋盘", "tabletop", "Black, then White, a stone a click. 9, 13 or 19 lines.", demoGoBoard},
{493, "MahjongTile", "麻将牌", "tabletop", "Dots, bamboo, characters, the four winds and three dragons.", demoMahjongTile},
}
type tableDemo struct {
rng *rand.Rand
die, rolls int
tray []int
hand []tabletop.Card
left int
drawn tabletop.Card
board [64]tabletop.Piece
stones []tabletop.Stone
}
func tableState(c *ui.Context) *tableDemo {
return ui.Local(c.Root(), demoKey("tabletop"), func() tableDemo {
return tableDemo{rng: rand.New(rand.NewPCG(8, 16)), die: 6, tray: []int{3, 5, 2}, hand: deal(), left: 52,
board: chessStart(), stones: make([]tabletop.Stone, 81)}
})
}
// deal is the demo's five-card hand.
func deal() []tabletop.Card {
return []tabletop.Card{{Rank: 1, Suit: tabletop.Spades}, {Rank: 10, Suit: tabletop.Hearts}, {Rank: 7, Suit: tabletop.Clubs},
{Rank: 12, Suit: tabletop.Diamonds}, {Rank: 13, Suit: tabletop.Hearts}}
}
// chessStart is the opening position, Black at the top.
func chessStart() (b [64]tabletop.Piece) {
back := []tabletop.Kind{tabletop.Rook, tabletop.Knight, tabletop.Bishop, tabletop.Queen, tabletop.King, tabletop.Bishop, tabletop.Knight, tabletop.Rook}
for i, kind := range back {
b[i], b[56+i] = tabletop.Piece{Kind: kind}, tabletop.Piece{Kind: kind, White: true}
b[8+i], b[48+i] = tabletop.Piece{Kind: tabletop.Pawn}, tabletop.Piece{Kind: tabletop.Pawn, White: true}
}
return b
}
func demoDice(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := tableState(c)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
tabletop.Dice(c, "Die", s.die, tabletop.DiceOptions{Cue: s.rolls})
if input.Button(c, "ROLL", input.ButtonOptions{Size: input.Small}).Clicked {
s.die, s.rolls = s.rng.IntN(6)+1, s.rolls+1
log(fmt.Sprintf("rolled a %d", s.die))
}
})
}
func demoDiceTray(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := tableState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
tabletop.DiceTray(c, "Dice", s.tray, tabletop.DiceTrayOptions{Cue: s.rolls})
if input.Button(c, "ROLL ALL", input.ButtonOptions{Size: input.Small}).Clicked {
total := 0
for i := range s.tray {
s.tray[i] = s.rng.IntN(6) + 1
total += s.tray[i]
}
s.rolls++
log(fmt.Sprintf("rolled %d", total))
}
})
}
func demoPlayingCard(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(4)).Children(func() {
for _, cd := range deal()[:4] {
tabletop.PlayingCard(c, cd, tabletop.PlayingCardOptions{})
}
tabletop.PlayingCard(c, tabletop.Card{Rank: 1}, tabletop.PlayingCardOptions{Back: true})
})
}
func demoCardHand(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := tableState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
if i := tabletop.CardHand(c, "Hand", s.hand, tabletop.CardHandOptions{}).Played; i >= 0 {
log(fmt.Sprintf("played the %d of suit %d", s.hand[i].Rank, s.hand[i].Suit))
s.hand = append(s.hand[:i:i], s.hand[i+1:]...)
}
if input.Button(c, "DEAL", input.ButtonOptions{Size: input.Small}).Clicked {
s.hand = deal()
log("a fresh hand")
}
})
}
func demoDeck(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := tableState(c)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.End).Children(func() {
if tabletop.Deck(c, "Draw pile", s.left, tabletop.DeckOptions{}).Drawn {
s.left--
s.drawn = tabletop.Card{Rank: s.rng.IntN(13) + 1, Suit: tabletop.Suit(s.rng.IntN(4))}
log(fmt.Sprintf("drew a card, %d left", s.left))
}
if s.drawn.Rank > 0 {
tabletop.PlayingCard(c, s.drawn, tabletop.PlayingCardOptions{})
}
})
}
func demoChessboard(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := tableState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
r := tabletop.Chessboard(c, "Board", &s.board, tabletop.ChessboardOptions{})
if r.Changed {
log(fmt.Sprintf("holding square %d", r.Held))
}
if r.Moved {
log(fmt.Sprintf("moved from square %d to %d", r.From, r.To))
}
if input.Button(c, "NEW GAME", input.ButtonOptions{Size: input.Small}).Clicked {
s.board = chessStart()
log("pieces reset")
}
})
}
func demoGoBoard(c *ui.Context, log func(string)) {
s := tableState(c)
if i := tabletop.GoBoard(c, "Goban", s.stones, tabletop.GoBoardOptions{}).Placed; i >= 0 {
log(fmt.Sprintf("stone at %d", i))
}
}
func demoMahjongTile(c *ui.Context, log func(string)) {
k := pixel.K(c)
tiles := []tabletop.Tile{
{Family: tabletop.Dots, Rank: 1}, {Family: tabletop.Dots, Rank: 5}, {Family: tabletop.Dots, Rank: 9},
{Family: tabletop.Bamboo, Rank: 3}, {Family: tabletop.Bamboo, Rank: 8}, {Family: tabletop.Characters, Rank: 7},
{Family: tabletop.Winds, Rank: 1}, {Family: tabletop.Winds, Rank: 4}, {Family: tabletop.Dragons, Rank: 1},
{Family: tabletop.Dragons, Rank: 2}, {Family: tabletop.Dragons, Rank: 3},
}
ui.Row(c).Gap(k.D(2)).Wrap().Width(k.D(300)).Children(func() {
for _, t := range tiles {
tabletop.MahjongTile(c, t, tabletop.MahjongTileOptions{})
}
tabletop.MahjongTile(c, tiles[0], tabletop.MahjongTileOptions{Back: true})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries, shopEntries, marketEntries, agentEntries, guildEntries, deskEntries, retroEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries, shopEntries, marketEntries, agentEntries, guildEntries, deskEntries, retroEntries, tabletopEntries, parlorEntries)
func Entries() []Entry { return entries }
+45
View File
@@ -0,0 +1,45 @@
package gallery
import (
"strconv"
"strings"
"testing"
)
func TestTabletopDemosLogTheirActions(t *testing.T) {
// Focusing, or a move that ends nothing, logs nothing.
quiet := map[string]bool{"key:Right": true}
for _, tc := range []struct {
path string
steps []string
}{
{"/c/486-dice", []string{"ROLL"}}, {"/c/487-dicetray", []string{"ROLL ALL"}},
{"/c/489-cardhand", []string{"Hand", "DEAL"}}, {"/c/490-deck", []string{"Draw pile"}},
{"/c/491-chessboard", []string{"square:52", "NEW GAME"}}, {"/c/492-goboard", []string{"Goban"}},
{"/c/495-roulette", []string{"SPIN"}}, {"/c/496-coinflip", []string{"FLIP"}},
{"/c/497-bingocard", []string{"Card", "key:Right", "key:Enter"}}, {"/c/498-tictactoe", []string{"Grid"}},
{"/c/499-scoretrack", []string{"TAKE A TURN"}},
} {
a, tt := open(tc.path)
slug := tc.path[3:]
for _, step := range tc.steps {
before := len(a.logs[slug])
if k, ok := strings.CutPrefix(step, "key:"); ok {
tt.Key(0, letters[k])
} else if sq, ok := strings.CutPrefix(step, "square:"); ok {
// A chess square: 16 dots, two pixels a dot.
r, ok := tt.Find("CHESSBOARD")
i, err := strconv.Atoi(sq)
if !ok || err != nil {
t.Fatalf("no board, or square %q: %v", sq, err)
}
tt.ClickAt(r.X+float32(32*(i%8)+16), r.Y+float32(32*(i/8)+16))
} else {
click(t, tt, step)
}
if len(a.logs[slug]) <= before && !quiet[step] && step != "Card" {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
package icon
import "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
var (
Spade = pixel.MustSprite(`
............
.....##.....
....####....
...######...
..########..
.##########.
.##########.
..###..###..
.....##.....
....####....
............
............`)
Club = pixel.MustSprite(`
............
....####....
...######...
...######...
.##.####.##.
############
############
.###.##.###.
.....##.....
....####....
............
............`)
Diamond = pixel.MustSprite(`
............
.....##.....
....####....
...######...
..########..
.##########.
.##########.
..########..
...######...
....####....
.....##.....
............`)
Meeple = pixel.MustSprite(`
............
.....##.....
....####....
....####....
.##########.
############
.##########.
...######...
..########..
..###..###..
.####..####.
............`)
King = pixel.MustSprite(`
.....##.....
....####....
.....##.....
...######...
..##.##.##..
..########..
...######...
....####....
...######...
..########..
.##########.
............`)
Queen = pixel.MustSprite(`
..#..##..#..
..##.##.##..
..########..
...######...
....####....
....####....
...######...
....####....
...######...
..########..
.##########.
............`)
Rook = pixel.MustSprite(`
............
..##.##.##..
..########..
...######...
...######...
....####....
....####....
....####....
...######...
..########..
.##########.
............`)
Bishop = pixel.MustSprite(`
.....##.....
....####....
...###.##...
...##.###...
...######...
....####....
.....##.....
....####....
...######...
..########..
.##########.
............`)
Knight = pixel.MustSprite(`
............
....###.....
...#####....
..###.###...
..########..
.###..####..
.##..#####..
....#####...
...######...
..########..
.##########.
............`)
Pawn = pixel.MustSprite(`
............
............
.....##.....
....####....
....####....
.....##.....
....####....
...######...
...######...
..########..
.##########.
............`)
)
+2
View File
@@ -36,4 +36,6 @@ var All = []Named{
{"skull", Skull}, {"scroll", Scroll}, {"flame", Flame}, {"drop", Drop},
{"leaf", Leaf}, {"flower", Flower}, {"sun", Sun}, {"moon", Moon},
{"cloud", Cloud}, {"snowflake", Snowflake}, {"bolt", Bolt}, {"palette", Palette},
{"spade", Spade}, {"club", Club}, {"diamond", Diamond}, {"meeple", Meeple},
{"king", King}, {"queen", Queen}, {"rook", Rook}, {"bishop", Bishop}, {"knight", Knight}, {"pawn", Pawn},
}
+18
View File
@@ -71,6 +71,15 @@ var messages = map[Locale]map[string]string{
"run": "RUN", "run_prompt": "TYPE A PROGRAM'S NAME, AND IT WILL OPEN.", "open_label": "OPEN:",
"safe_to_turn_off": "IT'S NOW SAFE TO TURN OFF YOUR COMPUTER.", "memory_test": "MEMORY TEST: %dK",
"press_any_key": "PRESS ANY KEY", "minefield": "MINEFIELD", "flag": "FLAG", "mine": "MINE", "percent_done": "%d%% DONE",
"spades": "SPADES", "hearts": "HEARTS", "diamonds": "DIAMONDS", "clubs": "CLUBS", "card_name": "%[1]s OF %[2]s",
"ace": "ACE", "jack": "JACK", "queen": "QUEEN", "king": "KING", "card_back": "CARD BACK", "cards_left": "%d CARDS",
"white": "WHITE", "black": "BLACK", "pawn": "PAWN", "knight": "KNIGHT", "bishop": "BISHOP", "rook": "ROOK",
"chess_queen": "QUEEN", "chess_king": "KING", "piece_name": "%[1]s %[2]s", "chessboard": "CHESSBOARD",
"stone": "%s STONE", "to_play": "%s TO PLAY", "dots_suit": "DOTS", "bamboo": "BAMBOO", "characters": "CHARACTERS",
"tile_name": "%[1]d %[2]s", "tile_back": "TILE BACK", "east": "EAST WIND", "south": "SOUTH WIND", "west": "WEST WIND",
"north": "NORTH WIND", "red_dragon": "RED DRAGON", "green_dragon": "GREEN DRAGON", "white_dragon": "WHITE DRAGON",
"heads": "HEADS", "tails": "TAILS", "bingo": "BINGO!", "free_space": "FREE", "marked": "MARKED",
"you_win": "YOU WIN!", "draw": "DRAW", "player_score": "PLAYER %d: %d",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -140,6 +149,15 @@ var messages = map[Locale]map[string]string{
"run": "运行", "run_prompt": "输入程序的名称,就能打开它。", "open_label": "打开:",
"safe_to_turn_off": "现在可以安全地关闭计算机了。", "memory_test": "内存测试:%dK",
"press_any_key": "按任意键", "minefield": "雷区", "flag": "旗子", "mine": "地雷", "percent_done": "已完成 %d%%",
"spades": "黑桃", "hearts": "红心", "diamonds": "方块", "clubs": "梅花", "card_name": "%[2]s%[1]s",
"ace": "A", "jack": "J", "queen": "Q", "king": "K", "card_back": "牌背", "cards_left": "剩余 %d 张",
"white": "白", "black": "黑", "pawn": "兵", "knight": "马", "bishop": "象", "rook": "车",
"chess_queen": "后", "chess_king": "王", "piece_name": "%[1]s%[2]s", "chessboard": "棋盘",
"stone": "%s子", "to_play": "轮到%s方", "dots_suit": "筒", "bamboo": "条", "characters": "万",
"tile_name": "%[1]d%[2]s", "tile_back": "牌背", "east": "东风", "south": "南风", "west": "西风",
"north": "北风", "red_dragon": "红中", "green_dragon": "发财", "white_dragon": "白板",
"heads": "正面", "tails": "反面", "bingo": "宾果!", "free_space": "免费", "marked": "已标记",
"you_win": "你赢了!", "draw": "平局", "player_score": "玩家 %d:%d",
},
}
+123
View File
@@ -0,0 +1,123 @@
package tabletop
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BingoCardOptions struct {
Disabled bool
}
type BingoCardResult struct {
Element *ui.Element
// Bingo is the first full line, this frame.
Changed, Bingo bool
}
type bingoKey struct{}
// card is the cursor, and if a line was full.
type card struct {
cur int
line bool
}
const bingoW, bingoH = 18, 16
// BingoCard marks a 5×5 card; a full line is BINGO!
func BingoCard(c *ui.Context, text string, numbers [25]int, marked *[25]bool, o BingoCardOptions) BingoCardResult {
k := pixel.K(c)
// Cells print three digits at most; the middle is FREE.
for i, n := range numbers {
if i != 12 && (n < 1 || n > 999) {
panic(fmt.Sprintf("tabletop: BingoCard number %d at %d", n, i))
}
}
col := ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).AlignSelf(ui.Start)
st := ui.Local(col, bingoKey{}, func() card { return card{cur: 12} })
// The middle is FREE: always marked.
marked[12] = true
res := BingoCardResult{}
res.Element = col.Children(func() {
ui.Box(c).Size(k.D(5*bingoW), k.D(7)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, l := range "BINGO" {
g.Micro(i*bingoW+7, 1, string(l), pixel.Accent, 1)
}
})
shell, base, ps, picked := pixel.Pad(c, text, 5, 25, bingoW, bingoH, &st.cur, o.Disabled)
if picked >= 0 && picked != 12 {
marked[picked], res.Changed = !marked[picked], true
}
say := k.Msg("free_space")
if st.cur != 12 {
say = strconv.Itoa(numbers[st.cur])
}
if marked[st.cur] {
say += ", " + k.Msg("marked")
}
base.Description(fmt.Sprintf("%c %s", "BINGO"[st.cur%5], say))
at, shown := st.cur, *marked
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 25 {
x, y := i%5*bingoW, i/5*bingoH
// Cells share their edges; the last ones end inside.
w, h := bingoW+1, bingoH+1
if i%5 == 4 {
w = bingoW
}
if i/5 == 4 {
h = bingoH
}
g.Frame(x, y, w, h, pixel.Flat, pixel.Lit)
if i == at && ps.Focus {
g.Cursor(x+1, y+1, bingoW-1, bingoH-1)
}
if shown[i] && i != 12 {
// A dauber's blot, dithered so the number reads.
g.Disc(x+9, y+8, 6, pixel.Magic)
g.Veil(x+3, y+2, 13, 13, pixel.Lit, 6)
}
// FREE is a star; it fits in any tongue.
if i == 12 {
g.Sprite(x+3, y+2, icon.Star, 0, pixel.Accent, 1)
continue
}
s := strconv.Itoa(numbers[i])
g.Micro(x+(bingoW+1-pixel.MicroWidth(s))/2, y+6, s, pixel.Ink, 1)
}
g.Finish(ps)
})
line := full(marked)
res.Bingo, st.line = line && !st.line, line
if line {
pixel.Text(c, k.Msg("bingo"), pixel.TextOptions{Slot: pixel.Good, Scale: 2})
}
})
if res.Changed || res.Bingo {
slog.Debug("8bit: bingo card", "label", text, "cell", st.cur, "bingo", res.Bingo)
}
return res
}
// full reports a whole row, column or diagonal marked.
func full(m *[25]bool) bool {
lines := [][5]int{{0, 6, 12, 18, 24}, {4, 8, 12, 16, 20}}
for i := range 5 {
lines = append(lines, [5]int{5 * i, 5*i + 1, 5*i + 2, 5*i + 3, 5*i + 4}, [5]int{i, i + 5, i + 10, i + 15, i + 20})
}
for _, l := range lines {
if m[l[0]] && m[l[1]] && m[l[2]] && m[l[3]] && m[l[4]] {
return true
}
}
return false
}
+192
View File
@@ -0,0 +1,192 @@
package tabletop_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
// cell clicks square i of a labeled grid.
func cell(t *testing.T, tt *ui.Tester, label string, cols, side, i int) {
t.Helper()
r := find(t, tt, label)
tt.ClickAt(r.X+float32(2*(i%cols*side+side/2)), r.Y+float32(2*(i/cols*side+side/2)))
}
func TestChessboardMovesCapturesAndSwaps(t *testing.T) {
var board [64]tabletop.Piece
board[57] = tabletop.Piece{Kind: tabletop.Knight, White: true}
board[62] = tabletop.Piece{Kind: tabletop.Knight, White: true}
board[42] = tabletop.Piece{Kind: tabletop.Pawn}
var last tabletop.ChessboardResult
tt := view(600, 400, newWorld(), func(c *ui.Context) {
if r := tabletop.Chessboard(c, "Board", &board, tabletop.ChessboardOptions{}); r.Moved {
last = r
}
})
// Its own side swaps the held piece; reclicking drops it.
cell(t, tt, "CHESSBOARD", 8, 16, 62)
cell(t, tt, "CHESSBOARD", 8, 16, 57)
cell(t, tt, "CHESSBOARD", 8, 16, 57)
x, y, _, _ := dots(find(t, tt, "CHESSBOARD"))
if last.Moved || board[42].Kind != tabletop.Pawn || count(tt.Image(), x, y, 128, 128, pixel.AccentLit) > 0 {
t.Fatal("a dropped piece is still held")
}
cell(t, tt, "CHESSBOARD", 8, 16, 57)
cell(t, tt, "CHESSBOARD", 8, 16, 42)
if last.From != 57 || last.To != 42 || board[42] != (tabletop.Piece{Kind: tabletop.Knight, White: true}) || board[57].Kind != tabletop.None {
t.Fatalf("the knight's capture went %+v", last)
}
}
func TestGoBoardTakesTurns(t *testing.T) {
board := make([]tabletop.Stone, 81)
placed := []int{}
tt := view(400, 400, newWorld(), func(c *ui.Context) {
if i := tabletop.GoBoard(c, "Goban", board, tabletop.GoBoardOptions{}).Placed; i >= 0 {
placed = append(placed, i)
}
})
find(t, tt, "BLACK TO PLAY")
cell(t, tt, "Goban", 9, 10, 40)
find(t, tt, "WHITE TO PLAY")
cell(t, tt, "Goban", 9, 10, 40)
cell(t, tt, "Goban", 9, 10, 41)
if len(placed) != 2 || board[40] != tabletop.Black || board[41] != tabletop.White {
t.Fatalf("placed %v: %v %v", placed, board[40], board[41])
}
find(t, tt, "BLACK TO PLAY")
}
func TestRouletteSpinsToItsResult(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := newWorld()
wd.reduce = reduce
segs := []string{"ZERO", "ONE", "TWO", "THREE", "FOUR"}
result, cue := 0, 0
tt := view(400, 300, wd, func(c *ui.Context) {
tabletop.Roulette(c, "Wheel", segs, result, tabletop.RouletteOptions{Cue: cue})
})
find(t, tt, "ZERO")
result, cue = 3, 1
tt.Frame()
_, landed := tt.Find("THREE")
if landed != reduce {
t.Errorf("reduce %v: landed at once is %v", reduce, landed)
}
later(tt, wd, 3*time.Second)
find(t, tt, "THREE")
}
}
func TestCoinFlipLandsOnItsFace(t *testing.T) {
wd := newWorld()
heads, cue := true, 0
tt := view(300, 300, wd, func(c *ui.Context) { tabletop.CoinFlip(c, "Coin", heads, tabletop.CoinFlipOptions{Cue: cue}) })
find(t, tt, "HEADS")
heads, cue = false, 1
tt.Frame()
if _, ok := tt.Find("TAILS"); ok {
t.Fatal("the coin landed before it flew")
}
later(tt, wd, time.Second)
find(t, tt, "TAILS")
}
func TestBingoCardCallsALineOnce(t *testing.T) {
var numbers [25]int
for i := range numbers {
numbers[i] = i + 1
}
var marked [25]bool
bingos, changes := 0, 0
tt := view(400, 400, newWorld(), func(c *ui.Context) {
r := tabletop.BingoCard(c, "Card", numbers, &marked, tabletop.BingoCardOptions{})
if r.Bingo {
bingos++
}
if r.Changed {
changes++
}
})
if !marked[12] {
t.Fatal("FREE is not marked")
}
// The middle row needs four marks beside FREE.
for _, i := range []int{10, 11, 13} {
cell(t, tt, "Card", 5, 18, i)
}
if bingos != 0 {
t.Fatal("BINGO before the line was full")
}
cell(t, tt, "Card", 5, 18, 14)
tt.Frame()
if bingos != 1 || !tt.HasText("BINGO!") {
t.Fatalf("a full row called %d bingos", bingos)
}
cell(t, tt, "Card", 5, 18, 12)
if !marked[12] || changes != 4 {
t.Fatalf("FREE came unmarked, or changed: %d changes", changes)
}
}
func TestTicTacToeAnswersAndContinues(t *testing.T) {
over, winner := 0, -1
tt := view(400, 400, newWorld(), func(c *ui.Context) {
if r := tabletop.TicTacToe(c, "Grid", tabletop.TicTacToeOptions{}); r.Over {
over, winner = over+1, r.Winner
}
})
// X plays 0, 8, 1; O answers 4, 2, 6.
for _, i := range []int{0, 8, 1} {
cell(t, tt, "Grid", 3, 22, i)
}
if over != 1 || winner != 2 {
t.Fatalf("over %d, winner %d: %q", over, winner, tt.Texts())
}
// X is a Bad cross; O is an Info ring.
x, y, _, _ := dots(find(t, tt, "Grid"))
if count(tt.Image(), x, y, 22, 22, pixel.Bad) == 0 || count(tt.Image(), x+22, y+22, 22, 22, pixel.Info) == 0 {
t.Fatal("the marks are not drawn")
}
find(t, tt, "GAME OVER")
find(t, tt, "CONTINUE?")
cell(t, tt, "Grid", 3, 22, 0)
if tt.HasText("CONTINUE?") {
t.Fatal("a click after the end did not start over")
}
}
func TestScoreTrackStandsMeeples(t *testing.T) {
tt := view(500, 200, newWorld(), func(c *ui.Context) {
tabletop.ScoreTrack(c, "Track", []int{3, 3, 42}, tabletop.ScoreTrackOptions{})
})
x, y, _, _ := dots(find(t, tt, "Track"))
img := tt.Image()
// Space 3 holds two pawns; 42 laps to space 12.
if count(img, x+60, y, 20, 20, pixel.Bad) == 0 || count(img, x+60, y, 20, 20, pixel.Info) == 0 {
t.Error("space 3 lacks its two pawns")
}
if count(img, x+7*20, y+20, 20, 20, pixel.Good) < 20 {
t.Error("space 12 lacks the third meeple")
}
}
func TestMeepleStandsOrWaits(t *testing.T) {
tt := view(300, 200, newWorld(), func(c *ui.Context) {
tabletop.Meeple(c, "Two", tabletop.MeepleOptions{Player: 2})
tabletop.Meeple(c, "Seat", tabletop.MeepleOptions{Player: 2, Empty: true})
})
x, y, w, h := dots(find(t, tt, "Two"))
if count(tt.Image(), x, y, w, h, pixel.Good) == 0 {
t.Error("player 3's meeple is not Good")
}
x, y, w, h = dots(find(t, tt, "Seat"))
if count(tt.Image(), x, y, w, h, pixel.Good) > 0 {
t.Error("an empty seat shows a meeple")
}
}
+120
View File
@@ -0,0 +1,120 @@
package tabletop
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CardHandOptions struct {
Disabled bool
}
type CardHandResult struct {
Element *ui.Element
// Played is the card played this frame, else -1.
Played int
}
type handKey struct{}
// hand is the cursor, and each card's last drawn rise.
type hand struct {
cur int
rise []int
}
// fan is how far apart a hand's cards sit.
const fan = 18
// CardHand fans cards; the pointed or chosen lifts; clicks play.
func CardHand(c *ui.Context, text string, cards []Card, o CardHandOptions) CardHandResult {
k := pixel.K(c)
seen := map[Card]bool{}
for _, cd := range cards {
cd.check("CardHand")
if seen[cd] {
panic(fmt.Sprintf("tabletop: CardHand holds %+v twice", cd))
}
seen[cd] = true
}
col := ui.Column(c)
h := ui.Local(col, handKey{}, func() hand { return hand{} })
cur := &h.cur
res := CardHandResult{Played: -1}
n := len(cards)
w := max(cardW, fan*(n-1)+cardW)
res.Element = col.Children(func() {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
*cur = max(0, min(*cur, n-1))
// hit is the top card drawn at a point.
hit := func(x, y float32) int {
dx, dy := int(x/k.Dot()), int(y/k.Dot())
for i := n - 1; i >= 0; i-- {
top := 8
if i < len(h.rise) {
top -= h.rise[i]
}
if dx >= i*fan && dx < i*fan+cardW && dy >= top && dy < top+cardH {
return i
}
}
return -1
}
lit := -1
if !base.IsDisabled() && n > 0 {
for _, m := range []struct {
key ui.Key
to int
}{{ui.KeyLeft, *cur - 1}, {ui.KeyRight, *cur + 1}, {ui.KeyHome, 0}, {ui.KeyEnd, n - 1}} {
if base.Shortcut(0, m.key) {
*cur = max(0, min(n-1, m.to))
}
}
// Enter and Space are taken, so clicks come from pointers.
if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) {
res.Played = *cur
}
x, y, over := base.PointerPosition()
if i := hit(x, y); base.Clicked() && i >= 0 {
*cur, res.Played = i, i
}
switch {
case over && st.Hover:
lit = hit(x, y)
case st.Focus:
lit = *cur
}
}
said := make([]string, n)
for i, cd := range cards {
said[i] = cd.name(k)
}
if n > 0 {
base.Description(said[*cur] + "; " + strings.Join(said, ", "))
}
// Cards rise in three steps, keyed: cards come and go.
rise := make([]int, n)
for i, cd := range cards {
rise[i] = 2 * k.Toward(c, ui.Box(c).Key(cd).Size(0, 0), i == lit, 15, 3)
}
h.rise = rise
shell.Size(k.D(w), k.D(cardH+8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, cd := range cards {
drawCard(g, i*fan, 8-rise[i], cd, false)
}
g.Finish(st)
})
})
if res.Played >= 0 {
slog.Debug("8bit: card played", "label", text, "card", cards[res.Played].name(k))
}
return res
}
+167
View File
@@ -0,0 +1,167 @@
package tabletop
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Kind is a chess piece's kind; None, an empty square.
type Kind int
const (
None Kind = iota
Pawn
Knight
Bishop
Rook
Queen
King
)
var kinds = [...]struct {
msg string
sp *pixel.Sprite
}{{}, {"pawn", icon.Pawn}, {"knight", icon.Knight}, {"bishop", icon.Bishop}, {"rook", icon.Rook}, {"chess_queen", icon.Queen}, {"chess_king", icon.King}}
// Piece is what stands on a square.
type Piece struct {
Kind Kind
White bool
}
type ChessboardOptions struct {
Disabled bool
}
type ChessboardResult struct {
Element *ui.Element
// Moved is this frame's move, From one square To another.
Moved bool
From, To int
// Changed is a pick-up or put-down; Held, the square.
Changed bool
Held int
}
type chessKey struct{}
// chess is the cursor and the held square, else -1.
type chess struct{ cur, held int }
const square = 16
// Chessboard: click a piece, then a square to move to.
func Chessboard(c *ui.Context, text string, board *[64]Piece, o ChessboardOptions) ChessboardResult {
k := pixel.K(c)
for i, pc := range board {
if pc.Kind < None || pc.Kind > King {
panic(fmt.Sprintf("tabletop: Chessboard square %d has kind %d", i, pc.Kind))
}
}
row := ui.Row(c).Gap(k.D(2)).Role(ui.RoleGroup).Label(text)
st := ui.Local(row, chessKey{}, func() chess { return chess{held: -1} })
res := ChessboardResult{From: -1, To: -1, Held: -1}
res.Element = row.Children(func() {
ui.Box(c).Size(k.D(5), k.D(8*square)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 8 {
g.Micro(1, i*square+6, fmt.Sprint(8-i), pixel.Faint, 1)
}
})
ui.Column(c).Gap(k.D(2)).Children(func() {
shell, base, ps, picked := pixel.Pad(c, k.Msg("chessboard"), 8, 64, square, square, &st.cur, o.Disabled)
// A held square the caller emptied is let go.
if st.held >= 0 && board[st.held].Kind == None {
st.held = -1
}
was := st.held
switch {
case picked < 0:
case picked == st.held:
st.held = -1
case st.held < 0 || board[picked].Kind != None && board[picked].White == board[st.held].White:
// A piece is picked up; its own side swaps it.
st.held = -1
if board[picked].Kind != None {
st.held = picked
}
default:
board[picked], board[st.held] = board[st.held], Piece{}
res.Moved, res.From, res.To, st.held = true, st.held, picked, -1
}
res.Held = st.held
if st.held != was && !res.Moved {
res.Changed = true
slog.Debug("8bit: chess hold", "label", text, "square", st.held)
}
base.Description(squareName(st.cur) + " " + pieceName(k, board[st.cur]))
held, at, shown := st.held, st.cur, *board
light, dark := shades(k)
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, pc := range shown {
x, y := i%8*square, i/8*square
fill := pixel.Lit
if (i%8+i/8)%2 == 1 {
fill = pixel.Shade
}
g.Rect(x, y, square, square, fill)
if i == held {
g.Rect(x, y, square, square, pixel.AccentLit)
}
if i == at && ps.Focus {
g.Cursor(x, y, square, square)
}
if pc.Kind != None {
piece(g, x+2, y+2, pc, light, dark)
}
}
g.Finish(ps)
})
ui.Box(c).Size(k.D(8*square), k.D(5)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 8 {
g.Micro(i*square+6, 0, string(rune('A'+i)), pixel.Faint, 1)
}
})
})
})
if res.Moved {
slog.Debug("8bit: chess move", "label", text, "from", squareName(res.From), "to", squareName(res.To))
}
return res
}
// piece draws a piece outlined, showing on either square.
func piece(g pixel.Canvas, x, y int, pc Piece, light, dark pixel.Slot) {
sp, fill, edge := kinds[pc.Kind].sp, dark, light
if pc.White {
fill, edge = light, dark
}
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
g.Silhouette(x+d[0], y+d[1], sp, 0, edge, 1)
}
g.Sprite(x, y, sp, 0, fill, 1)
}
// squareName is a square as players say it, as E4.
func squareName(i int) string {
return fmt.Sprintf("%c%d", 'A'+i%8, 8-i/8)
}
// pieceName reads a piece out, as WHITE KNIGHT.
func pieceName(k pixel.Kit, pc Piece) string {
if pc.Kind == None {
return k.Msg("empty")
}
side := k.Msg("black")
if pc.White {
side = k.Msg("white")
}
return fmt.Sprintf(k.Msg("piece_name"), side, k.Msg(kinds[pc.Kind].msg))
}
+64
View File
@@ -0,0 +1,64 @@
package tabletop
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CoinFlipOptions struct {
// Cue tosses it: a new cue flips, then it lands.
Cue int
}
// The coin's width and lift, a toss's frame by frame.
var (
turns = [...]int{24, 16, 6, 2, 6, 16, 24, 16, 6, 2, 6, 16}
toss = [...]int{0, 6, 11, 14, 16, 16, 14, 11, 7, 4, 2, 0}
)
const coinUp = 18
// CoinFlip tosses a coin on a new Cue.
func CoinFlip(c *ui.Context, text string, heads bool, o CoinFlipOptions) *ui.Element {
k := pixel.K(c)
word := k.Msg("tails")
if heads {
word = k.Msg("heads")
}
e := ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).AlignSelf(ui.Start).Role(ui.RoleGroup).Label(text).Description(word)
// A toss turns twelve frames; reduced, it lands at once.
f := max(0, k.Shot(c, e, o.Cue, 15, len(turns)))
return e.Children(func() {
ui.Box(c).Size(k.D(26), k.D(26+coinUp)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
w, y, up := 24, coinUp, heads
if f < len(turns) {
// Each half turn shows the other face.
w, y, up = turns[f], coinUp-toss[f], heads == (f/3%2 == 0)
}
for dy := -12; dy <= 12; dy++ {
half := int(math.Round(float64(w) / 2 * math.Sqrt(1-float64(dy*dy)/144)))
g.HLine(13-half, y+12+dy, 2*half, pixel.Edge)
if half > 1 {
g.HLine(14-half, y+12+dy, 2*half-2, pixel.Warn)
}
}
if w == 24 {
face := icon.Star
if up {
face = icon.Crown
}
g.Sprite(7, y+6, face, 0, pixel.Ink, 1)
}
})
if f >= len(turns) {
pixel.Text(c, word, pixel.TextOptions{})
} else {
ui.Box(c).Height(k.D(pixel.LineHeight))
}
})
}
+61
View File
@@ -0,0 +1,61 @@
package tabletop
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DeckOptions struct {
Disabled bool
}
type DeckResult struct {
Element *ui.Element
// Drawn is a card taken this frame.
Drawn bool
}
// Deck is a face-down pile, counted; a click draws.
func Deck(c *ui.Context, text string, count int, o DeckOptions) DeckResult {
k := pixel.K(c)
// The badge holds three digits.
if count < 0 || count > 999 {
panic(fmt.Sprintf("tabletop: Deck of %d", count))
}
left := fmt.Sprintf(k.Msg("cards_left"), count)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleButton).Label(text).Description(left).Disabled(o.Disabled || count == 0)
})
res := DeckResult{Element: shell, Drawn: !base.IsDisabled() && base.Clicked()}
if res.Drawn {
slog.Debug("8bit: deck drew", "label", text, "left", count-1)
}
// A deeper pile shows more edges, six at most.
layers := min(count, 6)
shell.Size(k.D(cardW+7), k.D(cardH+7)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if count == 0 {
g.Checker(0, 6, cardW, 1, pixel.Edge)
g.Checker(0, cardH+5, cardW, 1, pixel.Edge)
g.Checker(0, 6, 1, cardH, pixel.Edge)
g.Checker(cardW-1, 6, 1, cardH, pixel.Edge)
}
for i := range layers {
off := layers - 1 - i
drawCard(g, off, 6-off, Card{Rank: 1}, true)
}
if count > 0 {
n := strconv.Itoa(count)
w := pixel.MicroWidth(n) + 4
g.Rect(cardW+6-w, cardH, w, 7, pixel.Accent)
g.Micro(cardW+8-w, cardH+1, n, pixel.AccentInk, 1)
}
g.Finish(st)
})
return res
}
+48
View File
@@ -0,0 +1,48 @@
package tabletop
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DiceOptions struct {
// Cue rolls it: a new cue tumbles, then lands it.
Cue int
}
// hop is how high a tumbling die flies, each frame.
var hop = [...]int{0, 3, 5, 6, 6, 5, 3, 1}
// Dice is a d6 showing value; new Cues tumble it.
func Dice(c *ui.Context, text string, value int, o DiceOptions) *ui.Element {
k := pixel.K(c)
if value < 1 || value > 6 {
panic(fmt.Sprintf("tabletop: Dice value %d", value))
}
e := ui.Box(c).Size(k.D(20), k.D(26)).Role(ui.RoleImage).Label(text).Description(strconv.Itoa(value))
// A roll tumbles eight frames; reduced, it lands at once.
f := max(0, k.Shot(c, e, o.Cue, 12, len(hop)))
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
face, y, h := value, 8, 16
if f < len(hop) {
// Turning, it shows other faces, squashed every other frame.
face, y = (value+f*5)%6+1, 8-hop[f]
if f%2 == 1 {
y, h = y+2, 12
}
}
g.Frame(2, y, 16, h, pixel.Raised, pixel.Lit)
ink := pixel.Ink
if face == 1 {
ink = pixel.Bad
}
if h == 16 {
pips(g, 2, y, 16, face, ink)
}
})
}
+39
View File
@@ -0,0 +1,39 @@
package tabletop
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DiceTrayOptions struct {
// Cue rolls them all, as Dice's Cue does.
Cue int
}
// DiceTray holds several dice on felt, and their total.
func DiceTray(c *ui.Context, text string, values []int, o DiceTrayOptions) *ui.Element {
k := pixel.K(c)
if len(values) == 0 {
panic("tabletop: DiceTray has no dice")
}
total := 0
for _, v := range values {
total += v
}
sum := fmt.Sprintf("%s %d", k.Msg("total"), total)
return ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).AlignSelf(ui.Start).Role(ui.RoleGroup).Label(text).Description(sum).Children(func() {
ui.Row(c).Gap(k.D(2)).Padding(k.D(6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Good)
g.Veil(2, 2, g.W-4, g.H-4, pixel.Shade, 4)
}).Children(func() {
for i, v := range values {
Dice(c, fmt.Sprintf("%s %d", text, i+1), v, DiceOptions{Cue: o.Cue})
}
})
pixel.Text(c, sum, pixel.TextOptions{})
})
}
+39
View File
@@ -0,0 +1,39 @@
package tabletop
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DominoOptions struct {
// Vertical stands the tile on end.
Vertical bool
}
// Domino is a tile of two halves of 0–6 pips.
func Domino(c *ui.Context, a, b int, o DominoOptions) *ui.Element {
k := pixel.K(c)
if a < 0 || a > 6 || b < 0 || b > 6 {
panic(fmt.Sprintf("tabletop: Domino %d|%d", a, b))
}
w, h := 39, 20
if o.Vertical {
w, h = h, w
}
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(fmt.Sprintf("%d | %d", a, b)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, w, h, pixel.Raised, pixel.Lit)
if o.Vertical {
g.HLine(3, 19, 14, pixel.Edge)
pips(g, 1, 1, 18, a, pixel.Ink)
pips(g, 1, 20, 18, b, pixel.Ink)
return
}
g.VLine(19, 3, 14, pixel.Edge)
pips(g, 1, 1, 18, a, pixel.Ink)
pips(g, 20, 1, 18, b, pixel.Ink)
})
}
+87
View File
@@ -0,0 +1,87 @@
package tabletop_test
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestBadInputPanics(t *testing.T) {
n := 0
for name, build := range map[string]func(c *ui.Context){
"die 0": func(c *ui.Context) { tabletop.Dice(c, "D", 0, tabletop.DiceOptions{}) },
"die 7": func(c *ui.Context) { tabletop.Dice(c, "D", 7, tabletop.DiceOptions{}) },
"no dice": func(c *ui.Context) { tabletop.DiceTray(c, "T", nil, tabletop.DiceTrayOptions{}) },
"rank 14": func(c *ui.Context) { tabletop.PlayingCard(c, tabletop.Card{Rank: 14}, tabletop.PlayingCardOptions{}) },
"suit 4": func(c *ui.Context) {
tabletop.PlayingCard(c, tabletop.Card{Rank: 1, Suit: 4}, tabletop.PlayingCardOptions{})
},
"twin cards": func(c *ui.Context) {
tabletop.CardHand(c, "H", []tabletop.Card{{Rank: 1}, {Rank: 1}}, tabletop.CardHandOptions{})
},
"minus deck": func(c *ui.Context) { tabletop.Deck(c, "D", -1, tabletop.DeckOptions{}) },
"kind 9": func(c *ui.Context) {
var b [64]tabletop.Piece
b[0].Kind = 9
tabletop.Chessboard(c, "B", &b, tabletop.ChessboardOptions{})
},
"go 10x10": func(c *ui.Context) { tabletop.GoBoard(c, "G", make([]tabletop.Stone, 100), tabletop.GoBoardOptions{}) },
"stone 3": func(c *ui.Context) {
tabletop.GoBoard(c, "G", append(make([]tabletop.Stone, 80), 3), tabletop.GoBoardOptions{})
},
"dots 10": func(c *ui.Context) { tabletop.MahjongTile(c, tabletop.Tile{Rank: 10}, tabletop.MahjongTileOptions{}) },
"wind 5": func(c *ui.Context) {
tabletop.MahjongTile(c, tabletop.Tile{Family: tabletop.Winds, Rank: 5}, tabletop.MahjongTileOptions{})
},
"domino 7": func(c *ui.Context) { tabletop.Domino(c, 7, 0, tabletop.DominoOptions{}) },
"one wedge": func(c *ui.Context) { tabletop.Roulette(c, "R", []string{"A"}, 0, tabletop.RouletteOptions{}) },
"past end": func(c *ui.Context) { tabletop.Roulette(c, "R", []string{"A", "B"}, 2, tabletop.RouletteOptions{}) },
"blank": func(c *ui.Context) { tabletop.Roulette(c, "R", []string{"A", ""}, 0, tabletop.RouletteOptions{}) },
"no players": func(c *ui.Context) {
tabletop.ScoreTrack(c, "S", nil, tabletop.ScoreTrackOptions{})
},
"7 players": func(c *ui.Context) { tabletop.ScoreTrack(c, "S", make([]int, 7), tabletop.ScoreTrackOptions{}) },
"minus score": func(c *ui.Context) { tabletop.ScoreTrack(c, "S", []int{-1}, tabletop.ScoreTrackOptions{}) },
"player 6": func(c *ui.Context) { tabletop.Meeple(c, "M", tabletop.MeepleOptions{Player: 6}) },
} {
n++
// The panic must be the package's own, not a fault.
func() {
defer func() {
if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "tabletop:") {
t.Errorf("%s panicked with %q", name, r)
}
}()
view(600, 300, newWorld(), build)
}()
}
if n != 20 {
t.Fatalf("%d cases", n)
}
}
func TestExtremesStayInside(t *testing.T) {
long := strings.Repeat("W", 40)
for _, tc := range []struct {
label string
build func(c *ui.Context)
}{
{"R", func(c *ui.Context) { tabletop.Roulette(c, "R", []string{long, "B"}, 0, tabletop.RouletteOptions{}) }},
{"S", func(c *ui.Context) {
tabletop.ScoreTrack(c, "S", []int{9, 9, 9, 9, 9, 9}, tabletop.ScoreTrackOptions{Spaces: 10})
}},
{"T", func(c *ui.Context) {
tabletop.DiceTray(c, "T", []int{1, 2, 3, 4, 5, 6, 6, 6}, tabletop.DiceTrayOptions{})
}},
} {
tt := view(1400, 700, newWorld(), tc.build)
x, y, w, h := dots(find(t, tt, tc.label))
if n := spill(tt.Image(), x, y, w, h); n > 0 {
t.Errorf("%s spills %d dots", tc.label, n)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package tabletop
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Stone is a Go point: Empty, Black or White.
type Stone int8
const (
Empty Stone = iota
Black
White
)
type GoBoardOptions struct {
Disabled bool
}
type GoBoardResult struct {
Element *ui.Element
// Placed is the point played this frame, else -1.
Placed int
}
type goKey struct{}
const point = 10
// GoBoard places stones in turn, Black first, 9–19 lines.
func GoBoard(c *ui.Context, text string, board []Stone, o GoBoardOptions) GoBoardResult {
k := pixel.K(c)
size := map[int]int{81: 9, 169: 13, 361: 19}[len(board)]
if size == 0 {
panic(fmt.Sprintf("tabletop: GoBoard of %d points is not 9, 13 or 19 lines", len(board)))
}
blacks, whites := 0, 0
for i, s := range board {
switch s {
case Black:
blacks++
case White:
whites++
case Empty:
default:
panic(fmt.Sprintf("tabletop: GoBoard point %d holds %d", i, s))
}
}
turn := Black
if blacks > whites {
turn = White
}
col := ui.Column(c).Gap(k.D(4))
cur := ui.Local(col, goKey{}, func() int { return len(board) / 2 })
// A smaller board pulls the cursor in.
*cur = min(*cur, len(board)-1)
res := GoBoardResult{Placed: -1}
res.Element = col.Children(func() {
side := k.Msg("black")
if turn == White {
side = k.Msg("white")
}
next := fmt.Sprintf(k.Msg("to_play"), side)
pixel.Text(c, next, pixel.TextOptions{})
shell, base, st, picked := pixel.Pad(c, text, size, len(board), point, point, cur, o.Disabled)
if picked >= 0 && board[picked] == Empty {
board[picked], res.Placed = turn, picked
}
base.Description(goPoint(size, *cur) + " " + stoneName(k, board[*cur]))
at, shown := *cur, append([]Stone(nil), board...)
light, dark := shades(k)
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
end := (size-1)*point + 1
for i := range size {
g.HLine(point/2, point/2+i*point, end, pixel.Ink)
g.VLine(point/2+i*point, point/2, end, pixel.Ink)
}
// Star points sit three or four lines in, and mid-board.
edge := 2
if size > 9 {
edge = 3
}
for _, x := range []int{edge, size / 2, size - 1 - edge} {
for _, y := range []int{edge, size / 2, size - 1 - edge} {
g.Rect(point/2+x*point-1, point/2+y*point-1, 3, 3, pixel.Ink)
}
}
for i, s := range shown {
cx, cy := point/2+i%size*point, point/2+i/size*point
switch s {
case Black:
g.Disc(cx, cy, 4, dark)
case White:
g.Disc(cx, cy, 4, light)
g.Circle(cx, cy, 4, dark)
}
}
if st.Focus {
x, y := at%size*point, at/size*point
g.Rect(x, y, point, 1, pixel.Accent)
g.Rect(x, y+point-1, point, 1, pixel.Accent)
g.Rect(x, y, 1, point, pixel.Accent)
g.Rect(x+point-1, y, 1, point, pixel.Accent)
}
g.Finish(st)
})
})
if res.Placed >= 0 {
slog.Debug("8bit: go stone placed", "label", text, "point", goPoint(size, res.Placed))
}
return res
}
// goPoint names a point as Go does: columns skip I.
func goPoint(size, i int) string {
return fmt.Sprintf("%c%d", "ABCDEFGHJKLMNOPQRST"[i%size], size-i/size)
}
// stoneName reads a stone out, or the point's emptiness.
func stoneName(k pixel.Kit, s Stone) string {
switch s {
case Black:
return fmt.Sprintf(k.Msg("stone"), k.Msg("black"))
case White:
return fmt.Sprintf(k.Msg("stone"), k.Msg("white"))
}
return k.Msg("empty")
}
+37
View File
@@ -0,0 +1,37 @@
package tabletop
import "testing"
func TestAnswerWinsBlocksThenFills(t *testing.T) {
for _, tc := range []struct {
b [9]int
want int
}{
{[9]int{2, 2, 0, 1, 1, 0, 0, 0, 0}, 2},
{[9]int{1, 1, 0, 0, 2, 0, 0, 0, 0}, 2},
{[9]int{1, 0, 0, 0, 0, 0, 0, 0, 0}, 4},
{[9]int{0, 0, 0, 0, 1, 0, 0, 0, 0}, 0},
{[9]int{1, 2, 1, 2, 2, 1, 0, 1, 0}, 8}, {[9]int{1, 2, 1, 1, 2, 2, 2, 1, 0}, 8},
} {
if got := answer(tc.b); got != tc.want {
t.Errorf("answer(%v) = %d, want %d", tc.b, got, tc.want)
}
}
}
func TestFullSeesEveryLine(t *testing.T) {
lines := [][]int{{0, 1, 2, 3, 4}, {2, 7, 12, 17, 22}, {0, 6, 12, 18, 24}, {4, 8, 12, 16, 20}}
for _, l := range lines {
var m [25]bool
for _, i := range l[:4] {
m[i] = true
}
if full(&m) {
t.Fatalf("four of %v is full", l)
}
m[l[4]] = true
if !full(&m) {
t.Fatalf("%v is not full", l)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package tabletop
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Family is a mahjong suit, or the winds or dragons.
type Family int
const (
Dots Family = iota
Bamboo
Characters
Winds
Dragons
)
var families = [...]struct {
msg string
ranks int
}{{"dots_suit", 9}, {"bamboo", 9}, {"characters", 9}, {"", 4}, {"", 3}}
// Tile: suits rank 1–9, winds 1–4, dragons 1–3.
type Tile struct {
Family Family
Rank int
}
type MahjongTileOptions struct {
// Back shows the tile face down.
Back bool
}
// spots are where 1–9 dots or sticks stand, on 3×3.
var spots = [...][][2]int{{}, {{1, 1}}, {{1, 0}, {1, 2}}, {{0, 0}, {1, 1}, {2, 2}}, {{0, 0}, {2, 0}, {0, 2}, {2, 2}},
{{0, 0}, {2, 0}, {1, 1}, {0, 2}, {2, 2}}, {{0, 0}, {2, 0}, {0, 1}, {2, 1}, {0, 2}, {2, 2}},
{{0, 0}, {2, 0}, {0, 1}, {1, 1}, {2, 1}, {0, 2}, {2, 2}}, {{0, 0}, {1, 0}, {2, 0}, {0, 1}, {2, 1}, {0, 2}, {1, 2}, {2, 2}},
{{0, 0}, {1, 0}, {2, 0}, {0, 1}, {1, 1}, {2, 1}, {0, 2}, {1, 2}, {2, 2}}}
// Tile faces are art: their hanzi stay, whatever the locale.
var (
numerals = []string{"一", "二", "三", "四", "五", "六", "七", "八", "九"}
winds = []string{"east", "south", "west", "north"}
dragons = []string{"red_dragon", "green_dragon", "white_dragon"}
)
const tileW, tileH = 26, 34
// MahjongTile is a suit or honor tile, or its back.
func MahjongTile(c *ui.Context, t Tile, o MahjongTileOptions) *ui.Element {
k := pixel.K(c)
if t.Family < Dots || t.Family > Dragons || t.Rank < 1 || t.Rank > families[t.Family].ranks {
panic(fmt.Sprintf("tabletop: MahjongTile %+v", t))
}
name := k.Msg("tile_back")
switch {
case o.Back:
case t.Family == Winds:
name = k.Msg(winds[t.Rank-1])
case t.Family == Dragons:
name = k.Msg(dragons[t.Rank-1])
default:
name = fmt.Sprintf(k.Msg("tile_name"), t.Rank, k.Msg(families[t.Family].msg))
}
return ui.Box(c).Size(k.D(tileW), k.D(tileH)).Role(ui.RoleImage).Label(name).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// The green back shows as a rim below, right.
g.Frame(2, 2, tileW-2, tileH-2, pixel.Flat, pixel.Good)
face := pixel.Lit
if o.Back {
face = pixel.Good
}
g.Frame(0, 0, tileW-2, tileH-2, pixel.Flat, face)
if !o.Back {
tileFace(g, t)
}
})
}
// tileFace paints a tile's face in its 24×32 front.
func tileFace(g pixel.Canvas, t Tile) {
big := func(s string, ink pixel.Slot) { g.Text((24-2*pixel.Measure(s))/2, 2, s, ink, 2) }
switch t.Family {
case Dots:
for i, s := range spots[t.Rank] {
ink := []pixel.Slot{pixel.Info, pixel.Good, pixel.Bad}[i%3]
r := 2
if t.Rank == 1 {
r = 6
}
g.Circle(5+s[0]*7, 7+s[1]*9, r, ink)
g.Dot(5+s[0]*7, 7+s[1]*9, ink)
}
case Bamboo:
for _, s := range spots[t.Rank] {
x, y := 4+s[0]*7, 3+s[1]*9
g.Rect(x, y, 2, 8, pixel.Good)
g.Rect(x, y+3, 2, 1, pixel.Lit)
}
case Characters:
n := numerals[t.Rank-1]
g.Text((24-pixel.Measure(n))/2, 1, n, pixel.Ink, 1)
g.Text((24-pixel.Measure("万"))/2, 15, "万", pixel.Bad, 1)
case Winds:
big([]string{"东", "南", "西", "北"}[t.Rank-1], pixel.Ink)
case Dragons:
switch t.Rank {
case 1:
big("中", pixel.Bad)
case 2:
big("发", pixel.Good)
default:
g.Rect(5, 6, 14, 20, pixel.Info)
g.Rect(7, 8, 10, 16, pixel.Lit)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package tabletop
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MeepleOptions struct {
// Player picks its color, 0 to 5.
Player int
// Empty leaves the slot open.
Empty bool
}
// Meeple is a player's token, standing in its slot.
func Meeple(c *ui.Context, text string, o MeepleOptions) *ui.Element {
k := pixel.K(c)
if o.Player < 0 || o.Player >= len(players) {
panic(fmt.Sprintf("tabletop: Meeple player %d", o.Player))
}
e := ui.Box(c).Size(k.D(data.SlotSize), k.D(data.SlotSize)).Role(ui.RoleImage).Label(text)
if o.Empty {
e.Description(k.Msg("empty"))
}
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Slots look alike everywhere: an empty item slot holds it.
data.DrawSlot(g, 0, 0, data.Item{}, data.ItemSlotOptions{})
if o.Empty {
return
}
at := (data.SlotSize - 12) / 2
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
g.Silhouette(at+d[0], at+d[1], icon.Meeple, 0, pixel.Edge, 1)
}
g.Sprite(at, at, icon.Meeple, 0, players[o.Player], 1)
})
}
+194
View File
@@ -0,0 +1,194 @@
package tabletop_test
import (
"bytes"
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestDiceTumbleThenLand(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := newWorld()
wd.reduce = reduce
value, cue := 3, 0
tt := view(200, 200, wd, func(c *ui.Context) { tabletop.Dice(c, "Die", value, tabletop.DiceOptions{Cue: cue}) })
landed := func(v int) []byte {
return view(200, 200, newWorld(), func(c *ui.Context) { tabletop.Dice(c, "Die", v, tabletop.DiceOptions{}) }).Image().Pix
}
if !bytes.Equal(tt.Image().Pix, landed(3)) {
t.Fatal("a die shown first tumbled")
}
value, cue = 5, 1
tt.Frame()
if tumbling := !bytes.Equal(tt.Image().Pix, landed(5)); tumbling == reduce {
t.Errorf("reduce %v: tumbling is %v", reduce, tumbling)
}
later(tt, wd, time.Second)
if !bytes.Equal(tt.Image().Pix, landed(5)) {
t.Errorf("reduce %v: it never landed on 5", reduce)
}
}
}
func TestDiceTrayAddsUp(t *testing.T) {
tt := view(400, 200, newWorld(), func(c *ui.Context) { tabletop.DiceTray(c, "Dice", []int{6, 6, 1}, tabletop.DiceTrayOptions{}) })
find(t, tt, "TOTAL 13")
find(t, tt, "Dice 3")
}
func TestCardsReadAndInk(t *testing.T) {
tt := view(400, 400, newWorld(), func(c *ui.Context) {
tabletop.PlayingCard(c, tabletop.Card{Rank: 1, Suit: tabletop.Spades}, tabletop.PlayingCardOptions{})
tabletop.PlayingCard(c, tabletop.Card{Rank: 12, Suit: tabletop.Hearts}, tabletop.PlayingCardOptions{})
tabletop.PlayingCard(c, tabletop.Card{Rank: 9, Suit: tabletop.Clubs}, tabletop.PlayingCardOptions{Back: true})
})
img := tt.Image()
x, y, w, h := dots(find(t, tt, "ACE OF SPADES"))
if count(img, x, y, w, h, pixel.Bad) > 0 {
t.Error("a spade is red")
}
x, y, w, h = dots(find(t, tt, "QUEEN OF HEARTS"))
if count(img, x, y, w, h, pixel.Bad) == 0 {
t.Error("a heart is not red")
}
x, y, w, h = dots(find(t, tt, "CARD BACK"))
if count(img, x, y, w, h, pixel.Accent) == 0 {
t.Error("the back shows no pattern")
}
}
func TestCardHandLiftsAndPlays(t *testing.T) {
cards := []tabletop.Card{{Rank: 2, Suit: tabletop.Clubs}, {Rank: 3, Suit: tabletop.Clubs}, {Rank: 4, Suit: tabletop.Clubs}}
wd, played, off := newWorld(), -1, false
tt := view(400, 300, wd, func(c *ui.Context) {
if i := tabletop.CardHand(c, "Hand", cards, tabletop.CardHandOptions{Disabled: off}).Played; i >= 0 {
played = i
}
})
x, y, _, _ := dots(find(t, tt, "Hand"))
// top is the first row of a card's outline.
top := func(i int) int {
for j := y; j < y+60; j++ {
if at(tt.Image(), x+i*18+4, j) == pixel.Edge {
return j - y
}
}
return -1
}
rest := top(1)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
later(tt, wd, time.Second)
if top(1) != rest-6 || top(0) != rest {
t.Fatalf("the chosen card rose to %d from %d", top(1), rest)
}
tt.Key(0, ui.KeyEnter)
if played != 1 {
t.Fatalf("Enter played %d", played)
}
r := find(t, tt, "Hand")
tt.ClickAt(r.X+r.W-4, r.Y+r.H/2)
if played != 2 {
t.Fatalf("a click on the last card played %d", played)
}
off, played = true, -1
tt.Key(0, ui.KeyEnter)
if played != -1 {
t.Fatal("a disabled hand played")
}
}
func TestDeckDrawsUntilEmpty(t *testing.T) {
left, drawn := 2, 0
tt := view(300, 300, newWorld(), func(c *ui.Context) {
if tabletop.Deck(c, "Deck", left, tabletop.DeckOptions{}).Drawn {
left--
drawn++
}
})
// The count's badge is the only AccentInk on the pile.
x, y, w, h := dots(find(t, tt, "Deck"))
if count(tt.Image(), x, y, w, h, pixel.AccentInk) == 0 {
t.Fatal("no count badge")
}
click(t, tt, "Deck")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEnter)
click(t, tt, "Deck")
if left != 0 || drawn != 2 {
t.Fatalf("drew %d, %d left", drawn, left)
}
}
func TestDominoAndMahjongRead(t *testing.T) {
tt := view(600, 600, newWorld(), func(c *ui.Context) {
tabletop.Domino(c, 3, 5, tabletop.DominoOptions{Vertical: true})
tabletop.MahjongTile(c, tabletop.Tile{Family: tabletop.Dots, Rank: 3}, tabletop.MahjongTileOptions{})
tabletop.MahjongTile(c, tabletop.Tile{Family: tabletop.Winds, Rank: 1}, tabletop.MahjongTileOptions{})
tabletop.MahjongTile(c, tabletop.Tile{Family: tabletop.Dragons, Rank: 1}, tabletop.MahjongTileOptions{})
tabletop.MahjongTile(c, tabletop.Tile{Family: tabletop.Bamboo, Rank: 2}, tabletop.MahjongTileOptions{Back: true})
})
if _, _, w, h := dots(find(t, tt, "3 | 5")); w != 20 || h != 39 {
t.Fatalf("a standing domino is %d×%d", w, h)
}
for _, label := range []string{"3 DOTS", "EAST WIND", "TILE BACK"} {
find(t, tt, label)
}
x, y, w, h := dots(find(t, tt, "RED DRAGON"))
if count(tt.Image(), x, y, w, h, pixel.Bad) == 0 {
t.Fatal("the red dragon is not red")
}
}
func TestDiceShowPips(t *testing.T) {
for v := 2; v <= 6; v++ {
tt := view(200, 200, newWorld(), func(c *ui.Context) { tabletop.Dice(c, "Die", v, tabletop.DiceOptions{}) })
x, y, w, h := dots(find(t, tt, "Die"))
// Each pip is two dots square, in Ink.
if n := count(tt.Image(), x, y, w, h, pixel.Ink); n != 4*v {
t.Errorf("a %d shows %d Ink dots", v, n)
}
}
}
func TestWhitePiecesLookLight(t *testing.T) {
var board [64]tabletop.Piece
board[1] = tabletop.Piece{Kind: tabletop.Pawn, White: true}
board[3] = tabletop.Piece{Kind: tabletop.Pawn}
tt := view(600, 400, newWorld(), func(c *ui.Context) { tabletop.Chessboard(c, "B", &board, tabletop.ChessboardOptions{}) })
x, y, _, _ := dots(find(t, tt, "CHESSBOARD"))
// On dark squares, a white pawn takes the lighter slot.
white, black := count(tt.Image(), x+16, y, 16, 16, pixel.Lit), count(tt.Image(), x+48, y, 16, 16, pixel.Lit)
if white <= black {
t.Fatalf("the white pawn has %d Lit dots, the black %d", white, black)
}
}
func TestEveryTileAndDominoLooksItsOwn(t *testing.T) {
seen := map[string]tabletop.Tile{}
for f, ranks := range []int{9, 9, 9, 4, 3} {
for r := 1; r <= ranks; r++ {
tile := tabletop.Tile{Family: tabletop.Family(f), Rank: r}
pix := string(view(100, 100, newWorld(), func(c *ui.Context) { tabletop.MahjongTile(c, tile, tabletop.MahjongTileOptions{}) }).Image().Pix)
if other, ok := seen[pix]; ok {
t.Errorf("%+v looks like %+v", tile, other)
}
seen[pix] = tile
}
}
ink := func(a, b int) int {
tt := view(200, 100, newWorld(), func(c *ui.Context) { tabletop.Domino(c, a, b, tabletop.DominoOptions{}) })
x, y, w, h := dots(find(t, tt, fmt.Sprintf("%d | %d", a, b)))
return count(tt.Image(), x, y, w, h, pixel.Ink)
}
// Two dots square a pip, as on dice.
if blank, two, six := ink(0, 0), ink(1, 1), ink(6, 6); blank != 0 || two != 8 || six != 48 {
t.Errorf("0|0, 1|1 and 6|6 show %d, %d and %d Ink dots", blank, two, six)
}
}
+25
View File
@@ -0,0 +1,25 @@
package tabletop
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PlayingCardOptions struct {
// Back shows the card face down.
Back bool
}
// PlayingCard is a card: rank and suit, or its back.
func PlayingCard(c *ui.Context, cd Card, o PlayingCardOptions) *ui.Element {
k := pixel.K(c)
cd.check("PlayingCard")
name := cd.name(k)
if o.Back {
name = k.Msg("card_back")
}
return ui.Box(c).Size(k.D(cardW), k.D(cardH)).Role(ui.RoleImage).Label(name).Draw(func(p *ui.Painter, r ui.Rect) {
drawCard(k.Canvas(p, r), 0, 0, cd, o.Back)
})
}
+61
View File
@@ -0,0 +1,61 @@
package tabletop
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type RouletteOptions struct {
// Cue spins it: each new cue lands it on result.
Cue int
}
const wheel = 28
// Roulette spins its wheel, on a new Cue, to result.
func Roulette(c *ui.Context, text string, segments []string, result int, o RouletteOptions) *ui.Element {
k := pixel.K(c)
n := len(segments)
if n < 2 || n > 64 || result < 0 || result >= n || slices.Contains(segments, "") {
panic(fmt.Sprintf("tabletop: Roulette lands on %d of %d segments, or one is blank", result, n))
}
e := ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).AlignSelf(ui.Start).Role(ui.RoleGroup).Label(text).Description(segments[result])
// A spin: a lap plus twelve, a segment a frame.
total := n + 12
f := k.Shot(c, e, o.Cue, 15, total)
under := ((result-(total-f))%n + n) % n
return e.Children(func() {
ui.Box(c).Size(k.D(2*wheel+1), k.D(2*wheel+6)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
cx, cy, step := wheel, wheel+5, 2*math.Pi/float64(n)
for dy := -wheel; dy <= wheel; dy++ {
for dx := -wheel; dx <= wheel; dx++ {
if dx*dx+dy*dy > wheel*wheel {
continue
}
// Wedges run clockwise from the top; the pointer reads one.
t := math.Mod(math.Atan2(float64(dx), float64(-dy))+step/2+2*math.Pi, 2*math.Pi)
i := (int(t/step)%n + under) % n
ink := []pixel.Slot{pixel.Bad, pixel.Ink}[i%2]
if i == 0 {
ink = pixel.Good
}
g.Dot(cx+dx, cy+dy, ink)
}
}
g.Circle(cx, cy, wheel, pixel.Edge)
g.Disc(cx, cy, 4, pixel.Warn)
g.Circle(cx, cy, 4, pixel.Edge)
// The pointer hangs over the rim at twelve o'clock.
for j := range 4 {
g.HLine(cx-3+j, j, 7-2*j, pixel.Warn)
}
})
pixel.Text(c, segments[under], pixel.TextOptions{})
})
}
+112
View File
@@ -0,0 +1,112 @@
package tabletop_test
import (
"bytes"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestGoBoardShrinksUnderItsCursor(t *testing.T) {
board := make([]tabletop.Stone, 361)
tt := view(600, 600, newWorld(), func(c *ui.Context) { tabletop.GoBoard(c, "Goban", board, tabletop.GoBoardOptions{}) })
board = make([]tabletop.Stone, 81)
tt.Frame()
find(t, tt, "Goban")
}
func TestCourtCardsShowTheirSuit(t *testing.T) {
shot := func(cd tabletop.Card) []byte {
return view(200, 200, newWorld(), func(c *ui.Context) { tabletop.PlayingCard(c, cd, tabletop.PlayingCardOptions{}) }).Image().Pix
}
for _, rank := range []int{11, 12, 13} {
if bytes.Equal(shot(tabletop.Card{Rank: rank, Suit: tabletop.Hearts}), shot(tabletop.Card{Rank: rank, Suit: tabletop.Diamonds})) ||
bytes.Equal(shot(tabletop.Card{Rank: rank, Suit: tabletop.Spades}), shot(tabletop.Card{Rank: rank, Suit: tabletop.Clubs})) {
t.Errorf("rank %d hides its suit", rank)
}
}
}
func TestCardHandHitsTheCardDrawn(t *testing.T) {
cards := []tabletop.Card{{Rank: 2, Suit: tabletop.Clubs}, {Rank: 3, Suit: tabletop.Clubs}}
wd, played := newWorld(), -1
tt := view(400, 300, wd, func(c *ui.Context) {
if i := tabletop.CardHand(c, "Hand", cards, tabletop.CardHandOptions{}).Played; i >= 0 {
played = i
}
})
r := find(t, tt, "Hand")
// Above the cards is felt, not a card.
tt.ClickAt(r.X+44, r.Y+2)
if played != -1 {
t.Fatalf("a click above the cards played %d", played)
}
// Lifted, the first card shows above; clicking there takes it.
tt.Move(r.X+10, r.Y+40)
later(tt, wd, 1e9)
tt.ClickAt(r.X+44, r.Y+8)
if played != 0 {
t.Fatalf("a click on the lifted first card played %d", played)
}
}
func TestDeckAndBingoStayInTheirCells(t *testing.T) {
mustPanic(t, "deck", func() {
view(300, 300, newWorld(), func(c *ui.Context) { tabletop.Deck(c, "D", 1000, tabletop.DeckOptions{}) })
})
var numbers [25]int
for i := range numbers {
numbers[i] = i + 1
}
card := func(l pixel.Locale) []byte {
var m [25]bool
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: l})
tabletop.BingoCard(c, "B", numbers, &m, tabletop.BingoCardOptions{})
}, 400, 400).Image().Pix
}
// FREE is a star, alike in every locale.
if !bytes.Equal(card(pixel.En), card(pixel.Zh)) {
t.Fatal("the FREE cell differs by locale")
}
var m [25]bool
tt := view(400, 400, newWorld(), func(c *ui.Context) { tabletop.BingoCard(c, "B", numbers, &m, tabletop.BingoCardOptions{}) })
x, y, _, _ := dots(find(t, tt, "B"))
if count(tt.Image(), x+36, y+32, 18, 16, pixel.Accent) == 0 {
t.Fatal("no star on FREE")
}
}
func TestMeepleStandsInTheItemSlot(t *testing.T) {
tt := view(200, 200, newWorld(), func(c *ui.Context) { tabletop.Meeple(c, "M", tabletop.MeepleOptions{}) })
x, y, w, h := dots(find(t, tt, "M"))
if w != data.SlotSize || h != data.SlotSize || count(tt.Image(), x, y, w, h, pixel.Well) == 0 {
t.Fatalf("a meeple slot is %d×%d, or not an item slot", w, h)
}
}
func TestHoldsAndRestartsAreLogged(t *testing.T) {
var board [64]tabletop.Piece
board[0] = tabletop.Piece{Kind: tabletop.Rook}
restarts := 0
log := logged(func() {
tt := view(600, 700, newWorld(), func(c *ui.Context) {
tabletop.Chessboard(c, "B", &board, tabletop.ChessboardOptions{})
if tabletop.TicTacToe(c, "Grid", tabletop.TicTacToeOptions{}).Restarted {
restarts++
}
})
cell(t, tt, "CHESSBOARD", 8, 16, 0)
for _, i := range []int{0, 8, 1, 4} {
cell(t, tt, "Grid", 3, 22, i)
}
})
if !strings.Contains(log, "chess hold") || !strings.Contains(log, "tic-tac-toe restart") || restarts != 1 {
t.Fatalf("%d restarts; log:\n%s", restarts, log)
}
}
+49
View File
@@ -0,0 +1,49 @@
package tabletop_test
import (
"bytes"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestChessHoldsAreLoggedAndReported(t *testing.T) {
var board [64]tabletop.Piece
board[0] = tabletop.Piece{Kind: tabletop.Rook}
held := []int{}
log := logged(func() {
tt := view(600, 400, newWorld(), func(c *ui.Context) {
if r := tabletop.Chessboard(c, "B", &board, tabletop.ChessboardOptions{}); r.Changed {
held = append(held, r.Held)
}
})
cell(t, tt, "CHESSBOARD", 8, 16, 0)
cell(t, tt, "CHESSBOARD", 8, 16, 0)
// A move is a move, not a hold put down.
cell(t, tt, "CHESSBOARD", 8, 16, 0)
cell(t, tt, "CHESSBOARD", 8, 16, 8)
})
if len(held) != 3 || held[0] != 0 || held[1] != -1 || held[2] != 0 || strings.Count(log, "chess hold") != 3 {
t.Fatalf("holds %v; log:\n%s", held, log)
}
}
func TestMeepleSlotIsTheItemSlot(t *testing.T) {
got := view(200, 200, newWorld(), func(c *ui.Context) {
tabletop.Meeple(c, "M", tabletop.MeepleOptions{Empty: true})
}).Image().Pix
want := view(200, 200, newWorld(), func(c *ui.Context) {
k := pixel.K(c)
ui.Box(c).Size(k.D(data.SlotSize), k.D(data.SlotSize)).Draw(func(p *ui.Painter, r ui.Rect) {
data.DrawSlot(k.Canvas(p, r), 0, 0, data.Item{}, data.ItemSlotOptions{})
})
}).Image().Pix
if !bytes.Equal(got, want) {
t.Fatal("an empty meeple slot is not data's item slot")
}
}
+39
View File
@@ -0,0 +1,39 @@
package tabletop_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestBingoCardDrawsInsideItsGrid(t *testing.T) {
var numbers [25]int
for i := range numbers {
numbers[i] = i + 1
}
var m [25]bool
tt := view(400, 400, newWorld(), func(c *ui.Context) { tabletop.BingoCard(c, "B", numbers, &m, tabletop.BingoCardOptions{}) })
x, y, w, h := dots(find(t, tt, "B"))
// The letters sit nine dots above the grid.
if n := spill(tt.Image(), x, y-9, w, h+9); n > 0 {
t.Fatalf("the card spills %d dots past its grid", n)
}
}
func TestCardHandPlaysTheTopOfAnOverlap(t *testing.T) {
cards := []tabletop.Card{{Rank: 2, Suit: tabletop.Clubs}, {Rank: 3, Suit: tabletop.Clubs}}
played := -1
tt := view(400, 300, newWorld(), func(c *ui.Context) {
if i := tabletop.CardHand(c, "Hand", cards, tabletop.CardHandOptions{}).Played; i >= 0 {
played = i
}
})
r := find(t, tt, "Hand")
// Dots 18–35 hold both cards; the second is on top.
tt.ClickAt(r.X+2*24, r.Y+2*30)
if played != 1 {
t.Fatalf("a click where the cards overlap played %d", played)
}
}
+76
View File
@@ -0,0 +1,76 @@
package tabletop
import (
"fmt"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ScoreTrackOptions struct {
// Spaces on the track; zero means 30.
Spaces int
}
const space = 20
// ScoreTrack winds a path; player meeples mark their scores.
func ScoreTrack(c *ui.Context, text string, scores []int, o ScoreTrackOptions) *ui.Element {
k := pixel.K(c)
n := pixel.Or("ScoreTrack Spaces", o.Spaces, 30)
// A space prints three digits at most.
if len(scores) == 0 || len(scores) > len(players) || n > 999 {
panic(fmt.Sprintf("tabletop: ScoreTrack for %d players on %d spaces", len(scores), n))
}
said := make([]string, len(scores))
for i, s := range scores {
if s < 0 {
panic(fmt.Sprintf("tabletop: ScoreTrack score %d", s))
}
said[i] = fmt.Sprintf(k.Msg("player_score"), i+1, s)
}
cols := min(n, 10)
rows := (n + cols - 1) / cols
// at places space i; rows snake back and forth.
at := func(i int) (int, int) {
x, y := i%cols, i/cols
if y%2 == 1 {
x = cols - 1 - x
}
return x * space, y * space
}
return ui.Box(c).Size(k.D(cols*space), k.D(rows*space)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, ", ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range n {
x, y := at(i)
fill := pixel.Face
if i%5 == 0 {
fill = pixel.Lit
}
g.Frame(x, y, space, space, pixel.Flat, fill)
g.Micro(x+2, y+2, strconv.Itoa(i), pixel.Faint, 1)
}
// A lone player stands as a meeple; crowds, small pawns.
crowd := map[int]int{}
for _, s := range scores {
crowd[s%n]++
}
seen := map[int]int{}
for i, s := range scores {
x, y := at(s % n)
if crowd[s%n] == 1 {
g.Silhouette(x+3, y+7, icon.Meeple, 0, pixel.Edge, 1)
g.Sprite(x+4, y+7, icon.Meeple, 0, players[i], 1)
continue
}
j := seen[s%n]
seen[s%n]++
g.Rect(x+2+j%3*6, y+8+j/3*6, 5, 5, pixel.Edge)
g.Rect(x+3+j%3*6, y+9+j/3*6, 3, 3, players[i])
}
})
}
+57
View File
@@ -0,0 +1,57 @@
package tabletop_test
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/tabletop"
)
func TestTossesTakeAClockStepBack(t *testing.T) {
wd := newWorld()
cue := 0
tt := view(600, 400, wd, func(c *ui.Context) {
tabletop.Dice(c, "Die", 4, tabletop.DiceOptions{Cue: cue})
tabletop.CoinFlip(c, "Coin", true, tabletop.CoinFlipOptions{Cue: cue})
tabletop.Roulette(c, "Wheel", []string{"A", "B", "C"}, 1, tabletop.RouletteOptions{Cue: cue})
})
cue = 1
tt.Frame()
later(tt, wd, -time.Second)
later(tt, wd, 3*time.Second)
find(t, tt, "HEADS")
}
func TestTicTacToeReportsEachMove(t *testing.T) {
played := []int{}
log := logged(func() {
tt := view(400, 400, newWorld(), func(c *ui.Context) {
if r := tabletop.TicTacToe(c, "Grid", tabletop.TicTacToeOptions{}); r.Played >= 0 {
played = append(played, r.Played)
}
})
cell(t, tt, "Grid", 3, 22, 0)
})
if len(played) != 1 || played[0] != 0 || !strings.Contains(log, "tic-tac-toe move") {
t.Fatalf("played %v; log:\n%s", played, log)
}
}
func TestTooWideNumbersPanic(t *testing.T) {
for name, build := range map[string]func(c *ui.Context){
"track": func(c *ui.Context) { tabletop.ScoreTrack(c, "S", []int{1}, tabletop.ScoreTrackOptions{Spaces: 1000}) },
"bingo": func(c *ui.Context) {
var n [25]int
var m [25]bool
for i := range n {
n[i] = 1000 + i
}
tabletop.BingoCard(c, "B", n, &m, tabletop.BingoCardOptions{})
},
} {
mustPanic(t, name, func() { view(600, 400, newWorld(), build) })
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package tabletop is dice, cards, boards and tokens.
package tabletop
import (
"fmt"
"strconv"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Suit is a playing card's suit.
type Suit int
const (
Spades Suit = iota
Hearts
Diamonds
Clubs
)
var suits = [...]struct {
msg string
sp *pixel.Sprite
}{{"spades", icon.Spade}, {"hearts", icon.Heart}, {"diamonds", icon.Diamond}, {"clubs", icon.Club}}
// Card's Rank runs from ace, 1, to king, 13.
type Card struct {
Rank int
Suit Suit
}
// check fails on a rank or suit no deck has.
func (cd Card) check(name string) {
if cd.Rank < 1 || cd.Rank > 13 || cd.Suit < Spades || cd.Suit > Clubs {
panic(fmt.Sprintf("tabletop: %s card %+v", name, cd))
}
}
// ink is a card's color: hearts and diamonds are red.
func (cd Card) ink() pixel.Slot {
if cd.Suit == Hearts || cd.Suit == Diamonds {
return pixel.Bad
}
return pixel.Ink
}
// index is the printed rank: A, 2–10, J, Q, K.
func (cd Card) index() string {
return [...]string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}[cd.Rank-1]
}
// name reads a card out, as ACE OF SPADES.
func (cd Card) name(k pixel.Kit) string {
rank := strconv.Itoa(cd.Rank)
switch cd.Rank {
case 1:
rank = k.Msg("ace")
case 11, 12, 13:
rank = k.Msg([...]string{"jack", "queen", "king"}[cd.Rank-11])
}
return fmt.Sprintf(k.Msg("card_name"), rank, k.Msg(suits[cd.Suit].msg))
}
const cardW, cardH = 36, 50
// drawCard paints a card at x, y: face or back.
func drawCard(g pixel.Canvas, x, y int, cd Card, back bool) {
g.Frame(x, y, cardW, cardH, pixel.Flat, pixel.Lit)
if back {
g.Rect(x+3, y+3, cardW-6, cardH-6, pixel.Accent)
for j := 0; j < cardH-10; j += 4 {
for i := j / 4 % 2 * 2; i < cardW-10; i += 4 {
g.Rect(x+5+i, y+5+j, 2, 2, pixel.AccentLit)
}
}
return
}
ink, idx := cd.ink(), cd.index()
g.Micro(x+3, y+3, idx, ink, 1)
g.Micro(x+cardW-3-pixel.MicroWidth(idx), y+cardH-8, idx, ink, 1)
// Every card shows its suit in a corner.
g.Sprite(x+cardW-14, y+2, suits[cd.Suit].sp, 0, ink, 1)
// Court cards show their piece, the rest a big pip.
sp := suits[cd.Suit].sp
if cd.Rank > 10 {
sp = [...]*pixel.Sprite{icon.Knight, icon.Queen, icon.King}[cd.Rank-11]
}
g.Sprite(x+(cardW-24)/2, y+(cardH-24)/2+2, sp, 0, ink, 2)
}
// pips places 0–6 pips on a face of side s.
func pips(g pixel.Canvas, x, y, s, n int, ink pixel.Slot) {
if n < 0 || n > 6 {
panic(fmt.Sprintf("tabletop: %d pips", n))
}
spots := [...][][2]int{{}, {{1, 1}}, {{0, 0}, {2, 2}}, {{0, 0}, {1, 1}, {2, 2}}, {{0, 0}, {2, 0}, {0, 2}, {2, 2}},
{{0, 0}, {2, 0}, {1, 1}, {0, 2}, {2, 2}}, {{0, 0}, {2, 0}, {0, 1}, {2, 1}, {0, 2}, {2, 2}}}[n]
d := max(2, s/7)
for _, p := range spots {
g.Rect(x+s*(p[0]+1)/4-d/2, y+s*(p[1]+1)/4-d/2, d, d, ink)
}
}
// shades are Lit and Ink, lighter first: White looks white.
func shades(k pixel.Kit) (light, dark pixel.Slot) {
lum := func(s pixel.Slot) int {
c := k.C(s)
return 299*int(c.R) + 587*int(c.G) + 114*int(c.B)
}
if lum(pixel.Lit) >= lum(pixel.Ink) {
return pixel.Lit, pixel.Ink
}
return pixel.Ink, pixel.Lit
}
// players color tokens, one slot a player.
var players = []pixel.Slot{pixel.Bad, pixel.Info, pixel.Good, pixel.Warn, pixel.Magic, pixel.Ink}
+143
View File
@@ -0,0 +1,143 @@
package tabletop
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TicTacToeOptions struct {
Disabled bool
}
type TicTacToeResult struct {
Element *ui.Element
// Played is the cell you took this frame, else -1.
Played int
// Over is the game ending this frame; Restarted, the CONTINUE.
Over, Restarted bool
// Winner: 1 is you, 2 the computer, 0 a draw.
Winner int
}
type tttKey struct{}
// ttt holds the board (1 X, 2 O) and cursor.
type ttt struct {
board [9]int
cur, winner int
over bool
}
var rows3 = [8][3]int{{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, {0, 4, 8}, {2, 4, 6}}
const tcell = 22
// TicTacToe: you are X, the computer O; Enter goes again.
func TicTacToe(c *ui.Context, text string, o TicTacToeOptions) TicTacToeResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).AlignSelf(ui.Start)
st := ui.Local(col, tttKey{}, func() ttt { return ttt{cur: 4} })
res := TicTacToeResult{Played: -1}
res.Element = col.Children(func() {
shell, base, ps, picked := pixel.Pad(c, text, 3, 9, tcell, tcell, &st.cur, o.Disabled)
switch {
case picked < 0:
case st.over:
// After the end, a pick is the CONTINUE.
*st, res.Restarted = ttt{cur: st.cur}, true
slog.Debug("8bit: tic-tac-toe restart", "label", text)
case st.board[picked] == 0:
st.board[picked], res.Played = 1, picked
reply := -1
if !st.end() {
reply = answer(st.board)
st.board[reply] = 2
st.end()
}
res.Over, res.Winner = st.over, st.winner
slog.Debug("8bit: tic-tac-toe move", "label", text, "x", picked, "o", reply)
}
base.Description([...]string{k.Msg("empty"), "X", "O"}[st.board[st.cur]])
at, shown := st.cur, st.board
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Lit)
for i := 1; i < 3; i++ {
g.Rect(i*tcell-1, 2, 2, 3*tcell-4, pixel.Ink)
g.Rect(2, i*tcell-1, 3*tcell-4, 2, pixel.Ink)
}
for i, m := range shown {
x, y := i%3*tcell, i/3*tcell
if i == at && ps.Focus {
g.Cursor(x+2, y+2, tcell-4, tcell-4)
}
switch m {
case 1:
for d := range 12 {
g.Rect(x+5+d, y+5+d, 2, 2, pixel.Bad)
g.Rect(x+16-d, y+5+d, 2, 2, pixel.Bad)
}
case 2:
g.Circle(x+tcell/2, y+tcell/2, 6, pixel.Info)
g.Circle(x+tcell/2, y+tcell/2, 5, pixel.Info)
}
}
g.Finish(ps)
})
if st.over {
word := [...]string{k.Msg("draw"), k.Msg("you_win"), k.Msg("game_over")}[st.winner]
pixel.Text(c, word, pixel.TextOptions{Slot: [...]pixel.Slot{pixel.Ink, pixel.Good, pixel.Bad}[st.winner]})
pixel.Text(c, k.Msg("continue"), pixel.TextOptions{Slot: pixel.Faint})
}
})
if res.Over {
slog.Debug("8bit: tic-tac-toe over", "label", text, "winner", res.Winner)
}
return res
}
// end checks three in a row, or a full board.
func (t *ttt) end() bool {
for _, l := range rows3 {
if m := t.board[l[0]]; m != 0 && m == t.board[l[1]] && m == t.board[l[2]] {
t.over, t.winner = true, m
return true
}
}
for _, m := range t.board {
if m == 0 {
return false
}
}
t.over, t.winner = true, 0
return true
}
// answer is O's move: win, block, middle, corners, sides.
func answer(b [9]int) int {
for _, who := range []int{2, 1} {
for _, l := range rows3 {
n, open := 0, -1
for _, i := range l {
switch b[i] {
case who:
n++
case 0:
open = i
}
}
if n == 2 && open >= 0 {
return open
}
}
}
for _, i := range []int{4, 0, 2, 6, 8, 1, 3, 5, 7} {
if b[i] == 0 {
return i
}
}
panic("tabletop: TicTacToe answered a full board")
}
+135
View File
@@ -0,0 +1,135 @@
package tabletop_test
import (
"bytes"
"image"
"log/slog"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, inert bool
}
func newWorld() *world {
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert, Reduce: wd.reduce})
build(c)
}, w, h)
return tt
}
// later moves the clock on and builds a frame.
func later(tt *ui.Tester, wd *world, d time.Duration) {
wd.clk.t = wd.clk.t.Add(d)
tt.Frame()
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatalf("%v in %q", err, tt.Texts())
}
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// none is a color outside the palette.
const none pixel.Slot = 255
// at is the slot at dot x, y, or none.
func at(img *image.RGBA, x, y int) pixel.Slot {
c := img.RGBAAt(2*x, 2*y)
for s, want := range pixel.SpringDay.Colors {
if c.R == want.R && c.G == want.G && c.B == want.B {
return pixel.Slot(s)
}
}
return none
}
// count is how many dots in a box hold s.
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n := 0
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if at(img, i, j) == s {
n++
}
}
}
return n
}
// dots is a found box in dots.
func dots(r ui.Rect) (x, y, w, h int) {
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
}
func mustPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
fn()
}
// logged runs fn with the debug log captured.
func logged(fn func()) string {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
fn()
return buf.String()
}
// moves reports whether a view's picture changes over a second.
func moves(tt *ui.Tester, wd *world) bool {
first := append([]byte(nil), tt.Image().Pix...)
for range 15 {
later(tt, wd, time.Second/15)
if !bytes.Equal(first, tt.Image().Pix) {
return true
}
}
return false
}
// spill counts dots drawn outside a box.
func spill(img *image.RGBA, x, y, w, h int) int {
n, b := 0, img.Bounds()
for j := range b.Dy() / 2 {
for i := range b.Dx() / 2 {
if (i < x || i >= x+w || j < y || j >= y+h) && at(img, i, j) != pixel.Back {
n++
}
}
}
return n
}