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

+11 -1
View File
@@ -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))
}
+32
View File
@@ -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()
+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 }
+93
View File
@@ -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
}
+50
View File
@@ -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)
}
})
}
+42
View File
@@ -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)
}
})
})
}
+18
View File
@@ -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)
}
}
+78
View File
@@ -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
}
+78
View File
@@ -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
}
+37
View File
@@ -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)
})
}
+76
View File
@@ -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
}
+19 -1
View File
@@ -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
}
+43
View File
@@ -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
}
+4 -3
View File
@@ -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)
+26
View File
@@ -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()
+59
View File
@@ -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
}
+62
View File
@@ -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
}
+44
View File
@@ -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)
}
})
})
}
+51
View File
@@ -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}
}
+8 -1
View File
@@ -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
}
}
})
}
+258
View File
@@ -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())
}
}
+37
View File
@@ -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
}
+151
View File
@@ -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
}
+38
View File
@@ -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
}
+57
View File
@@ -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)
}
+379
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
})
}
+78
View File
@@ -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
}
+61
View File
@@ -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)
})
}
+94
View File
@@ -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)
}
}
+51
View File
@@ -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)
}
})
}
+7 -1
View File
@@ -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)
+32
View File
@@ -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"}}
+4
View File
@@ -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": "合成",
},
}