Add game components 269-290; CommandMenu AutoFocus

Press start, title screen, save slots, options menu, game over with
CONTINUE, credits roll, ending card, loading tips, letterbox, mission
brief, achievements, result screen, crafting grid, loot popup,
treasure chest, gacha reel, interact prompt, turn order, battle log,
target selector, ATB gauge and stamina bar. Cued effects count runs,
so each cue change plays once and reports once. CommandMenu takes
AutoFocus when enabled. HealthBar, ManaBar and CountUp keep exact
colors, ticks and counts for the largest ints.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 02:48:20 -04:00
1 parent f48075230f
commit 86a6901333
38 files changed
+2663 -8

No files matched your search

+244
View File
@@ -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))
}
})
}
+175
View File
@@ -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)
}
}
+33
View File
@@ -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])
}
}
}
}
+1 -1
View File
@@ -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 }