Add shop components 436-450; Odometer exact at 15 fps

Fifteen shop components: PriceTag, Wallet, ShopItem, Cart, Receipt,
Checkout, CurrencyConvert, SaleBadge, Quantity, PricingTiers, Coupon,
DailyReward, BidTicker, TradeWindow and VendingMachine.

Receipt prints dot by dot with feed holes and a torn edge; Coupon cuts
real notches; SaleBadge is a sixteen-point starburst; VendingMachine
sells A1 to D4 by code and drops the snack into its tray. Sums are
checked against overflow, TradeWindow withdraws both confirms on any
change to an offer, and a disabled Checkout veils its inert content.

text.Odometer now counts digits as text, steps at 15 fps as S5 asks,
draws exact digits at rest and lands rolls that floats would stall.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 11:15:07 -04:00
1 parent c8d058799d
commit 5605d785de
28 files changed
+2420 -9

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
package gallery
import (
"fmt"
"time"
"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/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/shop"
)
var marketEntries = []Entry{
{444, "Quantity", "数量", "shop", "- and + within limits. The side at its limit fades.", demoQuantity},
{445, "PricingTiers", "价格方案", "shop", "Bronze, silver and gold. Gold comes with a hat.", demoPricingTiers},
{446, "Coupon", "优惠券", "shop", "A ticket with notched edges. COPY takes the code.", demoCoupon},
{447, "DailyReward", "每日奖励", "shop", "Seven days, seven chests. Come back daily; today's is ready.", demoDailyReward},
{448, "BidTicker", "竞价", "shop", "The latest bids on top; the highest wears the crown.", demoBidTicker},
{449, "TradeWindow", "交易窗口", "shop", "Two offers. Both must confirm, and any change takes the confirms back.", demoTradeWindow},
{450, "VendingMachine", "自动售货机", "shop", "Type a code like B3 and press OK. Credit first, snacks after.", demoVendingMachine},
}
type marketDemo struct {
qty, tier, credit int
days []bool
mine []data.Item
trade shop.Trade
}
func marketState(c *ui.Context) *marketDemo {
return ui.Local(c.Root(), demoKey("market"), func() marketDemo {
return marketDemo{qty: 1, tier: -1, credit: 100, days: []bool{true, false, true, false, false, false, false}, mine: []data.Item{{Sprite: icon.Sword, Name: "Sword"}}}
})
}
func demoQuantity(c *ui.Context, log func(string)) {
s := marketState(c)
if shop.Quantity(c, "Potions", &s.qty, shop.QuantityOptions{Min: 1, Max: 9}).Changed {
log(fmt.Sprintf("%d potions", s.qty))
}
}
func demoPricingTiers(c *ui.Context, log func(string)) {
s := marketState(c)
if shop.PricingTiers(c, "Plans", [3]shop.Tier{
{Price: 0, Perks: []string{"1 SAVE SLOT", "ADS"}},
{Price: 5, Perks: []string{"3 SAVE SLOTS", "NO ADS"}},
{Price: 9, Perks: []string{"99 SLOTS", "NO ADS", "A HAT"}},
}, &s.tier, shop.PricingTiersOptions{Currency: shop.Gems}).Changed {
log([]string{"bronze", "silver", "gold"}[s.tier])
}
}
func demoCoupon(c *ui.Context, log func(string)) {
if shop.Coupon(c, "10% OFF", "UPUPDOWNDOWN", shop.CouponOptions{}).Copied {
log("code copied")
}
}
func demoDailyReward(c *ui.Context, log func(string)) {
s := marketState(c)
if shop.DailyReward(c, "Rewards", 4, s.days, shop.DailyRewardOptions{}).Claimed {
log("claimed day 4")
}
}
func demoBidTicker(c *ui.Context, log func(string)) {
now := pixel.K(c).Now()
shop.BidTicker(c, "Bids", []shop.Bid{
{Bidder: "Aria", Amount: 120, At: now.Add(-9 * time.Minute)},
{Bidder: "Brom", Amount: 150, At: now.Add(-6 * time.Minute)},
{Bidder: "Cass", Amount: 140, At: now.Add(-40 * time.Second)},
}, shop.BidTickerOptions{})
}
func demoTradeWindow(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := marketState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
r := shop.TradeWindow(c, "Trade", "Brom", s.mine, []data.Item{{Sprite: icon.Gem, Name: "Gem", Count: 3}}, &s.trade, shop.TradeWindowOptions{})
if r.Changed {
log(fmt.Sprintf("you %v, Brom %v", s.trade.Mine, s.trade.Theirs))
}
if r.Traded {
log("traded")
}
ui.Row(c).Gap(k.D(6)).Children(func() {
if input.Button(c, "BROM CONFIRMS", input.ButtonOptions{Size: input.Small}).Clicked {
s.trade.Theirs = true
log("Brom confirms")
}
if input.Button(c, "ADD POTION", input.ButtonOptions{Size: input.Small}).Clicked {
s.mine = append(s.mine, data.Item{Sprite: icon.Potion, Name: "Potion"})
log("you add a potion")
}
})
})
}
// snacks fill the machine: A1 to D4, three of each.
var snacks = func() (out [16]shop.Snack) {
sprites := []*pixel.Sprite{icon.Potion, icon.Gem, icon.Heart, icon.Star, icon.Coin, icon.Flower, icon.Leaf, icon.Drop, icon.Sun, icon.Moon, icon.Cloud, icon.Snowflake, icon.Bolt, icon.Gift, icon.Book, icon.Key}
names := []string{"POTION", "GEM", "HEART", "STAR", "COIN", "FLOWER", "LEAF", "DROP", "SUN", "MOON", "CLOUD", "SNOW", "BOLT", "GIFT", "BOOK", "KEY"}
for i := range out {
out[i] = shop.Snack{Name: names[i], Sprite: sprites[i], Price: 10 + i%4*10, Stock: 3 - i%5/4*3}
}
return out
}()
func demoVendingMachine(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := marketState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
if i := shop.VendingMachine(c, "Machine", snacks, &s.credit, shop.VendingMachineOptions{}).Vended; i >= 0 {
log(fmt.Sprintf("vended %s, credit %d", snacks[i].Name, s.credit))
}
if input.Button(c, "INSERT COIN", input.ButtonOptions{Size: input.Small}).Clicked {
s.credit += 10
log(fmt.Sprintf("credit %d", s.credit))
}
})
}
+121
View File
@@ -0,0 +1,121 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/shop"
)
var shopEntries = []Entry{
{436, "PriceTag", "价签", "shop", "A coin and a number. On sale, the old price gets a line through it.", demoPriceTag},
{437, "Wallet", "钱包", "shop", "Gold and gems. Earn some and watch the digits roll.", demoWallet},
{438, "ShopItem", "商品卡", "shop", "A ware on the shelf: sprite, name, price, BUY. Short of gold, BUY sits out.", demoShopItem},
{439, "Cart", "购物车", "shop", "What you are about to buy, how many, and how much it all costs.", demoCart},
{440, "Receipt", "小票", "shop", "Printed dot by dot, holes down the sides, torn at the bottom.", demoReceipt},
{441, "Checkout", "结账", "shop", "CART, PAY, CONFIRM, and the shopkeeper says THANK YOU!", demoCheckout},
{442, "CurrencyConvert", "兑换", "shop", "A hundred gold buys a gem. Pick how many and CONVERT.", demoCurrencyConvert},
{443, "SaleBadge", "促销角标", "shop", "SALE on a starburst, rim flashing, as shops have always shouted.", demoSaleBadge},
}
type shopDemo struct {
gold, gems, step int
cart []shop.CartLine
}
func shopState(c *ui.Context) *shopDemo {
return ui.Local(c.Root(), demoKey("shop"), func() shopDemo {
return shopDemo{gold: 950, gems: 3, cart: []shop.CartLine{
{Name: "POTION", Price: 30, Qty: 3}, {Name: "ETHER", Price: 45, Qty: 1}, {Name: "PHOENIX FEATHER", Price: 200, Qty: 2},
}}
})
}
func demoPriceTag(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
shop.PriceTag(c, 120, shop.PriceTagOptions{})
shop.PriceTag(c, 75, shop.PriceTagOptions{Was: 150})
shop.PriceTag(c, 3, shop.PriceTagOptions{Currency: shop.Gems})
})
}
func demoWallet(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := shopState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
shop.Wallet(c, s.gold, s.gems, shop.WalletOptions{Digits: 5})
if input.Button(c, "FIND GOLD", input.ButtonOptions{Size: input.Small}).Clicked {
s.gold += 137
log(fmt.Sprintf("gold %d", s.gold))
}
})
}
func demoShopItem(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := shopState(c)
ui.Row(c).Gap(k.D(6)).Children(func() {
for _, w := range []shop.Ware{
{Name: "POTION", Sprite: icon.Potion, Price: 30},
{Name: "SWORD", Sprite: icon.Sword, Price: 3000},
{Name: "SHIELD", Sprite: icon.Shield, Price: 80, Was: 120},
} {
if shop.ShopItem(c, w, shop.ShopItemOptions{Short: s.gold < w.Price}).Bought {
s.gold -= w.Price
log(fmt.Sprintf("bought %s, %d gold left", w.Name, s.gold))
}
}
})
}
func demoCart(c *ui.Context, log func(string)) {
s := shopState(c)
r := shop.Cart(c, "Cart", s.cart, shop.CartOptions{})
if r.Changed {
log("quantity changed")
}
if r.Removed >= 0 {
log("removed " + s.cart[r.Removed].Name)
s.cart = append(s.cart[:r.Removed:r.Removed], s.cart[r.Removed+1:]...)
}
}
func demoReceipt(c *ui.Context, log func(string)) {
shop.Receipt(c, "ITEM SHOP", []shop.ReceiptLine{
{Name: "POTION", Qty: 3, Price: 30}, {Name: "ETHER", Qty: 1, Price: 45}, {Name: "TENT", Qty: 1, Price: 120},
}, shop.ReceiptOptions{Time: time.Date(1997, 1, 31, 18, 42, 0, 0, time.UTC)})
}
func demoCheckout(c *ui.Context, log func(string)) {
s := shopState(c)
r := shop.Checkout(c, "Checkout", &s.step, shop.CheckoutOptions{}, func() {
pixel.Text(c, []string{"3 ITEMS, 375 GOLD", "PAY WITH: GOLD", "DELIVER TO: THE INN"}[s.step], pixel.TextOptions{})
})
if r.Changed {
log(fmt.Sprintf("step %d", s.step+1))
}
if r.Done {
log("order placed")
}
}
func demoCurrencyConvert(c *ui.Context, log func(string)) {
s := shopState(c)
if n := shop.CurrencyConvert(c, "Exchange", &s.gold, &s.gems, shop.CurrencyConvertOptions{}).Converted; n > 0 {
log(fmt.Sprintf("bought %d gems, %d gold left", n, s.gold))
}
}
func demoSaleBadge(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(8)).Children(func() {
shop.SaleBadge(c, 50, shop.SaleBadgeOptions{})
shop.SaleBadge(c, 10, shop.SaleBadgeOptions{})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries)
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)
func Entries() []Entry { return entries }
+36
View File
@@ -0,0 +1,36 @@
package gallery
import (
"strings"
"testing"
)
func TestShopDemosLogTheirActions(t *testing.T) {
// Clicks that only focus a component may log nothing.
quiet := map[string]bool{"Potions": true, "KEYPAD": true}
for _, tc := range []struct {
path string
steps []string
}{
{"/c/437-wallet", []string{"FIND GOLD"}}, {"/c/438-shopitem", []string{"BUY POTION"}},
{"/c/439-cart", []string{"REMOVE ETHER"}}, {"/c/441-checkout", []string{"NEXT ▶"}},
{"/c/442-currencyconvert", []string{"CONVERT"}}, {"/c/444-quantity", []string{"Potions", "key:Right"}},
{"/c/445-pricingtiers", []string{"SILVER, 5 GEMS"}}, {"/c/446-coupon", []string{"COPY"}},
{"/c/447-dailyreward", []string{"CLAIM"}}, {"/c/449-tradewindow", []string{"CONFIRM", "BROM CONFIRMS"}},
{"/c/450-vendingmachine", []string{"INSERT COIN"}},
} {
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 {
click(t, tt, step)
}
if len(a.logs[slug]) <= before && !quiet[step] {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+12
View File
@@ -54,6 +54,12 @@ var messages = map[Locale]map[string]string{
"filters": "FILTERS", "task_filters": "OPEN DONE URGENT", "quest_complete": "QUEST COMPLETE!", "focus": "FOCUS", "rest": "REST",
"ahead": "AHEAD", "behind": "BEHIND", "ideal": "IDEAL", "work_left": "LEFT", "total": "TOTAL", "days_left": "%d DAYS LEFT",
"last_day": "LAST DAY", "over": "OVER", "sprint_points": "%d/%d POINTS", "hours_of": "%s/%s H",
"wallet": "WALLET", "coins": "GOLD", "gems": "GEMS", "was": "WAS %s", "not_enough": "NOT ENOUGH", "checkout_steps": "CART PAY CONFIRM DONE",
"place_order": "PLACE ORDER", "thank_you": "THANK YOU!", "rate": "%d GOLD = 1 GEM", "convert": "CONVERT", "sale": "SALE",
"choose": "CHOOSE", "chosen": "CHOSEN", "claim": "CLAIM", "claimed": "CLAIMED", "missed": "MISSED", "day_n": "DAY %d",
"see_you": "SEE YOU TOMORROW!", "highest": "HIGHEST: %s, %d %s", "you": "YOU", "confirm": "CONFIRM", "confirmed": "CONFIRMED",
"waiting": "WAITING", "traded": "TRADE DONE!", "snacks": "SNACKS", "keypad": "KEYPAD", "sold_out": "SOLD OUT",
"insert_coin": "INSERT COIN", "enjoy": "ENJOY!", "code_prompt": "CODE?",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -106,6 +112,12 @@ var messages = map[Locale]map[string]string{
"filters": "筛选", "task_filters": "未完成 已完成 火急", "quest_complete": "任务完成!", "focus": "专注", "rest": "休息",
"ahead": "超前", "behind": "落后", "ideal": "理想", "work_left": "剩余", "total": "合计", "days_left": "还剩 %d 天",
"last_day": "最后一天", "over": "已结束", "sprint_points": "%d/%d 点", "hours_of": "%s/%s 小时",
"wallet": "钱包", "coins": "金币", "gems": "宝石", "was": "原价 %s", "not_enough": "钱不够", "checkout_steps": "购物车 支付 确认 完成",
"place_order": "下单", "thank_you": "谢谢惠顾!", "rate": "%d 金币 = 1 宝石", "convert": "兑换", "sale": "特价",
"choose": "选择", "chosen": "已选", "claim": "领取", "claimed": "已领", "missed": "错过", "day_n": "第 %d 天",
"see_you": "明天见!", "highest": "最高:%s,%d %s", "you": "你", "confirm": "确认", "confirmed": "已确认",
"waiting": "等待中", "traded": "交易完成!", "snacks": "零食", "keypad": "键盘", "sold_out": "售罄",
"insert_coin": "请投币", "enjoy": "请享用!", "code_prompt": "编号?",
},
}
+69
View File
@@ -0,0 +1,69 @@
package shop
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Bid is an offer by a bidder at a time.
type Bid struct {
Bidder string
Amount int
At time.Time
}
type BidTickerOptions struct {
Currency Currency
// Width is in dots; zero means 240.
Width int
}
// BidTicker lists bids, newest first, crowning the highest.
func BidTicker(c *ui.Context, text string, bids []Bid, o BidTickerOptions) *ui.Element {
k := pixel.K(c)
top := -1
for i, b := range bids {
amount("BidTicker", b.Amount)
if b.Bidder == "" || b.At.IsZero() {
panic(fmt.Sprintf("shop: BidTicker bid %d has no bidder or time", i))
}
// The first to reach the highest keeps the crown.
if top < 0 || b.Amount > bids[top].Amount {
top = i
}
}
list := ui.Column(c).Width(k.D(pixel.Or("BidTicker Width", o.Width, 240))).Gap(k.D(2)).Role(ui.RoleList).Label(text)
if top >= 0 {
_, word := coin(k, o.Currency)
list.Description(fmt.Sprintf(k.Msg("highest"), bids[top].Bidder, bids[top].Amount, word))
}
return list.Children(func() {
for i := len(bids) - 1; i >= 0; i-- {
b, crowned := bids[i], i == top
// New bids come in on top; rows keep keys.
ui.Row(c).Key(i).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
if crowned {
k.Canvas(p, r).Sprite(0, 0, icon.Crown, 0, pixel.Ink, 1)
}
})
ink := pixel.Ink
if crowned {
ink = pixel.Warn
}
pixel.Text(c, b.Bidder, pixel.TextOptions{Wrap: true, MaxLines: 1, Slot: ink}).Grow(1)
money(c, k, o.Currency, b.Amount, ink)
datetime.RelativeTime(c, b.At, datetime.RelativeTimeOptions{})
})
}
if len(bids) == 0 {
pixel.Text(c, k.Msg("empty"), pixel.TextOptions{Slot: pixel.Faint})
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package shop
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// CartLine is a ware in the cart and how many.
type CartLine struct {
// Name is unique in the cart; it keys the row.
Name string
Price int
Qty int
}
type CartOptions struct {
Currency Currency
// Width is in dots; zero means 260.
Width int
Disabled bool
}
type CartResult struct {
Element *ui.Element
Changed bool
// Removed is the line whose ✕ was pressed, else -1.
Removed int
}
// Cart lists lines with quantities and their total.
func Cart(c *ui.Context, text string, lines []CartLine, o CartOptions) CartResult {
k := pixel.K(c)
names, total := map[string]bool{}, 0
for _, l := range lines {
amount("Cart price", l.Price)
// Checked against what is left, so totals cannot overflow.
if l.Name == "" || names[l.Name] || l.Qty < 1 || l.Qty > 99 || l.Qty > 0 && l.Price > (math.MaxInt-total)/l.Qty {
panic(fmt.Sprintf("shop: Cart line %q, twice %v, %d at %d", l.Name, names[l.Name], l.Qty, l.Price))
}
names[l.Name], total = true, total+l.Price*l.Qty
}
res := CartResult{Removed: -1}
res.Element = ui.Column(c).Width(k.D(pixel.Or("Cart Width", o.Width, 260))).Gap(k.D(3)).Role(ui.RoleList).Label(text).Children(func() {
for i := range lines {
l := &lines[i]
ui.Row(c).Key(l.Name).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
pixel.Text(c, l.Name, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1)
// A raise may not carry the total past an int.
most := 99
if l.Price > 0 {
most = min(99, (math.MaxInt-(total-l.Price*l.Qty))/l.Price)
}
res.Changed = Quantity(c, l.Name, &l.Qty, QuantityOptions{Min: 1, Max: most, Disabled: o.Disabled}).Changed || res.Changed
money(c, k, o.Currency, l.Price*l.Qty, pixel.Ink)
if input.IconButton(c, icon.Close, k.Msg("remove")+" "+l.Name, input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
res.Removed = i
}
})
}
if len(lines) == 0 {
pixel.Text(c, k.Msg("empty"), pixel.TextOptions{Slot: pixel.Faint})
}
ui.Row(c).FillWidth().Gap(k.D(4)).Children(func() {
pixel.Text(c, k.Msg("total"), pixel.TextOptions{}).Grow(1)
money(c, k, o.Currency, total, pixel.Accent)
})
})
if res.Removed >= 0 {
slog.Debug("8bit: cart remove", "label", text, "line", res.Removed)
}
return res
}
+69
View File
@@ -0,0 +1,69 @@
package shop
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CheckoutOptions struct {
Disabled bool
}
type CheckoutResult struct {
Element *ui.Element
// Done reports the order placed this frame.
Changed, Done bool
}
// Checkout walks CART, PAY and CONFIRM, then says THANK YOU!
func Checkout(c *ui.Context, text string, step *int, o CheckoutOptions, content func()) CheckoutResult {
k := pixel.K(c)
labels := strings.Fields(k.Msg("checkout_steps"))
last := len(labels) - 1
if *step < 0 || *step > last {
panic(fmt.Sprintf("shop: Checkout step %d of %d", *step, len(labels)))
}
old := *step
var res CheckoutResult
res.Element = ui.Column(c).Gap(k.D(6)).Role(ui.RoleGroup).Label(text).Children(func() {
navigation.Steps(c, labels, *step, navigation.StepsOptions{})
if *step == last {
pixel.Text(c, k.Msg("thank_you"), pixel.TextOptions{Slot: pixel.Good, Scale: 2})
return
}
// A wrapper per step keeps NEXT in place; off, veiled.
in := pixel.Current(c)
in.Inert = in.Inert || o.Disabled
ui.Column(c).Key(*step).Children(func() { pixel.Scope(c, in, content) }).DrawOver(func(p *ui.Painter, r ui.Rect) {
if o.Disabled {
g := k.Canvas(p, r)
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
})
ui.Row(c).Gap(k.D(6)).Children(func() {
if input.Button(c, k.Msg("back"), input.ButtonOptions{Disabled: o.Disabled || *step == 0}).Clicked {
*step--
}
// The last step before THANK YOU! places the order.
next := k.Msg("next_step")
if *step == last-1 {
next = k.Msg("place_order")
}
if input.Button(c, next, input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked {
*step++
}
})
})
res.Changed, res.Done = *step != old, *step == last && old != last
if res.Changed {
slog.Debug("8bit: checkout", "label", text, "step", *step)
}
return res
}
+87
View File
@@ -0,0 +1,87 @@
package shop
import (
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CouponOptions struct {
Disabled bool
}
type CouponResult struct {
Element *ui.Element
Copied bool
}
type couponKey struct{}
// Coupon is a notched ticket: a deal, a code, COPY.
func Coupon(c *ui.Context, title, code string, o CouponOptions) CouponResult {
k := pixel.K(c)
if code == "" || title == "" {
panic("shop: Coupon needs a code and a title")
}
var res CouponResult
row := ui.Row(c).Padding(k.D(6), k.D(14)).Gap(k.D(8)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(title)
res.Element = row.AlignSelf(ui.Start).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Row by row, leaving round notches at both ends.
for y := range g.H {
in := 0
if dy := y - g.H/2; dy*dy < 36 {
in = int(math.Sqrt(float64(36 - dy*dy)))
}
g.Rect(in, y, g.W-2*in, 1, pixel.Face)
g.Dot(in, y, pixel.Edge)
g.Dot(g.W-1-in, y, pixel.Edge)
}
g.HLine(0, 0, g.W, pixel.Edge)
g.HLine(0, g.H-1, g.W, pixel.Edge)
}).Children(func() {
ui.Column(c).Gap(k.D(2)).Children(func() {
pixel.Text(c, title, pixel.TextOptions{Slot: pixel.Bad, Scale: 2})
ui.Box(c).Size(k.D(pixel.Measure(code)+10), k.D(pixel.LineHeight+4)).Role(ui.RoleText).Label(code).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for x := 0; x < g.W; x += 3 {
g.Rect(x, 0, 2, 1, pixel.Edge)
g.Rect(x, g.H-1, 2, 1, pixel.Edge)
}
for y := 0; y < g.H; y += 3 {
g.Rect(0, y, 1, 2, pixel.Edge)
g.Rect(g.W-1, y, 1, 2, pixel.Edge)
}
g.Text(5, 2, code, pixel.Ink, 1)
})
})
// A torn-here line between the deal and the button.
ui.Box(c).Size(k.D(1), k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for y := 0; y < g.H; y += 4 {
g.Rect(0, y, 1, 2, pixel.Shade)
}
})
e := ui.Box(c)
at := ui.Local(e, couponKey{}, func() time.Time { return time.Time{} })
// The button says COPIED! for two seconds.
label := k.Msg("copy")
if k.Since(c, *at, 1, 2) < 2 {
label = k.Msg("copied")
}
e.Children(func() {
if input.Button(c, label, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
c.WriteClipboard(code)
*at, res.Copied = k.Now(), true
// The code is a key; logs never see it.
slog.Debug("8bit: coupon copied", "title", title)
}
})
})
return res
}
+56
View File
@@ -0,0 +1,56 @@
package shop
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CurrencyConvertOptions struct {
// Rate is gold per gem; zero means 100.
Rate int
Disabled bool
}
type CurrencyConvertResult struct {
Element *ui.Element
// Converted is the gems bought this frame.
Converted int
}
type convertKey struct{}
// CurrencyConvert trades gold for gems at a rate.
func CurrencyConvert(c *ui.Context, text string, gold, gems *int, o CurrencyConvertOptions) CurrencyConvertResult {
k := pixel.K(c)
rate := pixel.Or("CurrencyConvert Rate", o.Rate, 100)
amount("CurrencyConvert gold", *gold)
amount("CurrencyConvert gems", *gems)
// No more gems than gold buys or an int holds.
most := min(*gold/rate, math.MaxInt-*gems)
col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
n := ui.Local(col, convertKey{}, func() int { return 1 })
*n = max(1, min(*n, most))
var res CurrencyConvertResult
res.Element = col.Children(func() {
pixel.Text(c, fmt.Sprintf(k.Msg("rate"), rate), pixel.TextOptions{Slot: pixel.Faint})
if most < 1 {
pixel.Text(c, k.Msg("not_enough"), pixel.TextOptions{Slot: pixel.Bad})
} else {
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
Quantity(c, k.Msg("gems"), n, QuantityOptions{Min: 1, Max: most, Disabled: o.Disabled})
money(c, k, Gold, *n*rate, pixel.Ink)
})
}
if input.Button(c, k.Msg("convert"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || most < 1}).Clicked {
*gold, *gems, res.Converted = *gold-*n*rate, *gems+*n, *n
slog.Debug("8bit: convert", "label", text, "gems", *n)
}
})
return res
}
+104
View File
@@ -0,0 +1,104 @@
package shop
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DailyRewardOptions struct {
Disabled bool
}
type DailyRewardResult struct {
Element *ui.Element
Claimed bool
}
// An open chest, its lid up and its gold showing.
var openChest = pixel.MustSprite(`
..eeeeeeee..
.esslsslsse.
.esssssssse.
.eeeeeeeeee.
.eyyyyylyye.
.eylyyyyyye.
.eeeeyyeeee.
.essseyesse.
.esssssssse.
.esssssssse.
.eeeeeeeeee.
............`)
const dayW, dayH = 44, 2*pixel.LineHeight + 28
// DailyReward lines up seven chests; today's can be claimed.
func DailyReward(c *ui.Context, text string, today int, claimed []bool, o DailyRewardOptions) DailyRewardResult {
k := pixel.K(c)
if today < 1 || today > 7 || len(claimed) != 7 {
panic(fmt.Sprintf("shop: DailyReward day %d of %d marks", today, len(claimed)))
}
said := make([]string, 7)
for i, got := range claimed {
state := k.Msg("locked")
switch {
case i >= today && got:
panic(fmt.Sprintf("shop: DailyReward day %d is claimed before it came", i+1))
case got:
state = k.Msg("claimed")
case i < today-1:
state = k.Msg("missed")
case i == today-1:
state = k.Msg("today")
}
said[i] = fmt.Sprintf(k.Msg("day_n"), i+1) + " " + state
}
marks := append([]bool(nil), claimed...)
var res DailyRewardResult
res.Element = ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Description(strings.Join(said, ", ")).Children(func() {
ui.Box(c).Size(k.D(7*dayW+6*4), k.D(dayH)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 7 {
x, day := i*(dayW+4), fmt.Sprintf(k.Msg("day_n"), i+1)
g.Frame(x, 0, dayW, dayH, pixel.Raised, pixel.Face)
g.Text(x+(dayW-pixel.Measure(day))/2, 3, day, pixel.Faint, 1)
cx, cy := x+(dayW-24)/2, pixel.LineHeight+6
switch {
case marks[i]:
g.Sprite(cx, cy, openChest, 0, pixel.Ink, 2)
g.Sprite(x+dayW-14, dayH-14, icon.Check, 0, pixel.Good, 1)
case i == today-1:
// Today's chest glows, ringing on 4 fps steps.
ring := pixel.Accent
if g.Step(4)%2 == 1 {
ring = pixel.AccentLit
}
g.Rect(x, 0, dayW, 2, ring)
g.Rect(x, dayH-2, dayW, 2, ring)
g.Sprite(cx, cy, icon.Chest, 0, pixel.Ink, 2)
default:
// Missed and later days stand in shadow.
g.Silhouette(cx, cy, icon.Chest, 0, pixel.Shade, 2)
if i >= today {
g.Sprite(x+dayW-14, dayH-14, icon.Lock, 0, pixel.Faint, 1)
}
}
}
})
done := claimed[today-1]
if input.Button(c, k.Msg("claim"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || done}).Clicked {
claimed[today-1], res.Claimed = true, true
slog.Debug("8bit: daily reward", "label", text, "day", today)
}
if done {
pixel.Text(c, k.Msg("see_you"), pixel.TextOptions{Slot: pixel.Faint})
}
})
return res
}
+143
View File
@@ -0,0 +1,143 @@
package shop_test
import (
"fmt"
"math"
"strings"
"testing"
"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/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/shop"
)
func TestTradeWindowSeesEveryChangeAndLogsTheTrade(t *testing.T) {
mine := []data.Item{{Sprite: icon.Sword, Name: "A", Count: 1}}
trade := shop.Trade{Mine: true, Theirs: true}
tt := view(700, 400, newWorld(), func(c *ui.Context) {
shop.TradeWindow(c, "Trade", "Brom", mine, nil, &trade, shop.TradeWindowOptions{})
})
// Same name and count, new sprite: a new offer.
trade = shop.Trade{Mine: true, Theirs: true}
mine[0].Sprite = icon.Shield
tt.Frame()
if trade.Mine || trade.Theirs {
t.Fatalf("a swapped sprite kept %+v", trade)
}
trade.Mine = true
tt.Frame()
log := logged(func() {
trade.Theirs = true
tt.Frame()
})
if !strings.Contains(log, "trade") {
t.Fatalf("the partner's confirm finished the trade unlogged:\n%s", log)
}
}
func TestLimitsHoldAtTheEdgeOfInt(t *testing.T) {
lines := []shop.CartLine{{Name: "BIG", Price: math.MaxInt/2 + 1, Qty: 1}}
tt := view(600, 300, newWorld(), func(c *ui.Context) { shop.Cart(c, "Cart", lines, shop.CartOptions{}) })
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if lines[0].Qty != 1 {
t.Fatalf("a raise past int took the cart to %d", lines[0].Qty)
}
n := math.MaxInt
tt = view(600, 200, newWorld(), func(c *ui.Context) {
shop.Quantity(c, "Q", &n, shop.QuantityOptions{Max: math.MaxInt})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if n != math.MaxInt {
t.Fatalf("+ at the top wrapped to %d", n)
}
}
func TestCheckoutTurnsItsContentOff(t *testing.T) {
step, pressed := 0, 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
shop.Checkout(c, "Checkout", &step, shop.CheckoutOptions{Disabled: true}, func() {
if input.Button(c, "EDIT CART", input.ButtonOptions{}).Clicked {
pressed++
}
})
})
click(t, tt, "EDIT CART")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if pressed != 0 {
t.Fatalf("a disabled checkout's content took %d presses", pressed)
}
}
func TestVendingKeysSpeakTheLocale(t *testing.T) {
credit := 0
tt := view(700, 400, newWorld(), func(c *ui.Context) {
shop.VendingMachine(c, "Machine", [16]shop.Snack{}, &credit, shop.VendingMachineOptions{})
})
x, y, _, _ := dots(find(t, tt, "KEYPAD"))
// OK is the last key: a Good check.
if count(tt.Image(), x+22, y+36, 22, 18, pixel.Good) == 0 {
t.Fatal("OK is not drawn as a check")
}
}
func TestCheckoutKeepsFocusAndVeilsWhenOff(t *testing.T) {
step := 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
shop.Checkout(c, "Checkout", &step, shop.CheckoutOptions{}, func() {
// Step two says more, which would move the buttons.
for range step + 1 {
pixel.Text(c, "line", pixel.TextOptions{})
}
})
})
click(t, tt, "NEXT ▶")
tt.Key(0, ui.KeyEnter)
if step != 2 {
t.Fatalf("NEXT, then Enter on it, reached step %d", step)
}
shot := func(off bool) int {
tt := view(600, 300, newWorld(), func(c *ui.Context) {
s := 0
shop.Checkout(c, "Checkout", &s, shop.CheckoutOptions{Disabled: off}, func() {
input.Button(c, "EDIT CART", input.ButtonOptions{})
})
})
x, y, w, h := dots(find(t, tt, "EDIT CART"))
return count(tt.Image(), x, y, w, h, pixel.Face)
}
// Off, the content wears the Face checker veil.
if on, off := shot(false), shot(true); on == off {
t.Fatalf("EDIT CART looks the same off: %d Face dots", on)
}
}
func TestCheckoutStepsStartFresh(t *testing.T) {
step, sel := 0, 0
rows := make([]string, 60)
for i := range rows {
rows[i] = fmt.Sprintf("row %d", i)
}
tt := view(600, 400, newWorld(), func(c *ui.Context) {
shop.Checkout(c, "Checkout", &step, shop.CheckoutOptions{}, func() {
// Each step shows a long list in the same place.
names := func(i int) string { return fmt.Sprint(step, rows[i]) }
data.List(c, "List", len(rows), &sel, data.ListOptions{Height: 60}, names, func(i int) {
pixel.Text(c, names(i), pixel.TextOptions{})
})
})
})
click(t, tt, "List")
tt.Key(0, ui.KeyEnd)
click(t, tt, "NEXT ▶")
// The new step's list starts at its top.
if _, ok := tt.Find("1row 0"); !ok {
t.Fatalf("step two's list kept the old scroll: %q", tt.Texts())
}
}
+167
View File
@@ -0,0 +1,167 @@
package shop_test
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/shop"
)
func TestPriceTagStrikesASale(t *testing.T) {
tt := view(400, 200, newWorld(), func(c *ui.Context) {
ui.Column(c).Children(func() {
shop.PriceTag(c, 120, shop.PriceTagOptions{})
shop.PriceTag(c, 75, shop.PriceTagOptions{Was: 150})
})
})
find(t, tt, "120 GOLD")
x, y, w, _ := dots(find(t, tt, "75 GOLD, WAS 150"))
// The strike runs Bad through the old price's middle.
if count(tt.Image(), x+13, y+8, w-13, 1, pixel.Bad) < 10 {
t.Fatal("the old price is not struck")
}
for name, o := range map[string]shop.PriceTagOptions{"a raise": {Was: 50}, "an unknown currency": {Currency: 2}} {
mustPanic(t, name, func() {
view(400, 200, newWorld(), func(c *ui.Context) { shop.PriceTag(c, 75, o) })
})
}
}
func TestWalletSaysBoth(t *testing.T) {
tt := view(400, 200, newWorld(), func(c *ui.Context) {
shop.Wallet(c, 950, 3, shop.WalletOptions{Digits: 5})
})
find(t, tt, "WALLET")
mustPanic(t, "a negative price", func() {
view(400, 200, newWorld(), func(c *ui.Context) { shop.PriceTag(c, -5, shop.PriceTagOptions{}) })
})
}
func TestShopItemBuysUnlessShort(t *testing.T) {
short, bought := false, 0
tt := view(400, 300, newWorld(), func(c *ui.Context) {
if shop.ShopItem(c, shop.Ware{Name: "POTION", Sprite: icon.Potion, Price: 30}, shop.ShopItemOptions{Short: short}).Bought {
bought++
}
})
click(t, tt, "BUY POTION")
short = true
tt.Frame()
click(t, tt, "BUY POTION")
if bought != 1 || !tt.HasText("NOT ENOUGH") {
t.Fatalf("bought %d times; texts %q", bought, tt.Texts())
}
}
func TestQuantityStepsWithinLimits(t *testing.T) {
n := 2
tt := view(400, 200, newWorld(), func(c *ui.Context) {
shop.Quantity(c, "Potions", &n, shop.QuantityOptions{Min: 1, Max: 3})
})
r := find(t, tt, "Potions")
tt.ClickAt(r.X+2*4, r.Y+r.H/2)
tt.ClickAt(r.X+2*4, r.Y+r.H/2)
if n != 1 {
t.Fatalf("two clicks on - from 2 gave %d", n)
}
// Pointer on +, Enter and Space still do nothing.
tt.Move(r.X+r.W-2*4, r.Y+r.H/2)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight)
if n != 2 {
t.Fatalf("Enter, Space, Right gave %d", n)
}
tt.Key(0, ui.KeyEnd)
if n != 3 {
t.Fatalf("End gave %d", n)
}
tt.Key(0, ui.KeyUp)
tt.ClickAt(r.X+r.W-2*4, r.Y+r.H/2)
if n != 3 {
t.Fatalf("Up and + past the top gave %d", n)
}
mustPanic(t, "a value past Max", func() {
m := 4
view(400, 200, newWorld(), func(c *ui.Context) { shop.Quantity(c, "Q", &m, shop.QuantityOptions{Max: 3}) })
})
}
func TestCartTotalsAndRemoves(t *testing.T) {
lines := []shop.CartLine{{Name: "POTION", Price: 30, Qty: 3}, {Name: "ETHER", Price: 45, Qty: 1}}
removed := -1
tt := view(600, 300, newWorld(), func(c *ui.Context) {
if r := shop.Cart(c, "Cart", lines, shop.CartOptions{}); r.Removed >= 0 {
removed = r.Removed
}
})
find(t, tt, "135 GOLD")
// Tab reaches POTION's quantity first; Right adds one.
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if lines[0].Qty != 4 {
t.Fatalf("Right on POTION's quantity left %+v", lines)
}
find(t, tt, "165 GOLD")
click(t, tt, "REMOVE ETHER")
if removed != 1 {
t.Fatalf("REMOVE ETHER gave %d", removed)
}
for name, bad := range map[string][]shop.CartLine{
"two POTION lines": {{Name: "P", Price: 1, Qty: 1}, {Name: "P", Price: 1, Qty: 1}},
"a total past int": {{Name: "P", Price: 1 << 61, Qty: 8}},
} {
mustPanic(t, name, func() {
view(600, 300, newWorld(), func(c *ui.Context) { shop.Cart(c, "C", bad, shop.CartOptions{}) })
})
}
}
func TestReceiptReadsLinesAndTotal(t *testing.T) {
tt := view(500, 400, newWorld(), func(c *ui.Context) {
shop.Receipt(c, "ITEM SHOP", []shop.ReceiptLine{{Name: "POTION", Qty: 3, Price: 30}, {Name: "TENT", Qty: 1, Price: 120}}, shop.ReceiptOptions{})
})
x, y, w, h := dots(find(t, tt, "ITEM SHOP"))
// Feed holes run down both edges in Shade.
if count(tt.Image(), x+3, y, 2, h, pixel.Shade) < 8 || count(tt.Image(), x+w-5, y, 2, h, pixel.Shade) < 8 {
t.Fatal("no feed holes down the sides")
}
mustPanic(t, "a line of none", func() {
view(500, 400, newWorld(), func(c *ui.Context) {
shop.Receipt(c, "S", []shop.ReceiptLine{{Name: "A", Qty: 0, Price: 1}}, shop.ReceiptOptions{})
})
})
}
func TestSaleBadgeFlashesUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := newWorld()
wd.reduce = reduce
tt := view(300, 200, wd, func(c *ui.Context) { shop.SaleBadge(c, 50, shop.SaleBadgeOptions{}) })
x, y, w, h := dots(find(t, tt, "SALE -50%"))
before := count(tt.Image(), x, y, w, h, pixel.Warn)
later(tt, wd, 1000e6)
after := count(tt.Image(), x, y, w, h, pixel.Warn)
if flashed := before != after; flashed == reduce {
t.Errorf("reduce %v: Warn dots went %d to %d", reduce, before, after)
}
}
mustPanic(t, "100% off", func() {
view(300, 200, newWorld(), func(c *ui.Context) { shop.SaleBadge(c, 100, shop.SaleBadgeOptions{}) })
})
}
func TestCouponCopiesQuietly(t *testing.T) {
var log string
tt := view(600, 200, newWorld(), func(c *ui.Context) {
shop.Coupon(c, "10% OFF", "SECRET42", shop.CouponOptions{})
})
log = logged(func() { click(t, tt, "COPY") })
if tt.Clipboard() != "SECRET42" || !tt.HasText("COPIED!") || strings.Contains(log, "SECRET42") {
t.Fatalf("clipboard %q; log:\n%s", tt.Clipboard(), log)
}
}
+42
View File
@@ -0,0 +1,42 @@
package shop
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PriceTagOptions struct {
// Was is the price before a sale; zero means none.
Was int
Currency Currency
}
// PriceTag is a coin and amount; sales strike the old.
func PriceTag(c *ui.Context, price int, o PriceTagOptions) *ui.Element {
k := pixel.K(c)
sp, word := coin(k, o.Currency)
now, was := amount("PriceTag", price), amount("PriceTag Was", o.Was)
if o.Was != 0 && o.Was <= price {
panic(fmt.Sprintf("shop: PriceTag sale from %d to %d raises it", o.Was, price))
}
say, w, wasW := now+" "+word, 14+pixel.Measure(now), 0
if o.Was > 0 {
say += ", " + fmt.Sprintf(k.Msg("was"), was)
wasW = pixel.Measure(was) + 6
}
return ui.Box(c).Size(k.D(w+wasW), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(say).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 2, sp, 0, pixel.Ink, 1)
ink := pixel.Ink
// On sale the old price is struck, the new red.
if wasW > 0 {
g.Text(14, 0, was, pixel.Faint, 1)
g.HLine(13, pixel.LineHeight/2, wasW-4, pixel.Bad)
ink = pixel.Bad
}
g.Text(14+wasW, 0, now, ink, 1)
})
}
+104
View File
@@ -0,0 +1,104 @@
package shop
import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Tier is a plan: its price and its perks.
type Tier struct {
Price int
Perks []string
}
type PricingTiersOptions struct {
Currency Currency
Disabled bool
}
type PricingTiersResult struct {
Element *ui.Element
Changed bool
}
const tierW = 112
var metals = [3]string{"bronze", "silver", "gold"}
// PricingTiers lays bronze, silver and gold plans to choose.
func PricingTiers(c *ui.Context, text string, tiers [3]Tier, chosen *int, o PricingTiersOptions) PricingTiersResult {
k := pixel.K(c)
if *chosen < -1 || *chosen > 2 {
panic(fmt.Sprintf("shop: PricingTiers choice %d", *chosen))
}
sp, word := coin(k, o.Currency)
most := 0
for _, t := range tiers {
amount("PricingTiers price", t.Price)
for _, p := range t.Perks {
if p == "" {
panic("shop: PricingTiers has an empty perk")
}
}
most = max(most, len(t.Perks))
}
// Band, name, price, perks, then the foot line under them.
h := 8 + pixel.LineHeight + 4 + 2*pixel.LineHeight + 2 + most*pixel.LineHeight + pixel.LineHeight + 7
old := *chosen
var res PricingTiersResult
res.Element = ui.Row(c).Gap(k.D(6)).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(text).Children(func() {
for i, t := range tiers {
name, price := k.Msg(metals[i]), strconv.Itoa(t.Price)
sel := *chosen
// The shell draws the card; the radio stays bare.
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, &sel, i).Label(name + ", " + price + " " + word).Disabled(o.Disabled)
})
// The radio picks before it is told it is off.
if sel != *chosen && !base.IsDisabled() {
*chosen = sel
}
base.Description(strings.Join(t.Perks, ", "))
on := i == *chosen
shell.Size(k.D(tierW), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, tierW, h, pixel.Raised, pixel.Face)
// A band in medal metal; the name sits on Face.
band := []pixel.Slot{pixel.Warn, pixel.Lit, pixel.Warn}[i]
g.Rect(2, 2, tierW-4, 6, band)
if i == 0 {
g.Veil(2, 2, tierW-4, 6, pixel.Bad, 8)
}
g.Text((tierW-pixel.Measure(name))/2, 10, name, pixel.Ink, 1)
y := 10 + pixel.LineHeight + 4
g.Sprite((tierW-pixel.Measure(price)*2-14)/2, y+6, sp, 0, pixel.Ink, 1)
g.Text((tierW-pixel.Measure(price)*2-14)/2+14, y, price, pixel.Ink, 2)
y += 2*pixel.LineHeight + 2
for _, perk := range t.Perks {
g.Text(6, y, "•", pixel.Accent, 1)
g.Text(14, y, pixel.Wrap(perk, tierW-18, 1)[0], pixel.Faint, 1)
y += pixel.LineHeight
}
foot, ink := k.Msg("choose"), pixel.Faint
if on {
foot, ink = k.Msg("chosen"), pixel.Accent
g.Rect(0, 0, tierW, 2, pixel.Accent)
g.Rect(0, h-2, tierW, 2, pixel.Accent)
}
g.Text((tierW-pixel.Measure(foot))/2, h-pixel.LineHeight-3, foot, ink, 1)
g.Finish(st)
})
}
})
res.Changed = *chosen != old
if res.Changed {
slog.Debug("8bit: pricing tier", "label", text, "tier", *chosen)
}
return res
}
+94
View File
@@ -0,0 +1,94 @@
package shop
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 QuantityOptions struct {
// Min is the least; Max the most, zero meaning 99.
Min, Max int
Disabled bool
}
type QuantityResult struct {
Element *ui.Element
Changed bool
}
// Quantity steps − n + within its limits.
func Quantity(c *ui.Context, text string, value *int, o QuantityOptions) QuantityResult {
k := pixel.K(c)
lo, hi := o.Min, pixel.Or("Quantity Max", o.Max, 99)
if lo < 0 || hi < lo || *value < lo || *value > hi {
panic(fmt.Sprintf("shop: Quantity %d outside %d to %d", *value, lo, hi))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleStepper).Label(text).Disabled(o.Disabled)
})
old, nw := *value, pixel.Measure(strconv.Itoa(hi))+6
if !base.IsDisabled() {
step := 0
// Enter and Space are taken, so clicks come from pointers.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) {
step--
}
if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) {
step++
}
if base.Clicked() {
if x, _, _ := base.PointerPosition(); int(x/k.Dot()) < 14 {
step--
} else if int(x/k.Dot()) >= 14+nw {
step++
}
}
// One at a time toward the limit, so nothing overflows.
for ; step > 0 && *value < hi; step-- {
*value++
}
for ; step < 0 && *value > lo; step++ {
*value--
}
if base.Shortcut(0, ui.KeyHome) {
*value = lo
}
if base.Shortcut(0, ui.KeyEnd) {
*value = hi
}
}
n := *value
base.Description(strconv.Itoa(n))
shell.Size(k.D(28+nw), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// A side at its limit fades; it goes no further.
for _, b := range []struct {
x int
sp *pixel.Sprite
on bool
}{{0, icon.Minus, n > lo}, {14 + nw, icon.Plus, n < hi}} {
ink := pixel.Faint
if b.on {
ink = pixel.Ink
}
g.Frame(b.x, 1, 14, 14, pixel.Raised, pixel.Face)
g.Sprite(b.x+1, 2, b.sp, 0, ink, 1)
}
s := strconv.Itoa(n)
g.Text(14+(nw-pixel.Measure(s))/2, 0, s, pixel.Ink, 1)
g.Finish(st)
})
res := QuantityResult{Element: shell, Changed: *value != old}
if res.Changed {
slog.Debug("8bit: quantity", "label", text, "value", *value)
}
return res
}
+102
View File
@@ -0,0 +1,102 @@
package shop
import (
"fmt"
"hash/fnv"
"math"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// ReceiptLine is a bought ware.
type ReceiptLine struct {
Name string
Qty, Price int
}
type ReceiptOptions struct {
// Width is in dots; zero means 180.
Width int
// Time prints under the shop; zero prints none.
Time time.Time
}
// Receipt prints lines dot-matrix style, perforated down the sides.
func Receipt(c *ui.Context, shop string, lines []ReceiptLine, o ReceiptOptions) *ui.Element {
k := pixel.K(c)
if shop == "" {
panic("shop: Receipt has no shop name")
}
w, total := pixel.Or("Receipt Width", o.Width, 180), 0
rows := make([][2]string, len(lines))
for i, l := range lines {
amount("Receipt price", l.Price)
if l.Name == "" || l.Qty < 1 || l.Price > (math.MaxInt-total)/l.Qty {
panic(fmt.Sprintf("shop: Receipt line %q, %d at %d", l.Name, l.Qty, l.Price))
}
total += l.Qty * l.Price
rows[i] = [2]string{fmt.Sprintf("%d×%s", l.Qty, l.Name), strconv.Itoa(l.Qty * l.Price)}
}
sum, thanks := strconv.Itoa(total), k.Msg("thank_you")
said := make([]string, len(rows))
for i, r := range rows {
said[i] = r[0] + " " + r[1]
}
h := pixel.LineHeight*(len(rows)+5) + 24
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(shop).Description(strings.Join(append(said, k.Msg("total")+" "+sum), ", ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Paper, with feed holes punched down both sides.
g.Rect(0, 0, w, h-4, pixel.Lit)
for y := 4; y < h-8; y += 8 {
g.Rect(3, y, 2, 2, pixel.Shade)
g.Rect(w-5, y, 2, 2, pixel.Shade)
}
// A torn edge of teeth along the bottom.
for x := 0; x < w; x += 4 {
g.Rect(x, h-4, 2, 2, pixel.Lit)
g.Rect(x+1, h-2, 1, 2, pixel.Lit)
}
line := func(y int, l, rt string, ink pixel.Slot) {
l = pixel.Wrap(l, w-28-pixel.Measure(rt), 1)[0]
g.Text(10, y, l, ink, 1)
g.Text(w-10-pixel.Measure(rt), y, rt, ink, 1)
// Dot leaders run from the name to the price.
for x := 12 + pixel.Measure(l); x < w-12-pixel.Measure(rt); x += 3 {
g.Dot(x, y+12, pixel.Faint)
}
}
y := 4
g.Text((w-pixel.Measure(shop))/2, y, shop, pixel.Ink, 1)
if !o.Time.IsZero() {
y += pixel.LineHeight
d := o.Time.Format("2006-01-02 15:04")
g.Text((w-pixel.Measure(d))/2, y, d, pixel.Faint, 1)
}
y += pixel.LineHeight + 2
for x := 10; x < w-10; x += 4 {
g.Rect(x, y, 2, 1, pixel.Shade)
}
y += 3
for _, rw := range rows {
line(y, rw[0], rw[1], pixel.Ink)
y += pixel.LineHeight
}
line(y+2, k.Msg("total"), sum, pixel.Ink)
y += pixel.LineHeight + 4
g.Text((w-pixel.Measure(thanks))/2, y, thanks, pixel.Ink, 1)
// A barcode drawn from the total, for show.
hs := fnv.New32a()
hs.Write([]byte(sum))
bits := hs.Sum32()
for i, x := 0, (w-64)/2; i < 32; i, x = i+1, x+2 {
if bits>>i&1 == 1 || i%8 == 0 {
g.Rect(x, y+pixel.LineHeight+2, 1, 10, pixel.Ink)
}
}
})
}
+133
View File
@@ -0,0 +1,133 @@
package shop
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SaleBadgeOptions struct{}
var (
// A sixteen-point starburst and its rim, to flash.
burst = pixel.MustSprite(`
................................................
.......................ee.......................
.......................ee.......................
...............ee.....e##e.....ee...............
...............e#e...e####e...e#e...............
..............e###ee.e####e.ee###e..............
..............e#####e######e#####e..............
.......e......e##################e......e.......
........eeeee.e##################e.eeeee........
........e####e####################e####e........
........e##############################e........
........e##############################e........
........e##############################e........
.........e############################e.........
.....eeee##############################eeee.....
...ee######################################ee...
...e########################################e...
....e######################################e....
.....e####################################e.....
.....e####################################e.....
......e##################################e......
....ee####################################ee....
...e########################################e...
.ee##########################################ee.
.ee##########################################ee.
...e########################################e...
....ee####################################ee....
......e##################################e......
.....e####################################e.....
.....e####################################e.....
....e######################################e....
...e########################################e...
...ee######################################ee...
.....eeee##############################eeee.....
.........e############################e.........
........e##############################e........
........e##############################e........
........e##############################e........
........e####e####################e####e........
........eeeee.e##################e.eeeee........
.......e......e##################e......e.......
..............e#####e######e#####e..............
..............e###ee.e####e.ee###e..............
...............e#e...e####e...e#e...............
...............ee.....e##e.....ee...............
.......................ee.......................
.......................ee.......................
................................................`)
rim = pixel.MustSprite(`
................................................
.......................##.......................
.......................##.......................
...............##.....#..#.....##...............
...............#.#...#....#...#.#...............
..............#...##.#....#.##...#..............
..............#.....#......#.....#..............
.......#......#..................#......#.......
........#####.#..................#.#####........
........#....#....................#....#........
........#..............................#........
........#..............................#........
........#..............................#........
.........#............................#.........
.....####..............................####.....
...##......................................##...
...#........................................#...
....#......................................#....
.....#....................................#.....
.....#....................................#.....
......#..................................#......
....##....................................##....
...#........................................#...
.##..........................................##.
.##..........................................##.
...#........................................#...
....##....................................##....
......#..................................#......
.....#....................................#.....
.....#....................................#.....
....#......................................#....
...#........................................#...
...##......................................##...
.....####..............................####.....
.........#............................#.........
........#..............................#........
........#..............................#........
........#..............................#........
........#....#....................#....#........
........#####.#..................#.#####........
.......#......#..................#......#.......
..............#.....#......#.....#..............
..............#...##.#....#.##...#..............
...............#.#...#....#...#.#...............
...............##.....#..#.....##...............
.......................##.......................
.......................##.......................
................................................`)
)
// SaleBadge is a SALE -n% starburst whose rim flashes.
func SaleBadge(c *ui.Context, percent int, o SaleBadgeOptions) *ui.Element {
k := pixel.K(c)
if percent < 1 || percent > 99 {
panic(fmt.Sprintf("shop: SaleBadge of %d%%", percent))
}
sale, off := k.Msg("sale"), "-"+strconv.Itoa(percent)+"%"
return ui.Box(c).Size(k.D(burst.W), k.D(burst.H)).Role(ui.RoleImage).Label(sale + " " + off).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 0, burst, 0, pixel.Bad, 1)
// Each second the rim turns gold, unless motion is reduced.
if g.Step(4)/4%2 == 1 {
g.Sprite(0, 0, rim, 0, pixel.Warn, 1)
}
g.Text((burst.W-pixel.Measure(sale))/2, 8, sale, pixel.Lit, 1)
g.Text((burst.W-pixel.Measure(off))/2, 24, off, pixel.Lit, 1)
})
}
+50
View File
@@ -0,0 +1,50 @@
// Package shop holds prices, carts, rewards and trading components.
package shop
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Currency is what a price is counted in.
type Currency int
const (
Gold Currency = iota
Gems
)
// coin is a currency's icon and word; unknown ones panic.
func coin(k pixel.Kit, cur Currency) (*pixel.Sprite, string) {
switch cur {
case Gold:
return icon.Coin, k.Msg("coins")
case Gems:
return icon.Gem, k.Msg("gems")
}
panic(fmt.Sprintf("shop: unknown currency %d", cur))
}
// amount checks n is not negative and prints it.
func amount(name string, n int) string {
if n < 0 {
panic(fmt.Sprintf("shop: %s amount %d is negative", name, n))
}
return strconv.Itoa(n)
}
// money draws a currency icon and amount, named for readers.
func money(c *ui.Context, k pixel.Kit, cur Currency, n int, slot pixel.Slot) *ui.Element {
sp, word := coin(k, cur)
s := amount("money", n)
return ui.Box(c).Size(k.D(14+pixel.Measure(s)), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(s + " " + word).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 2, sp, 0, pixel.Warn, 1)
g.Text(14, 0, s, slot, 1)
})
}
+69
View File
@@ -0,0 +1,69 @@
package shop
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Ware is something for sale.
type Ware struct {
Name string
// Sprite is at most 16 dots square.
Sprite *pixel.Sprite
Price int
// Was is the price before a sale; zero means none.
Was int
Currency Currency
}
type ShopItemOptions struct {
// Short says the wallet cannot pay; BUY turns off.
Short bool
Disabled bool
}
type ShopItemResult struct {
Element *ui.Element
Bought bool
}
// ShopItem is a ware's sprite, name, price and BUY.
func ShopItem(c *ui.Context, w Ware, o ShopItemOptions) ShopItemResult {
k := pixel.K(c)
if w.Name == "" || w.Sprite == nil || w.Sprite.W > 16 || w.Sprite.H > 16 {
panic(fmt.Sprintf("shop: ShopItem %q needs a sprite of 16 dots at most", w.Name))
}
var res ShopItemResult
res.Element = layout.Panel(c, layout.PanelOptions{Style: pixel.Raised}, func() {
ui.Column(c).Width(k.D(96)).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(k.D(32), k.D(32)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(16-w.Sprite.W, 16-w.Sprite.H, w.Sprite, 0, pixel.Ink, 2)
})
name := pixel.Wrap(w.Name, 92, 1)[0]
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Role(ui.RoleText).Label(w.Name).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text((g.W-pixel.Measure(name))/2, 0, name, pixel.Ink, 1)
})
PriceTag(c, w.Price, PriceTagOptions{Was: w.Was, Currency: w.Currency})
res.Bought = input.Button(c, k.Msg("buy"), input.ButtonOptions{Variant: input.Primary, Size: input.Small, Disabled: o.Disabled || o.Short, Label: k.Msg("buy") + " " + w.Name}).Clicked
// The line is kept, so shelves of cards stay even.
short := ""
if o.Short {
short = k.Msg("not_enough")
}
ui.Box(c).Size(k.D(pixel.Measure(short)), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(short).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, short, pixel.Bad, 1)
})
})
})
if res.Bought {
slog.Debug("8bit: shop buy", "ware", w.Name)
}
return res
}
+236
View File
@@ -0,0 +1,236 @@
package shop_test
import (
"image"
"testing"
"time"
"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"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/shop"
)
func TestCheckoutEndsInThanks(t *testing.T) {
step, done := 0, 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
if shop.Checkout(c, "Checkout", &step, shop.CheckoutOptions{}, func() {
pixel.Text(c, []string{"cart", "pay", "confirm"}[step], pixel.TextOptions{})
}).Done {
done++
}
})
click(t, tt, "BACK")
click(t, tt, "NEXT ▶")
click(t, tt, "NEXT ▶")
if step != 2 || !tt.HasText("confirm") {
t.Fatalf("BACK at the start, then NEXT twice: step %d", step)
}
click(t, tt, "PLACE ORDER")
if step != 3 || done != 1 || !tt.HasText("THANK YOU!") || tt.HasText("confirm") {
t.Fatalf("PLACE ORDER: step %d, done %d, texts %q", step, done, tt.Texts())
}
mustPanic(t, "a fifth step", func() {
s := 4
view(600, 300, newWorld(), func(c *ui.Context) { shop.Checkout(c, "C", &s, shop.CheckoutOptions{}, func() {}) })
})
}
func TestCurrencyConvertTradesAtTheRate(t *testing.T) {
gold, gems, got := 350, 1, 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
got += shop.CurrencyConvert(c, "Swap", &gold, &gems, shop.CurrencyConvertOptions{}).Converted
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
click(t, tt, "CONVERT")
if gold != 50 || gems != 4 || got != 3 {
t.Fatalf("End, CONVERT left %d gold, %d gems, converted %d", gold, gems, got)
}
if !tt.HasText("NOT ENOUGH") {
t.Fatalf("50 gold buys no gem: %q", tt.Texts())
}
click(t, tt, "CONVERT")
if gold != 50 {
t.Fatal("CONVERT spent gold it did not have")
}
}
func TestPricingTiersChooseAndIgnoreTurnOffKeys(t *testing.T) {
tiers := [3]shop.Tier{{Price: 0, Perks: []string{"ADS"}}, {Price: 5}, {Price: 9, Perks: []string{"A HAT"}}}
for _, inert := range []bool{false, true} {
chosen, off := -1, false
wd := newWorld()
tt := view(600, 300, wd, func(c *ui.Context) {
shop.PricingTiers(c, "Plans", tiers, &chosen, shop.PricingTiersOptions{Disabled: off && !inert})
})
click(t, tt, "SILVER, 5 GOLD")
if chosen != 1 {
t.Fatalf("clicking SILVER chose %d", chosen)
}
// Space, sent while on, lands as the plans turn off.
tt.Key(0, ui.KeyRight)
off, wd.inert = true, inert
tt.Key(0, ui.KeySpace)
if chosen != 1 {
t.Errorf("inert %v: Space as the plans turned off chose %d", inert, chosen)
}
}
mustPanic(t, "a fourth plan", func() {
ch := 3
view(600, 300, newWorld(), func(c *ui.Context) { shop.PricingTiers(c, "P", tiers, &ch, shop.PricingTiersOptions{}) })
})
}
func TestDailyRewardClaimsToday(t *testing.T) {
marks, claims := []bool{true, false, true, false, false, false, false}, 0
tt := view(700, 300, newWorld(), func(c *ui.Context) {
if shop.DailyReward(c, "Daily", 4, marks, shop.DailyRewardOptions{}).Claimed {
claims++
}
})
click(t, tt, "CLAIM")
click(t, tt, "CLAIM")
if !marks[3] || claims != 1 || !tt.HasText("SEE YOU TOMORROW!") {
t.Fatalf("CLAIM: %v, texts %q", marks, tt.Texts())
}
mustPanic(t, "a claim from the future", func() {
future := []bool{false, false, false, false, true, false, false}
view(700, 300, newWorld(), func(c *ui.Context) { shop.DailyReward(c, "D", 4, future, shop.DailyRewardOptions{}) })
})
}
func TestBidTickerCrownsTheFirstHighest(t *testing.T) {
wd := newWorld()
bids := []shop.Bid{{Bidder: "Aria", Amount: 150, At: wd.clk.t}, {Bidder: "Brom", Amount: 150, At: wd.clk.t}, {Bidder: "Cass", Amount: 90, At: wd.clk.t}}
tt := view(600, 300, wd, func(c *ui.Context) { shop.BidTicker(c, "Bids", bids, shop.BidTickerOptions{}) })
img := tt.Image()
// The crown sits left of the first to bid 150.
for name, want := range map[string]bool{"Aria": true, "Brom": false, "Cass": false} {
x, y, _, _ := dots(find(t, tt, name))
if got := count(img, x-16, y, 12, 12, pixel.Warn) > 0; got != want {
t.Errorf("%s crowned %v, want %v", name, got, want)
}
}
}
func TestTradeWindowNeedsBothAndResetsOnChange(t *testing.T) {
mine := []data.Item{{Sprite: icon.Sword, Name: "Sword"}}
var trade shop.Trade
traded := 0
tt := view(700, 400, newWorld(), func(c *ui.Context) {
if shop.TradeWindow(c, "Trade", "Brom", mine, []data.Item{{Sprite: icon.Gem, Name: "Gem", Count: 3}}, &trade, shop.TradeWindowOptions{}).Traded {
traded++
}
})
click(t, tt, "CONFIRM")
trade.Theirs = true
tt.Frame()
tt.Frame()
if traded != 1 || !tt.HasText("TRADE DONE!") {
t.Fatalf("both confirmed: traded %d, texts %q", traded, tt.Texts())
}
// A new item on my side takes both confirms back.
mine = append(mine, data.Item{Sprite: icon.Potion, Name: "Potion"})
tt.Frame()
if trade.Mine || trade.Theirs {
t.Fatalf("a changed offer kept %+v", trade)
}
mustPanic(t, "nine items", func() {
var tr shop.Trade
view(700, 400, newWorld(), func(c *ui.Context) {
shop.TradeWindow(c, "T", "B", make([]data.Item, 9), nil, &tr, shop.TradeWindowOptions{})
})
})
}
func TestVendingMachineVendsByCode(t *testing.T) {
var slots [16]shop.Snack
slots[6] = shop.Snack{Name: "LEAF", Sprite: icon.Leaf, Price: 30, Stock: 1}
slots[7] = shop.Snack{Name: "DROP", Sprite: icon.Drop, Price: 90, Stock: 0}
credit, vended := 100, -1
tt := view(700, 400, newWorld(), func(c *ui.Context) {
if v := shop.VendingMachine(c, "Machine", slots, &credit, shop.VendingMachineOptions{}).Vended; v >= 0 {
vended = v
}
})
// Keys sit four a row: A-D, 1-4, CLR, OK.
r := find(t, tt, "KEYPAD")
key := func(i int) { tt.ClickAt(r.X+2*float32(i%4*22+11), r.Y+2*float32(i/4*18+9)) }
key(1)
key(6)
key(9)
if vended != 6 || credit != 70 || !tt.HasText("ENJOY!") {
t.Fatalf("B3 OK: vended %d, credit %d, texts %q", vended, credit, tt.Texts())
}
key(1)
key(7)
key(9)
if !tt.HasText("SOLD OUT") {
t.Fatalf("B4 has none: %q", tt.Texts())
}
key(9)
if !tt.HasText("CODE?") {
t.Fatalf("OK with no code: %q", tt.Texts())
}
}
// offPalette counts dots no palette color matches.
func offPalette(img *image.RGBA) int {
b := img.Bounds()
return count(img, 0, 0, b.Dx()/2, b.Dy()/2, none)
}
func TestShopStaysOnThePaletteWhenOff(t *testing.T) {
builds := map[string]func(c *ui.Context, off bool){
"item": func(c *ui.Context, off bool) {
shop.ShopItem(c, shop.Ware{Name: "P", Sprite: icon.Potion, Price: 1}, shop.ShopItemOptions{Disabled: off})
},
"quantity": func(c *ui.Context, off bool) {
n := 1
shop.Quantity(c, "Q", &n, shop.QuantityOptions{Disabled: off})
},
"cart": func(c *ui.Context, off bool) {
shop.Cart(c, "C", []shop.CartLine{{Name: "P", Price: 1, Qty: 1}}, shop.CartOptions{Disabled: off})
},
"checkout": func(c *ui.Context, off bool) {
s := 1
shop.Checkout(c, "C", &s, shop.CheckoutOptions{Disabled: off}, func() {})
},
"convert": func(c *ui.Context, off bool) {
g, m := 500, 0
shop.CurrencyConvert(c, "X", &g, &m, shop.CurrencyConvertOptions{Disabled: off})
},
"tiers": func(c *ui.Context, off bool) {
ch := 0
shop.PricingTiers(c, "T", [3]shop.Tier{}, &ch, shop.PricingTiersOptions{Disabled: off})
},
"coupon": func(c *ui.Context, off bool) {
shop.Coupon(c, "OFF", "X", shop.CouponOptions{Disabled: off})
},
"daily": func(c *ui.Context, off bool) {
shop.DailyReward(c, "D", 2, make([]bool, 7), shop.DailyRewardOptions{Disabled: off})
},
"trade": func(c *ui.Context, off bool) {
var tr shop.Trade
shop.TradeWindow(c, "T", "B", nil, nil, &tr, shop.TradeWindowOptions{Disabled: off})
},
"vending": func(c *ui.Context, off bool) {
cr := 0
shop.VendingMachine(c, "V", [16]shop.Snack{}, &cr, shop.VendingMachineOptions{Disabled: off})
},
}
for name, build := range builds {
for _, inert := range []bool{false, true} {
wd := newWorld()
wd.inert = inert
wd.clk.t = wd.clk.t.Add(time.Second)
tt := view(800, 500, wd, func(c *ui.Context) { build(c, !inert) })
if n := offPalette(tt.Image()); n != 0 {
t.Errorf("%s, inert %v: %d dots off the palette", name, inert, n)
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package shop
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Trade is who has confirmed it.
type Trade struct {
Mine, Theirs bool
}
type TradeWindowOptions struct {
Disabled bool
}
type TradeWindowResult struct {
Element *ui.Element
Changed bool
// Traded reports both confirms landing this frame.
Traded bool
}
type tradeKey struct{}
// trade is the offers last seen and if both confirmed.
type trade struct {
mine, theirs []data.Item
both bool
}
const tradeSlots = 8
// TradeWindow shows two offers; both sides must confirm.
func TradeWindow(c *ui.Context, text, partner string, mine, theirs []data.Item, t *Trade, o TradeWindowOptions) TradeWindowResult {
k := pixel.K(c)
if partner == "" || len(mine) > tradeSlots || len(theirs) > tradeSlots {
panic(fmt.Sprintf("shop: TradeWindow with %q, %d and %d items", partner, len(mine), len(theirs)))
}
col := ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text)
st := ui.Local(col, tradeKey{}, func() trade { return trade{mine: slices.Clone(mine), theirs: slices.Clone(theirs)} })
var res TradeWindowResult
// Any change to an offer withdraws both confirms.
if !slices.Equal(mine, st.mine) || !slices.Equal(theirs, st.theirs) {
st.mine, st.theirs = slices.Clone(mine), slices.Clone(theirs)
if t.Mine || t.Theirs {
t.Mine, t.Theirs, res.Changed = false, false, true
slog.Debug("8bit: trade offer changed", "label", text)
}
}
res.Element = col.Children(func() {
ui.Row(c).Gap(k.D(8)).Children(func() {
side(c, k, k.Msg("you"), mine, t.Mine)
side(c, k, partner, theirs, t.Theirs)
})
label, v := k.Msg("confirm"), input.Primary
if t.Mine {
label, v = k.Msg("cancel"), input.Normal
}
if input.Button(c, label, input.ButtonOptions{Variant: v, Disabled: o.Disabled}).Clicked {
t.Mine, res.Changed = !t.Mine, true
slog.Debug("8bit: trade confirm", "label", text, "mine", t.Mine)
}
if t.Mine && t.Theirs {
pixel.Text(c, k.Msg("traded"), pixel.TextOptions{Slot: pixel.Good, Scale: 2})
}
})
// The partner may confirm between frames; compare with the last.
res.Traded = t.Mine && t.Theirs && !st.both
st.both = t.Mine && t.Theirs
if res.Traded {
slog.Debug("8bit: trade done", "label", text)
}
return res
}
// side is one offer: who, eight slots and a stamp.
func side(c *ui.Context, k pixel.Kit, who string, items []data.Item, ok bool) {
said := make([]string, len(items))
for i, it := range items {
said[i] = it.String()
}
layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() {
ui.Column(c).Gap(k.D(3)).Children(func() {
pixel.Text(c, who, pixel.TextOptions{})
w := 4*data.SlotSize + 3*2
ui.Box(c).Size(k.D(w), k.D(2*data.SlotSize+2)).Role(ui.RoleList).Label(who).Description(strings.Join(said, ", ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range tradeSlots {
var it data.Item
if i < len(items) {
it = items[i]
}
data.DrawSlot(g, i%4*(data.SlotSize+2), i/4*(data.SlotSize+2), it, data.ItemSlotOptions{})
}
})
stamp, ink := k.Msg("waiting"), pixel.Faint
if ok {
stamp, ink = k.Msg("confirmed"), pixel.Good
}
pixel.Text(c, stamp, pixel.TextOptions{Slot: ink})
})
})
}
+177
View File
@@ -0,0 +1,177 @@
package shop
import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Snack is a slot in the machine; nameless means empty.
type Snack struct {
Name string
// Sprite is at most 16 dots square.
Sprite *pixel.Sprite
Price int
Stock int
}
type VendingMachineOptions struct {
Disabled bool
}
type VendingMachineResult struct {
Element *ui.Element
// Vended is the slot dropped this frame, else -1.
Vended int
}
type vendKey struct{}
// vend is the code typed, the line shown, the drops.
type vend struct {
code, msg string
cur, drops int
last int
}
var keys = []string{"A", "B", "C", "D", "1", "2", "3", "4", "CLR", "OK"}
const vcell = 30
// VendingMachine sells slots A1 to D4 by code; credit pays.
func VendingMachine(c *ui.Context, text string, slots [16]Snack, credit *int, o VendingMachineOptions) VendingMachineResult {
k := pixel.K(c)
amount("VendingMachine credit", *credit)
said := make([]string, 0, 16)
for i, s := range slots {
if s.Name == "" {
continue
}
amount("VendingMachine price", s.Price)
if s.Sprite == nil || s.Sprite.W > 16 || s.Sprite.H > 16 || s.Stock < 0 {
panic(fmt.Sprintf("shop: VendingMachine slot %d %q needs a small sprite and stock", i, s.Name))
}
said = append(said, code(i)+" "+s.Name+" "+strconv.Itoa(s.Price))
}
row := ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(text)
v := ui.Local(row, vendKey{}, func() vend { return vend{last: -1} })
res := VendingMachineResult{Vended: -1}
res.Element = row.Children(func() {
glass := ui.Box(c).Size(k.D(4*vcell+4), k.D(4*vcell+28)).Role(ui.RoleImage).Label(k.Msg("snacks")).Description(strings.Join(said, ", "))
// The bought snack bounces into the tray in steps.
bounce, last := k.Shot(c, glass, v.drops, 8, 4), v.last
glass.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
for i, s := range slots {
x, y := 2+i%4*vcell, 2+i/4*vcell
g.Micro(x+2, y+2, code(i), pixel.Faint, 1)
if s.Name == "" {
continue
}
g.Sprite(x+(vcell-s.Sprite.W)/2, y+8, s.Sprite, 0, pixel.Ink, 1)
p := strconv.Itoa(s.Price)
g.Micro(x+vcell-2-pixel.MicroWidth(p), y+vcell-7, p, pixel.Warn, 1)
if s.Stock == 0 {
g.Veil(x+1, y+1, vcell-2, vcell-2, pixel.Shade, 8)
}
}
ty := 4*vcell + 6
g.Rect(4, ty, g.W-8, 18, pixel.Shade)
if last >= 0 && slots[last].Sprite != nil {
sp := slots[last].Sprite
dy := []int{-6, 0, -2, 0}[min(bounce, 3)]
g.Sprite((g.W-sp.W)/2, ty+18-sp.H-1+dy, sp, 0, pixel.Ink, 1)
}
})
ui.Column(c).Gap(k.D(4)).Children(func() {
line := v.msg
if line == "" {
line = v.code
}
ui.Box(c).Size(k.D(4*22), k.D(pixel.LineHeight+4)).Role(ui.RoleStatus).Label(line).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.Text(4, 2, pixel.Wrap(line, g.W-8, 1)[0], pixel.Good, 1)
})
shell, base, st, picked := pixel.Pad(c, k.Msg("keypad"), 4, len(keys), 22, 18, &v.cur, o.Disabled)
if picked >= 0 {
res.Vended = press(k, v, keys[picked], slots, credit)
}
say := keys[v.cur]
switch say {
case "CLR":
say = k.Msg("clear")
case "OK":
say = k.Msg("ok")
}
base.Description(say)
cur := v.cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, key := range keys {
x, y := i%4*22, i/4*18
g.Frame(x+1, y+1, 20, 16, pixel.Raised, pixel.Face)
if i == cur && st.Focus {
g.Cursor(x, y, 22, 18)
}
// CLR and OK are a cross and a check.
switch key {
case "CLR":
g.Sprite(x+5, y+3, icon.Close, 0, pixel.Bad, 1)
case "OK":
g.Sprite(x+5, y+3, icon.Check, 0, pixel.Good, 1)
default:
g.Micro(x+(22-pixel.MicroWidth(key))/2, y+7, key, pixel.Ink, 1)
}
}
g.Finish(st)
})
money(c, k, Gold, *credit, pixel.Ink)
})
})
if res.Vended >= 0 {
slog.Debug("8bit: vended", "label", text, "slot", code(res.Vended))
}
return res
}
// code names slot i: A1 to D4.
func code(i int) string {
return string(rune('A'+i/4)) + strconv.Itoa(i%4+1)
}
// press takes a key; OK on a full code vends.
func press(k pixel.Kit, v *vend, key string, slots [16]Snack, credit *int) int {
switch {
case len(key) == 1 && key[0] >= 'A' && key[0] <= 'D':
v.code, v.msg = key, ""
case len(key) == 1 && len(v.code) == 1:
v.code += key
case key == "CLR":
v.code, v.msg = "", ""
case key == "OK" && len(v.code) == 2:
i := int(v.code[0]-'A')*4 + int(v.code[1]-'1')
s := slots[i]
v.code = ""
switch {
case s.Name == "" || s.Stock == 0:
v.msg = k.Msg("sold_out")
case *credit < s.Price:
v.msg = k.Msg("insert_coin")
default:
*credit -= s.Price
v.msg, v.drops, v.last = k.Msg("enjoy"), v.drops+1, i
return i
}
case key == "OK":
v.msg = k.Msg("code_prompt")
}
return -1
}
+110
View File
@@ -0,0 +1,110 @@
package shop_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()
}
+35
View File
@@ -0,0 +1,35 @@
package shop
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type WalletOptions struct {
// Digits pads each balance; zero fits the number.
Digits int
}
// Wallet shows gold and gem balances that roll on change.
func Wallet(c *ui.Context, gold, gems int, o WalletOptions) *ui.Element {
k := pixel.K(c)
g, m := amount("Wallet gold", gold), amount("Wallet gems", gems)
_, gw := coin(k, Gold)
_, mw := coin(k, Gems)
return ui.Column(c).Gap(k.D(2)).Role(ui.RoleGroup).Label(k.Msg("wallet")).Description(g + " " + gw + ", " + m + " " + mw).Children(func() {
for _, row := range []struct {
cur Currency
n int
}{{Gold, gold}, {Gems, gems}} {
sp, _ := coin(k, row.cur)
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, sp, 0, pixel.Ink, 1)
})
text.Odometer(c, row.n, text.OdometerOptions{Digits: o.Digits})
})
}
})
}
+15 -7
View File
@@ -31,8 +31,9 @@ func Odometer(c *ui.Context, value int, o OdometerOptions) *ui.Element {
panic(fmt.Sprintf("text: Odometer value %d is negative", value))
}
k := pixel.K(c)
// Counted as text, so the largest int needs no float.
digits := pixel.Or("Odometer Digits", o.Digits, len(strconv.Itoa(value)))
if value >= int(math.Pow10(digits)) {
if len(strconv.Itoa(value)) > digits {
panic(fmt.Sprintf("text: Odometer value %d needs more than %d digits", value, digits))
}
h := pixel.LineHeight + 2
@@ -43,20 +44,25 @@ func Odometer(c *ui.Context, value int, o OdometerOptions) *ui.Element {
case k.Reduce() || st.shown == target:
st.shown, st.last = target, -1
default:
tick := k.Tick(c, 30)
// S5 caps steps at 15 fps; two rolls a step.
tick := k.Tick(c, 15)
if st.last >= 0 {
for range tick - st.last {
for range 2 * (tick - st.last) {
st.shown = roll(st.shown, target)
}
}
st.last = tick
}
shown := st.shown
// At rest digits come from the int; floats would round.
shown, settled, exact := st.shown, st.last < 0, fmt.Sprintf("%0*d", digits, value)
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range digits {
x := (digits - 1 - i) * odoCell
d, frac := wheel(shown, i)
d, frac := int(exact[digits-1-i]-'0'), 0.0
if !settled {
d, frac = wheel(shown, i)
}
g.Frame(x, 0, odoCell-1, h, pixel.Sunken, pixel.Well)
g.Clip(x+2, 2, odoCell-5, h-4, func() {
y := 1 - int(frac*float64(h))
@@ -71,10 +77,12 @@ func Odometer(c *ui.Context, value int, o OdometerOptions) *ui.Element {
func roll(shown, target float64) float64 {
diff := target - shown
stride := max(0.25, math.Abs(diff)/6)
if math.Abs(diff) <= stride {
// Near 2^53 a step may not move; then land.
next := shown + math.Copysign(stride, diff)
if math.Abs(diff) <= stride || next == shown {
return target
}
return shown + math.Copysign(stride, diff)
return next
}
// wheel is digit i of shown and its roll.
+69
View File
@@ -0,0 +1,69 @@
package text_test
import (
"bytes"
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
func TestOdometerHoldsMaxIntAndRollsWithinS5(t *testing.T) {
clk := newClock()
view(400, 100, clk, func(c *ui.Context) { text.Odometer(c, math.MaxInt, text.OdometerOptions{}) })
value := 0
tt := view(400, 100, clk, func(c *ui.Context) { text.Odometer(c, value, text.OdometerOptions{Digits: 3}) })
value = 999
tt.Frame()
// S5: a second of rolling changes at most 15 times.
changes, last := 0, append([]byte(nil), tt.Image().Pix...)
for range 30 {
clk.t = clk.t.Add(time.Second / 30)
tt.Frame()
if !bytes.Equal(last, tt.Image().Pix) {
changes++
last = append(last[:0], tt.Image().Pix...)
}
}
if changes > 15 || changes == 0 {
t.Fatalf("the odometer changed %d times in a second", changes)
}
}
func TestOdometerShowsBigIntsExactly(t *testing.T) {
clk := newClock()
cell := func(v, digits int) []byte {
tt := view(400, 100, clk, func(c *ui.Context) { text.Odometer(c, v, text.OdometerOptions{Digits: digits}) })
img := tt.Image()
// The last digit's 10-dot cell, at two pixels a dot.
var out []byte
for y := 0; y < 2*18; y++ {
x0 := 2 * 10 * (digits - 1)
out = append(out, img.Pix[y*img.Stride+4*x0:y*img.Stride+4*(x0+20)]...)
}
return out
}
// 2^53+1 ends in 3; a float rounds it away.
if !bytes.Equal(cell(9007199254740993, 16), cell(3, 1)) {
t.Fatal("9007199254740993 does not end in a drawn 3")
}
}
func TestOdometerRollsHomeOnBigInts(t *testing.T) {
clk := newClock()
value := 0
tt := view(400, 100, clk, func(c *ui.Context) { text.Odometer(c, value, text.OdometerOptions{Digits: 16}) })
value = 9007199254740993
for range 400 {
clk.add(time.Second / 15)
tt.Frame()
}
// Floats stall short of 2^53+1; the roll must still land.
ref := view(400, 100, clk, func(c *ui.Context) { text.Odometer(c, 9007199254740993, text.OdometerOptions{Digits: 16}) })
if !bytes.Equal(tt.Image().Pix, ref.Image().Pix) {
t.Fatal("the roll never landed on 9007199254740993")
}
}
+2 -1
View File
@@ -337,7 +337,8 @@ func TestOdometerRollsToValue(t *testing.T) {
five := digits()
value = 6
tt.Frame()
clk.add(34 * time.Millisecond)
// One step at 15 fps, S5's fastest.
clk.add(67 * time.Millisecond)
tt.Frame()
mid := digits()
clk.add(time.Second)