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:
@@ -69,7 +69,7 @@ func main() {
|
|||||||
lastState: state,
|
lastState: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
win, err := ui.NewWindow("View Map " + *gameMap)
|
win, err := ui.NewWindow(env, "View Map "+*gameMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Couldn't create window: %v", err)
|
log.Fatal("Couldn't create window: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,12 +86,12 @@ func main() {
|
|||||||
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i+1))
|
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i+1))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := win.Run(env.Update, env.Draw); err != nil {
|
if err := win.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) Update() error {
|
func (e *env) Update(screenX, screenY int) error {
|
||||||
if e.step == 0 || e.lastState != e.state {
|
if e.step == 0 || e.lastState != e.state {
|
||||||
log.Printf("zoom=%.2f zIdx=%v camPos=%#v", e.state.zoom, e.state.zIdx, e.state.origin)
|
log.Printf("zoom=%.2f zIdx=%v camPos=%#v", e.state.zoom, e.state.zIdx, e.state.origin)
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"image"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"code.ur.gs/lupine/ordoor/internal/assetstore"
|
"code.ur.gs/lupine/ordoor/internal/assetstore"
|
||||||
"code.ur.gs/lupine/ordoor/internal/data"
|
|
||||||
"code.ur.gs/lupine/ordoor/internal/menus"
|
|
||||||
"code.ur.gs/lupine/ordoor/internal/ui"
|
"code.ur.gs/lupine/ordoor/internal/ui"
|
||||||
"github.com/hajimehoshi/ebiten"
|
"github.com/hajimehoshi/ebiten"
|
||||||
"github.com/hajimehoshi/ebiten/audio"
|
"github.com/hajimehoshi/ebiten/audio"
|
||||||
@@ -17,12 +13,11 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
|
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
|
||||||
menuFile = flag.String("menu", "", "Name of a menu, e.g. Main")
|
menuName = flag.String("menu", "", "Name of a menu, e.g. Main")
|
||||||
)
|
)
|
||||||
|
|
||||||
type env struct {
|
type env struct {
|
||||||
menu *menus.Menu
|
ui *ui.Interface
|
||||||
objects []*assetstore.Object
|
|
||||||
|
|
||||||
// fonts []*assetstore.Font
|
// fonts []*assetstore.Font
|
||||||
// fontObjs []*assetstore.Object
|
// fontObjs []*assetstore.Object
|
||||||
@@ -32,15 +27,12 @@ type env struct {
|
|||||||
lastState state
|
lastState state
|
||||||
}
|
}
|
||||||
|
|
||||||
type state struct {
|
type state struct{}
|
||||||
// Redraw the window if these change
|
|
||||||
winBounds image.Rectangle
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *gamePath == "" || *menuFile == "" {
|
if *gamePath == "" || *menuName == "" {
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -50,11 +42,12 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
menu, err := menus.LoadMenu(*menuFile)
|
menu, err := assets.Menu(*menuName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Couldn't load menu file %s: %v", *menuFile, err)
|
log.Fatalf("Couldn't load menu %s: %v", *menuName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TODO: move i18n, fonts into assetstore
|
||||||
if i18n, err := data.LoadI18n(filepath.Join(*gamePath, "Data", data.I18nFile)); err != nil {
|
if i18n, err := data.LoadI18n(filepath.Join(*gamePath, "Data", data.I18nFile)); err != nil {
|
||||||
log.Printf("Failed to load i18n data, skipping internationalization: %v", err)
|
log.Printf("Failed to load i18n data, skipping internationalization: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -65,15 +58,22 @@ func main() {
|
|||||||
// if err != nil {
|
// if err != nil {
|
||||||
// log.Fatalf("Failed to load font: %v", err)
|
// log.Fatalf("Failed to load font: %v", err)
|
||||||
// }
|
// }
|
||||||
|
*/
|
||||||
|
|
||||||
var menuObjs []*assetstore.Object
|
iface, err := ui.NewInterface(menu)
|
||||||
for _, filename := range menu.ObjectFiles {
|
if err != nil {
|
||||||
obj, err := assets.ObjectByPath(filepath.Join(*gamePath, "Menu", filename))
|
log.Fatalf("Couldn't initialize interface: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if menu.Name == "main" {
|
||||||
|
log.Printf("Installing a click handler!")
|
||||||
|
widget, err := iface.Widget(2, 5) // Menu 2, submenu 5
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load %v: %v", filename, err)
|
log.Fatalf("Couldn't find widget 2,5: %v", err)
|
||||||
|
}
|
||||||
|
widget.OnMouseClick = func() {
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
menuObjs = append(menuObjs, obj)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yay sound
|
// Yay sound
|
||||||
@@ -92,132 +92,30 @@ func main() {
|
|||||||
|
|
||||||
state := state{}
|
state := state{}
|
||||||
env := &env{
|
env := &env{
|
||||||
menu: menu,
|
ui: iface,
|
||||||
objects: menuObjs,
|
//objects: menuObjs,
|
||||||
// fonts: loadedFonts,
|
// fonts: loadedFonts,
|
||||||
state: state,
|
state: state,
|
||||||
lastState: state,
|
lastState: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
win, err := ui.NewWindow("View Menu: " + *menuFile)
|
win, err := ui.NewWindow(env, "View Menu: "+*menuName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Couldn't create window: %v", err)
|
log.Fatal("Couldn't create window: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := win.Run(env.Update, env.Draw); err != nil {
|
if err := win.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) Update() error {
|
func (e *env) Update(screenX, screenY int) error {
|
||||||
// No behaviour yet
|
|
||||||
|
|
||||||
e.step += 1
|
e.step += 1
|
||||||
e.lastState = e.state
|
e.lastState = e.state
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
return e.ui.Update(screenX, screenY)
|
||||||
origX = 640.0
|
}
|
||||||
origY = 480.0
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *env) Draw(screen *ebiten.Image) error {
|
func (e *env) Draw(screen *ebiten.Image) error {
|
||||||
// The menus expect to be drawn to a 640x480 screen. We need to scale and
|
return e.ui.Draw(screen)
|
||||||
// project that so it fills the window appropriately. This is a combination
|
|
||||||
// of translate + zoom
|
|
||||||
winSize := screen.Bounds().Max
|
|
||||||
scaleX := float64(winSize.X) / float64(origX)
|
|
||||||
scaleY := float64(winSize.Y) / float64(origY)
|
|
||||||
|
|
||||||
cam := ebiten.GeoM{}
|
|
||||||
cam.Scale(scaleX, scaleY)
|
|
||||||
|
|
||||||
for _, record := range e.menu.Records {
|
|
||||||
if err := e.drawRecordRecursive(record, screen, cam); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) drawRecordRecursive(record *menus.Record, screen *ebiten.Image, geo ebiten.GeoM) error {
|
|
||||||
if err := e.drawRecord(record, screen, geo); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw all children of this record
|
|
||||||
for _, child := range record.Children {
|
|
||||||
if err := e.drawRecordRecursive(child, screen, geo); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the record has a "share" type, we can work out whether it's
|
|
||||||
func (e *env) isFocused(record *menus.Record, geo ebiten.GeoM) bool {
|
|
||||||
if record.Share < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
sprite, err := e.objects[0].Sprite(record.Share) // FIXME: need to handle multiple objects
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
invGeo := geo
|
|
||||||
invGeo.Invert()
|
|
||||||
|
|
||||||
cX, cY := ebiten.CursorPosition()
|
|
||||||
cursorX, cursorY := invGeo.Apply(float64(cX), float64(cY)) // Undo screen scaling
|
|
||||||
cursorPoint := image.Pt(int(cursorX), int(cursorY))
|
|
||||||
|
|
||||||
return cursorPoint.In(sprite.Rect)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) drawRecord(record *menus.Record, screen *ebiten.Image, geo ebiten.GeoM) error {
|
|
||||||
// Draw this record if it's valid to do so. FIXME: lots to learn
|
|
||||||
|
|
||||||
spriteId := record.SelectSprite(
|
|
||||||
e.step/2,
|
|
||||||
ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft),
|
|
||||||
e.isFocused(record, geo),
|
|
||||||
)
|
|
||||||
|
|
||||||
if spriteId < 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// X-CORD and Y-CORD are universally either 0 or -1, so ignore here.
|
|
||||||
// TODO: maybe 0 overrides in-sprite offset (set below)?
|
|
||||||
|
|
||||||
// FIXME: Need to handle multiple objects
|
|
||||||
obj := e.objects[0]
|
|
||||||
sprite, err := obj.Sprite(spriteId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account for scaling, draw sprite at its specified offset
|
|
||||||
x, y := geo.Apply(float64(sprite.XOffset), float64(sprite.YOffset))
|
|
||||||
|
|
||||||
// log.Printf(
|
|
||||||
// "Drawing id=%v type=%v spriteid=%v x=%v(+%v) y=%v(%+v) desc=%q parent=%p",
|
|
||||||
// record.Id, record.Type, spriteId, record.X, record.Y, sprite.XOffset, sprite.YOffset, record.Desc, record.Parent,
|
|
||||||
// )
|
|
||||||
|
|
||||||
geo.Translate(x, y)
|
|
||||||
|
|
||||||
screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: geo})
|
|
||||||
|
|
||||||
// FIXME: we probably shouldn't draw everything?
|
|
||||||
// FIXME: handle multiple fonts
|
|
||||||
// if len(e.fonts) > 0 && record.Desc != "" {
|
|
||||||
// e.fonts[0].Output(screen, origOffset, record.Desc)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
@@ -71,7 +71,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
env := &env{gameMap: gameMap, set: mapSet, state: state, lastState: state}
|
env := &env{gameMap: gameMap, set: mapSet, state: state, lastState: state}
|
||||||
|
|
||||||
win, err := ui.NewWindow("View Map " + *mapFile)
|
win, err := ui.NewWindow(env, "View Map "+*mapFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Couldn't create window: %v", err)
|
log.Fatal("Couldn't create window: %v", err)
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func main() {
|
|||||||
|
|
||||||
win.OnMouseWheel(env.changeZoom)
|
win.OnMouseWheel(env.changeZoom)
|
||||||
|
|
||||||
if err := win.Run(env.Update, env.Draw); err != nil {
|
if err := win.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func (e *env) changeZoom(_, y float64) {
|
|||||||
e.state.zoom *= math.Pow(1.2, y)
|
e.state.zoom *= math.Pow(1.2, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) Update() error {
|
func (e *env) Update(screenX, screenY int) error {
|
||||||
// TODO: show details of clicked-on cell in terminal
|
// TODO: show details of clicked-on cell in terminal
|
||||||
|
|
||||||
// Automatically cycle every 500ms when auto-update is on
|
// Automatically cycle every 500ms when auto-update is on
|
||||||
|
@@ -68,7 +68,7 @@ func main() {
|
|||||||
lastState: state,
|
lastState: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
win, err := ui.NewWindow("View Object: " + *objName)
|
win, err := ui.NewWindow(env, "View Object: "+*objName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -82,12 +82,12 @@ func main() {
|
|||||||
win.OnMouseWheel(env.changeZoom)
|
win.OnMouseWheel(env.changeZoom)
|
||||||
|
|
||||||
// The main thread now belongs to ebiten
|
// The main thread now belongs to ebiten
|
||||||
if err := win.Run(env.Update, env.Draw); err != nil {
|
if err := win.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) Update() error {
|
func (e *env) Update(screenX, screenY int) error {
|
||||||
if e.step == 0 || e.lastState != e.state {
|
if e.step == 0 || e.lastState != e.state {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"new state: sprite=%d/%d zoom=%.2f, origin=%+v",
|
"new state: sprite=%d/%d zoom=%.2f, origin=%+v",
|
||||||
|
@@ -51,11 +51,6 @@ func main() {
|
|||||||
log.Fatalf("Couldn't load set %s: %v", *setName, err)
|
log.Fatalf("Couldn't load set %s: %v", *setName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
win, err := ui.NewWindow("View Set: " + *setName)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Couldn't create window: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state := state{zoom: 8.0}
|
state := state{zoom: 8.0}
|
||||||
env := &env{
|
env := &env{
|
||||||
set: set,
|
set: set,
|
||||||
@@ -63,6 +58,11 @@ func main() {
|
|||||||
lastState: state,
|
lastState: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
win, err := ui.NewWindow(env, "View Set: "+*setName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Couldn't create window: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
win.OnKeyUp(ebiten.KeyLeft, env.changeObjIdx(-1))
|
win.OnKeyUp(ebiten.KeyLeft, env.changeObjIdx(-1))
|
||||||
win.OnKeyUp(ebiten.KeyRight, env.changeObjIdx(+1))
|
win.OnKeyUp(ebiten.KeyRight, env.changeObjIdx(+1))
|
||||||
|
|
||||||
@@ -72,12 +72,12 @@ func main() {
|
|||||||
win.OnMouseWheel(env.changeZoom)
|
win.OnMouseWheel(env.changeZoom)
|
||||||
|
|
||||||
// Main thread now belongs to ebiten
|
// Main thread now belongs to ebiten
|
||||||
if err := win.Run(env.Update, env.Draw); err != nil {
|
if err := win.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) Update() error {
|
func (e *env) Update(screenX, screenY int) error {
|
||||||
curObj, err := e.curObject()
|
curObj, err := e.curObject()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@@ -33,6 +33,7 @@ type AssetStore struct {
|
|||||||
|
|
||||||
// These members are used to store things we've already loaded
|
// These members are used to store things we've already loaded
|
||||||
maps map[string]*Map
|
maps map[string]*Map
|
||||||
|
menus map[string]*Menu
|
||||||
objs map[string]*Object
|
objs map[string]*Object
|
||||||
sets map[string]*Set
|
sets map[string]*Set
|
||||||
sounds map[string]*Sound
|
sounds map[string]*Sound
|
||||||
@@ -81,6 +82,7 @@ func (a *AssetStore) Refresh() error {
|
|||||||
// Refresh
|
// Refresh
|
||||||
a.entries = newEntryMap
|
a.entries = newEntryMap
|
||||||
a.maps = make(map[string]*Map)
|
a.maps = make(map[string]*Map)
|
||||||
|
a.menus = make(map[string]*Menu)
|
||||||
a.objs = make(map[string]*Object)
|
a.objs = make(map[string]*Object)
|
||||||
a.sets = make(map[string]*Set)
|
a.sets = make(map[string]*Set)
|
||||||
a.sounds = make(map[string]*Sound)
|
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) {
|
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 {
|
for _, dir := range dirs {
|
||||||
dir = canonical(dir)
|
dir = canonical(dir)
|
||||||
|
81
internal/assetstore/menu.go
Normal file
81
internal/assetstore/menu.go
Normal 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)
|
||||||
|
}
|
@@ -170,29 +170,6 @@ func (r *Record) Toplevel() *Record {
|
|||||||
return r
|
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) {
|
func setProperty(r *Record, k, v string) {
|
||||||
vSplit := strings.Split(v, ",")
|
vSplit := strings.Split(v, ",")
|
||||||
vInt, _ := strconv.Atoi(v)
|
vInt, _ := strconv.Atoi(v)
|
||||||
|
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"
|
"github.com/hajimehoshi/ebiten/inpututil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Game interface {
|
||||||
|
Update(screenX, screenY int) error
|
||||||
|
Draw(*ebiten.Image) error
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
|
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()
|
KeyUpHandlers map[ebiten.Key]func()
|
||||||
MouseWheelHandler func(float64, float64)
|
MouseWheelHandler func(float64, float64)
|
||||||
|
|
||||||
// User-provided update actions
|
// Allow the "game" to be switched out at any time
|
||||||
updateFn func() error
|
game Game
|
||||||
drawFn func(*ebiten.Image) error
|
|
||||||
|
|
||||||
debug bool
|
debug bool
|
||||||
firstRun bool
|
firstRun bool
|
||||||
@@ -37,7 +41,7 @@ type Window struct {
|
|||||||
// 0,0 is the *top left* of the window
|
// 0,0 is the *top left* of the window
|
||||||
//
|
//
|
||||||
// ebiten assumes a single window, so only call this once...
|
// 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)
|
ebiten.SetRunnableInBackground(true)
|
||||||
|
|
||||||
return &Window{
|
return &Window{
|
||||||
@@ -45,6 +49,7 @@ func NewWindow(title string) (*Window, error) {
|
|||||||
KeyUpHandlers: make(map[ebiten.Key]func()),
|
KeyUpHandlers: make(map[ebiten.Key]func()),
|
||||||
debug: true,
|
debug: true,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
game: game,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +62,12 @@ func (w *Window) OnMouseWheel(f func(x, y float64)) {
|
|||||||
w.MouseWheelHandler = f
|
w.MouseWheelHandler = f
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Window) run(screen *ebiten.Image) error {
|
func (w *Window) Layout(_, _ int) (int, int) {
|
||||||
if w.firstRun {
|
return *winX, *winY
|
||||||
ebiten.SetScreenScale(*screenScale)
|
}
|
||||||
w.firstRun = false
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,16 +87,18 @@ func (w *Window) run(screen *ebiten.Image) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ebiten.IsDrawingSkipped() {
|
if ebiten.IsDrawingSkipped() {
|
||||||
if err := w.drawFn(screen); err != nil {
|
return nil
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if w.debug {
|
if err := w.game.Draw(screen); err != nil {
|
||||||
// Draw FPS, etc, to the screen
|
return err
|
||||||
msg := fmt.Sprintf("tps=%0.2f fps=%0.2f", ebiten.CurrentTPS(), ebiten.CurrentFPS())
|
}
|
||||||
ebitenutil.DebugPrint(screen, msg)
|
|
||||||
}
|
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
|
return nil
|
||||||
@@ -101,10 +107,7 @@ func (w *Window) run(screen *ebiten.Image) error {
|
|||||||
// TODO: a stop or other cancellation mechanism
|
// TODO: a stop or other cancellation mechanism
|
||||||
//
|
//
|
||||||
// Note that this must be called on the main OS thread
|
// Note that this must be called on the main OS thread
|
||||||
func (w *Window) Run(updateFn func() error, drawFn func(*ebiten.Image) error) error {
|
func (w *Window) Run() error {
|
||||||
w.updateFn = updateFn
|
|
||||||
w.drawFn = drawFn
|
|
||||||
|
|
||||||
if *cpuprofile != "" {
|
if *cpuprofile != "" {
|
||||||
f, err := os.Create(*cpuprofile)
|
f, err := os.Create(*cpuprofile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,5 +120,7 @@ func (w *Window) Run(updateFn func() error, drawFn func(*ebiten.Image) error) er
|
|||||||
defer pprof.StopCPUProfile()
|
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
|
||||||
}
|
}
|
||||||
|
@@ -27,5 +27,7 @@ func Run(configFile string) error {
|
|||||||
wh40k.PlaySkippableVideo("LOGOS")
|
wh40k.PlaySkippableVideo("LOGOS")
|
||||||
wh40k.PlaySkippableVideo("movie1")
|
wh40k.PlaySkippableVideo("movie1")
|
||||||
|
|
||||||
|
// TODO: load main interface
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user