Start work on menu interactivity.
With this commit, we get a ui.Interface and ui.Widget type. The interface monitors hover and mouse click state and tells the widgets about them; the widgets execute code specified by the application when events occur. Next step: have wh40k load the main menu and play sound, etc.
This commit is contained in:
192
internal/ui/interface.go
Normal file
192
internal/ui/interface.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"reflect" // For DeepEqual
|
||||
|
||||
"github.com/hajimehoshi/ebiten"
|
||||
|
||||
"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 {
|
||||
menu *assetstore.Menu
|
||||
static []*assetstore.Sprite // Static elements in the interface
|
||||
ticks int
|
||||
widgets []*Widget
|
||||
}
|
||||
|
||||
func NewInterface(menu *assetstore.Menu) (*Interface, error) {
|
||||
iface := &Interface{
|
||||
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 ...int) (*Widget, error) {
|
||||
for _, widget := range i.widgets {
|
||||
if reflect.DeepEqual(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 {
|
||||
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 {
|
||||
geo := i.scale(screen.Size())
|
||||
do := &ebiten.DrawImageOptions{GeoM: geo}
|
||||
|
||||
for _, sprite := range i.static {
|
||||
do.GeoM.Translate(geo.Apply(float64(sprite.XOffset), float64(sprite.YOffset)))
|
||||
if err := screen.DrawImage(sprite.Image, do); err != nil {
|
||||
return err
|
||||
}
|
||||
do.GeoM = geo
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Interface) addRecord(record *menus.Record) error {
|
||||
switch record.Type {
|
||||
case menus.TypeStatic: // These are static
|
||||
if sprite, err := i.menu.Sprite(record.SpriteId[0]); err != nil {
|
||||
return err
|
||||
} else {
|
||||
i.static = append(i.static, sprite)
|
||||
}
|
||||
case menus.TypeMenu: // These aren't drawable and can be ignored
|
||||
case menus.TypeOverlay, menus.TypeMainButton: // Widgets \o/
|
||||
if widget, err := i.widgetFromRecord(record); err != nil {
|
||||
return err
|
||||
} else {
|
||||
i.widgets = append(i.widgets, widget)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("ui.interface: encountered unknown menu record: %#+v", record)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
func (i *Interface) widgetFromRecord(record *menus.Record) (*Widget, error) {
|
||||
// FIXME: we assume that all widgets have a share sprite, but is that true?
|
||||
sprite, err := i.menu.Sprite(record.Share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var path []int
|
||||
for r := record; r != nil; r = r.Parent {
|
||||
path = append([]int{r.Id}, path...)
|
||||
}
|
||||
|
||||
widget := &Widget{
|
||||
Bounds: sprite.Rect,
|
||||
path: path,
|
||||
record: record,
|
||||
sprite: sprite,
|
||||
}
|
||||
|
||||
switch record.Type {
|
||||
case menus.TypeMainButton:
|
||||
hovers, err := i.menu.Images(record.SpriteId[0], record.DrawType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
widget.hoverAnimation = hovers
|
||||
|
||||
sprite, err := i.menu.Sprite(record.Share + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
widget.mouseButtonDownImage = sprite.Image
|
||||
}
|
||||
|
||||
return widget, nil
|
||||
}
|
84
internal/ui/widget.go
Normal file
84
internal/ui/widget.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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 // TODO: show the tooltip when hovering?
|
||||
|
||||
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()
|
||||
|
||||
// These are expected to have the same dimensions as the Bounds
|
||||
hoverAnimation []*ebiten.Image
|
||||
hoverState bool
|
||||
|
||||
// FIXME: We assume right mouse button isn't needed here
|
||||
// TODO: down, up, and click hooks.
|
||||
mouseButtonDownImage *ebiten.Image
|
||||
mouseButtonState bool
|
||||
|
||||
path []int
|
||||
record *menus.Record
|
||||
sprite *assetstore.Sprite
|
||||
}
|
||||
|
||||
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.hoverState && w.mouseButtonState {
|
||||
return w.mouseButtonDownImage, nil
|
||||
}
|
||||
|
||||
if w.hoverState && len(w.hoverAnimation) > 0 {
|
||||
return w.hoverAnimation[(aniStep)%len(w.hoverAnimation)], nil
|
||||
}
|
||||
|
||||
return w.sprite.Image, nil
|
||||
}
|
@@ -12,6 +12,11 @@ import (
|
||||
"github.com/hajimehoshi/ebiten/inpututil"
|
||||
)
|
||||
|
||||
type Game interface {
|
||||
Update(screenX, screenY int) error
|
||||
Draw(*ebiten.Image) error
|
||||
}
|
||||
|
||||
var (
|
||||
screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
|
||||
|
||||
@@ -26,9 +31,8 @@ type Window struct {
|
||||
KeyUpHandlers map[ebiten.Key]func()
|
||||
MouseWheelHandler func(float64, float64)
|
||||
|
||||
// User-provided update actions
|
||||
updateFn func() error
|
||||
drawFn func(*ebiten.Image) error
|
||||
// Allow the "game" to be switched out at any time
|
||||
game Game
|
||||
|
||||
debug bool
|
||||
firstRun bool
|
||||
@@ -37,7 +41,7 @@ type Window struct {
|
||||
// 0,0 is the *top left* of the window
|
||||
//
|
||||
// ebiten assumes a single window, so only call this once...
|
||||
func NewWindow(title string) (*Window, error) {
|
||||
func NewWindow(game Game, title string) (*Window, error) {
|
||||
ebiten.SetRunnableInBackground(true)
|
||||
|
||||
return &Window{
|
||||
@@ -45,6 +49,7 @@ func NewWindow(title string) (*Window, error) {
|
||||
KeyUpHandlers: make(map[ebiten.Key]func()),
|
||||
debug: true,
|
||||
firstRun: true,
|
||||
game: game,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -57,13 +62,12 @@ func (w *Window) OnMouseWheel(f func(x, y float64)) {
|
||||
w.MouseWheelHandler = f
|
||||
}
|
||||
|
||||
func (w *Window) run(screen *ebiten.Image) error {
|
||||
if w.firstRun {
|
||||
ebiten.SetScreenScale(*screenScale)
|
||||
w.firstRun = false
|
||||
}
|
||||
func (w *Window) Layout(_, _ int) (int, int) {
|
||||
return *winX, *winY
|
||||
}
|
||||
|
||||
if err := w.updateFn(); err != nil {
|
||||
func (w *Window) Update(screen *ebiten.Image) error {
|
||||
if err := w.game.Update(screen.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,16 +87,18 @@ func (w *Window) run(screen *ebiten.Image) error {
|
||||
}
|
||||
}
|
||||
|
||||
if !ebiten.IsDrawingSkipped() {
|
||||
if err := w.drawFn(screen); err != nil {
|
||||
return err
|
||||
}
|
||||
if ebiten.IsDrawingSkipped() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if w.debug {
|
||||
// Draw FPS, etc, to the screen
|
||||
msg := fmt.Sprintf("tps=%0.2f fps=%0.2f", ebiten.CurrentTPS(), ebiten.CurrentFPS())
|
||||
ebitenutil.DebugPrint(screen, msg)
|
||||
}
|
||||
if err := w.game.Draw(screen); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w.debug {
|
||||
// Draw FPS, etc, to the screen
|
||||
msg := fmt.Sprintf("tps=%0.2f fps=%0.2f", ebiten.CurrentTPS(), ebiten.CurrentFPS())
|
||||
ebitenutil.DebugPrint(screen, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -101,10 +107,7 @@ func (w *Window) run(screen *ebiten.Image) error {
|
||||
// TODO: a stop or other cancellation mechanism
|
||||
//
|
||||
// Note that this must be called on the main OS thread
|
||||
func (w *Window) Run(updateFn func() error, drawFn func(*ebiten.Image) error) error {
|
||||
w.updateFn = updateFn
|
||||
w.drawFn = drawFn
|
||||
|
||||
func (w *Window) Run() error {
|
||||
if *cpuprofile != "" {
|
||||
f, err := os.Create(*cpuprofile)
|
||||
if err != nil {
|
||||
@@ -117,5 +120,7 @@ func (w *Window) Run(updateFn func() error, drawFn func(*ebiten.Image) error) er
|
||||
defer pprof.StopCPUProfile()
|
||||
}
|
||||
|
||||
return ebiten.Run(w.run, *winX, *winY, 1, w.Title) // Native game resolution: 640x480
|
||||
ebiten.SetWindowSize(int(float64(*winX)**screenScale), int(float64(*winY)**screenScale))
|
||||
ebiten.SetWindowTitle(w.Title)
|
||||
return ebiten.RunGame(w) // Native game resolution: 640x480
|
||||
}
|
||||
|
Reference in New Issue
Block a user