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:
38 files changed
+2663
-8
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user