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:
2020-03-22 02:58:52 +00:00
parent bcaf3d9b58
commit bfe9fbdf7d
12 changed files with 440 additions and 194 deletions

View File

@@ -33,6 +33,7 @@ type AssetStore struct {
// These members are used to store things we've already loaded
maps map[string]*Map
menus map[string]*Menu
objs map[string]*Object
sets map[string]*Set
sounds map[string]*Sound
@@ -81,6 +82,7 @@ func (a *AssetStore) Refresh() error {
// Refresh
a.entries = newEntryMap
a.maps = make(map[string]*Map)
a.menus = make(map[string]*Menu)
a.objs = make(map[string]*Object)
a.sets = make(map[string]*Set)
a.sounds = make(map[string]*Sound)
@@ -89,7 +91,12 @@ func (a *AssetStore) Refresh() error {
}
func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
filename := canonical(name + "." + ext)
var filename string
if ext != "" {
filename = canonical(name + "." + ext)
} else {
filename = canonical(name)
}
for _, dir := range dirs {
dir = canonical(dir)

View File

@@ -0,0 +1,81 @@
package assetstore
import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/menus"
)
type Menu struct {
assets *AssetStore
obj *Object // TODO: handle multiple objects in the menu
raw *menus.Menu
Name string
}
// FIXME: don't expose this
func (m *Menu) Records() []*menus.Record {
return m.raw.Records
}
func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) {
out := make([]*ebiten.Image, count)
for i := start; i < start+count; i++ {
sprite, err := m.Sprite(i)
if err != nil {
return nil, err
}
out[i-start] = sprite.Image
}
return out, nil
}
func (m *Menu) Sprite(idx int) (*Sprite, error) {
return m.obj.Sprite(idx)
}
func (a *AssetStore) Menu(name string) (*Menu, error) {
name = canonical(name)
if menu, ok := a.menus[name]; ok {
return menu, nil
}
filename, err := a.lookup(name, "mnu", "Menu")
if err != nil {
return nil, err
}
raw, err := menus.LoadMenu(filename)
if err != nil {
return nil, err
}
obj, err := a.loadMenuObject(raw) // TODO: multiple objects
if err != nil {
return nil, err
}
menu := &Menu{
assets: a,
obj: obj,
raw: raw,
Name: name,
}
a.menus[name] = menu
return menu, nil
}
func (a *AssetStore) loadMenuObject(menu *menus.Menu) (*Object, error) {
filename := menu.ObjectFiles[0]
filename, err := a.lookup(filename, "", "Menu") // Extension already present
if err != nil {
return nil, err
}
return a.ObjectByPath(filename)
}

View File

@@ -170,29 +170,6 @@ func (r *Record) Toplevel() *Record {
return r
}
func (r *Record) SelectSprite(step int, pressed, focused bool) int {
switch r.Type {
case TypeStatic:
return r.SpriteId[0]
case TypeMenu:
return r.SpriteId[0] // Probably -1
case TypeOverlay:
return r.Share
case TypeMainButton:
// A main button has 4 states: unfocused, focused (animated), mousedown, disabled
if focused && pressed {
return r.Share + 1
} else if focused {
return r.SpriteId[0] + (step % r.DrawType)
}
// TODO: disabled
return r.Share
}
return -1
}
func setProperty(r *Record, k, v string) {
vSplit := strings.Split(v, ",")
vInt, _ := strconv.Atoi(v)

192
internal/ui/interface.go Normal file
View 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
View 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
}

View File

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

View File

@@ -27,5 +27,7 @@ func Run(configFile string) error {
wh40k.PlaySkippableVideo("LOGOS")
wh40k.PlaySkippableVideo("movie1")
// TODO: load main interface
return nil
}