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.
This commit is contained in:
2020-03-24 20:21:55 +00:00
parent bcee07e8f7
commit 69971b2825
14 changed files with 791 additions and 669 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)]
}

136
internal/ui/buttons.go Normal file
View File

@@ -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)
}

284
internal/ui/driver.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -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))
}

136
internal/ui/interfaces.go Normal file
View File

@@ -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
}

View File

@@ -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
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))
}

62
internal/ui/selectors.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}