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:
@@ -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
136
internal/ui/buttons.go
Normal 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
284
internal/ui/driver.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
136
internal/ui/interfaces.go
Normal 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
|
||||
}
|
@@ -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))
|
||||
}
|
||||
|
62
internal/ui/selectors.go
Normal file
62
internal/ui/selectors.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
||||
}
|
Reference in New Issue
Block a user