From 81003b21ee2cd52ca81b5fa4b914f3015d06028f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 14:03:58 -0400 Subject: [PATCH] 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. --- gallery/demos_parlor.go | 127 +++++++++++++++++++++ gallery/demos_tabletop.go | 167 +++++++++++++++++++++++++++ gallery/registry.go | 2 +- gallery/tabletop_demo_test.go | 45 ++++++++ icon/games.go | 136 ++++++++++++++++++++++ icon/icon.go | 2 + pixel/messages.go | 18 +++ tabletop/bingocard.go | 123 ++++++++++++++++++++ tabletop/boards_test.go | 192 +++++++++++++++++++++++++++++++ tabletop/cardhand.go | 120 ++++++++++++++++++++ tabletop/chessboard.go | 167 +++++++++++++++++++++++++++ tabletop/coinflip.go | 64 +++++++++++ tabletop/deck.go | 61 ++++++++++ tabletop/dice.go | 48 ++++++++ tabletop/dicetray.go | 39 +++++++ tabletop/domino.go | 39 +++++++ tabletop/extremes_test.go | 87 ++++++++++++++ tabletop/goboard.go | 135 ++++++++++++++++++++++ tabletop/logic_internal_test.go | 37 ++++++ tabletop/mahjongtile.go | 121 ++++++++++++++++++++ tabletop/meeple.go | 43 +++++++ tabletop/pieces_test.go | 194 ++++++++++++++++++++++++++++++++ tabletop/playingcard.go | 25 ++++ tabletop/roulette.go | 61 ++++++++++ tabletop/round1_test.go | 112 ++++++++++++++++++ tabletop/round2_test.go | 49 ++++++++ tabletop/round3_test.go | 39 +++++++ tabletop/scoretrack.go | 76 +++++++++++++ tabletop/selfreview_test.go | 57 ++++++++++ tabletop/tabletop.go | 118 +++++++++++++++++++ tabletop/tictactoe.go | 143 +++++++++++++++++++++++ tabletop/view_test.go | 135 ++++++++++++++++++++++ 32 files changed, 2781 insertions(+), 1 deletion(-) create mode 100644 gallery/demos_parlor.go create mode 100644 gallery/demos_tabletop.go create mode 100644 gallery/tabletop_demo_test.go create mode 100644 icon/games.go create mode 100644 tabletop/bingocard.go create mode 100644 tabletop/boards_test.go create mode 100644 tabletop/cardhand.go create mode 100644 tabletop/chessboard.go create mode 100644 tabletop/coinflip.go create mode 100644 tabletop/deck.go create mode 100644 tabletop/dice.go create mode 100644 tabletop/dicetray.go create mode 100644 tabletop/domino.go create mode 100644 tabletop/extremes_test.go create mode 100644 tabletop/goboard.go create mode 100644 tabletop/logic_internal_test.go create mode 100644 tabletop/mahjongtile.go create mode 100644 tabletop/meeple.go create mode 100644 tabletop/pieces_test.go create mode 100644 tabletop/playingcard.go create mode 100644 tabletop/roulette.go create mode 100644 tabletop/round1_test.go create mode 100644 tabletop/round2_test.go create mode 100644 tabletop/round3_test.go create mode 100644 tabletop/scoretrack.go create mode 100644 tabletop/selfreview_test.go create mode 100644 tabletop/tabletop.go create mode 100644 tabletop/tictactoe.go create mode 100644 tabletop/view_test.go diff --git a/gallery/demos_parlor.go b/gallery/demos_parlor.go new file mode 100644 index 0000000..f4acb61 --- /dev/null +++ b/gallery/demos_parlor.go @@ -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}) + }) +} diff --git a/gallery/demos_tabletop.go b/gallery/demos_tabletop.go new file mode 100644 index 0000000..0e7dde8 --- /dev/null +++ b/gallery/demos_tabletop.go @@ -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}) + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 7f1408f..4dd24e5 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/gallery/tabletop_demo_test.go b/gallery/tabletop_demo_test.go new file mode 100644 index 0000000..d8c32da --- /dev/null +++ b/gallery/tabletop_demo_test.go @@ -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]) + } + } + } +} diff --git a/icon/games.go b/icon/games.go new file mode 100644 index 0000000..f1a3770 --- /dev/null +++ b/icon/games.go @@ -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(` + ............ + ............ + .....##..... + ....####.... + ....####.... + .....##..... + ....####.... + ...######... + ...######... + ..########.. + .##########. + ............`) +) diff --git a/icon/icon.go b/icon/icon.go index c6e7c47..214fab2 100644 --- a/icon/icon.go +++ b/icon/icon.go @@ -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}, } diff --git a/pixel/messages.go b/pixel/messages.go index 62bac06..a4f944f 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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", }, } diff --git a/tabletop/bingocard.go b/tabletop/bingocard.go new file mode 100644 index 0000000..daffbdb --- /dev/null +++ b/tabletop/bingocard.go @@ -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 +} diff --git a/tabletop/boards_test.go b/tabletop/boards_test.go new file mode 100644 index 0000000..d6839a1 --- /dev/null +++ b/tabletop/boards_test.go @@ -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") + } +} diff --git a/tabletop/cardhand.go b/tabletop/cardhand.go new file mode 100644 index 0000000..2da8896 --- /dev/null +++ b/tabletop/cardhand.go @@ -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 +} diff --git a/tabletop/chessboard.go b/tabletop/chessboard.go new file mode 100644 index 0000000..b83689e --- /dev/null +++ b/tabletop/chessboard.go @@ -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)) +} diff --git a/tabletop/coinflip.go b/tabletop/coinflip.go new file mode 100644 index 0000000..526a7cb --- /dev/null +++ b/tabletop/coinflip.go @@ -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)) + } + }) +} diff --git a/tabletop/deck.go b/tabletop/deck.go new file mode 100644 index 0000000..9923217 --- /dev/null +++ b/tabletop/deck.go @@ -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 +} diff --git a/tabletop/dice.go b/tabletop/dice.go new file mode 100644 index 0000000..7725369 --- /dev/null +++ b/tabletop/dice.go @@ -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) + } + }) +} diff --git a/tabletop/dicetray.go b/tabletop/dicetray.go new file mode 100644 index 0000000..d422e45 --- /dev/null +++ b/tabletop/dicetray.go @@ -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{}) + }) +} diff --git a/tabletop/domino.go b/tabletop/domino.go new file mode 100644 index 0000000..c4750c4 --- /dev/null +++ b/tabletop/domino.go @@ -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) + }) +} diff --git a/tabletop/extremes_test.go b/tabletop/extremes_test.go new file mode 100644 index 0000000..015f416 --- /dev/null +++ b/tabletop/extremes_test.go @@ -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) + } + } +} diff --git a/tabletop/goboard.go b/tabletop/goboard.go new file mode 100644 index 0000000..749403b --- /dev/null +++ b/tabletop/goboard.go @@ -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") +} diff --git a/tabletop/logic_internal_test.go b/tabletop/logic_internal_test.go new file mode 100644 index 0000000..3be519f --- /dev/null +++ b/tabletop/logic_internal_test.go @@ -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) + } + } +} diff --git a/tabletop/mahjongtile.go b/tabletop/mahjongtile.go new file mode 100644 index 0000000..f143807 --- /dev/null +++ b/tabletop/mahjongtile.go @@ -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) + } + } +} diff --git a/tabletop/meeple.go b/tabletop/meeple.go new file mode 100644 index 0000000..46d5bd0 --- /dev/null +++ b/tabletop/meeple.go @@ -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) + }) +} diff --git a/tabletop/pieces_test.go b/tabletop/pieces_test.go new file mode 100644 index 0000000..0e29833 --- /dev/null +++ b/tabletop/pieces_test.go @@ -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) + } +} diff --git a/tabletop/playingcard.go b/tabletop/playingcard.go new file mode 100644 index 0000000..692d2eb --- /dev/null +++ b/tabletop/playingcard.go @@ -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) + }) +} diff --git a/tabletop/roulette.go b/tabletop/roulette.go new file mode 100644 index 0000000..4417432 --- /dev/null +++ b/tabletop/roulette.go @@ -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{}) + }) +} diff --git a/tabletop/round1_test.go b/tabletop/round1_test.go new file mode 100644 index 0000000..0f2456f --- /dev/null +++ b/tabletop/round1_test.go @@ -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) + } +} diff --git a/tabletop/round2_test.go b/tabletop/round2_test.go new file mode 100644 index 0000000..901dca6 --- /dev/null +++ b/tabletop/round2_test.go @@ -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") + } +} diff --git a/tabletop/round3_test.go b/tabletop/round3_test.go new file mode 100644 index 0000000..8917058 --- /dev/null +++ b/tabletop/round3_test.go @@ -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) + } +} diff --git a/tabletop/scoretrack.go b/tabletop/scoretrack.go new file mode 100644 index 0000000..822a98f --- /dev/null +++ b/tabletop/scoretrack.go @@ -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]) + } + }) +} diff --git a/tabletop/selfreview_test.go b/tabletop/selfreview_test.go new file mode 100644 index 0000000..1852545 --- /dev/null +++ b/tabletop/selfreview_test.go @@ -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) }) + } +} diff --git a/tabletop/tabletop.go b/tabletop/tabletop.go new file mode 100644 index 0000000..aa0cd62 --- /dev/null +++ b/tabletop/tabletop.go @@ -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} diff --git a/tabletop/tictactoe.go b/tabletop/tictactoe.go new file mode 100644 index 0000000..c214c38 --- /dev/null +++ b/tabletop/tictactoe.go @@ -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") +} diff --git a/tabletop/view_test.go b/tabletop/view_test.go new file mode 100644 index 0000000..a36339c --- /dev/null +++ b/tabletop/view_test.go @@ -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 +}