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
@@ -69,7 +69,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
|
||||
- Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child.
|
||||
- `List` row builders run during layout: wrap them in `pixel.Lazy(c, row)`. A click met there lands after the build; keep it in `Local` state, invalidate, and report it next frame.
|
||||
- Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field.
|
||||
- Copy shown by a component comes from `k.Msg`; business copy comes from the caller.
|
||||
- Copy shown by a component comes from `k.Msg`; business copy comes from the caller. Ark Pixel 12px lacks some hanzi: `TestMessagesHaveGlyphs` holds every message to the font.
|
||||
- Item slots look alike everywhere: draw them with `data.DrawSlot` or `data.ItemSlot`.
|
||||
- Fail fast: panic on programmer errors such as bad options. Never swallow an error.
|
||||
- Composites log state changes: `slog.Debug("8bit: <component> <event>", …)`. Never log secrets.
|
||||
- No cross-frame `*ui.Element` caching. No goroutines touching elements.
|
||||
|
||||
+4
-4
@@ -38,12 +38,12 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent
|
||||
panic(fmt.Sprintf("data: Inventory slot %d of %d", *selected, len(items)))
|
||||
}
|
||||
old := *selected
|
||||
shell, base, st, _ := pixel.Pad(c, text, cols, len(items), slotSize, slotSize, selected, o.Disabled)
|
||||
shell, base, st, _ := pixel.Pad(c, text, cols, len(items), SlotSize, SlotSize, selected, o.Disabled)
|
||||
b := ui.Local(base, bagKey{}, func() bag { return bag{} })
|
||||
res := InventoryResult{Element: shell, Changed: *selected != old}
|
||||
px, py, _ := base.PointerPosition()
|
||||
at := func() int {
|
||||
col, row := int(px/k.Dot())/slotSize, int(py/k.Dot())/slotSize
|
||||
col, row := int(px/k.Dot())/SlotSize, int(py/k.Dot())/SlotSize
|
||||
if px < 0 || py < 0 || col >= cols || row*cols+col >= len(items) {
|
||||
return -1
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent
|
||||
default:
|
||||
b.dead = false
|
||||
}
|
||||
base.Description(itemName(items[*selected]))
|
||||
base.Description(items[*selected].String())
|
||||
drag := -1
|
||||
if b.held && b.over != b.from {
|
||||
drag = b.from
|
||||
@@ -79,7 +79,7 @@ func Inventory(c *ui.Context, text string, items []Item, selected *int, o Invent
|
||||
if i == drag {
|
||||
it = Item{}
|
||||
}
|
||||
slot(g, i%cols*slotSize, i/cols*slotSize, it, 0, i == *selected)
|
||||
DrawSlot(g, i%cols*SlotSize, i/cols*SlotSize, it, ItemSlotOptions{Selected: i == *selected})
|
||||
}
|
||||
// The dragged item rides with the pointer.
|
||||
if drag >= 0 && shown[drag].Sprite != nil {
|
||||
|
||||
+24
-18
@@ -28,43 +28,49 @@ type ItemSlotOptions struct {
|
||||
// ItemSlot shows an item, count, rarity edge and cooldown.
|
||||
func ItemSlot(c *ui.Context, it Item, o ItemSlotOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if !(o.Cooldown >= 0 && o.Cooldown <= 1) {
|
||||
panic(fmt.Sprintf("data: Cooldown %v outside 0–1", o.Cooldown))
|
||||
}
|
||||
return ui.Box(c).Role(ui.RoleImage).Label(itemName(it)).Size(k.D(slotSize), k.D(slotSize)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
slot(k.Canvas(p, r), 0, 0, it, o.Cooldown, o.Selected)
|
||||
o.check()
|
||||
return ui.Box(c).Role(ui.RoleImage).Label(it.String()).Size(k.D(SlotSize), k.D(SlotSize)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
DrawSlot(k.Canvas(p, r), 0, 0, it, o)
|
||||
})
|
||||
}
|
||||
|
||||
const slotSize = 22
|
||||
func (o ItemSlotOptions) check() {
|
||||
if !(o.Cooldown >= 0 && o.Cooldown <= 1) {
|
||||
panic(fmt.Sprintf("data: Cooldown %v outside 0–1", o.Cooldown))
|
||||
}
|
||||
}
|
||||
|
||||
// itemName reads an item with its count.
|
||||
func itemName(it Item) string {
|
||||
// SlotSize is a slot's side in dots.
|
||||
const SlotSize = 22
|
||||
|
||||
// String reads an item with its count: "Potion ×3".
|
||||
func (it Item) String() string {
|
||||
if it.Count > 1 {
|
||||
return it.Name + " ×" + strconv.Itoa(it.Count)
|
||||
}
|
||||
return it.Name
|
||||
}
|
||||
|
||||
// slot draws a slot with its top-left at x, y.
|
||||
func slot(g pixel.Canvas, x, y int, it Item, cool float64, chosen bool) {
|
||||
// DrawSlot paints a slot, top left at x, y.
|
||||
func DrawSlot(g pixel.Canvas, x, y int, it Item, o ItemSlotOptions) {
|
||||
o.check()
|
||||
edge := pixel.Edge
|
||||
if it.Sprite != nil {
|
||||
edge = it.Rarity.Slot()
|
||||
}
|
||||
if chosen {
|
||||
g.Rect(x, y, slotSize, slotSize, pixel.Accent)
|
||||
if o.Selected {
|
||||
g.Rect(x, y, SlotSize, SlotSize, pixel.Accent)
|
||||
}
|
||||
g.Rect(x+1, y+1, slotSize-2, slotSize-2, edge)
|
||||
g.Rect(x+2, y+2, slotSize-4, slotSize-4, pixel.Well)
|
||||
g.Rect(x+1, y+1, SlotSize-2, SlotSize-2, edge)
|
||||
g.Rect(x+2, y+2, SlotSize-4, SlotSize-4, pixel.Well)
|
||||
if sp := it.Sprite; sp != nil {
|
||||
g.Sprite(x+2+(slotSize-4-sp.W)/2, y+2+(slotSize-4-sp.H)/2, sp, 0, pixel.Ink, 1)
|
||||
g.Sprite(x+2+(SlotSize-4-sp.W)/2, y+2+(SlotSize-4-sp.H)/2, sp, 0, pixel.Ink, 1)
|
||||
}
|
||||
if h := int(cool * (slotSize - 4)); h > 0 {
|
||||
g.Veil(x+2, y+2, slotSize-4, h, pixel.Shade, 10)
|
||||
if h := int(o.Cooldown * (SlotSize - 4)); h > 0 {
|
||||
g.Veil(x+2, y+2, SlotSize-4, h, pixel.Shade, 10)
|
||||
}
|
||||
if it.Count > 1 {
|
||||
n := strconv.Itoa(it.Count)
|
||||
g.Micro(x+slotSize-2-pixel.MicroWidth(n), y+slotSize-8, n, pixel.Ink, 1)
|
||||
g.Micro(x+SlotSize-2-pixel.MicroWidth(n), y+SlotSize-8, n, pixel.Ink, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var gameEntries = []Entry{
|
||||
{246, "HealthBar", "生命条", "game", "HIT and HEAL. The pink trail lags; under a quarter the bar blinks.", demoHealthBar},
|
||||
{247, "ManaBar", "魔力条", "game", "A spell costs 12 MP. Ticks mark every 10.", demoManaBar},
|
||||
{248, "ExpBar", "经验条", "game", "Slay slimes for XP. A full bar flashes and the level goes up.", demoExpBar},
|
||||
{249, "Hearts", "心形血量", "game", "Damage in quarter hearts. A potion mends a whole one.", demoHearts},
|
||||
{250, "Lives", "剩余命数", "game", "Your face, times lives. 1UP!", demoLives},
|
||||
{251, "CoinCounter", "金币", "game", "The coin spins; the count ticks up to the new total.", demoCoinCounter},
|
||||
{252, "ScoreBoard", "计分板", "game", "Beat the HI-SCORE and its label lights up.", demoScoreBoard},
|
||||
{253, "TimerHUD", "关卡计时", "game", "TIME runs out. Under 30 it turns red and blinks. Hurry!", demoTimerHUD},
|
||||
{254, "Minimap", "小地图", "game", "Walk with the D-pad; the fog lifts around you.", demoMinimap},
|
||||
{255, "Compass", "指南针", "game", "Turn the heading; the quest and town markers slide along.", demoCompass},
|
||||
{256, "Hotbar", "快捷栏", "game", "Press 1–9, scroll, or click. Enter uses the slot.", demoHotbar},
|
||||
{257, "Equipment", "装备栏", "game", "A paper doll. Arrows walk the slots; Enter opens one.", demoEquipment},
|
||||
}
|
||||
|
||||
type gameDemo struct {
|
||||
hp, mp, level, xp, lives, coins, score, slot, gear int
|
||||
hearts, heading float64
|
||||
deadline time.Time
|
||||
pos game.Spot
|
||||
seen [][]bool
|
||||
dice *rand.Rand
|
||||
}
|
||||
|
||||
func gameState(c *ui.Context) *gameDemo {
|
||||
now := pixel.K(c).Now()
|
||||
return ui.Local(c.Root(), demoKey("game"), func() gameDemo {
|
||||
s := gameDemo{hp: 100, mp: 60, level: 1, lives: 3, coins: 120, hearts: 5, heading: 30, gear: game.Weapon,
|
||||
deadline: now.Add(100 * time.Second), pos: game.Spot{X: 2, Y: 2}, dice: rand.New(rand.NewPCG(3, 4))}
|
||||
for range dungeon {
|
||||
s.seen = append(s.seen, make([]bool, len(dungeon[0])))
|
||||
}
|
||||
s.look()
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
// small is a small button.
|
||||
func small(c *ui.Context, text string, disabled bool) bool {
|
||||
return input.Button(c, text, input.ButtonOptions{Size: input.Small, Disabled: disabled}).Clicked
|
||||
}
|
||||
|
||||
func demoHealthBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.HealthBar(c, "HP", s.hp, 100, game.HealthBarOptions{Width: 140})
|
||||
ui.Row(c).Gap(k.D(4)).Children(func() {
|
||||
if small(c, "HIT", s.hp == 0) {
|
||||
s.hp = max(0, s.hp-18)
|
||||
log(fmt.Sprintf("ouch! %d HP", s.hp))
|
||||
}
|
||||
if small(c, "HEAL", s.hp == 100) {
|
||||
s.hp = min(100, s.hp+30)
|
||||
log(fmt.Sprintf("healed to %d HP", s.hp))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoManaBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.ManaBar(c, "MP", s.mp, 60, game.ManaBarOptions{Width: 140})
|
||||
ui.Row(c).Gap(k.D(4)).Children(func() {
|
||||
if small(c, "FIREBALL", s.mp < 12) {
|
||||
s.mp -= 12
|
||||
log(fmt.Sprintf("fwoosh! %d MP left", s.mp))
|
||||
}
|
||||
if small(c, "REST", s.mp == 60) {
|
||||
s.mp = min(60, s.mp+20)
|
||||
log("zzz")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoExpBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
next := s.level * 100
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.ExpBar(c, "EXP", s.level, s.xp, next, game.ExpBarOptions{Width: 140})
|
||||
if small(c, "SLAY A SLIME", false) {
|
||||
log("+45 XP")
|
||||
if s.xp += 45; s.xp >= next {
|
||||
s.level, s.xp = s.level+1, s.xp-next
|
||||
log(fmt.Sprintf("level up! LV %d", s.level))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoHearts(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.Hearts(c, "Life", s.hearts, 5, game.HeartsOptions{})
|
||||
ui.Row(c).Gap(k.D(4)).Children(func() {
|
||||
if small(c, "OUCH", s.hearts == 0) {
|
||||
s.hearts = max(0, s.hearts-0.75)
|
||||
log(fmt.Sprintf("%.2f hearts", s.hearts))
|
||||
}
|
||||
if small(c, "POTION", s.hearts == 5) {
|
||||
s.hearts = min(5, s.hearts+1)
|
||||
log("glug glug")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoLives(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
game.Lives(c, "Lives", s.lives, game.LivesOptions{})
|
||||
if small(c, "1UP", false) {
|
||||
s.lives++
|
||||
log("1UP!")
|
||||
}
|
||||
if small(c, "FALL IN A PIT", s.lives == 0) {
|
||||
s.lives--
|
||||
log("aaaaa...")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoCoinCounter(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
game.CoinCounter(c, "Gold", s.coins, game.CoinCounterOptions{Scale: 2})
|
||||
if small(c, "OPEN CHEST", false) {
|
||||
s.coins += 250
|
||||
log("+250 gold")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoScoreBoard(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.ScoreBoard(c, s.score, 9900, game.ScoreBoardOptions{})
|
||||
ui.Row(c).Gap(k.D(4)).Children(func() {
|
||||
if small(c, "STOMP", false) {
|
||||
s.score += 1500
|
||||
log(fmt.Sprintf("score %d", s.score))
|
||||
}
|
||||
if small(c, "RESET", s.score == 0) {
|
||||
s.score = 0
|
||||
log("score reset")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoTimerHUD(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
left := max(0, s.deadline.Sub(k.Now()))
|
||||
if left > 0 {
|
||||
k.Tick(c, 2)
|
||||
}
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
||||
game.TimerHUD(c, left, game.TimerHUDOptions{})
|
||||
if small(c, "RESTART", false) {
|
||||
s.deadline = k.Now().Add(100 * time.Second)
|
||||
log("go go go!")
|
||||
}
|
||||
if small(c, "SKIP TO 33", left <= 33*time.Second) {
|
||||
s.deadline = k.Now().Add(33 * time.Second)
|
||||
log("33 seconds left")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dungeon is the minimap demo's map.
|
||||
var dungeon = []string{
|
||||
`############`,
|
||||
`#....~~....#`,
|
||||
`#.""...~...#`,
|
||||
`#.""..###..#`,
|
||||
`#.....#^^..#`,
|
||||
`############`,
|
||||
}
|
||||
|
||||
// look lifts the fog two tiles around the player.
|
||||
func (s *gameDemo) look() {
|
||||
for y := range dungeon {
|
||||
for x := range dungeon[y] {
|
||||
if max(x-s.pos.X, s.pos.X-x, y-s.pos.Y, s.pos.Y-y) <= 2 {
|
||||
s.seen[y][x] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func demoMinimap(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Row(c).Gap(k.D(10)).AlignItems(ui.Center).Children(func() {
|
||||
game.Minimap(c, "Dungeon", dungeon, s.seen, s.pos, []game.Marker{{Spot: game.Spot{X: 9, Y: 4}, Slot: pixel.Warn}}, game.MinimapOptions{Cell: 6})
|
||||
step := map[input.Direction]game.Spot{input.Up: {Y: -1}, input.Down: {Y: 1}, input.Left: {X: -1}, input.Right: {X: 1}}
|
||||
if d, ok := step[input.DPad(c, "Walk", input.DPadOptions{}).Pressed]; ok {
|
||||
to := game.Spot{X: s.pos.X + d.X, Y: s.pos.Y + d.Y}
|
||||
if dungeon[to.Y][to.X] != '#' {
|
||||
s.pos = to
|
||||
s.look()
|
||||
log(fmt.Sprintf("at %d, %d", to.X, to.Y))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoCompass(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := gameState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.Compass(c, "Heading", s.heading, []game.Bearing{{Degrees: 120, Slot: pixel.Warn}, {Degrees: 300, Slot: pixel.Info}}, game.CompassOptions{Width: 160})
|
||||
if input.Slider(c, "Heading", &s.heading, input.SliderOptions{Max: 360, Width: 160}).Changed {
|
||||
log(fmt.Sprintf("%.0f°", s.heading))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pack is the hotbar demo's items.
|
||||
var pack = []data.Item{
|
||||
{Sprite: icon.Sword, Name: "Sword", Rarity: display.Rare}, {Sprite: icon.Shield, Name: "Shield"}, {Sprite: icon.Potion, Name: "Potion", Count: 5},
|
||||
{Sprite: icon.Bolt, Name: "Bolt scroll", Rarity: display.Epic}, {Sprite: icon.Key, Name: "Key"}, {}, {Sprite: icon.Map, Name: "Map"}, {}, {Sprite: icon.Gem, Name: "Gem", Count: 3},
|
||||
}
|
||||
|
||||
func demoHotbar(c *ui.Context, log func(string)) {
|
||||
s := gameState(c)
|
||||
r := game.Hotbar(c, "Belt", pack, &s.slot, game.HotbarOptions{})
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("slot %d", s.slot+1))
|
||||
}
|
||||
if r.Used >= 0 {
|
||||
name := pack[r.Used].Name
|
||||
if name == "" {
|
||||
name = "an empty hand"
|
||||
}
|
||||
log("used " + name)
|
||||
}
|
||||
}
|
||||
|
||||
func demoEquipment(c *ui.Context, log func(string)) {
|
||||
s := gameState(c)
|
||||
gear := []data.Item{{Sprite: icon.Crown, Name: "Old crown", Rarity: display.Legendary}, {}, {Sprite: icon.Sword, Name: "Iron sword"}, {Sprite: icon.Shield, Name: "Pot lid"}, {}}
|
||||
r := game.Equipment(c, "Gear", gear, &s.gear, game.EquipmentOptions{})
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("slot %d", s.gear))
|
||||
}
|
||||
if r.Picked >= 0 {
|
||||
log(fmt.Sprintf("open slot %d", r.Picked))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var rpgEntries = []Entry{
|
||||
{258, "SkillTree", "技能树", "game", "Spend points on open skills: click once to choose, twice to learn.", demoSkillTree},
|
||||
{259, "SkillButton", "技能按钮", "game", "Cast, then wait out the clockwise cooldown wipe.", demoSkillButton},
|
||||
{260, "StatusEffects", "状态效果", "game", "Buffs edged green, debuffs red, turns left in the corner.", demoStatusEffects},
|
||||
{261, "BossBar", "首领血条", "game", "A boss in phases. Hit it until it gets angry.", demoBossBar},
|
||||
{262, "DamageNumbers", "伤害数字", "game", "Numbers float up and dissolve. Criticals in gold!", demoDamageNumbers},
|
||||
{263, "QuestLog", "任务日志", "game", "Choose a quest to track; its objectives show beside it.", demoQuestLog},
|
||||
{264, "QuestTracker", "任务追踪", "game", "Compact objectives with counts. Go slay one.", demoQuestTracker},
|
||||
{265, "PartyFrames", "队伍面板", "game", "Cards with HP and MP. Brom needs a phoenix down.", demoPartyFrames},
|
||||
{266, "CharacterSheet", "角色面板", "game", "Who you are: class, level and stats.", demoCharacterSheet},
|
||||
{267, "LevelUpScreen", "升级分配", "game", "LEVEL UP! Spend points with ◀ ▶, then DONE.", demoLevelUpScreen},
|
||||
{268, "Shop", "商店", "game", "Buy at the price, sell at half. Too dear? It goes grey.", demoShop},
|
||||
}
|
||||
|
||||
type rpgDemo struct {
|
||||
skills []game.Skill
|
||||
cursor, points, boss, quest int
|
||||
slain int
|
||||
cast time.Time
|
||||
effects []game.Effect
|
||||
hits []game.Hit
|
||||
attrs []game.Attribute
|
||||
wares []game.Ware
|
||||
gold, raise int
|
||||
}
|
||||
|
||||
func rpgState(c *ui.Context) *rpgDemo {
|
||||
return ui.Local(c.Root(), demoKey("rpg"), func() rpgDemo {
|
||||
return rpgDemo{skills: talents(), points: 2, boss: 1000, gold: 300, raise: 3,
|
||||
effects: []game.Effect{{Sprite: icon.Bolt, Name: "Haste", Turns: 3}, {Sprite: icon.Shield, Name: "Guard", Turns: 2}, {Sprite: icon.Skull, Name: "Poison", Turns: 4, Debuff: true}},
|
||||
attrs: []game.Attribute{{Name: "STR", Base: 12}, {Name: "DEX", Base: 9}, {Name: "INT", Base: 7}, {Name: "LUK", Base: 5}},
|
||||
wares: []game.Ware{{Item: data.Item{Sprite: icon.Potion, Name: "Potion"}, Price: 30, Owned: 2}, {Item: data.Item{Sprite: icon.Key, Name: "Skeleton key"}, Price: 120},
|
||||
{Item: data.Item{Sprite: icon.Sword, Name: "Flame sword"}, Price: 900}}}
|
||||
})
|
||||
}
|
||||
|
||||
// talents is a root, two branches and a capstone.
|
||||
func talents() []game.Skill {
|
||||
return []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: "Fireball", Sprite: icon.Flame, At: game.Spot{X: 2, Y: 1}, Needs: []int{0}},
|
||||
{Name: "Bulwark", Sprite: icon.Crown, At: game.Spot{Y: 2}, Needs: []int{1}},
|
||||
{Name: "Meteor", Sprite: icon.Star, At: game.Spot{X: 2, Y: 2}, Needs: []int{2}},
|
||||
{Name: "Starfall", Sprite: icon.Sparkle, At: game.Spot{X: 1, Y: 3}, Needs: []int{3, 4}},
|
||||
}
|
||||
}
|
||||
|
||||
func demoSkillTree(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := rpgState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
r := game.SkillTree(c, "Talents", s.skills, &s.cursor, &s.points, game.SkillTreeOptions{})
|
||||
if r.Changed {
|
||||
log(s.skills[s.cursor].Name)
|
||||
}
|
||||
if r.Learned >= 0 {
|
||||
log("learned " + s.skills[r.Learned].Name)
|
||||
}
|
||||
if small(c, "RESPEC", s.points == 2) {
|
||||
s.skills, s.points = talents(), 2
|
||||
log("a clean slate")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoSkillButton(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := rpgState(c)
|
||||
// The spell cools for three seconds, in tenths.
|
||||
cool := 1 - float64(k.Since(c, s.cast, 10, 30))/30
|
||||
ui.Row(c).Gap(k.D(6)).Children(func() {
|
||||
if game.SkillButton(c, "Fireball", icon.Flame, "Q", cool, game.SkillButtonOptions{}).Clicked {
|
||||
s.cast = k.Now()
|
||||
log("fireball!")
|
||||
}
|
||||
if game.SkillButton(c, "Heal", icon.Heart, "E", 0, game.SkillButtonOptions{}).Clicked {
|
||||
log("you feel better")
|
||||
}
|
||||
game.SkillButton(c, "Meteor", icon.Star, "R", 0, game.SkillButtonOptions{Disabled: true})
|
||||
})
|
||||
}
|
||||
|
||||
func demoStatusEffects(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := rpgState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
game.StatusEffects(c, "Effects", s.effects, game.StatusEffectsOptions{})
|
||||
if small(c, "END TURN", len(s.effects) == 0) {
|
||||
left := s.effects[:0]
|
||||
for _, ef := range s.effects {
|
||||
if ef.Turns--; ef.Turns > 0 {
|
||||
left = append(left, ef)
|
||||
}
|
||||
}
|
||||
s.effects = left
|
||||
log(fmt.Sprintf("%d effects left", len(s.effects)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoBossBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := rpgState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.BossBar(c, "SLIME KING", s.boss, 1000, game.BossBarOptions{Width: 220, Phases: []float64{0.66, 0.33}})
|
||||
if small(c, "ATTACK", s.boss == 0) {
|
||||
was := s.boss
|
||||
s.boss = max(0, s.boss-90)
|
||||
log(fmt.Sprintf("hit! %d HP left", s.boss))
|
||||
for _, mark := range []int{660, 330} {
|
||||
if was > mark && s.boss <= mark {
|
||||
log("the Slime King wobbles angrily!")
|
||||
}
|
||||
}
|
||||
if s.boss == 0 {
|
||||
log("victory!")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoDamageNumbers(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s, dice := rpgState(c), gameState(c).dice
|
||||
ui.Column(c).Gap(k.D(4)).Children(func() {
|
||||
// The numbers float over a slime having a bad day.
|
||||
layout.Layers(c, func() {
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Sprite((g.W-3*slime.W)/2, g.H-3*slime.H, slime, 0, pixel.Ink, 3)
|
||||
})
|
||||
}, func() {
|
||||
game.DamageNumbers(c, "Damage", &s.hits, game.DamageNumbersOptions{Width: 180, Height: 60})
|
||||
})
|
||||
if small(c, "ATTACK", false) {
|
||||
h := game.Hit{Value: 5 + dice.IntN(30), X: 20 + dice.IntN(140), Y: 25 + dice.IntN(25)}
|
||||
// One hit in five is critical, for double.
|
||||
if h.Crit = dice.IntN(5) == 0; h.Crit {
|
||||
h.Value *= 2
|
||||
}
|
||||
s.hits = append(s.hits, h)
|
||||
log(fmt.Sprintf("hit for %d", h.Value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adventures are the quest demos' quests.
|
||||
func adventures(slain int) []game.Quest {
|
||||
return []game.Quest{
|
||||
{Title: "Slime trouble", Objectives: []game.Objective{{Text: "Slay slimes", Have: slain, Need: 5}, {Text: "Tell the mayor", Have: slain / 5, Need: 1}}},
|
||||
{Title: "Lost cat", Objectives: []game.Objective{{Text: "Find Mittens", Have: 1, Need: 1}, {Text: "Bring her home", Need: 1}}},
|
||||
{Title: "Herb run", Objectives: []game.Objective{{Text: "Pick moonleaf", Have: 2, Need: 6}}},
|
||||
}
|
||||
}
|
||||
|
||||
func demoQuestLog(c *ui.Context, log func(string)) {
|
||||
s := rpgState(c)
|
||||
quests := adventures(s.slain)
|
||||
if game.QuestLog(c, "Quests", quests, &s.quest, game.QuestLogOptions{}).Changed {
|
||||
log("tracking " + quests[s.quest].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func demoQuestTracker(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := rpgState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
game.QuestTracker(c, adventures(s.slain)[0], game.QuestTrackerOptions{})
|
||||
if small(c, "SLAY A SLIME", s.slain == 5) {
|
||||
s.slain++
|
||||
log(fmt.Sprintf("%d of 5", s.slain))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoPartyFrames(c *ui.Context, log func(string)) {
|
||||
game.PartyFrames(c, "Party", []game.Member{
|
||||
{Name: "Aria", Portrait: game.Hero, HP: 34, MaxHP: 40, MP: 18, MaxMP: 20},
|
||||
{Name: "Cyra", Portrait: game.Hero, HP: 9, MaxHP: 30, MP: 31, MaxMP: 40},
|
||||
{Name: "Brom", Portrait: game.Hero, MaxHP: 50, MP: 4, MaxMP: 10},
|
||||
}, game.PartyFramesOptions{})
|
||||
}
|
||||
|
||||
func demoCharacterSheet(c *ui.Context, log func(string)) {
|
||||
game.CharacterSheet(c, game.Character{Name: "Aria", Class: "Knight", Level: 7, Portrait: game.Hero,
|
||||
Stats: []game.Stat{{Name: "STR", Value: 14}, {Name: "DEX", Value: 9}, {Name: "INT", Value: 7}, {Name: "LUK", Value: 11}}}, game.CharacterSheetOptions{})
|
||||
}
|
||||
|
||||
func demoLevelUpScreen(c *ui.Context, log func(string)) {
|
||||
s := rpgState(c)
|
||||
r := game.LevelUpScreen(c, "Level up", s.attrs, &s.raise, game.LevelUpScreenOptions{})
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("%d points left", s.raise))
|
||||
}
|
||||
if r.Done {
|
||||
for i := range s.attrs {
|
||||
s.attrs[i].Base, s.attrs[i].Added = s.attrs[i].Base+s.attrs[i].Added, 0
|
||||
}
|
||||
s.raise = 3
|
||||
log("stronger! three more points, for the demo")
|
||||
}
|
||||
}
|
||||
|
||||
func demoShop(c *ui.Context, log func(string)) {
|
||||
s := rpgState(c)
|
||||
r := game.Shop(c, "Item shop", s.wares, &s.gold, game.ShopOptions{})
|
||||
if r.Bought >= 0 {
|
||||
log("bought " + s.wares[r.Bought].Item.Name)
|
||||
}
|
||||
if r.Sold >= 0 {
|
||||
log("sold " + s.wares[r.Sold].Item.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestGameDemosLogTheirActions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path, label string
|
||||
key ui.Key
|
||||
}{
|
||||
{"/c/248-expbar", "SLAY A SLIME", 0}, {"/c/252-scoreboard", "RESET", 0}, {"/c/253-timerhud", "SKIP TO 33", 0},
|
||||
{"/c/257-equipment", "Gear", ui.KeyRight}, {"/c/258-skilltree", "Talents", ui.KeyDown}, {"/c/261-bossbar", "ATTACK", 0},
|
||||
} {
|
||||
a, tt := open(tc.path)
|
||||
slug := tc.path[3:]
|
||||
if tc.label == "RESET" {
|
||||
click(t, tt, "STOMP")
|
||||
}
|
||||
before := len(a.logs[slug])
|
||||
click(t, tt, tc.label)
|
||||
// A key moves a control the click has focused.
|
||||
if tc.key != 0 {
|
||||
before = len(a.logs[slug])
|
||||
tt.Key(0, tc.key)
|
||||
}
|
||||
if len(a.logs[slug]) <= before {
|
||||
t.Errorf("%s: %s logged nothing: %q", slug, tc.label, a.logs[slug])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotbarDemoLogsAnEmptySlotUsed(t *testing.T) {
|
||||
a, tt := open("/c/256-hotbar")
|
||||
click(t, tt, "Belt")
|
||||
tt.Key(0, ui.Key6)
|
||||
before := len(a.logs["256-hotbar"])
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(a.logs["256-hotbar"]) <= before {
|
||||
t.Fatalf("using empty slot 6 logged nothing: %q", a.logs["256-hotbar"])
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ var Categories = []Category{
|
||||
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
||||
}
|
||||
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries)
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries)
|
||||
|
||||
func Entries() []Entry { return entries }
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -268,3 +268,26 @@ func boolInt(b bool) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestTextVeilKeepsDotsBelowTheLevel(t *testing.T) {
|
||||
plain := canvas(t, 40, 40, func(g pixel.Canvas) { g.Text(2, 2, "HM", pixel.Ink, 2) })
|
||||
for _, level := range []int{0, 8, 16} {
|
||||
veil := canvas(t, 40, 40, func(g pixel.Canvas) { g.TextVeil(2, 2, "HM", pixel.Ink, level, 2) })
|
||||
kept, all := 0, 0
|
||||
for y := range 40 {
|
||||
for x := range 40 {
|
||||
ink, got := plain.at(x, y) == pixel.Ink, veil.at(x, y) == pixel.Ink
|
||||
if got && (!ink || pixel.Bayer(x, y) >= level) {
|
||||
t.Fatalf("level %d: dot %d,%d drawn", level, x, y)
|
||||
}
|
||||
if ink && pixel.Bayer(x, y) < level && !got {
|
||||
t.Fatalf("level %d: dot %d,%d missing", level, x, y)
|
||||
}
|
||||
kept, all = kept+boolInt(got), all+boolInt(ink)
|
||||
}
|
||||
}
|
||||
if want := all * level / 16; kept < want-all/8 || kept > want+all/8 {
|
||||
t.Fatalf("level %d kept %d of %d dots", level, kept, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,21 @@ func checkLevel(level int) {
|
||||
panic(fmt.Sprintf("pixel: dither level %d outside 0–16", level))
|
||||
}
|
||||
}
|
||||
|
||||
// TextVeil draws s, keeping glyph dots below the dither level.
|
||||
func (g Canvas) TextVeil(x, y int, s string, ink Slot, level, scale int) {
|
||||
checkLevel(level)
|
||||
checkScale(scale)
|
||||
for _, r := range s {
|
||||
gl := glyphOf(r)
|
||||
gx := x + gl.x*scale
|
||||
for j := range gl.h * scale {
|
||||
yy := y + gl.top()*scale + j
|
||||
row := bayer[(g.gy+yy)&3]
|
||||
runs(gl.w*scale, func(i int) bool {
|
||||
return gl.on(i/scale, j/scale) && row[(g.gx+gx+i)&3] < level
|
||||
}, func(a, b int) { g.Rect(gx+a, yy, b-a, 1, ink) })
|
||||
}
|
||||
x += gl.adv * scale
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -24,14 +24,18 @@ var messages = map[Locale]map[string]string{
|
||||
"just_now": "JUST NOW", "min_ago": "%d MIN AGO", "hours_ago": "%d H AGO", "yesterday": "YESTERDAY", "days_ago": "%d DAYS AGO",
|
||||
"in_min": "IN %d MIN", "in_hours": "IN %d H", "tomorrow": "TOMORROW", "in_days": "IN %d DAYS",
|
||||
"spring": "SPRING", "summer": "SUMMER", "autumn": "AUTUMN", "winter": "WINTER", "minutes": "MIN", "seconds": "SEC",
|
||||
"level": "LV %d", "score": "SCORE", "hi_score": "HI-SCORE", "time": "TIME", "compass": "N E S W",
|
||||
"head": "HEAD", "body": "BODY", "weapon": "WEAPON", "shield": "SHIELD", "ring": "RING",
|
||||
"points": "POINTS %d", "learned": "LEARNED", "can_learn": "CAN LEARN", "locked": "LOCKED", "ready": "READY",
|
||||
"buy": "BUY", "sell": "SELL", "done": "DONE", "tracking": "TRACKING",
|
||||
},
|
||||
Zh: {
|
||||
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
||||
"continue": "继续?", "game_over": "游戏结束", "loading": "读取中",
|
||||
"search": "搜索", "empty": "空空如也", "minimize": "最小化", "resize": "调整大小",
|
||||
"scroll_up": "向上滚动", "scroll_down": "向下滚动",
|
||||
"scroll_up": "向上翻动", "scroll_down": "向下翻动",
|
||||
"up": "上", "down": "下", "left": "左", "right": "右", "disabled": "已禁用",
|
||||
"remove": "移除", "online": "在线", "away": "离开", "busy": "忙碌", "offline": "离线",
|
||||
"remove": "移除", "online": "在线", "away": "离开", "busy": "繁忙", "offline": "离线",
|
||||
"gold": "金", "silver": "银", "bronze": "铜",
|
||||
"common": "普通", "uncommon": "优秀", "rare": "稀有", "epic": "史诗", "legendary": "传说",
|
||||
"hold": "长按确认", "del": "删", "end": "完", "lives": "命数 +30!",
|
||||
@@ -46,6 +50,10 @@ var messages = map[Locale]map[string]string{
|
||||
"just_now": "刚刚", "min_ago": "%d 分钟前", "hours_ago": "%d 小时前", "yesterday": "昨天", "days_ago": "%d 天前",
|
||||
"in_min": "%d 分钟后", "in_hours": "%d 小时后", "tomorrow": "明天", "in_days": "%d 天后",
|
||||
"spring": "春", "summer": "夏", "autumn": "秋", "winter": "冬", "minutes": "分", "seconds": "秒",
|
||||
"level": "等级 %d", "score": "得分", "hi_score": "最高分", "time": "时间", "compass": "北 东 南 西",
|
||||
"head": "头", "body": "身体", "weapon": "武器", "shield": "盾牌", "ring": "戒指",
|
||||
"points": "点数 %d", "learned": "已学会", "can_learn": "可学习", "locked": "未解锁", "ready": "就绪",
|
||||
"buy": "购买", "sell": "出售", "done": "完成", "tracking": "追踪中",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,3 +26,15 @@ func TestMessagesCoverBothLocales(t *testing.T) {
|
||||
}()
|
||||
Kit{locale: En}.Msg("no_such_key")
|
||||
}
|
||||
|
||||
func TestMessagesHaveGlyphs(t *testing.T) {
|
||||
for loc, m := range messages {
|
||||
for key, s := range m {
|
||||
for _, r := range s {
|
||||
if _, ok := font12.find(r); !ok && r != ' ' {
|
||||
t.Errorf("locale %d message %q: the font has no %q", loc, key, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user