From 86a69013333d16d7cdc376a119818c0f7b88bf57 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 02:48:20 -0400 Subject: [PATCH] Add game components 269-290; CommandMenu AutoFocus Press start, title screen, save slots, options menu, game over with CONTINUE, credits roll, ending card, loading tips, letterbox, mission brief, achievements, result screen, crafting grid, loot popup, treasure chest, gacha reel, interact prompt, turn order, battle log, target selector, ATB gauge and stamina bar. Cued effects count runs, so each cue change plays once and reports once. CommandMenu takes AutoFocus when enabled. HealthBar, ManaBar and CountUp keep exact colors, ticks and counts for the largest ints. --- feedback/countup.go | 12 +- feedback/effects_test.go | 32 +++ gallery/demos_loot.go | 244 +++++++++++++++++++++++ gallery/demos_screens.go | 175 +++++++++++++++++ gallery/game_demo_test.go | 33 ++++ gallery/registry.go | 2 +- game/achievements.go | 93 +++++++++ game/atbgauge.go | 50 +++++ game/battlelog.go | 42 ++++ game/count_internal_test.go | 18 ++ game/crafting.go | 78 ++++++++ game/credits.go | 78 ++++++++ game/endingcard.go | 37 ++++ game/gachareel.go | 76 ++++++++ game/game.go | 20 +- game/gameover.go | 43 ++++ game/healthbar.go | 7 +- game/hud_test.go | 26 +++ game/interactprompt.go | 59 ++++++ game/letterbox.go | 62 ++++++ game/loadingtips.go | 44 +++++ game/lootpopup.go | 51 +++++ game/manabar.go | 9 +- game/menus_test.go | 258 ++++++++++++++++++++++++ game/missionbrief.go | 37 ++++ game/optionsmenu.go | 151 ++++++++++++++ game/pressstart.go | 38 ++++ game/resultscreen.go | 57 ++++++ game/rewards_test.go | 379 ++++++++++++++++++++++++++++++++++++ game/saveslots.go | 89 +++++++++ game/staminabar.go | 43 ++++ game/targetselector.go | 78 ++++++++ game/titlescreen.go | 61 ++++++ game/treasurechest.go | 94 +++++++++ game/turnorder.go | 51 +++++ navigation/commandmenu.go | 8 +- navigation/menus_test.go | 32 +++ pixel/messages.go | 4 + 38 files changed, 2663 insertions(+), 8 deletions(-) create mode 100644 gallery/demos_loot.go create mode 100644 gallery/demos_screens.go create mode 100644 game/achievements.go create mode 100644 game/atbgauge.go create mode 100644 game/battlelog.go create mode 100644 game/count_internal_test.go create mode 100644 game/crafting.go create mode 100644 game/credits.go create mode 100644 game/endingcard.go create mode 100644 game/gachareel.go create mode 100644 game/gameover.go create mode 100644 game/interactprompt.go create mode 100644 game/letterbox.go create mode 100644 game/loadingtips.go create mode 100644 game/lootpopup.go create mode 100644 game/menus_test.go create mode 100644 game/missionbrief.go create mode 100644 game/optionsmenu.go create mode 100644 game/pressstart.go create mode 100644 game/resultscreen.go create mode 100644 game/rewards_test.go create mode 100644 game/saveslots.go create mode 100644 game/staminabar.go create mode 100644 game/targetselector.go create mode 100644 game/titlescreen.go create mode 100644 game/treasurechest.go create mode 100644 game/turnorder.go diff --git a/feedback/countup.go b/feedback/countup.go index f7e0815..3f54cfa 100644 --- a/feedback/countup.go +++ b/feedback/countup.go @@ -34,7 +34,7 @@ func CountUp(c *ui.Context, value int, o CountUpOptions) *ui.Element { if !k.Reduce() { step = k.Since(c, s.at, 15, 8) } - return s.from + (s.to-s.from)*step/8 + return lerp(s.from, s.to, step) } if value != s.to { s.from, s.to, s.at = shown(), value, k.Now() @@ -45,3 +45,13 @@ func CountUp(c *ui.Context, value int, o CountUpOptions) *ui.Element { k.Canvas(p, r).Text(0, 0, n, o.Slot, sc) }) } + +// lerp moves step eighths from a toward b, overflow-free. +func lerp(a, b, step int) int { + if b >= a { + d := uint64(b) - uint64(a) + return int(uint64(a) + d/8*uint64(step) + d%8*uint64(step)/8) + } + d := uint64(a) - uint64(b) + return int(uint64(a) - (d/8*uint64(step) + d%8*uint64(step)/8)) +} diff --git a/feedback/effects_test.go b/feedback/effects_test.go index 9e7ccdb..a483b45 100644 --- a/feedback/effects_test.go +++ b/feedback/effects_test.go @@ -1,6 +1,8 @@ package feedback_test import ( + "math" + "strconv" "testing" "time" @@ -30,6 +32,36 @@ func TestCountUpStepsOrJumps(t *testing.T) { } } +func TestCountUpReachesAHugeValue(t *testing.T) { + clk := newClock() + v := 0 + tt := view(600, 100, clk, false, func(c *ui.Context) { + feedback.CountUp(c, v, feedback.CountUpOptions{Slot: pixel.Ink}) + pixel.Text(c, strconv.Itoa(math.MaxInt64), pixel.TextOptions{}) + }) + v = math.MaxInt64 + wait(tt, clk, 0) + wait(tt, clk, time.Second) + // The count ends on the same digits as plain text. + img := tt.Image() + if !equal(slots(t, img, ui.Rect{W: 600, H: 32}), slots(t, img, ui.Rect{Y: 32, W: 600, H: 32})) { + t.Fatal("the count ended on other digits") + } + // From -1, halfway is 4611686018427387903, across the sign. + half := -1 + tt = view(600, 100, clk, false, func(c *ui.Context) { + feedback.CountUp(c, half, feedback.CountUpOptions{Slot: pixel.Ink}) + pixel.Text(c, "4611686018427387903", pixel.TextOptions{}) + }) + half = math.MaxInt64 + wait(tt, clk, 0) + wait(tt, clk, 4*time.Second/15+time.Millisecond) + img = tt.Image() + if !equal(slots(t, img, ui.Rect{W: 600, H: 32}), slots(t, img, ui.Rect{Y: 32, W: 600, H: 32})) { + t.Fatal("halfway the count drew other digits") + } +} + func TestPulseStopsUnderReduceMotion(t *testing.T) { for _, reduce := range []bool{false, true} { clk := newClock() diff --git a/gallery/demos_loot.go b/gallery/demos_loot.go new file mode 100644 index 0000000..49eb6e6 --- /dev/null +++ b/gallery/demos_loot.go @@ -0,0 +1,244 @@ +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/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var lootEntries = []Entry{ + {280, "ResultScreen", "结算画面", "game", "Time and score count up; then the rank lands.", demoResultScreen}, + {281, "Crafting", "合成台", "game", "Click a cell to swap ingredients. Herb over water makes a potion.", demoCrafting}, + {282, "LootPopup", "获得物品", "game", "YOU GOT: something nice! The item rises out of the box.", demoLootPopup}, + {283, "TreasureChest", "宝箱", "game", "Press the chest. It creaks open; the gem floats up.", demoTreasureChest}, + {284, "GachaReel", "抽奖转轮", "game", "SPIN: three reels, stopping one by one. No real money involved.", demoGachaReel}, + {285, "InteractPrompt", "互动提示", "game", "Walk up to the mayor and a TALK bubble bobs above him.", demoInteractPrompt}, + {286, "TurnOrder", "行动顺序", "game", "Who acts next. The active one stands raised.", demoTurnOrder}, + {287, "BattleLog", "战斗日志", "game", "Combat lines roll up as the fight goes on.", demoBattleLog}, + {288, "TargetSelector", "目标选择", "game", "A ▼ over the target. Arrows choose; Enter attacks.", demoTargetSelector}, + {289, "ATBGauge", "行动槽", "game", "Each gauge fills at its hero's speed and flashes READY.", demoATBGauge}, + {290, "StaminaBar", "耐力条", "game", "Sprint and it drains; rest and it refills.", demoStaminaBar}, +} + +type lootDemo struct { + result, cell, spin, turn, target int + grid []data.Item + stops [3]int + popup, chest, near, sprint bool + lines []game.Line + ready [3]time.Time + stamina float64 + last time.Time +} + +func lootState(c *ui.Context) *lootDemo { + now := pixel.K(c).Now() + return ui.Local(c.Root(), demoKey("loot"), func() lootDemo { + s := lootDemo{grid: make([]data.Item, 9), near: true, stamina: 1, last: now, ready: [3]time.Time{now, now, now}} + s.grid[1], s.grid[4] = herb, water + return s + }) +} + +var ( + herb = data.Item{Sprite: icon.Leaf, Name: "Herb"} + water = data.Item{Sprite: icon.Drop, Name: "Water"} +) + +func demoResultScreen(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + // A new key makes a fresh screen; counts run again. + ui.Box(c).Key(s.result).Children(func() { + game.ResultScreen(c, "Stage clear", 83*time.Second+450*time.Millisecond, 12500, "S", game.ResultScreenOptions{}) + }) + if small(c, "AGAIN", false) { + s.result++ + log("counting again") + } + }) +} + +func demoCrafting(c *ui.Context, log func(string)) { + s := lootState(c) + recipes := []game.Recipe{{Grid: [9]string{1: "Herb", 4: "Water"}, Result: data.Item{Sprite: icon.Potion, Name: "Potion"}}} + r := game.Crafting(c, "Workbench", s.grid, recipes, &s.cell, game.CraftingOptions{}) + // A picked cell cycles: empty, herb, water. + if r.Picked >= 0 { + switch s.grid[r.Picked].Name { + case "": + s.grid[r.Picked] = herb + case "Herb": + s.grid[r.Picked] = water + default: + s.grid[r.Picked] = data.Item{} + } + log(fmt.Sprintf("cell %d: %s", r.Picked+1, s.grid[r.Picked].Name)) + } + if r.Crafted { + s.grid = make([]data.Item, 9) + log("made a potion!") + } +} + +func demoLootPopup(c *ui.Context, log func(string)) { + s := lootState(c) + if small(c, "OPEN THE BOX", s.popup) { + s.popup = true + log("ooh") + } + if game.LootPopup(c, data.Item{Sprite: icon.Potion, Name: "POTION", Count: 3}, &s.popup, game.LootPopupOptions{}).Closed { + log("put it in the bag") + } +} + +func demoTreasureChest(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + ui.Row(c).Gap(k.D(10)).AlignItems(ui.End).Children(func() { + if game.TreasureChest(c, "Chest", data.Item{Sprite: icon.Gem, Name: "Gem"}, &s.chest, game.TreasureChestOptions{}).Opened { + log("a gem!") + } + if small(c, "CLOSE", !s.chest) { + s.chest = false + log("closed") + } + }) +} + +// faces are the gacha reel's faces. +var faces = []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Heart, Name: "Heart"}, {Sprite: icon.Coin, Name: "Coin"}, {Sprite: icon.Skull, Name: "Skull"}, {Sprite: icon.Gem, Name: "Gem"}} + +func demoGachaReel(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + dice := gameState(c).dice + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + if game.GachaReel(c, "Reels", faces, s.stops, s.spin, game.GachaReelOptions{}).Done { + log(fmt.Sprintf("%s %s %s", faces[s.stops[0]].Name, faces[s.stops[1]].Name, faces[s.stops[2]].Name)) + } + if small(c, "SPIN", false) { + s.stops = [3]int{dice.IntN(len(faces)), dice.IntN(len(faces)), dice.IntN(len(faces))} + s.spin++ + log("spinning") + } + }) +} + +func demoInteractPrompt(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + ui.Row(c).Gap(k.D(16)).AlignItems(ui.End).Padding(k.D(24), 0, 0, 0).Children(func() { + mayor := ui.Box(c).Size(k.D(24), k.D(24)).Label("Mayor").Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, game.Hero, 0, pixel.Ink, 2) + }) + if s.near { + game.InteractPrompt(c, mayor, "TALK", game.InteractPromptOptions{}) + } + if input.Toggle(c, "NEAR THE MAYOR", &s.near, input.ToggleOptions{Size: input.Small}).Changed { + log(fmt.Sprintf("near %v", s.near)) + } + }) +} + +// fighters are the turn order demo's lineup. +var fighters = []game.Fighter{{Name: "Aria", Portrait: game.Hero}, {Name: "Slime", Portrait: slime, Foe: true}, {Name: "Brom", Portrait: game.Hero}, {Name: "Bat", Portrait: icon.Bug, Foe: true}} + +func demoTurnOrder(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + ui.Row(c).Gap(k.D(10)).AlignItems(ui.Center).Children(func() { + game.TurnOrder(c, "Turns", fighters, s.turn, game.TurnOrderOptions{}) + if small(c, "NEXT TURN", false) { + s.turn = (s.turn + 1) % len(fighters) + log(fighters[s.turn].Name + "'s turn") + } + }) +} + +func demoBattleLog(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + dice := gameState(c).dice + ui.Column(c).Gap(k.D(6)).Children(func() { + game.BattleLog(c, "Battle", s.lines, game.BattleLogOptions{}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if small(c, "ATTACK", false) { + line := game.Line{Text: fmt.Sprintf("Slime takes %d.", 3+dice.IntN(9)), Slot: pixel.Bad} + s.lines = append(s.lines, game.Line{Text: "Aria attacks!"}, line) + log(line.Text) + } + if small(c, "HEAL", false) { + s.lines = append(s.lines, game.Line{Text: "Brom heals 8.", Slot: pixel.Good}) + log("healed") + } + }) + }) +} + +func demoTargetSelector(c *ui.Context, log func(string)) { + s := lootState(c) + enemies := []game.Enemy{{Name: "Slime", Sprite: slime, HP: 6, MaxHP: 9}, {Name: "Bat", Sprite: icon.Bug, HP: 5, MaxHP: 5}, {Name: "Ghost", Sprite: icon.Skull, MaxHP: 7}} + r := game.TargetSelector(c, "Targets", enemies, &s.target, game.TargetSelectorOptions{}) + if r.Changed { + log("aim at " + enemies[s.target].Name) + } + if r.Chosen >= 0 { + log("attack " + enemies[r.Chosen].Name) + } +} + +func demoATBGauge(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + for i, h := range []struct { + name string + speed int + }{{"ARIA", 30}, {"BROM", 45}, {"CYRA", 20}} { + ui.Row(c).Gap(k.D(6)).AlignItems(ui.End).Children(func() { + // Each gauge fills in its hero's speed, in tenths. + fill := k.Since(c, s.ready[i], 10, h.speed) + game.ATBGauge(c, h.name, float64(fill)/float64(h.speed), game.ATBGaugeOptions{Width: 80}) + if small(c, "ACT", fill < h.speed) { + s.ready[i] = k.Now() + log(h.name + " acts") + } + }) + } + }) +} + +func demoStaminaBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := lootState(c) + now := k.Now() + // Sprinting drains a quarter a second; rest refills a tenth. + if dt := now.Sub(s.last).Seconds(); s.sprint { + s.stamina = max(0, s.stamina-dt/4) + } else { + s.stamina = min(1, s.stamina+dt/10) + } + s.last = now + if s.sprint && s.stamina == 0 { + s.sprint = false + log("out of breath") + } + if s.sprint || s.stamina < 1 { + k.Tick(c, 10) + } + ui.Row(c).Gap(k.D(10)).AlignItems(ui.End).Children(func() { + game.StaminaBar(c, "STAMINA", s.stamina, game.StaminaBarOptions{}) + if input.Toggle(c, "SPRINT", &s.sprint, input.ToggleOptions{Size: input.Small, Disabled: s.stamina == 0}).Changed { + log(fmt.Sprintf("sprint %v", s.sprint)) + } + }) +} diff --git a/gallery/demos_screens.go b/gallery/demos_screens.go new file mode 100644 index 0000000..317c2fb --- /dev/null +++ b/gallery/demos_screens.go @@ -0,0 +1,175 @@ +package gallery + +import ( + "fmt" + "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/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var screenEntries = []Entry{ + {269, "SaveSlots", "存档位", "game", "Three files: where you are and how long you've played. Empty ones start anew.", demoSaveSlots}, + {270, "TitleScreen", "标题画面", "game", "Logo, PRESS START, then the menu. Escape goes back.", demoTitleScreen}, + {271, "PressStart", "按下开始", "game", "Blinks politely until you press something.", demoPressStart}, + {272, "OptionsMenu", "选项菜单", "game", "Up and Down pick a row; ◀ ▶ change it.", demoOptionsMenu}, + {273, "GameOver", "游戏结束", "game", "GAME OVER dithers in. CONTINUE? Of course you will.", demoGameOver}, + {274, "Credits", "制作人员", "game", "Credits roll up and rest on the last name.", demoCredits}, + {275, "EndingCard", "结局卡", "game", "THE END, dithering in on the dark.", demoEndingCard}, + {276, "LoadingTips", "加载提示", "game", "A tip every four seconds while the bar fills.", demoLoadingTips}, + {277, "Letterbox", "宽银幕", "game", "Cutscene bars slide in and out in four steps.", demoLetterbox}, + {278, "MissionBrief", "任务简报", "game", "The map, the objectives, and a big START.", demoMissionBrief}, + {279, "Achievements", "成就墙", "game", "Earned awards shine; locked ones are shadows with a hint.", demoAchievements}, +} + +type screenDemo struct { + slot, row, award, credits, ending, load int + speed, bgm, sfx, rumble int + started, cutscene bool + since time.Time +} + +func screenState(c *ui.Context) *screenDemo { + now := pixel.K(c).Now() + return ui.Local(c.Root(), demoKey("screens"), func() screenDemo { + return screenDemo{speed: 1, bgm: 7, sfx: 5, since: now} + }) +} + +func demoSaveSlots(c *ui.Context, log func(string)) { + s := screenState(c) + saves := []game.Save{{Place: "Slime Woods", Played: 3*time.Hour + 25*time.Minute, Portrait: game.Hero}, {}, {Place: "Castle Gate", Played: 12 * time.Minute, Portrait: game.Hero}} + r := game.SaveSlots(c, "Save files", saves, &s.slot, game.SaveSlotsOptions{}) + if r.Changed { + log(fmt.Sprintf("file %d", s.slot+1)) + } + if r.Chosen >= 0 { + log(fmt.Sprintf("load file %d", r.Chosen+1)) + } +} + +func demoTitleScreen(c *ui.Context, log func(string)) { + s := screenState(c) + was := s.started + r := game.TitleScreen(c, "SLIME QUEST", &s.started, game.TitleScreenOptions{Saved: true}) + if s.started != was { + log(fmt.Sprintf("started %v", s.started)) + } + if r.Chosen >= 0 { + log([]string{"new game", "continue", "options"}[r.Chosen]) + } +} + +func demoPressStart(c *ui.Context, log func(string)) { + if game.PressStart(c, game.PressStartOptions{}).Pressed { + log("here we go!") + } +} + +func demoOptionsMenu(c *ui.Context, log func(string)) { + s := screenState(c) + settings := []game.Setting{ + {Name: "TEXT SPEED", Choices: []string{"SLOW", "NORMAL", "FAST"}, Value: &s.speed}, + {Name: "BGM", Max: 10, Value: &s.bgm}, {Name: "SFX", Max: 10, Value: &s.sfx}, + {Name: "RUMBLE", Choices: []string{"OFF", "ON"}, Value: &s.rumble}, + } + if game.OptionsMenu(c, "Options", settings, &s.row, game.OptionsMenuOptions{}).Changed { + log(fmt.Sprintf("speed %d, bgm %d, sfx %d, rumble %d", s.speed, s.bgm, s.sfx, s.rumble)) + } +} + +func demoGameOver(c *ui.Context, log func(string)) { + r := game.GameOver(c, game.GameOverOptions{}) + if r.Continue { + log("never give up") + } + if r.Quit { + log("see you next time") + } +} + +func demoCredits(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := screenState(c) + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + credits := []game.Credit{{Heading: "DIRECTOR", Names: []string{"A Slime"}}, {Heading: "PIXELS", Names: []string{"Aria", "Brom"}}, + {Heading: "MUSIC", Names: []string{"The Bard"}}, {Heading: "THANK YOU", Names: []string{"for playing!"}}} + if game.Credits(c, "Credits", credits, game.CreditsOptions{Cue: s.credits}).Done { + log("the end of the credits") + } + if small(c, "ROLL AGAIN", false) { + s.credits++ + log("rolling") + } + }) +} + +func demoEndingCard(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := screenState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.EndingCard(c, game.EndingCardOptions{Cue: s.ending}) + if small(c, "AGAIN", false) { + s.ending++ + log("fade in") + } + }) +} + +func demoLoadingTips(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := screenState(c) + // The pretend load takes eight seconds, then starts over. + done := k.Since(c, s.since, 10, 80) + if done == 80 { + s.since, s.load = k.Now(), s.load+1 + log(fmt.Sprintf("loaded %d times", s.load)) + } + tips := []string{"Slimes fear fire. Mostly.", "Save often. Then save again.", "The cat is in the tower."} + game.LoadingTips(c, tips, float64(done)/80, game.LoadingTipsOptions{}) +} + +func demoLetterbox(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := screenState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + game.Letterbox(c, s.cutscene, game.LetterboxOptions{}, func() { + ui.Box(c).Size(k.D(200), k.D(80)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 4) + g.Rect(0, g.H-16, g.W, 16, pixel.Good) + g.Sprite(90, g.H-16-slime.H, slime, 0, pixel.Ink, 1) + }) + }) + if input.Toggle(c, "CUTSCENE", &s.cutscene, input.ToggleOptions{Size: input.Small}).Changed { + log(fmt.Sprintf("cutscene %v", s.cutscene)) + } + }) +} + +func demoMissionBrief(c *ui.Context, log func(string)) { + q := game.Quest{Title: "Rescue Mittens", Objectives: []game.Objective{{Text: "Reach the tower", Need: 1}, {Text: "Beat the bats", Have: 1, Need: 3}}} + area := []string{`############`, `#..""..~~..#`, `#..""...~..#`, `#....##....#`, `############`} + if game.MissionBrief(c, q, area, game.Spot{X: 2, Y: 3}, []game.Marker{{Spot: game.Spot{X: 9, Y: 1}, Slot: pixel.Warn}}, game.MissionBriefOptions{}).Start { + log("mission start!") + } +} + +func demoAchievements(c *ui.Context, log func(string)) { + s := screenState(c) + awards := []game.Award{ + {Sprite: icon.Sword, Name: "First blood", How: "Win your first fight.", Unlocked: true}, + {Sprite: icon.Coin, Name: "Rich", How: "Carry 1000 gold.", Unlocked: true}, + {Sprite: icon.Crown, Name: "Kingslayer", How: "Beat the Slime King."}, + {Sprite: icon.Heart, Name: "Cat person", How: "Bring Mittens home.", Unlocked: true}, + {Sprite: icon.Skull, Name: "Oops", How: "Fall in a pit 10 times."}, + {Sprite: icon.Star, Name: "Completionist", How: "Earn every award."}, + } + if game.Achievements(c, "Awards", awards, &s.award, game.AchievementsOptions{Columns: 3}).Changed { + log(awards[s.award].Name) + } +} diff --git a/gallery/game_demo_test.go b/gallery/game_demo_test.go index 2a37e43..ddc16ca 100644 --- a/gallery/game_demo_test.go +++ b/gallery/game_demo_test.go @@ -42,3 +42,36 @@ func TestHotbarDemoLogsAnEmptySlotUsed(t *testing.T) { t.Fatalf("using empty slot 6 logged nothing: %q", a.logs["256-hotbar"]) } } + +func TestScreenDemosLogTheirActions(t *testing.T) { + for _, tc := range []struct { + path string + steps []string + }{ + {"/c/269-saveslots", []string{"Save files"}}, {"/c/270-titlescreen", []string{"PRESS START"}}, {"/c/271-pressstart", []string{"PRESS START"}}, + {"/c/272-optionsmenu", []string{"Options", "Right"}}, {"/c/273-gameover", []string{"Enter"}}, {"/c/274-credits", []string{"ROLL AGAIN"}}, + {"/c/275-endingcard", []string{"AGAIN"}}, {"/c/277-letterbox", []string{"CUTSCENE"}}, {"/c/278-missionbrief", []string{"START"}}, + {"/c/279-achievements", []string{"Awards", "Right"}}, {"/c/280-resultscreen", []string{"AGAIN"}}, {"/c/281-crafting", []string{"CRAFT"}}, + {"/c/282-lootpopup", []string{"OPEN THE BOX", "OK"}}, {"/c/283-treasurechest", []string{"Chest", "CLOSE"}}, {"/c/284-gachareel", []string{"SPIN"}}, + {"/c/285-interactprompt", []string{"NEAR THE MAYOR"}}, {"/c/286-turnorder", []string{"NEXT TURN"}}, {"/c/287-battlelog", []string{"ATTACK", "HEAL"}}, + {"/c/288-targetselector", []string{"Targets", "Right", "Enter"}}, {"/c/290-staminabar", []string{"SPRINT"}}, + } { + a, tt := open(tc.path) + slug := tc.path[3:] + for _, step := range tc.steps { + before := len(a.logs[slug]) + switch step { + case "Right": + tt.Key(0, ui.KeyRight) + case "Enter": + tt.Key(0, ui.KeyEnter) + default: + click(t, tt, step) + } + // A focusing click may change nothing worth a line. + if len(a.logs[slug]) <= before && step != "Options" && step != "Awards" && step != "Targets" { + t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index c02d99d..6f84b3e 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries) func Entries() []Entry { return entries } diff --git a/game/achievements.go b/game/achievements.go new file mode 100644 index 0000000..101aa9b --- /dev/null +++ b/game/achievements.go @@ -0,0 +1,93 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Award is an achievement; locked ones show as silhouettes. +type Award struct { + Sprite *pixel.Sprite + Name string + // How tells how it is earned. + How string + Unlocked bool +} + +type AchievementsOptions struct { + // Columns is the grid's width; zero means 6. + Columns int + Disabled bool +} + +type AchievementsResult struct { + Element *ui.Element + Changed bool +} + +// tile is an award's cell, in dots. +const tile = 24 + +// Achievements grids awards; arrows read each one's name and how. +func Achievements(c *ui.Context, text string, awards []Award, selected *int, o AchievementsOptions) AchievementsResult { + k := pixel.K(c) + cols := pixel.Or("Achievements Columns", o.Columns, 6) + if len(awards) == 0 || *selected < 0 || *selected >= len(awards) { + panic(fmt.Sprintf("game: Achievements award %d of %d", *selected, len(awards))) + } + got := 0 + for _, a := range awards { + if a.Sprite == nil { + panic(fmt.Sprintf("game: award %q has no sprite", a.Name)) + } + if a.Unlocked { + got++ + } + } + old := *selected + col := ui.Column(c).Gap(k.D(4)) + res := AchievementsResult{Element: col} + col.Children(func() { + pixel.Text(c, fmt.Sprintf(k.Msg("unlocked"), got, len(awards)), pixel.TextOptions{Slot: pixel.Accent}) + shell, base, st, _ := pixel.Pad(c, text, cols, len(awards), tile, tile, selected, o.Disabled) + a := awards[*selected] + name := a.Name + if !a.Unlocked { + name = k.Msg("unknown") + } + base.Description(name + ": " + a.How) + sel, shown := *selected, append([]Award(nil), awards...) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, a := range shown { + x, y := i%cols*tile, i/cols*tile + sx, sy := x+(tile-a.Sprite.W)/2, y+(tile-a.Sprite.H)/2 + if a.Unlocked { + g.Frame(x+1, y+1, tile-2, tile-2, pixel.Raised, pixel.Lit) + g.Sprite(sx, sy, a.Sprite, 0, pixel.Ink, 1) + } else { + g.Frame(x+1, y+1, tile-2, tile-2, pixel.Sunken, pixel.Well) + g.Silhouette(sx, sy, a.Sprite, 0, pixel.Shade, 1) + } + if i == sel { + g.Rect(x, y, tile, 1, pixel.Accent) + g.Rect(x, y+tile-1, tile, 1, pixel.Accent) + g.Rect(x, y, 1, tile, pixel.Accent) + g.Rect(x+tile-1, y, 1, tile, pixel.Accent) + } + } + g.Finish(st) + }) + pixel.Text(c, name, pixel.TextOptions{}) + pixel.Text(c, a.How, pixel.TextOptions{Slot: pixel.Faint, Wrap: true}) + }) + res.Changed = *selected != old + if res.Changed { + slog.Debug("8bit: achievement", "label", text, "award", *selected) + } + return res +} diff --git a/game/atbgauge.go b/game/atbgauge.go new file mode 100644 index 0000000..d948fa3 --- /dev/null +++ b/game/atbgauge.go @@ -0,0 +1,50 @@ +package game + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ATBGaugeOptions struct { + // Width is in dots; zero means 64. + Width int +} + +// ATBGauge fills as a turn nears; full, it flashes READY. +func ATBGauge(c *ui.Context, text string, value float64, o ATBGaugeOptions) *ui.Element { + k := pixel.K(c) + if !(value >= 0 && value <= 1) { + panic(fmt.Sprintf("game: ATBGauge value %v outside 0–1", value)) + } + w := pixel.Or("ATBGauge Width", o.Width, 64) + ready := k.Msg("ready") + fits("ATBGauge", w, text, ready) + state := strconv.Itoa(int(value*100)) + "%" + if value == 1 { + state = ready + } + // Both labels sit on one line, tall enough for either. + top := max(headH(text), headH(ready)) + return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(state).Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + tag(g, 0, top-2-tagH(text), text, pixel.Ink) + if value < 1 { + meter(g, 0, top, w, 8, value, pixel.Info) + return + } + // Full, READY flashes at 4 Hz; reduced, it holds. + lit := g.Step(8)%2 == 0 + fill := pixel.Warn + if !lit { + fill = pixel.Lit + } + meter(g, 0, top, w, 8, 1, fill) + if lit { + tag(g, w-tagW(ready), top-2-tagH(ready), ready, pixel.Warn) + } + }) +} diff --git a/game/battlelog.go b/game/battlelog.go new file mode 100644 index 0000000..479c875 --- /dev/null +++ b/game/battlelog.go @@ -0,0 +1,42 @@ +package game + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Line is a battle log line; Slot zero is Ink. +type Line struct { + Text string + Slot pixel.Slot +} + +type BattleLogOptions struct { + // Width and Height are dots; zeros mean 180 by 68. + Width, Height int +} + +// BattleLog keeps the newest lines last; old ones roll off. +func BattleLog(c *ui.Context, text string, lines []Line, o BattleLogOptions) *ui.Element { + k := pixel.K(c) + w, h := pixel.Or("BattleLog Width", o.Width, 180), pixel.Or("BattleLog Height", o.Height, 68) + n := (h - 4) / pixel.LineHeight + if n < 1 { + panic("game: BattleLog is shorter than a line") + } + shown := lines[max(0, len(lines)-n):] + e := ui.Box(c).Role(ui.RoleStatus).Label(text) + if len(lines) > 0 { + e.Description(lines[len(lines)-1].Text) + } + return e.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, w, h, pixel.Sunken, pixel.Well) + g.Clip(2, 2, w-4, h-4, func() { + for i, l := range shown { + g.Text(4, h-2-(len(shown)-i)*pixel.LineHeight, l.Text, l.Slot, 1) + } + }) + }) +} diff --git a/game/count_internal_test.go b/game/count_internal_test.go new file mode 100644 index 0000000..edd3450 --- /dev/null +++ b/game/count_internal_test.go @@ -0,0 +1,18 @@ +package game + +import ( + "math" + "testing" + "time" +) + +func TestCountsNeverOverflow(t *testing.T) { + for _, tc := range [][3]int{{math.MaxInt64, 15, math.MaxInt64}, {100, 7, 46}, {0, 3, 0}, {29, 15, 29}} { + if got := upTo(tc[0], tc[1], 15); got != tc[2] { + t.Errorf("upTo(%d, %d, 15) = %d, want %d", tc[0], tc[1], got, tc[2]) + } + } + if got := upTo(time.Duration(math.MaxInt64), 15, 15); got != math.MaxInt64 { + t.Errorf("a long run counts to %d", got) + } +} diff --git a/game/crafting.go b/game/crafting.go new file mode 100644 index 0000000..f760532 --- /dev/null +++ b/game/crafting.go @@ -0,0 +1,78 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Recipe makes Result from item names in a 3×3 grid. +type Recipe struct { + Grid [9]string + Result data.Item +} + +type CraftingOptions struct { + Disabled bool +} + +type CraftingResult struct { + Element *ui.Element + Changed bool + // Picked is the cell chosen this frame, or -1. + Picked int + Crafted bool +} + +// Crafting is a 3×3 grid showing a known recipe's result. +func Crafting(c *ui.Context, text string, grid []data.Item, recipes []Recipe, selected *int, o CraftingOptions) CraftingResult { + k := pixel.K(c) + if len(grid) != 9 || *selected < 0 || *selected > 8 { + panic(fmt.Sprintf("game: Crafting has %d cells, cell %d; it takes 9", len(grid), *selected)) + } + var made data.Item + for _, r := range recipes { + if r.makes(grid) { + made = r.Result + break + } + } + row := ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center) + res := CraftingResult{Element: row, Picked: -1} + row.Children(func() { + old := *selected + shell, base, st, picked := pixel.Pad(c, text, 3, 9, data.SlotSize, data.SlotSize, selected, o.Disabled) + res.Changed, res.Picked = *selected != old, picked + base.Description(grid[*selected].String()) + sel, shown := *selected, append([]data.Item(nil), grid...) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, it := range shown { + data.DrawSlot(g, i%3*data.SlotSize, i/3*data.SlotSize, it, data.ItemSlotOptions{Selected: i == sel}) + } + g.Finish(st) + }) + pixel.Text(c, "→", pixel.TextOptions{Slot: pixel.Faint}) + data.ItemSlot(c, made, data.ItemSlotOptions{}) + res.Crafted = input.Button(c, k.Msg("craft"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || made.Sprite == nil}).Clicked + }) + if res.Changed || res.Crafted { + slog.Debug("8bit: crafting", "label", text, "cell", *selected, "crafted", res.Crafted, "item", made.Name) + } + return res +} + +// makes is whether the grid lays out the recipe's names. +func (r Recipe) makes(grid []data.Item) bool { + for i, name := range r.Grid { + if grid[i].Name != name { + return false + } + } + return true +} diff --git a/game/credits.go b/game/credits.go new file mode 100644 index 0000000..f626a51 --- /dev/null +++ b/game/credits.go @@ -0,0 +1,78 @@ +package game + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Credit is a heading and the names under it. +type Credit struct { + Heading string + Names []string +} + +type CreditsOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + // Cue rolls them again each time it changes. + Cue int +} + +type CreditsResult struct { + Element *ui.Element + // Done is true in the frame the roll ends. + Done bool +} + +type rolledKey struct{} + +// Credits roll up until the last name rests mid-window. +func Credits(c *ui.Context, text string, credits []Credit, o CreditsOptions) CreditsResult { + k := pixel.K(c) + w, h := pixel.Or("Credits Width", o.Width, 160), pixel.Or("Credits Height", o.Height, 96) + type line struct { + s string + slot pixel.Slot + } + var lines []line + var all []string + for i, cr := range credits { + if i > 0 { + lines = append(lines, line{}) + } + lines = append(lines, line{cr.Heading, pixel.Warn}) + all = append(all, cr.Heading+":") + for _, n := range cr.Names { + lines, all = append(lines, line{n, pixel.Ink}), append(all, n) + } + } + full := len(lines) * pixel.LineHeight + e := ui.Box(c).Role(ui.RoleGroup).Label(text).Description(strings.Join(all, " ")) + // A dot a frame, until the last line centers. + end, roll := h/2+full-pixel.LineHeight/2, start(k, e, o.Cue) + top, step := 0, end + if !k.Reduce() { + step = k.Since(c, roll.at, 16, end) + top = h - step + } else { + h = full + } + // Done comes once a roll. + done := ui.Local(e, rolledKey{}, func() int { return -1 }) + res := CreditsResult{Element: e, Done: step == end && *done != roll.run} + if res.Done { + *done = roll.run + } + e.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, w, h, func() { + for i, l := range lines { + g.Text((w-pixel.Measure(l.s))/2, top+i*pixel.LineHeight, l.s, l.slot, 1) + } + }) + }) + return res +} diff --git a/game/endingcard.go b/game/endingcard.go new file mode 100644 index 0000000..21da4ad --- /dev/null +++ b/game/endingcard.go @@ -0,0 +1,37 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type EndingCardOptions struct { + // Width and Height are dots; zeros mean 200 by 80. + Width, Height int + // Cue fades it in again each time it changes. + Cue int +} + +// EndingCard dithers THE END in, step by step. +func EndingCard(c *ui.Context, o EndingCardOptions) *ui.Element { + k := pixel.K(c) + w, h := pixel.Or("EndingCard Width", o.Width, 200), pixel.Or("EndingCard Height", o.Height, 80) + s := k.Msg("the_end") + if w < 3*pixel.Measure(s) || h < 3*pixel.LineHeight { + panic(fmt.Sprintf("game: EndingCard %d×%d is too small for %q", w, h, s)) + } + e := ui.Box(c).Role(ui.RoleText).Label(s).Size(k.D(w), k.D(h)) + level := 16 + // It fades in over 2 s; reduced, at once. + if !k.Reduce() { + level = k.Since(c, start(k, e, o.Cue).at, 8, 16) + } + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, w, h, pixel.Ink) + g.TextVeil((w-3*pixel.Measure(s))/2, (h-3*pixel.LineHeight)/2, s, pixel.Back, level, 3) + }) +} diff --git a/game/gachareel.go b/game/gachareel.go new file mode 100644 index 0000000..eb26eb5 --- /dev/null +++ b/game/gachareel.go @@ -0,0 +1,76 @@ +package game + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type GachaReelOptions struct{} + +type GachaReelResult struct { + Element *ui.Element + // Done is true in the frame the last reel stops. + Done bool +} + +type spunKey struct{} + +// reelStop staggers reel stops: 0.8 s, then 0.4 s more. +func reelStop(i int) time.Duration { + return 800*time.Millisecond + time.Duration(i)*400*time.Millisecond +} + +// GachaReel spins three reels per spin, stopping on stops. +func GachaReel(c *ui.Context, text string, faces []data.Item, stops [3]int, spin int, o GachaReelOptions) GachaReelResult { + k := pixel.K(c) + for _, f := range faces { + if f.Sprite == nil { + panic(fmt.Sprintf("game: GachaReel face %q has no sprite", f.Name)) + } + } + names := make([]string, 3) + for i, s := range stops { + if s < 0 || s >= len(faces) || faces[s].Sprite == nil { + panic(fmt.Sprintf("game: GachaReel stop %d of %d faces", s, len(faces))) + } + names[i] = faces[s].Name + } + e := ui.Box(c).Role(ui.RoleImage).Label(text) + roll := start(k, e, spin) + // First shown, the reels rest; each new spin spins once. + landed := ui.Local(e, spunKey{}, func() int { return roll.run }) + shown := stops + spinning := *landed != roll.run && !k.Reduce() + elapsed := k.Now().Sub(roll.at) + if spinning { + n := k.Tick(c, 12) + for i := range 3 { + if elapsed < reelStop(i) { + shown[i] = (n + 5*i) % len(faces) + } + } + } + res := GachaReelResult{Element: e, Done: *landed != roll.run && (k.Reduce() || elapsed >= reelStop(2))} + if res.Done { + *landed = roll.run + } + if *landed == roll.run { + e.Description(strings.Join(names, ", ")) + } + e.Size(k.D(3*28-4), k.D(28)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, f := range shown { + x := i * 28 + g.Frame(x, 0, 24, 28, pixel.Sunken, pixel.Well) + sp := faces[f].Sprite + g.Sprite(x+(24-sp.W)/2, (28-sp.H)/2, sp, 0, pixel.Ink, 1) + } + }) + return res +} diff --git a/game/game.go b/game/game.go index 3bddba9..be82c36 100644 --- a/game/game.go +++ b/game/game.go @@ -4,6 +4,7 @@ package game import ( "fmt" "strconv" + "time" "github.com/egoist/mygo/ui" @@ -55,7 +56,7 @@ func ratio(v, most int) string { return strconv.Itoa(v) + "/" + strconv.Itoa(mos // 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 { + if need := tagW(text) + 4 + tagW(value); w < need { panic(fmt.Sprintf("game: %s Width %d is under the %d its labels need", name, w, need)) } } @@ -104,3 +105,20 @@ func abs(n int) int { } return n } + +type startKey struct{} + +// begun is a cue, its start and its run count. +type begun struct { + cue, run int + at time.Time +} + +// start is e's first showing, or its last cue change. +func start(k pixel.Kit, e *ui.Element, cue int) begun { + s := ui.Local(e, startKey{}, func() begun { return begun{cue: cue, at: k.Now()} }) + if s.cue != cue { + s.cue, s.at, s.run = cue, k.Now(), s.run+1 + } + return *s +} diff --git a/game/gameover.go b/game/gameover.go new file mode 100644 index 0000000..24e100f --- /dev/null +++ b/game/gameover.go @@ -0,0 +1,43 @@ +package game + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type GameOverOptions struct { + Disabled bool +} + +type GameOverResult struct { + Element *ui.Element + Continue, Quit bool +} + +// GameOver dithers GAME OVER in, above CONTINUE and QUIT. +func GameOver(c *ui.Context, o GameOverOptions) GameOverResult { + k := pixel.K(c) + s := k.Msg("game_over") + col := ui.Column(c).Gap(k.D(10)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(s) + res := GameOverResult{Element: col} + col.Children(func() { + e := ui.Box(c).Role(ui.RoleText).Label(s).Size(k.D(3*pixel.Measure(s)), k.D(3*pixel.LineHeight)) + level := 16 + // It starts as it appears, dithering in for 2 s. + if !k.Reduce() { + level = k.Since(c, start(k, e, 0).at, 8, 16) + } + e.Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).TextVeil(0, 0, s, pixel.Bad, level, 3) }) + items := []navigation.MenuItem{{Label: k.Msg("continue_game")}, {Label: k.Msg("quit")}} + r := navigation.CommandMenu(c, s, items, navigation.CommandMenuOptions{AutoFocus: true, Disabled: o.Disabled}) + res.Continue, res.Quit = r.Chosen == 0, r.Chosen == 1 + }) + if res.Continue || res.Quit { + slog.Debug("8bit: game over", "continue", res.Continue) + } + return res +} diff --git a/game/healthbar.go b/game/healthbar.go index 0f6af49..5db86b1 100644 --- a/game/healthbar.go +++ b/game/healthbar.go @@ -56,14 +56,15 @@ func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui if shown > float64(hp) { k.Tick(c, 30) } + // Divide, not multiply: huge HP must not overflow. ink := pixel.Good switch { - case hp*4 <= full: + case hp <= full/4: ink = pixel.Bad - case hp*2 <= full: + case hp <= full/2: ink = pixel.Warn } - low := hp > 0 && hp*4 <= full + low := hp > 0 && hp <= full/4 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) diff --git a/game/hud_test.go b/game/hud_test.go index fc0c27e..abf9bce 100644 --- a/game/hud_test.go +++ b/game/hud_test.go @@ -1,6 +1,7 @@ package game_test import ( + "math" "testing" "time" @@ -88,6 +89,31 @@ func TestHealthBarReducedHasNoTrailAndNoBlink(t *testing.T) { } } +func TestHugeHPKeepsItsColor(t *testing.T) { + tt := view(500, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.HealthBar(c, "HP", math.MaxInt64/2, math.MaxInt64, game.HealthBarOptions{Width: 200}) + }) + // Half the largest HP is Warn, not wrapped to Bad. + if n := count(t, tt.Image(), 0, 10, 200, 1, pixel.Warn); n != 98 { + t.Fatalf("half of the largest HP fills %d Warn dots", n) + } + tt = view(500, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.ManaBar(c, "MP", 1, math.MaxInt64/2, game.ManaBarOptions{Width: 200, Tick: math.MaxInt64/4 + 1}) + }) + if n := count(t, tt.Image(), 2, 10, 196, 1, pixel.Edge); n != 1 { + t.Fatalf("one tick at the middle came out as %d", n) + } + // Ticks stop at the top; dense ticks drop out. + for _, tc := range []struct{ full, tick, want int }{{math.MaxInt64, math.MaxInt64 - 1, 1}, {1000, 10, 0}} { + tt = view(500, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.ManaBar(c, "MP", 1, tc.full, game.ManaBarOptions{Width: 200, Tick: tc.tick}) + }) + if n := count(t, tt.Image(), 2, 10, 196, 1, pixel.Edge); n != tc.want { + t.Fatalf("full %d, tick %d drew %d ticks", tc.full, tc.tick, n) + } + } +} + 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() diff --git a/game/interactprompt.go b/game/interactprompt.go new file mode 100644 index 0000000..d9496b7 --- /dev/null +++ b/game/interactprompt.go @@ -0,0 +1,59 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type InteractPromptOptions struct { + // Key is the pad button's letter; zero means A. + Key string +} + +// InteractPrompt bobs a Ⓐ TALK bubble above target. +func InteractPrompt(c *ui.Context, target *ui.Element, action string, o InteractPromptOptions) *ui.Element { + k := pixel.K(c) + key := o.Key + if key == "" { + key = "A" + } + if len([]rune(key)) != 1 || !pixel.MicroOK(key) { + panic(fmt.Sprintf("game: InteractPrompt key %q is not one micro letter", key)) + } + b := target.Bounds() + ww, wh := c.Size() + // The bubble's tail points at the target's middle. + x := max(0, min(b.X+b.W/2-k.D(10), ww-k.D(pixel.Measure(action)+30))) + at := max(3, int((b.X+b.W/2-x)/k.Dot())) + bob := 0 + if !k.Reduce() { + bob = k.Tick(c, 2) % 2 + } + var e *ui.Element + ui.Overlay(c, func() { + e = ui.Box(c).Absolute().Left(x).Bottom(wh - b.Y + k.D(1+bob)).PassThrough().Role(ui.RoleText).Label(key + " " + action) + // Bounds lag a layout; if the target moved, build again. + e.Draw(func(p *ui.Painter, r ui.Rect) { + if target.Bounds() != b { + c.Invalidate() + } + }) + e.Children(func() { + display.Bubble(c, display.BubbleOptions{Tail: display.Bottom, At: at}, func() { + ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(k.D(11), k.D(11)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Disc(5, 5, 5, pixel.Ink) + g.Micro((11-pixel.MicroWidth(key))/2, 3, key, pixel.Back, 1) + }) + pixel.Text(c, action, pixel.TextOptions{}) + }) + }) + }) + }) + return e +} diff --git a/game/letterbox.go b/game/letterbox.go new file mode 100644 index 0000000..f22f025 --- /dev/null +++ b/game/letterbox.go @@ -0,0 +1,62 @@ +package game + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LetterboxOptions struct { + // Bar is each bar's height in dots; zero means 16. + Bar int +} + +type barsKey struct{} + +// bars is the last on, its change time and step. +type bars struct { + on bool + at time.Time + from int +} + +// Letterbox slides cutscene bars over content in four steps. +func Letterbox(c *ui.Context, on bool, o LetterboxOptions, content func()) *ui.Element { + k := pixel.K(c) + bar := pixel.Or("Letterbox Bar", o.Bar, 16) + var cover *ui.Element + e := layout.Layers(c, content, func() { cover = ui.Box(c).Fill().PassThrough() }) + // First shown, the bars are already where on puts them. + s := ui.Local(e, barsKey{}, func() bars { return bars{on: on, from: 4 * boolInt(on)} }) + // shown is the step, 0–4; turns start where it is. + shown := func() int { + if k.Reduce() { + return 4 * boolInt(s.on) + } + if n := k.Since(c, s.at, 8, 4); s.on { + return min(4, s.from+n) + } else { + return max(0, s.from-n) + } + } + if on != s.on { + s.from, s.on, s.at = shown(), on, k.Now() + } + h := bar * shown() / 4 + cover.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, h, pixel.Ink) + g.Rect(0, g.H-h, g.W, h, pixel.Ink) + }) + return e +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/game/loadingtips.go b/game/loadingtips.go new file mode 100644 index 0000000..f33ae7a --- /dev/null +++ b/game/loadingtips.go @@ -0,0 +1,44 @@ +package game + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LoadingTipsOptions struct { + // Width is in dots; zero means 200. + Width int +} + +// tipTurn is how long a tip shows. +const tipTurn = 4 * time.Second + +// LoadingTips turns tips every 4 s under a progress bar. +func LoadingTips(c *ui.Context, tips []string, value float64, o LoadingTipsOptions) *ui.Element { + k := pixel.K(c) + w := pixel.Or("LoadingTips Width", o.Width, 200) + if len(tips) == 0 { + panic("game: LoadingTips has no tips") + } + // Tips turn on the wall clock, like a loader's dots. + now := k.Now() + turn := now.Truncate(tipTurn) + k.At(c, turn.Add(tipTurn)) + tip := tips[int(turn.Unix()/int64(tipTurn/time.Second))%len(tips)] + name := k.Msg("tip") + return ui.Column(c).Gap(k.D(6)).Width(k.D(w)).Children(func() { + feedback.Progress(c, k.Msg("loading"), value, feedback.ProgressOptions{Width: w - 4 - pixel.MicroWidth("100%"), Percent: true}) + lines := pixel.Wrap(tip, w-tagW(name)-4, 3) + ui.Box(c).Role(ui.RoleStatus).Label(name+": "+tip).Size(k.D(w), k.D(len(lines)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + tag(g, 0, 3, name, pixel.Accent) + for i, l := range lines { + g.Text(tagW(name)+4, i*pixel.LineHeight, l, pixel.Faint, 1) + } + }) + }) +} diff --git a/game/lootpopup.go b/game/lootpopup.go new file mode 100644 index 0000000..6346f34 --- /dev/null +++ b/game/lootpopup.go @@ -0,0 +1,51 @@ +package game + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LootPopupOptions struct { + Disabled bool +} + +type LootPopupResult struct { + Element *ui.Element + // Closed is OK, Escape or a click outside. + Closed bool +} + +// LootPopup announces YOU GOT: an item, rising, until OK. +func LootPopup(c *ui.Context, it data.Item, open *bool, o LootPopupOptions) LootPopupResult { + k := pixel.K(c) + if it.Sprite == nil { + panic(fmt.Sprintf("game: LootPopup item %q has no sprite", it.Name)) + } + title := fmt.Sprintf(k.Msg("you_got"), it.String()) + w := max(140, pixel.Measure(title)+24) + r := overlay.Dialog(c, title, open, overlay.DialogOptions{Actions: []string{k.Msg("ok")}, Width: w, Disabled: o.Disabled}, func() { + sp := it.Sprite + e := ui.Box(c).Size(k.D(w-24), k.D(3*sp.H+12)).Role(ui.RoleImage).Label(it.String()) + // The item rises 8 dots; reduced, it starts up. + rise := 8 + if !k.Reduce() { + rise = k.Since(c, start(k, e, 0).at, 16, 8) + } + e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := (g.W - 3*sp.W) / 2 + g.Sprite(x, g.H-3*sp.H-rise-2, sp, 0, pixel.Ink, 3) + if rise == 8 { + for _, d := range [][2]int{{-4, 2}, {3 * sp.W, 4}, {-2, 3 * sp.H / 2}, {3*sp.W + 2, 3 * sp.H / 2}} { + g.Rect(x+d[0], d[1], 2, 2, pixel.Warn) + } + } + }) + }) + return LootPopupResult{Element: r.Element, Closed: r.Action == 0 || r.Closed} +} diff --git a/game/manabar.go b/game/manabar.go index 298925d..171d9f0 100644 --- a/game/manabar.go +++ b/game/manabar.go @@ -25,8 +25,15 @@ func ManaBar(c *ui.Context, text string, mp, full int, o ManaBarOptions) *ui.Ele g := k.Canvas(p, r) head(g, w, text, ratio(mp, full)) meter(g, 0, top, w, 8, float64(mp)/float64(full), pixel.Info) + // Ticks under two dots apart are left out. + if float64(step)/float64(full)*float64(w-4) < 2 { + return + } for v := step; v < full; v += step { - g.VLine(2+v*(w-4)/full, top+2, 4, pixel.Edge) + g.VLine(2+min(w-5, int(float64(v)/float64(full)*float64(w-4))), top+2, 4, pixel.Edge) + if v > full-step { + break + } } }) } diff --git a/game/menus_test.go b/game/menus_test.go new file mode 100644 index 0000000..f028623 --- /dev/null +++ b/game/menus_test.go @@ -0,0 +1,258 @@ +package game_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestPressStartBlinksAndReports(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + pressed := 0 + tt := view(300, 60, wd, func(c *ui.Context) { + if game.PressStart(c, game.PressStartOptions{}).Pressed { + pressed++ + } + }) + var ink []int + for range 2 { + ink = append(ink, count(t, tt.Image(), 0, 0, 100, 20, pixel.Ink)) + wd.clk.t = wd.clk.t.Add(500 * time.Millisecond) + tt.Frame() + } + if blinked := (ink[0] == 0) != (ink[1] == 0); blinked == reduce { + t.Fatalf("reduced %v: PRESS START showed %v", reduce, ink) + } + tt.Click("PRESS START") + tt.Key(0, ui.KeyEnter) + if pressed != 2 { + t.Fatalf("a click and Enter pressed %d times", pressed) + } + } +} + +func TestTitleScreenStartsThenChooses(t *testing.T) { + started, chosen := false, -1 + tt := view(600, 300, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.TitleScreen(c, "SLIME QUEST", &started, game.TitleScreenOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + tt.Click("PRESS START") + if !started || tt.HasText("PRESS START") { + t.Fatalf("PRESS START left %q", tt.Texts()) + } + // The menu takes focus; no save turns CONTINUE off. + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if chosen != -1 { + t.Fatalf("CONTINUE with no save chose %d", chosen) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if chosen != 2 { + t.Fatalf("OPTIONS chose %d", chosen) + } + tt.Key(0, ui.KeyEscape) + if started || !tt.HasText("PRESS START") { + t.Fatal("Escape did not go back to PRESS START") + } + keeps(t, tt.Image()) + // Shown first thing, the menu still takes the keys. + again, picked := true, -1 + tt = view(600, 300, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.TitleScreen(c, "SLIME QUEST", &again, game.TitleScreenOptions{}); r.Chosen >= 0 { + picked = r.Chosen + } + }) + tt.Key(0, ui.KeyEnter) + if picked != 0 { + t.Fatalf("Enter on a fresh menu chose %d", picked) + } +} + +func TestSaveSlotsChooseByClickAndKeys(t *testing.T) { + saves := []game.Save{{Place: "Woods", Played: 90 * time.Minute, Portrait: game.Hero}, {}, {Place: "Castle", Played: time.Minute}} + sel, chosen := 0, -1 + tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.SaveSlots(c, "Saves", saves, &sel, game.SaveSlotsOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + r := find(t, tt, "Saves") + // Cards are 32 dots tall with a 3-dot gap. + tt.ClickAt(r.X+40, r.Y+float32(2*(2*35+10))) + if sel != 2 || chosen != 2 { + t.Fatalf("clicking card 3 chose %d, %d", sel, chosen) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if chosen != 1 { + t.Fatalf("Up, Enter chose %d", chosen) + } + keeps(t, tt.Image()) + // A long place name stays on its card. + long := []game.Save{{Place: "The Very Long Forest of Many Wobbly Slimes", Played: time.Hour, Portrait: game.Hero}} + first := 0 + tt = view(500, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.SaveSlots(c, "Long", long, &first, game.SaveSlotsOptions{}) + }) + if n := count(t, tt.Image(), 180, 0, 70, 40, pixel.Ink); n != 0 { + t.Fatalf("%d place dots past the card", n) + } + panics(t, "an empty save with play time", func() { + view(400, 300, &world{clk: newClock()}, func(c *ui.Context) { + game.SaveSlots(c, "x", []game.Save{{Played: time.Hour}}, &first, game.SaveSlotsOptions{}) + }) + }) +} + +func TestOptionsMenuStepsAndClamps(t *testing.T) { + speed, bgm, row, changes := 0, 9, 0, 0 + tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) { + if game.OptionsMenu(c, "Options", []game.Setting{{Name: "TEXT SPEED", Choices: []string{"SLOW", "FAST"}, Value: &speed}, {Name: "BGM", Max: 10, Value: &bgm}}, &row, game.OptionsMenuOptions{}).Changed { + changes++ + } + }) + // The focusing click lands on the second row. + tt.Click("Options") + for _, k := range []ui.Key{ui.KeyUp, ui.KeyLeft, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyLeft} { + tt.Key(0, k) + } + // SLOW stays, one step to FAST, BGM tops at 10. + if speed != 1 || bgm != 9 || row != 1 || changes != 3 { + t.Fatalf("speed %d, bgm %d, row %d, %d changes", speed, bgm, row, changes) + } + keeps(t, tt.Image()) + panics(t, "a setting with both kinds", func() { + view(500, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.OptionsMenu(c, "x", []game.Setting{{Name: "x", Choices: []string{"A"}, Max: 3, Value: &speed}}, &row, game.OptionsMenuOptions{}) + }) + }) +} + +func TestOptionsMenuKeysDoNotReplayClicks(t *testing.T) { + level, row := 5, 0 + tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.OptionsMenu(c, "Options", []game.Setting{{Name: "BGM", Max: 10, Value: &level}}, &row, game.OptionsMenuOptions{}) + }) + r := find(t, tt, "Options") + // ▶ follows the hand, name, ◀ and ten blocks. + bx := pixel.Hand.W + 4 + pixel.Measure("BGM") + 10 + pixel.Measure("◀") + 3 + 40 + 3 + tt.ClickAt(r.X+float32(2*(bx+2)), r.Y+16) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if level != 6 { + t.Fatalf("a click on ▶, Enter and Space left BGM at %d", level) + } +} + +func TestGameOverDithersInAndAnswers(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + quit := false + tt := view(600, 300, wd, func(c *ui.Context) { + quit = quit || game.GameOver(c, game.GameOverOptions{}).Quit + }) + r := find(t, tt, "GAME OVER") + x, y := dots(r) + first := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad) + wd.clk.t = wd.clk.t.Add(3 * time.Second) + tt.Frame() + full := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad) + if full == 0 || (first == full) != reduce { + t.Fatalf("reduced %v: GAME OVER drew %d dots, then %d", reduce, first, full) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if !quit { + t.Fatalf("reduced %v: Down, Enter did not quit", reduce) + } + } +} + +func TestCreditsRollOnceAndAgainOnCue(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + cue, done := 0, 0 + tt := view(400, 400, wd, func(c *ui.Context) { + if game.Credits(c, "Credits", []game.Credit{{Heading: "DIRECTOR", Names: []string{"Aria"}}, {Heading: "CAST", Names: []string{"A", "B", "C", "D", "E", "F"}}}, game.CreditsOptions{Cue: cue}).Done { + done++ + } + }) + // Rolling, it starts empty; reduced, it shows all ten lines. + if lit := count(t, tt.Image(), 0, 0, 160, 96, pixel.Warn) > 0; lit != reduce || (done == 1) != reduce { + t.Fatalf("reduced %v: first frame lit %v, done %d", reduce, lit, done) + } + if h := find(t, tt, "Credits").H; (h == 2*160) != reduce { + t.Fatalf("reduced %v: the window is %v px tall", reduce, h) + } + for range 3 { + wd.clk.t = wd.clk.t.Add(5 * time.Second) + tt.Frame() + } + if done != 1 || count(t, tt.Image(), 0, 0, 160, 96, pixel.Ink) == 0 { + t.Fatalf("reduced %v: done %d times; the last name not resting", reduce, done) + } + cue = 1 + tt.Frame() + for range 3 { + wd.clk.t = wd.clk.t.Add(5 * time.Second) + tt.Frame() + } + if done != 2 { + t.Fatalf("reduced %v: a new cue rolled %d times in all", reduce, done) + } + // An old cue mid-roll rolls anew; reduced, both end. + cue = 2 + tt.Frame() + cue = 1 + tt.Frame() + for range 3 { + wd.clk.t = wd.clk.t.Add(5 * time.Second) + tt.Frame() + } + if want := map[bool]int{false: 3, true: 4}[reduce]; done != want { + t.Fatalf("reduced %v: going back to cue 1 rolled %d times in all", reduce, done) + } + } +} + +func TestEndingCardDithersIn(t *testing.T) { + wd := &world{clk: newClock()} + tt := view(400, 200, wd, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{}) }) + first := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back) + wd.clk.t = wd.clk.t.Add(time.Second) + tt.Frame() + half := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back) + wd.clk.t = wd.clk.t.Add(2 * time.Second) + tt.Frame() + if full := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back); !(first < half && half < full) { + t.Fatalf("THE END dots %d, %d, %d", first, half, full) + } +} + +func TestEndingCardRefusesNoRoom(t *testing.T) { + panics(t, "a card narrower than THE END", func() { + view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{Width: 40}) }) + }) +} + +func TestLoadingTipsTurnEveryFourSeconds(t *testing.T) { + wd := &world{clk: newClock()} + tt := view(500, 100, wd, func(c *ui.Context) { + game.LoadingTips(c, []string{"Tip one.", "Tip two."}, 0.5, game.LoadingTipsOptions{}) + }) + a := tt.HasText("TIP: Tip one.") || tt.HasText("TIP: Tip two.") + before := tt.HasText("TIP: Tip one.") + wd.clk.t = wd.clk.t.Add(4 * time.Second) + tt.Frame() + if !a || tt.HasText("TIP: Tip one.") == before { + t.Fatalf("tips did not turn: %q", tt.Texts()) + } +} diff --git a/game/missionbrief.go b/game/missionbrief.go new file mode 100644 index 0000000..7cddca7 --- /dev/null +++ b/game/missionbrief.go @@ -0,0 +1,37 @@ +package game + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MissionBriefOptions struct { + Disabled bool +} + +type MissionBriefResult struct { + Element *ui.Element + Start bool +} + +// MissionBrief sets the map beside the objectives and START. +func MissionBrief(c *ui.Context, q Quest, area []string, at Spot, marks []Marker, o MissionBriefOptions) MissionBriefResult { + k := pixel.K(c) + row := ui.Row(c).Gap(k.D(10)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(q.Title) + res := MissionBriefResult{Element: row} + row.Children(func() { + Minimap(c, q.Title, area, nil, at, marks, MinimapOptions{Cell: 5}) + ui.Column(c).Gap(k.D(8)).Children(func() { + QuestTracker(c, q, QuestTrackerOptions{}) + res.Start = input.Button(c, k.Msg("start"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked + }) + }) + if res.Start { + slog.Debug("8bit: mission start", "quest", q.Title) + } + return res +} diff --git a/game/optionsmenu.go b/game/optionsmenu.go new file mode 100644 index 0000000..66b7825 --- /dev/null +++ b/game/optionsmenu.go @@ -0,0 +1,151 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Setting is an options row: a choice, or a level. +type Setting struct { + Name string + Choices []string + // Max makes a level row, 0 to Max, not Choices. + Max int + Value *int +} + +// top is the setting's highest value. +func (s Setting) top() int { + if s.Max > 0 { + return s.Max + } + return len(s.Choices) - 1 +} + +// shown is the setting's value as words. +func (s Setting) shown() string { + if s.Max > 0 { + return fmt.Sprintf("%d/%d", *s.Value, s.Max) + } + return s.Choices[*s.Value] +} + +type OptionsMenuOptions struct { + Disabled bool +} + +type OptionsMenuResult struct { + Element *ui.Element + Changed bool +} + +// rowH is an options row's height in dots. +const rowH = pixel.LineHeight + 2 + +// OptionsMenu rows settings: Up and Down choose, ◀ ▶ change. +func OptionsMenu(c *ui.Context, text string, settings []Setting, row *int, o OptionsMenuOptions) OptionsMenuResult { + k := pixel.K(c) + if len(settings) == 0 || *row < 0 || *row >= len(settings) { + panic(fmt.Sprintf("game: OptionsMenu row %d of %d", *row, len(settings))) + } + name, value := 0, 0 + for _, s := range settings { + if (len(s.Choices) > 0) == (s.Max > 0) || s.Value == nil || *s.Value < 0 || *s.Value > s.top() { + panic(fmt.Sprintf("game: setting %q needs Choices or Max, and a value in range", s.Name)) + } + name = max(name, pixel.Measure(s.Name)) + value = max(value, 4*s.Max) + for _, ch := range s.Choices { + value = max(value, pixel.Measure(ch)) + } + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled) + }) + res, was := OptionsMenuResult{Element: shell}, *row + step := func(d int) { + s := settings[*row] + if v := *s.Value + d; v >= 0 && v <= s.top() { + *s.Value, res.Changed = v, true + } + } + if base.Shortcut(0, ui.KeyUp) && *row > 0 { + *row-- + } + if base.Shortcut(0, ui.KeyDown) && *row < len(settings)-1 { + *row++ + } + if base.Shortcut(0, ui.KeyLeft) { + step(-1) + } + if base.Shortcut(0, ui.KeyRight) { + step(1) + } + // Enter and Space are taken, so clicks come from pointers. + base.Shortcut(0, ui.KeyEnter) + base.Shortcut(0, ui.KeySpace) + aw := pixel.Measure("◀") + ax := pixel.Hand.W + 4 + name + 10 + vx := ax + aw + 3 + bx := vx + value + 3 + // A click picks its row; an arrow click steps too. + if base.Clicked() { + x, y, _ := base.PointerPosition() + dx, dy := int(x/k.Dot()), int(y/k.Dot()) + if i := dy / rowH; dy >= 0 && i < len(settings) { + *row = i + switch { + case dx >= ax && dx < ax+aw: + step(-1) + case dx >= bx && dx < bx+aw: + step(1) + } + } + } + base.Description(settings[*row].Name + ": " + settings[*row].shown()) + if res.Changed || *row != was { + slog.Debug("8bit: option", "label", text, "setting", settings[*row].Name, "value", settings[*row].shown()) + } + cur := *row + values := make([]int, len(settings)) + for i, s := range settings { + values[i] = *s.Value + } + shell.Size(k.D(bx+aw), k.D(len(settings)*rowH)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, s := range settings { + y, v := i*rowH, values[i] + if i == cur { + g.Sprite(0, y+(rowH-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1) + } + g.Text(pixel.Hand.W+4, y, s.Name, pixel.Ink, 1) + arrow := func(x int, glyph string, on bool) { + ink := pixel.Faint + if on { + ink = pixel.Accent + } + g.Text(x, y, glyph, ink, 1) + } + arrow(ax, "◀", v > 0) + arrow(bx, "▶", v < s.top()) + if s.Max > 0 { + for j := range s.Max { + if j < v { + g.Rect(vx+j*4, y+5, 3, 7, pixel.Good) + } else { + g.Checker(vx+j*4, y+5, 3, 7, pixel.Shade) + } + } + continue + } + ch := s.Choices[v] + g.Text(vx+(value-pixel.Measure(ch))/2, y, ch, pixel.Ink, 1) + } + g.Finish(st) + }) + return res +} diff --git a/game/pressstart.go b/game/pressstart.go new file mode 100644 index 0000000..f9e4a66 --- /dev/null +++ b/game/pressstart.go @@ -0,0 +1,38 @@ +package game + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PressStartOptions struct { + Disabled bool +} + +type PressStartResult struct { + Element *ui.Element + Pressed bool +} + +// PressStart blinks PRESS START until pressed. +func PressStart(c *ui.Context, o PressStartOptions) PressStartResult { + k := pixel.K(c) + s := k.Msg("press_start") + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(s).Disabled(o.Disabled) }) + res := PressStartResult{Element: shell, Pressed: base.Clicked()} + if res.Pressed { + slog.Debug("8bit: press start") + } + shell.Size(k.D(pixel.Measure(s)+8), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + // It blinks at 1 Hz; reduced, it holds still. + if g.Step(2)%2 == 0 { + g.Text(4, 2, s, pixel.Ink, 1) + } + g.Finish(st) + }) + return res +} diff --git a/game/resultscreen.go b/game/resultscreen.go new file mode 100644 index 0000000..ee1d2a2 --- /dev/null +++ b/game/resultscreen.go @@ -0,0 +1,57 @@ +package game + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ResultScreenOptions struct{} + +// ResultScreen counts time and score up, then shows the rank. +func ResultScreen(c *ui.Context, text string, took time.Duration, score int, rank string, o ResultScreenOptions) *ui.Element { + k := pixel.K(c) + if took < 0 || score < 0 || rank == "" { + panic(fmt.Sprintf("game: ResultScreen %v, %d, rank %q", took, score, rank)) + } + rows := [][2]string{{k.Msg("time"), ""}, {k.Msg("score"), ""}, {k.Msg("rank"), rank}} + label := rows[0][0] + " " + lap(took) + ", " + rows[1][0] + " " + fmt.Sprint(score) + ", " + rows[2][0] + " " + rank + e := ui.Box(c).Role(ui.RoleGroup).Label(text).Description(label) + // Counts run 1.5 s from showing; reduced, they end. + step := 15 + if !k.Reduce() { + step = k.Since(c, start(k, e, 0).at, 10, 15) + } + rows[0][1] = lap(upTo(took, step, 15)) + rows[1][1] = fmt.Sprint(upTo(score, step, 15)) + name := 0 + for _, r := range rows { + name = max(name, pixel.Measure(r[0])) + } + w := name + 12 + max(pixel.Measure(lap(took)), pixel.Measure(fmt.Sprint(score)), 3*pixel.Measure(rank)) + return e.Size(k.D(w), k.D(2*pixel.LineHeight+3*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, row := range rows[:2] { + g.Text(0, i*pixel.LineHeight, row[0], pixel.Faint, 1) + g.Text(w-pixel.Measure(row[1]), i*pixel.LineHeight, row[1], pixel.Ink, 1) + } + g.Text(0, 2*pixel.LineHeight+pixel.LineHeight, rows[2][0], pixel.Faint, 1) + if step == 15 { + g.Text(w-3*pixel.Measure(rank), 2*pixel.LineHeight, rank, pixel.Warn, 3) + } + }) +} + +// lap prints a duration as M:SS.CC. +func lap(d time.Duration) string { + cs := int(d / (10 * time.Millisecond)) + return fmt.Sprintf("%d:%02d.%02d", cs/6000, cs/100%60, cs%100) +} + +// upTo is step/of of v, never forming the full product. +func upTo[T ~int | ~int64](v T, step, of int) T { + return v/T(of)*T(step) + v%T(of)*T(step)/T(of) +} diff --git a/game/rewards_test.go b/game/rewards_test.go new file mode 100644 index 0000000..57829fd --- /dev/null +++ b/game/rewards_test.go @@ -0,0 +1,379 @@ +package game_test + +import ( + "testing" + "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/pixel" +) + +func TestLetterboxSlidesInSteps(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + on := false + tt := view(300, 200, wd, func(c *ui.Context) { + game.Letterbox(c, on, game.LetterboxOptions{}, func() { ui.Box(c).Size(pixel.K(c).D(100), pixel.K(c).D(60)) }) + }) + bar := func() int { return count(t, tt.Image(), 50, 0, 1, 30, pixel.Ink) } + on = true + tt.Frame() + // Two of four steps in, a bar is 8 dots. + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + if half := bar(); (half == 8) == reduce || reduce && half != 16 { + t.Fatalf("reduced %v: a quarter second in, the bar is %d dots", reduce, half) + } + wd.clk.t = wd.clk.t.Add(time.Second) + tt.Frame() + if bar() != 16 { + t.Fatalf("reduced %v: the bar settled at %d", reduce, bar()) + } + // Turned off mid-slide, the bars return from there. + on = false + tt.Frame() + wd.clk.t = wd.clk.t.Add(250 * time.Millisecond) + tt.Frame() + on = true + tt.Frame() + if now := bar(); !reduce && now != 8 || reduce && now != 16 { + t.Fatalf("reduced %v: turned back on at half, the bar is %d", reduce, now) + } + } +} + +func TestMissionBriefStarts(t *testing.T) { + started := false + tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) { + started = started || game.MissionBrief(c, game.Quest{Title: "Rescue", Objectives: []game.Objective{{Text: "Go", Need: 1}}}, []string{"####", "#..#", "####"}, game.Spot{X: 1, Y: 1}, nil, game.MissionBriefOptions{}).Start + }) + tt.Click("START") + if !started { + t.Fatal("START did not report") + } +} + +func TestAchievementsHideLockedNames(t *testing.T) { + awards := []game.Award{{Sprite: icon.Trophy, Name: "First blood", How: "Win a fight", Unlocked: true}, {Sprite: icon.Coin, Name: "King", How: "Beat the king"}} + sel := 0 + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.Achievements(c, "Awards", awards, &sel, game.AchievementsOptions{}) + }) + if !tt.HasText("UNLOCKED 1/2") || !tt.HasText("First blood") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Click("Awards") + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + if sel != 1 || tt.HasText("King") || !tt.HasText("???") || !tt.HasText("Beat the king") { + t.Fatalf("a locked award showed %q", tt.Texts()) + } + r := find(t, tt, "Awards") + x, y := dots(r) + // The locked coin is a Shade shadow: no Warn gold. + if count(t, tt.Image(), x+24, y, 24, 24, pixel.Warn) != 0 || count(t, tt.Image(), x+24, y, 24, 24, pixel.Shade) == 0 { + t.Fatal("the locked award is not a silhouette") + } +} + +func TestResultScreenCountsThenRanks(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + tt := view(400, 200, wd, func(c *ui.Context) { + game.ResultScreen(c, "Result", 83*time.Second, 12500, "S", game.ResultScreenOptions{}) + }) + rank := func() int { return count(t, tt.Image(), 0, 0, 200, 100, pixel.Warn) } + if (rank() > 0) != reduce { + t.Fatalf("reduced %v: the rank showed before the counts", reduce) + } + wd.clk.t = wd.clk.t.Add(2 * time.Second) + tt.Frame() + if rank() == 0 { + t.Fatalf("reduced %v: no rank after the counts", reduce) + } + } +} + +func TestResultScreenFitsABigScore(t *testing.T) { + tt := view(600, 200, &world{clk: newClock()}, func(c *ui.Context) { + ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() { + game.ResultScreen(c, "Result", 83*time.Second, 1000000000000000, "S", game.ResultScreenOptions{}) + }) + }) + if n := count(t, tt.Image(), 0, 0, 20, 100, pixel.Ink); n != 0 { + t.Fatalf("%d score dots left of the screen", n) + } + if r := find(t, tt, "Result"); int(r.W)/2 < pixel.Measure("SCORE")+12+pixel.Measure("1000000000000000") { + t.Fatalf("the screen is %v px wide", r.W) + } +} + +func TestCraftingShowsAKnownRecipe(t *testing.T) { + grid := make([]data.Item, 9) + grid[4] = data.Item{Sprite: icon.Leaf, Name: "Herb"} + recipes := []game.Recipe{{Grid: [9]string{4: "Herb"}, Result: data.Item{Sprite: icon.Potion, Name: "Potion"}}} + cell, crafted, picked := 0, 0, -1 + tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) { + r := game.Crafting(c, "Craft", grid, recipes, &cell, game.CraftingOptions{}) + if r.Crafted { + crafted++ + } + if r.Picked >= 0 { + picked = r.Picked + } + }) + if !tt.HasText("Potion") { + t.Fatalf("no result shown: %q", tt.Texts()) + } + tt.Click("CRAFT") + grid[4] = data.Item{} + tt.Frame() + tt.Click("CRAFT") + if crafted != 1 || tt.HasText("Potion") { + t.Fatalf("crafted %d times; texts %q", crafted, tt.Texts()) + } + r := find(t, tt, "Craft") + tt.ClickAt(r.X+float32(2*(22*2+11)), r.Y+float32(2*(22*2+11))) + if cell != 8 || picked != 8 { + t.Fatalf("clicking the last cell picked %d", picked) + } +} + +func TestLootPopupRisesAndCloses(t *testing.T) { + wd := &world{clk: newClock()} + open, closed := true, 0 + tt := view(500, 300, wd, func(c *ui.Context) { + if game.LootPopup(c, data.Item{Sprite: icon.Potion, Name: "POTION", Count: 3}, &open, game.LootPopupOptions{}).Closed { + closed++ + } + }) + if !tt.HasText("YOU GOT: POTION ×3!") { + t.Fatalf("texts %q", tt.Texts()) + } + r := find(t, tt, "POTION ×3") + x, y := dots(r) + top := func() int { + for j := range int(r.H) / 2 { + if count(t, tt.Image(), x, y+j, int(r.W)/2, 1, pixel.Bad) > 0 { + return j + } + } + return -1 + } + low := top() + wd.clk.t = wd.clk.t.Add(time.Second) + tt.Frame() + if rose := low - top(); rose != 8 { + t.Fatalf("the potion rose %d dots", rose) + } + tt.Click("OK") + if open || closed != 1 { + t.Fatal("OK did not close it") + } +} + +func TestTreasureChestOpensOnce(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + open, opened := false, 0 + tt := view(200, 200, wd, func(c *ui.Context) { + if game.TreasureChest(c, "Chest", data.Item{Sprite: icon.Gem, Name: "Gem"}, &open, game.TreasureChestOptions{}).Opened { + opened++ + } + }) + gem := func() int { return count(t, tt.Image(), 0, 0, 24, 21, pixel.Info) } + tt.Click("Chest") + if !open || opened != 1 || (gem() > 0) != reduce { + t.Fatalf("reduced %v: the press showed the gem %v", reduce, gem() > 0) + } + wd.clk.t = wd.clk.t.Add(time.Second) + tt.Frame() + tt.Click("Chest") + if gem() == 0 || opened != 1 { + t.Fatalf("reduced %v: no gem, or opened twice", reduce) + } + } +} + +func TestTreasureChestGemStaysInside(t *testing.T) { + open := true + tt := view(200, 200, &world{clk: newClock()}, func(c *ui.Context) { + ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() { + game.TreasureChest(c, "Chest", data.Item{Sprite: icon.Gem, Name: "Gem"}, &open, game.TreasureChestOptions{}) + }) + }) + if n, in := count(t, tt.Image(), 0, 0, 100, 20, pixel.Info), count(t, tt.Image(), 20, 20, 24, 50, pixel.Info); n != 0 || in == 0 { + t.Fatalf("%d gem dots above the chest, %d in it", n, in) + } +} + +func TestGachaReelSpinsAndStops(t *testing.T) { + faces := []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Heart, Name: "Heart"}, {Sprite: icon.Coin, Name: "Coin"}} + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + spin, done := 0, 0 + tt := view(300, 100, wd, func(c *ui.Context) { + if game.GachaReel(c, "Reels", faces, [3]int{2, 2, 2}, spin, game.GachaReelOptions{}).Done { + done++ + } + }) + coins := func() int { return count(t, tt.Image(), 0, 0, 84, 28, pixel.Warn) } + still := coins() + spin = 1 + tt.Frame() + var seen []int + for range 4 { + wd.clk.t = wd.clk.t.Add(100 * time.Millisecond) + tt.Frame() + seen = append(seen, coins()) + } + spun := seen[0] != still || seen[1] != still || seen[2] != still + if spun == reduce || (done == 1) != reduce { + t.Fatalf("reduced %v: spun %v, done %d", reduce, spun, done) + } + wd.clk.t = wd.clk.t.Add(2 * time.Second) + tt.Frame() + if done != 1 || coins() != still { + t.Fatalf("reduced %v: done %d, coins %d of %d", reduce, done, coins(), still) + } + // Spinning back to an old cue lands; reduced, both do. + spin = 2 + tt.Frame() + spin = 1 + tt.Frame() + wd.clk.t = wd.clk.t.Add(2 * time.Second) + tt.Frame() + if want := map[bool]int{false: 2, true: 3}[reduce]; done != want { + t.Fatalf("reduced %v: spinning back to 1 landed %d times in all", reduce, done) + } + } +} + +func TestGachaReelRefusesAFaceWithoutSprite(t *testing.T) { + panics(t, "a face with no sprite", func() { + view(300, 100, &world{clk: newClock()}, func(c *ui.Context) { + game.GachaReel(c, "x", []data.Item{{Sprite: icon.Star, Name: "Star"}, {Name: "Blank"}}, [3]int{}, 0, game.GachaReelOptions{}) + }) + }) +} + +func TestInteractPromptSitsAboveItsTarget(t *testing.T) { + var target *ui.Element + pad := 40 + tt := view(400, 300, &world{clk: newClock(), reduce: true}, func(c *ui.Context) { + ui.Column(c).Padding(pixel.K(c).D(pad)).Children(func() { + target = ui.Box(c).Size(pixel.K(c).D(20), pixel.K(c).D(20)).Label("Mayor") + }) + game.InteractPrompt(c, target, "TALK", game.InteractPromptOptions{}) + }) + // Right from the start, and after a move. + for range 2 { + bubble, mayor := find(t, tt, "A TALK"), find(t, tt, "Mayor") + if bubble.Y+bubble.H > mayor.Y || bubble.Y+bubble.H < mayor.Y-20 || bubble.X > mayor.X+mayor.W/2 { + t.Fatalf("the bubble %v does not sit above %v", bubble, mayor) + } + pad = 70 + tt.Frame() + } + panics(t, "a two-letter key", func() { + view(300, 200, &world{clk: newClock()}, func(c *ui.Context) { + game.InteractPrompt(c, ui.Box(c), "TALK", game.InteractPromptOptions{Key: "AB"}) + }) + }) +} + +func TestTurnOrderRaisesTheActive(t *testing.T) { + tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { + game.TurnOrder(c, "Turns", []game.Fighter{{Name: "Aria", Portrait: game.Hero}, {Name: "Slime", Portrait: icon.Skull, Foe: true}}, 1, game.TurnOrderOptions{}) + }) + img := tt.Image() + if at(t, img, 19+8, 0) != pixel.Accent || at(t, img, 8, 0) == pixel.Accent || at(t, img, 8, 5) != pixel.Good { + t.Fatal("the active portrait is not raised in Accent") + } +} + +func TestBattleLogKeepsTheNewest(t *testing.T) { + lines := []game.Line{{Text: "one", Slot: pixel.Good}, {Text: "two"}, {Text: "three"}, {Text: "four"}, {Text: "five", Slot: pixel.Bad}} + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.BattleLog(c, "Log", lines, game.BattleLogOptions{}) }) + // 68 dots hold four lines: the first rolled off. + if count(t, tt.Image(), 0, 0, 180, 68, pixel.Good) != 0 || count(t, tt.Image(), 0, 50, 180, 18, pixel.Bad) == 0 { + t.Fatal("the log does not keep the newest lines at the bottom") + } + // Two lines sit at the bottom, not the top. + lines = lines[3:] + lines[0].Slot = pixel.Good + tt.Frame() + if count(t, tt.Image(), 0, 34, 180, 16, pixel.Good) == 0 || count(t, tt.Image(), 0, 0, 180, 30, pixel.Good) != 0 { + t.Fatal("a short log is not at the bottom") + } +} + +func TestTargetSelectorMovesTheCursor(t *testing.T) { + sel, chosen := 0, -1 + enemies := []game.Enemy{{Name: "Slime", Sprite: icon.Skull, HP: 3, MaxHP: 9}, {Name: "Bat", Sprite: icon.Bug, HP: 6, MaxHP: 6}} + tt := view(300, 200, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.TargetSelector(c, "Targets", enemies, &sel, game.TargetSelectorOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + cursor := func(i int) int { return count(t, tt.Image(), i*40, 0, 40, 12, pixel.Accent) } + if cursor(0) == 0 || cursor(1) != 0 { + t.Fatal("the ▼ is not over the first target") + } + tt.Click("Targets") + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if sel != 1 || chosen != 1 || cursor(1) == 0 { + t.Fatalf("Right, Enter chose %d", chosen) + } + // A long name stays in its 40-dot cell. + enemies[0].Name = "Slime of the Very Long Name" + tt.Frame() + if n := count(t, tt.Image(), 80, 36, 60, 20, pixel.Ink); n != 0 { + t.Fatalf("%d name dots spilled past the cells", n) + } +} + +func TestATBGaugeFlashesReady(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := &world{clk: newClock(), reduce: reduce} + v := 0.5 + tt := view(200, 60, wd, func(c *ui.Context) { game.ATBGauge(c, "ARIA", v, game.ATBGaugeOptions{}) }) + if count(t, tt.Image(), 0, 9, 64, 1, pixel.Info) != 30 { + t.Fatal("half a gauge is not 30 dots") + } + v = 1 + tt.Frame() + var ready []int + for range 2 { + ready = append(ready, count(t, tt.Image(), 30, 0, 34, 6, pixel.Warn)) + wd.clk.t = wd.clk.t.Add(125 * time.Millisecond) + tt.Frame() + } + if flashed := (ready[0] == 0) != (ready[1] == 0); flashed == reduce || ready[0]+ready[1] == 0 { + t.Fatalf("reduced %v: READY dots %v", reduce, ready) + } + } + zh := view(200, 60, &world{clk: newClock(), zh: true}, func(c *ui.Context) { game.ATBGauge(c, "ARIA", 1, game.ATBGaugeOptions{}) }) + // 就绪 needs a 14-row label line above the 8-row bar. + if count(t, zh.Image(), 20, 0, 44, 14, pixel.Warn) == 0 || find(t, zh, "ARIA").H != 2*(14+8) { + t.Fatal("a Chinese READY is missing or crowds the bar") + } + panics(t, "a gauge too narrow", func() { + view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { game.ATBGauge(c, "ARIA", 1, game.ATBGaugeOptions{Width: 10}) }) + }) +} + +func TestStaminaBarDithersThePartBlock(t *testing.T) { + tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { game.StaminaBar(c, "STAMINA", 0.65, game.StaminaBarOptions{}) }) + img := tt.Image() + // Six blocks full, the seventh half, the rest empty. + if full, part, empty := count(t, img, 0, 7, 36, 6, pixel.Good), count(t, img, 36, 7, 5, 6, pixel.Good), count(t, img, 42, 7, 18, 6, pixel.Good); full != 180 || part == 0 || part == 30 || empty != 0 { + t.Fatalf("blocks: full %d, part %d, empty %d", full, part, empty) + } +} diff --git a/game/saveslots.go b/game/saveslots.go new file mode 100644 index 0000000..daca104 --- /dev/null +++ b/game/saveslots.go @@ -0,0 +1,89 @@ +package game + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Save is a slot; an empty Place reads NEW GAME. +type Save struct { + Place string + Played time.Duration + Portrait *pixel.Sprite +} + +type SaveSlotsOptions struct { + Disabled bool +} + +type SaveSlotsResult struct { + Element *ui.Element + Changed bool + // Chosen is the slot opened this frame, or -1. + Chosen int +} + +// card is a save slot's size in dots. +const cardW, cardH = 180, 32 + +// SaveSlots stacks save cards: portrait, place and play time. +func SaveSlots(c *ui.Context, text string, saves []Save, selected *int, o SaveSlotsOptions) SaveSlotsResult { + k := pixel.K(c) + if len(saves) == 0 || *selected < 0 || *selected >= len(saves) { + panic(fmt.Sprintf("game: SaveSlots slot %d of %d", *selected, len(saves))) + } + captions := make([]string, len(saves)) + for i, s := range saves { + if s.Played < 0 || s.Place == "" && (s.Played != 0 || s.Portrait != nil) { + panic(fmt.Sprintf("game: save %d is empty but played %v", i, s.Played)) + } + captions[i] = fmt.Sprintf(k.Msg("file"), i+1) + " " + k.Msg("new_game") + if s.Place != "" { + captions[i] = fmt.Sprintf(k.Msg("file"), i+1) + " " + s.Place + " " + clockTime(s.Played) + } + } + old := *selected + shell, base, st, chosen := pixel.Pad(c, text, 1, len(saves), cardW, cardH+3, selected, o.Disabled) + res := SaveSlotsResult{Element: shell, Changed: *selected != old, Chosen: chosen} + base.Description(captions[*selected]) + if res.Changed || res.Chosen >= 0 { + slog.Debug("8bit: save slots", "label", text, "slot", *selected, "chosen", res.Chosen) + } + sel, shown, newGame := *selected, append([]Save(nil), saves...), k.Msg("new_game") + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, s := range shown { + y, fill := i*(cardH+3), pixel.Face + if i == sel { + fill = pixel.Lit + } + g.Frame(0, y, cardW, cardH, pixel.Raised, fill) + g.Micro(4, y+3, fmt.Sprintf("%d", i+1), pixel.Faint, 1) + if s.Place == "" { + g.Text((cardW-pixel.Measure(newGame))/2, y+(cardH-pixel.LineHeight)/2, newGame, pixel.Faint, 1) + continue + } + g.Frame(12, y+3, 26, 26, pixel.Sunken, pixel.Well) + if s.Portrait != nil { + g.Sprite(13, y+4, s.Portrait, 0, pixel.Ink, 2) + } + g.Text(44, y+2, pixel.Wrap(s.Place, cardW-48, 1)[0], pixel.Ink, 1) + g.Text(44, y+cardH-pixel.LineHeight-1, clockTime(s.Played), pixel.Faint, 1) + } + if i := sel; i >= 0 { + g.Rect(0, i*(cardH+3)+cardH-2, cardW, 2, pixel.Accent) + } + g.Finish(st) + }) + return res +} + +// clockTime prints play time as H:MM. +func clockTime(d time.Duration) string { + return fmt.Sprintf("%d:%02d", int(d.Hours()), int(d.Minutes())%60) +} diff --git a/game/staminabar.go b/game/staminabar.go new file mode 100644 index 0000000..668f01d --- /dev/null +++ b/game/staminabar.go @@ -0,0 +1,43 @@ +package game + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type StaminaBarOptions struct { + // Segments are the bar's blocks; zero means 10. + Segments int +} + +// StaminaBar blocks stamina out; the part-full block dithers. +func StaminaBar(c *ui.Context, text string, value float64, o StaminaBarOptions) *ui.Element { + k := pixel.K(c) + if !(value >= 0 && value <= 1) { + panic(fmt.Sprintf("game: StaminaBar value %v outside 0–1", value)) + } + n := pixel.Or("StaminaBar Segments", o.Segments, 10) + top := headH(text) + w := max(n*6, tagW(text)) + return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(strconv.Itoa(int(value*100))+"%").Size(k.D(w), k.D(top+6)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + tag(g, 0, 0, text, pixel.Ink) + full := value * float64(n) + for i := range n { + x := i * 6 + switch part := full - float64(i); { + case part >= 1: + g.Rect(x, top, 5, 6, pixel.Good) + case part > 0: + g.Rect(x, top, 5, 6, pixel.Well) + g.Veil(x, top, 5, 6, pixel.Good, max(1, int(part*16))) + default: + g.Checker(x, top, 5, 6, pixel.Shade) + } + } + }) +} diff --git a/game/targetselector.go b/game/targetselector.go new file mode 100644 index 0000000..310dbbd --- /dev/null +++ b/game/targetselector.go @@ -0,0 +1,78 @@ +package game + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Enemy is a target; at zero HP, a shadow. +type Enemy struct { + Name string + Sprite *pixel.Sprite + HP, MaxHP int +} + +type TargetSelectorOptions struct { + Disabled bool +} + +type TargetSelectorResult struct { + Element *ui.Element + Changed bool + // Chosen is the target confirmed this frame, or -1. + Chosen int +} + +// foeW and foeH are a target's cell: cursor, sprite, name. +const foeW, foeH = 40, 12 + 24 + 4 + pixel.LineHeight + +// TargetSelector rows enemies under a bobbing ▼; arrows choose. +func TargetSelector(c *ui.Context, text string, enemies []Enemy, selected *int, o TargetSelectorOptions) TargetSelectorResult { + k := pixel.K(c) + if len(enemies) == 0 || *selected < 0 || *selected >= len(enemies) { + panic(fmt.Sprintf("game: TargetSelector target %d of %d", *selected, len(enemies))) + } + for _, en := range enemies { + if en.Sprite == nil { + panic(fmt.Sprintf("game: enemy %q has no sprite", en.Name)) + } + amount("enemy "+en.Name+" HP", en.HP, en.MaxHP) + } + old := *selected + shell, base, st, chosen := pixel.Pad(c, text, len(enemies), len(enemies), foeW, foeH, selected, o.Disabled) + res := TargetSelectorResult{Element: shell, Changed: *selected != old, Chosen: chosen} + en := enemies[*selected] + base.Description(en.Name + " " + ratio(en.HP, en.MaxHP)) + if res.Changed || res.Chosen >= 0 { + slog.Debug("8bit: target", "label", text, "enemy", en.Name, "chosen", res.Chosen) + } + sel, shown := *selected, append([]Enemy(nil), enemies...) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, en := range shown { + x := i * foeW + sx := x + (foeW-2*en.Sprite.W)/2 + if en.HP == 0 { + g.Silhouette(sx, 12, en.Sprite, 0, pixel.Shade, 2) + } else { + g.Sprite(sx, 12, en.Sprite, 0, pixel.Ink, 2) + } + ink := pixel.Ink + if en.HP == 0 { + ink = pixel.Faint + } + name := pixel.Wrap(en.Name, foeW-2, 1)[0] + g.Text(x+(foeW-pixel.Measure(name))/2, 12+24+4, name, ink, 1) + // The cursor bobs a dot; reduced, it holds. + if i == sel { + g.Text(x+(foeW-pixel.Measure("▼"))/2, g.Step(4)%2-4, "▼", pixel.Accent, 1) + } + } + g.Finish(st) + }) + return res +} diff --git a/game/titlescreen.go b/game/titlescreen.go new file mode 100644 index 0000000..888ff31 --- /dev/null +++ b/game/titlescreen.go @@ -0,0 +1,61 @@ +package game + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TitleScreenOptions struct { + // Saved turns CONTINUE on. + Saved bool + Disabled bool +} + +type TitleScreenResult struct { + Element *ui.Element + // Chosen is 0 NEW GAME, 1 CONTINUE, 2 OPTIONS. + Chosen int +} + +// TitleScreen shows the logo and PRESS START, then the menu. +func TitleScreen(c *ui.Context, title string, started *bool, o TitleScreenOptions) TitleScreenResult { + k := pixel.K(c) + col := ui.Column(c).Gap(k.D(12)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(title) + res := TitleScreenResult{Element: col, Chosen: -1} + col.Children(func() { + logo(c, title) + if !*started { + if PressStart(c, PressStartOptions{Disabled: o.Disabled}).Pressed { + *started = true + } + return + } + items := []navigation.MenuItem{{Label: k.Msg("new_game")}, {Label: k.Msg("continue_game"), Disabled: !o.Saved}, {Label: k.Msg("options")}} + r := navigation.CommandMenu(c, title, items, navigation.CommandMenuOptions{AutoFocus: true, Disabled: o.Disabled}) + res.Chosen = r.Chosen + // Escape goes back to PRESS START. + if r.Canceled { + *started = false + slog.Debug("8bit: title screen back", "title", title) + } + }) + if res.Chosen >= 0 { + slog.Debug("8bit: title screen", "title", title, "chosen", res.Chosen) + } + return res +} + +// logo writes the title big, gold over red, shadowed. +func logo(c *ui.Context, title string) *ui.Element { + k := pixel.K(c) + w := 3*pixel.Measure(title) + 2 + return ui.Box(c).Role(ui.RoleText).Label(title).Size(k.D(w), k.D(3*pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Text(2, 2, title, pixel.Edge, 3) + g.TextBlend(0, 0, title, pixel.Warn, pixel.Bad, 3) + }) +} diff --git a/game/treasurechest.go b/game/treasurechest.go new file mode 100644 index 0000000..9ab653d --- /dev/null +++ b/game/treasurechest.go @@ -0,0 +1,94 @@ +package game + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TreasureChestOptions struct { + Disabled bool +} + +type TreasureChestResult struct { + Element *ui.Element + // Opened is true in the frame a press opens it. + Opened bool +} + +type lidKey struct{} + +// lid is whether the chest was open, and since when. +type lid struct { + open bool + at time.Time +} + +// TreasureChest opens in frames when pressed; the item rises out. +func TreasureChest(c *ui.Context, text string, it data.Item, open *bool, o TreasureChestOptions) TreasureChestResult { + k := pixel.K(c) + if it.Sprite == nil { + panic(fmt.Sprintf("game: TreasureChest item %q has no sprite", it.Name)) + } + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(text).Disabled(o.Disabled) }) + res := TreasureChestResult{Element: shell} + if base.Clicked() && !*open { + *open, res.Opened = true, true + slog.Debug("8bit: chest opened", "label", text, "item", it.Name) + } + // First shown, the chest sits as open says. + s := ui.Local(base, lidKey{}, func() lid { return lid{open: *open} }) + if *open != s.open { + s.open, s.at = *open, k.Now() + } + frame, rise := 0, 0 + if *open { + frame, rise = 2, 10 + if !k.Reduce() { + frame, rise = k.Since(c, s.at, 8, 2), k.Since(c, s.at, 16, 10) + } + } + if *open { + base.Description(it.String()) + } + // Room above the chest holds the fully risen item. + sp := it.Sprite + top := sp.H + 13 + shell.Size(k.D(24), k.D(top+14)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if frame == 2 { + g.Sprite((24-sp.W)/2, top-3-sp.H-rise, sp, 0, pixel.Ink, 1) + } + chest(g, 2, top, frame) + g.Finish(st) + }) + return res +} + +// chest draws a 20×14 chest: closed, ajar or open. +func chest(g pixel.Canvas, x, y, frame int) { + g.Frame(x, y+5, 20, 9, pixel.Flat, pixel.Shade) + switch frame { + case 0: + g.Frame(x, y, 20, 6, pixel.Flat, pixel.Shade) + case 1: + g.Frame(x, y-3, 20, 6, pixel.Flat, pixel.Shade) + g.Rect(x+1, y+3, 18, 2, pixel.Ink) + g.Veil(x+1, y+3, 18, 2, pixel.Warn, 6) + default: + g.Frame(x, y-4, 20, 4, pixel.Flat, pixel.Edge) + g.Rect(x+1, y+5, 18, 3, pixel.Ink) + g.Veil(x+1, y+5, 18, 3, pixel.Warn, 8) + } + g.Rect(x+9, y+5, 2, 9, pixel.Warn) + if frame == 0 { + g.Rect(x+9, y, 2, 6, pixel.Warn) + g.Rect(x+8, y+4, 4, 4, pixel.Warn) + g.Dot(x+10, y+6, pixel.Edge) + } +} diff --git a/game/turnorder.go b/game/turnorder.go new file mode 100644 index 0000000..cf7b9d3 --- /dev/null +++ b/game/turnorder.go @@ -0,0 +1,51 @@ +package game + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Fighter is a portrait in the turn order. +type Fighter struct { + Name string + Portrait *pixel.Sprite + Foe bool +} + +type TurnOrderOptions struct{} + +// TurnOrder rows portraits in order, the active raised. +func TurnOrder(c *ui.Context, text string, order []Fighter, active int, o TurnOrderOptions) *ui.Element { + k := pixel.K(c) + if active < 0 || active >= len(order) { + panic(fmt.Sprintf("game: TurnOrder active %d of %d", active, len(order))) + } + // Assistive technology hears who acts, from the active one on. + names := make([]string, len(order)) + for i := range order { + f := order[(active+i)%len(order)] + if f.Portrait == nil { + panic(fmt.Sprintf("game: fighter %q has no portrait", f.Name)) + } + names[i] = f.Name + } + return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(names, ", ")).Size(k.D(len(order)*19), k.D(22)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, f := range order { + x, y, edge := i*19, 5, pixel.Good + if f.Foe { + edge = pixel.Bad + } + if i == active { + y, edge = 0, pixel.Accent + } + g.Rect(x, y, 18, 17, edge) + g.Rect(x+1, y+1, 16, 15, pixel.Well) + g.Sprite(x+3, y+3, f.Portrait, 0, pixel.Ink, 1) + } + }) +} diff --git a/navigation/commandmenu.go b/navigation/commandmenu.go index d57a0e6..d910055 100644 --- a/navigation/commandmenu.go +++ b/navigation/commandmenu.go @@ -9,7 +9,9 @@ import ( ) type CommandMenuOptions struct { - Disabled bool + // AutoFocus takes the focus when the menu appears. + AutoFocus bool + Disabled bool } type CommandMenuResult struct { @@ -27,6 +29,10 @@ func CommandMenu(c *ui.Context, text string, items []MenuItem, o CommandMenuOpti shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled) }) + // An off menu never takes the focus. + if o.AutoFocus && !base.IsDisabled() { + base.AutoFocus() + } cur := ui.Local(base, cursorKey{}, func() int { return 0 }) res := CommandMenuResult{Element: shell, Chosen: -1} n := len(items) diff --git a/navigation/menus_test.go b/navigation/menus_test.go index 2d544c5..aab65a2 100644 --- a/navigation/menus_test.go +++ b/navigation/menus_test.go @@ -42,6 +42,38 @@ func TestCommandMenuChoosesAndCancels(t *testing.T) { } } +func TestCommandMenuAutoFocusTakesKeysAtOnce(t *testing.T) { + for _, auto := range []bool{false, true} { + chosen := -1 + tt := view(300, 200, newClock(), false, func(c *ui.Context) { + if r := navigation.CommandMenu(c, "Command", commands, navigation.CommandMenuOptions{AutoFocus: auto}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + // Only a focused menu hears Enter. + tt.Key(0, ui.KeyEnter) + if (chosen == 0) != auto { + t.Fatalf("AutoFocus %v: Enter chose %d", auto, chosen) + } + } +} + +func TestOffMenusTakeNoAutoFocus(t *testing.T) { + for _, inert := range []bool{false, true} { + chosen := -1 + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Inert: inert}) + if r := navigation.CommandMenu(c, "Command", commands, navigation.CommandMenuOptions{AutoFocus: true, Disabled: !inert}); r.Chosen >= 0 { + chosen = r.Chosen + } + }, 300, 200) + tt.Key(0, ui.KeyEnter) + if chosen != -1 { + t.Fatalf("inert %v: an off menu chose %d", inert, chosen) + } + } +} + func TestDropdownMenuKeysAndClicks(t *testing.T) { chosen, disabled := -1, false items := []navigation.MenuItem{{Label: "Save", Icon: icon.Save, Keys: "CTRL+S"}, {Separator: true}, {Label: "Load", Disabled: true}, {Label: "Quit"}} diff --git a/pixel/messages.go b/pixel/messages.go index 9b58c29..654c180 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -28,6 +28,8 @@ var messages = map[Locale]map[string]string{ "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", + "new_game": "NEW GAME", "file": "FILE %d", "press_start": "PRESS START", "continue_game": "CONTINUE", "the_end": "THE END", + "tip": "TIP", "you_got": "YOU GOT: %s!", "unlocked": "UNLOCKED %d/%d", "rank": "RANK", "craft": "CRAFT", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -54,6 +56,8 @@ var messages = map[Locale]map[string]string{ "head": "头", "body": "身体", "weapon": "武器", "shield": "盾牌", "ring": "戒指", "points": "点数 %d", "learned": "已学会", "can_learn": "可学习", "locked": "未解锁", "ready": "就绪", "buy": "购买", "sell": "出售", "done": "完成", "tracking": "追踪中", + "new_game": "新游戏", "file": "存档 %d", "press_start": "按下开始", "continue_game": "继续", "the_end": "完", + "tip": "提示", "you_got": "获得了 %s!", "unlocked": "已解锁 %d/%d", "rank": "评价", "craft": "合成", }, }