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": "合成", }, }