Implement the options menu, part 1

This commit implements loading and saving options from/to config, and
enough UI toolkit magic to allow changes to boolean options to be
persisted.

We now respect the "play movies" setting and take screen resolution
from the config file.
This commit is contained in:
2020-03-22 22:12:59 +00:00
parent 0adbfaa573
commit 971b3178d6
9 changed files with 481 additions and 96 deletions

View File

@@ -1,7 +1,19 @@
[ordoor]
data_dir = "./orig"
video_player = [
"mpv",
"--no-config", "--keep-open=no", "--force-window=no", "--no-border",
"--no-osc", "--fullscreen", "--no-input-default-bindings"
]
data_dir = "./orig"
video_player = ["mpv", "--no-config", "--keep-open=no", "--force-window=no", "--no-border", "--no-osc", "--fullscreen", "--no-input-default-bindings"]
[options]
play_movies = true
animations = true
play_music = true
combat_voices = true
show_grid = false
show_paths = false
point_saving = false
auto_cut_level = false
x_resolution = 1280
y_resolution = 1024
music_volume = 100
sfx_volume = 100
unit_speed = 100
animation_speed = 100

View File

@@ -245,6 +245,21 @@ observed, suggesting structure. For instance, we have `24`, `240`, `241` and
`2410`, but not `2411` or `2409`. Sometimes we have a comma-separated list,
e.g.: `400,30,-1,5`.
A listing of currently-known values:
| Value | Type |
| ----- | ---------------- |
| 0 | Static image |
| 1 | Menu |
| 3 | Button |
| 50 | Invoke? Button? |
| 61 | "Overlay" |
| 70 | "Hypertext" |
| 91 | Checkbox |
| 220 | Animation sample |
| 228 | Main menu button |
| 232 | Slider |
### `ACTIVE`
There are only 4 values seen across all menus: `0`, `1`, `1,0`, `102` and `1,1`.

View File

@@ -1,6 +1,7 @@
package config
import (
"os"
"path/filepath"
"github.com/BurntSushi/toml"
@@ -11,8 +12,32 @@ type Ordoor struct {
VideoPlayer []string `toml:"video_player"`
}
// Things set
type Options struct {
PlayMovies bool `toml:"play_movies"`
Animations bool `toml:"animations"`
PlayMusic bool `toml:"play_music"`
CombatVoices bool `toml:"combat_voices"`
ShowGrid bool `toml:"show_grid"`
ShowPaths bool `toml:"show_paths"`
PointSaving bool `toml:"point_saving"`
AutoCutLevel bool `toml:"auto_cut_level"`
XRes int `toml:"x_resolution"`
YRes int `toml:"y_resolution"`
MusicVolume int `toml:"music_volume"`
SFXVolume int `toml:"sfx_volume"`
UnitSpeed int `toml:"unit_speed"`
AnimSpeed int `toml:"animation_speed"`
}
type Config struct {
filename string `toml:"-"`
Ordoor `toml:"ordoor"`
Options `toml:"options"`
}
func Load(filename string) (*Config, error) {
@@ -23,9 +48,21 @@ func Load(filename string) (*Config, error) {
return nil, err
}
out.filename = filename
return &out, err
}
func (c *Config) Save() error {
f, err := os.OpenFile(c.filename, os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(c)
}
// TODO: case-insensitive lookup
func (c *Config) DataFile(path string) string {
return filepath.Join(c.DataDir, path)

View File

@@ -10,11 +10,19 @@ import (
"code.ur.gs/lupine/ordoor/internal/util/asciiscan"
)
type MenuType int
const (
TypeStatic = 0
TypeMenu = 1
TypeOverlay = 61
TypeMainButton = 228
TypeStatic MenuType = 0
TypeMenu MenuType = 1
TypeButton MenuType = 3
TypeInvokeButton MenuType = 50
TypeOverlay MenuType = 61
TypeHypertext MenuType = 70
TypeCheckbox MenuType = 91
TypeAnimationSample MenuType = 220
TypeMainButton MenuType = 228
TypeSlider MenuType = 232
)
type Record struct {
@@ -22,7 +30,7 @@ type Record struct {
Children []*Record
Id int
Type int
Type MenuType
DrawType int
FontType int
Active bool
@@ -183,7 +191,7 @@ func setProperty(r *Record, k, v string) {
case "MENUID", "SUBMENUID":
r.Id = vInt
case "MENUTYPE", "SUBMENUTYPE":
r.Type = vInt
r.Type = MenuType(vInt)
case "ACTIVE":
r.Active = (vInt != 0)
case "SPRITEID":

View File

@@ -2,15 +2,138 @@ package ordoor
import (
"fmt"
"log"
"code.ur.gs/lupine/ordoor/internal/ui"
)
func try(result error, into *error) {
if *into == nil {
*into = result
}
}
// These are UI interfaces covering the game entrypoint
func (o *Ordoor) ifaceMain() (*ui.Interface, error) {
// TODO: Start in the "main" menu
menu, err := o.assets.Menu("Main")
// Start in the "main" menu
main, err := o.buildInterface("main")
if err != nil {
return nil, err
}
options, err := o.ifaceOptions(main)
if err != nil {
return nil, err
}
// TODO: clicking these buttons should load other interfaces
try(wireupClick(main, func() {}, 2, 1), &err) // New game
try(wireupClick(main, func() {}, 2, 2), &err) // Load game
try(disableWidget(main, 2, 3), &err) // Multiplayer - disable for now
try(wireupClick(main, func() { o.iface = options }, 2, 4), &err) // Options
try(wireupClick(main, func() { o.nextState = StateExit }, 2, 5), &err) // Quit
return main, err
}
// Options needs to know how to go back to main
func (o *Ordoor) ifaceOptions(main *ui.Interface) (*ui.Interface, error) {
options, err := o.buildInterface("options")
if err != nil {
return nil, err
}
if err := o.configIntoOptions(options); err != nil {
return nil, err
}
// TODO: load current options state into UI
try(wireupClick(options, func() {}, 2, 8), &err) // Keyboard settings button
// Resolution slider is 2,9
// Music volume slider is 2,10
// Sound FX volume slider is 2,11
// Accept button
try(wireupClick(
options, func() {
if err := o.optionsIntoConfig(options); err != nil {
// FIXME: exiting is a bit OTT. Perhaps display "save failed"?
log.Printf("Saving options to config failed: %v", err)
o.nextState = StateExit
} else {
o.iface = main
}
},
2, 12,
), &err)
// 13...23 are "hypertext"
// Cancel button
try(
wireupClick(
options,
func() {
// FIXME: again, exiting is OTT. We're just resetting the state of
// the interface to the values in config.
if err := o.configIntoOptions(options); err != nil {
log.Printf("Saving options to config failed: %v", err)
o.nextState = StateExit
} else {
o.iface = main
}
},
2, 24,
), &err)
// Unit speed slider is 2,26
// Looping effect speed slider is 2,27
// Sample of unit speed animation is 2,28
// Sample of effect speed animation is 2,29
// 30...35 are "hypertext"
return options, err
}
func (o *Ordoor) configIntoOptions(options *ui.Interface) error {
cfg := &o.config.Options
var err error
try(setWidgetValueBool(options, cfg.PlayMovies, 2, 1), &err)
try(setWidgetValueBool(options, cfg.Animations, 2, 2), &err)
try(setWidgetValueBool(options, cfg.PlayMusic, 2, 3), &err)
try(setWidgetValueBool(options, cfg.CombatVoices, 2, 4), &err)
try(setWidgetValueBool(options, cfg.ShowGrid, 2, 5), &err)
try(setWidgetValueBool(options, cfg.ShowPaths, 2, 6), &err)
try(setWidgetValueBool(options, cfg.PointSaving, 2, 7), &err)
try(setWidgetValueBool(options, cfg.AutoCutLevel, 2, 25), &err)
return err
}
func (o *Ordoor) optionsIntoConfig(options *ui.Interface) error {
cfg := &o.config.Options
var err error
try(getWidgetValueBool(options, &cfg.PlayMovies, 2, 1), &err)
try(getWidgetValueBool(options, &cfg.Animations, 2, 2), &err)
try(getWidgetValueBool(options, &cfg.PlayMusic, 2, 3), &err)
try(getWidgetValueBool(options, &cfg.CombatVoices, 2, 4), &err)
try(getWidgetValueBool(options, &cfg.ShowGrid, 2, 5), &err)
try(getWidgetValueBool(options, &cfg.ShowPaths, 2, 6), &err)
try(getWidgetValueBool(options, &cfg.PointSaving, 2, 7), &err)
try(getWidgetValueBool(options, &cfg.AutoCutLevel, 2, 25), &err)
if err != nil {
return err
}
return o.config.Save()
}
func (o *Ordoor) buildInterface(name string) (*ui.Interface, error) {
menu, err := o.assets.Menu(name)
if err != nil {
return nil, err
}
@@ -20,33 +143,79 @@ func (o *Ordoor) ifaceMain() (*ui.Interface, error) {
return nil, err
}
// TODO: clicking these buttons should load other interfaces
wireupClick(iface, func() {}, 2, 1) // New game
wireupClick(iface, func() {}, 2, 2) // Load game
disableWidget(iface, 2, 3) // Multiplayer. Disable for now.
wireupClick(iface, func() {}, 2, 4) // Options
wireupClick(iface, func() { o.nextState = StateExit }, 2, 5) // Quit
return iface, nil
}
func findWidgetOrPanic(iface *ui.Interface, spec ...int) *ui.Widget {
func findWidget(iface *ui.Interface, spec ...int) (*ui.Widget, error) {
widget, err := iface.Widget(spec...)
if err != nil {
panic(fmt.Sprintf("Couldn't find widget %v:%+v", iface.Name, spec))
return nil, fmt.Errorf("Couldn't find widget %v:%+v", iface.Name, spec)
}
return widget
return widget, nil
}
func wireupClick(iface *ui.Interface, f func(), spec ...int) {
findWidgetOrPanic(iface, spec...).OnMouseClick = f
func getWidgetValue(iface *ui.Interface, spec ...int) (string, error) {
widget, err := findWidget(iface, spec...)
if err != nil {
return "", err
}
return widget.Value, nil
}
func disableWidget(iface *ui.Interface, spec ...int) {
findWidgetOrPanic(iface, spec...).Disable()
func setWidgetValue(iface *ui.Interface, value string, spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
widget.Value = value
return nil
}
func (o *Ordoor) ifaceOptions() (*ui.Interface, error) {
return nil, nil
func getWidgetValueBool(iface *ui.Interface, into *bool, spec ...int) error {
vStr, err := getWidgetValue(iface, spec...)
if err != nil {
return err
}
*into = vStr == "1"
return nil
}
func setWidgetValueBool(iface *ui.Interface, value bool, spec ...int) error {
vStr := "0"
if value {
vStr = "1"
}
return setWidgetValue(iface, vStr, spec...)
}
func wireupClick(iface *ui.Interface, f func(), spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
if widget.OnMouseClick != nil {
return fmt.Errorf("Widget %#+v already has an OnMouseClick handler", widget)
}
widget.OnMouseClick = f
return nil
}
func disableWidget(iface *ui.Interface, spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
widget.Disable()
return nil
}

View File

@@ -64,7 +64,7 @@ func Run(configFile string) error {
nextState: StateInterface,
}
win, err := ui.NewWindow(ordoor, "Ordoor")
win, err := ui.NewWindow(ordoor, "Ordoor", cfg.Options.XRes, cfg.Options.YRes)
if err != nil {
return fmt.Errorf("Failed to create window: %v", err)
}
@@ -79,12 +79,14 @@ func Run(configFile string) error {
}
func (o *Ordoor) Run() error {
// On startup, play these two videos
o.PlaySkippableVideo("LOGOS")
if o.config.Options.PlayMovies {
o.PlayUnskippableVideo("LOGOS")
o.PlaySkippableVideo("movie1")
}
err := o.win.Run()
if err == errExit {
log.Printf("Exit requested")
return nil
}

View File

@@ -3,6 +3,7 @@ package ui
import (
"fmt"
"image"
"log"
"reflect" // For DeepEqual
"github.com/hajimehoshi/ebiten"
@@ -30,7 +31,6 @@ type Interface struct {
func NewInterface(menu *assetstore.Menu) (*Interface, error) {
iface := &Interface{
Name: menu.Name,
menu: menu,
}
@@ -112,23 +112,17 @@ func (i *Interface) Draw(screen *ebiten.Image) error {
}
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)
log.Printf("Adding record: %#+v", record)
handler, ok := setupHandlers[record.Type]
if !ok {
return fmt.Errorf("ui.interface: encountered unknown menu record: %#+v", record)
}
default:
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
@@ -166,47 +160,3 @@ func (i *Interface) getMousePos(w, h int) image.Point {
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,
Tooltip: record.Desc,
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
pressed, err := i.menu.Sprite(record.Share + 1)
if err != nil {
return nil, err
}
widget.mouseButtonDownImage = pressed.Image
disabled, err := i.menu.Sprite(record.Share + 2)
if err != nil {
return nil, err
}
widget.disabledImage = disabled.Image
}
return widget, nil
}

View File

@@ -0,0 +1,185 @@
package ui
import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// Setup handlers know how to handle each type of widget
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: handleStatic, // FIXME: more?
menus.TypeHypertext: nil, // FIXME: handle this
menus.TypeCheckbox: handleCheckbox,
menus.TypeAnimationSample: nil, // FIXME: handle this
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
}
i.static = append(i.static, sprite)
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
}
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 = 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
}
var path []int
for r := record; r != nil; r = r.Parent {
path = append([]int{r.Id}, path...)
}
widget := &Widget{
Bounds: sprite.Rect,
Tooltip: record.Desc,
path: path,
record: record,
sprite: sprite,
}
return widget, nil
}

View File

@@ -15,6 +15,7 @@ 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()
@@ -40,6 +41,8 @@ type Widget struct {
path []int
record *menus.Record
sprite *assetstore.Sprite
valueToImage func() *ebiten.Image
}
func (w *Widget) Disable() {
@@ -86,7 +89,7 @@ func (w *Widget) Image(aniStep int) (*ebiten.Image, error) {
return w.disabledImage, nil
}
if w.hoverState && w.mouseButtonState {
if w.mouseButtonDownImage != nil && w.hoverState && w.mouseButtonState {
return w.mouseButtonDownImage, nil
}
@@ -94,5 +97,9 @@ func (w *Widget) Image(aniStep int) (*ebiten.Image, error) {
return w.hoverAnimation[(aniStep)%len(w.hoverAnimation)], nil
}
if w.valueToImage != nil {
return w.valueToImage(), nil
}
return w.sprite.Image, nil
}