diff --git a/config.toml.example b/config.toml.example index 0b14d0f..154d4ec 100644 --- a/config.toml.example +++ b/config.toml.example @@ -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 diff --git a/doc/formats/mnu.md b/doc/formats/mnu.md index d94a49e..02690a8 100644 --- a/doc/formats/mnu.md +++ b/doc/formats/mnu.md @@ -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`. diff --git a/internal/config/config.go b/internal/config/config.go index ac6dd3c..d611364 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { - Ordoor `toml:"ordoor"` + 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) diff --git a/internal/menus/menus.go b/internal/menus/menus.go index a0f4d02..2fb225a 100644 --- a/internal/menus/menus.go +++ b/internal/menus/menus.go @@ -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": diff --git a/internal/ordoor/interfaces.go b/internal/ordoor/interfaces.go index ec6831a..5b8932a 100644 --- a/internal/ordoor/interfaces.go +++ b/internal/ordoor/interfaces.go @@ -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 } diff --git a/internal/ordoor/ordoor.go b/internal/ordoor/ordoor.go index b9114cf..5db6913 100644 --- a/internal/ordoor/ordoor.go +++ b/internal/ordoor/ordoor.go @@ -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") - o.PlaySkippableVideo("movie1") + if o.config.Options.PlayMovies { + o.PlayUnskippableVideo("LOGOS") + o.PlaySkippableVideo("movie1") + } err := o.win.Run() if err == errExit { + log.Printf("Exit requested") return nil } diff --git a/internal/ui/interface.go b/internal/ui/interface.go index 229c4a9..4fe44e8 100644 --- a/internal/ui/interface.go +++ b/internal/ui/interface.go @@ -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,25 +112,19 @@ 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) - default: + 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 { @@ -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 -} diff --git a/internal/ui/setup_handlers.go b/internal/ui/setup_handlers.go new file mode 100644 index 0000000..356d8b8 --- /dev/null +++ b/internal/ui/setup_handlers.go @@ -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 +} diff --git a/internal/ui/widget.go b/internal/ui/widget.go index 0dea1a6..d48d554 100644 --- a/internal/ui/widget.go +++ b/internal/ui/widget.go @@ -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 }