Add game components 246-268; data.DrawSlot, Canvas.TextVeil

Health, mana and experience bars, quarter hearts, lives, a spinning
coin counter, score board, stage timer, minimap with fog, compass,
hotbar, paper-doll equipment, skill tree, skill buttons with cooldown
wipes, status effects, a boss bar, damage numbers, quest log and
tracker, party frames, character sheet, level-up screen and shop.
data exports DrawSlot so every slot looks alike; pixel adds TextVeil
for dissolving text. Messages the font cannot draw are fixed and
TestMessagesHaveGlyphs holds every message to the font.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 01:53:20 -04:00
1 parent 596c62e026
commit f48075230f
42 files changed
+3260 -26

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
package game
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BossBarOptions struct {
// Width is in dots; zero means 200.
Width int
// Phases are where the boss changes, as fractions: 0.5.
Phases []float64
}
// BossBar is a wide bar under the name, phases marked.
func BossBar(c *ui.Context, name string, hp, full int, o BossBarOptions) *ui.Element {
k := pixel.K(c)
amount("BossBar hp", hp, full)
for _, f := range o.Phases {
if !(f > 0 && f < 1) {
panic(fmt.Sprintf("game: BossBar phase %v outside 0–1", f))
}
}
w := pixel.Or("BossBar Width", o.Width, 200)
if need := icon.Skull.W + 3 + pixel.Measure(name); w < need {
panic(fmt.Sprintf("game: BossBar Width %d is under the %d its name needs", w, need))
}
return ui.Box(c).Role(ui.RoleMeter).Label(name).Description(ratio(hp, full)).Size(k.D(w), k.D(pixel.LineHeight+12)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 2, icon.Skull, 0, pixel.Bad, 1)
g.Text(icon.Skull.W+3, 0, name, pixel.Ink, 1)
meter(g, 0, pixel.LineHeight, w, 12, float64(hp)/float64(full), pixel.Bad)
for _, f := range o.Phases {
x := 2 + int(f*float64(w-4))
g.VLine(x, pixel.LineHeight-2, 14, pixel.Ink)
g.Rect(x-1, pixel.LineHeight-2, 3, 1, pixel.Ink)
}
})
}
+63
View File
@@ -0,0 +1,63 @@
package game
import (
"fmt"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Stat is a named number on a sheet.
type Stat struct {
Name string
Value int
}
// Character is who a character sheet describes.
type Character struct {
Name, Class string
Level int
Portrait *pixel.Sprite
Stats []Stat
}
type CharacterSheetOptions struct{}
// CharacterSheet shows a portrait, name, class, level and stats.
func CharacterSheet(c *ui.Context, h Character, o CharacterSheetOptions) *ui.Element {
k := pixel.K(c)
if h.Portrait == nil || h.Level < 1 {
panic(fmt.Sprintf("game: character %q needs a portrait and a level (%d)", h.Name, h.Level))
}
name, value := 0, 0
stats := make([]string, len(h.Stats))
for i, s := range h.Stats {
name, value = max(name, pixel.Measure(s.Name)), max(value, pixel.Measure(strconv.Itoa(s.Value)))
stats[i] = s.Name + " " + strconv.Itoa(s.Value)
}
return ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(h.Name).Children(func() {
ui.Box(c).Size(k.D(40), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 40, 40, pixel.Sunken, pixel.Well)
g.Sprite(2, 2, h.Portrait, 0, pixel.Ink, 3)
})
ui.Column(c).Gap(k.D(3)).Children(func() {
text.ShadowText(c, h.Name, text.ShadowTextOptions{Scale: 2})
pixel.Text(c, h.Class+" "+fmt.Sprintf(k.Msg("level"), h.Level), pixel.TextOptions{Slot: pixel.Faint})
// Stats stand in a column: names Faint, values right-aligned.
w := name + 8 + value
ui.Box(c).Role(ui.RoleText).Label(strings.Join(stats, ", ")).Size(k.D(w), k.D(len(h.Stats)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, s := range h.Stats {
n := strconv.Itoa(s.Value)
g.Text(0, i*pixel.LineHeight, s.Name, pixel.Faint, 1)
g.Text(w-pixel.Measure(n), i*pixel.LineHeight, n, pixel.Ink, 1)
}
})
})
})
}
+29
View File
@@ -0,0 +1,29 @@
package game
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CoinCounterOptions struct {
// Scale is 1–4; zero means 1.
Scale int
}
// CoinCounter spins a coin beside a count that ticks up.
func CoinCounter(c *ui.Context, text string, coins int, o CoinCounterOptions) *ui.Element {
k := pixel.K(c)
if coins < 0 {
panic(fmt.Sprintf("game: CoinCounter %d", coins))
}
sc := pixel.Or("CoinCounter Scale", o.Scale, 1)
return ui.Row(c).Gap(k.D(2 * sc)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() {
display.Sprite(c, coin, display.SpriteOptions{Scale: sc, FPS: 8})
feedback.CountUp(c, coins, feedback.CountUpOptions{Scale: sc})
})
}
+78
View File
@@ -0,0 +1,78 @@
package game
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Bearing marks a direction on a compass, in degrees.
type Bearing struct {
Degrees float64
Slot pixel.Slot
}
type CompassOptions struct {
// Width is in dots; zero means 120. It shows 180°.
Width int
}
// Compass is a heading strip with letters and markers.
func Compass(c *ui.Context, text string, heading float64, marks []Bearing, o CompassOptions) *ui.Element {
k := pixel.K(c)
for _, v := range append([]float64{heading}, bearings(marks)...) {
if math.IsNaN(v) || math.IsInf(v, 0) {
panic(fmt.Sprintf("game: Compass bearing %v", v))
}
}
w := pixel.Or("Compass Width", o.Width, 120)
letters := strings.Fields(k.Msg("compass"))
heading = math.Mod(math.Mod(heading, 360)+360, 360)
// x places bearing b; false means off the strip.
x := func(b float64) (int, bool) {
d := math.Mod(math.Mod(b-heading, 360)+540, 360) - 180
return w/2 + int(math.Round(d*float64(w)/180)), d >= -90 && d <= 90
}
top := tagH(letters[0]) + 3
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strconv.Itoa(int(math.Round(heading)))+"°").Size(k.D(w), k.D(top+10)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, top, w, 10, pixel.Sunken, pixel.Well)
for b := 0; b < 360; b += 15 {
at, ok := x(float64(b))
if !ok || at < 1 || at > w-2 {
continue
}
tall := 3
if b%45 == 0 {
tall = 6
}
g.VLine(at, top+10-1-tall, tall, pixel.Edge)
if b%90 == 0 {
l := letters[b/90]
tag(g, max(0, min(w-tagW(l), at-tagW(l)/2)), 0, l, pixel.Ink)
}
}
for _, m := range marks {
if at, ok := x(m.Degrees); ok {
g.Rect(max(1, min(w-4, at-1)), top+2, 3, 3, m.Slot)
}
}
// The heading's own mark points down at the middle.
g.Rect(w/2-2, top-1, 5, 1, pixel.Accent)
g.Rect(w/2-1, top, 3, 1, pixel.Accent)
g.Dot(w/2, top+1, pixel.Accent)
})
}
func bearings(marks []Bearing) []float64 {
out := make([]float64, len(marks))
for i, m := range marks {
out[i] = m.Degrees
}
return out
}
+208
View File
@@ -0,0 +1,208 @@
package game_test
import (
"testing"
"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"
)
var bag = []data.Item{{Sprite: icon.Potion, Name: "Potion", Count: 5}, {Sprite: icon.Sword, Name: "Sword"}, {Sprite: icon.Bolt, Name: "Bolt"}, {}, {Sprite: icon.Key, Name: "Key"}}
func TestHotbarKeysWheelAndClicks(t *testing.T) {
sel, changes, used := 0, 0, -1
off := false
wd := &world{clk: newClock()}
tt := view(400, 100, wd, func(c *ui.Context) {
r := game.Hotbar(c, "Hotbar", bag, &sel, game.HotbarOptions{Disabled: off})
if r.Changed {
changes++
}
if r.Used >= 0 {
used = r.Used
}
})
// Slot i is 22 dots wide, its middle at 22i+11.
tt.ClickAt(2*(22*2+11), 22)
if sel != 2 || used != 2 || changes != 1 {
t.Fatalf("click: slot %d, used %d, %d changes", sel, used, changes)
}
tt.Key(0, ui.Key5)
tt.Key(0, ui.Key9)
tt.Key(0, ui.KeyLeft)
if sel != 3 || changes != 3 {
t.Fatalf("5, 9, Left: slot %d, %d changes", sel, changes)
}
// A notch is a 16-dot line; the bar wraps.
tt.Scroll(22, 22, 0, 32)
tt.Scroll(22, 22, 0, 32)
tt.Scroll(22, 22, 0, -16)
if sel != 0 || changes != 5 {
t.Fatalf("two notches and a half: slot %d, %d changes", sel, changes)
}
tt.Key(0, ui.KeyEnter)
if used != 0 {
t.Fatalf("Enter used %d", used)
}
keeps(t, tt.Image())
off = true
tt.Frame()
tt.Key(0, ui.Key3)
tt.Scroll(22, 22, 0, 32)
if sel != 0 {
t.Fatalf("a disabled bar moved to %d", sel)
}
panics(t, "ten slots", func() {
view(400, 100, wd, func(c *ui.Context) {
game.Hotbar(c, "x", make([]data.Item, 10), &sel, game.HotbarOptions{})
})
})
}
func TestInertHotbarIgnoresTheWheel(t *testing.T) {
sel := 0
wd := &world{clk: newClock(), inert: true}
tt := view(400, 100, wd, func(c *ui.Context) { game.Hotbar(c, "Hotbar", bag, &sel, game.HotbarOptions{}) })
tt.Scroll(22, 22, 0, 32)
tt.ClickAt(2*(22*3+11), 22)
if sel != 0 {
t.Fatalf("an inert bar moved to %d", sel)
}
}
func TestEquipmentWalksTheDoll(t *testing.T) {
gear := []data.Item{{Sprite: icon.Crown, Name: "Crown"}, {}, {Sprite: icon.Sword, Name: "Iron sword"}, {}, {}}
sel, picked := game.Weapon, -1
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.Equipment(c, "Gear", gear, &sel, game.EquipmentOptions{}); r.Picked >= 0 {
picked = r.Picked
}
})
r := find(t, tt, "Gear")
// The doll is centered under the widest caption.
w := 3*26 - 4
for _, s := range []string{"HEAD: Crown", "BODY", "WEAPON: Iron sword", "SHIELD", "RING"} {
w = max(w, pixel.Measure(s))
}
left := (w - (3*26 - 4)) / 2
click := func(x, y int) { tt.ClickAt(r.X+float32(2*(left+x*26+11)), r.Y+float32(2*(y*26+11))) }
click(2, 1)
if sel != game.Shield || picked != game.Shield {
t.Fatalf("clicking the shield slot chose %d, picked %d", sel, picked)
}
// Left of the doll, nothing is hit.
tt.ClickAt(r.X+float32(2*(left-5)), r.Y+float32(2*(26+11)))
if sel != game.Shield {
t.Fatalf("a click beside the doll chose %d", sel)
}
for _, step := range []struct {
key ui.Key
want int
}{{ui.KeyLeft, game.Body}, {ui.KeyUp, game.Head}, {ui.KeyLeft, game.Weapon}, {ui.KeyDown, game.Ring}, {ui.KeyUp, game.Weapon}, {ui.KeyRight, game.Body}, {ui.KeyDown, game.Ring}} {
tt.Key(0, step.key)
if sel != step.want {
t.Fatalf("%v went to %d, not %d", step.key, sel, step.want)
}
}
tt.Key(0, ui.KeySpace)
if picked != game.Ring {
t.Fatalf("Space picked %d", picked)
}
keeps(t, tt.Image())
panics(t, "four slots", func() {
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.Equipment(c, "x", gear[:4], &sel, game.EquipmentOptions{})
})
})
}
func TestSkillTreeLearnsOpenSkillsWithPoints(t *testing.T) {
skills := []game.Skill{
{Name: "Slash", Sprite: icon.Sword, At: game.Spot{X: 1}, Learned: true},
{Name: "Guard", Sprite: icon.Shield, At: game.Spot{Y: 1}, Needs: []int{0}},
{Name: "Fire", Sprite: icon.Flame, At: game.Spot{X: 2, Y: 1}, Needs: []int{0}, Learned: true},
{Name: "Meteor", Sprite: icon.Star, At: game.Spot{X: 2, Y: 2}, Needs: []int{2, 1}},
}
cur, points, learned := 0, 1, -1
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.SkillTree(c, "Skills", skills, &cur, &points, game.SkillTreeOptions{}); r.Learned >= 0 {
learned = r.Learned
}
})
r := find(t, tt, "Skills")
// Nodes sit 28 dots apart under a 20-dot header.
node := func(x, y int) { tt.ClickAt(r.X+float32(2*(x*28+10)), r.Y+float32(2*(20+y*28+10))) }
node(1, 0)
tt.Key(0, ui.KeyEnter)
if points != 1 {
t.Fatal("a learned skill took a point")
}
node(2, 2)
tt.Key(0, ui.KeyEnter)
if cur != 3 || skills[3].Learned || points != 1 {
t.Fatal("a locked skill was learned")
}
node(0, 1)
if cur != 1 || skills[1].Learned {
t.Fatal("the first click on a skill learned it")
}
node(0, 1)
if !skills[1].Learned || points != 0 || learned != 1 {
t.Fatalf("the second click: learned %d, %d points", learned, points)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if cur != 3 || skills[3].Learned {
t.Fatal("Meteor was learned with no points")
}
points = 1
tt.Key(0, ui.KeyEnter)
if !skills[3].Learned || points != 0 || learned != 3 {
t.Fatal("Meteor was not learned with a point")
}
keeps(t, tt.Image())
first := 0
panics(t, "skills needing each other", func() {
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, Needs: []int{1}}, {Name: "b", Sprite: icon.Star, At: game.Spot{X: 1}, Needs: []int{0}}}, &first, &points, game.SkillTreeOptions{})
})
})
panics(t, "a skill off the grid", func() {
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, At: game.Spot{X: -1}}}, &first, &points, game.SkillTreeOptions{})
})
})
panics(t, "a skill needing itself", func() {
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.SkillTree(c, "x", []game.Skill{{Name: "a", Sprite: icon.Star, Needs: []int{0}}}, &cur, &points, game.SkillTreeOptions{})
})
})
}
func TestSkillButtonFiresOnlyWhenReady(t *testing.T) {
cool, clicks := 0.5, 0
tt := view(200, 100, &world{clk: newClock()}, func(c *ui.Context) {
if game.SkillButton(c, "Fireball", icon.Flame, "Q", cool, game.SkillButtonOptions{}).Clicked {
clicks++
}
})
// The wipe darkens the left half for half a cooldown.
wiped := count(t, tt.Image(), 2, 2, 9, 20, pixel.Edge)
if err := tt.Click("Fireball"); err != nil || clicks != 0 {
t.Fatalf("a cooling skill fired: %v", err)
}
cool = 0
tt.Frame()
if n := count(t, tt.Image(), 2, 2, 9, 20, pixel.Edge); wiped < n+50 {
t.Fatalf("wipe dots %d, ready %d", wiped, n)
}
tt.Click("Fireball")
if clicks != 1 {
t.Fatal("a ready skill did not fire")
}
}
+88
View File
@@ -0,0 +1,88 @@
package game
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Hit is a number rising from dot X, Y.
type Hit struct {
Value int
Crit bool
X, Y int
at time.Time
}
type DamageNumbersOptions struct {
// Width and Height are dots; zeros mean 160 by 80.
Width, Height int
}
// life is a number's time; it dissolves the last half.
const life = 800 * time.Millisecond
// DamageNumbers floats hits up, dissolves, then drops them.
func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptions) *ui.Element {
k := pixel.K(c)
w, h := pixel.Or("DamageNumbers Width", o.Width, 160), pixel.Or("DamageNumbers Height", o.Height, 80)
now := k.Now()
live := (*hits)[:0]
for _, hit := range *hits {
if hit.X < 0 || hit.X >= w || hit.Y < 0 || hit.Y >= h {
panic(fmt.Sprintf("game: hit at %d,%d is off the %d×%d box", hit.X, hit.Y, w, h))
}
if hit.at.IsZero() {
hit.at = now
}
if now.Sub(hit.at) < life {
live = append(live, hit)
}
}
*hits = live
shown := append([]Hit(nil), live...)
parts := make([]string, len(shown))
for i, hit := range shown {
parts[i] = strconv.Itoa(hit.Value)
if hit.Crit {
parts[i] += "!"
}
}
if len(shown) > 0 {
k.Tick(c, 20)
}
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Numbers wider than the box are cut at it.
g.Clip(0, 0, w, h, func() { numbers(g, k, shown, now, w, h) })
})
}
// numbers draws live hits, risen and dissolved by age.
func numbers(g pixel.Canvas, k pixel.Kit, shown []Hit, now time.Time, w, h int) {
for _, hit := range shown {
t := float64(now.Sub(hit.at)) / float64(life)
ink, sc, s := pixel.Ink, 1, strconv.Itoa(hit.Value)
if hit.Crit {
ink, sc, s = pixel.Warn, 2, s+"!"
}
// Wide or high numbers are pushed back inside the box.
tw, th := pixel.Measure(s)*sc, pixel.LineHeight*sc
x, y := max(0, min(w-tw, hit.X-tw/2)), max(0, min(h-th, hit.Y-th/2))
// It rises 12 dots and dissolves; reduced, it holds still.
if k.Reduce() {
g.Text(x, y, s, ink, sc)
continue
}
level := 16
if t > 0.5 {
level = int(16 * (1 - t) * 2)
}
g.TextVeil(x, max(0, y-int(12*t)), s, ink, level, sc)
}
}
+112
View File
@@ -0,0 +1,112 @@
package game
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// The paper doll's slots, in the order Equipment takes gear.
const (
Head = iota
Body
Weapon
Shield
Ring
)
// doll places the slots on a 3×3 grid.
var doll = []struct {
at Spot
name string
hint *pixel.Sprite
}{
{Spot{1, 0}, "head", icon.Crown}, {Spot{1, 1}, "body", icon.User}, {Spot{0, 1}, "weapon", icon.Sword},
{Spot{2, 1}, "shield", icon.Shield}, {Spot{0, 2}, "ring", icon.Gem},
}
// pitch is a doll cell: a slot and its gap.
const pitch = data.SlotSize + 4
type EquipmentOptions struct {
Disabled bool
}
type EquipmentResult struct {
Element *ui.Element
Changed bool
// Picked is the slot opened this frame, or -1.
Picked int
}
// Equipment is a paper doll with five slots.
func Equipment(c *ui.Context, text string, gear []data.Item, selected *int, o EquipmentOptions) EquipmentResult {
k := pixel.K(c)
if len(gear) != len(doll) || *selected < 0 || *selected >= len(doll) {
panic(fmt.Sprintf("game: Equipment has %d slots, slot %d; it takes head, body, weapon, shield, ring", len(gear), *selected))
}
old := *selected
spots := make([]Spot, len(doll))
captions := make([]string, len(doll))
w := 3*pitch - 4
for i, d := range doll {
spots[i], captions[i] = d.at, k.Msg(d.name)
if gear[i].Sprite != nil {
captions[i] += ": " + gear[i].String()
}
w = max(w, pixel.Measure(captions[i]))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
arrows(base, spots, selected)
res := EquipmentResult{Element: shell, Picked: -1}
if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) {
res.Picked = *selected
}
left := (w - (3*pitch - 4)) / 2
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
x, y, _ := base.PointerPosition()
dx, dy := int(x/k.Dot())-left, int(y/k.Dot())
for i, s := range spots {
if dx >= s.X*pitch && dx < s.X*pitch+data.SlotSize && dy >= s.Y*pitch && dy < s.Y*pitch+data.SlotSize {
*selected, res.Picked = i, i
}
}
}
res.Changed = *selected != old
base.Description(captions[*selected])
if res.Changed {
slog.Debug("8bit: equipment", "label", text, "slot", doll[*selected].name)
}
sel, shown := *selected, append([]data.Item(nil), gear...)
shell.Size(k.D(w), k.D(3*pitch+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
figure(g, left)
for i, d := range doll {
x, y := left+d.at.X*pitch, d.at.Y*pitch
data.DrawSlot(g, x, y, shown[i], data.ItemSlotOptions{Selected: i == sel})
if shown[i].Sprite == nil {
g.Silhouette(x+(data.SlotSize-d.hint.W)/2, y+(data.SlotSize-d.hint.H)/2, d.hint, 0, pixel.Shade, 1)
}
}
g.Text((w-pixel.Measure(captions[sel]))/2, 3*pitch, captions[sel], pixel.Ink, 1)
g.Finish(st)
})
return res
}
// figure sketches the body the slots dress: neck, arms, legs.
func figure(g pixel.Canvas, x int) {
mid := x + pitch + data.SlotSize/2
g.Rect(mid-2, data.SlotSize, 4, 4, pixel.Shade)
g.Rect(x+data.SlotSize/2, pitch+9, 2*pitch, 4, pixel.Shade)
g.Rect(mid-6, 2*pitch-4, 5, pitch, pixel.Shade)
g.Rect(mid+1, 2*pitch-4, 5, pitch, pixel.Shade)
}
+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 ExpBarOptions struct {
// Width is in dots; zero means 96.
Width int
}
type riseKey struct{}
// rise is the last level, xp and fill time.
type rise struct {
level, xp int
at time.Time
}
// ExpBar shows xp toward next and the level; ups flash.
func ExpBar(c *ui.Context, text string, level, xp, next int, o ExpBarOptions) *ui.Element {
k := pixel.K(c)
if level < 1 {
panic(fmt.Sprintf("game: ExpBar level %d", level))
}
amount("ExpBar xp", xp, next)
w := pixel.Or("ExpBar Width", o.Width, 96)
lv := fmt.Sprintf(k.Msg("level"), level)
fits("ExpBar", w, lv, ratio(next, next))
e := ui.Box(c).Role(ui.RoleMeter).Label(text).Description(lv + " " + ratio(xp, next))
up := ui.Local(e, riseKey{}, func() rise { return rise{level: level, xp: xp} })
// It fills as xp reaches next or the level rises.
if level > up.level || level == up.level && xp == next && up.xp < next {
up.at = k.Now()
}
up.level, up.xp = level, xp
// A fill flashes 0.6 s; reduced, it jumps past.
flash := !k.Reduce() && k.Since(c, up.at, 10, 6) < 6
top := headH(lv)
return e.Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
head(g, w, lv, ratio(xp, next))
switch {
case !flash:
meter(g, 0, top, w, 8, float64(xp)/float64(next), pixel.Warn)
case g.Busy(10)%2 == 0:
meter(g, 0, top, w, 8, 1, pixel.Lit)
default:
meter(g, 0, top, w, 8, 1, pixel.Warn)
}
})
}
+106
View File
@@ -0,0 +1,106 @@
// Package game is an RPG's HUD, menus and screens.
package game
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// tag writes s from ink top y, micro if possible.
func tag(g pixel.Canvas, x, y int, s string, ink pixel.Slot) {
if pixel.MicroOK(s) {
g.Micro(x, y, s, ink, 1)
return
}
g.Text(x, y-3, s, ink, 1)
}
// tagW is s's width as tag writes it.
func tagW(s string) int {
if pixel.MicroOK(s) {
return pixel.MicroWidth(s)
}
return pixel.Measure(s)
}
// tagH is s's ink height; font ink spans rows 3–14.
func tagH(s string) int {
if pixel.MicroOK(s) {
return pixel.MicroHeight
}
return 12
}
// meter frames a bar, fills part and returns its end.
func meter(g pixel.Canvas, x, y, w, h int, part float64, ink pixel.Slot) int {
g.Frame(x, y, w, h, pixel.Sunken, pixel.Well)
fill := int(part * float64(w-4))
g.Rect(x+2, y+2, fill, h-4, ink)
return x + 2 + fill
}
// amount checks v of most, as hit points are.
func amount(name string, v, most int) {
if most <= 0 || v < 0 || v > most {
panic(fmt.Sprintf("game: %s %d of %d", name, v, most))
}
}
// ratio prints "v/most".
func ratio(v, most int) string { return strconv.Itoa(v) + "/" + strconv.Itoa(most) }
// 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 {
panic(fmt.Sprintf("game: %s Width %d is under the %d its labels need", name, w, need))
}
}
// headH is the label row's height above a HUD bar.
func headH(text string) int { return tagH(text) + 2 }
// head writes a label left and a micro value right.
func head(g pixel.Canvas, w int, text, value string) {
tag(g, 0, 0, text, pixel.Ink)
g.Micro(w-pixel.MicroWidth(value), headH(text)-2-pixel.MicroHeight, value, pixel.Ink, 1)
}
// nearest is the next spot toward dx, dy, or from.
func nearest(spots []Spot, from, dx, dy int) int {
best, score := from, 0
for i, s := range spots {
ahead := (s.X-spots[from].X)*dx + (s.Y-spots[from].Y)*dy
aside := abs((s.X-spots[from].X)*dy) + abs((s.Y-spots[from].Y)*dx)
if ahead <= 0 {
continue
}
// Straight ahead beats near but off to the side.
if d := ahead + 2*aside; best == from || d < score {
best, score = i, d
}
}
return best
}
// arrows moves cur among spots by arrow keys.
func arrows(base *ui.Element, spots []Spot, cur *int) {
for _, m := range []struct {
key ui.Key
dx, dy int
}{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} {
if base.Shortcut(0, m.key) {
*cur = nearest(spots, *cur, m.dx, m.dy)
}
}
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
+105
View File
@@ -0,0 +1,105 @@
package game_test
import (
"image"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func newClock() *clock { return &clock{time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)} }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, zh, inert bool
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
s := pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert}
if wd.zh {
s.Locale = pixel.Zh
}
pixel.Use(c, s)
build(c)
}, w, h)
if wd.reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
}
return tt
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// at is the slot of dot x, y.
func at(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
t.Helper()
c := img.RGBAAt(2*x, 2*y)
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
return pixel.Slot(s)
}
}
t.Fatalf("dot %d,%d is %v, off the palette", x, y, c)
return 0
}
// count tallies dots of s in a dot box.
func count(t *testing.T, img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
t.Helper()
n := 0
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if at(t, img, i, j) == s {
n++
}
}
}
return n
}
// keeps fails on off-palette colors or split dots.
func keeps(t *testing.T, img *image.RGBA) {
t.Helper()
b := img.Bounds()
for y := 0; y+1 < b.Dy(); y += 2 {
for x := 0; x+1 < b.Dx(); x += 2 {
at(t, img, x/2, y/2)
c := img.RGBAAt(x, y)
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
t.Fatalf("dot at %d,%d is split", x/2, y/2)
}
}
}
}
// panics fails unless fn panics.
func panics(t *testing.T, what string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatalf("%s did not panic", what)
}
}()
fn()
}
// dots is r's top-left in dots.
func dots(r ui.Rect) (int, int) { return int(r.X) / 2, int(r.Y) / 2 }
+81
View File
@@ -0,0 +1,81 @@
package game
import (
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HealthBarOptions struct {
// Width is in dots; zero means 96.
Width int
}
type trailKey struct{}
// trail is the last hp and full, and the trail.
type trail struct {
seen, full int
from float64
at time.Time
}
// hold is how long a trail waits before draining.
const hold = 300 * time.Millisecond
// HealthBar shows hp with a lagging trail; low HP blinks.
func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui.Element {
k := pixel.K(c)
amount("HealthBar hp", hp, full)
w := pixel.Or("HealthBar Width", o.Width, 96)
fits("HealthBar", w, text, ratio(full, full))
e := ui.Box(c).Role(ui.RoleMeter).Label(text).Description(ratio(hp, full))
tr := ui.Local(e, trailKey{}, func() trail { return trail{seen: hp, full: full, from: float64(hp)} })
// A new full makes the old trail meaningless.
if full != tr.full {
tr.seen, tr.full, tr.from = hp, full, float64(hp)
}
now := k.Now()
// end is the trail's end: held, then draining fast.
end := func() float64 {
return max(float64(tr.seen), tr.from-max(0, now.Sub(tr.at)-hold).Seconds()*float64(full))
}
if hp < tr.seen {
tr.from, tr.at = end(), now
}
if hp > tr.seen {
tr.from = float64(hp)
}
tr.seen = hp
shown := end()
if k.Reduce() {
shown = float64(hp)
}
if shown > float64(hp) {
k.Tick(c, 30)
}
ink := pixel.Good
switch {
case hp*4 <= full:
ink = pixel.Bad
case hp*2 <= full:
ink = pixel.Warn
}
low := hp > 0 && hp*4 <= full
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)
head(g, w, text, ratio(hp, full))
fill := ink
// Low HP blinks; reduced, it holds steady.
if low && !k.Reduce() && g.Busy(4)%2 == 1 {
fill = pixel.Lit
}
x := meter(g, 0, top, w, 8, float64(hp)/float64(full), fill)
if t := 2 + int(shown/float64(full)*float64(w-4)); t > x {
g.Rect(x, top+2, t-x, 4, pixel.AccentLit)
}
})
}
+43
View File
@@ -0,0 +1,43 @@
package game
import (
"fmt"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HeartsOptions struct {
// PerRow wraps the hearts; zero means 10.
PerRow int
}
// Hearts shows hp of full hearts, in quarters.
func Hearts(c *ui.Context, text string, hp, full float64, o HeartsOptions) *ui.Element {
k := pixel.K(c)
if full < 1 || full != math.Trunc(full) || !(hp >= 0 && hp <= full) || hp*4 != math.Trunc(hp*4) {
panic(fmt.Sprintf("game: Hearts %v of %v; hearts are whole, health in quarters", hp, full))
}
per := pixel.Or("Hearts PerRow", o.PerRow, 10)
n := int(full)
cols, rows := min(n, per), (n+per-1)/per
shown := strconv.FormatFloat(hp, 'f', -1, 64) + "/" + strconv.Itoa(n)
return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(shown).Size(k.D(cols*11+1), k.D(rows*10+2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range n {
x, y := i%per*11, i/per*10
// Quarters fill from the bottom left, clockwise.
q := min(4, max(0, int(hp*4)-4*i))
for j, part := range [][2]int{{0, 6}, {0, 0}, {6, 0}, {6, 6}} {
if j < q {
g.Clip(x+part[0], y+part[1], 6, 6, func() { g.Sprite(x, y, icon.Heart, 0, pixel.Bad, 1) })
}
}
g.Sprite(x, y, icon.HeartEmpty, 0, pixel.Edge, 1)
}
})
}
+81
View File
@@ -0,0 +1,81 @@
package game
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HotbarOptions struct {
Disabled bool
}
type HotbarResult struct {
Element *ui.Element
Changed bool
// Used is the slot used this frame, or -1.
Used int
}
type wheelKey struct{}
// wheel holds unspent scroll and a turn not yet reported.
type wheel struct {
left float32
moved bool
}
// Hotbar rows nine slots; 1–9, arrows or the wheel choose.
func Hotbar(c *ui.Context, text string, items []data.Item, selected *int, o HotbarOptions) HotbarResult {
k := pixel.K(c)
n := len(items)
if n == 0 || n > 9 || *selected < 0 || *selected >= n {
panic(fmt.Sprintf("game: Hotbar slot %d of %d; it holds 1–9", *selected, n))
}
old := *selected
shell, base, st, used := pixel.Pad(c, text, n, n, data.SlotSize, data.SlotSize, selected, o.Disabled)
for i := range n {
if base.Shortcut(0, ui.Key1+ui.Key(i)) {
*selected = i
}
}
wh := ui.Local(base, wheelKey{}, func() wheel { return wheel{} })
notch := k.D(pixel.LineHeight)
base.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll {
return false
}
// A notch moves one slot; the bar wraps around.
for wh.left += ev.DY; wh.left >= notch || wh.left <= -notch; {
d := 1
if wh.left < 0 {
d = -1
}
wh.left -= float32(d) * notch
*selected, wh.moved = ((*selected+d)%n+n)%n, true
}
return true
})
res := HotbarResult{Element: shell, Changed: *selected != old || wh.moved, Used: used}
wh.moved = false
base.Description(items[*selected].String())
if res.Changed {
slog.Debug("8bit: hotbar", "label", text, "slot", *selected)
}
sel, shown := *selected, append([]data.Item(nil), items...)
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, it := range shown {
x := i * data.SlotSize
data.DrawSlot(g, x, 0, it, data.ItemSlotOptions{Selected: i == sel})
g.Micro(x+3, 3, strconv.Itoa(i+1), pixel.Faint, 1)
}
g.Finish(st)
})
return res
}
+232
View File
@@ -0,0 +1,232 @@
package game_test
import (
"testing"
"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/pixel"
)
func TestHealthBarTrailHoldsThenDrains(t *testing.T) {
wd := &world{clk: newClock()}
hp := 80
tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
// The fill's middle row is 10; full is 92 dots.
row := func(s pixel.Slot) int { return count(t, tt.Image(), 0, 10, 96, 1, s) }
if row(pixel.Good) != 73 {
t.Fatalf("80 HP fills %d dots", row(pixel.Good))
}
hp = 40
tt.Frame()
if row(pixel.Warn) != 36 || row(pixel.AccentLit) != 37 {
t.Fatalf("at the hit: fill %d, trail %d", row(pixel.Warn), row(pixel.AccentLit))
}
for _, step := range []struct {
after time.Duration
trail int
}{{300 * time.Millisecond, 37}, {200 * time.Millisecond, 19}, {500 * time.Millisecond, 0}} {
wd.clk.t = wd.clk.t.Add(step.after)
tt.Frame()
if row(pixel.AccentLit) != step.trail {
t.Fatalf("%v later the trail is %d dots", step.after, row(pixel.AccentLit))
}
}
hp = 90
tt.Frame()
if row(pixel.Good) != 82 || row(pixel.AccentLit) != 0 {
t.Fatal("a heal left a trail")
}
// A heal during a live trail clears it too.
hp = 30
tt.Frame()
hp = 60
tt.Frame()
if row(pixel.AccentLit) != 0 {
t.Fatal("a heal kept a live trail")
}
keeps(t, tt.Image())
// A new full drops the old trail.
full := 100
tt = view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, full, game.HealthBarOptions{}) })
hp, full = 40, 50
tt.Frame()
if row(pixel.AccentLit) != 0 || count(t, tt.Image(), 96, 0, 54, 30, pixel.AccentLit) != 0 {
t.Fatal("the trail outlived a new full")
}
panics(t, "a bar narrower than its labels", func() {
view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 100, game.HealthBarOptions{Width: 20}) })
})
panics(t, "hp past full", func() {
view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 4, game.HealthBarOptions{}) })
})
}
func TestHealthBarReducedHasNoTrailAndNoBlink(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
hp := 90
tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
hp = 20
tt.Frame()
if trail := count(t, tt.Image(), 0, 10, 96, 1, pixel.AccentLit); (trail == 0) != reduce {
t.Fatalf("reduced %v: trail of %d dots", reduce, trail)
}
// Low HP blinks at 2 Hz: Bad, then Lit.
seen := map[pixel.Slot]bool{}
for range 2 {
seen[at(t, tt.Image(), 4, 10)] = true
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := seen[pixel.Lit] && seen[pixel.Bad]; blinked == reduce {
t.Fatalf("reduced %v: low HP showed %v", reduce, seen)
}
}
}
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()
// 35 of 60 fills 53 dots; three ticks cut in.
if info, ticks := count(t, img, 0, 10, 96, 1, pixel.Info), count(t, img, 2, 10, 92, 1, pixel.Edge); info != 50 || ticks != 5 {
t.Fatalf("fill %d dots, %d ticks", info, ticks)
}
}
func TestExpBarFlashesWhenItFills(t *testing.T) {
for _, tc := range []struct {
name string
level, xp int
reduce, flash bool
}{
{"a level up", 8, 10, false, true}, {"a fill", 7, 300, false, true},
{"a reduced level up", 8, 10, true, false}, {"a lost level", 6, 10, false, false},
} {
wd := &world{clk: newClock(), reduce: tc.reduce}
level, xp := 7, 290
tt := view(300, 60, wd, func(c *ui.Context) { game.ExpBar(c, "EXP", level, xp, 300, game.ExpBarOptions{}) })
level, xp = tc.level, tc.xp
tt.Frame()
// With no flash, the first frame already shows xp.
if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; !tc.flash && n != want {
t.Fatalf("%s: the first frame fills %d dots, not %d", tc.name, n, want)
}
seen := map[pixel.Slot]bool{}
for range 2 {
img := tt.Image()
seen[at(t, img, 4, 10)] = true
if full := count(t, img, 2, 10, 92, 1, pixel.Lit) + count(t, img, 2, 10, 92, 1, pixel.Warn); tc.flash && full != 92 {
t.Fatalf("%s: the flash fills %d dots", tc.name, full)
}
wd.clk.t = wd.clk.t.Add(100 * time.Millisecond)
tt.Frame()
}
if flashed := seen[pixel.Lit] && seen[pixel.Warn]; flashed != tc.flash {
t.Fatalf("%s: the bar showed %v", tc.name, seen)
}
// After any flash the bar shows xp.
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; n != want {
t.Fatalf("%s: %d of 300 fills %d dots, not %d", tc.name, tc.xp, n, want)
}
}
}
func TestHeartsFillByQuarters(t *testing.T) {
tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Hearts(c, "Life", 1.75, 3, game.HeartsOptions{})
})
img := tt.Image()
// Quadrant dots: bottom left, top left, top right, bottom right.
quads := [][2]int{{4, 7}, {3, 4}, {8, 4}, {7, 7}}
for i, want := range [][4]bool{{true, true, true, true}, {true, true, true, false}, {false, false, false, false}} {
for q, d := range quads {
if got := at(t, img, i*11+d[0], d[1]) == pixel.Bad; got != want[q] {
t.Fatalf("heart %d quadrant %d filled %v", i, q, got)
}
}
}
keeps(t, img)
// A lone quarter fills the bottom left only.
tt = view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "Life", 0.25, 1, game.HeartsOptions{}) })
for q, d := range quads {
if got := at(t, tt.Image(), d[0], d[1]) == pixel.Bad; got != (q == 0) {
t.Fatalf("a quarter heart filled quadrant %d: %v", q, got)
}
}
for _, bad := range [][2]float64{{0.3, 3}, {1, 2.5}, {4, 3}, {-1, 3}} {
panics(t, "hearts out of quarters", func() {
view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "x", bad[0], bad[1], game.HeartsOptions{}) })
})
}
}
func TestLivesShowTheirIcon(t *testing.T) {
tt := view(300, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.Lives(c, "Lives", 3, game.LivesOptions{})
game.Lives(c, "Stars", 2, game.LivesOptions{Sprite: icon.Star})
})
lives, stars := find(t, tt, "Lives"), find(t, tt, "Stars")
lx, ly := dots(lives)
sx, sy := dots(stars)
// The hero wears a Bad cap; the star is ink.
if count(t, tt.Image(), lx, ly, 12, 16, pixel.Bad) == 0 || count(t, tt.Image(), sx, sy, 12, 16, pixel.Bad) != 0 {
t.Fatal("life icons are wrong")
}
if int(lives.W)/2 != 12+2+pixel.Measure("×3") {
t.Fatalf("lives are %v px wide", lives.W)
}
}
func TestCoinSpinsUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
tt := view(300, 60, wd, func(c *ui.Context) { game.CoinCounter(c, "Gold", 1250, game.CoinCounterOptions{}) })
find(t, tt, "Gold")
before := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn)
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
if spun := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn) != before; spun == reduce {
t.Fatalf("reduced %v: the coin spun %v", reduce, spun)
}
}
}
func TestScoreBoardLightsANewBest(t *testing.T) {
score := 500
tt := view(400, 100, &world{clk: newClock()}, func(c *ui.Context) { game.ScoreBoard(c, score, 9900, game.ScoreBoardOptions{}) })
if !tt.HasText("SCORE 500, HI-SCORE 9900") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) != 0 {
t.Fatalf("texts %q", tt.Texts())
}
score = 12500
tt.Frame()
if !tt.HasText("SCORE 12500, HI-SCORE 12500") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) == 0 {
t.Fatal("a new best is not lit")
}
}
func TestTimerHUDHurriesUnderThirty(t *testing.T) {
for _, tc := range []struct {
left time.Duration
reduce, blink bool
ink pixel.Slot
}{{87 * time.Second, false, false, pixel.Ink}, {12 * time.Second, false, true, pixel.Bad}, {12 * time.Second, true, false, pixel.Bad},
{29500 * time.Millisecond, true, false, pixel.Bad}} {
wd := &world{clk: newClock(), reduce: tc.reduce}
tt := view(300, 100, wd, func(c *ui.Context) { game.TimerHUD(c, tc.left, game.TimerHUDOptions{}) })
var shown []int
for range 2 {
shown = append(shown, count(t, tt.Image(), 0, 8, 60, 34, tc.ink))
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := (shown[0] == 0) != (shown[1] == 0); blinked != tc.blink || shown[0]+shown[1] == 0 {
t.Fatalf("%v left, reduced %v: digit dots %v", tc.left, tc.reduce, shown)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package game
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Attribute is a stat raised at a level up.
type Attribute struct {
Name string
Base int
// Added is the points put in it this time.
Added int
}
type LevelUpScreenOptions struct {
Disabled bool
}
type LevelUpScreenResult struct {
Element *ui.Element
Changed bool
Done bool
}
// back is ◀, the triangle icon turned around.
var back = icon.TriangleRight.Mirror()
// LevelUpScreen spends points on attributes with ◀ ▶, then DONE.
func LevelUpScreen(c *ui.Context, title string, attrs []Attribute, points *int, o LevelUpScreenOptions) LevelUpScreenResult {
k := pixel.K(c)
if *points < 0 {
panic(fmt.Sprintf("game: LevelUpScreen has %d points", *points))
}
name := 0
for _, a := range attrs {
if a.Base < 0 || a.Added < 0 {
panic(fmt.Sprintf("game: attribute %q is %d%+d", a.Name, a.Base, a.Added))
}
name = max(name, pixel.Measure(a.Name))
}
col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleGroup).Label(title)
res := LevelUpScreenResult{Element: col}
col.Children(func() {
ui.Row(c).Gap(k.D(10)).AlignItems(ui.End).Children(func() {
text.ShadowText(c, k.Msg("level_up"), text.ShadowTextOptions{Scale: 2, Slot: pixel.Warn})
pixel.Text(c, fmt.Sprintf(k.Msg("points"), *points), pixel.TextOptions{Slot: pixel.Accent})
})
for i := range attrs {
a := &attrs[i]
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Width(k.D(name + 6)).Children(func() { pixel.Text(c, a.Name, pixel.TextOptions{}) })
if input.IconButton(c, back, a.Name+" "+k.Msg("down"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || a.Added == 0}).Clicked {
a.Added, *points, res.Changed = a.Added-1, *points+1, true
}
ui.Box(c).Width(k.D(40)).Children(func() {
ui.Row(c).Children(func() {
pixel.Text(c, strconv.Itoa(a.Base+a.Added), pixel.TextOptions{})
if a.Added > 0 {
pixel.Text(c, "+"+strconv.Itoa(a.Added), pixel.TextOptions{Slot: pixel.Good})
}
})
})
if input.IconButton(c, icon.TriangleRight, a.Name+" "+k.Msg("up"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || *points == 0}).Clicked {
a.Added, *points, res.Changed = a.Added+1, *points-1, true
}
})
}
res.Done = input.Button(c, k.Msg("done"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
})
if res.Changed {
slog.Debug("8bit: level up", "label", title, "points", *points)
}
return res
}
+33
View File
@@ -0,0 +1,33 @@
package game
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LivesOptions struct {
// Sprite is the life icon; nil means Hero.
Sprite *pixel.Sprite
}
// Lives shows a life icon and ×count.
func Lives(c *ui.Context, text string, n int, o LivesOptions) *ui.Element {
k := pixel.K(c)
if n < 0 {
panic(fmt.Sprintf("game: Lives %d", n))
}
sp := o.Sprite
if sp == nil {
sp = Hero
}
count := "×" + strconv.Itoa(n)
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(count).Size(k.D(sp.W+2+pixel.Measure(count)), k.D(max(sp.H, pixel.LineHeight))).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, (g.H-sp.H)/2, sp, 0, pixel.Ink, 1)
g.Text(sp.W+2, (g.H-pixel.LineHeight)/2, count, pixel.Ink, 1)
})
}
+32
View File
@@ -0,0 +1,32 @@
package game
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ManaBarOptions struct {
// Width is in dots; zero means 96.
Width int
// Tick marks every Tick points; zero means 10.
Tick int
}
// ManaBar shows mp of full, ticked every few points.
func ManaBar(c *ui.Context, text string, mp, full int, o ManaBarOptions) *ui.Element {
k := pixel.K(c)
amount("ManaBar mp", mp, full)
w := pixel.Or("ManaBar Width", o.Width, 96)
fits("ManaBar", w, text, ratio(full, full))
step := pixel.Or("ManaBar Tick", o.Tick, 10)
top := headH(text)
return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(ratio(mp, full)).Size(k.D(w), k.D(top+8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
head(g, w, text, ratio(mp, full))
meter(g, 0, top, w, 8, float64(mp)/float64(full), pixel.Info)
for v := step; v < full; v += step {
g.VLine(2+v*(w-4)/full, top+2, 4, pixel.Edge)
}
})
}
+179
View File
@@ -0,0 +1,179 @@
package game_test
import (
"testing"
"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/pixel"
)
func TestMinimapTilesFogPlayerAndMarks(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
seen := [][]bool{{true, true, false}, {true, true, true}}
tt := view(100, 60, wd, func(c *ui.Context) {
game.Minimap(c, "Map", []string{`#.~`, `"^ `}, seen, game.Spot{X: 1, Y: 0}, []game.Marker{{Spot: game.Spot{X: 0, Y: 1}, Slot: pixel.Warn}}, game.MinimapOptions{})
})
img := tt.Image()
// Tiles are 3 dots inside a 1-dot frame.
for _, tc := range []struct {
x, y int
s pixel.Slot
}{{0, 0, pixel.Edge}, {0, 1, pixel.Warn}, {1, 1, pixel.Faint}, {2, 1, pixel.Back}} {
if n := count(t, img, 1+tc.x*3, 1+tc.y*3, 3, 3, tc.s); n != 9 {
t.Fatalf("tile %d,%d holds %d %v dots", tc.x, tc.y, n, tc.s)
}
}
if fog := count(t, img, 7, 1, 3, 3, pixel.Edge); fog == 0 || fog == 9 {
t.Fatalf("the fogged tile holds %d Edge dots", fog)
}
var lit []int
for range 2 {
lit = append(lit, count(t, tt.Image(), 4, 1, 3, 3, pixel.Accent))
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := lit[0] != lit[1]; blinked == reduce || lit[0]+lit[1] == 0 {
t.Fatalf("reduced %v: the player lit %v", reduce, lit)
}
}
panics(t, "a ragged map", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##", "#"}, nil, game.Spot{}, nil, game.MinimapOptions{})
})
})
panics(t, "ragged fog", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##", "##"}, [][]bool{{true, true}, {true}}, game.Spot{}, nil, game.MinimapOptions{})
})
})
panics(t, "a player off the map", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##"}, nil, game.Spot{X: 2}, nil, game.MinimapOptions{})
})
})
}
func TestCompassTurnsWithTheHeading(t *testing.T) {
shot := func(heading float64) (*ui.Tester, int) {
tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Compass(c, "Heading", heading, []game.Bearing{{Degrees: 70, Slot: pixel.Warn}, {Degrees: 250, Slot: pixel.Bad}}, game.CompassOptions{})
})
return tt, count(t, tt.Image(), 0, 0, 120, 20, pixel.Ink)
}
tt, ink := shot(30)
img := tt.Image()
// N sits 20 dots left of middle, E 40 right.
if count(t, img, 38, 0, 5, 5, pixel.Ink) == 0 || count(t, img, 98, 0, 5, 5, pixel.Ink) == 0 {
t.Fatal("N or E is not where 30° puts it")
}
if count(t, img, 0, 0, 120, 20, pixel.Warn) != 9 || count(t, img, 0, 0, 120, 20, pixel.Bad) != 0 {
t.Fatal("markers in and out of view are wrong")
}
if _, again := shot(390); again != ink {
t.Fatal("390° differs from 30°")
}
// -360° is 0°, in view of a 350° heading.
tt = view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Compass(c, "Heading", 350, []game.Bearing{{Degrees: -360, Slot: pixel.Magic}}, game.CompassOptions{})
})
if count(t, tt.Image(), 0, 0, 120, 20, pixel.Magic) != 9 {
t.Fatal("a marker at -360° is missing")
}
keeps(t, img)
}
func TestBossBarPhases(t *testing.T) {
tt := view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "SLIME KING", 500, 1000, game.BossBarOptions{Phases: []float64{0.25}})
})
img := tt.Image()
// The quarter mark stands 49 dots into the 196-dot fill.
if n := count(t, img, 0, 22, 200, 1, pixel.Bad); n != 97 || at(t, img, 51, 22) != pixel.Ink {
t.Fatalf("half a 196-dot bar is %d Bad dots", n)
}
panics(t, "a bar narrower than the name", func() {
view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "SLIME KING", 1, 1, game.BossBarOptions{Width: 20})
})
})
panics(t, "a phase at 1", func() {
view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "x", 1, 1, game.BossBarOptions{Phases: []float64{1}})
})
})
}
func TestStatusEffectsEdgeByKind(t *testing.T) {
tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.StatusEffects(c, "Effects", []game.Effect{{Sprite: icon.Bolt, Name: "Haste", Turns: 3}, {Sprite: icon.Skull, Name: "Poison", Debuff: true}}, game.StatusEffectsOptions{})
})
img := tt.Image()
if at(t, img, 5, 1) != pixel.Good || at(t, img, 17+5, 1) != pixel.Bad {
t.Fatal("edges do not tell buffs from debuffs")
}
// Only a count above zero gets a badge.
if count(t, img, 0, 9, 16, 7, pixel.Good) == 0 || count(t, img, 17, 9, 16, 7, pixel.Bad) != 0 {
t.Fatal("turn badges are wrong")
}
}
func TestDamageNumbersStayInTheirBox(t *testing.T) {
hits := []game.Hit{{Value: 123456789, Crit: true, X: 155, Y: 2}, {Value: 7, Crit: true, X: 2, Y: 79}, {Value: 1234567890123456789, Crit: true, X: 80, Y: 40}}
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{})
})
})
img := tt.Image()
// The box spans dots 20–179 across and 20–99 down.
outside := count(t, img, 0, 0, 200, 20, pixel.Warn) + count(t, img, 180, 0, 20, 120, pixel.Warn) +
count(t, img, 0, 100, 200, 20, pixel.Warn) + count(t, img, 0, 0, 20, 120, pixel.Warn)
if n := outside; n != 0 || count(t, img, 20, 20, 160, 80, pixel.Warn) == 0 {
t.Fatalf("%d crit dots outside the box", n)
}
}
func TestDamageNumbersShiftInsideWhole(t *testing.T) {
whole := func(x, y int) int {
hits := []game.Hit{{Value: 12345, Crit: true, X: x, Y: y}}
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) })
return count(t, tt.Image(), 0, 0, 160, 80, pixel.Warn)
}
// Near an edge a number shifts in, keeping its dots.
if mid := whole(80, 40); mid == 0 || whole(158, 40) != mid || whole(80, 79) != mid || whole(1, 1) != mid {
t.Fatal("a number near an edge lost dots")
}
}
func TestDamageNumbersRiseAndExpire(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
hits := []game.Hit{{Value: 12, X: 40, Y: 40}, {Value: 99, Crit: true, X: 110, Y: 40}}
tt := view(400, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) })
top := func(s pixel.Slot) int {
for y := range 80 {
if count(t, tt.Image(), 0, y, 160, 1, s) > 0 {
return y
}
}
return -1
}
t0, crit := top(pixel.Ink), top(pixel.Warn)
wd.clk.t = wd.clk.t.Add(400 * time.Millisecond)
tt.Frame()
// Halfway through its life it has risen 6 dots.
if rose := t0 - top(pixel.Ink); t0 < 0 || crit < 0 || (rose == 6) == reduce || rose != 6 && rose != 0 {
t.Fatalf("reduced %v: the number rose %d from %d", reduce, rose, t0)
}
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
tt.Frame()
if len(hits) != 0 || top(pixel.Ink) != -1 {
t.Fatalf("reduced %v: %d hits outlived their time", reduce, len(hits))
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package game
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Spot is a map tile, counted from the top left.
type Spot struct{ X, Y int }
// Marker is a colored spot on a map.
type Marker struct {
Spot
Slot pixel.Slot
}
type MinimapOptions struct {
// Cell is a tile's side in dots; zero means 3.
Cell int
}
// tiles color map runes: wall, floor, water, grass, rock, void.
var tiles = map[rune]pixel.Slot{'#': pixel.Edge, '.': pixel.Well, '~': pixel.Info, '"': pixel.Good, '^': pixel.Faint, ' ': pixel.Back}
// Minimap draws tiles, fog where unseen, player and marks.
func Minimap(c *ui.Context, text string, rows []string, seen [][]bool, player Spot, marks []Marker, o MinimapOptions) *ui.Element {
k := pixel.K(c)
cell := pixel.Or("Minimap Cell", o.Cell, 3)
if len(rows) == 0 {
panic("game: Minimap has no rows")
}
grid := make([][]rune, len(rows))
for y, row := range rows {
grid[y] = []rune(row)
if len(grid[y]) != len(grid[0]) {
panic(fmt.Sprintf("game: Minimap row %d is %d tiles, not %d", y, len(grid[y]), len(grid[0])))
}
for _, r := range grid[y] {
if _, ok := tiles[r]; !ok {
panic(fmt.Sprintf("game: Minimap tile %q", r))
}
}
}
w, h := len(grid[0]), len(grid)
inside := func(s Spot) bool { return s.X >= 0 && s.X < w && s.Y >= 0 && s.Y < h }
for y := range seen {
if len(seen) != h || len(seen[y]) != w {
panic(fmt.Sprintf("game: Minimap fog is not %d×%d", w, h))
}
}
for _, m := range append([]Marker{{Spot: player}}, marks...) {
if !inside(m.Spot) {
panic(fmt.Sprintf("game: Minimap spot %v is off the %d×%d map", m.Spot, w, h))
}
}
at := strconv.Itoa(player.X) + ", " + strconv.Itoa(player.Y)
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(at).Size(k.D(w*cell+2), k.D(h*cell+2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Back)
for y, row := range grid {
for x, t := range row {
if seen != nil && !seen[y][x] {
g.Dither(1+x*cell, 1+y*cell, cell, cell, pixel.Back, pixel.Edge, 12)
continue
}
g.Rect(1+x*cell, 1+y*cell, cell, cell, tiles[t])
}
}
for _, m := range marks {
g.Rect(1+m.X*cell, 1+m.Y*cell, cell, cell, m.Slot)
}
// The player blinks at 2 Hz; reduced, it holds steady.
if k.Reduce() || g.Busy(4)%2 == 0 {
g.Rect(1+player.X*cell, 1+player.Y*cell, cell, cell, pixel.Accent)
}
})
}
+54
View File
@@ -0,0 +1,54 @@
package game
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Member is a party member: portrait, HP and MP.
type Member struct {
Name string
Portrait *pixel.Sprite
HP, MaxHP, MP, MaxMP int
}
type PartyFramesOptions struct{}
// PartyFrames stacks member cards; a fallen member greys out.
func PartyFrames(c *ui.Context, text string, members []Member, o PartyFramesOptions) *ui.Element {
k := pixel.K(c)
return ui.Column(c).Gap(k.D(3)).AlignItems(ui.Start).Role(ui.RoleGroup).Label(text).Children(func() {
for _, m := range members {
if m.Portrait == nil {
panic(fmt.Sprintf("game: member %q has no portrait", m.Name))
}
down := m.HP == 0
ui.Row(c).Gap(k.D(4)).Padding(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(m.Name).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face)
}).Children(func() {
ui.Box(c).Size(k.D(28), k.D(28)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 28, 28, pixel.Sunken, pixel.Well)
if down {
g.Silhouette(2, 2, m.Portrait, 0, pixel.Shade, 2)
return
}
g.Sprite(2, 2, m.Portrait, 0, pixel.Ink, 2)
})
ui.Column(c).Gap(k.D(1)).Children(func() {
slot := pixel.Ink
if down {
slot = pixel.Faint
}
pixel.Text(c, m.Name, pixel.TextOptions{Slot: slot})
HealthBar(c, "HP", m.HP, m.MaxHP, HealthBarOptions{Width: 72})
ManaBar(c, "MP", m.MP, m.MaxMP, ManaBarOptions{Width: 72})
})
})
}
})
}
+47
View File
@@ -0,0 +1,47 @@
package game
import (
"fmt"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Objective is a quest step, Have of Need (≥ 1).
type Objective struct {
Text string
Have, Need int
}
// Quest is a title and its objectives.
type Quest struct {
Title string
Objectives []Objective
}
// check fails on objectives past their need.
func (q Quest) check() {
for _, ob := range q.Objectives {
if ob.Need < 1 || ob.Have < 0 || ob.Have > ob.Need {
panic(fmt.Sprintf("game: quest %q objective %q has %d of %d", q.Title, ob.Text, ob.Have, ob.Need))
}
}
}
// done is whether every objective is met.
func (q Quest) done() bool {
for _, ob := range q.Objectives {
if ob.Have < ob.Need {
return false
}
}
return true
}
// tick marks a 7×7 box, checked when done.
func tick(g pixel.Canvas, x, y int, done bool) {
g.Frame(x, y, 7, 7, pixel.Flat, pixel.Well)
if done {
g.Line(x+1, y+3, x+2, y+5, pixel.Good)
g.Line(x+3, y+4, x+5, y+1, pixel.Good)
}
}
+68
View File
@@ -0,0 +1,68 @@
package game
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type QuestLogOptions struct {
Disabled bool
}
type QuestLogResult struct {
Element *ui.Element
Changed bool
}
// QuestLog lists quests; the chosen one is tracked and detailed.
func QuestLog(c *ui.Context, text string, quests []Quest, tracked *int, o QuestLogOptions) QuestLogResult {
k := pixel.K(c)
if len(quests) == 0 || *tracked < 0 || *tracked >= len(quests) {
panic(fmt.Sprintf("game: QuestLog tracks %d of %d", *tracked, len(quests)))
}
for _, q := range quests {
q.check()
}
row := ui.Row(c).Gap(k.D(10)).AlignItems(ui.Start)
res := QuestLogResult{Element: row}
row.Children(func() {
r := data.List(c, text, len(quests), tracked, data.ListOptions{Height: 6 * pixel.LineHeight, Disabled: o.Disabled}, func(i int) {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
mark, ink := icon.Flag, pixel.Accent
switch {
case i == *tracked:
case quests[i].done():
mark, ink = icon.Check, pixel.Good
default:
mark = nil
}
ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
if mark != nil {
k.Canvas(p, r).Sprite(0, 0, mark, 0, ink, 1)
}
})
slot := pixel.Ink
if quests[i].done() {
slot = pixel.Faint
}
pixel.Text(c, quests[i].Title, pixel.TextOptions{Slot: slot})
})
})
r.Element.Width(k.D(128))
res.Changed = r.Changed
ui.Column(c).Gap(k.D(2)).Children(func() {
pixel.Text(c, k.Msg("tracking"), pixel.TextOptions{Slot: pixel.Faint})
QuestTracker(c, quests[*tracked], QuestTrackerOptions{})
})
})
if res.Changed {
slog.Debug("8bit: quest tracked", "label", text, "quest", quests[*tracked].Title)
}
return res
}
+42
View File
@@ -0,0 +1,42 @@
package game
import (
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type QuestTrackerOptions struct{}
// QuestTracker lists a quest's objectives with counts, met ones ticked.
func QuestTracker(c *ui.Context, q Quest, o QuestTrackerOptions) *ui.Element {
k := pixel.K(c)
q.check()
w := icon.Star.W + 2 + pixel.Measure(q.Title)
parts := make([]string, len(q.Objectives))
for i, ob := range q.Objectives {
parts[i] = ob.Text + " " + ratio(ob.Have, ob.Need)
w = max(w, 11+pixel.Measure(ob.Text)+6+pixel.MicroWidth(ratio(ob.Have, ob.Need)))
}
return ui.Box(c).Role(ui.RoleGroup).Label(q.Title).Description(strings.Join(parts, "; ")).Size(k.D(w), k.D((1+len(q.Objectives))*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 2, icon.Star, 0, pixel.Warn, 1)
g.Text(icon.Star.W+2, 0, q.Title, pixel.Warn, 1)
for i, ob := range q.Objectives {
y, met := (i+1)*pixel.LineHeight, ob.Have == ob.Need
tick(g, 1, y+5, met)
ink := pixel.Ink
if met {
ink = pixel.Faint
}
g.Text(11, y, ob.Text, ink, 1)
if ob.Need > 1 {
n := ratio(ob.Have, ob.Need)
g.Micro(w-pixel.MicroWidth(n), y+6, n, ink, 1)
}
}
})
}
+45
View File
@@ -0,0 +1,45 @@
package game
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ScoreBoardOptions struct {
// Digits pads both scores; zero means 6.
Digits int
}
// ScoreBoard shows SCORE and HI-SCORE in big digits.
func ScoreBoard(c *ui.Context, score, hi int, o ScoreBoardOptions) *ui.Element {
k := pixel.K(c)
if score < 0 || hi < 0 {
panic(fmt.Sprintf("game: ScoreBoard %d and %d", score, hi))
}
digits := pixel.Or("ScoreBoard Digits", o.Digits, 6)
// A score past the best becomes the best, lit.
best, beat := max(hi, score), score > hi
cols := []struct {
name, n string
ink pixel.Slot
}{{k.Msg("score"), fmt.Sprintf("%0*d", digits, score), pixel.Faint}, {k.Msg("hi_score"), fmt.Sprintf("%0*d", digits, best), pixel.Faint}}
if beat {
cols[1].ink = pixel.Warn
}
w, top := 0, 0
for _, col := range cols {
w, top = max(w, tagW(col.name), 2*pixel.Measure(col.n)), max(top, tagH(col.name)+2)
}
label := fmt.Sprintf("%s %d, %s %d", cols[0].name, score, cols[1].name, best)
return ui.Box(c).Role(ui.RoleText).Label(label).Size(k.D(2*w+16), k.D(top+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, col := range cols {
x := i * (w + 16)
tag(g, x, 0, col.name, col.ink)
g.Text(x, top-2, col.n, pixel.Ink, 2)
}
})
}
+136
View File
@@ -0,0 +1,136 @@
package game_test
import (
"slices"
"testing"
"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"
)
var quests = []game.Quest{
{Title: "Slime trouble", Objectives: []game.Objective{{Text: "Slay slimes", Have: 3, Need: 5}, {Text: "Tell the mayor", Have: 1, Need: 1}}},
{Title: "Lost cat", Objectives: []game.Objective{{Text: "Find Mittens", Have: 1, Need: 1}}},
}
func TestQuestLogTracksTheChosenQuest(t *testing.T) {
tracked, changes := 0, 0
tt := view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
if game.QuestLog(c, "Quests", quests, &tracked, game.QuestLogOptions{}).Changed {
changes++
}
})
shown := func(title string) int {
return len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != title }))
}
// The tracked title shows in the list and the tracker.
if shown("Slime trouble") != 2 || shown("Lost cat") != 1 || !tt.HasText("TRACKING") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Lost cat"); err != nil {
t.Fatal(err)
}
if tracked != 1 || changes != 1 || shown("Lost cat") != 2 {
t.Fatalf("clicking Lost cat tracked %d", tracked)
}
panics(t, "an objective past its need", func() {
view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
game.QuestTracker(c, game.Quest{Title: "x", Objectives: []game.Objective{{Text: "y", Have: 6, Need: 5}}}, game.QuestTrackerOptions{})
})
})
}
func TestQuestTrackerTicksMetObjectives(t *testing.T) {
tt := view(400, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.QuestTracker(c, quests[0], game.QuestTrackerOptions{})
})
img := tt.Image()
// Ticks are 7-dot boxes 5 dots into each 16-dot row.
if count(t, img, 1, 16+5, 7, 7, pixel.Good) != 0 || count(t, img, 1, 32+5, 7, 7, pixel.Good) == 0 {
t.Fatal("ticks do not follow the objectives")
}
keeps(t, img)
}
func TestPartyFramesGreyTheFallen(t *testing.T) {
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.PartyFrames(c, "Party", []game.Member{{Name: "Aria", Portrait: game.Hero, HP: 30, MaxHP: 40, MP: 12, MaxMP: 20},
{Name: "Brom", Portrait: game.Hero, MaxHP: 50, MaxMP: 10}}, game.PartyFramesOptions{})
})
img := tt.Image()
aria, brom := find(t, tt, "Aria"), find(t, tt, "Brom")
ax, ay := dots(aria)
bx, by := dots(brom)
// Each portrait is a 28-dot box inside a 3-dot pad.
if count(t, img, ax+3, ay+3, 28, 28, pixel.Bad) == 0 || count(t, img, bx+3, by+3, 28, 28, pixel.Bad) != 0 || count(t, img, bx+3, by+3, 28, 28, pixel.Shade) == 0 {
t.Fatal("the fallen member is not greyed")
}
keeps(t, img)
}
func TestCharacterSheetShowsWhoAndStats(t *testing.T) {
tt := view(400, 200, &world{clk: newClock(), zh: true}, func(c *ui.Context) {
game.CharacterSheet(c, game.Character{Name: "Aria", Class: "Knight", Level: 7, Portrait: game.Hero, Stats: []game.Stat{{Name: "STR", Value: 14}}}, game.CharacterSheetOptions{})
})
if !tt.HasText("Aria") || !tt.HasText("Knight 等级 7") || !tt.HasText("STR 14") {
t.Fatalf("texts %q", tt.Texts())
}
keeps(t, tt.Image())
}
func TestLevelUpScreenSpendsPoints(t *testing.T) {
attrs := []game.Attribute{{Name: "STR", Base: 12}, {Name: "DEX", Base: 9}}
points, done, changes := 2, false, 0
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
r := game.LevelUpScreen(c, "Level up", attrs, &points, game.LevelUpScreenOptions{})
done = done || r.Done
if r.Changed {
changes++
}
})
for _, label := range []string{"STR UP", "STR UP", "DEX UP", "DEX DOWN", "STR DOWN"} {
tt.Click(label)
}
// With no points, DEX UP and DEX DOWN are off.
if attrs[0].Added != 1 || attrs[1].Added != 0 || points != 1 || changes != 3 {
t.Fatalf("STR +%d, DEX +%d, %d points, %d changes", attrs[0].Added, attrs[1].Added, points, changes)
}
tt.Click("DONE")
if !done {
t.Fatal("DONE did not report")
}
}
func TestShopBuysAndSellsWithinGold(t *testing.T) {
wares := []game.Ware{{Item: data.Item{Sprite: icon.Potion, Name: "Potion"}, Price: 30}, {Item: data.Item{Sprite: icon.Shield, Name: "Shield"}, Price: 400}}
gold, bought, sold := 100, -1, -1
tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) {
r := game.Shop(c, "Shop", wares, &gold, game.ShopOptions{})
if r.Bought >= 0 {
bought = r.Bought
}
if r.Sold >= 0 {
sold = r.Sold
}
})
tt.Click("BUY Potion")
tt.Click("BUY Potion")
tt.Click("SELL Potion")
if gold != 55 || wares[0].Owned != 1 || bought != 0 || sold != 0 {
t.Fatalf("gold %d, owned %d", gold, wares[0].Owned)
}
tt.Click("BUY Shield")
tt.Click("SELL Shield")
if gold != 55 || wares[1].Owned != 0 {
t.Fatal("a ware too dear, or none owned, still traded")
}
sx, sy := dots(find(t, tt, "Shield"))
if count(t, tt.Image(), sx, sy, 30, 16, pixel.Faint) == 0 || count(t, tt.Image(), sx, sy, 30, 16, pixel.Ink) != 0 {
t.Fatal("a dear ware's name is not Faint")
}
keeps(t, tt.Image())
}
+79
View File
@@ -0,0 +1,79 @@
package game
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Ware is an item for sale; Owned counts those held.
type Ware struct {
Item data.Item
Price, Owned int
}
type ShopOptions struct {
Disabled bool
}
type ShopResult struct {
Element *ui.Element
// Bought and Sold are this frame's trades, or -1.
Bought, Sold int
}
// Shop buys at price, sells at half; dear wares fade.
func Shop(c *ui.Context, text string, wares []Ware, gold *int, o ShopOptions) ShopResult {
k := pixel.K(c)
if *gold < 0 {
panic(fmt.Sprintf("game: Shop gold %d", *gold))
}
name := 0
for _, w := range wares {
if w.Price < 0 || w.Owned < 0 || w.Item.Sprite == nil {
panic(fmt.Sprintf("game: ware %q costs %d, owned %d", w.Item.Name, w.Price, w.Owned))
}
name = max(name, pixel.Measure(w.Item.Name))
}
col := ui.Column(c).Gap(k.D(3)).Role(ui.RoleGroup).Label(text)
res := ShopResult{Element: col, Bought: -1, Sold: -1}
col.Children(func() {
CoinCounter(c, k.Msg("gold"), *gold, CoinCounterOptions{})
for i := range wares {
w := &wares[i]
dear := w.Price > *gold
ink := pixel.Ink
if dear {
ink = pixel.Faint
}
ui.Row(c).Gap(k.D(5)).AlignItems(ui.Center).Children(func() {
data.ItemSlot(c, w.Item, data.ItemSlotOptions{})
ui.Box(c).Width(k.D(name + 4)).Children(func() { pixel.Text(c, w.Item.Name, pixel.TextOptions{Slot: ink}) })
ui.Row(c).Width(k.D(40)).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, icon.Coin, 0, pixel.Ink, 1)
})
pixel.Text(c, strconv.Itoa(w.Price), pixel.TextOptions{Slot: ink})
})
ui.Box(c).Width(k.D(24)).Children(func() { pixel.Text(c, "×"+strconv.Itoa(w.Owned), pixel.TextOptions{Slot: pixel.Faint}) })
if input.Button(c, k.Msg("buy"), input.ButtonOptions{Size: input.Small, Label: k.Msg("buy") + " " + w.Item.Name, Disabled: o.Disabled || dear}).Clicked {
*gold, w.Owned, res.Bought = *gold-w.Price, w.Owned+1, i
}
if input.Button(c, k.Msg("sell"), input.ButtonOptions{Size: input.Small, Label: k.Msg("sell") + " " + w.Item.Name, Disabled: o.Disabled || w.Owned == 0}).Clicked {
*gold, w.Owned, res.Sold = *gold+w.Price/2, w.Owned-1, i
}
})
}
})
if res.Bought >= 0 || res.Sold >= 0 {
slog.Debug("8bit: shop", "label", text, "bought", res.Bought, "sold", res.Sold, "gold", *gold)
}
return res
}
+66
View File
@@ -0,0 +1,66 @@
package game
import (
"fmt"
"log/slog"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SkillButtonOptions struct {
Disabled bool
}
type SkillButtonResult struct {
Element *ui.Element
// Clicked is a press while the skill is ready.
Clicked bool
}
// SkillButton shows a skill, its key and a cooldown wipe.
func SkillButton(c *ui.Context, text string, sprite *pixel.Sprite, key string, cooldown float64, o SkillButtonOptions) SkillButtonResult {
k := pixel.K(c)
if sprite == nil || !(cooldown >= 0 && cooldown <= 1) || !pixel.MicroOK(key) {
panic(fmt.Sprintf("game: SkillButton %q needs a sprite, a micro key %q, a cooldown in 0–1 (%v)", text, key, cooldown))
}
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(text).Disabled(o.Disabled) })
res := SkillButtonResult{Element: shell, Clicked: base.Clicked() && cooldown == 0}
if res.Clicked {
slog.Debug("8bit: skill", "label", text)
}
state := k.Msg("ready")
if cooldown > 0 {
state = strconv.Itoa(int(math.Ceil(cooldown*100))) + "%"
}
base.Description(state)
shell.Size(k.D(24), k.D(24)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
style := pixel.Raised
if st.Press {
style = pixel.Sunken
}
g.Frame(0, 0, 24, 24, style, pixel.Face)
g.Sprite((24-sprite.W)/2, (24-sprite.H)/2, sprite, 0, pixel.Ink, 1)
// The wipe clears from twelve, clockwise, as the cooldown ends.
for y := 2; y < 22; y++ {
for x := 2; x < 22; x++ {
a := math.Atan2(float64(x)-11.5, 11.5-float64(y)) / (2 * math.Pi)
if a < 0 {
a++
}
if a >= 1-cooldown {
g.Veil(x, y, 1, 1, pixel.Edge, 11)
}
}
}
kw := pixel.MicroWidth(key)
g.Rect(1, 1, kw+3, 8, pixel.Lit)
g.Micro(2, 2, key, pixel.Ink, 1)
g.Finish(st)
})
return res
}
+181
View File
@@ -0,0 +1,181 @@
package game
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Skill is a node of a skill tree.
type Skill struct {
Name string
Sprite *pixel.Sprite
// At places it on the tree's grid.
At Spot
// Needs lists the skills that open it.
Needs []int
Learned bool
}
type SkillTreeOptions struct {
Disabled bool
}
type SkillTreeResult struct {
Element *ui.Element
Changed bool
// Learned is the skill learned this frame, or -1.
Learned int
}
// node is a node's side; cellPitch adds the gap.
const node, cellPitch = 20, 28
// SkillTree links skills; points learn the cursor's skill.
func SkillTree(c *ui.Context, text string, skills []Skill, cursor, points *int, o SkillTreeOptions) SkillTreeResult {
k := pixel.K(c)
if *cursor < 0 || *cursor >= len(skills) || *points < 0 {
panic(fmt.Sprintf("game: SkillTree cursor %d of %d, %d points", *cursor, len(skills), *points))
}
spots, cols, rows := make([]Spot, len(skills)), 0, 0
for i, s := range skills {
if s.Sprite == nil || s.At.X < 0 || s.At.Y < 0 {
panic(fmt.Sprintf("game: skill %q needs a sprite and a place on the grid, not %v", s.Name, s.At))
}
for _, n := range s.Needs {
if n < 0 || n >= len(skills) || n == i {
panic(fmt.Sprintf("game: skill %q needs %d", s.Name, n))
}
}
for j := range i {
if skills[j].At == s.At {
panic(fmt.Sprintf("game: skills %q and %q share %v", skills[j].Name, s.Name, s.At))
}
}
spots[i], cols, rows = s.At, max(cols, s.At.X+1), max(rows, s.At.Y+1)
}
if loop := cycle(skills); loop >= 0 {
panic(fmt.Sprintf("game: skill %q needs itself through others", skills[loop].Name))
}
open := func(i int) bool {
for _, n := range skills[i].Needs {
if !skills[n].Learned {
return false
}
}
return !skills[i].Learned
}
old := *cursor
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
arrows(base, spots, cursor)
learn := base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace)
top := pixel.LineHeight + 4
// A click moves the cursor; clicking it again learns.
if base.Clicked() {
x, y, _ := base.PointerPosition()
dx, dy := int(x/k.Dot()), int(y/k.Dot())-top
for i, s := range spots {
if dx >= s.X*cellPitch && dx < s.X*cellPitch+node && dy >= s.Y*cellPitch && dy < s.Y*cellPitch+node {
learn, *cursor = learn || i == *cursor, i
}
}
}
res := SkillTreeResult{Element: shell, Changed: *cursor != old, Learned: -1}
if res.Changed {
slog.Debug("8bit: skill cursor", "label", text, "skill", skills[*cursor].Name)
}
if learn && open(*cursor) && *points > 0 {
skills[*cursor].Learned, *points, res.Learned = true, *points-1, *cursor
slog.Debug("8bit: skill learned", "label", text, "skill", skills[*cursor].Name, "points", *points)
}
states := make([]string, len(skills))
for i := range skills {
switch {
case skills[i].Learned:
states[i] = k.Msg("learned")
case open(i):
states[i] = k.Msg("can_learn")
default:
states[i] = k.Msg("locked")
}
}
caption := skills[*cursor].Name + " · " + states[*cursor]
cur, shown, left := *cursor, append([]Skill(nil), skills...), fmt.Sprintf(k.Msg("points"), *points)
base.Description(caption + " · " + left)
opens := make([]bool, len(skills))
for i := range skills {
opens[i] = open(i)
}
w := max(cols*cellPitch-(cellPitch-node), pixel.Measure(left)+8+pixel.Measure(caption))
shell.Size(k.D(w), k.D(top+rows*cellPitch-(cellPitch-node)+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text(0, 0, left, pixel.Accent, 1)
g.Text(w-pixel.Measure(caption), 0, caption, pixel.Faint, 1)
mid := func(s Spot) (int, int) { return s.X*cellPitch + node/2, top + s.Y*cellPitch + node/2 }
for _, s := range shown {
for _, n := range s.Needs {
x0, y0 := mid(shown[n].At)
x1, y1 := mid(s.At)
ink := pixel.Shade
if shown[n].Learned {
ink = pixel.Accent
}
g.Line(x0, y0, x1, y1, ink)
}
}
for i, s := range shown {
x, y := s.At.X*cellPitch, top+s.At.Y*cellPitch
sx, sy := x+(node-s.Sprite.W)/2, y+(node-s.Sprite.H)/2
switch {
case s.Learned:
g.Frame(x, y, node, node, pixel.Raised, pixel.Lit)
g.Sprite(sx, sy, s.Sprite, 0, pixel.Ink, 1)
case opens[i]:
g.Frame(x, y, node, node, pixel.Flat, pixel.Face)
g.Sprite(sx, sy, s.Sprite, 0, pixel.Ink, 1)
default:
g.Frame(x, y, node, node, pixel.Sunken, pixel.Well)
g.Silhouette(sx, sy, s.Sprite, 0, pixel.Shade, 1)
}
if i == cur {
g.HLine(x-2, y-2, node+4, pixel.Accent)
g.HLine(x-2, y+node+1, node+4, pixel.Accent)
g.VLine(x-2, y-2, node+4, pixel.Accent)
g.VLine(x+node+1, y-2, node+4, pixel.Accent)
}
}
g.Finish(st)
})
return res
}
// cycle finds a skill needing itself, or -1.
func cycle(skills []Skill) int {
state := make([]int, len(skills))
var visit func(i int) bool
// visit is false on a loop; state 1 is mid-walk.
visit = func(i int) bool {
if state[i] != 0 {
return state[i] == 2
}
state[i] = 1
for _, n := range skills[i].Needs {
if !visit(n) {
return false
}
}
state[i] = 2
return true
}
for i := range skills {
if !visit(i) {
return i
}
}
return -1
}
+72
View File
@@ -0,0 +1,72 @@
package game
import "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
// coin spins: face, turning, edge on, turning back.
var coin = pixel.MustSprite(`
............
...eeeeee...
..eyyyyyye..
.eyllyyyyye.
.eylyyeyyye.
.eylyyeyyse.
.eyyyyeyyse.
.eyyyyeyyse.
.eyyyyyyyse.
..essssssse.
...eeeeeee..
............
............
....eeee....
...elyyye...
..elyyyyse..
..elyeyyse..
..elyeyyse..
..eyyeyyse..
..eyyeyyse..
..eyyyyyse..
...essssse..
....eeeee...
............
............
.....ee.....
.....le.....
.....ye.....
.....ye.....
.....ye.....
.....ye.....
.....ye.....
.....ye.....
.....se.....
.....ee.....
............
............
....eeee....
...eyyyle...
..esyyyyle..
..esyyeyle..
..esyyeyle..
..esyyeyye..
..esyyeyye..
..esyyyyye..
..essssse...
...eeeee....
............`)
// Hero is a small adventurer's head, the default life icon.
var Hero = pixel.MustSprite(`
....rrrr....
...rrrrrr...
..rrrrrrrrr.
..eeeeeeee..
..elkllkle..
..elllllle..
..ellaalle..
...elllle...
....eeee....
...gggggg...
..ggeggegg..
..gg.gg.gg..`)
+54
View File
@@ -0,0 +1,54 @@
package game
import (
"fmt"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Effect is a buff, or a debuff, with turns left.
type Effect struct {
Sprite *pixel.Sprite
Name string
// Turns left; zero hides the count.
Turns int
Debuff bool
}
type StatusEffectsOptions struct{}
// StatusEffects rows effect icons: buffs edged Good, debuffs Bad.
func StatusEffects(c *ui.Context, text string, effects []Effect, o StatusEffectsOptions) *ui.Element {
k := pixel.K(c)
parts := make([]string, len(effects))
for i, ef := range effects {
if ef.Sprite == nil || ef.Turns < 0 {
panic(fmt.Sprintf("game: effect %q has no sprite or %d turns", ef.Name, ef.Turns))
}
parts[i] = ef.Name
if ef.Turns > 0 {
parts[i] += " " + strconv.Itoa(ef.Turns)
}
}
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(len(effects)*17), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, ef := range effects {
x, edge := i*17, pixel.Good
if ef.Debuff {
edge = pixel.Bad
}
g.Frame(x, 0, 16, 16, pixel.Flat, pixel.Well)
g.Rect(x+1, 1, 14, 1, edge)
g.Sprite(x+(16-ef.Sprite.W)/2, (16-ef.Sprite.H)/2, ef.Sprite, 0, pixel.Ink, 1)
if ef.Turns > 0 {
n := strconv.Itoa(ef.Turns)
g.Rect(x+15-pixel.MicroWidth(n)-2, 9, pixel.MicroWidth(n)+2, 7, edge)
g.Micro(x+14-pixel.MicroWidth(n), 10, n, pixel.AccentInk, 1)
}
}
})
}
+39
View File
@@ -0,0 +1,39 @@
package game
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TimerHUDOptions struct{}
// TimerHUD shows TIME in seconds; under 30 it blinks red.
func TimerHUD(c *ui.Context, left time.Duration, o TimerHUDOptions) *ui.Element {
k := pixel.K(c)
if left < 0 {
panic(fmt.Sprintf("game: TimerHUD %v left", left))
}
secs := int(math.Ceil(left.Seconds()))
name, n := k.Msg("time"), fmt.Sprintf("%03d", secs)
hurry := left < 30*time.Second
top := tagH(name) + 2
w := max(tagW(name), 2*pixel.Measure(n))
return ui.Box(c).Role(ui.RoleText).Label(name+" "+n).Size(k.D(w), k.D(top+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
tag(g, 0, 0, name, pixel.Faint)
ink := pixel.Ink
if hurry {
ink = pixel.Bad
// It blinks at 2 Hz; reduced, it holds steady.
if !k.Reduce() && g.Busy(4)%2 == 1 {
return
}
}
g.Text(0, top-2, n, ink, 2)
})
}