475 lines
11 KiB
Go
475 lines
11 KiB
Go
package saves
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"aego/contracts/persistence"
|
|
"aego/core/ids"
|
|
"aego/core/mathx"
|
|
"aego/editor/core/context"
|
|
"aego/editor/core/services"
|
|
"aego/save"
|
|
"aego/ui/core"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
const ID = "saves"
|
|
|
|
type Deps struct {
|
|
Store func() *save.Store
|
|
Service func() persistence.Service
|
|
Label func(id ids.AssetID) string
|
|
Notices services.Notices
|
|
}
|
|
|
|
type Panel struct {
|
|
d Deps
|
|
f *services.Frame
|
|
list []save.SlotInfo
|
|
at float64
|
|
sel string
|
|
doc *save.Doc
|
|
docSlot string
|
|
docMod time.Time
|
|
docErr error
|
|
scroll float32
|
|
dscroll float32
|
|
slot string
|
|
prefix string
|
|
typed bool
|
|
}
|
|
|
|
func New(d Deps) *Panel {
|
|
return &Panel{d: d, at: -10, slot: "slot1", prefix: "slot"}
|
|
}
|
|
|
|
func (p *Panel) ID() string {
|
|
return ID
|
|
}
|
|
|
|
func (p *Panel) Title() string {
|
|
return "Saves"
|
|
}
|
|
|
|
func (p *Panel) Refresh() {
|
|
p.at = -10
|
|
}
|
|
|
|
func (p *Panel) Select(slot string) {
|
|
p.sel = slot
|
|
}
|
|
|
|
func (p *Panel) notify(l context.Level, s string) {
|
|
if p.d.Notices != nil {
|
|
p.d.Notices.Notify(l, s)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) service() persistence.Service {
|
|
if p.d.Service == nil {
|
|
return nil
|
|
}
|
|
return p.d.Service()
|
|
}
|
|
|
|
func splitSlot(slot string) (string, int, bool) {
|
|
i := len(slot)
|
|
for i > 0 && slot[i-1] >= '0' && slot[i-1] <= '9' {
|
|
i--
|
|
}
|
|
if i == len(slot) {
|
|
return slot, 0, false
|
|
}
|
|
n, err := strconv.Atoi(slot[i:])
|
|
return slot[:i], n, err == nil
|
|
}
|
|
|
|
// NextSlot is prefix followed by one more than the highest number already used with that prefix.
|
|
func NextSlot(prefix string, taken []string) string {
|
|
if prefix == "" {
|
|
prefix = "slot"
|
|
}
|
|
top := 0
|
|
for _, t := range taken {
|
|
if p, n, ok := splitSlot(t); ok && p == prefix {
|
|
top = max(top, n)
|
|
}
|
|
}
|
|
return prefix + strconv.Itoa(top+1)
|
|
}
|
|
|
|
func (p *Panel) taken() []string {
|
|
out := make([]string, 0, len(p.list))
|
|
for i := range p.list {
|
|
out = append(out, p.list[i].Slot)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Panel) exists(slot string) bool {
|
|
for i := range p.list {
|
|
if p.list[i].Slot == slot {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Panel) suggest() {
|
|
if !p.typed {
|
|
p.slot = NextSlot(p.prefix, p.taken())
|
|
}
|
|
}
|
|
|
|
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
|
|
p.f = f
|
|
ui := f.UI
|
|
th := &ui.Theme
|
|
var st *save.Store
|
|
if p.d.Store != nil {
|
|
st = p.d.Store()
|
|
}
|
|
if st == nil {
|
|
widgets.LabelDim(ui, "Saves are not available: this run has no player profile")
|
|
return
|
|
}
|
|
if f.Now-p.at > 1 {
|
|
p.list, p.at = st.List(), f.Now
|
|
p.suggest()
|
|
}
|
|
p.toolbar(st)
|
|
cur := ui.Cursor()
|
|
area := mathx.Rect{X: cur.X, Y: cur.Y, W: cur.W, H: max(th.RowHeight*4, r.Y+r.H-cur.Y)}
|
|
list, side := area, mathx.Rect{}
|
|
if area.W >= 640 {
|
|
list.W = area.W * 0.6
|
|
side = mathx.Rect{X: list.X + list.W + th.Spacing*2 + 1, Y: area.Y, W: area.W - list.W - th.Spacing*2 - 1, H: area.H}
|
|
ui.Rect(mathx.Rect{X: list.X + list.W + th.Spacing, Y: area.Y, W: 1, H: area.H}, th.Border)
|
|
} else {
|
|
list.H = max(th.RowHeight*3, area.H*0.45)
|
|
side = mathx.Rect{X: area.X, Y: list.Y + list.H + th.Spacing*2 + 1, W: area.W, H: area.Y + area.H - list.Y - list.H - th.Spacing*2 - 1}
|
|
ui.Rect(mathx.Rect{X: area.X, Y: list.Y + list.H + th.Spacing, W: area.W, H: 1}, th.Border)
|
|
}
|
|
p.slots(list)
|
|
p.details(st, side)
|
|
cur.Y = area.Y + area.H
|
|
}
|
|
|
|
func (p *Panel) slots(r mathx.Rect) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
hdr := mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight}
|
|
p.header(hdr)
|
|
body := mathx.Rect{X: r.X, Y: hdr.Y + hdr.H, W: r.W, H: max(0, r.H-hdr.H)}
|
|
widgets.VirtualList(ui, ui.ID("slots"), body, len(p.list), th.RowHeight, &p.scroll, func(i int, rr mathx.Rect) {
|
|
p.row(&p.list[i], rr)
|
|
})
|
|
if len(p.list) == 0 {
|
|
ui.TextIn(body, "No saves yet", th.TextDisabled, core.AlignCenter)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) button(id core.ID, r mathx.Rect, label string, enabled bool) bool {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
if !enabled {
|
|
ui.Box(r, th.Radius.Button, th.Widget, th.Border)
|
|
ui.TextIn(r, label, th.TextDisabled, core.AlignCenter)
|
|
return false
|
|
}
|
|
return widgets.ButtonRect(ui, id, r, label)
|
|
}
|
|
|
|
func (p *Panel) toolbar(st *save.Store) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
bar := ui.Cursor().Next(0)
|
|
slot := strings.TrimSpace(p.slot)
|
|
over := p.exists(slot)
|
|
live := p.service() != nil
|
|
saveLabel := "Save"
|
|
if over {
|
|
saveLabel = "Overwrite"
|
|
}
|
|
sw := ui.TextSize("Overwrite").X + 14
|
|
rw := ui.TextSize("Refresh").X + 14
|
|
fw := max(80, bar.W-sw-rw-8)
|
|
field := mathx.Rect{X: bar.X, Y: bar.Y, W: fw, H: bar.H}
|
|
changed, committed := widgets.TextFieldRect(ui, ui.ID("slot"), field, &p.slot, false)
|
|
if changed {
|
|
p.typed = strings.TrimSpace(p.slot) != ""
|
|
}
|
|
x := field.X + fw + 4
|
|
if p.button(ui.ID("write"), mathx.Rect{X: x, Y: bar.Y, W: sw, H: bar.H}, saveLabel, live && slot != "") || (live && committed && slot != "") {
|
|
p.saveNow()
|
|
}
|
|
if p.button(ui.ID("refresh"), mathx.Rect{X: x + sw + 4, Y: bar.Y, W: rw, H: bar.H}, "Refresh", true) {
|
|
p.Refresh()
|
|
}
|
|
line := ui.Cursor().Next(0)
|
|
note, col := st.Dir(), th.TextDim
|
|
switch {
|
|
case !live:
|
|
note += " · saving works during Play"
|
|
case over:
|
|
note, col = slot+" already exists and will be replaced", th.Warn
|
|
}
|
|
ui.PushClip(line)
|
|
ui.Text(note, mathx.Vec2{X: line.X, Y: ui.TextY(line)}, col)
|
|
ui.PopClip()
|
|
}
|
|
|
|
func (p *Panel) saveNow() {
|
|
svc := p.service()
|
|
if svc == nil {
|
|
p.notify(context.LevelInfo, "Saving works during Play")
|
|
return
|
|
}
|
|
slot := strings.TrimSpace(p.slot)
|
|
if err := svc.Save(slot); err != nil {
|
|
p.notify(context.LevelError, err.Error())
|
|
return
|
|
}
|
|
p.sel = slot
|
|
if pre, _, ok := splitSlot(slot); ok {
|
|
p.prefix = pre
|
|
}
|
|
p.typed = false
|
|
p.slot = NextSlot(p.prefix, append(p.taken(), slot))
|
|
p.Refresh()
|
|
p.notify(context.LevelInfo, "Saving to "+slot)
|
|
}
|
|
|
|
var columns = [...]string{"Slot", "Scene", "Saved", "Playtime", "Objects", "Size"}
|
|
|
|
var colAlign = [len(columns)]core.Align{core.AlignLeft, core.AlignLeft, core.AlignLeft, core.AlignRight, core.AlignRight, core.AlignRight}
|
|
|
|
var (
|
|
wide = []int{0, 1, 2, 3, 4, 5}
|
|
compact = []int{0, 2, 3, 5}
|
|
weights = [len(columns)]float32{1.1, 1.6, 1.5, 0.8, 0.7, 0.7}
|
|
)
|
|
|
|
type cell struct {
|
|
col int
|
|
r mathx.Rect
|
|
}
|
|
|
|
func (p *Panel) cells(r mathx.Rect, out []cell) []cell {
|
|
show := wide
|
|
if r.W < 720 {
|
|
show = compact
|
|
}
|
|
total := float32(0)
|
|
for _, c := range show {
|
|
total += weights[c]
|
|
}
|
|
x := r.X
|
|
for _, c := range show {
|
|
cw := r.W * weights[c] / total
|
|
out = append(out, cell{col: c, r: mathx.Rect{X: x + 4, Y: r.Y, W: cw - 8, H: r.H}})
|
|
x += cw
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Panel) header(r mathx.Rect) {
|
|
ui := p.f.UI
|
|
ui.Rect(r, ui.Theme.CardHeader)
|
|
var buf [len(columns)]cell
|
|
for _, c := range p.cells(r, buf[:0]) {
|
|
ui.PushClip(c.r)
|
|
ui.TextIn(c.r, columns[c.col], ui.Theme.Text, colAlign[c.col])
|
|
ui.PopClip()
|
|
}
|
|
}
|
|
|
|
func Playtime(sec float64) string {
|
|
d := time.Duration(sec * float64(time.Second)).Round(time.Second)
|
|
h := int(d / time.Hour)
|
|
m := int(d/time.Minute) % 60
|
|
s := int(d/time.Second) % 60
|
|
pad := func(n int) string {
|
|
if n < 10 {
|
|
return "0" + strconv.Itoa(n)
|
|
}
|
|
return strconv.Itoa(n)
|
|
}
|
|
if h > 0 {
|
|
return strconv.Itoa(h) + ":" + pad(m) + ":" + pad(s)
|
|
}
|
|
return strconv.Itoa(m) + ":" + pad(s)
|
|
}
|
|
|
|
func size(n int64) string {
|
|
if n < 1024 {
|
|
return strconv.FormatInt(n, 10) + " B"
|
|
}
|
|
return strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KB"
|
|
}
|
|
|
|
func (p *Panel) scene(id ids.AssetID) string {
|
|
if id.IsZero() {
|
|
return "—"
|
|
}
|
|
if p.d.Label != nil {
|
|
return p.d.Label(id)
|
|
}
|
|
return id.Short()
|
|
}
|
|
|
|
func (p *Panel) row(info *save.SlotInfo, rr mathx.Rect) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
id := core.IDOf("saves.row/" + info.Slot)
|
|
if ui.Pressed(id, rr) {
|
|
p.sel = info.Slot
|
|
}
|
|
if ui.DoubleClicked(id) {
|
|
p.load(info.Slot)
|
|
}
|
|
switch {
|
|
case p.sel == info.Slot:
|
|
ui.Rect(rr, th.Selection)
|
|
case ui.Hot() == id:
|
|
ui.Rect(rr, th.WidgetHot)
|
|
}
|
|
col := th.Text
|
|
vals := [len(columns)]string{info.Slot, p.scene(info.Meta.Scene), info.Meta.Date.Local().Format("2006-01-02 15:04"),
|
|
Playtime(info.Meta.Playtime), strconv.Itoa(info.Meta.Objects), size(info.Size)}
|
|
if info.Err != nil {
|
|
col = th.Error
|
|
vals = [len(columns)]string{info.Slot, "unreadable", info.Mod.Local().Format("2006-01-02 15:04"), "", "", size(info.Size)}
|
|
}
|
|
var buf [len(columns)]cell
|
|
for _, c := range p.cells(rr, buf[:0]) {
|
|
ui.PushClip(c.r)
|
|
ui.TextIn(c.r, vals[c.col], col, colAlign[c.col])
|
|
ui.PopClip()
|
|
}
|
|
}
|
|
|
|
func (p *Panel) selected() *save.SlotInfo {
|
|
for i := range p.list {
|
|
if p.list[i].Slot == p.sel {
|
|
return &p.list[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Panel) details(st *save.Store, r mathx.Rect) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
if r.W <= 0 || r.H <= 0 {
|
|
return
|
|
}
|
|
info := p.selected()
|
|
if info == nil {
|
|
ui.TextIn(mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight}, "Select a save to see what it holds", th.TextDim, core.AlignLeft)
|
|
return
|
|
}
|
|
if p.docSlot != info.Slot || !p.docMod.Equal(info.Mod) {
|
|
p.doc, p.docErr = st.Load(info.Slot)
|
|
p.docSlot, p.docMod = info.Slot, info.Mod
|
|
}
|
|
ui.PushClip(r)
|
|
defer ui.PopClip()
|
|
row := mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight}
|
|
labels := [...]string{"Load", "Overwrite", "Delete"}
|
|
live := p.service() != nil
|
|
base := ui.ID("detail")
|
|
x := row.X
|
|
for i, l := range labels {
|
|
w := ui.TextSize(l).X + 14
|
|
if p.button(core.IDSeq(base, i+1), mathx.Rect{X: x, Y: row.Y, W: w, H: row.H}, l, live || i == 2) {
|
|
switch i {
|
|
case 0:
|
|
p.load(info.Slot)
|
|
case 1:
|
|
p.slot, p.typed = info.Slot, true
|
|
p.saveNow()
|
|
case 2:
|
|
p.remove(st, info.Slot)
|
|
}
|
|
}
|
|
x += w + 4
|
|
}
|
|
y := row.Y + row.H + th.Spacing
|
|
if p.docErr != nil {
|
|
ui.TextIn(mathx.Rect{X: r.X, Y: y, W: r.W, H: th.RowHeight}, p.docErr.Error(), th.Error, core.AlignLeft)
|
|
return
|
|
}
|
|
d := p.doc
|
|
m := &d.Meta
|
|
kv := [][2]string{
|
|
{"Slot", info.Slot},
|
|
{"Scene", p.scene(m.Scene)},
|
|
{"Saved", m.Date.Local().Format(time.DateTime)},
|
|
{"Playtime", Playtime(m.Playtime)},
|
|
{"Game", orDash(m.Game)},
|
|
{"Engine", orDash(m.Engine)},
|
|
{"Objects", strconv.Itoa(len(d.Nodes)) + " scene, " + strconv.Itoa(len(d.Spawned)) + " spawned, " + strconv.Itoa(len(d.Destroyed)) + " destroyed"},
|
|
{"Next id", strconv.FormatUint(m.Counter+1, 10)},
|
|
}
|
|
if len(d.Global) > 0 {
|
|
keys := make([]string, 0, len(d.Global))
|
|
for _, f := range d.Global {
|
|
keys = append(keys, f.Name)
|
|
}
|
|
kv = append(kv, [2]string{"Global", strings.Join(keys, ", ")})
|
|
}
|
|
for _, sec := range d.Sections {
|
|
kv = append(kv, [2]string{"Section", sec.ID + " v" + strconv.Itoa(sec.Version)})
|
|
}
|
|
lw := widgets.LabelWidth(r.W)
|
|
body := mathx.Rect{X: r.X, Y: y, W: r.W, H: max(0, r.Y+r.H-y)}
|
|
widgets.VirtualList(ui, ui.ID("details"), body, len(kv), th.RowHeight+th.Spacing, &p.dscroll, func(i int, rr mathx.Rect) {
|
|
lr := mathx.Rect{X: rr.X, Y: rr.Y, W: lw - 4, H: rr.H}
|
|
vr := mathx.Rect{X: rr.X + lw, Y: rr.Y, W: rr.W - lw, H: rr.H}
|
|
ui.Text(kv[i][0], mathx.Vec2{X: lr.X, Y: ui.TextY(lr)}, th.TextDim)
|
|
ui.Text(kv[i][1], mathx.Vec2{X: vr.X, Y: ui.TextY(vr)}, th.Text)
|
|
})
|
|
}
|
|
|
|
func orDash(s string) string {
|
|
if s == "" {
|
|
return "—"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (p *Panel) load(slot string) {
|
|
svc := p.service()
|
|
if svc == nil {
|
|
p.notify(context.LevelInfo, "Start Play to load a save")
|
|
return
|
|
}
|
|
if err := svc.Load(slot); err != nil {
|
|
p.notify(context.LevelError, err.Error())
|
|
}
|
|
}
|
|
|
|
func (p *Panel) remove(st *save.Store, slot string) {
|
|
if p.d.Notices == nil {
|
|
return
|
|
}
|
|
p.d.Notices.Ask(context.Question{
|
|
Title: "Delete save",
|
|
Message: "Delete the save in slot " + slot + "?",
|
|
Buttons: []string{"Delete", "Cancel"},
|
|
Then: func(choice int) {
|
|
if choice != 0 {
|
|
return
|
|
}
|
|
if err := st.Delete(slot); err != nil {
|
|
p.notify(context.LevelError, err.Error())
|
|
}
|
|
p.sel = ""
|
|
p.Refresh()
|
|
},
|
|
})
|
|
}
|