diff --git a/doc/formats/mnu.md b/doc/formats/mnu.md index 03f925d..8305517 100644 --- a/doc/formats/mnu.md +++ b/doc/formats/mnu.md @@ -134,6 +134,16 @@ $GenLoad.mni It looks like we just interpolate the named file into the text when we come across one of these lines. +The `MENUID` in `GenDialog` and `GenLoad` is a 2-element list, like `1000,1` +or `2000,2`. The second number corresponds to the offset in the list of object +files. + +## `MENUTYPE` + +Here's the full list of + +## `SUBMENUTYPE` + ## (Sub)menu types The types seem to refer to different types of UI widget. Here's a list of unique diff --git a/internal/assetstore/menu.go b/internal/assetstore/menu.go index 8f38653..1b9b8b9 100644 --- a/internal/assetstore/menu.go +++ b/internal/assetstore/menu.go @@ -7,10 +7,10 @@ import ( ) type Menu struct { - assets *AssetStore - fonts []*Font // TODO: place the fonts directly into the relevant records - obj *Object // TODO: handle multiple objects in the menu - raw *menus.Menu // TODO: remove raw + assets *AssetStore + fonts []*Font // TODO: place the fonts directly into the relevant records + objects []*Object // TODO: place the objects directly into the relevant records + raw *menus.Menu // TODO: remove raw Name string } @@ -25,10 +25,10 @@ func (m *Menu) Font(idx int) *Font { return m.fonts[idx] } -func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) { +func (m *Menu) Images(objIdx, start, count int) ([]*ebiten.Image, error) { out := make([]*ebiten.Image, count) - sprites, err := m.Sprites(start, count) + sprites, err := m.Sprites(objIdx, start, count) if err != nil { return nil, err } @@ -40,11 +40,11 @@ func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) { return out, nil } -func (m *Menu) Sprites(start, count int) ([]*Sprite, error) { +func (m *Menu) Sprites(objIdx, start, count int) ([]*Sprite, error) { out := make([]*Sprite, count) for i := start; i < start+count; i++ { - sprite, err := m.Sprite(i) + sprite, err := m.Sprite(objIdx, i) if err != nil { return nil, err } @@ -55,8 +55,8 @@ func (m *Menu) Sprites(start, count int) ([]*Sprite, error) { return out, nil } -func (m *Menu) Sprite(idx int) (*Sprite, error) { - return m.obj.Sprite(idx) +func (m *Menu) Sprite(objIdx, idx int) (*Sprite, error) { + return m.objects[objIdx].Sprite(idx) } func (a *AssetStore) Menu(name string) (*Menu, error) { @@ -93,29 +93,40 @@ func (a *AssetStore) Menu(name string) (*Menu, error) { raw.Internationalize(i18n) - obj, err := a.loadMenuObject(raw) // TODO: multiple objects + // FIXME: we should parse the menu into a list of elements like "ListBox", + // "Dialogue", etc, and present those with objects already selected + objects, err := a.loadMenuObjects(raw) if err != nil { return nil, err } menu := &Menu{ - assets: a, - fonts: fonts, - obj: obj, - raw: raw, - Name: name, + assets: a, + fonts: fonts, + objects: objects, + 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 +func (a *AssetStore) loadMenuObjects(menu *menus.Menu) ([]*Object, error) { + out := make([]*Object, len(menu.ObjectFiles)) + for i, name := range menu.ObjectFiles { + filename, err := a.lookup(name, "", "Menu") // Extension already present + if err != nil { + return nil, err + } + + obj, err := a.ObjectByPath(filename) + if err != nil { + return nil, err + } + + out[i] = obj } - return a.ObjectByPath(filename) + return out, nil } diff --git a/internal/menus/menus.go b/internal/menus/menus.go index bd30c1d..170be74 100644 --- a/internal/menus/menus.go +++ b/internal/menus/menus.go @@ -16,9 +16,10 @@ import ( type MenuType int const ( - TypeStatic MenuType = 0 - TypeMenu MenuType = 1 - TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra} + TypeStatic MenuType = 0 + TypeMenu MenuType = 1 + TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra} + TypeSimpleButton MenuType = 3 TypeDoorHotspot MenuType = 30 // Like a button I guess? "FONTTYPE is animation speed" TypeDoorHotspot2 MenuType = 31 // Seems like a duplicate of the above? What's different? @@ -85,7 +86,9 @@ type Record struct { Parent *Record Children []*Record - Id int + Id int + ObjectIdx int // Can be specified in MENUID, defaults to 0 + Type MenuType DrawType int FontType int @@ -109,11 +112,11 @@ type Menu struct { ObjectFiles []string FontNames []string + // These are properties set in the menu header. We don't know what they're + // all for. BackgroundColor color.Color HypertextColor color.Color - - // FIXME: turn these into first-class data - Properties map[string]string + FontType int // The actual menu records. There are multiple top-level items. Submenus are // only ever nested one deep. @@ -122,10 +125,9 @@ type Menu struct { func LoadMenu(filename string) (*Menu, error) { name := filepath.Base(filename) - name = strings.Replace(name, filepath.Ext(name), "", -1) + name = strings.TrimSuffix(name, filepath.Ext(name)) name = strings.ToLower(name) - // FIXME: this needs turning into a real parser sometime scanner, err := asciiscan.New(filename) if err != nil { return nil, err @@ -133,81 +135,135 @@ func LoadMenu(filename string) (*Menu, error) { defer scanner.Close() - var str string - var record *Record - - section := 0 - isProp := false out := &Menu{ - Name: name, - Properties: map[string]string{}, + Name: name, } + if err := loadObjects(out, scanner); err != nil { + return nil, err + } + + if err := loadProperties(out, scanner); err != nil { + return nil, err + } + + if err := loadFonts(out, scanner); err != nil { + return nil, err + } + + if err := loadRecords(filepath.Dir(filename), out, scanner); err != nil { + return nil, err + } + + return out, nil +} + +func loadObjects(menu *Menu, scanner *asciiscan.Scanner) error { + strs, err := scanner.ConsumeStringList() + if err != nil { + return err + } + + menu.ObjectFiles = strs + + return nil +} + +func loadProperties(menu *Menu, scanner *asciiscan.Scanner) error { for { - str, err = scanner.ConsumeString() + ok, err := scanner.PeekProperty() + if err != nil { - return nil, err + return err } - - // Whether the lines are properties or not alternate with each section, - // except the records use `*` as a separator - if section < 3 && isProp != asciiscan.IsProperty(str) { - section += 1 - isProp = !isProp - } - - if str == "~" { + if !ok { break } - switch section { - case 0: // List of object files - out.ObjectFiles = append(out.ObjectFiles, str) - case 1: // List of properties - k, v := asciiscan.ConsumeProperty(str) - vInt, err := strconv.Atoi(v) // FIXME: - switch k { - case "BACKGROUND COLOR 0..255..-1 trans": - if err != nil { - return nil, err - } + k, v, err := scanner.ConsumeProperty() + if err != nil { + return err + } - out.BackgroundColor = data.ColorPalette[vInt] - case "HYPERTEXT COLOR 0..255": - if err != nil { - return nil, err - } + vInt, err := strconv.Atoi(v) // All properties have been int + if err != nil { + return err + } - out.HypertextColor = data.ColorPalette[vInt] - default: - out.Properties[k] = v - } - case 2: // list of fonts - // FIXME: do we need to do something cleverer here? - if str == "NULL" { - continue - } - out.FontNames = append(out.FontNames, str) - case 3: // Menu records - if str == "*" { // NEXT RECORD - out.Records = append(out.Records, record.Toplevel()) - continue - } - - k, v := asciiscan.ConsumeProperty(str) - switch k { - case "MENUID": - record = newRecord(out, nil) - case "SUBMENUID": - record = newRecord(out, record.Toplevel()) - } - setProperty(record, k, v) + switch strings.ToUpper(k) { + case "BACKGROUND COLOR 0..255..-1 TRANS": + menu.BackgroundColor = data.ColorPalette[vInt] + case "HYPERTEXT COLOR 0..255": + menu.HypertextColor = data.ColorPalette[vInt] + case "FONT TYPE 0..5": + menu.FontType = vInt + default: + return fmt.Errorf("Unhandled menu property in %v: %q=%q", menu.Name, k, v) } } - log.Printf("Menu properties: %#+v", out.Properties) + return nil +} - return out, nil +func loadFonts(menu *Menu, scanner *asciiscan.Scanner) error { + // FIXME: Can we just ignore NULL, or does the index matter? + strs, err := scanner.ConsumeStringList("NULL") + if err != nil { + return err + } + + menu.FontNames = strs + + return nil +} + +func loadRecords(baseDir string, menu *Menu, scanner *asciiscan.Scanner) error { + var record *Record // We build records here and add them when complete + + for { + str, err := scanner.ConsumeString() + if err != nil { + return err + } + + if strings.HasPrefix(str, "$") { + subScanner, err := asciiscan.New(filepath.Join(baseDir, str[1:])) + if err != nil { + return err + } + + err = loadRecords(baseDir, menu, subScanner) + subScanner.Close() // Don't keep this around for all of loadRecords + + if err != nil { + return fmt.Errorf("Processing child %q: %v", str, err) + } + + continue + } + + switch str { + case "*": + if record != nil { + menu.Records = append(menu.Records, record.Toplevel()) + } + + continue // NEXT RECORD + case "~": + return nil // THE END + } + + k, v := asciiscan.ConsumeProperty(str) + switch k { + case "MENUID": + record = newRecord(menu, nil) + case "SUBMENUID": + record = newRecord(menu, record.Toplevel()) + } + setProperty(record, k, v) + } + + return nil } func LoadMenus(dir string) (map[string]*Menu, error) { @@ -271,8 +327,25 @@ func setProperty(r *Record, k, v string) { } switch k { - case "MENUID", "SUBMENUID": - r.Id = vInt + case "MENUID": + // ObjectIdx can be specified in the MENUID. Only seen for .mni files + if strings.Contains(v, ",") && len(vSplitInt) >= 2 { + r.Id = vSplitInt[0] + r.ObjectIdx = vSplitInt[1] + } else { + r.Id = vInt + } + case "SUBMENUID": + if strings.Contains(v, ",") { + log.Printf("%v has an object index in SUBMENUID - surprising", r.Locator()) + r.Id = vSplitInt[0] + r.ObjectIdx = vSplitInt[1] + } else { + r.Id = vInt + if r.Parent != nil { // Children seem to inherit from parents? + r.ObjectIdx = r.Parent.ObjectIdx + } + } case "MENUTYPE", "SUBMENUTYPE": if strings.Contains(v, ",") { r.Type = MenuType(vSplitInt[0]) // FIXME: what are the other values in this case? diff --git a/internal/ui/buttons.go b/internal/ui/buttons.go index e0c3e5c..2df669e 100644 --- a/internal/ui/buttons.go +++ b/internal/ui/buttons.go @@ -51,12 +51,12 @@ func registerInvokeButton(d *Driver, r *menus.Record) error { } func registerMainButton(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.Share, 3) // base, pressed, disabled + sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // base, pressed, disabled if err != nil { return err } - hovers, err := d.menu.Images(r.SpriteId[0], r.DrawType) + hovers, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) if err != nil { return err } @@ -81,7 +81,7 @@ func registerMainButton(d *Driver, r *menus.Record) error { } func registerDoorHotspot(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.Share, 2) // base, pressed + sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 2) // base, pressed if err != nil { return err } @@ -104,7 +104,7 @@ func registerDoorHotspot(d *Driver, r *menus.Record) error { } func registerButton(d *Driver, r *menus.Record, spriteId int) (*button, error) { - sprites, err := d.menu.Sprites(spriteId, 3) // base, pressed, disabled + sprites, err := d.menu.Sprites(r.ObjectIdx, spriteId, 3) // base, pressed, disabled if err != nil { return nil, err } diff --git a/internal/ui/dialogue.go b/internal/ui/dialogue.go new file mode 100644 index 0000000..17af1d1 --- /dev/null +++ b/internal/ui/dialogue.go @@ -0,0 +1,20 @@ +package ui + +import ( + "code.ur.gs/lupine/ordoor/internal/menus" +) + +func init() { + // Needed for Keyboard.mnu (main -> options -> keyboard). + // Dialogues can be active(?) or not. If they're not, then they are hidden. + // Dialogues seem to be modal in all cases? + registerBuilder(menus.TypeDialogue, registerDebug("WIP Dialogue", registerDialogue)) +} + +func registerDialogue(d *Driver, r *menus.Record) ([]*menus.Record, error) { + // The dialogue itself has a sprite + _, err := registerNoninteractive(d, r) + + // TODO: we need to group these. Z levels seem overkill? + return r.Children, err +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go index d8b7691..33dfd35 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -17,11 +17,6 @@ import ( func init() { // FIXME: these need implementing - // Needed for Keyboard.mnu (main -> options -> keyboard) - registerBuilder(menus.TypeDialogue, registerDebug("Unimplemented Dialogue", nil)) - - // Needed for ChaEquip.mnu - // Needed for MainGameChaos.mnu registerBuilder(menus.TypeStatusBar, registerDebug("Unimplemented StatusBar", nil)) @@ -51,7 +46,7 @@ type builderFunc func(d *Driver, r *menus.Record) (children []*menus.Record, err func registerDebug(reason string, onward builderFunc) builderFunc { return func(d *Driver, r *menus.Record) ([]*menus.Record, error) { - log.Printf("%v: %#+v", reason, r) + log.Printf("%v: %v: %#+v", reason, r.Locator(), r) if onward == nil { return r.Children, nil } @@ -327,7 +322,7 @@ func (d *Driver) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) { } func (d *Driver) addRecord(record *menus.Record) error { - log.Printf("Adding record %v: %#+v", record.Locator(), record) + //log.Printf("Adding record %v: %#+v", record.Locator(), record) children := record.Children handler, ok := widgetBuilders[record.Type] diff --git a/internal/ui/inventory_select.go b/internal/ui/inventory_select.go index 4bdff11..04d8d27 100644 --- a/internal/ui/inventory_select.go +++ b/internal/ui/inventory_select.go @@ -21,7 +21,7 @@ type inventorySelect struct { // Called from the menu, which fills "others" for us func registerInventorySelect(d *Driver, r *menus.Record) (*inventorySelect, error) { - sprites, err := d.menu.Sprites(r.Share, 3) // unchecked, checked, disabled + sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // unchecked, checked, disabled if err != nil { return nil, err } diff --git a/internal/ui/list_box.go b/internal/ui/list_box.go index 5abf449..dd329db 100644 --- a/internal/ui/list_box.go +++ b/internal/ui/list_box.go @@ -101,7 +101,7 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) { return nil, err } - thumbBaseSpr, err := d.menu.Sprite(thumb.Share) + thumbBaseSpr, err := d.menu.Sprite(menu.ObjectIdx, thumb.Share) if err != nil { return nil, err } @@ -111,7 +111,7 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) { thumbSprId = thumb.Share } - thumbImgSpr, err := d.menu.Sprite(thumbSprId) + thumbImgSpr, err := d.menu.Sprite(menu.ObjectIdx, thumbSprId) if err != nil { return nil, err } diff --git a/internal/ui/noninteractive.go b/internal/ui/noninteractive.go index ec5767f..e413378 100644 --- a/internal/ui/noninteractive.go +++ b/internal/ui/noninteractive.go @@ -70,7 +70,7 @@ func registerNoninteractive(d *Driver, r *menus.Record) (*noninteractive, error) spriteId = r.SpriteId[0] } - sprite, err := d.menu.Sprite(spriteId) + sprite, err := d.menu.Sprite(r.ObjectIdx, spriteId) if err != nil { return nil, err } @@ -89,7 +89,7 @@ func registerNoninteractive(d *Driver, r *menus.Record) (*noninteractive, error) } func registerHypertext(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.Share) + sprite, err := d.menu.Sprite(r.ObjectIdx, r.Share) if err != nil { return err } @@ -108,7 +108,7 @@ func registerHypertext(d *Driver, r *menus.Record) error { // An overlay is a static image + some text that needs to be rendered func registerOverlay(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.Share) + sprite, err := d.menu.Sprite(r.ObjectIdx, r.Share) if err != nil { return err } @@ -139,12 +139,12 @@ func registerOverlay(d *Driver, r *menus.Record) error { // An animation is a non-interactive element that displays something in a loop func registerAnimation(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.SpriteId[0]) + sprite, err := d.menu.Sprite(r.ObjectIdx, r.SpriteId[0]) if err != nil { return err } - frames, err := d.menu.Images(r.SpriteId[0], r.DrawType) + frames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) if err != nil { return err } @@ -163,17 +163,17 @@ func registerAnimation(d *Driver, r *menus.Record) error { } func registerAnimationHover(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.SpriteId[0]) + sprite, err := d.menu.Sprite(r.ObjectIdx, r.SpriteId[0]) if err != nil { return err } - enterFrames, err := d.menu.Images(r.SpriteId[0], r.DrawType) + enterFrames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) if err != nil { return err } - exitFrames, err := d.menu.Images(r.SpriteId[0]+r.DrawType, r.DrawType) + exitFrames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0]+r.DrawType, r.DrawType) if err != nil { return err } diff --git a/internal/ui/selectors.go b/internal/ui/selectors.go index 5fec20c..6694cec 100644 --- a/internal/ui/selectors.go +++ b/internal/ui/selectors.go @@ -39,7 +39,7 @@ type slider struct { // A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled. func registerCheckbox(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.Share, 3) // unchecked, disabled, checked + sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // unchecked, disabled, checked if err != nil { return err } @@ -65,7 +65,7 @@ func registerCheckbox(d *Driver, r *menus.Record) error { } func registerSlider(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.Share, 3) // base, clicked, slider element + sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // base, clicked, slider element if err != nil { return err } diff --git a/internal/util/asciiscan/asciiscan.go b/internal/util/asciiscan/asciiscan.go index b09fda1..e6b4afb 100644 --- a/internal/util/asciiscan/asciiscan.go +++ b/internal/util/asciiscan/asciiscan.go @@ -4,6 +4,7 @@ package asciiscan import ( "bufio" "bytes" + "fmt" "io" "os" "strconv" @@ -15,6 +16,9 @@ var hashComment = []byte("#") type Scanner struct { bufio *bufio.Scanner closer io.Closer + + // If we've peeked, there will be items here + buffered []string } func New(filename string) (*Scanner, error) { @@ -38,6 +42,13 @@ func (s *Scanner) Close() error { } func (s *Scanner) ConsumeString() (string, error) { + if len(s.buffered) > 0 { + out, buffered := s.buffered[0], s.buffered[1:] + s.buffered = buffered + + return out, nil + } + for s.bufio.Scan() { line := s.bufio.Bytes() @@ -68,15 +79,41 @@ func ConsumeProperty(s string) (string, string) { } parts := strings.SplitN(s, ":", 2) + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) } -// Peek ahead in the input stream to see if the next line might be a property -// (contain a colon character). +// Check to see if the line looks like a property (contains a colon character). func IsProperty(s string) bool { return strings.Contains(s, ":") } +// Checks if the next line might be a property, without reading it +func (s *Scanner) PeekProperty() (bool, error) { + str, err := s.ConsumeString() + if err != nil { + return false, err + } + + s.buffered = append(s.buffered, str) + + return IsProperty(str), nil +} + +func (s *Scanner) ConsumeProperty() (string, string, error) { + str, err := s.ConsumeString() + if err != nil { + return "", "", err + } + + if !IsProperty(str) { + return "", "", fmt.Errorf("Not a property: %q", str) + } + + k, v := ConsumeProperty(str) + return k, v, nil +} + func (s *Scanner) ConsumeInt() (int, error) { str, err := s.ConsumeString() if err != nil { @@ -86,6 +123,39 @@ func (s *Scanner) ConsumeInt() (int, error) { return strconv.Atoi(str) } +// Reads a list of non-property lines, skipping any that match the given strings +func (s *Scanner) ConsumeStringList(skip ...string) ([]string, error) { + skipper := make(map[string]bool, len(skip)) + for _, str := range skip { + skipper[str] = true + } + + var out []string + + for { + isProp, err := s.PeekProperty() + if err != nil { + return nil, err + } + + // The object list is terminated by the first property + if isProp { + break + } + + str, err := s.ConsumeString() + if err != nil { + return nil, err + } + + if !skipper[str] { + out = append(out, str) + } + } + + return out, nil +} + func (s *Scanner) ConsumeIntPtr(to *int) error { val, err := s.ConsumeInt() if err != nil {