From 69971b28254d261efc60c831c8d9ed4ebda20166 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 24 Mar 2020 20:21:55 +0000 Subject: [PATCH] Rework the UI framework Interface is now Driver, and Widget is now a set of interfaces with a struct per widget type. This should make it easier to add other types. --- cmd/view-menu/main.go | 4 +- internal/assetstore/menu.go | 18 ++- internal/menus/menus.go | 2 +- internal/ordoor/interfaces.go | 198 +++++++----------------- internal/ordoor/ordoor.go | 10 +- internal/ui/animation.go | 8 +- internal/ui/buttons.go | 136 ++++++++++++++++ internal/ui/driver.go | 284 ++++++++++++++++++++++++++++++++++ internal/ui/interface.go | 175 --------------------- internal/ui/interfaces.go | 136 ++++++++++++++++ internal/ui/noninteractive.go | 89 ++++++++++- internal/ui/selectors.go | 62 ++++++++ internal/ui/setup_handlers.go | 229 --------------------------- internal/ui/widget.go | 109 ------------- 14 files changed, 791 insertions(+), 669 deletions(-) create mode 100644 internal/ui/buttons.go create mode 100644 internal/ui/driver.go delete mode 100644 internal/ui/interface.go create mode 100644 internal/ui/interfaces.go create mode 100644 internal/ui/selectors.go delete mode 100644 internal/ui/setup_handlers.go delete mode 100644 internal/ui/widget.go diff --git a/cmd/view-menu/main.go b/cmd/view-menu/main.go index cef7aba..e6c68f3 100644 --- a/cmd/view-menu/main.go +++ b/cmd/view-menu/main.go @@ -35,12 +35,12 @@ func main() { log.Fatalf("Couldn't load menu %s: %v", *menuName, err) } - iface, err := ui.NewInterface(menu) + driver, err := ui.NewDriver(menu) if err != nil { log.Fatalf("Couldn't initialize interface: %v", err) } - win, err := ui.NewWindow(iface, "View Menu: "+*menuName, *winX, *winY) + win, err := ui.NewWindow(driver, "View Menu: "+*menuName, *winX, *winY) if err != nil { log.Fatal("Couldn't create window: %v", err) } diff --git a/internal/assetstore/menu.go b/internal/assetstore/menu.go index fb6e608..75e685f 100644 --- a/internal/assetstore/menu.go +++ b/internal/assetstore/menu.go @@ -21,13 +21,29 @@ func (m *Menu) Records() []*menus.Record { func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) { out := make([]*ebiten.Image, count) + + sprites, err := m.Sprites(start, count) + if err != nil { + return nil, err + } + + for i, sprite := range sprites { + out[i] = sprite.Image + } + + return out, nil +} + +func (m *Menu) Sprites(start, count int) ([]*Sprite, error) { + out := make([]*Sprite, count) + for i := start; i < start+count; i++ { sprite, err := m.Sprite(i) if err != nil { return nil, err } - out[i-start] = sprite.Image + out[i-start] = sprite } return out, nil diff --git a/internal/menus/menus.go b/internal/menus/menus.go index 3ae677b..377d046 100644 --- a/internal/menus/menus.go +++ b/internal/menus/menus.go @@ -15,7 +15,7 @@ type MenuType int const ( TypeStatic MenuType = 0 TypeMenu MenuType = 1 - TypeButton MenuType = 3 + TypeSimpleButton MenuType = 3 TypeInvokeButton MenuType = 50 TypeOverlay MenuType = 61 TypeHypertext MenuType = 70 diff --git a/internal/ordoor/interfaces.go b/internal/ordoor/interfaces.go index e0d33cb..ff10b57 100644 --- a/internal/ordoor/interfaces.go +++ b/internal/ordoor/interfaces.go @@ -1,7 +1,6 @@ package ordoor import ( - "fmt" "log" "code.ur.gs/lupine/ordoor/internal/ui" @@ -15,31 +14,31 @@ func try(result error, into *error) { // These are UI interfaces covering the game entrypoint -func (o *Ordoor) ifaceMain() (*ui.Interface, error) { +func (o *Ordoor) mainDriver() (*ui.Driver, error) { // Start in the "main" menu - main, err := o.buildInterface("main") + main, err := o.buildDriver("main") if err != nil { return nil, err } - options, err := o.ifaceOptions(main) + options, err := o.optionsDriver(main) if err != nil { return nil, err } // TODO: clicking these buttons should load other interfaces - try(wireupClick(main, func() {}, "2.1"), &err) // New game - try(wireupClick(main, func() {}, "2.2"), &err) // Load game - try(disableWidget(main, "2.3"), &err) // Multiplayer - disable for now - try(wireupClick(main, func() { o.iface = options }, "2.4"), &err) // Options - try(wireupClick(main, func() { o.nextState = StateExit }, "2.5"), &err) // Quit + try(main.OnClick("2.1", func() {}), &err) // New game + try(main.OnClick("2.2", func() {}), &err) // Load game + try(main.SetFreeze("2.3", true), &err) // Multiplayer - disable for now + try(main.OnClick("2.4", func() { o.driver = options }), &err) // Options + try(main.OnClick("2.5", func() { o.nextState = StateExit }), &err) // Quit return main, err } // Options needs to know how to go back to main -func (o *Ordoor) ifaceOptions(main *ui.Interface) (*ui.Interface, error) { - options, err := o.buildInterface("options") +func (o *Ordoor) optionsDriver(main *ui.Driver) (*ui.Driver, error) { + options, err := o.buildDriver("options") if err != nil { return nil, err } @@ -49,43 +48,13 @@ func (o *Ordoor) ifaceOptions(main *ui.Interface) (*ui.Interface, error) { } // TODO: load current options state into UI - try(wireupClick(options, func() {}, "2.8"), &err) // Keyboard settings button + try(options.OnClick("2.8", func() {}), &err) // Keyboard settings button // Resolution slider is 2.9 // Music volume slider is 2.10 // Sound FX volume slider is 2.11 - - // Accept button - try(wireupClick( - options, func() { - if err := o.optionsIntoConfig(options); err != nil { - // FIXME: exiting is a bit OTT. Perhaps display "save failed"? - log.Printf("Saving options to config failed: %v", err) - o.nextState = StateExit - } else { - o.iface = main - } - }, - "2.12", - ), &err) - + try(options.OnClick("2.12", acceptOptionsFn(o, main, options)), &err) // 13...23 are "hypertext" - - // Cancel button - try( - wireupClick( - options, - func() { - // FIXME: again, exiting is OTT. We're just resetting the state of - // the interface to the values in config. - if err := o.configIntoOptions(options); err != nil { - log.Printf("Saving options to config failed: %v", err) - o.nextState = StateExit - } else { - o.iface = main - } - }, - "2.24", - ), &err) + try(options.OnClick("2.24", cancelOptionsFn(o, main, options)), &err) // Unit speed slider is 2,26 // Looping effect speed slider is 2,27 // Sample of unit speed animation is 2,28 @@ -96,34 +65,59 @@ func (o *Ordoor) ifaceOptions(main *ui.Interface) (*ui.Interface, error) { return options, err } -func (o *Ordoor) configIntoOptions(options *ui.Interface) error { +// FIXME: exiting is a bit OTT. Perhaps display "save failed"? +func acceptOptionsFn(o *Ordoor, main, options *ui.Driver) func() { + return func() { + if err := o.optionsIntoConfig(options); err != nil { + log.Printf("Saving options to config failed: %v", err) + o.nextState = StateExit + } else { + o.driver = main + } + } +} + +// FIXME: again, exiting is OTT. We're just resetting the state of +// the interface to the values in config. +func cancelOptionsFn(o *Ordoor, main, options *ui.Driver) func() { + return func() { + if err := o.configIntoOptions(options); err != nil { + log.Printf("Saving options to config failed: %v", err) + o.nextState = StateExit + } else { + o.driver = main + } + } +} + +func (o *Ordoor) configIntoOptions(options *ui.Driver) error { cfg := &o.config.Options var err error - try(setWidgetValueBool(options, cfg.PlayMovies, "2.1"), &err) - try(setWidgetValueBool(options, cfg.Animations, "2.2"), &err) - try(setWidgetValueBool(options, cfg.PlayMusic, "2.3"), &err) - try(setWidgetValueBool(options, cfg.CombatVoices, "2.4"), &err) - try(setWidgetValueBool(options, cfg.ShowGrid, "2.5"), &err) - try(setWidgetValueBool(options, cfg.ShowPaths, "2.6"), &err) - try(setWidgetValueBool(options, cfg.PointSaving, "2.7"), &err) - try(setWidgetValueBool(options, cfg.AutoCutLevel, "2.25"), &err) + try(options.SetValueBool("2.1", cfg.PlayMovies), &err) + try(options.SetValueBool("2.1", cfg.Animations), &err) + try(options.SetValueBool("2.3", cfg.PlayMusic), &err) + try(options.SetValueBool("2.4", cfg.CombatVoices), &err) + try(options.SetValueBool("2.5", cfg.ShowGrid), &err) + try(options.SetValueBool("2.6", cfg.ShowPaths), &err) + try(options.SetValueBool("2.7", cfg.PointSaving), &err) + try(options.SetValueBool("2.25", cfg.AutoCutLevel), &err) return err } -func (o *Ordoor) optionsIntoConfig(options *ui.Interface) error { +func (o *Ordoor) optionsIntoConfig(options *ui.Driver) error { cfg := &o.config.Options var err error - try(getWidgetValueBool(options, &cfg.PlayMovies, "2.1"), &err) - try(getWidgetValueBool(options, &cfg.Animations, "2.2"), &err) - try(getWidgetValueBool(options, &cfg.PlayMusic, "2.3"), &err) - try(getWidgetValueBool(options, &cfg.CombatVoices, "2.4"), &err) - try(getWidgetValueBool(options, &cfg.ShowGrid, "2.5"), &err) - try(getWidgetValueBool(options, &cfg.ShowPaths, "2.6"), &err) - try(getWidgetValueBool(options, &cfg.PointSaving, "2.7"), &err) - try(getWidgetValueBool(options, &cfg.AutoCutLevel, "2.25"), &err) + try(options.ValueBool("2.1", &cfg.PlayMovies), &err) + try(options.ValueBool("2.2", &cfg.Animations), &err) + try(options.ValueBool("2.3", &cfg.PlayMusic), &err) + try(options.ValueBool("2.4", &cfg.CombatVoices), &err) + try(options.ValueBool("2.5", &cfg.ShowGrid), &err) + try(options.ValueBool("2.6", &cfg.ShowPaths), &err) + try(options.ValueBool("2.7", &cfg.PointSaving), &err) + try(options.ValueBool("2.25", &cfg.AutoCutLevel), &err) if err != nil { return err @@ -146,90 +140,16 @@ func (o *Ordoor) optionsIntoConfig(options *ui.Interface) error { return nil } -func (o *Ordoor) buildInterface(name string) (*ui.Interface, error) { +func (o *Ordoor) buildDriver(name string) (*ui.Driver, error) { menu, err := o.assets.Menu(name) if err != nil { return nil, err } - iface, err := ui.NewInterface(menu) + driver, err := ui.NewDriver(menu) if err != nil { return nil, err } - return iface, nil -} - -func findWidget(iface *ui.Interface, spec string) (*ui.Widget, error) { - widget, err := iface.Widget(spec) - if err != nil { - return nil, fmt.Errorf("Couldn't find widget %v:%+v", iface.Name, spec) - } - - return widget, nil -} - -func getWidgetValue(iface *ui.Interface, spec string) (string, error) { - widget, err := findWidget(iface, spec) - if err != nil { - return "", err - } - - return widget.Value, nil -} - -func setWidgetValue(iface *ui.Interface, value string, spec string) error { - widget, err := findWidget(iface, spec) - if err != nil { - return err - } - - widget.Value = value - - return nil -} - -func getWidgetValueBool(iface *ui.Interface, into *bool, spec string) error { - vStr, err := getWidgetValue(iface, spec) - if err != nil { - return err - } - - *into = vStr == "1" - return nil -} - -func setWidgetValueBool(iface *ui.Interface, value bool, spec string) error { - vStr := "0" - if value { - vStr = "1" - } - - return setWidgetValue(iface, vStr, spec) -} - -func wireupClick(iface *ui.Interface, f func(), spec string) error { - widget, err := findWidget(iface, spec) - if err != nil { - return err - } - - if widget.OnMouseClick != nil { - return fmt.Errorf("Widget %#+v already has an OnMouseClick handler", widget) - } - - widget.OnMouseClick = f - - return nil -} - -func disableWidget(iface *ui.Interface, spec string) error { - widget, err := findWidget(iface, spec) - if err != nil { - return err - } - - widget.Disable() - - return nil + return driver, nil } diff --git a/internal/ordoor/ordoor.go b/internal/ordoor/ordoor.go index 91831a3..d9e4faf 100644 --- a/internal/ordoor/ordoor.go +++ b/internal/ordoor/ordoor.go @@ -39,7 +39,7 @@ type Ordoor struct { nextState gameState // Relevant to interface state - iface *ui.Interface + driver *ui.Driver } func Run(configFile string, overrideX, overrideY int) error { @@ -130,12 +130,12 @@ func (o *Ordoor) PlayMusic(name string) error { func (o *Ordoor) setupInterface() error { o.PlayMusic("music_interface") - initial, err := o.ifaceMain() + main, err := o.mainDriver() if err != nil { return err } - o.iface = initial + o.driver = main return nil } @@ -163,7 +163,7 @@ func (o *Ordoor) Update(screenX, screenY int) error { switch o.state { case StateInterface: - return o.iface.Update(screenX, screenY) + return o.driver.Update(screenX, screenY) default: return fmt.Errorf("Unknown state: %v", o.state) } @@ -172,7 +172,7 @@ func (o *Ordoor) Update(screenX, screenY int) error { func (o *Ordoor) Draw(screen *ebiten.Image) error { switch o.state { case StateInterface: - return o.iface.Draw(screen) + return o.driver.Draw(screen) default: return fmt.Errorf("Unknown state: %v", o.state) } diff --git a/internal/ui/animation.go b/internal/ui/animation.go index cdb270e..e02547f 100644 --- a/internal/ui/animation.go +++ b/internal/ui/animation.go @@ -4,12 +4,16 @@ import ( "github.com/hajimehoshi/ebiten" ) +var ( + SpeedDivisor = 2 +) + type animation []*ebiten.Image -func (a animation) image(step int) *ebiten.Image { +func (a animation) image(tick int) *ebiten.Image { if len(a) == 0 { return nil } - return a[step%len(a)] + return a[(tick/SpeedDivisor)%len(a)] } diff --git a/internal/ui/buttons.go b/internal/ui/buttons.go new file mode 100644 index 0000000..236524f --- /dev/null +++ b/internal/ui/buttons.go @@ -0,0 +1,136 @@ +package ui + +import ( + "image" + + "code.ur.gs/lupine/ordoor/internal/assetstore" + "code.ur.gs/lupine/ordoor/internal/menus" +) + +func init() { + registerBuilder(menus.TypeSimpleButton, registerSimpleButton) + registerBuilder(menus.TypeInvokeButton, registerInvokeButton) + registerBuilder(menus.TypeMainButton, registerMainButton) +} + +// A button without hover animation +type button struct { + path string + + baseSpr *assetstore.Sprite + clickSpr *assetstore.Sprite + frozenSpr *assetstore.Sprite + + clickImpl + freezeImpl + hoverImpl +} + +// A button with hover animation +type mainButton struct { + hoverAnim animation + + button +} + +func registerSimpleButton(d *Driver, r *menus.Record) error { + return registerButton(d, r, r.SpriteId[0]) +} + +func registerInvokeButton(d *Driver, r *menus.Record) error { + return registerButton(d, r, r.Share) +} + +func registerMainButton(d *Driver, r *menus.Record) error { + sprites, err := d.menu.Sprites(r.Share, 3) // base, pressed, disabled + if err != nil { + return err + } + + hovers, err := d.menu.Images(r.SpriteId[0], r.DrawType) + if err != nil { + return err + } + + btn := &mainButton{ + hoverAnim: animation(hovers), + button: button{ + path: r.Path(), + baseSpr: sprites[0], + clickSpr: sprites[1], + frozenSpr: sprites[2], + hoverImpl: hoverImpl{text: r.Desc}, + }, + } + + d.clickables = append(d.clickables, btn) + d.freezables = append(d.freezables, btn) + d.hoverables = append(d.hoverables, btn) + d.paintables = append(d.paintables, btn) + + return nil +} + +func registerButton(d *Driver, r *menus.Record, spriteId int) error { + sprites, err := d.menu.Sprites(spriteId, 3) // base, pressed, disabled + if err != nil { + return err + } + + btn := &button{ + path: r.Path(), + baseSpr: sprites[0], + clickSpr: sprites[1], + frozenSpr: sprites[2], + hoverImpl: hoverImpl{text: r.Desc}, + } + + d.clickables = append(d.clickables, btn) + d.freezables = append(d.freezables, btn) + d.hoverables = append(d.hoverables, btn) + d.paintables = append(d.paintables, btn) + + return nil +} + +func (b *button) id() string { + return b.path +} + +func (b *button) bounds() image.Rectangle { + return b.baseSpr.Rect +} + +func (b *button) mouseDownState() bool { + if b.isFrozen() { + return false + } + + return b.clickImpl.mouseDownState() +} + +func (b *button) registerMouseClick() { + if !b.isFrozen() { + b.clickImpl.registerMouseClick() + } +} + +func (b *button) regions(tick int) []region { + if b.isFrozen() { + return oneRegion(b.bounds().Min, b.frozenSpr.Image) + } + + if b.mouseDownState() { + return oneRegion(b.bounds().Min, b.clickSpr.Image) + } + + return oneRegion(b.bounds().Min, b.baseSpr.Image) +} + +func (m *mainButton) regions(tick int) []region { + if !m.isFrozen() && !m.mouseDownState() && m.hoverState() { + return oneRegion(m.bounds().Min, m.hoverAnim.image(tick)) + } + + return m.button.regions(tick) +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go new file mode 100644 index 0000000..fa0f423 --- /dev/null +++ b/internal/ui/driver.go @@ -0,0 +1,284 @@ +package ui + +import ( + "fmt" + "image" + "log" + + "github.com/hajimehoshi/ebiten" + "github.com/hajimehoshi/ebiten/ebitenutil" + + "code.ur.gs/lupine/ordoor/internal/assetstore" + "code.ur.gs/lupine/ordoor/internal/menus" +) + +func init() { + // These menu types don't need driving, so we can ignore them + registerBuilder(menus.TypeMenu, nil) // Menus are just containers + + // FIXME: these need further investigation / implementation + registerBuilder(menus.TypeOverlay, nil) + registerBuilder(menus.TypeSlider, nil) +} + +const ( + OriginalX = 640.0 + OriginalY = 480.0 +) + +var ( + // Widgets register their builder here + widgetBuilders = map[menus.MenuType]builderFunc{} +) + +// Used to add widgets to a driver +type builderFunc func(d *Driver, r *menus.Record) error + +func registerBuilder(t menus.MenuType, f builderFunc) { + if _, ok := widgetBuilders[t]; ok { + panic(fmt.Sprintf("A builder for menu type %v already exists", t)) + } + + widgetBuilders[t] = f +} + +// Driver acts as an interface between the main loop and the widgets specified +// in a menu. +// +// Menu assets assume a 640x480 screen; Driver is responsible for scaling to the +// actual screen size when drawing. +type Driver struct { + Name string + + menu *assetstore.Menu + + // UI elements we need to drive + clickables []clickable + freezables []freezable + hoverables []hoverable + paintables []paintable + valueables []valueable + + // The cursor in two different coordinate spaces: original, and screen-scaled + cursorOrig image.Point + cursorScaled image.Point + + // These two matrices are used for scaling between the two + orig2native ebiten.GeoM + native2orig ebiten.GeoM + + ticks int // Used in animation effects + tooltip string +} + +func NewDriver(menu *assetstore.Menu) (*Driver, error) { + driver := &Driver{ + Name: menu.Name, + menu: menu, + } + + for _, record := range menu.Records() { + if err := driver.addRecord(record); err != nil { + return nil, err + } + } + + return driver, nil +} + +func (d *Driver) Value(id string, into *string) error { + for _, valueable := range d.valueables { + if valueable.id() == id { + *into = valueable.value() + return nil + } + } + + return fmt.Errorf("Couldn't find valueable widget %q", id) +} + +func (d *Driver) SetValue(id, value string) error { + for _, valueable := range d.valueables { + if valueable.id() == id { + valueable.setValue(value) + return nil + } + } + + return fmt.Errorf("Couldn't find valueable widget %q", id) +} + +func (d *Driver) ValueBool(id string, into *bool) error { + var vStr string + if err := d.Value(id, &vStr); err != nil { + return err + } + + *into = vStr == "1" + return nil +} + +func (d *Driver) SetValueBool(id string, value bool) error { + vStr := "0" + if value { + vStr = "1" + } + + return d.SetValue(id, vStr) +} + +func (d *Driver) SetFreeze(id string, value bool) error { + for _, freezable := range d.freezables { + if freezable.id() == id { + freezable.setFreezeState(value) + return nil + } + } + + return fmt.Errorf("Couldn't find clickable widget %q", id) +} + +func (d *Driver) OnClick(id string, f func()) error { + for _, clickable := range d.clickables { + if clickable.id() == id { + clickable.onClick(f) + return nil + } + } + + return fmt.Errorf("Couldn't find clickable widget %q", id) +} + +func (d *Driver) Update(screenX, screenY int) error { + // This will be updated while processing hovers + d.tooltip = "" + d.ticks += 1 + + // Update translation matrices + d.orig2native.Reset() + d.orig2native.Scale(float64(screenX)/OriginalX, float64(screenY)/OriginalY) + + d.native2orig = d.orig2native + d.native2orig.Invert() + + // Update original and scaled mouse coordinates + mouseX, mouseY := ebiten.CursorPosition() + d.cursorScaled = image.Pt(mouseX, mouseY) + + mnX, mnY := d.native2orig.Apply(float64(mouseX), float64(mouseY)) + d.cursorOrig = image.Pt(int(mnX), int(mnY)) + + // Dispatch notifications to our widgets + for _, hoverable := range d.hoverables { + inBounds := d.cursorOrig.In(hoverable.bounds()) + + d.hoverStartEvent(hoverable, inBounds) + d.hoverEndEvent(hoverable, inBounds) + + if hoverable.hoverState() && hoverable.tooltip() != "" { + d.tooltip = hoverable.tooltip() + } + } + + mouseIsDown := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) + for _, clickable := range d.clickables { + inBounds := d.cursorOrig.In(clickable.bounds()) + mouseWasDown := clickable.mouseDownState() + + d.mouseDownEvent(clickable, inBounds, mouseWasDown, mouseIsDown) + d.mouseClickEvent(clickable, inBounds, mouseWasDown, mouseIsDown) + d.mouseUpEvent(clickable, inBounds, mouseWasDown, mouseIsDown) + } + + return nil +} + +func (d *Driver) Draw(screen *ebiten.Image) error { + var do ebiten.DrawImageOptions + + for _, paint := range d.paintables { + for _, region := range paint.regions(d.ticks) { + x, y := d.orig2native.Apply(float64(region.offset.X), float64(region.offset.Y)) + + do.GeoM = d.orig2native + do.GeoM.Translate(x, y) + + if err := screen.DrawImage(region.image, &do); err != nil { + return err + } + } + } + + if d.tooltip != "" { + x, y := d.cursorScaled.X+16, d.cursorScaled.Y-16 + ebitenutil.DebugPrintAt(screen, d.tooltip, x, y) + } + + return nil +} + +func (d *Driver) addRecord(record *menus.Record) error { + log.Printf("Adding record: %#+v", record) + + handler, ok := widgetBuilders[record.Type] + if !ok { + return fmt.Errorf("UI driver encountered unknown menu record: %#+v", record) + } + + if handler != nil { + if err := handler(d, record); err != nil { + return err + } + } + + // Recursively add all children of this record + for _, record := range record.Children { + if err := d.addRecord(record); err != nil { + return err + } + } + + return nil +} + +func (d *Driver) hoverStartEvent(h hoverable, inBounds bool) { + if inBounds && !h.hoverState() { + log.Printf("hoverable false -> true") + h.setHoverState(true) + } +} + +func (d *Driver) hoverEndEvent(h hoverable, inBounds bool) { + if !inBounds && h.hoverState() { + log.Printf("hoverable true -> false") + h.setHoverState(false) + } +} + +func (d *Driver) mouseDownEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds && !wasDown && isDown { + log.Printf("mouse down false -> true") + c.setMouseDownState(true) + } +} + +func (d *Driver) mouseClickEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds && wasDown && !isDown { + log.Printf("mouse click") + c.registerMouseClick() + } +} + +func (d *Driver) mouseUpEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds { + if wasDown && !isDown { + log.Printf("mouse down true -> false") + c.setMouseDownState(false) + } + } else { + if wasDown { + log.Printf("mouse down true -> false") + c.setMouseDownState(false) + } + } +} diff --git a/internal/ui/interface.go b/internal/ui/interface.go deleted file mode 100644 index e24b2be..0000000 --- a/internal/ui/interface.go +++ /dev/null @@ -1,175 +0,0 @@ -package ui - -import ( - "fmt" - "image" - "log" - - "github.com/hajimehoshi/ebiten" - "github.com/hajimehoshi/ebiten/ebitenutil" - - "code.ur.gs/lupine/ordoor/internal/assetstore" - "code.ur.gs/lupine/ordoor/internal/menus" -) - -// type Interface encapsulates a user interface, providing a means to track UI -// state, draw the interface, and execute code when the widgets are interacted -// with. -// -// The graphics for UI elements were all created with a 640x480 resolution in -// mind. The interface transparently scales them all to the current screen size -// to compensate. -type Interface struct { - Name string - menu *assetstore.Menu - static []*noninteractive - ticks int - widgets []*Widget -} - -func NewInterface(menu *assetstore.Menu) (*Interface, error) { - iface := &Interface{ - Name: menu.Name, - menu: menu, - } - - for _, record := range menu.Records() { - if err := iface.addRecord(record); err != nil { - return nil, err - } - } - - return iface, nil -} - -// Find a widget by its hierarchical ID path -func (i *Interface) Widget(path string) (*Widget, error) { - for _, widget := range i.widgets { - if path == widget.path { - return widget, nil - } - } - - return nil, fmt.Errorf("Couldn't find widget %v", path) -} - -func (i *Interface) Update(screenX, screenY int) error { - // Used in animation effects - i.ticks += 1 - - mousePos := i.getMousePos(screenX, screenY) - - // Iterate through all widgets, update mouse state - for _, widget := range i.widgets { - if widget.disabled { - continue // No activity for disabled widgets - } - - mouseIsOver := mousePos.In(widget.Bounds) - widget.hovering(mouseIsOver) - widget.mouseButton(mouseIsOver && ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft)) - } - - return nil -} - -func (i *Interface) Draw(screen *ebiten.Image) error { - var tooltip string // Draw this last, so it's on top of everything - - mousePt := i.getMousePos(screen.Size()) - geo := i.scale(screen.Size()) - do := &ebiten.DrawImageOptions{GeoM: geo} - - for _, s := range i.static { - if image := s.image(i.ticks); image != nil { - do.GeoM.Translate(geo.Apply(float64(s.bounds.Min.X), float64(s.bounds.Min.Y))) - if err := screen.DrawImage(image, do); err != nil { - return err - } - do.GeoM = geo - } - - if mousePt.In(s.bounds) && s.tooltip != "" { - tooltip = s.tooltip - } - } - - for _, widget := range i.widgets { - img, err := widget.Image(i.ticks / 2) - if err != nil { - return err - } - - if img == nil { - continue - } - - do.GeoM.Translate(geo.Apply(float64(widget.Bounds.Min.X), float64(widget.Bounds.Min.Y))) - if err := screen.DrawImage(img, do); err != nil { - return err - } - do.GeoM = geo - - if widget.hoverState && widget.Tooltip != "" { - tooltip = widget.Tooltip - } - } - - if tooltip != "" { - cX, cY := ebiten.CursorPosition() - - ebitenutil.DebugPrintAt(screen, tooltip, cX+16, cY-16) - } - - return nil -} - -func (i *Interface) addRecord(record *menus.Record) error { - log.Printf("Adding record: %#+v", record) - - handler, ok := setupHandlers[record.Type] - if !ok { - return fmt.Errorf("ui.interface: encountered unknown menu record: %#+v", record) - } - - if handler != nil { - if err := handler(i, record); err != nil { - return err - } - } - - // Recursively add all children - for _, record := range record.Children { - if err := i.addRecord(record); err != nil { - return err - } - } - - return nil -} - -// Works out how much we have to scale the current screen by to draw correctly -func (i *Interface) scale(w, h int) ebiten.GeoM { - var geo ebiten.GeoM - geo.Scale(float64(w)/640.0, float64(h)/480.0) - - return geo -} - -func (i *Interface) unscale(w, h int) ebiten.GeoM { - geo := i.scale(w, h) - geo.Invert() - - return geo -} - -// Returns the current position of the mouse in 640x480 coordinates. Needs the -// actual size of the screen to do so. -func (i *Interface) getMousePos(w, h int) image.Point { - cX, cY := ebiten.CursorPosition() - geo := i.unscale(w, h) - - sX, sY := geo.Apply(float64(cX), float64(cY)) - - return image.Pt(int(sX), int(sY)) -} diff --git a/internal/ui/interfaces.go b/internal/ui/interfaces.go new file mode 100644 index 0000000..73536e4 --- /dev/null +++ b/internal/ui/interfaces.go @@ -0,0 +1,136 @@ +package ui + +import ( + "image" + + "github.com/hajimehoshi/ebiten" +) + +type region struct { + offset image.Point + image *ebiten.Image +} + +func oneRegion(offset image.Point, image *ebiten.Image) []region { + return []region{{offset: offset, image: image}} +} + +type idable interface { + id() string +} + +// Clickable can be clicked by the left button of a mouse. Specify code to run +// with OnClick(). +type clickable interface { + idable + + bounds() image.Rectangle + onClick(f func()) + + // These are used to drive the state of the item + mouseDownState() bool + setMouseDownState(bool) + registerMouseClick() +} + +// This implements the clickable interface except id(), bounds(), and registerMouseClick() +type clickImpl struct { + f func() + mouseDown bool +} + +func (c *clickImpl) onClick(f func()) { + c.f = f +} + +func (c *clickImpl) mouseDownState() bool { + return c.mouseDown +} + +func (c *clickImpl) setMouseDownState(down bool) { + c.mouseDown = down +} + +func (c *clickImpl) registerMouseClick() { + if c.f != nil { + c.f() + } +} + +// Freezable represents a widget that can be enabled or disabled +type freezable interface { + idable + + isFrozen() bool + setFreezeState(bool) +} + +// This implements the freezable interface except id() +type freezeImpl struct { + frozen bool +} + +func (f *freezeImpl) isFrozen() bool { + return f.frozen +} + +func (f *freezeImpl) setFreezeState(frozen bool) { + f.frozen = frozen +} + +// Hoverable can be hovered over by the mouse cursor. +// +// If something can be hovered, it can have a tooltip, so that is implemented +// here too. +type hoverable interface { + bounds() image.Rectangle + tooltip() string + + // These are used to drive the state of the item + hoverState() bool + setHoverState(bool) +} + +// Implements the hoverable interface with the exception of bounds() +type hoverImpl struct { + hovering bool + text string +} + +func (h *hoverImpl) tooltip() string { + return h.text +} + +func (h *hoverImpl) hoverState() bool { + return h.hovering +} + +func (h *hoverImpl) setHoverState(hovering bool) { + h.hovering = hovering +} + +// Paintable encapsulates one or more regions to be painted to the screen +type paintable interface { + regions(tick int) []region +} + +// Valueable encapsulates the idea of an element with a value. Only strings are +// supported - #dealwithit for bools, ints, etc +type valueable interface { + idable + + value() string + setValue(string) +} + +type valueImpl struct { + str string +} + +func (v *valueImpl) value() string { + return v.str +} + +func (v *valueImpl) setValue(value string) { + v.str = value +} diff --git a/internal/ui/noninteractive.go b/internal/ui/noninteractive.go index c18648f..bb62d45 100644 --- a/internal/ui/noninteractive.go +++ b/internal/ui/noninteractive.go @@ -1,20 +1,97 @@ package ui import ( - "github.com/hajimehoshi/ebiten" "image" + + "code.ur.gs/lupine/ordoor/internal/menus" ) +func init() { + registerBuilder(menus.TypeStatic, registerStatic) + registerBuilder(menus.TypeHypertext, registerHypertext) + registerBuilder(menus.TypeAnimationSample, registerAnimation) +} + // A non-interactive element is not a widget; it merely displays some pixels and // may optionally have a tooltip for display within bounds. // // For non-animated non-interactive elements, just give them a single frame. type noninteractive struct { - bounds image.Rectangle - frames animation - tooltip string + frames animation + rect image.Rectangle + + hoverImpl } -func (n *noninteractive) image(step int) *ebiten.Image { - return n.frames.image(step) +func registerStatic(d *Driver, r *menus.Record) error { + // FIXME: SpriteID takes precedence over SHARE if present, but is that right? + spriteId := r.Share + if len(r.SpriteId) > 0 && r.SpriteId[0] != -1 { + spriteId = r.SpriteId[0] + } + + sprite, err := d.menu.Sprite(spriteId) + if err != nil { + return err + } + + ni := &noninteractive{ + frames: animation{sprite.Image}, + hoverImpl: hoverImpl{text: r.Desc}, + rect: sprite.Rect, + } + + d.hoverables = append(d.hoverables, ni) + d.paintables = append(d.paintables, ni) + + return nil +} + +func registerHypertext(d *Driver, r *menus.Record) error { + sprite, err := d.menu.Sprite(r.Share) + if err != nil { + return err + } + + ni := &noninteractive{ + frames: nil, + hoverImpl: hoverImpl{text: r.Desc}, + rect: sprite.Rect, + } + + d.hoverables = append(d.hoverables, ni) + + return nil +} + +// An animation is a non-interactive element that displays something in a loop +func registerAnimation(d *Driver, r *menus.Record) error { + sprite, err := d.menu.Sprite(r.SpriteId[0]) + if err != nil { + return err + } + + frames, err := d.menu.Images(r.SpriteId[0], r.DrawType) + if err != nil { + return err + } + + ani := &noninteractive{ + frames: animation(frames), + hoverImpl: hoverImpl{text: r.Desc}, + rect: sprite.Rect, + } + + d.hoverables = append(d.hoverables, ani) + d.paintables = append(d.paintables, ani) + + return nil +} + +func (n *noninteractive) bounds() image.Rectangle { + return n.rect +} + +func (n *noninteractive) regions(tick int) []region { + return oneRegion(n.bounds().Min, n.frames.image(tick)) } diff --git a/internal/ui/selectors.go b/internal/ui/selectors.go new file mode 100644 index 0000000..8894073 --- /dev/null +++ b/internal/ui/selectors.go @@ -0,0 +1,62 @@ +package ui + +import ( + "code.ur.gs/lupine/ordoor/internal/menus" +) + +func init() { + registerBuilder(menus.TypeCheckbox, registerCheckbox) +} + +type checkbox struct { + button + + valueImpl +} + +// A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled. +func registerCheckbox(d *Driver, r *menus.Record) error { + sprites, err := d.menu.Sprites(r.Share, 3) // unchecked, disabled, checked + if err != nil { + return err + } + + checkbox := &checkbox{ + button: button{ + path: r.Path(), + baseSpr: sprites[0], // unchecked + clickSpr: sprites[2], // checked + frozenSpr: sprites[1], + hoverImpl: hoverImpl{text: r.Desc}, + }, + valueImpl: valueImpl{str: "0"}, + } + + d.clickables = append(d.clickables, checkbox) + d.freezables = append(d.freezables, checkbox) + d.hoverables = append(d.hoverables, checkbox) + d.paintables = append(d.paintables, checkbox) + d.valueables = append(d.valueables, checkbox) + + return nil +} + +func (c *checkbox) registerMouseClick() { + if c.value() == "1" { // Click disables + c.setValue("0") + } else { // Click enables + c.setValue("1") + } +} + +func (c *checkbox) regions(tick int) []region { + if c.isFrozen() { + return oneRegion(c.bounds().Min, c.frozenSpr.Image) + } + + if c.value() == "1" { + return oneRegion(c.bounds().Min, c.clickSpr.Image) + } + + return oneRegion(c.bounds().Min, c.baseSpr.Image) +} diff --git a/internal/ui/setup_handlers.go b/internal/ui/setup_handlers.go deleted file mode 100644 index aa207ab..0000000 --- a/internal/ui/setup_handlers.go +++ /dev/null @@ -1,229 +0,0 @@ -package ui - -import ( - "github.com/hajimehoshi/ebiten" - - "code.ur.gs/lupine/ordoor/internal/menus" -) - -// Setup handlers know how to handle each type of widget. -// TODO: it might be better to have a Widget interface and different structs for -// each type of widget, but let's see how far we can push this model. -var setupHandlers = map[menus.MenuType]func(i *Interface, r *menus.Record) error{ - menus.TypeStatic: handleStatic, - menus.TypeMenu: nil, - menus.TypeButton: handleButton, - menus.TypeInvokeButton: handleInvokeButton, - menus.TypeOverlay: nil, // FIXME: What's it for? - menus.TypeHypertext: handleHypertext, - menus.TypeCheckbox: handleCheckbox, - menus.TypeAnimationSample: handleAnimation, - menus.TypeMainButton: handleMainButton, - menus.TypeSlider: nil, // FIXME: handle this -} - -func handleStatic(i *Interface, record *menus.Record) error { - spriteId := record.Share - - // FIXME: SpriteID takes precedence over SHARE if present, but is that right? - if len(record.SpriteId) > 0 && record.SpriteId[0] != -1 { - spriteId = record.SpriteId[0] - } - - sprite, err := i.menu.Sprite(spriteId) - if err != nil { - return err - } - - static := &noninteractive{ - bounds: sprite.Rect, - frames: animation{sprite.Image}, - tooltip: record.Desc, - } - - i.static = append(i.static, static) - - return nil -} - -// A hypertext is static, but we should only take the bounds from "SHARE", not -// display anything. -func handleHypertext(i *Interface, record *menus.Record) error { - sprite, err := i.menu.Sprite(record.Share) - if err != nil { - return err - } - - static := &noninteractive{ - bounds: sprite.Rect, - frames: nil, - tooltip: record.Desc, - } - - i.static = append(i.static, static) - - return nil -} - -// A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled. -func handleCheckbox(i *Interface, record *menus.Record) error { - widget, err := i.widgetFromRecord(record, record.Share) - if err != nil { - return err - } - - unchecked := widget.sprite - disabled, err := i.menu.Sprite(record.Share + 1) - if err != nil { - return err - } - checked, err := i.menu.Sprite(record.Share + 2) - if err != nil { - return err - } - - widget.Value = "0" - - widget.OnMouseClick = func() { - if widget.Value == "1" { // Click disables - widget.Value = "0" - } else { // Click enables - widget.Value = "1" - } - } - - widget.disabledImage = disabled.Image - widget.valueToImage = func() *ebiten.Image { - if widget.Value == "1" { - return checked.Image - } - - return unchecked.Image - } - - i.widgets = append(i.widgets, widget) - - return nil -} - -// An animation is a non-interactive element that displays something in a loop -func handleAnimation(i *Interface, record *menus.Record) error { - sprite, err := i.menu.Sprite(record.SpriteId[0]) - if err != nil { - return err - } - - frames, err := i.menu.Images(record.SpriteId[0], record.DrawType) - if err != nil { - return err - } - - ani := &noninteractive{ - bounds: sprite.Rect, - frames: animation(frames), - tooltip: record.Desc, - } - - i.static = append(i.static, ani) - - return nil -} - -func handleButton(i *Interface, record *menus.Record) error { - spriteId := record.SpriteId[0] - widget, err := i.widgetFromRecord(record, spriteId) - if err != nil { - return err - } - - pressed, err := i.menu.Sprite(spriteId + 1) - if err != nil { - return err - } - - disabled, err := i.menu.Sprite(spriteId + 2) - if err != nil { - return err - } - - widget.mouseButtonDownImage = pressed.Image - widget.disabledImage = disabled.Image - - i.widgets = append(i.widgets, widget) - - return nil -} - -func handleInvokeButton(i *Interface, record *menus.Record) error { - widget, err := i.widgetFromRecord(record, record.Share) - if err != nil { - return err - } - - pressed, err := i.menu.Sprite(record.Share + 1) - if err != nil { - return err - } - - disabled, err := i.menu.Sprite(record.Share + 2) - if err != nil { - return err - } - - widget.mouseButtonDownImage = pressed.Image - widget.disabledImage = disabled.Image - - i.widgets = append(i.widgets, widget) - - return nil -} - -// A main button is quite complex. It has 3 main sprites and a hover animation -func handleMainButton(i *Interface, record *menus.Record) error { - widget, err := i.widgetFromRecord(record, record.Share) - if err != nil { - return err - } - - pressed, err := i.menu.Sprite(record.Share + 1) - if err != nil { - return err - } - - disabled, err := i.menu.Sprite(record.Share + 2) - if err != nil { - return err - } - - hovers, err := i.menu.Images(record.SpriteId[0], record.DrawType) - if err != nil { - return err - } - - widget.mouseButtonDownImage = pressed.Image - widget.disabledImage = disabled.Image - widget.hoverAnimation = animation(hovers) - - i.widgets = append(i.widgets, widget) - - return nil -} - -// Widgets need a bounding box determined by a sprite. Different widgets specify -// their sprites in different attributes, so pass in the right sprite externally -func (i *Interface) widgetFromRecord(record *menus.Record, spriteId int) (*Widget, error) { - sprite, err := i.menu.Sprite(spriteId) - if err != nil { - return nil, err - } - - widget := &Widget{ - Bounds: sprite.Rect, - Tooltip: record.Desc, - path: record.Path(), - record: record, - sprite: sprite, - } - - return widget, nil -} diff --git a/internal/ui/widget.go b/internal/ui/widget.go deleted file mode 100644 index 27b4e3e..0000000 --- a/internal/ui/widget.go +++ /dev/null @@ -1,109 +0,0 @@ -package ui - -import ( - "image" - - "github.com/hajimehoshi/ebiten" - - "code.ur.gs/lupine/ordoor/internal/assetstore" - "code.ur.gs/lupine/ordoor/internal/menus" -) - -// Widget represents an interactive area of the screen. Backgrounds and other -// non-interactive areas are not widgets. -type Widget struct { - // Position on the screen in original (i.e., unscaled) coordinates - Bounds image.Rectangle - Tooltip string - Value string // #dealwithit for bools and ints and so on :p - - OnHoverEnter func() - OnHoverLeave func() - - // Mouse up can happen without a click taking place if, for instance, the - // mouse cursor leaves the bounds while still pressed. - OnMouseDown func() - OnMouseClick func() - OnMouseUp func() - - disabled bool - disabledImage *ebiten.Image - - // These are expected to have the same dimensions as the Bounds - hoverAnimation animation - hoverState bool - - // FIXME: We assume right mouse button isn't needed here - // TODO: down, up, and click hooks. - mouseButtonDownImage *ebiten.Image - mouseButtonState bool - - path string - record *menus.Record - sprite *assetstore.Sprite - - valueToImage func() *ebiten.Image -} - -func (w *Widget) Disable() { - w.hovering(false) - w.mouseButton(false) - - w.disabled = true -} - -func (w *Widget) hovering(value bool) { - if w.OnHoverEnter != nil && !w.hoverState && value { - w.OnHoverEnter() - } - - if w.OnHoverLeave != nil && w.hoverState && !value { - w.OnHoverLeave() - } - - w.hoverState = value - - return -} - -func (w *Widget) mouseButton(value bool) { - if w.OnMouseDown != nil && !w.mouseButtonState && value { - w.OnMouseDown() - } - - if w.mouseButtonState && !value { - if w.OnMouseClick != nil && w.hoverState { - w.OnMouseClick() - } - - if w.OnMouseUp != nil { - w.OnMouseUp() - } - } - - w.mouseButtonState = value -} - -func (w *Widget) Image(aniStep int) (*ebiten.Image, error) { - if w.disabled { - if w.disabledImage != nil { - return w.disabledImage, nil - } - - return w.sprite.Image, nil - } - - if w.mouseButtonDownImage != nil && w.hoverState && w.mouseButtonState { - return w.mouseButtonDownImage, nil - } - - if w.hoverState && w.hoverAnimation != nil { - return w.hoverAnimation.image(aniStep), nil - } - - if w.valueToImage != nil { - return w.valueToImage(), nil - } - - return w.sprite.Image, nil -}