From 5605d785de154742c913b40b999cc122b2b6ff0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 11:15:07 -0400 Subject: [PATCH] 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. --- gallery/demos_market.go | 125 ++++++++++++++++++++ gallery/demos_shop.go | 121 +++++++++++++++++++ gallery/registry.go | 2 +- gallery/shop_demo_test.go | 36 ++++++ pixel/messages.go | 12 ++ shop/bidticker.go | 69 +++++++++++ shop/cart.go | 79 +++++++++++++ shop/checkout.go | 69 +++++++++++ shop/coupon.go | 87 ++++++++++++++ shop/currencyconvert.go | 56 +++++++++ shop/dailyreward.go | 104 ++++++++++++++++ shop/limits_test.go | 143 ++++++++++++++++++++++ shop/money_test.go | 167 ++++++++++++++++++++++++++ shop/pricetag.go | 42 +++++++ shop/pricingtiers.go | 104 ++++++++++++++++ shop/quantity.go | 94 +++++++++++++++ shop/receipt.go | 102 ++++++++++++++++ shop/salebadge.go | 133 +++++++++++++++++++++ shop/shop.go | 50 ++++++++ shop/shopitem.go | 69 +++++++++++ shop/trade_test.go | 236 +++++++++++++++++++++++++++++++++++++ shop/tradewindow.go | 113 ++++++++++++++++++ shop/vendingmachine.go | 177 ++++++++++++++++++++++++++++ shop/view_test.go | 110 +++++++++++++++++ shop/wallet.go | 35 ++++++ text/odometer.go | 22 ++-- text/odometer_rate_test.go | 69 +++++++++++ text/text_test.go | 3 +- 28 files changed, 2420 insertions(+), 9 deletions(-) create mode 100644 gallery/demos_market.go create mode 100644 gallery/demos_shop.go create mode 100644 gallery/shop_demo_test.go create mode 100644 shop/bidticker.go create mode 100644 shop/cart.go create mode 100644 shop/checkout.go create mode 100644 shop/coupon.go create mode 100644 shop/currencyconvert.go create mode 100644 shop/dailyreward.go create mode 100644 shop/limits_test.go create mode 100644 shop/money_test.go create mode 100644 shop/pricetag.go create mode 100644 shop/pricingtiers.go create mode 100644 shop/quantity.go create mode 100644 shop/receipt.go create mode 100644 shop/salebadge.go create mode 100644 shop/shop.go create mode 100644 shop/shopitem.go create mode 100644 shop/trade_test.go create mode 100644 shop/tradewindow.go create mode 100644 shop/vendingmachine.go create mode 100644 shop/view_test.go create mode 100644 shop/wallet.go create mode 100644 text/odometer_rate_test.go 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)