diff --git a/gallery/demos_market.go b/gallery/demos_market.go new file mode 100644 index 0000000..b4ced53 --- /dev/null +++ b/gallery/demos_market.go @@ -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)) + } + }) +} diff --git a/gallery/demos_shop.go b/gallery/demos_shop.go new file mode 100644 index 0000000..ce80ecf --- /dev/null +++ b/gallery/demos_shop.go @@ -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{}) + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index a89b9e7..850799b 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) +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 } diff --git a/gallery/shop_demo_test.go b/gallery/shop_demo_test.go new file mode 100644 index 0000000..b35ea3d --- /dev/null +++ b/gallery/shop_demo_test.go @@ -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]) + } + } + } +} diff --git a/pixel/messages.go b/pixel/messages.go index b92ac43..20507cd 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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": "编号?", }, } diff --git a/shop/bidticker.go b/shop/bidticker.go new file mode 100644 index 0000000..b688650 --- /dev/null +++ b/shop/bidticker.go @@ -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}) + } + }) +} diff --git a/shop/cart.go b/shop/cart.go new file mode 100644 index 0000000..c9e3af0 --- /dev/null +++ b/shop/cart.go @@ -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 +} diff --git a/shop/checkout.go b/shop/checkout.go new file mode 100644 index 0000000..7c2c3fa --- /dev/null +++ b/shop/checkout.go @@ -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 +} diff --git a/shop/coupon.go b/shop/coupon.go new file mode 100644 index 0000000..84459c9 --- /dev/null +++ b/shop/coupon.go @@ -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 +} diff --git a/shop/currencyconvert.go b/shop/currencyconvert.go new file mode 100644 index 0000000..e58dca8 --- /dev/null +++ b/shop/currencyconvert.go @@ -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 +} diff --git a/shop/dailyreward.go b/shop/dailyreward.go new file mode 100644 index 0000000..9808ab9 --- /dev/null +++ b/shop/dailyreward.go @@ -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 +} diff --git a/shop/limits_test.go b/shop/limits_test.go new file mode 100644 index 0000000..d39bc20 --- /dev/null +++ b/shop/limits_test.go @@ -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()) + } +} diff --git a/shop/money_test.go b/shop/money_test.go new file mode 100644 index 0000000..3a92fb5 --- /dev/null +++ b/shop/money_test.go @@ -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) + } +} diff --git a/shop/pricetag.go b/shop/pricetag.go new file mode 100644 index 0000000..03f313a --- /dev/null +++ b/shop/pricetag.go @@ -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) + }) +} diff --git a/shop/pricingtiers.go b/shop/pricingtiers.go new file mode 100644 index 0000000..9998772 --- /dev/null +++ b/shop/pricingtiers.go @@ -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 +} diff --git a/shop/quantity.go b/shop/quantity.go new file mode 100644 index 0000000..0e856dd --- /dev/null +++ b/shop/quantity.go @@ -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 +} diff --git a/shop/receipt.go b/shop/receipt.go new file mode 100644 index 0000000..13eb3be --- /dev/null +++ b/shop/receipt.go @@ -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) + } + } + }) +} diff --git a/shop/salebadge.go b/shop/salebadge.go new file mode 100644 index 0000000..864f7f6 --- /dev/null +++ b/shop/salebadge.go @@ -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) + }) +} diff --git a/shop/shop.go b/shop/shop.go new file mode 100644 index 0000000..8523144 --- /dev/null +++ b/shop/shop.go @@ -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) + }) +} diff --git a/shop/shopitem.go b/shop/shopitem.go new file mode 100644 index 0000000..1ffc290 --- /dev/null +++ b/shop/shopitem.go @@ -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 +} diff --git a/shop/trade_test.go b/shop/trade_test.go new file mode 100644 index 0000000..c706a2e --- /dev/null +++ b/shop/trade_test.go @@ -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) + } + } + } +} diff --git a/shop/tradewindow.go b/shop/tradewindow.go new file mode 100644 index 0000000..096b9ec --- /dev/null +++ b/shop/tradewindow.go @@ -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}) + }) + }) +} diff --git a/shop/vendingmachine.go b/shop/vendingmachine.go new file mode 100644 index 0000000..b96651a --- /dev/null +++ b/shop/vendingmachine.go @@ -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 +} diff --git a/shop/view_test.go b/shop/view_test.go new file mode 100644 index 0000000..a1a30c6 --- /dev/null +++ b/shop/view_test.go @@ -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() +} diff --git a/shop/wallet.go b/shop/wallet.go new file mode 100644 index 0000000..0211eb7 --- /dev/null +++ b/shop/wallet.go @@ -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}) + }) + } + }) +} diff --git a/text/odometer.go b/text/odometer.go index 18c41bc..6351e9b 100644 --- a/text/odometer.go +++ b/text/odometer.go @@ -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. diff --git a/text/odometer_rate_test.go b/text/odometer_rate_test.go new file mode 100644 index 0000000..646c350 --- /dev/null +++ b/text/odometer_rate_test.go @@ -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") + } +} diff --git a/text/text_test.go b/text/text_test.go index e7f9f41..b39dd75 100644 --- a/text/text_test.go +++ b/text/text_test.go @@ -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)