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:
32 files changed
+2781
-1
No files matched your search
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
@@ -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(`
|
||||
............
|
||||
............
|
||||
.....##.....
|
||||
....####....
|
||||
....####....
|
||||
.....##.....
|
||||
....####....
|
||||
...######...
|
||||
...######...
|
||||
..########..
|
||||
.##########.
|
||||
............`)
|
||||
)
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user