From f48075230f6a5c5d72e4379ba08788227fa99263 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 01:53:20 -0400 Subject: [PATCH] Add game components 246-268; data.DrawSlot, Canvas.TextVeil Health, mana and experience bars, quarter hearts, lives, a spinning coin counter, score board, stage timer, minimap with fog, compass, hotbar, paper-doll equipment, skill tree, skill buttons with cooldown wipes, status effects, a boss bar, damage numbers, quest log and tracker, party frames, character sheet, level-up screen and shop. data exports DrawSlot so every slot looks alike; pixel adds TextVeil for dissolving text. Messages the font cannot draw are fixed and TestMessagesHaveGlyphs holds every message to the font. --- AGENTS.md | 3 +- data/inventory.go | 8 +- data/itemslot.go | 42 +++--- gallery/demos_game.go | 276 ++++++++++++++++++++++++++++++++++++++ gallery/demos_rpg.go | 229 +++++++++++++++++++++++++++++++ gallery/game_demo_test.go | 44 ++++++ gallery/registry.go | 2 +- game/bossbar.go | 43 ++++++ game/charactersheet.go | 63 +++++++++ game/coincounter.go | 29 ++++ game/compass.go | 78 +++++++++++ game/controls_test.go | 208 ++++++++++++++++++++++++++++ game/damagenumbers.go | 88 ++++++++++++ game/equipment.go | 112 ++++++++++++++++ game/expbar.go | 57 ++++++++ game/game.go | 106 +++++++++++++++ game/game_test.go | 105 +++++++++++++++ game/healthbar.go | 81 +++++++++++ game/hearts.go | 43 ++++++ game/hotbar.go | 81 +++++++++++ game/hud_test.go | 232 ++++++++++++++++++++++++++++++++ game/levelupscreen.go | 83 ++++++++++++ game/lives.go | 33 +++++ game/manabar.go | 32 +++++ game/map_test.go | 179 ++++++++++++++++++++++++ game/minimap.go | 81 +++++++++++ game/partyframes.go | 54 ++++++++ game/quest.go | 47 +++++++ game/questlog.go | 68 ++++++++++ game/questtracker.go | 42 ++++++ game/scoreboard.go | 45 +++++++ game/screens_test.go | 136 +++++++++++++++++++ game/shop.go | 79 +++++++++++ game/skillbutton.go | 66 +++++++++ game/skilltree.go | 181 +++++++++++++++++++++++++ game/sprites.go | 72 ++++++++++ game/statuseffects.go | 54 ++++++++ game/timerhud.go | 39 ++++++ pixel/canvas_test.go | 23 ++++ pixel/dither.go | 18 +++ pixel/messages.go | 12 +- pixel/messages_test.go | 12 ++ 42 files changed, 3260 insertions(+), 26 deletions(-) create mode 100644 gallery/demos_game.go create mode 100644 gallery/demos_rpg.go create mode 100644 gallery/game_demo_test.go create mode 100644 game/bossbar.go create mode 100644 game/charactersheet.go create mode 100644 game/coincounter.go create mode 100644 game/compass.go create mode 100644 game/controls_test.go create mode 100644 game/damagenumbers.go create mode 100644 game/equipment.go create mode 100644 game/expbar.go create mode 100644 game/game.go create mode 100644 game/game_test.go create mode 100644 game/healthbar.go create mode 100644 game/hearts.go create mode 100644 game/hotbar.go create mode 100644 game/hud_test.go create mode 100644 game/levelupscreen.go create mode 100644 game/lives.go create mode 100644 game/manabar.go create mode 100644 game/map_test.go create mode 100644 game/minimap.go create mode 100644 game/partyframes.go create mode 100644 game/quest.go create mode 100644 game/questlog.go create mode 100644 game/questtracker.go create mode 100644 game/scoreboard.go create mode 100644 game/screens_test.go create mode 100644 game/shop.go create mode 100644 game/skillbutton.go create mode 100644 game/skilltree.go create mode 100644 game/sprites.go create mode 100644 game/statuseffects.go create mode 100644 game/timerhud.go diff --git a/AGENTS.md b/AGENTS.md index 8872b08..4d24a0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child. - `List` row builders run during layout: wrap them in `pixel.Lazy(c, row)`. A click met there lands after the build; keep it in `Local` state, invalidate, and report it next frame. - Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field. -- Copy shown by a component comes from `k.Msg`; business copy comes from the caller. +- Copy shown by a component comes from `k.Msg`; business copy comes from the caller. Ark Pixel 12px lacks some hanzi: `TestMessagesHaveGlyphs` holds every message to the font. +- Item slots look alike everywhere: draw them with `data.DrawSlot` or `data.ItemSlot`. - Fail fast: panic on programmer errors such as bad options. Never swallow an error. - Composites log state changes: `slog.Debug("8bit: ", …)`. Never log secrets. - No cross-frame `*ui.Element` caching. No goroutines touching elements. diff --git a/data/inventory.go b/data/inventory.go index 48f1893..02ea471 100644 --- a/data/inventory.go +++ b/data/inventory.go @@ -38,12 +38,12 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent panic(fmt.Sprintf("data: Inventory slot %d of %d", *selected, len(items))) } old := *selected - shell, base, st, _ := pixel.Pad(c, text, cols, len(items), slotSize, slotSize, selected, o.Disabled) + shell, base, st, _ := pixel.Pad(c, text, cols, len(items), SlotSize, SlotSize, selected, o.Disabled) b := ui.Local(base, bagKey{}, func() bag { return bag{} }) res := InventoryResult{Element: shell, Changed: *selected != old} px, py, _ := base.PointerPosition() at := func() int { - col, row := int(px/k.Dot())/slotSize, int(py/k.Dot())/slotSize + col, row := int(px/k.Dot())/SlotSize, int(py/k.Dot())/SlotSize if px < 0 || py < 0 || col >= cols || row*cols+col >= len(items) { return -1 } @@ -67,7 +67,7 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent default: b.dead = false } - base.Description(itemName(items[*selected])) + base.Description(items[*selected].String()) drag := -1 if b.held && b.over != b.from { drag = b.from @@ -79,7 +79,7 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent if i == drag { it = Item{} } - slot(g, i%cols*slotSize, i/cols*slotSize, it, 0, i == *selected) + DrawSlot(g, i%cols*SlotSize, i/cols*SlotSize, it, ItemSlotOptions{Selected: i == *selected}) } // The dragged item rides with the pointer. if drag >= 0 && shown[drag].Sprite != nil { diff --git a/data/itemslot.go b/data/itemslot.go index 95ccbce..e0679e8 100644 --- a/data/itemslot.go +++ b/data/itemslot.go @@ -28,43 +28,49 @@ type ItemSlotOptions struct { // ItemSlot shows an item, count, rarity edge and cooldown. func ItemSlot(c *ui.Context, it Item, o ItemSlotOptions) *ui.Element { k := pixel.K(c) - if !(o.Cooldown >= 0 && o.Cooldown <= 1) { - panic(fmt.Sprintf("data: Cooldown %v outside 0–1", o.Cooldown)) - } - return ui.Box(c).Role(ui.RoleImage).Label(itemName(it)).Size(k.D(slotSize), k.D(slotSize)).Draw(func(p *ui.Painter, r ui.Rect) { - slot(k.Canvas(p, r), 0, 0, it, o.Cooldown, o.Selected) + o.check() + return ui.Box(c).Role(ui.RoleImage).Label(it.String()).Size(k.D(SlotSize), k.D(SlotSize)).Draw(func(p *ui.Painter, r ui.Rect) { + DrawSlot(k.Canvas(p, r), 0, 0, it, o) }) } -const slotSize = 22 +func (o ItemSlotOptions) check() { + if !(o.Cooldown >= 0 && o.Cooldown <= 1) { + panic(fmt.Sprintf("data: Cooldown %v outside 0–1", o.Cooldown)) + } +} -// itemName reads an item with its count. -func itemName(it Item) string { +// SlotSize is a slot's side in dots. +const SlotSize = 22 + +// String reads an item with its count: "Potion ×3". +func (it Item) String() string { if it.Count > 1 { return it.Name + " ×" + strconv.Itoa(it.Count) } return it.Name } -// slot draws a slot with its top-left at x, y. -func slot(g pixel.Canvas, x, y int, it Item, cool float64, chosen bool) { +// DrawSlot paints a slot, top left at x, y. +func DrawSlot(g pixel.Canvas, x, y int, it Item, o ItemSlotOptions) { + o.check() edge := pixel.Edge if it.Sprite != nil { edge = it.Rarity.Slot() } - if chosen { - g.Rect(x, y, slotSize, slotSize, pixel.Accent) + if o.Selected { + g.Rect(x, y, SlotSize, SlotSize, pixel.Accent) } - g.Rect(x+1, y+1, slotSize-2, slotSize-2, edge) - g.Rect(x+2, y+2, slotSize-4, slotSize-4, pixel.Well) + g.Rect(x+1, y+1, SlotSize-2, SlotSize-2, edge) + g.Rect(x+2, y+2, SlotSize-4, SlotSize-4, pixel.Well) if sp := it.Sprite; sp != nil { - g.Sprite(x+2+(slotSize-4-sp.W)/2, y+2+(slotSize-4-sp.H)/2, sp, 0, pixel.Ink, 1) + g.Sprite(x+2+(SlotSize-4-sp.W)/2, y+2+(SlotSize-4-sp.H)/2, sp, 0, pixel.Ink, 1) } - if h := int(cool * (slotSize - 4)); h > 0 { - g.Veil(x+2, y+2, slotSize-4, h, pixel.Shade, 10) + if h := int(o.Cooldown * (SlotSize - 4)); h > 0 { + g.Veil(x+2, y+2, SlotSize-4, h, pixel.Shade, 10) } if it.Count > 1 { n := strconv.Itoa(it.Count) - g.Micro(x+slotSize-2-pixel.MicroWidth(n), y+slotSize-8, n, pixel.Ink, 1) + g.Micro(x+SlotSize-2-pixel.MicroWidth(n), y+SlotSize-8, n, pixel.Ink, 1) } } diff --git a/gallery/demos_game.go b/gallery/demos_game.go new file mode 100644 index 0000000..3f1c265 --- /dev/null +++ b/gallery/demos_game.go @@ -0,0 +1,276 @@ +package gallery + +import ( + "fmt" + "math/rand/v2" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var gameEntries = []Entry{ + {246, "HealthBar", "生命条", "game", "HIT and HEAL. The pink trail lags; under a quarter the bar blinks.", demoHealthBar}, + {247, "ManaBar", "魔力条", "game", "A spell costs 12 MP. Ticks mark every 10.", demoManaBar}, + {248, "ExpBar", "经验条", "game", "Slay slimes for XP. A full bar flashes and the level goes up.", demoExpBar}, + {249, "Hearts", "心形血量", "game", "Damage in quarter hearts. A potion mends a whole one.", demoHearts}, + {250, "Lives", "剩余命数", "game", "Your face, times lives. 1UP!", demoLives}, + {251, "CoinCounter", "金币", "game", "The coin spins; the count ticks up to the new total.", demoCoinCounter}, + {252, "ScoreBoard", "计分板", "game", "Beat the HI-SCORE and its label lights up.", demoScoreBoard}, + {253, "TimerHUD", "关卡计时", "game", "TIME runs out. Under 30 it turns red and blinks. Hurry!", demoTimerHUD}, + {254, "Minimap", "小地图", "game", "Walk with the D-pad; the fog lifts around you.", demoMinimap}, + {255, "Compass", "指南针", "game", "Turn the heading; the quest and town markers slide along.", demoCompass}, + {256, "Hotbar", "快捷栏", "game", "Press 1–9, scroll, or click. Enter uses the slot.", demoHotbar}, + {257, "Equipment", "装备栏", "game", "A paper doll. Arrows walk the slots; Enter opens one.", demoEquipment}, +} + +type gameDemo struct { + hp, mp, level, xp, lives, coins, score, slot, gear int + hearts, heading float64 + deadline time.Time + pos game.Spot + seen [][]bool + dice *rand.Rand +} + +func gameState(c *ui.Context) *gameDemo { + now := pixel.K(c).Now() + return ui.Local(c.Root(), demoKey("game"), func() gameDemo { + s := gameDemo{hp: 100, mp: 60, level: 1, lives: 3, coins: 120, hearts: 5, heading: 30, gear: game.Weapon, + deadline: now.Add(100 * time.Second), pos: game.Spot{X: 2, Y: 2}, dice: rand.New(rand.NewPCG(3, 4))} + for range dungeon { + s.seen = append(s.seen, make([]bool, len(dungeon[0]))) + } + s.look() + return s + }) +} + +// small is a small button. +func small(c *ui.Context, text string, disabled bool) bool { + return input.Button(c, text, input.ButtonOptions{Size: input.Small, Disabled: disabled}).Clicked +} + +func demoHealthBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.HealthBar(c, "HP", s.hp, 100, game.HealthBarOptions{Width: 140}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if small(c, "HIT", s.hp == 0) { + s.hp = max(0, s.hp-18) + log(fmt.Sprintf("ouch! %d HP", s.hp)) + } + if small(c, "HEAL", s.hp == 100) { + s.hp = min(100, s.hp+30) + log(fmt.Sprintf("healed to %d HP", s.hp)) + } + }) + }) +} + +func demoManaBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.ManaBar(c, "MP", s.mp, 60, game.ManaBarOptions{Width: 140}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if small(c, "FIREBALL", s.mp < 12) { + s.mp -= 12 + log(fmt.Sprintf("fwoosh! %d MP left", s.mp)) + } + if small(c, "REST", s.mp == 60) { + s.mp = min(60, s.mp+20) + log("zzz") + } + }) + }) +} + +func demoExpBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + next := s.level * 100 + ui.Column(c).Gap(k.D(6)).Children(func() { + game.ExpBar(c, "EXP", s.level, s.xp, next, game.ExpBarOptions{Width: 140}) + if small(c, "SLAY A SLIME", false) { + log("+45 XP") + if s.xp += 45; s.xp >= next { + s.level, s.xp = s.level+1, s.xp-next + log(fmt.Sprintf("level up! LV %d", s.level)) + } + } + }) +} + +func demoHearts(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.Hearts(c, "Life", s.hearts, 5, game.HeartsOptions{}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if small(c, "OUCH", s.hearts == 0) { + s.hearts = max(0, s.hearts-0.75) + log(fmt.Sprintf("%.2f hearts", s.hearts)) + } + if small(c, "POTION", s.hearts == 5) { + s.hearts = min(5, s.hearts+1) + log("glug glug") + } + }) + }) +} + +func demoLives(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + game.Lives(c, "Lives", s.lives, game.LivesOptions{}) + if small(c, "1UP", false) { + s.lives++ + log("1UP!") + } + if small(c, "FALL IN A PIT", s.lives == 0) { + s.lives-- + log("aaaaa...") + } + }) +} + +func demoCoinCounter(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + game.CoinCounter(c, "Gold", s.coins, game.CoinCounterOptions{Scale: 2}) + if small(c, "OPEN CHEST", false) { + s.coins += 250 + log("+250 gold") + } + }) +} + +func demoScoreBoard(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.ScoreBoard(c, s.score, 9900, game.ScoreBoardOptions{}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if small(c, "STOMP", false) { + s.score += 1500 + log(fmt.Sprintf("score %d", s.score)) + } + if small(c, "RESET", s.score == 0) { + s.score = 0 + log("score reset") + } + }) + }) +} + +func demoTimerHUD(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + left := max(0, s.deadline.Sub(k.Now())) + if left > 0 { + k.Tick(c, 2) + } + ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() { + game.TimerHUD(c, left, game.TimerHUDOptions{}) + if small(c, "RESTART", false) { + s.deadline = k.Now().Add(100 * time.Second) + log("go go go!") + } + if small(c, "SKIP TO 33", left <= 33*time.Second) { + s.deadline = k.Now().Add(33 * time.Second) + log("33 seconds left") + } + }) +} + +// dungeon is the minimap demo's map. +var dungeon = []string{ + `############`, + `#....~~....#`, + `#.""...~...#`, + `#.""..###..#`, + `#.....#^^..#`, + `############`, +} + +// look lifts the fog two tiles around the player. +func (s *gameDemo) look() { + for y := range dungeon { + for x := range dungeon[y] { + if max(x-s.pos.X, s.pos.X-x, y-s.pos.Y, s.pos.Y-y) <= 2 { + s.seen[y][x] = true + } + } + } +} + +func demoMinimap(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Row(c).Gap(k.D(10)).AlignItems(ui.Center).Children(func() { + game.Minimap(c, "Dungeon", dungeon, s.seen, s.pos, []game.Marker{{Spot: game.Spot{X: 9, Y: 4}, Slot: pixel.Warn}}, game.MinimapOptions{Cell: 6}) + step := map[input.Direction]game.Spot{input.Up: {Y: -1}, input.Down: {Y: 1}, input.Left: {X: -1}, input.Right: {X: 1}} + if d, ok := step[input.DPad(c, "Walk", input.DPadOptions{}).Pressed]; ok { + to := game.Spot{X: s.pos.X + d.X, Y: s.pos.Y + d.Y} + if dungeon[to.Y][to.X] != '#' { + s.pos = to + s.look() + log(fmt.Sprintf("at %d, %d", to.X, to.Y)) + } + } + }) +} + +func demoCompass(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := gameState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.Compass(c, "Heading", s.heading, []game.Bearing{{Degrees: 120, Slot: pixel.Warn}, {Degrees: 300, Slot: pixel.Info}}, game.CompassOptions{Width: 160}) + if input.Slider(c, "Heading", &s.heading, input.SliderOptions{Max: 360, Width: 160}).Changed { + log(fmt.Sprintf("%.0f°", s.heading)) + } + }) +} + +// pack is the hotbar demo's items. +var pack = []data.Item{ + {Sprite: icon.Sword, Name: "Sword", Rarity: display.Rare}, {Sprite: icon.Shield, Name: "Shield"}, {Sprite: icon.Potion, Name: "Potion", Count: 5}, + {Sprite: icon.Bolt, Name: "Bolt scroll", Rarity: display.Epic}, {Sprite: icon.Key, Name: "Key"}, {}, {Sprite: icon.Map, Name: "Map"}, {}, {Sprite: icon.Gem, Name: "Gem", Count: 3}, +} + +func demoHotbar(c *ui.Context, log func(string)) { + s := gameState(c) + r := game.Hotbar(c, "Belt", pack, &s.slot, game.HotbarOptions{}) + if r.Changed { + log(fmt.Sprintf("slot %d", s.slot+1)) + } + if r.Used >= 0 { + name := pack[r.Used].Name + if name == "" { + name = "an empty hand" + } + log("used " + name) + } +} + +func demoEquipment(c *ui.Context, log func(string)) { + s := gameState(c) + gear := []data.Item{{Sprite: icon.Crown, Name: "Old crown", Rarity: display.Legendary}, {}, {Sprite: icon.Sword, Name: "Iron sword"}, {Sprite: icon.Shield, Name: "Pot lid"}, {}} + r := game.Equipment(c, "Gear", gear, &s.gear, game.EquipmentOptions{}) + if r.Changed { + log(fmt.Sprintf("slot %d", s.gear)) + } + if r.Picked >= 0 { + log(fmt.Sprintf("open slot %d", r.Picked)) + } +} diff --git a/gallery/demos_rpg.go b/gallery/demos_rpg.go new file mode 100644 index 0000000..60b4f00 --- /dev/null +++ b/gallery/demos_rpg.go @@ -0,0 +1,229 @@ +package gallery + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var rpgEntries = []Entry{ + {258, "SkillTree", "技能树", "game", "Spend points on open skills: click once to choose, twice to learn.", demoSkillTree}, + {259, "SkillButton", "技能按钮", "game", "Cast, then wait out the clockwise cooldown wipe.", demoSkillButton}, + {260, "StatusEffects", "状态效果", "game", "Buffs edged green, debuffs red, turns left in the corner.", demoStatusEffects}, + {261, "BossBar", "首领血条", "game", "A boss in phases. Hit it until it gets angry.", demoBossBar}, + {262, "DamageNumbers", "伤害数字", "game", "Numbers float up and dissolve. Criticals in gold!", demoDamageNumbers}, + {263, "QuestLog", "任务日志", "game", "Choose a quest to track; its objectives show beside it.", demoQuestLog}, + {264, "QuestTracker", "任务追踪", "game", "Compact objectives with counts. Go slay one.", demoQuestTracker}, + {265, "PartyFrames", "队伍面板", "game", "Cards with HP and MP. Brom needs a phoenix down.", demoPartyFrames}, + {266, "CharacterSheet", "角色面板", "game", "Who you are: class, level and stats.", demoCharacterSheet}, + {267, "LevelUpScreen", "升级分配", "game", "LEVEL UP! Spend points with ◀ ▶, then DONE.", demoLevelUpScreen}, + {268, "Shop", "商店", "game", "Buy at the price, sell at half. Too dear? It goes grey.", demoShop}, +} + +type rpgDemo struct { + skills []game.Skill + cursor, points, boss, quest int + slain int + cast time.Time + effects []game.Effect + hits []game.Hit + attrs []game.Attribute + wares []game.Ware + gold, raise int +} + +func rpgState(c *ui.Context) *rpgDemo { + return ui.Local(c.Root(), demoKey("rpg"), func() rpgDemo { + return rpgDemo{skills: talents(), points: 2, boss: 1000, gold: 300, raise: 3, + effects: []game.Effect{{Sprite: icon.Bolt, Name: "Haste", Turns: 3}, {Sprite: icon.Shield, Name: "Guard", Turns: 2}, {Sprite: icon.Skull, Name: "Poison", Turns: 4, Debuff: true}}, + attrs: []game.Attribute{{Name: "STR", Base: 12}, {Name: "DEX", Base: 9}, {Name: "INT", Base: 7}, {Name: "LUK", Base: 5}}, + wares: []game.Ware{{Item: data.Item{Sprite: icon.Potion, Name: "Potion"}, Price: 30, Owned: 2}, {Item: data.Item{Sprite: icon.Key, Name: "Skeleton key"}, Price: 120}, + {Item: data.Item{Sprite: icon.Sword, Name: "Flame sword"}, Price: 900}}} + }) +} + +// talents is a root, two branches and a capstone. +func talents() []game.Skill { + return []game.Skill{ + {Name: "Slash", Sprite: icon.Sword, At: game.Spot{X: 1}, Learned: true}, + {Name: "Guard", Sprite: icon.Shield, At: game.Spot{Y: 1}, Needs: []int{0}}, + {Name: "Fireball", Sprite: icon.Flame, At: game.Spot{X: 2, Y: 1}, Needs: []int{0}}, + {Name: "Bulwark", Sprite: icon.Crown, At: game.Spot{Y: 2}, Needs: []int{1}}, + {Name: "Meteor", Sprite: icon.Star, At: game.Spot{X: 2, Y: 2}, Needs: []int{2}}, + {Name: "Starfall", Sprite: icon.Sparkle, At: game.Spot{X: 1, Y: 3}, Needs: []int{3, 4}}, + } +} + +func demoSkillTree(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := rpgState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + r := game.SkillTree(c, "Talents", s.skills, &s.cursor, &s.points, game.SkillTreeOptions{}) + if r.Changed { + log(s.skills[s.cursor].Name) + } + if r.Learned >= 0 { + log("learned " + s.skills[r.Learned].Name) + } + if small(c, "RESPEC", s.points == 2) { + s.skills, s.points = talents(), 2 + log("a clean slate") + } + }) +} + +func demoSkillButton(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := rpgState(c) + // The spell cools for three seconds, in tenths. + cool := 1 - float64(k.Since(c, s.cast, 10, 30))/30 + ui.Row(c).Gap(k.D(6)).Children(func() { + if game.SkillButton(c, "Fireball", icon.Flame, "Q", cool, game.SkillButtonOptions{}).Clicked { + s.cast = k.Now() + log("fireball!") + } + if game.SkillButton(c, "Heal", icon.Heart, "E", 0, game.SkillButtonOptions{}).Clicked { + log("you feel better") + } + game.SkillButton(c, "Meteor", icon.Star, "R", 0, game.SkillButtonOptions{Disabled: true}) + }) +} + +func demoStatusEffects(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := rpgState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + game.StatusEffects(c, "Effects", s.effects, game.StatusEffectsOptions{}) + if small(c, "END TURN", len(s.effects) == 0) { + left := s.effects[:0] + for _, ef := range s.effects { + if ef.Turns--; ef.Turns > 0 { + left = append(left, ef) + } + } + s.effects = left + log(fmt.Sprintf("%d effects left", len(s.effects))) + } + }) +} + +func demoBossBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := rpgState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.BossBar(c, "SLIME KING", s.boss, 1000, game.BossBarOptions{Width: 220, Phases: []float64{0.66, 0.33}}) + if small(c, "ATTACK", s.boss == 0) { + was := s.boss + s.boss = max(0, s.boss-90) + log(fmt.Sprintf("hit! %d HP left", s.boss)) + for _, mark := range []int{660, 330} { + if was > mark && s.boss <= mark { + log("the Slime King wobbles angrily!") + } + } + if s.boss == 0 { + log("victory!") + } + } + }) +} + +func demoDamageNumbers(c *ui.Context, log func(string)) { + k := pixel.K(c) + s, dice := rpgState(c), gameState(c).dice + ui.Column(c).Gap(k.D(4)).Children(func() { + // The numbers float over a slime having a bad day. + layout.Layers(c, func() { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite((g.W-3*slime.W)/2, g.H-3*slime.H, slime, 0, pixel.Ink, 3) + }) + }, func() { + game.DamageNumbers(c, "Damage", &s.hits, game.DamageNumbersOptions{Width: 180, Height: 60}) + }) + if small(c, "ATTACK", false) { + h := game.Hit{Value: 5 + dice.IntN(30), X: 20 + dice.IntN(140), Y: 25 + dice.IntN(25)} + // One hit in five is critical, for double. + if h.Crit = dice.IntN(5) == 0; h.Crit { + h.Value *= 2 + } + s.hits = append(s.hits, h) + log(fmt.Sprintf("hit for %d", h.Value)) + } + }) +} + +// adventures are the quest demos' quests. +func adventures(slain int) []game.Quest { + return []game.Quest{ + {Title: "Slime trouble", Objectives: []game.Objective{{Text: "Slay slimes", Have: slain, Need: 5}, {Text: "Tell the mayor", Have: slain / 5, Need: 1}}}, + {Title: "Lost cat", Objectives: []game.Objective{{Text: "Find Mittens", Have: 1, Need: 1}, {Text: "Bring her home", Need: 1}}}, + {Title: "Herb run", Objectives: []game.Objective{{Text: "Pick moonleaf", Have: 2, Need: 6}}}, + } +} + +func demoQuestLog(c *ui.Context, log func(string)) { + s := rpgState(c) + quests := adventures(s.slain) + if game.QuestLog(c, "Quests", quests, &s.quest, game.QuestLogOptions{}).Changed { + log("tracking " + quests[s.quest].Title) + } +} + +func demoQuestTracker(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := rpgState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.QuestTracker(c, adventures(s.slain)[0], game.QuestTrackerOptions{}) + if small(c, "SLAY A SLIME", s.slain == 5) { + s.slain++ + log(fmt.Sprintf("%d of 5", s.slain)) + } + }) +} + +func demoPartyFrames(c *ui.Context, log func(string)) { + game.PartyFrames(c, "Party", []game.Member{ + {Name: "Aria", Portrait: game.Hero, HP: 34, MaxHP: 40, MP: 18, MaxMP: 20}, + {Name: "Cyra", Portrait: game.Hero, HP: 9, MaxHP: 30, MP: 31, MaxMP: 40}, + {Name: "Brom", Portrait: game.Hero, MaxHP: 50, MP: 4, MaxMP: 10}, + }, game.PartyFramesOptions{}) +} + +func demoCharacterSheet(c *ui.Context, log func(string)) { + game.CharacterSheet(c, game.Character{Name: "Aria", Class: "Knight", Level: 7, Portrait: game.Hero, + Stats: []game.Stat{{Name: "STR", Value: 14}, {Name: "DEX", Value: 9}, {Name: "INT", Value: 7}, {Name: "LUK", Value: 11}}}, game.CharacterSheetOptions{}) +} + +func demoLevelUpScreen(c *ui.Context, log func(string)) { + s := rpgState(c) + r := game.LevelUpScreen(c, "Level up", s.attrs, &s.raise, game.LevelUpScreenOptions{}) + if r.Changed { + log(fmt.Sprintf("%d points left", s.raise)) + } + if r.Done { + for i := range s.attrs { + s.attrs[i].Base, s.attrs[i].Added = s.attrs[i].Base+s.attrs[i].Added, 0 + } + s.raise = 3 + log("stronger! three more points, for the demo") + } +} + +func demoShop(c *ui.Context, log func(string)) { + s := rpgState(c) + r := game.Shop(c, "Item shop", s.wares, &s.gold, game.ShopOptions{}) + if r.Bought >= 0 { + log("bought " + s.wares[r.Bought].Item.Name) + } + if r.Sold >= 0 { + log("sold " + s.wares[r.Sold].Item.Name) + } +} diff --git a/gallery/game_demo_test.go b/gallery/game_demo_test.go new file mode 100644 index 0000000..2a37e43 --- /dev/null +++ b/gallery/game_demo_test.go @@ -0,0 +1,44 @@ +package gallery + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestGameDemosLogTheirActions(t *testing.T) { + for _, tc := range []struct { + path, label string + key ui.Key + }{ + {"/c/248-expbar", "SLAY A SLIME", 0}, {"/c/252-scoreboard", "RESET", 0}, {"/c/253-timerhud", "SKIP TO 33", 0}, + {"/c/257-equipment", "Gear", ui.KeyRight}, {"/c/258-skilltree", "Talents", ui.KeyDown}, {"/c/261-bossbar", "ATTACK", 0}, + } { + a, tt := open(tc.path) + slug := tc.path[3:] + if tc.label == "RESET" { + click(t, tt, "STOMP") + } + before := len(a.logs[slug]) + click(t, tt, tc.label) + // A key moves a control the click has focused. + if tc.key != 0 { + before = len(a.logs[slug]) + tt.Key(0, tc.key) + } + if len(a.logs[slug]) <= before { + t.Errorf("%s: %s logged nothing: %q", slug, tc.label, a.logs[slug]) + } + } +} + +func TestHotbarDemoLogsAnEmptySlotUsed(t *testing.T) { + a, tt := open("/c/256-hotbar") + click(t, tt, "Belt") + tt.Key(0, ui.Key6) + before := len(a.logs["256-hotbar"]) + tt.Key(0, ui.KeyEnter) + if len(a.logs["256-hotbar"]) <= before { + t.Fatalf("using empty slot 6 logged nothing: %q", a.logs["256-hotbar"]) + } +} diff --git a/gallery/registry.go b/gallery/registry.go index b47897a..c02d99d 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) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries) func Entries() []Entry { return entries } diff --git a/game/bossbar.go b/game/bossbar.go new file mode 100644 index 0000000..e38023f --- /dev/null +++ b/game/bossbar.go @@ -0,0 +1,43 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type BossBarOptions struct { + // Width is in dots; zero means 200. + Width int + // Phases are where the boss changes, as fractions: 0.5. + Phases []float64 +} + +// BossBar is a wide bar under the name, phases marked. +func BossBar(c *ui.Context, name string, hp, full int, o BossBarOptions) *ui.Element { + k := pixel.K(c) + amount("BossBar hp", hp, full) + for _, f := range o.Phases { + if !(f > 0 && f < 1) { + panic(fmt.Sprintf("game: BossBar phase %v outside 0–1", f)) + } + } + w := pixel.Or("BossBar Width", o.Width, 200) + if need := icon.Skull.W + 3 + pixel.Measure(name); w < need { + panic(fmt.Sprintf("game: BossBar Width %d is under the %d its name needs", w, need)) + } + return ui.Box(c).Role(ui.RoleMeter).Label(name).Description(ratio(hp, full)).Size(k.D(w), k.D(pixel.LineHeight+12)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite(0, 2, icon.Skull, 0, pixel.Bad, 1) + g.Text(icon.Skull.W+3, 0, name, pixel.Ink, 1) + meter(g, 0, pixel.LineHeight, w, 12, float64(hp)/float64(full), pixel.Bad) + for _, f := range o.Phases { + x := 2 + int(f*float64(w-4)) + g.VLine(x, pixel.LineHeight-2, 14, pixel.Ink) + g.Rect(x-1, pixel.LineHeight-2, 3, 1, pixel.Ink) + } + }) +} diff --git a/game/charactersheet.go b/game/charactersheet.go new file mode 100644 index 0000000..4ed2994 --- /dev/null +++ b/game/charactersheet.go @@ -0,0 +1,63 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +// Stat is a named number on a sheet. +type Stat struct { + Name string + Value int +} + +// Character is who a character sheet describes. +type Character struct { + Name, Class string + Level int + Portrait *pixel.Sprite + Stats []Stat +} + +type CharacterSheetOptions struct{} + +// CharacterSheet shows a portrait, name, class, level and stats. +func CharacterSheet(c *ui.Context, h Character, o CharacterSheetOptions) *ui.Element { + k := pixel.K(c) + if h.Portrait == nil || h.Level < 1 { + panic(fmt.Sprintf("game: character %q needs a portrait and a level (%d)", h.Name, h.Level)) + } + name, value := 0, 0 + stats := make([]string, len(h.Stats)) + for i, s := range h.Stats { + name, value = max(name, pixel.Measure(s.Name)), max(value, pixel.Measure(strconv.Itoa(s.Value))) + stats[i] = s.Name + " " + strconv.Itoa(s.Value) + } + return ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(h.Name).Children(func() { + ui.Box(c).Size(k.D(40), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, 40, 40, pixel.Sunken, pixel.Well) + g.Sprite(2, 2, h.Portrait, 0, pixel.Ink, 3) + }) + ui.Column(c).Gap(k.D(3)).Children(func() { + text.ShadowText(c, h.Name, text.ShadowTextOptions{Scale: 2}) + pixel.Text(c, h.Class+" "+fmt.Sprintf(k.Msg("level"), h.Level), pixel.TextOptions{Slot: pixel.Faint}) + // Stats stand in a column: names Faint, values right-aligned. + w := name + 8 + value + ui.Box(c).Role(ui.RoleText).Label(strings.Join(stats, ", ")).Size(k.D(w), k.D(len(h.Stats)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, s := range h.Stats { + n := strconv.Itoa(s.Value) + g.Text(0, i*pixel.LineHeight, s.Name, pixel.Faint, 1) + g.Text(w-pixel.Measure(n), i*pixel.LineHeight, n, pixel.Ink, 1) + } + }) + }) + }) +} diff --git a/game/coincounter.go b/game/coincounter.go new file mode 100644 index 0000000..ddacbad --- /dev/null +++ b/game/coincounter.go @@ -0,0 +1,29 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CoinCounterOptions struct { + // Scale is 1–4; zero means 1. + Scale int +} + +// CoinCounter spins a coin beside a count that ticks up. +func CoinCounter(c *ui.Context, text string, coins int, o CoinCounterOptions) *ui.Element { + k := pixel.K(c) + if coins < 0 { + panic(fmt.Sprintf("game: CoinCounter %d", coins)) + } + sc := pixel.Or("CoinCounter Scale", o.Scale, 1) + return ui.Row(c).Gap(k.D(2 * sc)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() { + display.Sprite(c, coin, display.SpriteOptions{Scale: sc, FPS: 8}) + feedback.CountUp(c, coins, feedback.CountUpOptions{Scale: sc}) + }) +} diff --git a/game/compass.go b/game/compass.go new file mode 100644 index 0000000..2c60a80 --- /dev/null +++ b/game/compass.go @@ -0,0 +1,78 @@ +package game + +import ( + "fmt" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Bearing marks a direction on a compass, in degrees. +type Bearing struct { + Degrees float64 + Slot pixel.Slot +} + +type CompassOptions struct { + // Width is in dots; zero means 120. It shows 180°. + Width int +} + +// Compass is a heading strip with letters and markers. +func Compass(c *ui.Context, text string, heading float64, marks []Bearing, o CompassOptions) *ui.Element { + k := pixel.K(c) + for _, v := range append([]float64{heading}, bearings(marks)...) { + if math.IsNaN(v) || math.IsInf(v, 0) { + panic(fmt.Sprintf("game: Compass bearing %v", v)) + } + } + w := pixel.Or("Compass Width", o.Width, 120) + letters := strings.Fields(k.Msg("compass")) + heading = math.Mod(math.Mod(heading, 360)+360, 360) + // x places bearing b; false means off the strip. + x := func(b float64) (int, bool) { + d := math.Mod(math.Mod(b-heading, 360)+540, 360) - 180 + return w/2 + int(math.Round(d*float64(w)/180)), d >= -90 && d <= 90 + } + top := tagH(letters[0]) + 3 + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strconv.Itoa(int(math.Round(heading)))+"°").Size(k.D(w), k.D(top+10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, top, w, 10, pixel.Sunken, pixel.Well) + for b := 0; b < 360; b += 15 { + at, ok := x(float64(b)) + if !ok || at < 1 || at > w-2 { + continue + } + tall := 3 + if b%45 == 0 { + tall = 6 + } + g.VLine(at, top+10-1-tall, tall, pixel.Edge) + if b%90 == 0 { + l := letters[b/90] + tag(g, max(0, min(w-tagW(l), at-tagW(l)/2)), 0, l, pixel.Ink) + } + } + for _, m := range marks { + if at, ok := x(m.Degrees); ok { + g.Rect(max(1, min(w-4, at-1)), top+2, 3, 3, m.Slot) + } + } + // The heading's own mark points down at the middle. + g.Rect(w/2-2, top-1, 5, 1, pixel.Accent) + g.Rect(w/2-1, top, 3, 1, pixel.Accent) + g.Dot(w/2, top+1, pixel.Accent) + }) +} + +func bearings(marks []Bearing) []float64 { + out := make([]float64, len(marks)) + for i, m := range marks { + out[i] = m.Degrees + } + return out +} diff --git a/game/controls_test.go b/game/controls_test.go new file mode 100644 index 0000000..cc04529 --- /dev/null +++ b/game/controls_test.go @@ -0,0 +1,208 @@ +package game_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var bag = []data.Item{{Sprite: icon.Potion, Name: "Potion", Count: 5}, {Sprite: icon.Sword, Name: "Sword"}, {Sprite: icon.Bolt, Name: "Bolt"}, {}, {Sprite: icon.Key, Name: "Key"}} + +func TestHotbarKeysWheelAndClicks(t *testing.T) { + sel, changes, used := 0, 0, -1 + off := false + wd := &world{clk: newClock()} + tt := view(400, 100, wd, func(c *ui.Context) { + r := game.Hotbar(c, "Hotbar", bag, &sel, game.HotbarOptions{Disabled: off}) + if r.Changed { + changes++ + } + if r.Used >= 0 { + used = r.Used + } + }) + // Slot i is 22 dots wide, its middle at 22i+11. + tt.ClickAt(2*(22*2+11), 22) + if sel != 2 || used != 2 || changes != 1 { + t.Fatalf("click: slot %d, used %d, %d changes", sel, used, changes) + } + tt.Key(0, ui.Key5) + tt.Key(0, ui.Key9) + tt.Key(0, ui.KeyLeft) + if sel != 3 || changes != 3 { + t.Fatalf("5, 9, Left: slot %d, %d changes", sel, changes) + } + // A notch is a 16-dot line; the bar wraps. + tt.Scroll(22, 22, 0, 32) + tt.Scroll(22, 22, 0, 32) + tt.Scroll(22, 22, 0, -16) + if sel != 0 || changes != 5 { + t.Fatalf("two notches and a half: slot %d, %d changes", sel, changes) + } + tt.Key(0, ui.KeyEnter) + if used != 0 { + t.Fatalf("Enter used %d", used) + } + keeps(t, tt.Image()) + off = true + tt.Frame() + tt.Key(0, ui.Key3) + tt.Scroll(22, 22, 0, 32) + if sel != 0 { + t.Fatalf("a disabled bar moved to %d", sel) + } + panics(t, "ten slots", func() { + view(400, 100, wd, func(c *ui.Context) { + game.Hotbar(c, "x", make([]data.Item, 10), &sel, game.HotbarOptions{}) + }) + }) +} + +func TestInertHotbarIgnoresTheWheel(t *testing.T) { + sel := 0 + wd := &world{clk: newClock(), inert: true} + tt := view(400, 100, wd, func(c *ui.Context) { game.Hotbar(c, "Hotbar", bag, &sel, game.HotbarOptions{}) }) + tt.Scroll(22, 22, 0, 32) + tt.ClickAt(2*(22*3+11), 22) + if sel != 0 { + t.Fatalf("an inert bar moved to %d", sel) + } +} + +func TestEquipmentWalksTheDoll(t *testing.T) { + gear := []data.Item{{Sprite: icon.Crown, Name: "Crown"}, {}, {Sprite: icon.Sword, Name: "Iron sword"}, {}, {}} + sel, picked := game.Weapon, -1 + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.Equipment(c, "Gear", gear, &sel, game.EquipmentOptions{}); r.Picked >= 0 { + picked = r.Picked + } + }) + r := find(t, tt, "Gear") + // The doll is centered under the widest caption. + w := 3*26 - 4 + for _, s := range []string{"HEAD: Crown", "BODY", "WEAPON: Iron sword", "SHIELD", "RING"} { + w = max(w, pixel.Measure(s)) + } + left := (w - (3*26 - 4)) / 2 + click := func(x, y int) { tt.ClickAt(r.X+float32(2*(left+x*26+11)), r.Y+float32(2*(y*26+11))) } + click(2, 1) + if sel != game.Shield || picked != game.Shield { + t.Fatalf("clicking the shield slot chose %d, picked %d", sel, picked) + } + // Left of the doll, nothing is hit. + tt.ClickAt(r.X+float32(2*(left-5)), r.Y+float32(2*(26+11))) + if sel != game.Shield { + t.Fatalf("a click beside the doll chose %d", sel) + } + for _, step := range []struct { + key ui.Key + want int + }{{ui.KeyLeft, game.Body}, {ui.KeyUp, game.Head}, {ui.KeyLeft, game.Weapon}, {ui.KeyDown, game.Ring}, {ui.KeyUp, game.Weapon}, {ui.KeyRight, game.Body}, {ui.KeyDown, game.Ring}} { + tt.Key(0, step.key) + if sel != step.want { + t.Fatalf("%v went to %d, not %d", step.key, sel, step.want) + } + } + tt.Key(0, ui.KeySpace) + if picked != game.Ring { + t.Fatalf("Space picked %d", picked) + } + keeps(t, tt.Image()) + panics(t, "four slots", func() { + view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.Equipment(c, "x", gear[:4], &sel, game.EquipmentOptions{}) + }) + }) +} + +func TestSkillTreeLearnsOpenSkillsWithPoints(t *testing.T) { + skills := []game.Skill{ + {Name: "Slash", Sprite: icon.Sword, At: game.Spot{X: 1}, Learned: true}, + {Name: "Guard", Sprite: icon.Shield, At: game.Spot{Y: 1}, Needs: []int{0}}, + {Name: "Fire", Sprite: icon.Flame, At: game.Spot{X: 2, Y: 1}, Needs: []int{0}, Learned: true}, + {Name: "Meteor", Sprite: icon.Star, At: game.Spot{X: 2, Y: 2}, Needs: []int{2, 1}}, + } + cur, points, learned := 0, 1, -1 + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.SkillTree(c, "Skills", skills, &cur, &points, game.SkillTreeOptions{}); r.Learned >= 0 { + learned = r.Learned + } + }) + r := find(t, tt, "Skills") + // Nodes sit 28 dots apart under a 20-dot header. + node := func(x, y int) { tt.ClickAt(r.X+float32(2*(x*28+10)), r.Y+float32(2*(20+y*28+10))) } + node(1, 0) + tt.Key(0, ui.KeyEnter) + if points != 1 { + t.Fatal("a learned skill took a point") + } + node(2, 2) + tt.Key(0, ui.KeyEnter) + if cur != 3 || skills[3].Learned || points != 1 { + t.Fatal("a locked skill was learned") + } + node(0, 1) + if cur != 1 || skills[1].Learned { + t.Fatal("the first click on a skill learned it") + } + node(0, 1) + if !skills[1].Learned || points != 0 || learned != 1 { + t.Fatalf("the second click: learned %d, %d points", learned, points) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if cur != 3 || skills[3].Learned { + t.Fatal("Meteor was learned with no points") + } + points = 1 + tt.Key(0, ui.KeyEnter) + if !skills[3].Learned || points != 0 || learned != 3 { + t.Fatal("Meteor was not learned with a point") + } + keeps(t, tt.Image()) + first := 0 + panics(t, "skills needing each other", func() { + view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, Needs: []int{1}}, {Name: "b", Sprite: icon.Star, At: game.Spot{X: 1}, Needs: []int{0}}}, &first, &points, game.SkillTreeOptions{}) + }) + }) + panics(t, "a skill off the grid", func() { + view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, At: game.Spot{X: -1}}}, &first, &points, game.SkillTreeOptions{}) + }) + }) + panics(t, "a skill needing itself", func() { + view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, Needs: []int{0}}}, &cur, &points, game.SkillTreeOptions{}) + }) + }) +} + +func TestSkillButtonFiresOnlyWhenReady(t *testing.T) { + cool, clicks := 0.5, 0 + tt := view(200, 100, &world{clk: newClock()}, func(c *ui.Context) { + if game.SkillButton(c, "Fireball", icon.Flame, "Q", cool, game.SkillButtonOptions{}).Clicked { + clicks++ + } + }) + // The wipe darkens the left half for half a cooldown. + wiped := count(t, tt.Image(), 2, 2, 9, 20, pixel.Edge) + if err := tt.Click("Fireball"); err != nil || clicks != 0 { + t.Fatalf("a cooling skill fired: %v", err) + } + cool = 0 + tt.Frame() + if n := count(t, tt.Image(), 2, 2, 9, 20, pixel.Edge); wiped < n+50 { + t.Fatalf("wipe dots %d, ready %d", wiped, n) + } + tt.Click("Fireball") + if clicks != 1 { + t.Fatal("a ready skill did not fire") + } +} diff --git a/game/damagenumbers.go b/game/damagenumbers.go new file mode 100644 index 0000000..9d3e8c9 --- /dev/null +++ b/game/damagenumbers.go @@ -0,0 +1,88 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Hit is a number rising from dot X, Y. +type Hit struct { + Value int + Crit bool + X, Y int + at time.Time +} + +type DamageNumbersOptions struct { + // Width and Height are dots; zeros mean 160 by 80. + Width, Height int +} + +// life is a number's time; it dissolves the last half. +const life = 800 * time.Millisecond + +// DamageNumbers floats hits up, dissolves, then drops them. +func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptions) *ui.Element { + k := pixel.K(c) + w, h := pixel.Or("DamageNumbers Width", o.Width, 160), pixel.Or("DamageNumbers Height", o.Height, 80) + now := k.Now() + live := (*hits)[:0] + for _, hit := range *hits { + if hit.X < 0 || hit.X >= w || hit.Y < 0 || hit.Y >= h { + panic(fmt.Sprintf("game: hit at %d,%d is off the %d×%d box", hit.X, hit.Y, w, h)) + } + if hit.at.IsZero() { + hit.at = now + } + if now.Sub(hit.at) < life { + live = append(live, hit) + } + } + *hits = live + shown := append([]Hit(nil), live...) + parts := make([]string, len(shown)) + for i, hit := range shown { + parts[i] = strconv.Itoa(hit.Value) + if hit.Crit { + parts[i] += "!" + } + } + if len(shown) > 0 { + k.Tick(c, 20) + } + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + // Numbers wider than the box are cut at it. + g.Clip(0, 0, w, h, func() { numbers(g, k, shown, now, w, h) }) + }) +} + +// numbers draws live hits, risen and dissolved by age. +func numbers(g pixel.Canvas, k pixel.Kit, shown []Hit, now time.Time, w, h int) { + for _, hit := range shown { + t := float64(now.Sub(hit.at)) / float64(life) + ink, sc, s := pixel.Ink, 1, strconv.Itoa(hit.Value) + if hit.Crit { + ink, sc, s = pixel.Warn, 2, s+"!" + } + // Wide or high numbers are pushed back inside the box. + tw, th := pixel.Measure(s)*sc, pixel.LineHeight*sc + x, y := max(0, min(w-tw, hit.X-tw/2)), max(0, min(h-th, hit.Y-th/2)) + // It rises 12 dots and dissolves; reduced, it holds still. + if k.Reduce() { + g.Text(x, y, s, ink, sc) + continue + } + level := 16 + if t > 0.5 { + level = int(16 * (1 - t) * 2) + } + g.TextVeil(x, max(0, y-int(12*t)), s, ink, level, sc) + } +} diff --git a/game/equipment.go b/game/equipment.go new file mode 100644 index 0000000..f6e4368 --- /dev/null +++ b/game/equipment.go @@ -0,0 +1,112 @@ +package game + +import ( + "fmt" + "log/slog" + + "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" +) + +// The paper doll's slots, in the order Equipment takes gear. +const ( + Head = iota + Body + Weapon + Shield + Ring +) + +// doll places the slots on a 3×3 grid. +var doll = []struct { + at Spot + name string + hint *pixel.Sprite +}{ + {Spot{1, 0}, "head", icon.Crown}, {Spot{1, 1}, "body", icon.User}, {Spot{0, 1}, "weapon", icon.Sword}, + {Spot{2, 1}, "shield", icon.Shield}, {Spot{0, 2}, "ring", icon.Gem}, +} + +// pitch is a doll cell: a slot and its gap. +const pitch = data.SlotSize + 4 + +type EquipmentOptions struct { + Disabled bool +} + +type EquipmentResult struct { + Element *ui.Element + Changed bool + // Picked is the slot opened this frame, or -1. + Picked int +} + +// Equipment is a paper doll with five slots. +func Equipment(c *ui.Context, text string, gear []data.Item, selected *int, o EquipmentOptions) EquipmentResult { + k := pixel.K(c) + if len(gear) != len(doll) || *selected < 0 || *selected >= len(doll) { + panic(fmt.Sprintf("game: Equipment has %d slots, slot %d; it takes head, body, weapon, shield, ring", len(gear), *selected)) + } + old := *selected + spots := make([]Spot, len(doll)) + captions := make([]string, len(doll)) + w := 3*pitch - 4 + for i, d := range doll { + spots[i], captions[i] = d.at, k.Msg(d.name) + if gear[i].Sprite != nil { + captions[i] += ": " + gear[i].String() + } + w = max(w, pixel.Measure(captions[i])) + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) + }) + arrows(base, spots, selected) + res := EquipmentResult{Element: shell, Picked: -1} + if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) { + res.Picked = *selected + } + left := (w - (3*pitch - 4)) / 2 + // Enter and Space are taken, so clicks come from pointers. + if base.Clicked() { + x, y, _ := base.PointerPosition() + dx, dy := int(x/k.Dot())-left, int(y/k.Dot()) + for i, s := range spots { + if dx >= s.X*pitch && dx < s.X*pitch+data.SlotSize && dy >= s.Y*pitch && dy < s.Y*pitch+data.SlotSize { + *selected, res.Picked = i, i + } + } + } + res.Changed = *selected != old + base.Description(captions[*selected]) + if res.Changed { + slog.Debug("8bit: equipment", "label", text, "slot", doll[*selected].name) + } + sel, shown := *selected, append([]data.Item(nil), gear...) + shell.Size(k.D(w), k.D(3*pitch+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + figure(g, left) + for i, d := range doll { + x, y := left+d.at.X*pitch, d.at.Y*pitch + data.DrawSlot(g, x, y, shown[i], data.ItemSlotOptions{Selected: i == sel}) + if shown[i].Sprite == nil { + g.Silhouette(x+(data.SlotSize-d.hint.W)/2, y+(data.SlotSize-d.hint.H)/2, d.hint, 0, pixel.Shade, 1) + } + } + g.Text((w-pixel.Measure(captions[sel]))/2, 3*pitch, captions[sel], pixel.Ink, 1) + g.Finish(st) + }) + return res +} + +// figure sketches the body the slots dress: neck, arms, legs. +func figure(g pixel.Canvas, x int) { + mid := x + pitch + data.SlotSize/2 + g.Rect(mid-2, data.SlotSize, 4, 4, pixel.Shade) + g.Rect(x+data.SlotSize/2, pitch+9, 2*pitch, 4, pixel.Shade) + g.Rect(mid-6, 2*pitch-4, 5, pitch, pixel.Shade) + g.Rect(mid+1, 2*pitch-4, 5, pitch, pixel.Shade) +} diff --git a/game/expbar.go b/game/expbar.go new file mode 100644 index 0000000..92edace --- /dev/null +++ b/game/expbar.go @@ -0,0 +1,57 @@ +package game + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ExpBarOptions struct { + // Width is in dots; zero means 96. + Width int +} + +type riseKey struct{} + +// rise is the last level, xp and fill time. +type rise struct { + level, xp int + at time.Time +} + +// ExpBar shows xp toward next and the level; ups flash. +func ExpBar(c *ui.Context, text string, level, xp, next int, o ExpBarOptions) *ui.Element { + k := pixel.K(c) + if level < 1 { + panic(fmt.Sprintf("game: ExpBar level %d", level)) + } + amount("ExpBar xp", xp, next) + w := pixel.Or("ExpBar Width", o.Width, 96) + lv := fmt.Sprintf(k.Msg("level"), level) + fits("ExpBar", w, lv, ratio(next, next)) + e := ui.Box(c).Role(ui.RoleMeter).Label(text).Description(lv + " " + ratio(xp, next)) + up := ui.Local(e, riseKey{}, func() rise { return rise{level: level, xp: xp} }) + // It fills as xp reaches next or the level rises. + if level > up.level || level == up.level && xp == next && up.xp < next { + up.at = k.Now() + } + up.level, up.xp = level, xp + // A fill flashes 0.6 s; reduced, it jumps past. + flash := !k.Reduce() && k.Since(c, up.at, 10, 6) < 6 + top := headH(lv) + return e.Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + head(g, w, lv, ratio(xp, next)) + switch { + case !flash: + meter(g, 0, top, w, 8, float64(xp)/float64(next), pixel.Warn) + case g.Busy(10)%2 == 0: + meter(g, 0, top, w, 8, 1, pixel.Lit) + default: + meter(g, 0, top, w, 8, 1, pixel.Warn) + } + }) +} diff --git a/game/game.go b/game/game.go new file mode 100644 index 0000000..3bddba9 --- /dev/null +++ b/game/game.go @@ -0,0 +1,106 @@ +// Package game is an RPG's HUD, menus and screens. +package game + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// tag writes s from ink top y, micro if possible. +func tag(g pixel.Canvas, x, y int, s string, ink pixel.Slot) { + if pixel.MicroOK(s) { + g.Micro(x, y, s, ink, 1) + return + } + g.Text(x, y-3, s, ink, 1) +} + +// tagW is s's width as tag writes it. +func tagW(s string) int { + if pixel.MicroOK(s) { + return pixel.MicroWidth(s) + } + return pixel.Measure(s) +} + +// tagH is s's ink height; font ink spans rows 3–14. +func tagH(s string) int { + if pixel.MicroOK(s) { + return pixel.MicroHeight + } + return 12 +} + +// meter frames a bar, fills part and returns its end. +func meter(g pixel.Canvas, x, y, w, h int, part float64, ink pixel.Slot) int { + g.Frame(x, y, w, h, pixel.Sunken, pixel.Well) + fill := int(part * float64(w-4)) + g.Rect(x+2, y+2, fill, h-4, ink) + return x + 2 + fill +} + +// amount checks v of most, as hit points are. +func amount(name string, v, most int) { + if most <= 0 || v < 0 || v > most { + panic(fmt.Sprintf("game: %s %d of %d", name, v, most)) + } +} + +// ratio prints "v/most". +func ratio(v, most int) string { return strconv.Itoa(v) + "/" + strconv.Itoa(most) } + +// fits fails when a bar is narrower than its labels. +func fits(name string, w int, text, value string) { + if need := tagW(text) + 4 + pixel.MicroWidth(value); w < need { + panic(fmt.Sprintf("game: %s Width %d is under the %d its labels need", name, w, need)) + } +} + +// headH is the label row's height above a HUD bar. +func headH(text string) int { return tagH(text) + 2 } + +// head writes a label left and a micro value right. +func head(g pixel.Canvas, w int, text, value string) { + tag(g, 0, 0, text, pixel.Ink) + g.Micro(w-pixel.MicroWidth(value), headH(text)-2-pixel.MicroHeight, value, pixel.Ink, 1) +} + +// nearest is the next spot toward dx, dy, or from. +func nearest(spots []Spot, from, dx, dy int) int { + best, score := from, 0 + for i, s := range spots { + ahead := (s.X-spots[from].X)*dx + (s.Y-spots[from].Y)*dy + aside := abs((s.X-spots[from].X)*dy) + abs((s.Y-spots[from].Y)*dx) + if ahead <= 0 { + continue + } + // Straight ahead beats near but off to the side. + if d := ahead + 2*aside; best == from || d < score { + best, score = i, d + } + } + return best +} + +// arrows moves cur among spots by arrow keys. +func arrows(base *ui.Element, spots []Spot, cur *int) { + for _, m := range []struct { + key ui.Key + dx, dy int + }{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} { + if base.Shortcut(0, m.key) { + *cur = nearest(spots, *cur, m.dx, m.dy) + } + } +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/game/game_test.go b/game/game_test.go new file mode 100644 index 0000000..e3f4ede --- /dev/null +++ b/game/game_test.go @@ -0,0 +1,105 @@ +package game_test + +import ( + "image" + "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 } + +func newClock() *clock { return &clock{time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)} } + +// world is the settings a test view runs under. +type world struct { + clk *clock + reduce, zh, inert bool +} + +// 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) { + s := pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert} + if wd.zh { + s.Locale = pixel.Zh + } + pixel.Use(c, s) + build(c) + }, w, h) + if wd.reduce { + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + } + return tt +} + +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 +} + +// at is the slot of dot x, y. +func at(t *testing.T, img *image.RGBA, x, y int) pixel.Slot { + t.Helper() + c := img.RGBAAt(2*x, 2*y) + for s, pc := range pixel.SpringDay.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + return pixel.Slot(s) + } + } + t.Fatalf("dot %d,%d is %v, off the palette", x, y, c) + return 0 +} + +// count tallies dots of s in a dot box. +func count(t *testing.T, img *image.RGBA, x, y, w, h int, s pixel.Slot) int { + t.Helper() + n := 0 + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + if at(t, img, i, j) == s { + n++ + } + } + } + return n +} + +// keeps fails on off-palette colors or split dots. +func keeps(t *testing.T, img *image.RGBA) { + t.Helper() + b := img.Bounds() + for y := 0; y+1 < b.Dy(); y += 2 { + for x := 0; x+1 < b.Dx(); x += 2 { + at(t, img, x/2, y/2) + c := img.RGBAAt(x, y) + if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c { + t.Fatalf("dot at %d,%d is split", x/2, y/2) + } + } + } +} + +// panics fails unless fn panics. +func panics(t *testing.T, what string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatalf("%s did not panic", what) + } + }() + fn() +} + +// dots is r's top-left in dots. +func dots(r ui.Rect) (int, int) { return int(r.X) / 2, int(r.Y) / 2 } diff --git a/game/healthbar.go b/game/healthbar.go new file mode 100644 index 0000000..0f6af49 --- /dev/null +++ b/game/healthbar.go @@ -0,0 +1,81 @@ +package game + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HealthBarOptions struct { + // Width is in dots; zero means 96. + Width int +} + +type trailKey struct{} + +// trail is the last hp and full, and the trail. +type trail struct { + seen, full int + from float64 + at time.Time +} + +// hold is how long a trail waits before draining. +const hold = 300 * time.Millisecond + +// HealthBar shows hp with a lagging trail; low HP blinks. +func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui.Element { + k := pixel.K(c) + amount("HealthBar hp", hp, full) + w := pixel.Or("HealthBar Width", o.Width, 96) + fits("HealthBar", w, text, ratio(full, full)) + e := ui.Box(c).Role(ui.RoleMeter).Label(text).Description(ratio(hp, full)) + tr := ui.Local(e, trailKey{}, func() trail { return trail{seen: hp, full: full, from: float64(hp)} }) + // A new full makes the old trail meaningless. + if full != tr.full { + tr.seen, tr.full, tr.from = hp, full, float64(hp) + } + now := k.Now() + // end is the trail's end: held, then draining fast. + end := func() float64 { + return max(float64(tr.seen), tr.from-max(0, now.Sub(tr.at)-hold).Seconds()*float64(full)) + } + if hp < tr.seen { + tr.from, tr.at = end(), now + } + if hp > tr.seen { + tr.from = float64(hp) + } + tr.seen = hp + shown := end() + if k.Reduce() { + shown = float64(hp) + } + if shown > float64(hp) { + k.Tick(c, 30) + } + ink := pixel.Good + switch { + case hp*4 <= full: + ink = pixel.Bad + case hp*2 <= full: + ink = pixel.Warn + } + low := hp > 0 && hp*4 <= full + top := headH(text) + return e.Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + head(g, w, text, ratio(hp, full)) + fill := ink + // Low HP blinks; reduced, it holds steady. + if low && !k.Reduce() && g.Busy(4)%2 == 1 { + fill = pixel.Lit + } + x := meter(g, 0, top, w, 8, float64(hp)/float64(full), fill) + if t := 2 + int(shown/float64(full)*float64(w-4)); t > x { + g.Rect(x, top+2, t-x, 4, pixel.AccentLit) + } + }) +} diff --git a/game/hearts.go b/game/hearts.go new file mode 100644 index 0000000..88daac3 --- /dev/null +++ b/game/hearts.go @@ -0,0 +1,43 @@ +package game + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HeartsOptions struct { + // PerRow wraps the hearts; zero means 10. + PerRow int +} + +// Hearts shows hp of full hearts, in quarters. +func Hearts(c *ui.Context, text string, hp, full float64, o HeartsOptions) *ui.Element { + k := pixel.K(c) + if full < 1 || full != math.Trunc(full) || !(hp >= 0 && hp <= full) || hp*4 != math.Trunc(hp*4) { + panic(fmt.Sprintf("game: Hearts %v of %v; hearts are whole, health in quarters", hp, full)) + } + per := pixel.Or("Hearts PerRow", o.PerRow, 10) + n := int(full) + cols, rows := min(n, per), (n+per-1)/per + shown := strconv.FormatFloat(hp, 'f', -1, 64) + "/" + strconv.Itoa(n) + return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(shown).Size(k.D(cols*11+1), k.D(rows*10+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range n { + x, y := i%per*11, i/per*10 + // Quarters fill from the bottom left, clockwise. + q := min(4, max(0, int(hp*4)-4*i)) + for j, part := range [][2]int{{0, 6}, {0, 0}, {6, 0}, {6, 6}} { + if j < q { + g.Clip(x+part[0], y+part[1], 6, 6, func() { g.Sprite(x, y, icon.Heart, 0, pixel.Bad, 1) }) + } + } + g.Sprite(x, y, icon.HeartEmpty, 0, pixel.Edge, 1) + } + }) +} diff --git a/game/hotbar.go b/game/hotbar.go new file mode 100644 index 0000000..a8d6327 --- /dev/null +++ b/game/hotbar.go @@ -0,0 +1,81 @@ +package game + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HotbarOptions struct { + Disabled bool +} + +type HotbarResult struct { + Element *ui.Element + Changed bool + // Used is the slot used this frame, or -1. + Used int +} + +type wheelKey struct{} + +// wheel holds unspent scroll and a turn not yet reported. +type wheel struct { + left float32 + moved bool +} + +// Hotbar rows nine slots; 1–9, arrows or the wheel choose. +func Hotbar(c *ui.Context, text string, items []data.Item, selected *int, o HotbarOptions) HotbarResult { + k := pixel.K(c) + n := len(items) + if n == 0 || n > 9 || *selected < 0 || *selected >= n { + panic(fmt.Sprintf("game: Hotbar slot %d of %d; it holds 1–9", *selected, n)) + } + old := *selected + shell, base, st, used := pixel.Pad(c, text, n, n, data.SlotSize, data.SlotSize, selected, o.Disabled) + for i := range n { + if base.Shortcut(0, ui.Key1+ui.Key(i)) { + *selected = i + } + } + wh := ui.Local(base, wheelKey{}, func() wheel { return wheel{} }) + notch := k.D(pixel.LineHeight) + base.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputScroll { + return false + } + // A notch moves one slot; the bar wraps around. + for wh.left += ev.DY; wh.left >= notch || wh.left <= -notch; { + d := 1 + if wh.left < 0 { + d = -1 + } + wh.left -= float32(d) * notch + *selected, wh.moved = ((*selected+d)%n+n)%n, true + } + return true + }) + res := HotbarResult{Element: shell, Changed: *selected != old || wh.moved, Used: used} + wh.moved = false + base.Description(items[*selected].String()) + if res.Changed { + slog.Debug("8bit: hotbar", "label", text, "slot", *selected) + } + sel, shown := *selected, append([]data.Item(nil), items...) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, it := range shown { + x := i * data.SlotSize + data.DrawSlot(g, x, 0, it, data.ItemSlotOptions{Selected: i == sel}) + g.Micro(x+3, 3, strconv.Itoa(i+1), pixel.Faint, 1) + } + g.Finish(st) + }) + return res +} diff --git a/game/hud_test.go b/game/hud_test.go new file mode 100644 index 0000000..fc0c27e --- /dev/null +++ b/game/hud_test.go @@ -0,0 +1,232 @@ +package game_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestHealthBarTrailHoldsThenDrains(t *testing.T) { + wd := &world{clk: newClock()} + hp := 80 + tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) }) + // The fill's middle row is 10; full is 92 dots. + row := func(s pixel.Slot) int { return count(t, tt.Image(), 0, 10, 96, 1, s) } + if row(pixel.Good) != 73 { + t.Fatalf("80 HP fills %d dots", row(pixel.Good)) + } + hp = 40 + tt.Frame() + if row(pixel.Warn) != 36 || row(pixel.AccentLit) != 37 { + t.Fatalf("at the hit: fill %d, trail %d", row(pixel.Warn), row(pixel.AccentLit)) + } + for _, step := range []struct { + after time.Duration + trail int + }{{300 * time.Millisecond, 37}, {200 * time.Millisecond, 19}, {500 * time.Millisecond, 0}} { + wd.clk.t = wd.clk.t.Add(step.after) + tt.Frame() + if row(pixel.AccentLit) != step.trail { + t.Fatalf("%v later the trail is %d dots", step.after, row(pixel.AccentLit)) + } + } + hp = 90 + tt.Frame() + if row(pixel.Good) != 82 || row(pixel.AccentLit) != 0 { + t.Fatal("a heal left a trail") + } + // A heal during a live trail clears it too. + hp = 30 + tt.Frame() + hp = 60 + tt.Frame() + if row(pixel.AccentLit) != 0 { + t.Fatal("a heal kept a live trail") + } + keeps(t, tt.Image()) + // A new full drops the old trail. + full := 100 + tt = view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, full, game.HealthBarOptions{}) }) + hp, full = 40, 50 + tt.Frame() + if row(pixel.AccentLit) != 0 || count(t, tt.Image(), 96, 0, 54, 30, pixel.AccentLit) != 0 { + t.Fatal("the trail outlived a new full") + } + panics(t, "a bar narrower than its labels", func() { + view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 100, game.HealthBarOptions{Width: 20}) }) + }) + panics(t, "hp past full", func() { + view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 4, game.HealthBarOptions{}) }) + }) +} + +func TestHealthBarReducedHasNoTrailAndNoBlink(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + hp := 90 + tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) }) + hp = 20 + tt.Frame() + if trail := count(t, tt.Image(), 0, 10, 96, 1, pixel.AccentLit); (trail == 0) != reduce { + t.Fatalf("reduced %v: trail of %d dots", reduce, trail) + } + // Low HP blinks at 2 Hz: Bad, then Lit. + seen := map[pixel.Slot]bool{} + for range 2 { + seen[at(t, tt.Image(), 4, 10)] = true + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + } + if blinked := seen[pixel.Lit] && seen[pixel.Bad]; blinked == reduce { + t.Fatalf("reduced %v: low HP showed %v", reduce, seen) + } + } +} + +func TestManaBarTicks(t *testing.T) { + tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) { game.ManaBar(c, "MP", 35, 60, game.ManaBarOptions{}) }) + img := tt.Image() + // 35 of 60 fills 53 dots; three ticks cut in. + if info, ticks := count(t, img, 0, 10, 96, 1, pixel.Info), count(t, img, 2, 10, 92, 1, pixel.Edge); info != 50 || ticks != 5 { + t.Fatalf("fill %d dots, %d ticks", info, ticks) + } +} + +func TestExpBarFlashesWhenItFills(t *testing.T) { + for _, tc := range []struct { + name string + level, xp int + reduce, flash bool + }{ + {"a level up", 8, 10, false, true}, {"a fill", 7, 300, false, true}, + {"a reduced level up", 8, 10, true, false}, {"a lost level", 6, 10, false, false}, + } { + wd := &world{clk: newClock(), reduce: tc.reduce} + level, xp := 7, 290 + tt := view(300, 60, wd, func(c *ui.Context) { game.ExpBar(c, "EXP", level, xp, 300, game.ExpBarOptions{}) }) + level, xp = tc.level, tc.xp + tt.Frame() + // With no flash, the first frame already shows xp. + if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; !tc.flash && n != want { + t.Fatalf("%s: the first frame fills %d dots, not %d", tc.name, n, want) + } + seen := map[pixel.Slot]bool{} + for range 2 { + img := tt.Image() + seen[at(t, img, 4, 10)] = true + if full := count(t, img, 2, 10, 92, 1, pixel.Lit) + count(t, img, 2, 10, 92, 1, pixel.Warn); tc.flash && full != 92 { + t.Fatalf("%s: the flash fills %d dots", tc.name, full) + } + wd.clk.t = wd.clk.t.Add(100 * time.Millisecond) + tt.Frame() + } + if flashed := seen[pixel.Lit] && seen[pixel.Warn]; flashed != tc.flash { + t.Fatalf("%s: the bar showed %v", tc.name, seen) + } + // After any flash the bar shows xp. + wd.clk.t = wd.clk.t.Add(time.Second) + tt.Frame() + if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; n != want { + t.Fatalf("%s: %d of 300 fills %d dots, not %d", tc.name, tc.xp, n, want) + } + } +} + +func TestHeartsFillByQuarters(t *testing.T) { + tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Hearts(c, "Life", 1.75, 3, game.HeartsOptions{}) + }) + img := tt.Image() + // Quadrant dots: bottom left, top left, top right, bottom right. + quads := [][2]int{{4, 7}, {3, 4}, {8, 4}, {7, 7}} + for i, want := range [][4]bool{{true, true, true, true}, {true, true, true, false}, {false, false, false, false}} { + for q, d := range quads { + if got := at(t, img, i*11+d[0], d[1]) == pixel.Bad; got != want[q] { + t.Fatalf("heart %d quadrant %d filled %v", i, q, got) + } + } + } + keeps(t, img) + // A lone quarter fills the bottom left only. + tt = view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "Life", 0.25, 1, game.HeartsOptions{}) }) + for q, d := range quads { + if got := at(t, tt.Image(), d[0], d[1]) == pixel.Bad; got != (q == 0) { + t.Fatalf("a quarter heart filled quadrant %d: %v", q, got) + } + } + for _, bad := range [][2]float64{{0.3, 3}, {1, 2.5}, {4, 3}, {-1, 3}} { + panics(t, "hearts out of quarters", func() { + view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "x", bad[0], bad[1], game.HeartsOptions{}) }) + }) + } +} + +func TestLivesShowTheirIcon(t *testing.T) { + tt := view(300, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.Lives(c, "Lives", 3, game.LivesOptions{}) + game.Lives(c, "Stars", 2, game.LivesOptions{Sprite: icon.Star}) + }) + lives, stars := find(t, tt, "Lives"), find(t, tt, "Stars") + lx, ly := dots(lives) + sx, sy := dots(stars) + // The hero wears a Bad cap; the star is ink. + if count(t, tt.Image(), lx, ly, 12, 16, pixel.Bad) == 0 || count(t, tt.Image(), sx, sy, 12, 16, pixel.Bad) != 0 { + t.Fatal("life icons are wrong") + } + if int(lives.W)/2 != 12+2+pixel.Measure("×3") { + t.Fatalf("lives are %v px wide", lives.W) + } +} + +func TestCoinSpinsUnlessReduced(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + tt := view(300, 60, wd, func(c *ui.Context) { game.CoinCounter(c, "Gold", 1250, game.CoinCounterOptions{}) }) + find(t, tt, "Gold") + before := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn) + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + if spun := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn) != before; spun == reduce { + t.Fatalf("reduced %v: the coin spun %v", reduce, spun) + } + } +} + +func TestScoreBoardLightsANewBest(t *testing.T) { + score := 500 + tt := view(400, 100, &world{clk: newClock()}, func(c *ui.Context) { game.ScoreBoard(c, score, 9900, game.ScoreBoardOptions{}) }) + if !tt.HasText("SCORE 500, HI-SCORE 9900") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) != 0 { + t.Fatalf("texts %q", tt.Texts()) + } + score = 12500 + tt.Frame() + if !tt.HasText("SCORE 12500, HI-SCORE 12500") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) == 0 { + t.Fatal("a new best is not lit") + } +} + +func TestTimerHUDHurriesUnderThirty(t *testing.T) { + for _, tc := range []struct { + left time.Duration + reduce, blink bool + ink pixel.Slot + }{{87 * time.Second, false, false, pixel.Ink}, {12 * time.Second, false, true, pixel.Bad}, {12 * time.Second, true, false, pixel.Bad}, + {29500 * time.Millisecond, true, false, pixel.Bad}} { + wd := &world{clk: newClock(), reduce: tc.reduce} + tt := view(300, 100, wd, func(c *ui.Context) { game.TimerHUD(c, tc.left, game.TimerHUDOptions{}) }) + var shown []int + for range 2 { + shown = append(shown, count(t, tt.Image(), 0, 8, 60, 34, tc.ink)) + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + } + if blinked := (shown[0] == 0) != (shown[1] == 0); blinked != tc.blink || shown[0]+shown[1] == 0 { + t.Fatalf("%v left, reduced %v: digit dots %v", tc.left, tc.reduce, shown) + } + } +} diff --git a/game/levelupscreen.go b/game/levelupscreen.go new file mode 100644 index 0000000..69d6cd3 --- /dev/null +++ b/game/levelupscreen.go @@ -0,0 +1,83 @@ +package game + +import ( + "fmt" + "log/slog" + "strconv" + + "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/text" +) + +// Attribute is a stat raised at a level up. +type Attribute struct { + Name string + Base int + // Added is the points put in it this time. + Added int +} + +type LevelUpScreenOptions struct { + Disabled bool +} + +type LevelUpScreenResult struct { + Element *ui.Element + Changed bool + Done bool +} + +// back is ◀, the triangle icon turned around. +var back = icon.TriangleRight.Mirror() + +// LevelUpScreen spends points on attributes with ◀ ▶, then DONE. +func LevelUpScreen(c *ui.Context, title string, attrs []Attribute, points *int, o LevelUpScreenOptions) LevelUpScreenResult { + k := pixel.K(c) + if *points < 0 { + panic(fmt.Sprintf("game: LevelUpScreen has %d points", *points)) + } + name := 0 + for _, a := range attrs { + if a.Base < 0 || a.Added < 0 { + panic(fmt.Sprintf("game: attribute %q is %d%+d", a.Name, a.Base, a.Added)) + } + name = max(name, pixel.Measure(a.Name)) + } + col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleGroup).Label(title) + res := LevelUpScreenResult{Element: col} + col.Children(func() { + ui.Row(c).Gap(k.D(10)).AlignItems(ui.End).Children(func() { + text.ShadowText(c, k.Msg("level_up"), text.ShadowTextOptions{Scale: 2, Slot: pixel.Warn}) + pixel.Text(c, fmt.Sprintf(k.Msg("points"), *points), pixel.TextOptions{Slot: pixel.Accent}) + }) + for i := range attrs { + a := &attrs[i] + ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Width(k.D(name + 6)).Children(func() { pixel.Text(c, a.Name, pixel.TextOptions{}) }) + if input.IconButton(c, back, a.Name+" "+k.Msg("down"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || a.Added == 0}).Clicked { + a.Added, *points, res.Changed = a.Added-1, *points+1, true + } + ui.Box(c).Width(k.D(40)).Children(func() { + ui.Row(c).Children(func() { + pixel.Text(c, strconv.Itoa(a.Base+a.Added), pixel.TextOptions{}) + if a.Added > 0 { + pixel.Text(c, "+"+strconv.Itoa(a.Added), pixel.TextOptions{Slot: pixel.Good}) + } + }) + }) + if input.IconButton(c, icon.TriangleRight, a.Name+" "+k.Msg("up"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || *points == 0}).Clicked { + a.Added, *points, res.Changed = a.Added+1, *points-1, true + } + }) + } + res.Done = input.Button(c, k.Msg("done"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked + }) + if res.Changed { + slog.Debug("8bit: level up", "label", title, "points", *points) + } + return res +} diff --git a/game/lives.go b/game/lives.go new file mode 100644 index 0000000..c547ced --- /dev/null +++ b/game/lives.go @@ -0,0 +1,33 @@ +package game + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LivesOptions struct { + // Sprite is the life icon; nil means Hero. + Sprite *pixel.Sprite +} + +// Lives shows a life icon and ×count. +func Lives(c *ui.Context, text string, n int, o LivesOptions) *ui.Element { + k := pixel.K(c) + if n < 0 { + panic(fmt.Sprintf("game: Lives %d", n)) + } + sp := o.Sprite + if sp == nil { + sp = Hero + } + count := "×" + strconv.Itoa(n) + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(count).Size(k.D(sp.W+2+pixel.Measure(count)), k.D(max(sp.H, pixel.LineHeight))).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite(0, (g.H-sp.H)/2, sp, 0, pixel.Ink, 1) + g.Text(sp.W+2, (g.H-pixel.LineHeight)/2, count, pixel.Ink, 1) + }) +} diff --git a/game/manabar.go b/game/manabar.go new file mode 100644 index 0000000..298925d --- /dev/null +++ b/game/manabar.go @@ -0,0 +1,32 @@ +package game + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ManaBarOptions struct { + // Width is in dots; zero means 96. + Width int + // Tick marks every Tick points; zero means 10. + Tick int +} + +// ManaBar shows mp of full, ticked every few points. +func ManaBar(c *ui.Context, text string, mp, full int, o ManaBarOptions) *ui.Element { + k := pixel.K(c) + amount("ManaBar mp", mp, full) + w := pixel.Or("ManaBar Width", o.Width, 96) + fits("ManaBar", w, text, ratio(full, full)) + step := pixel.Or("ManaBar Tick", o.Tick, 10) + top := headH(text) + return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(ratio(mp, full)).Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + head(g, w, text, ratio(mp, full)) + meter(g, 0, top, w, 8, float64(mp)/float64(full), pixel.Info) + for v := step; v < full; v += step { + g.VLine(2+v*(w-4)/full, top+2, 4, pixel.Edge) + } + }) +} diff --git a/game/map_test.go b/game/map_test.go new file mode 100644 index 0000000..819d47d --- /dev/null +++ b/game/map_test.go @@ -0,0 +1,179 @@ +package game_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestMinimapTilesFogPlayerAndMarks(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + seen := [][]bool{{true, true, false}, {true, true, true}} + tt := view(100, 60, wd, func(c *ui.Context) { + game.Minimap(c, "Map", []string{`#.~`, `"^ `}, seen, game.Spot{X: 1, Y: 0}, []game.Marker{{Spot: game.Spot{X: 0, Y: 1}, Slot: pixel.Warn}}, game.MinimapOptions{}) + }) + img := tt.Image() + // Tiles are 3 dots inside a 1-dot frame. + for _, tc := range []struct { + x, y int + s pixel.Slot + }{{0, 0, pixel.Edge}, {0, 1, pixel.Warn}, {1, 1, pixel.Faint}, {2, 1, pixel.Back}} { + if n := count(t, img, 1+tc.x*3, 1+tc.y*3, 3, 3, tc.s); n != 9 { + t.Fatalf("tile %d,%d holds %d %v dots", tc.x, tc.y, n, tc.s) + } + } + if fog := count(t, img, 7, 1, 3, 3, pixel.Edge); fog == 0 || fog == 9 { + t.Fatalf("the fogged tile holds %d Edge dots", fog) + } + var lit []int + for range 2 { + lit = append(lit, count(t, tt.Image(), 4, 1, 3, 3, pixel.Accent)) + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + } + if blinked := lit[0] != lit[1]; blinked == reduce || lit[0]+lit[1] == 0 { + t.Fatalf("reduced %v: the player lit %v", reduce, lit) + } + } + panics(t, "a ragged map", func() { + view(100, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Minimap(c, "x", []string{"##", "#"}, nil, game.Spot{}, nil, game.MinimapOptions{}) + }) + }) + panics(t, "ragged fog", func() { + view(100, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Minimap(c, "x", []string{"##", "##"}, [][]bool{{true, true}, {true}}, game.Spot{}, nil, game.MinimapOptions{}) + }) + }) + panics(t, "a player off the map", func() { + view(100, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Minimap(c, "x", []string{"##"}, nil, game.Spot{X: 2}, nil, game.MinimapOptions{}) + }) + }) +} + +func TestCompassTurnsWithTheHeading(t *testing.T) { + shot := func(heading float64) (*ui.Tester, int) { + tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Compass(c, "Heading", heading, []game.Bearing{{Degrees: 70, Slot: pixel.Warn}, {Degrees: 250, Slot: pixel.Bad}}, game.CompassOptions{}) + }) + return tt, count(t, tt.Image(), 0, 0, 120, 20, pixel.Ink) + } + tt, ink := shot(30) + img := tt.Image() + // N sits 20 dots left of middle, E 40 right. + if count(t, img, 38, 0, 5, 5, pixel.Ink) == 0 || count(t, img, 98, 0, 5, 5, pixel.Ink) == 0 { + t.Fatal("N or E is not where 30° puts it") + } + if count(t, img, 0, 0, 120, 20, pixel.Warn) != 9 || count(t, img, 0, 0, 120, 20, pixel.Bad) != 0 { + t.Fatal("markers in and out of view are wrong") + } + if _, again := shot(390); again != ink { + t.Fatal("390° differs from 30°") + } + // -360° is 0°, in view of a 350° heading. + tt = view(300, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.Compass(c, "Heading", 350, []game.Bearing{{Degrees: -360, Slot: pixel.Magic}}, game.CompassOptions{}) + }) + if count(t, tt.Image(), 0, 0, 120, 20, pixel.Magic) != 9 { + t.Fatal("a marker at -360° is missing") + } + keeps(t, img) +} + +func TestBossBarPhases(t *testing.T) { + tt := view(500, 80, &world{clk: newClock()}, func(c *ui.Context) { + game.BossBar(c, "SLIME KING", 500, 1000, game.BossBarOptions{Phases: []float64{0.25}}) + }) + img := tt.Image() + // The quarter mark stands 49 dots into the 196-dot fill. + if n := count(t, img, 0, 22, 200, 1, pixel.Bad); n != 97 || at(t, img, 51, 22) != pixel.Ink { + t.Fatalf("half a 196-dot bar is %d Bad dots", n) + } + panics(t, "a bar narrower than the name", func() { + view(500, 80, &world{clk: newClock()}, func(c *ui.Context) { + game.BossBar(c, "SLIME KING", 1, 1, game.BossBarOptions{Width: 20}) + }) + }) + panics(t, "a phase at 1", func() { + view(500, 80, &world{clk: newClock()}, func(c *ui.Context) { + game.BossBar(c, "x", 1, 1, game.BossBarOptions{Phases: []float64{1}}) + }) + }) +} + +func TestStatusEffectsEdgeByKind(t *testing.T) { + tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.StatusEffects(c, "Effects", []game.Effect{{Sprite: icon.Bolt, Name: "Haste", Turns: 3}, {Sprite: icon.Skull, Name: "Poison", Debuff: true}}, game.StatusEffectsOptions{}) + }) + img := tt.Image() + if at(t, img, 5, 1) != pixel.Good || at(t, img, 17+5, 1) != pixel.Bad { + t.Fatal("edges do not tell buffs from debuffs") + } + // Only a count above zero gets a badge. + if count(t, img, 0, 9, 16, 7, pixel.Good) == 0 || count(t, img, 17, 9, 16, 7, pixel.Bad) != 0 { + t.Fatal("turn badges are wrong") + } +} + +func TestDamageNumbersStayInTheirBox(t *testing.T) { + hits := []game.Hit{{Value: 123456789, Crit: true, X: 155, Y: 2}, {Value: 7, Crit: true, X: 2, Y: 79}, {Value: 1234567890123456789, Crit: true, X: 80, Y: 40}} + tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) { + ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() { + game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) + }) + }) + img := tt.Image() + // The box spans dots 20–179 across and 20–99 down. + outside := count(t, img, 0, 0, 200, 20, pixel.Warn) + count(t, img, 180, 0, 20, 120, pixel.Warn) + + count(t, img, 0, 100, 200, 20, pixel.Warn) + count(t, img, 0, 0, 20, 120, pixel.Warn) + if n := outside; n != 0 || count(t, img, 20, 20, 160, 80, pixel.Warn) == 0 { + t.Fatalf("%d crit dots outside the box", n) + } +} + +func TestDamageNumbersShiftInsideWhole(t *testing.T) { + whole := func(x, y int) int { + hits := []game.Hit{{Value: 12345, Crit: true, X: x, Y: y}} + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) }) + return count(t, tt.Image(), 0, 0, 160, 80, pixel.Warn) + } + // Near an edge a number shifts in, keeping its dots. + if mid := whole(80, 40); mid == 0 || whole(158, 40) != mid || whole(80, 79) != mid || whole(1, 1) != mid { + t.Fatal("a number near an edge lost dots") + } +} + +func TestDamageNumbersRiseAndExpire(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + hits := []game.Hit{{Value: 12, X: 40, Y: 40}, {Value: 99, Crit: true, X: 110, Y: 40}} + tt := view(400, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) }) + top := func(s pixel.Slot) int { + for y := range 80 { + if count(t, tt.Image(), 0, y, 160, 1, s) > 0 { + return y + } + } + return -1 + } + t0, crit := top(pixel.Ink), top(pixel.Warn) + wd.clk.t = wd.clk.t.Add(400 * time.Millisecond) + tt.Frame() + // Halfway through its life it has risen 6 dots. + if rose := t0 - top(pixel.Ink); t0 < 0 || crit < 0 || (rose == 6) == reduce || rose != 6 && rose != 0 { + t.Fatalf("reduced %v: the number rose %d from %d", reduce, rose, t0) + } + wd.clk.t = wd.clk.t.Add(500 * time.Millisecond) + tt.Frame() + if len(hits) != 0 || top(pixel.Ink) != -1 { + t.Fatalf("reduced %v: %d hits outlived their time", reduce, len(hits)) + } + } +} diff --git a/game/minimap.go b/game/minimap.go new file mode 100644 index 0000000..3eb41f9 --- /dev/null +++ b/game/minimap.go @@ -0,0 +1,81 @@ +package game + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Spot is a map tile, counted from the top left. +type Spot struct{ X, Y int } + +// Marker is a colored spot on a map. +type Marker struct { + Spot + Slot pixel.Slot +} + +type MinimapOptions struct { + // Cell is a tile's side in dots; zero means 3. + Cell int +} + +// tiles color map runes: wall, floor, water, grass, rock, void. +var tiles = map[rune]pixel.Slot{'#': pixel.Edge, '.': pixel.Well, '~': pixel.Info, '"': pixel.Good, '^': pixel.Faint, ' ': pixel.Back} + +// Minimap draws tiles, fog where unseen, player and marks. +func Minimap(c *ui.Context, text string, rows []string, seen [][]bool, player Spot, marks []Marker, o MinimapOptions) *ui.Element { + k := pixel.K(c) + cell := pixel.Or("Minimap Cell", o.Cell, 3) + if len(rows) == 0 { + panic("game: Minimap has no rows") + } + grid := make([][]rune, len(rows)) + for y, row := range rows { + grid[y] = []rune(row) + if len(grid[y]) != len(grid[0]) { + panic(fmt.Sprintf("game: Minimap row %d is %d tiles, not %d", y, len(grid[y]), len(grid[0]))) + } + for _, r := range grid[y] { + if _, ok := tiles[r]; !ok { + panic(fmt.Sprintf("game: Minimap tile %q", r)) + } + } + } + w, h := len(grid[0]), len(grid) + inside := func(s Spot) bool { return s.X >= 0 && s.X < w && s.Y >= 0 && s.Y < h } + for y := range seen { + if len(seen) != h || len(seen[y]) != w { + panic(fmt.Sprintf("game: Minimap fog is not %d×%d", w, h)) + } + } + for _, m := range append([]Marker{{Spot: player}}, marks...) { + if !inside(m.Spot) { + panic(fmt.Sprintf("game: Minimap spot %v is off the %d×%d map", m.Spot, w, h)) + } + } + at := strconv.Itoa(player.X) + ", " + strconv.Itoa(player.Y) + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(at).Size(k.D(w*cell+2), k.D(h*cell+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Back) + for y, row := range grid { + for x, t := range row { + if seen != nil && !seen[y][x] { + g.Dither(1+x*cell, 1+y*cell, cell, cell, pixel.Back, pixel.Edge, 12) + continue + } + g.Rect(1+x*cell, 1+y*cell, cell, cell, tiles[t]) + } + } + for _, m := range marks { + g.Rect(1+m.X*cell, 1+m.Y*cell, cell, cell, m.Slot) + } + // The player blinks at 2 Hz; reduced, it holds steady. + if k.Reduce() || g.Busy(4)%2 == 0 { + g.Rect(1+player.X*cell, 1+player.Y*cell, cell, cell, pixel.Accent) + } + }) +} diff --git a/game/partyframes.go b/game/partyframes.go new file mode 100644 index 0000000..d510b63 --- /dev/null +++ b/game/partyframes.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Member is a party member: portrait, HP and MP. +type Member struct { + Name string + Portrait *pixel.Sprite + HP, MaxHP, MP, MaxMP int +} + +type PartyFramesOptions struct{} + +// PartyFrames stacks member cards; a fallen member greys out. +func PartyFrames(c *ui.Context, text string, members []Member, o PartyFramesOptions) *ui.Element { + k := pixel.K(c) + return ui.Column(c).Gap(k.D(3)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(text).Children(func() { + for _, m := range members { + if m.Portrait == nil { + panic(fmt.Sprintf("game: member %q has no portrait", m.Name)) + } + down := m.HP == 0 + ui.Row(c).Gap(k.D(4)).Padding(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(m.Name).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + }).Children(func() { + ui.Box(c).Size(k.D(28), k.D(28)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, 28, 28, pixel.Sunken, pixel.Well) + if down { + g.Silhouette(2, 2, m.Portrait, 0, pixel.Shade, 2) + return + } + g.Sprite(2, 2, m.Portrait, 0, pixel.Ink, 2) + }) + ui.Column(c).Gap(k.D(1)).Children(func() { + slot := pixel.Ink + if down { + slot = pixel.Faint + } + pixel.Text(c, m.Name, pixel.TextOptions{Slot: slot}) + HealthBar(c, "HP", m.HP, m.MaxHP, HealthBarOptions{Width: 72}) + ManaBar(c, "MP", m.MP, m.MaxMP, ManaBarOptions{Width: 72}) + }) + }) + } + }) +} diff --git a/game/quest.go b/game/quest.go new file mode 100644 index 0000000..c9fb1cc --- /dev/null +++ b/game/quest.go @@ -0,0 +1,47 @@ +package game + +import ( + "fmt" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Objective is a quest step, Have of Need (≥ 1). +type Objective struct { + Text string + Have, Need int +} + +// Quest is a title and its objectives. +type Quest struct { + Title string + Objectives []Objective +} + +// check fails on objectives past their need. +func (q Quest) check() { + for _, ob := range q.Objectives { + if ob.Need < 1 || ob.Have < 0 || ob.Have > ob.Need { + panic(fmt.Sprintf("game: quest %q objective %q has %d of %d", q.Title, ob.Text, ob.Have, ob.Need)) + } + } +} + +// done is whether every objective is met. +func (q Quest) done() bool { + for _, ob := range q.Objectives { + if ob.Have < ob.Need { + return false + } + } + return true +} + +// tick marks a 7×7 box, checked when done. +func tick(g pixel.Canvas, x, y int, done bool) { + g.Frame(x, y, 7, 7, pixel.Flat, pixel.Well) + if done { + g.Line(x+1, y+3, x+2, y+5, pixel.Good) + g.Line(x+3, y+4, x+5, y+1, pixel.Good) + } +} diff --git a/game/questlog.go b/game/questlog.go new file mode 100644 index 0000000..11420bd --- /dev/null +++ b/game/questlog.go @@ -0,0 +1,68 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type QuestLogOptions struct { + Disabled bool +} + +type QuestLogResult struct { + Element *ui.Element + Changed bool +} + +// QuestLog lists quests; the chosen one is tracked and detailed. +func QuestLog(c *ui.Context, text string, quests []Quest, tracked *int, o QuestLogOptions) QuestLogResult { + k := pixel.K(c) + if len(quests) == 0 || *tracked < 0 || *tracked >= len(quests) { + panic(fmt.Sprintf("game: QuestLog tracks %d of %d", *tracked, len(quests))) + } + for _, q := range quests { + q.check() + } + row := ui.Row(c).Gap(k.D(10)).AlignItems(ui.Start) + res := QuestLogResult{Element: row} + row.Children(func() { + r := data.List(c, text, len(quests), tracked, data.ListOptions{Height: 6 * pixel.LineHeight, Disabled: o.Disabled}, func(i int) { + ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() { + mark, ink := icon.Flag, pixel.Accent + switch { + case i == *tracked: + case quests[i].done(): + mark, ink = icon.Check, pixel.Good + default: + mark = nil + } + ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) { + if mark != nil { + k.Canvas(p, r).Sprite(0, 0, mark, 0, ink, 1) + } + }) + slot := pixel.Ink + if quests[i].done() { + slot = pixel.Faint + } + pixel.Text(c, quests[i].Title, pixel.TextOptions{Slot: slot}) + }) + }) + r.Element.Width(k.D(128)) + res.Changed = r.Changed + ui.Column(c).Gap(k.D(2)).Children(func() { + pixel.Text(c, k.Msg("tracking"), pixel.TextOptions{Slot: pixel.Faint}) + QuestTracker(c, quests[*tracked], QuestTrackerOptions{}) + }) + }) + if res.Changed { + slog.Debug("8bit: quest tracked", "label", text, "quest", quests[*tracked].Title) + } + return res +} diff --git a/game/questtracker.go b/game/questtracker.go new file mode 100644 index 0000000..fbaa0fc --- /dev/null +++ b/game/questtracker.go @@ -0,0 +1,42 @@ +package game + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type QuestTrackerOptions struct{} + +// QuestTracker lists a quest's objectives with counts, met ones ticked. +func QuestTracker(c *ui.Context, q Quest, o QuestTrackerOptions) *ui.Element { + k := pixel.K(c) + q.check() + w := icon.Star.W + 2 + pixel.Measure(q.Title) + parts := make([]string, len(q.Objectives)) + for i, ob := range q.Objectives { + parts[i] = ob.Text + " " + ratio(ob.Have, ob.Need) + w = max(w, 11+pixel.Measure(ob.Text)+6+pixel.MicroWidth(ratio(ob.Have, ob.Need))) + } + return ui.Box(c).Role(ui.RoleGroup).Label(q.Title).Description(strings.Join(parts, "; ")).Size(k.D(w), k.D((1+len(q.Objectives))*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Sprite(0, 2, icon.Star, 0, pixel.Warn, 1) + g.Text(icon.Star.W+2, 0, q.Title, pixel.Warn, 1) + for i, ob := range q.Objectives { + y, met := (i+1)*pixel.LineHeight, ob.Have == ob.Need + tick(g, 1, y+5, met) + ink := pixel.Ink + if met { + ink = pixel.Faint + } + g.Text(11, y, ob.Text, ink, 1) + if ob.Need > 1 { + n := ratio(ob.Have, ob.Need) + g.Micro(w-pixel.MicroWidth(n), y+6, n, ink, 1) + } + } + }) +} diff --git a/game/scoreboard.go b/game/scoreboard.go new file mode 100644 index 0000000..6c49aee --- /dev/null +++ b/game/scoreboard.go @@ -0,0 +1,45 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ScoreBoardOptions struct { + // Digits pads both scores; zero means 6. + Digits int +} + +// ScoreBoard shows SCORE and HI-SCORE in big digits. +func ScoreBoard(c *ui.Context, score, hi int, o ScoreBoardOptions) *ui.Element { + k := pixel.K(c) + if score < 0 || hi < 0 { + panic(fmt.Sprintf("game: ScoreBoard %d and %d", score, hi)) + } + digits := pixel.Or("ScoreBoard Digits", o.Digits, 6) + // A score past the best becomes the best, lit. + best, beat := max(hi, score), score > hi + cols := []struct { + name, n string + ink pixel.Slot + }{{k.Msg("score"), fmt.Sprintf("%0*d", digits, score), pixel.Faint}, {k.Msg("hi_score"), fmt.Sprintf("%0*d", digits, best), pixel.Faint}} + if beat { + cols[1].ink = pixel.Warn + } + w, top := 0, 0 + for _, col := range cols { + w, top = max(w, tagW(col.name), 2*pixel.Measure(col.n)), max(top, tagH(col.name)+2) + } + label := fmt.Sprintf("%s %d, %s %d", cols[0].name, score, cols[1].name, best) + return ui.Box(c).Role(ui.RoleText).Label(label).Size(k.D(2*w+16), k.D(top+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, col := range cols { + x := i * (w + 16) + tag(g, x, 0, col.name, col.ink) + g.Text(x, top-2, col.n, pixel.Ink, 2) + } + }) +} diff --git a/game/screens_test.go b/game/screens_test.go new file mode 100644 index 0000000..cdea945 --- /dev/null +++ b/game/screens_test.go @@ -0,0 +1,136 @@ +package game_test + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var quests = []game.Quest{ + {Title: "Slime trouble", Objectives: []game.Objective{{Text: "Slay slimes", Have: 3, Need: 5}, {Text: "Tell the mayor", Have: 1, Need: 1}}}, + {Title: "Lost cat", Objectives: []game.Objective{{Text: "Find Mittens", Have: 1, Need: 1}}}, +} + +func TestQuestLogTracksTheChosenQuest(t *testing.T) { + tracked, changes := 0, 0 + tt := view(600, 300, &world{clk: newClock()}, func(c *ui.Context) { + if game.QuestLog(c, "Quests", quests, &tracked, game.QuestLogOptions{}).Changed { + changes++ + } + }) + shown := func(title string) int { + return len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != title })) + } + // The tracked title shows in the list and the tracker. + if shown("Slime trouble") != 2 || shown("Lost cat") != 1 || !tt.HasText("TRACKING") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Lost cat"); err != nil { + t.Fatal(err) + } + if tracked != 1 || changes != 1 || shown("Lost cat") != 2 { + t.Fatalf("clicking Lost cat tracked %d", tracked) + } + panics(t, "an objective past its need", func() { + view(600, 300, &world{clk: newClock()}, func(c *ui.Context) { + game.QuestTracker(c, game.Quest{Title: "x", Objectives: []game.Objective{{Text: "y", Have: 6, Need: 5}}}, game.QuestTrackerOptions{}) + }) + }) +} + +func TestQuestTrackerTicksMetObjectives(t *testing.T) { + tt := view(400, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.QuestTracker(c, quests[0], game.QuestTrackerOptions{}) + }) + img := tt.Image() + // Ticks are 7-dot boxes 5 dots into each 16-dot row. + if count(t, img, 1, 16+5, 7, 7, pixel.Good) != 0 || count(t, img, 1, 32+5, 7, 7, pixel.Good) == 0 { + t.Fatal("ticks do not follow the objectives") + } + keeps(t, img) +} + +func TestPartyFramesGreyTheFallen(t *testing.T) { + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.PartyFrames(c, "Party", []game.Member{{Name: "Aria", Portrait: game.Hero, HP: 30, MaxHP: 40, MP: 12, MaxMP: 20}, + {Name: "Brom", Portrait: game.Hero, MaxHP: 50, MaxMP: 10}}, game.PartyFramesOptions{}) + }) + img := tt.Image() + aria, brom := find(t, tt, "Aria"), find(t, tt, "Brom") + ax, ay := dots(aria) + bx, by := dots(brom) + // Each portrait is a 28-dot box inside a 3-dot pad. + if count(t, img, ax+3, ay+3, 28, 28, pixel.Bad) == 0 || count(t, img, bx+3, by+3, 28, 28, pixel.Bad) != 0 || count(t, img, bx+3, by+3, 28, 28, pixel.Shade) == 0 { + t.Fatal("the fallen member is not greyed") + } + keeps(t, img) +} + +func TestCharacterSheetShowsWhoAndStats(t *testing.T) { + tt := view(400, 200, &world{clk: newClock(), zh: true}, func(c *ui.Context) { + game.CharacterSheet(c, game.Character{Name: "Aria", Class: "Knight", Level: 7, Portrait: game.Hero, Stats: []game.Stat{{Name: "STR", Value: 14}}}, game.CharacterSheetOptions{}) + }) + if !tt.HasText("Aria") || !tt.HasText("Knight 等级 7") || !tt.HasText("STR 14") { + t.Fatalf("texts %q", tt.Texts()) + } + keeps(t, tt.Image()) +} + +func TestLevelUpScreenSpendsPoints(t *testing.T) { + attrs := []game.Attribute{{Name: "STR", Base: 12}, {Name: "DEX", Base: 9}} + points, done, changes := 2, false, 0 + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + r := game.LevelUpScreen(c, "Level up", attrs, &points, game.LevelUpScreenOptions{}) + done = done || r.Done + if r.Changed { + changes++ + } + }) + for _, label := range []string{"STR UP", "STR UP", "DEX UP", "DEX DOWN", "STR DOWN"} { + tt.Click(label) + } + // With no points, DEX UP and DEX DOWN are off. + if attrs[0].Added != 1 || attrs[1].Added != 0 || points != 1 || changes != 3 { + t.Fatalf("STR +%d, DEX +%d, %d points, %d changes", attrs[0].Added, attrs[1].Added, points, changes) + } + tt.Click("DONE") + if !done { + t.Fatal("DONE did not report") + } +} + +func TestShopBuysAndSellsWithinGold(t *testing.T) { + wares := []game.Ware{{Item: data.Item{Sprite: icon.Potion, Name: "Potion"}, Price: 30}, {Item: data.Item{Sprite: icon.Shield, Name: "Shield"}, Price: 400}} + gold, bought, sold := 100, -1, -1 + tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) { + r := game.Shop(c, "Shop", wares, &gold, game.ShopOptions{}) + if r.Bought >= 0 { + bought = r.Bought + } + if r.Sold >= 0 { + sold = r.Sold + } + }) + tt.Click("BUY Potion") + tt.Click("BUY Potion") + tt.Click("SELL Potion") + if gold != 55 || wares[0].Owned != 1 || bought != 0 || sold != 0 { + t.Fatalf("gold %d, owned %d", gold, wares[0].Owned) + } + tt.Click("BUY Shield") + tt.Click("SELL Shield") + if gold != 55 || wares[1].Owned != 0 { + t.Fatal("a ware too dear, or none owned, still traded") + } + sx, sy := dots(find(t, tt, "Shield")) + if count(t, tt.Image(), sx, sy, 30, 16, pixel.Faint) == 0 || count(t, tt.Image(), sx, sy, 30, 16, pixel.Ink) != 0 { + t.Fatal("a dear ware's name is not Faint") + } + keeps(t, tt.Image()) +} diff --git a/game/shop.go b/game/shop.go new file mode 100644 index 0000000..3cb7692 --- /dev/null +++ b/game/shop.go @@ -0,0 +1,79 @@ +package game + +import ( + "fmt" + "log/slog" + "strconv" + + "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" +) + +// Ware is an item for sale; Owned counts those held. +type Ware struct { + Item data.Item + Price, Owned int +} + +type ShopOptions struct { + Disabled bool +} + +type ShopResult struct { + Element *ui.Element + // Bought and Sold are this frame's trades, or -1. + Bought, Sold int +} + +// Shop buys at price, sells at half; dear wares fade. +func Shop(c *ui.Context, text string, wares []Ware, gold *int, o ShopOptions) ShopResult { + k := pixel.K(c) + if *gold < 0 { + panic(fmt.Sprintf("game: Shop gold %d", *gold)) + } + name := 0 + for _, w := range wares { + if w.Price < 0 || w.Owned < 0 || w.Item.Sprite == nil { + panic(fmt.Sprintf("game: ware %q costs %d, owned %d", w.Item.Name, w.Price, w.Owned)) + } + name = max(name, pixel.Measure(w.Item.Name)) + } + col := ui.Column(c).Gap(k.D(3)).Role(ui.RoleGroup).Label(text) + res := ShopResult{Element: col, Bought: -1, Sold: -1} + col.Children(func() { + CoinCounter(c, k.Msg("gold"), *gold, CoinCounterOptions{}) + for i := range wares { + w := &wares[i] + dear := w.Price > *gold + ink := pixel.Ink + if dear { + ink = pixel.Faint + } + ui.Row(c).Gap(k.D(5)).AlignItems(ui.Center).Children(func() { + data.ItemSlot(c, w.Item, data.ItemSlotOptions{}) + ui.Box(c).Width(k.D(name + 4)).Children(func() { pixel.Text(c, w.Item.Name, pixel.TextOptions{Slot: ink}) }) + ui.Row(c).Width(k.D(40)).Gap(k.D(2)).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, icon.Coin, 0, pixel.Ink, 1) + }) + pixel.Text(c, strconv.Itoa(w.Price), pixel.TextOptions{Slot: ink}) + }) + ui.Box(c).Width(k.D(24)).Children(func() { pixel.Text(c, "×"+strconv.Itoa(w.Owned), pixel.TextOptions{Slot: pixel.Faint}) }) + if input.Button(c, k.Msg("buy"), input.ButtonOptions{Size: input.Small, Label: k.Msg("buy") + " " + w.Item.Name, Disabled: o.Disabled || dear}).Clicked { + *gold, w.Owned, res.Bought = *gold-w.Price, w.Owned+1, i + } + if input.Button(c, k.Msg("sell"), input.ButtonOptions{Size: input.Small, Label: k.Msg("sell") + " " + w.Item.Name, Disabled: o.Disabled || w.Owned == 0}).Clicked { + *gold, w.Owned, res.Sold = *gold+w.Price/2, w.Owned-1, i + } + }) + } + }) + if res.Bought >= 0 || res.Sold >= 0 { + slog.Debug("8bit: shop", "label", text, "bought", res.Bought, "sold", res.Sold, "gold", *gold) + } + return res +} diff --git a/game/skillbutton.go b/game/skillbutton.go new file mode 100644 index 0000000..8cf6aa7 --- /dev/null +++ b/game/skillbutton.go @@ -0,0 +1,66 @@ +package game + +import ( + "fmt" + "log/slog" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SkillButtonOptions struct { + Disabled bool +} + +type SkillButtonResult struct { + Element *ui.Element + // Clicked is a press while the skill is ready. + Clicked bool +} + +// SkillButton shows a skill, its key and a cooldown wipe. +func SkillButton(c *ui.Context, text string, sprite *pixel.Sprite, key string, cooldown float64, o SkillButtonOptions) SkillButtonResult { + k := pixel.K(c) + if sprite == nil || !(cooldown >= 0 && cooldown <= 1) || !pixel.MicroOK(key) { + panic(fmt.Sprintf("game: SkillButton %q needs a sprite, a micro key %q, a cooldown in 0–1 (%v)", text, key, cooldown)) + } + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(text).Disabled(o.Disabled) }) + res := SkillButtonResult{Element: shell, Clicked: base.Clicked() && cooldown == 0} + if res.Clicked { + slog.Debug("8bit: skill", "label", text) + } + state := k.Msg("ready") + if cooldown > 0 { + state = strconv.Itoa(int(math.Ceil(cooldown*100))) + "%" + } + base.Description(state) + shell.Size(k.D(24), k.D(24)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + style := pixel.Raised + if st.Press { + style = pixel.Sunken + } + g.Frame(0, 0, 24, 24, style, pixel.Face) + g.Sprite((24-sprite.W)/2, (24-sprite.H)/2, sprite, 0, pixel.Ink, 1) + // The wipe clears from twelve, clockwise, as the cooldown ends. + for y := 2; y < 22; y++ { + for x := 2; x < 22; x++ { + a := math.Atan2(float64(x)-11.5, 11.5-float64(y)) / (2 * math.Pi) + if a < 0 { + a++ + } + if a >= 1-cooldown { + g.Veil(x, y, 1, 1, pixel.Edge, 11) + } + } + } + kw := pixel.MicroWidth(key) + g.Rect(1, 1, kw+3, 8, pixel.Lit) + g.Micro(2, 2, key, pixel.Ink, 1) + g.Finish(st) + }) + return res +} diff --git a/game/skilltree.go b/game/skilltree.go new file mode 100644 index 0000000..7e16925 --- /dev/null +++ b/game/skilltree.go @@ -0,0 +1,181 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Skill is a node of a skill tree. +type Skill struct { + Name string + Sprite *pixel.Sprite + // At places it on the tree's grid. + At Spot + // Needs lists the skills that open it. + Needs []int + Learned bool +} + +type SkillTreeOptions struct { + Disabled bool +} + +type SkillTreeResult struct { + Element *ui.Element + Changed bool + // Learned is the skill learned this frame, or -1. + Learned int +} + +// node is a node's side; cellPitch adds the gap. +const node, cellPitch = 20, 28 + +// SkillTree links skills; points learn the cursor's skill. +func SkillTree(c *ui.Context, text string, skills []Skill, cursor, points *int, o SkillTreeOptions) SkillTreeResult { + k := pixel.K(c) + if *cursor < 0 || *cursor >= len(skills) || *points < 0 { + panic(fmt.Sprintf("game: SkillTree cursor %d of %d, %d points", *cursor, len(skills), *points)) + } + spots, cols, rows := make([]Spot, len(skills)), 0, 0 + for i, s := range skills { + if s.Sprite == nil || s.At.X < 0 || s.At.Y < 0 { + panic(fmt.Sprintf("game: skill %q needs a sprite and a place on the grid, not %v", s.Name, s.At)) + } + for _, n := range s.Needs { + if n < 0 || n >= len(skills) || n == i { + panic(fmt.Sprintf("game: skill %q needs %d", s.Name, n)) + } + } + for j := range i { + if skills[j].At == s.At { + panic(fmt.Sprintf("game: skills %q and %q share %v", skills[j].Name, s.Name, s.At)) + } + } + spots[i], cols, rows = s.At, max(cols, s.At.X+1), max(rows, s.At.Y+1) + } + if loop := cycle(skills); loop >= 0 { + panic(fmt.Sprintf("game: skill %q needs itself through others", skills[loop].Name)) + } + open := func(i int) bool { + for _, n := range skills[i].Needs { + if !skills[n].Learned { + return false + } + } + return !skills[i].Learned + } + old := *cursor + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) + }) + arrows(base, spots, cursor) + learn := base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) + top := pixel.LineHeight + 4 + // A click moves the cursor; clicking it again learns. + if base.Clicked() { + x, y, _ := base.PointerPosition() + dx, dy := int(x/k.Dot()), int(y/k.Dot())-top + for i, s := range spots { + if dx >= s.X*cellPitch && dx < s.X*cellPitch+node && dy >= s.Y*cellPitch && dy < s.Y*cellPitch+node { + learn, *cursor = learn || i == *cursor, i + } + } + } + res := SkillTreeResult{Element: shell, Changed: *cursor != old, Learned: -1} + if res.Changed { + slog.Debug("8bit: skill cursor", "label", text, "skill", skills[*cursor].Name) + } + if learn && open(*cursor) && *points > 0 { + skills[*cursor].Learned, *points, res.Learned = true, *points-1, *cursor + slog.Debug("8bit: skill learned", "label", text, "skill", skills[*cursor].Name, "points", *points) + } + states := make([]string, len(skills)) + for i := range skills { + switch { + case skills[i].Learned: + states[i] = k.Msg("learned") + case open(i): + states[i] = k.Msg("can_learn") + default: + states[i] = k.Msg("locked") + } + } + caption := skills[*cursor].Name + " · " + states[*cursor] + cur, shown, left := *cursor, append([]Skill(nil), skills...), fmt.Sprintf(k.Msg("points"), *points) + base.Description(caption + " · " + left) + opens := make([]bool, len(skills)) + for i := range skills { + opens[i] = open(i) + } + w := max(cols*cellPitch-(cellPitch-node), pixel.Measure(left)+8+pixel.Measure(caption)) + shell.Size(k.D(w), k.D(top+rows*cellPitch-(cellPitch-node)+4)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Text(0, 0, left, pixel.Accent, 1) + g.Text(w-pixel.Measure(caption), 0, caption, pixel.Faint, 1) + mid := func(s Spot) (int, int) { return s.X*cellPitch + node/2, top + s.Y*cellPitch + node/2 } + for _, s := range shown { + for _, n := range s.Needs { + x0, y0 := mid(shown[n].At) + x1, y1 := mid(s.At) + ink := pixel.Shade + if shown[n].Learned { + ink = pixel.Accent + } + g.Line(x0, y0, x1, y1, ink) + } + } + for i, s := range shown { + x, y := s.At.X*cellPitch, top+s.At.Y*cellPitch + sx, sy := x+(node-s.Sprite.W)/2, y+(node-s.Sprite.H)/2 + switch { + case s.Learned: + g.Frame(x, y, node, node, pixel.Raised, pixel.Lit) + g.Sprite(sx, sy, s.Sprite, 0, pixel.Ink, 1) + case opens[i]: + g.Frame(x, y, node, node, pixel.Flat, pixel.Face) + g.Sprite(sx, sy, s.Sprite, 0, pixel.Ink, 1) + default: + g.Frame(x, y, node, node, pixel.Sunken, pixel.Well) + g.Silhouette(sx, sy, s.Sprite, 0, pixel.Shade, 1) + } + if i == cur { + g.HLine(x-2, y-2, node+4, pixel.Accent) + g.HLine(x-2, y+node+1, node+4, pixel.Accent) + g.VLine(x-2, y-2, node+4, pixel.Accent) + g.VLine(x+node+1, y-2, node+4, pixel.Accent) + } + } + g.Finish(st) + }) + return res +} + +// cycle finds a skill needing itself, or -1. +func cycle(skills []Skill) int { + state := make([]int, len(skills)) + var visit func(i int) bool + // visit is false on a loop; state 1 is mid-walk. + visit = func(i int) bool { + if state[i] != 0 { + return state[i] == 2 + } + state[i] = 1 + for _, n := range skills[i].Needs { + if !visit(n) { + return false + } + } + state[i] = 2 + return true + } + for i := range skills { + if !visit(i) { + return i + } + } + return -1 +} diff --git a/game/sprites.go b/game/sprites.go new file mode 100644 index 0000000..1d72d05 --- /dev/null +++ b/game/sprites.go @@ -0,0 +1,72 @@ +package game + +import "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + +// coin spins: face, turning, edge on, turning back. +var coin = pixel.MustSprite(` + ............ + ...eeeeee... + ..eyyyyyye.. + .eyllyyyyye. + .eylyyeyyye. + .eylyyeyyse. + .eyyyyeyyse. + .eyyyyeyyse. + .eyyyyyyyse. + ..essssssse. + ...eeeeeee.. + ............ + + ............ + ....eeee.... + ...elyyye... + ..elyyyyse.. + ..elyeyyse.. + ..elyeyyse.. + ..eyyeyyse.. + ..eyyeyyse.. + ..eyyyyyse.. + ...essssse.. + ....eeeee... + ............ + + ............ + .....ee..... + .....le..... + .....ye..... + .....ye..... + .....ye..... + .....ye..... + .....ye..... + .....ye..... + .....se..... + .....ee..... + ............ + + ............ + ....eeee.... + ...eyyyle... + ..esyyyyle.. + ..esyyeyle.. + ..esyyeyle.. + ..esyyeyye.. + ..esyyeyye.. + ..esyyyyye.. + ..essssse... + ...eeeee.... + ............`) + +// Hero is a small adventurer's head, the default life icon. +var Hero = pixel.MustSprite(` + ....rrrr.... + ...rrrrrr... + ..rrrrrrrrr. + ..eeeeeeee.. + ..elkllkle.. + ..elllllle.. + ..ellaalle.. + ...elllle... + ....eeee.... + ...gggggg... + ..ggeggegg.. + ..gg.gg.gg..`) diff --git a/game/statuseffects.go b/game/statuseffects.go new file mode 100644 index 0000000..2a854b0 --- /dev/null +++ b/game/statuseffects.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Effect is a buff, or a debuff, with turns left. +type Effect struct { + Sprite *pixel.Sprite + Name string + // Turns left; zero hides the count. + Turns int + Debuff bool +} + +type StatusEffectsOptions struct{} + +// StatusEffects rows effect icons: buffs edged Good, debuffs Bad. +func StatusEffects(c *ui.Context, text string, effects []Effect, o StatusEffectsOptions) *ui.Element { + k := pixel.K(c) + parts := make([]string, len(effects)) + for i, ef := range effects { + if ef.Sprite == nil || ef.Turns < 0 { + panic(fmt.Sprintf("game: effect %q has no sprite or %d turns", ef.Name, ef.Turns)) + } + parts[i] = ef.Name + if ef.Turns > 0 { + parts[i] += " " + strconv.Itoa(ef.Turns) + } + } + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(len(effects)*17), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, ef := range effects { + x, edge := i*17, pixel.Good + if ef.Debuff { + edge = pixel.Bad + } + g.Frame(x, 0, 16, 16, pixel.Flat, pixel.Well) + g.Rect(x+1, 1, 14, 1, edge) + g.Sprite(x+(16-ef.Sprite.W)/2, (16-ef.Sprite.H)/2, ef.Sprite, 0, pixel.Ink, 1) + if ef.Turns > 0 { + n := strconv.Itoa(ef.Turns) + g.Rect(x+15-pixel.MicroWidth(n)-2, 9, pixel.MicroWidth(n)+2, 7, edge) + g.Micro(x+14-pixel.MicroWidth(n), 10, n, pixel.AccentInk, 1) + } + } + }) +} diff --git a/game/timerhud.go b/game/timerhud.go new file mode 100644 index 0000000..d464fce --- /dev/null +++ b/game/timerhud.go @@ -0,0 +1,39 @@ +package game + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TimerHUDOptions struct{} + +// TimerHUD shows TIME in seconds; under 30 it blinks red. +func TimerHUD(c *ui.Context, left time.Duration, o TimerHUDOptions) *ui.Element { + k := pixel.K(c) + if left < 0 { + panic(fmt.Sprintf("game: TimerHUD %v left", left)) + } + secs := int(math.Ceil(left.Seconds())) + name, n := k.Msg("time"), fmt.Sprintf("%03d", secs) + hurry := left < 30*time.Second + top := tagH(name) + 2 + w := max(tagW(name), 2*pixel.Measure(n)) + return ui.Box(c).Role(ui.RoleText).Label(name+" "+n).Size(k.D(w), k.D(top+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + tag(g, 0, 0, name, pixel.Faint) + ink := pixel.Ink + if hurry { + ink = pixel.Bad + // It blinks at 2 Hz; reduced, it holds steady. + if !k.Reduce() && g.Busy(4)%2 == 1 { + return + } + } + g.Text(0, top-2, n, ink, 2) + }) +} diff --git a/pixel/canvas_test.go b/pixel/canvas_test.go index bb8dbba..998f5bd 100644 --- a/pixel/canvas_test.go +++ b/pixel/canvas_test.go @@ -268,3 +268,26 @@ func boolInt(b bool) int { } return 0 } + +func TestTextVeilKeepsDotsBelowTheLevel(t *testing.T) { + plain := canvas(t, 40, 40, func(g pixel.Canvas) { g.Text(2, 2, "HM", pixel.Ink, 2) }) + for _, level := range []int{0, 8, 16} { + veil := canvas(t, 40, 40, func(g pixel.Canvas) { g.TextVeil(2, 2, "HM", pixel.Ink, level, 2) }) + kept, all := 0, 0 + for y := range 40 { + for x := range 40 { + ink, got := plain.at(x, y) == pixel.Ink, veil.at(x, y) == pixel.Ink + if got && (!ink || pixel.Bayer(x, y) >= level) { + t.Fatalf("level %d: dot %d,%d drawn", level, x, y) + } + if ink && pixel.Bayer(x, y) < level && !got { + t.Fatalf("level %d: dot %d,%d missing", level, x, y) + } + kept, all = kept+boolInt(got), all+boolInt(ink) + } + } + if want := all * level / 16; kept < want-all/8 || kept > want+all/8 { + t.Fatalf("level %d kept %d of %d dots", level, kept, all) + } + } +} diff --git a/pixel/dither.go b/pixel/dither.go index b547a5f..5d52e00 100644 --- a/pixel/dither.go +++ b/pixel/dither.go @@ -40,3 +40,21 @@ func checkLevel(level int) { panic(fmt.Sprintf("pixel: dither level %d outside 0–16", level)) } } + +// TextVeil draws s, keeping glyph dots below the dither level. +func (g Canvas) TextVeil(x, y int, s string, ink Slot, level, scale int) { + checkLevel(level) + checkScale(scale) + for _, r := range s { + gl := glyphOf(r) + gx := x + gl.x*scale + for j := range gl.h * scale { + yy := y + gl.top()*scale + j + row := bayer[(g.gy+yy)&3] + runs(gl.w*scale, func(i int) bool { + return gl.on(i/scale, j/scale) && row[(g.gx+gx+i)&3] < level + }, func(a, b int) { g.Rect(gx+a, yy, b-a, 1, ink) }) + } + x += gl.adv * scale + } +} diff --git a/pixel/messages.go b/pixel/messages.go index afa1e65..9b58c29 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -24,14 +24,18 @@ var messages = map[Locale]map[string]string{ "just_now": "JUST NOW", "min_ago": "%d MIN AGO", "hours_ago": "%d H AGO", "yesterday": "YESTERDAY", "days_ago": "%d DAYS AGO", "in_min": "IN %d MIN", "in_hours": "IN %d H", "tomorrow": "TOMORROW", "in_days": "IN %d DAYS", "spring": "SPRING", "summer": "SUMMER", "autumn": "AUTUMN", "winter": "WINTER", "minutes": "MIN", "seconds": "SEC", + "level": "LV %d", "score": "SCORE", "hi_score": "HI-SCORE", "time": "TIME", "compass": "N E S W", + "head": "HEAD", "body": "BODY", "weapon": "WEAPON", "shield": "SHIELD", "ring": "RING", + "points": "POINTS %d", "learned": "LEARNED", "can_learn": "CAN LEARN", "locked": "LOCKED", "ready": "READY", + "buy": "BUY", "sell": "SELL", "done": "DONE", "tracking": "TRACKING", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", "continue": "继续?", "game_over": "游戏结束", "loading": "读取中", "search": "搜索", "empty": "空空如也", "minimize": "最小化", "resize": "调整大小", - "scroll_up": "向上滚动", "scroll_down": "向下滚动", + "scroll_up": "向上翻动", "scroll_down": "向下翻动", "up": "上", "down": "下", "left": "左", "right": "右", "disabled": "已禁用", - "remove": "移除", "online": "在线", "away": "离开", "busy": "忙碌", "offline": "离线", + "remove": "移除", "online": "在线", "away": "离开", "busy": "繁忙", "offline": "离线", "gold": "金", "silver": "银", "bronze": "铜", "common": "普通", "uncommon": "优秀", "rare": "稀有", "epic": "史诗", "legendary": "传说", "hold": "长按确认", "del": "删", "end": "完", "lives": "命数 +30!", @@ -46,6 +50,10 @@ var messages = map[Locale]map[string]string{ "just_now": "刚刚", "min_ago": "%d 分钟前", "hours_ago": "%d 小时前", "yesterday": "昨天", "days_ago": "%d 天前", "in_min": "%d 分钟后", "in_hours": "%d 小时后", "tomorrow": "明天", "in_days": "%d 天后", "spring": "春", "summer": "夏", "autumn": "秋", "winter": "冬", "minutes": "分", "seconds": "秒", + "level": "等级 %d", "score": "得分", "hi_score": "最高分", "time": "时间", "compass": "北 东 南 西", + "head": "头", "body": "身体", "weapon": "武器", "shield": "盾牌", "ring": "戒指", + "points": "点数 %d", "learned": "已学会", "can_learn": "可学习", "locked": "未解锁", "ready": "就绪", + "buy": "购买", "sell": "出售", "done": "完成", "tracking": "追踪中", }, } diff --git a/pixel/messages_test.go b/pixel/messages_test.go index ac5a023..8aa82fc 100644 --- a/pixel/messages_test.go +++ b/pixel/messages_test.go @@ -26,3 +26,15 @@ func TestMessagesCoverBothLocales(t *testing.T) { }() Kit{locale: En}.Msg("no_such_key") } + +func TestMessagesHaveGlyphs(t *testing.T) { + for loc, m := range messages { + for key, s := range m { + for _, r := range s { + if _, ok := font12.find(r); !ok && r != ' ' { + t.Errorf("locale %d message %q: the font has no %q", loc, key, r) + } + } + } + } +}