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:
42 files changed
+3260
-26
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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..`)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user