From 786d261f98f4e25c9b7ed2361329df023db14de5 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 14 Apr 2020 03:14:49 +0100 Subject: [PATCH] Allow dialogues to be hidden or shown To do this, MENU and SUBMENU are split into two types (at last), and a Widget type is introduced. This should allow lots of code to be removed at some point. --- cmd/loader/main.go | 14 +- cmd/view-menu/main.go | 39 ++++ doc/formats/mnu.md | 133 +++++------ internal/assetstore/menu.go | 4 +- internal/menus/menus.go | 393 ++++++++++++++++++-------------- internal/ui/buttons.go | 117 +++++----- internal/ui/dialogue.go | 20 -- internal/ui/dialogues.go | 30 +++ internal/ui/driver.go | 281 +++++------------------ internal/ui/events.go | 43 ++++ internal/ui/group.go | 196 ++++++++++++++++ internal/ui/inventory_select.go | 33 +-- internal/ui/list_box.go | 106 ++------- internal/ui/menus.go | 72 ------ internal/ui/noninteractive.go | 153 +++++++------ internal/ui/selectors.go | 55 +++-- internal/ui/value.go | 119 ++++++++++ internal/ui/widget.go | 73 ++++++ 18 files changed, 1034 insertions(+), 847 deletions(-) delete mode 100644 internal/ui/dialogue.go create mode 100644 internal/ui/dialogues.go create mode 100644 internal/ui/events.go create mode 100644 internal/ui/group.go delete mode 100644 internal/ui/menus.go create mode 100644 internal/ui/value.go create mode 100644 internal/ui/widget.go diff --git a/cmd/loader/main.go b/cmd/loader/main.go index 75e94ab..ee41102 100644 --- a/cmd/loader/main.go +++ b/cmd/loader/main.go @@ -157,24 +157,24 @@ func loadMenus() { for _, menu := range menus { fmt.Printf(" * `%s`: objects=%v fonts=%v\n", menu.Name, menu.ObjectFiles, menu.FontNames) - for _, record := range menu.Records { - displayRecord(record, 2) + + for _, group := range menu.Groups { + // TODO: display group + for _, record := range group.Records { + displayRecord(record, 2) + } } } } func displayRecord(record *menus.Record, depth int) { - content := fmt.Sprintf("id=%v type=%v sprite=%v", record.Id, record.Type, record.SpriteId) + content := fmt.Sprintf("id=%v type=%v sprite=%v", record.ID, record.Type, record.SpriteId) if !record.Active { content = "(" + content + ")" } fmt.Printf("%s* %s\n", strings.Repeat(" ", depth), content) - - for _, child := range record.Children { - displayRecord(child, depth+1) - } } func loadFonts() { diff --git a/cmd/view-menu/main.go b/cmd/view-menu/main.go index c093273..f4d7be8 100644 --- a/cmd/view-menu/main.go +++ b/cmd/view-menu/main.go @@ -5,6 +5,8 @@ import ( "log" "os" + "github.com/hajimehoshi/ebiten" + "code.ur.gs/lupine/ordoor/internal/assetstore" "code.ur.gs/lupine/ordoor/internal/ui" ) @@ -17,6 +19,12 @@ var ( winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension") ) +type dlg struct { + driver *ui.Driver + list []string + pos int +} + func main() { flag.Parse() @@ -45,7 +53,38 @@ func main() { log.Fatal("Couldn't create window: %v", err) } + // Change the active dialogue + dialogues := driver.Dialogues() + if len(dialogues) > 0 { + dlg := &dlg{ + driver: driver, + list: dialogues, + } + win.OnKeyUp(ebiten.KeyLeft, dlg.changeDialogue(-1)) + win.OnKeyUp(ebiten.KeyRight, dlg.changeDialogue(+1)) + for i, dialogue := range dlg.list { + log.Printf("Dialogue %v: %v", i, dialogue) + } + } + if err := win.Run(); err != nil { log.Fatal(err) } } + +func (d *dlg) changeDialogue(by int) func() { + return func() { + newPos := d.pos + by + if newPos < 0 || newPos > len(d.list)-1 { + log.Printf("Hiding dialogue %v: %q", d.pos, d.list[d.pos]) + d.driver.HideDialogue() + return + } + + locator := d.list[newPos] + log.Printf("Showing dialogue %v: %q", newPos, locator) + + d.driver.ShowDialogue(locator) + d.pos = newPos + } +} diff --git a/doc/formats/mnu.md b/doc/formats/mnu.md index 8305517..1fa9339 100644 --- a/doc/formats/mnu.md +++ b/doc/formats/mnu.md @@ -140,59 +140,71 @@ files. ## `MENUTYPE` -Here's the full list of +Here's the full list of values for `MENUTYPE`: + +| Value | Meaning | +| ----- | ------------ | +| 0 | `Background` | +| 1 | `Menu` | +| 2 | `DragMenu` | +| 3 | `RadioMenu` ??? - only seen in `LevelPly` and `LoadGame` around select-one items | +| 45 | `MainBackground` ??? - only seen in `MainGame` and `MainGameChaos` | +| 300 | `Dialogue` | + +The `MENUTYPE` acts as a logical grouping of a set of objects onscreen, and +gives strong hints about how to handle their children. ## `SUBMENUTYPE` -## (Sub)menu types - The types seem to refer to different types of UI widget. Here's a list of unique values: | Value | Meaning | |-------|---------| -| 0 | Background | -| 1 | Logical menu grouping? | -| 2 | ? | -| 3 | Standard button? | -| 30 | Equipment? | -| 31 | "Character helmet" / "Slot" | -| 40 | "X Line Y" | -| 41 | "X Line Y" | -| 45 | ? | -| 45,10,11,9 | ? | -| 45,11,12,10 | ? | -| 45,14,15,13 | ? | -| 45,17,18,16 | ? | -| 45,3,4,2 | ? | -| 45,5,6,4 | ? | -| 45,6,7,5 | ? | -| 45,7,8,6 | ? | -| 45,8,9,7 | ? | -| 45,9,10,8 | ? | -| 50 | ? | -| 60 | Other text to display? (`UltEquip.mnu`) | -| 61 | Text to display | -| 70 | Hypertext to display | -| 91 | ? | -| 100 | ? | -| 110 | ? | -| 120 | ? | -| 200 | Drop-down button? | -| 205 | Single list box item? | -| 220 | Psyker power? | -| 221 | Page? | -| 228 | Big buttons in `Main.mnu` | -| 232 | ? | -| 233 | ? | -| 300 | Pop-up dialog box | -| 400,0,0,{8, 16} | ? | -| 400,22,22,{2, 4, 5, 6, 7, 8, 9, 9, 10, 13, 16} | ? | -| 400,30,-1,5 | ? | -| 405,0,0,{8, 16} | ? | -| 405,22,22,{2, 4, 5, 6, 7, 8, 9, 10, 13, 16} | ? | -| 405,30,-1,5 | ? | +| 3 | `Button` | +| 30 | `DoorHotspot1` | +| 31 | `DoorHotspot2` | +| 40 | `LineKbd` | +| 41 | `LineBriefing` | +| 45 | `Thumb` | +| 50 | `InvokeButton` | +| 60 | `DoorHotspot3` | +| 61 | `Overlay` | +| 70 | `Hypertext` | +| 91 | `Checkbox` | +| 100 | `EditBox` | +| 110 | `InventorySelect` | +| 120 | `RadioButton` | +| 200 | `DropdownButton` | +| 205 | `ComboBoxItem` | +| 220 | `AnimationSample` | +| 221 | `AnimationHover` | +| 228 | `MainButton` | +| 232 | `Slider` | +| 233 | `StatusBar` | +| 400 | `ListBoxUp` | +| 405 | `ListBoxDown` | + +`400`, `405`, and `45`, can all accept 4 values for `SUBMENUTYPE` in a +comma-separated list. These records combine to form a `TListBox` control, with a +number of visible slots that act as a viewport. There is a draggable vertical +slider (the "thumb") to show where in the full list the viewport is, and up + +down buttons to move the position of the thumb by one, so it's feasible that +these values tell us about the available steps. + +Here are the values in `Briefing.mnu`: + +``` +#rem..........List Box Menu +MENUTYPE : 1 # List Box Menu + SUBMENUTYPE: 400,22,22,13 # Scroll Up + SUBMENUTYPE: 405,22,22,13 # Scroll Down + SUBMENUTYPE: 45, 14,15,13 # Thumb +``` + +There are 13 elements in this listbox, which sorts out the fourth number (but +what is it used for?). The other two need more investigation. ## Positioning @@ -205,6 +217,9 @@ successfully, for instance: ![](img/Options.mnu.png) +However, it's *not* sufficient to put all the items for `MainGame.mnu` in the +right place. + ## Animation This seems to be done by choosing a different sprite to draw every N ticks. They @@ -248,36 +263,6 @@ attributes plucked from `Main.mnu`: The buttons, menu title and version hotspot are submenus of the start menu. -### `MENUTYPE` - -This is the only menu where we see a type of 228. ~750 other unique values are -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 some currently-known values: - -| Value | Type | -| ----- | ---------------- | -| 0 | Static image | -| 1 | Menu | -| 3 | Button | -| 45 | Thumb | -| 50 | Invoke? Button? | -| 61 | "Overlay" | -| 70 | "Hypertext" | -| 91 | Checkbox | -| 220 | Animation sample | -| 228 | Main menu button | -| 232 | Slider | - -Hypothesis: `MENUTYPE` and `SUBMENUTYPE` are actually distinct lists of values. -So far, I've been treating them as the same thing, but, e.g., `MainGame.mnu` has -a `MENUTYPE: 45` which is labelled "MAIN BACKGROUND", while `SUBMENUTYPE: 45` -is tentatively labelled a "thumb" and used in text boxes. There are also a few -cases where I've had to manually override the `MENUTYPE` because it coincides -with `Button`. - ### `ACTIVE` There are only 4 values seen across all menus: `0`, `1`, `1,0`, `102` and `1,1`. diff --git a/internal/assetstore/menu.go b/internal/assetstore/menu.go index 1b9b8b9..3d5d604 100644 --- a/internal/assetstore/menu.go +++ b/internal/assetstore/menu.go @@ -16,8 +16,8 @@ type Menu struct { } // FIXME: don't expose this -func (m *Menu) Records() []*menus.Record { - return m.raw.Records +func (m *Menu) Groups() []*menus.Group { + return m.raw.Groups } // FIXME: don't expose this diff --git a/internal/menus/menus.go b/internal/menus/menus.go index 170be74..dc0c99d 100644 --- a/internal/menus/menus.go +++ b/internal/menus/menus.go @@ -4,7 +4,6 @@ import ( "fmt" "image/color" "io/ioutil" - "log" "path/filepath" "strconv" "strings" @@ -13,38 +12,44 @@ import ( "code.ur.gs/lupine/ordoor/internal/util/asciiscan" ) +// MenuType tells us what sort of Group we have type MenuType int +// SubMenuType tells us what sort of Record we have +type SubMenuType 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} + TypeRadioMenu MenuType = 3 // ??? + TypeMainBackground MenuType = 45 // ??? + TypeDialogue MenuType = 300 - 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? - TypeLineKbd MenuType = 40 - TypeThumb MenuType = 45 // A "thumb" appears to be a vertical slider - TypeLineBriefing MenuType = 41 - TypeInvokeButton MenuType = 50 - TypeDoorHotspot3 MenuType = 60 // Maybe? Appears in Arrange.mnu - TypeOverlay MenuType = 61 - TypeHypertext MenuType = 70 - TypeCheckbox MenuType = 91 - TypeEditBox MenuType = 100 - TypeInventorySelect MenuType = 110 - TypeRadioButton MenuType = 120 - TypeDropdownButton MenuType = 200 - TypeComboBoxItem MenuType = 205 - TypeAnimationSample MenuType = 220 - TypeAnimationHover MenuType = 221 // FONTTYPE is animation speed. Only animate when hovered - TypeMainButton MenuType = 228 - TypeSlider MenuType = 232 - TypeStatusBar MenuType = 233 - TypeDialogue MenuType = 300 + SubTypeSimpleButton SubMenuType = 3 + SubTypeDoorHotspot1 SubMenuType = 30 // Like a button I guess? "FONTTYPE is animation speed" + SubTypeDoorHotspot2 SubMenuType = 31 // Seems like a duplicate of the above? What's different? + SubTypeLineKbd SubMenuType = 40 + SubTypeLineBriefing SubMenuType = 41 + SubTypeThumb SubMenuType = 45 // A "thumb" appears to be a vertical slider + SubTypeInvokeButton SubMenuType = 50 + SubTypeDoorHotspot3 SubMenuType = 60 // Maybe? Appears in Arrange.mnu + SubTypeOverlay SubMenuType = 61 + SubTypeHypertext SubMenuType = 70 + SubTypeCheckbox SubMenuType = 91 + SubTypeEditBox SubMenuType = 100 + SubTypeInventorySelect SubMenuType = 110 + SubTypeRadioButton SubMenuType = 120 + SubTypeDropdownButton SubMenuType = 200 + SubTypeComboBoxItem SubMenuType = 205 + SubTypeAnimationSample SubMenuType = 220 + SubTypeAnimationHover SubMenuType = 221 // FONTTYPE is animation speed. Only animate when hovered + SubTypeMainButton SubMenuType = 228 + SubTypeSlider SubMenuType = 232 + SubTypeStatusBar SubMenuType = 233 - TypeListBoxUp MenuType = 400 // FIXME: these have multiple items in MENUTYPE - TypeListBoxDown MenuType = 405 + SubTypeListBoxUp SubMenuType = 400 // FIXME: these have multiple items in SUBMENUTYPE + SubTypeListBoxDown SubMenuType = 405 ) // FIXME: certain elements - especially overlays - don't have a DESC specified @@ -64,46 +69,11 @@ var TextOverrides = map[string]string{ "main:2.7": "0.1-ordoor", } -// FIXME: The menu is specified as type 2 (button) in these cases, which is -// weird. Make it a menu for now. -// -// Hypothesis: MENUTYPE and SUBMENUTYPE are not equivalent? -var TypeOverrides = map[string]MenuType{ - "levelply:2": TypeMenu, - "savegame:2": TypeMenu, - "loadgame:2": TypeMenu, - - // "thumb" is not a background. - "maingame:2": TypeStatic, - - // ??? - "configure_ultequip:7.5": TypeListBoxUp, - "configure_ultequip:7.6": TypeListBoxDown, -} - -type Record struct { - Menu *Menu - Parent *Record - Children []*Record - - Id int - ObjectIdx int // Can be specified in MENUID, defaults to 0 - - Type MenuType - DrawType int - FontType int - Active bool - SpriteId []int - Share int - X int - Y int - - // From i18n - Text string - Help string - - // FIXME: turn these into first-class data - properties map[string]string +var TypeOverrides = map[string]SubMenuType{ + // FIXME: These are put down as simple buttons, but it's a *lot* easier to + // understand them as list box buttons. + "configure_ultequip:7.5": SubTypeListBoxUp, + "configure_ultequip:7.6": SubTypeListBoxDown, } type Menu struct { @@ -120,7 +90,48 @@ type Menu struct { // The actual menu records. There are multiple top-level items. Submenus are // only ever nested one deep. + Groups []*Group +} + +// Group represents an element with a MENUTYPE. It is part of a Menu and may +// have children. +type Group struct { + Menu *Menu Records []*Record + + Properties + Type MenuType +} + +type Record struct { + Menu *Menu + Group *Group + + Properties + Type SubMenuType +} + +type Properties struct { + Locator string // Not strictly a property. Set for tracking. + + ID int + ObjectIdx int // Can be specified in MENUID, defaults to 0 + + Accelerator int + Active bool + Desc string + DrawType int + FontType int + Moveable bool + Share int + SoundType int + SpriteId []int + X int + Y int + + // From i18n + Text string + Help string } func LoadMenu(filename string) (*Menu, error) { @@ -218,7 +229,10 @@ func loadFonts(menu *Menu, scanner *asciiscan.Scanner) error { } func loadRecords(baseDir string, menu *Menu, scanner *asciiscan.Scanner) error { - var record *Record // We build records here and add them when complete + // We build things up line by line in these variables + var group *Group + var record *Record + var properties *Properties for { str, err := scanner.ConsumeString() @@ -242,25 +256,49 @@ func loadRecords(baseDir string, menu *Menu, scanner *asciiscan.Scanner) error { continue } - switch str { - case "*": + if str == "*" { if record != nil { - menu.Records = append(menu.Records, record.Toplevel()) + group.Records = append(group.Records, record) + record = nil } - continue // NEXT RECORD - case "~": - return nil // THE END + if group != nil { + menu.Groups = append(menu.Groups, group) + group = nil + } + + continue // New group + } + + if str == "~" { + break // THE END } k, v := asciiscan.ConsumeProperty(str) - switch k { + switch strings.ToUpper(k) { case "MENUID": - record = newRecord(menu, nil) + if group != nil { + menu.Groups = append(menu.Groups, group) + } + + group = newGroup(menu, v) + properties = &group.Properties case "SUBMENUID": - record = newRecord(menu, record.Toplevel()) + if record != nil { + group.Records = append(group.Records, record) + } + + record = newRecord(group, v) + properties = &record.Properties + case "MENUTYPE": + group.setMenuType(v) + case "SUBMENUTYPE": + record.setSubMenuType(v) + default: + if err := properties.setProperty(k, v); err != nil { + return err + } } - setProperty(record, k, v) } return nil @@ -295,85 +333,94 @@ func LoadMenus(dir string) (map[string]*Menu, error) { return out, nil } -func newRecord(menu *Menu, parent *Record) *Record { - out := &Record{ - Menu: menu, - Parent: parent, - properties: map[string]string{}, - } - - if parent != nil { - parent.Children = append(parent.Children, out) - } - - return out -} - -func (r *Record) Toplevel() *Record { - if r.Parent != nil { - return r.Parent.Toplevel() - } - - return r -} - -func setProperty(r *Record, k, v string) { - vSplit := strings.Split(v, ",") - vInt, _ := strconv.Atoi(v) +func listOfInts(s string) []int { + vSplit := strings.Split(s, ",") vSplitInt := make([]int, len(vSplit)) for i, subV := range vSplit { vSplitInt[i], _ = strconv.Atoi(subV) } - switch k { - 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? - } else { - r.Type = MenuType(vInt) - } + return vSplitInt +} - // FIXME: Type override. Note that MENUID is specified first, so this works - if override, ok := TypeOverrides[r.Locator()]; ok { - r.Type = override - } - case "ACTIVE": - r.Active = (vInt != 0) - case "SPRITEID": - r.SpriteId = vSplitInt - case "X-CORD": - r.X = vInt - case "Y-CORD": - r.Y = vInt - case "FONTTYPE": - r.FontType = vInt - case "DRAW TYPE": - r.DrawType = vInt - case "SHARE": - r.Share = vInt - default: - r.properties[k] = v +func newGroup(menu *Menu, idStr string) *Group { + out := &Group{Menu: menu} + + // ObjectIdx can be specified in the MENUID. Only seen for .mni files + ints := listOfInts(idStr) + out.ID = ints[0] + if len(ints) > 1 { + out.ObjectIdx = ints[1] } + + out.Locator = fmt.Sprintf("%v:%v", menu.Name, out.ID) + + return out +} + +func newRecord(group *Group, idStr string) *Record { + out := &Record{Group: group} + + out.ID, _ = strconv.Atoi(idStr) // FIXME: we're ignoring conversion errors here + out.ObjectIdx = group.ObjectIdx // FIXME: we shouldn't *copy* this + + out.Locator = fmt.Sprintf("%v.%v", group.Locator, out.ID) + + return out +} + +func (g *Group) setMenuType(s string) { + v, _ := strconv.Atoi(s) // FIXME: conversion errors + g.Type = MenuType(v) +} + +func (r *Record) setSubMenuType(s string) { + // FIXME: Type overrides shouldn't be necessary! + if override, ok := TypeOverrides[r.Locator]; ok { + r.Type = override + return + } + + // FIXME: what are the other types here? Related to list boxes? + ints := listOfInts(s) + r.Type = SubMenuType(ints[0]) +} + +func (p *Properties) setProperty(k, v string) error { + ints := listOfInts(v) + vInt := ints[0] + asBool := (vInt != 0) + + switch strings.ToUpper(k) { + case "ACCELERATOR": + p.Accelerator = vInt + case "ACTIVE": + p.Active = asBool + case "DESC": + p.Desc = v // Usually int, occasionally string + case "DRAW TYPE": + p.DrawType = vInt + case "FONTTYPE": + p.FontType = vInt + case "MOVEABLE": + p.Moveable = asBool + case "SOUNDTYPE": + p.SoundType = vInt + case "SPRITEID": + p.SpriteId = ints + case "X-CORD": + p.X = vInt + case "Y-CORD": + p.Y = vInt + case "SHARE": + p.Share = vInt + + default: + return fmt.Errorf("Unknown property for %v: %v=%v", p.Locator, k, v) + } + + return nil } type Replacer interface { @@ -382,43 +429,47 @@ type Replacer interface { } func (r *Record) Internationalize(replacer Replacer) { - if override, ok := TextOverrides[r.Locator()]; ok { - delete(r.properties, "DESC") + if override, ok := TextOverrides[r.Locator]; ok { r.Text = override + return } - if override, ok := DescOverrides[r.Locator()]; ok { - r.properties["DESC"] = strconv.Itoa(override) + if override, ok := DescOverrides[r.Locator]; ok { + r.Desc = strconv.Itoa(override) } - id, err := strconv.Atoi(r.properties["DESC"]) + id, err := strconv.Atoi(r.Desc) if err == nil { - delete(r.properties, "DESC") replacer.ReplaceText(id, &r.Text) replacer.ReplaceHelp(id, &r.Help) - } - - for _, child := range r.Children { - child.Internationalize(replacer) + } else { + r.Text = r.Desc // Sometimes it's a string like "EQUIPMENT" } } func (m *Menu) Internationalize(replacer Replacer) { - for _, record := range m.Records { - record.Internationalize(replacer) + for _, group := range m.Groups { + for _, record := range group.Records { + record.Internationalize(replacer) + } } } -func (r *Record) Path() string { - var path []string +func (g *Group) Props() *Properties { + return &g.Properties +} - for rec := r; rec != nil; rec = rec.Parent { - path = append([]string{strconv.Itoa(rec.Id)}, path...) +func (r *Record) Props() *Properties { + return &r.Properties +} + +func (p *Properties) BaseSpriteID() int { + base := p.Share + + // SpriteId takes precedence if present + if len(p.SpriteId) > 0 && p.SpriteId[0] >= 0 { + base = p.SpriteId[0] } - return strings.Join(path, ".") -} - -func (r *Record) Locator() string { - return fmt.Sprintf("%v:%v", r.Menu.Name, r.Path()) + return base } diff --git a/internal/ui/buttons.go b/internal/ui/buttons.go index 2df669e..de42d51 100644 --- a/internal/ui/buttons.go +++ b/internal/ui/buttons.go @@ -7,20 +7,11 @@ import ( "code.ur.gs/lupine/ordoor/internal/menus" ) -func init() { - registerBuilder(menus.TypeSimpleButton, noChildren(registerSimpleButton)) - registerBuilder(menus.TypeInvokeButton, noChildren(registerInvokeButton)) - registerBuilder(menus.TypeMainButton, noChildren(registerMainButton)) - registerBuilder(menus.TypeDoorHotspot, noChildren(registerDoorHotspot)) - registerBuilder(menus.TypeDoorHotspot2, noChildren(registerDoorHotspot)) - registerBuilder(menus.TypeDoorHotspot3, noChildren(registerDoorHotspot)) -} - // A button without hover animation // FIXME: Keyboard.mnu has TypeSimpleButton instances that seem to include a // hover in the SpriteId field type button struct { - path string + locator string baseSpr *assetstore.Sprite clickSpr *assetstore.Sprite @@ -38,95 +29,89 @@ type mainButton struct { button } -func registerSimpleButton(d *Driver, r *menus.Record) error { - _, err := registerButton(d, r, r.SpriteId[0]) - - return err -} - -func registerInvokeButton(d *Driver, r *menus.Record) error { - _, err := registerButton(d, r, r.Share) - - return err -} - -func registerMainButton(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // base, pressed, disabled +func (d *Driver) buildButton(p *menus.Properties) (*button, *Widget, error) { + sprites, err := d.menu.Sprites(p.ObjectIdx, p.BaseSpriteID(), 3) // base, pressed, disabled if err != nil { - return err + return nil, nil, err } - hovers, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) + btn := &button{ + locator: p.Locator, + baseSpr: sprites[0], + clickSpr: sprites[1], + frozenSpr: sprites[2], + hoverImpl: hoverImpl{text: p.Text}, + } + + widget := &Widget{ + ownClickables: []clickable{btn}, + ownFreezables: []freezable{btn}, + ownHoverables: []hoverable{btn}, + ownPaintables: []paintable{btn}, + } + + return btn, widget, nil +} + +func (d *Driver) buildMainButton(p *menus.Properties) (*mainButton, *Widget, error) { + sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // base, pressed, disabled if err != nil { - return err + return nil, nil, err + } + + hovers, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType) + if err != nil { + return nil, nil, err } btn := &mainButton{ hoverAnim: animation(hovers), button: button{ - path: r.Path(), + locator: p.Locator, baseSpr: sprites[0], clickSpr: sprites[1], frozenSpr: sprites[2], - hoverImpl: hoverImpl{text: r.Text}, + hoverImpl: hoverImpl{text: p.Text}, }, } - d.clickables = append(d.clickables, btn) - d.freezables = append(d.freezables, btn) - d.hoverables = append(d.hoverables, btn) - d.paintables = append(d.paintables, btn) + widget := &Widget{ + ownClickables: []clickable{btn}, + ownFreezables: []freezable{btn}, + ownHoverables: []hoverable{btn}, + ownPaintables: []paintable{btn}, + } - return nil + return btn, widget, nil } -func registerDoorHotspot(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 2) // base, pressed +func (d *Driver) buildDoorHotspot(p *menus.Properties) (*button, *Widget, error) { + sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 2) // base, pressed if err != nil { - return err + return nil, nil, err } btn := &button{ - path: r.Path(), + locator: p.Locator, baseSpr: sprites[0], clickSpr: sprites[1], frozenSpr: sprites[0], // No disabled sprite - hoverImpl: hoverImpl{text: r.Text}, + hoverImpl: hoverImpl{text: p.Text}, } - d.clickables = append(d.clickables, btn) - d.freezables = append(d.freezables, btn) - d.hoverables = append(d.hoverables, btn) - d.paintables = append(d.paintables, btn) - - return nil - -} - -func registerButton(d *Driver, r *menus.Record, spriteId int) (*button, error) { - sprites, err := d.menu.Sprites(r.ObjectIdx, spriteId, 3) // base, pressed, disabled - if err != nil { - return nil, err + widget := &Widget{ + ownClickables: []clickable{btn}, + ownFreezables: []freezable{btn}, + ownHoverables: []hoverable{btn}, + ownPaintables: []paintable{btn}, } - btn := &button{ - path: r.Path(), - baseSpr: sprites[0], - clickSpr: sprites[1], - frozenSpr: sprites[2], - hoverImpl: hoverImpl{text: r.Text}, - } + return btn, widget, nil - d.clickables = append(d.clickables, btn) - d.freezables = append(d.freezables, btn) - d.hoverables = append(d.hoverables, btn) - d.paintables = append(d.paintables, btn) - - return btn, nil } func (b *button) id() string { - return b.path + return b.locator } func (b *button) bounds() image.Rectangle { diff --git a/internal/ui/dialogue.go b/internal/ui/dialogue.go deleted file mode 100644 index 17af1d1..0000000 --- a/internal/ui/dialogue.go +++ /dev/null @@ -1,20 +0,0 @@ -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/dialogues.go b/internal/ui/dialogues.go new file mode 100644 index 0000000..304781f --- /dev/null +++ b/internal/ui/dialogues.go @@ -0,0 +1,30 @@ +package ui + +import ( + "fmt" +) + +func (d *Driver) Dialogues() []string { + out := make([]string, len(d.dialogues)) + + for i, dialogue := range d.dialogues { + out[i] = dialogue.Locator + } + + return out +} + +func (d *Driver) ShowDialogue(locator string) error { + for _, dialogue := range d.dialogues { + if dialogue.Locator == locator { + d.activeDialogue = dialogue + + return nil + } + } + return fmt.Errorf("Couldn't find dialogue %v", locator) +} + +func (d *Driver) HideDialogue() { + d.activeDialogue = nil +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go index 33dfd35..d53044e 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -3,98 +3,40 @@ package ui import ( "fmt" "image" - "log" "runtime/debug" - "strconv" "github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten/ebitenutil" "code.ur.gs/lupine/ordoor/internal/assetstore" - "code.ur.gs/lupine/ordoor/internal/menus" ) -func init() { - // FIXME: these need implementing - - // Needed for MainGameChaos.mnu - registerBuilder(menus.TypeStatusBar, registerDebug("Unimplemented StatusBar", nil)) - - // Needed for Multiplayer_Choose.mnu - registerBuilder(menus.TypeComboBoxItem, registerDebug("Unimplemented ComboBoxItem", nil)) - registerBuilder(menus.TypeDropdownButton, registerDebug("Unimplemented DropdownButton", nil)) - - // Needed for Multiplayer_Configure.mnu - registerBuilder(menus.TypeEditBox, registerDebug("Unimplemented EditBox", nil)) - - // Needed for Multiplayer_Connect.mnu - registerBuilder(menus.TypeRadioButton, registerDebug("Unimplemented RadioButton", nil)) -} - const ( OriginalX = 640.0 OriginalY = 480.0 ) -var ( - // Widgets register their builder here - widgetBuilders = map[menus.MenuType]builderFunc{} -) - -// Used to add widgets to a driver -type builderFunc func(d *Driver, r *menus.Record) (children []*menus.Record, err error) - -func registerDebug(reason string, onward builderFunc) builderFunc { - return func(d *Driver, r *menus.Record) ([]*menus.Record, error) { - log.Printf("%v: %v: %#+v", reason, r.Locator(), r) - if onward == nil { - return r.Children, nil - } - - return onward(d, r) - } -} - -func noChildren(f func(d *Driver, r *menus.Record) error) builderFunc { - return func(d *Driver, r *menus.Record) ([]*menus.Record, error) { - if len(r.Children) > 0 { - return nil, fmt.Errorf("Children in record %v:%v (%#+v)", r.Menu.Name, r.Path(), r) - } - - return nil, f(d, r) - } -} - -func ownedByMenu(d *Driver, r *menus.Record) ([]*menus.Record, error) { - return nil, fmt.Errorf("This record should be handled by a menu: %v:%v (%#+v)", r.Menu.Name, r.Path(), r) -} - -func registerBuilder(t menus.MenuType, f builderFunc) { - if _, ok := widgetBuilders[t]; ok { - panic(fmt.Sprintf("A builder for menu type %v already exists", t)) - } - - widgetBuilders[t] = f -} - // Driver acts as an interface between the main loop and the widgets specified // in a menu. // // Menu assets assume a 640x480 screen; Driver is responsible for scaling to the // actual screen size when drawing. +// +// TODO: move scaling responsibilities to Window? type Driver struct { Name string assets *assetstore.AssetStore menu *assetstore.Menu - // UI elements we need to drive - clickables []clickable - freezables []freezable - hoverables []hoverable - mouseables []mouseable - paintables []paintable - valueables []valueable + // UI elements we need to drive. Note that widgets are hierarchical - these + // are just the toplevel. Dialogues are separated out. We only want to show + // one dialogue at a time, and if a dialogue is active, the main widgets are + // unusable (i.e., dialogues are modal) + dialogues []*Widget + widgets []*Widget + + activeDialogue *Widget cursor assetstore.CursorName @@ -118,8 +60,8 @@ func NewDriver(assets *assetstore.AssetStore, menu *assetstore.Menu) (*Driver, e menu: menu, } - for _, record := range menu.Records() { - if err := driver.addRecord(record); err != nil { + for _, group := range menu.Groups() { + if err := driver.registerGroup(group); err != nil { return nil, err } } @@ -127,104 +69,6 @@ func NewDriver(assets *assetstore.AssetStore, menu *assetstore.Menu) (*Driver, e return driver, nil } -func (d *Driver) Value(id string, into *string) error { - for _, valueable := range d.valueables { - if valueable.id() == id { - *into = valueable.value() - return nil - } - } - - return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id) -} - -func (d *Driver) SetValue(id, value string) error { - for _, valueable := range d.valueables { - if valueable.id() == id { - valueable.setValue(value) - return nil - } - } - - return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id) -} - -func (d *Driver) ValueBool(id string, into *bool) error { - var vStr string - if err := d.Value(id, &vStr); err != nil { - return err - } - - *into = vStr == "1" - return nil -} - -func (d *Driver) SetValueBool(id string, value bool) error { - vStr := "0" - if value { - vStr = "1" - } - - return d.SetValue(id, vStr) -} - -func (d *Driver) SetFreeze(id string, value bool) error { - for _, freezable := range d.freezables { - if freezable.id() == id { - freezable.setFreezeState(value) - return nil - } - } - - return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id) -} - -func (d *Driver) OnClick(id string, f func()) error { - for _, clickable := range d.clickables { - if clickable.id() == id { - clickable.onClick(f) - return nil - } - } - - return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id) -} - -// FIXME: HURK. Surely I'm missing something? steps is value:offset -func (d *Driver) ConfigureSlider(id string, steps map[int]int) error { - for _, clickable := range d.clickables { - if slider, ok := clickable.(*slider); ok && slider.id() == id { - slider.steps = steps - - return nil - } - } - - return fmt.Errorf("Couldn't find slider %v:%v", d.menu.Name, id) -} - -func (d *Driver) ValueInt(id string, into *int) error { - var vStr string - if err := d.Value(id, &vStr); err != nil { - return err - } - - value, err := strconv.Atoi(vStr) - if err != nil { - return err - } - - *into = value - - return nil -} - -func (d *Driver) SetValueInt(id string, value int) error { - vStr := strconv.Itoa(value) - - return d.SetValue(id, vStr) -} - func (d *Driver) Update(screenX, screenY int) error { if d == nil { debug.PrintStack() @@ -250,7 +94,7 @@ func (d *Driver) Update(screenX, screenY int) error { d.cursorOrig = image.Pt(int(mnX), int(mnY)) // Dispatch notifications to our widgets - for _, hoverable := range d.hoverables { + for _, hoverable := range d.hoverables() { inBounds := d.cursorOrig.In(hoverable.bounds()) d.hoverStartEvent(hoverable, inBounds) @@ -262,7 +106,7 @@ func (d *Driver) Update(screenX, screenY int) error { } mouseIsDown := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) - for _, clickable := range d.clickables { + for _, clickable := range d.clickables() { inBounds := d.cursorOrig.In(clickable.bounds()) mouseWasDown := clickable.mouseDownState() @@ -271,7 +115,7 @@ func (d *Driver) Update(screenX, screenY int) error { d.mouseUpEvent(clickable, inBounds, mouseWasDown, mouseIsDown) } - for _, mouseable := range d.mouseables { + for _, mouseable := range d.mouseables() { mouseable.registerMousePosition(d.cursorOrig) } @@ -286,7 +130,7 @@ func (d *Driver) Draw(screen *ebiten.Image) error { var do ebiten.DrawImageOptions - for _, paint := range d.paintables { + for _, paint := range d.paintables() { for _, region := range paint.regions(d.ticks) { x, y := d.orig2native.Apply(float64(region.offset.X), float64(region.offset.Y)) @@ -321,71 +165,66 @@ func (d *Driver) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) { return cursor.Image, op, nil } -func (d *Driver) addRecord(record *menus.Record) error { - //log.Printf("Adding record %v: %#+v", record.Locator(), record) - children := record.Children +func (d *Driver) clickables() []clickable { + var out []clickable - handler, ok := widgetBuilders[record.Type] - if !ok { - return fmt.Errorf("UI driver encountered unknown menu record: %#+v", record) + for _, widget := range d.widgets { + out = append(out, widget.clickables()...) } - if handler != nil { - var err error - children, err = handler(d, record) - if err != nil { - return err - } - } - - // Recursively add all remaining children of this record - for _, record := range children { - if err := d.addRecord(record); err != nil { - return err - } - } - - return nil + return out } -func (d *Driver) hoverStartEvent(h hoverable, inBounds bool) { - if inBounds && !h.hoverState() { - //log.Printf("hoverable false -> true") - h.setHoverState(true) +func (d *Driver) freezables() []freezable { + var out []freezable + + for _, widget := range d.widgets { + out = append(out, widget.freezables()...) } + + return out } -func (d *Driver) hoverEndEvent(h hoverable, inBounds bool) { - if !inBounds && h.hoverState() { - //log.Printf("hoverable true -> false") - h.setHoverState(false) +func (d *Driver) hoverables() []hoverable { + var out []hoverable + + for _, widget := range d.widgets { + out = append(out, widget.hoverables()...) } + + return out } -func (d *Driver) mouseDownEvent(c clickable, inBounds, wasDown, isDown bool) { - if inBounds && !wasDown && isDown { - //log.Printf("mouse down false -> true") - c.setMouseDownState(true) +func (d *Driver) mouseables() []mouseable { + var out []mouseable + + for _, widget := range d.widgets { + out = append(out, widget.mouseables()...) } + + return out } -func (d *Driver) mouseClickEvent(c clickable, inBounds, wasDown, isDown bool) { - if inBounds && wasDown && !isDown { - //log.Printf("mouse click") - c.registerMouseClick() +func (d *Driver) paintables() []paintable { + var out []paintable + + for _, widget := range d.widgets { + out = append(out, widget.paintables()...) } + + if d.activeDialogue != nil { + out = append(out, d.activeDialogue.paintables()...) + } + + return out } -func (d *Driver) mouseUpEvent(c clickable, inBounds, wasDown, isDown bool) { - if inBounds { - if wasDown && !isDown { - //log.Printf("mouse down true -> false") - c.setMouseDownState(false) - } - } else { - if wasDown { - //log.Printf("mouse down true -> false") - c.setMouseDownState(false) - } +func (d *Driver) valueables() []valueable { + var out []valueable + + for _, widget := range d.widgets { + out = append(out, widget.valueables()...) } + + return out } diff --git a/internal/ui/events.go b/internal/ui/events.go new file mode 100644 index 0000000..f1b994f --- /dev/null +++ b/internal/ui/events.go @@ -0,0 +1,43 @@ +package ui + +func (d *Driver) hoverStartEvent(h hoverable, inBounds bool) { + if inBounds && !h.hoverState() { + //log.Printf("hoverable false -> true") + h.setHoverState(true) + } +} + +func (d *Driver) hoverEndEvent(h hoverable, inBounds bool) { + if !inBounds && h.hoverState() { + //log.Printf("hoverable true -> false") + h.setHoverState(false) + } +} + +func (d *Driver) mouseDownEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds && !wasDown && isDown { + //log.Printf("mouse down false -> true") + c.setMouseDownState(true) + } +} + +func (d *Driver) mouseClickEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds && wasDown && !isDown { + //log.Printf("mouse click") + c.registerMouseClick() + } +} + +func (d *Driver) mouseUpEvent(c clickable, inBounds, wasDown, isDown bool) { + if inBounds { + if wasDown && !isDown { + //log.Printf("mouse down true -> false") + c.setMouseDownState(false) + } + } else { + if wasDown { + //log.Printf("mouse down true -> false") + c.setMouseDownState(false) + } + } +} diff --git a/internal/ui/group.go b/internal/ui/group.go new file mode 100644 index 0000000..37306bd --- /dev/null +++ b/internal/ui/group.go @@ -0,0 +1,196 @@ +package ui + +import ( + "code.ur.gs/lupine/ordoor/internal/menus" + "fmt" + "log" +) + +func (d *Driver) registerGroup(group *menus.Group) error { + // log.Printf("Adding group %v: %#+v", group.Locator, group) + + var dialogue bool + + switch group.Type { + case menus.TypeStatic, menus.TypeMainBackground, menus.TypeMenu, menus.TypeDragMenu, menus.TypeRadioMenu: + case menus.TypeDialogue: + dialogue = true + default: + return fmt.Errorf("Unknown group type: %v", group.Type) + } + + var groupWidget *Widget + // Groups have a background sprite (FIXME: always?) + if group.BaseSpriteID() >= 0 { + var err error + _, groupWidget, err = d.buildStatic(group.Props()) + if err != nil { + return err + } + } else { + groupWidget = &Widget{Locator: group.Locator} + } + + if dialogue { + d.dialogues = append(d.dialogues, groupWidget) + } else { + d.widgets = append(d.widgets, groupWidget) + } + + // TRadioGroup is best handled like this + records, widget, err := d.maybeBuildInventorySelect(group, group.Records) + if err != nil { + return err + } + if widget != nil { + groupWidget.Children = append(groupWidget.Children, widget) + } + + records, widget, err = d.maybeBuildListBox(group, records) + if err != nil { + return err + } + if widget != nil { + groupWidget.Children = append(groupWidget.Children, widget) + } + + for _, record := range records { + child, err := d.buildRecord(record) + if err != nil { + return err + } + + if child != nil { + groupWidget.Children = append(groupWidget.Children, child) + } + } + + return nil +} + +func (d *Driver) buildRecord(r *menus.Record) (*Widget, error) { + var widget *Widget + var err error + + switch r.Type { + case menus.SubTypeSimpleButton, menus.SubTypeInvokeButton: + _, widget, err = d.buildButton(r.Props()) + case menus.SubTypeDoorHotspot1, menus.SubTypeDoorHotspot2, menus.SubTypeDoorHotspot3: + _, widget, err = d.buildDoorHotspot(r.Props()) + case menus.SubTypeOverlay: + _, widget, err = d.buildOverlay(r.Props()) + case menus.SubTypeHypertext: + _, widget, err = d.buildHypertext(r.Props()) + case menus.SubTypeCheckbox: + _, widget, err = d.buildCheckbox(r.Props()) + case menus.SubTypeEditBox: + log.Printf("Unimplemented: SubTypeEditBox: %v", r.Locator) // TODO + case menus.SubTypeRadioButton: + log.Printf("Unimplemented: SubTypeRadioButton: %v", r.Locator) // TODO + case menus.SubTypeDropdownButton: + log.Printf("Unimplemented: SubTypeDropdownButton: %v", r.Locator) // TODO + case menus.SubTypeComboBoxItem: + log.Printf("Unimplemented: SubTypeComboBoxItem: %v", r.Locator) // TODO + case menus.SubTypeAnimationSample: + _, widget, err = d.buildAnimationSample(r.Props()) + case menus.SubTypeAnimationHover: + _, widget, err = d.buildAnimationHover(r.Props()) + case menus.SubTypeMainButton: + _, widget, err = d.buildMainButton(r.Props()) + case menus.SubTypeSlider: + _, widget, err = d.buildSlider(r.Props()) // TODO: take sliders at an earlier point? + case menus.SubTypeStatusBar: + log.Printf("Unimplemented: SubTypeStatusBar: %v", r.Locator) // TODO + default: + return nil, fmt.Errorf("Unknown record type for %v: %v", r.Locator, r.Type) + } + + return widget, err +} + +func (d *Driver) maybeBuildInventorySelect(group *menus.Group, records []*menus.Record) ([]*menus.Record, *Widget, error) { + var untouched []*menus.Record + var touched []*menus.Record + + for _, record := range records { + if record.Type == menus.SubTypeInventorySelect { + touched = append(touched, record) + } else { + untouched = append(untouched, record) + } + } + + if len(touched) == 0 { + return untouched, nil, nil + } + + elements := make([]*inventorySelect, len(touched)) + widget := &Widget{ + Locator: group.Locator, + } + + for i, record := range touched { + element, childWidget, err := d.buildInventorySelect(record.Props()) + if err != nil { + return nil, nil, err + } + + elements[i] = element + widget.Children = append(widget.Children, childWidget) + } + + elements[0].setValue("1") + + for _, element := range elements { + element.others = elements + } + + return untouched, widget, nil +} + +func (d *Driver) maybeBuildListBox(group *menus.Group, records []*menus.Record) ([]*menus.Record, *Widget, error) { + // Unless up, down, thumb, and items, are all present, it's not a listbox + var up *menus.Record + var down *menus.Record + var thumb *menus.Record + var items []*menus.Record + + var untouched []*menus.Record + + for _, rec := range records { + switch rec.Type { + case menus.SubTypeListBoxUp: + if up != nil { + return nil, nil, fmt.Errorf("Duplicate up buttons in menu %v", group.Locator) + } + up = rec + case menus.SubTypeListBoxDown: + if down != nil { + return nil, nil, fmt.Errorf("Duplicate down buttons in menu %v", group.Locator) + } + down = rec + case menus.SubTypeLineKbd, menus.SubTypeLineBriefing: + items = append(items, rec) + case menus.SubTypeThumb: + if thumb != nil { + return nil, nil, fmt.Errorf("Duplicate thumbs in menu %v", group.Locator) + } + thumb = rec + default: + // e.g. maingame:18.12 includes a button that is not part of the box + untouched = append(untouched, rec) + } + } + + // Since not all the elements are present, this isn't a listbox + if len(items) == 0 || thumb == nil || up == nil || down == nil { + return untouched, nil, nil + } + + _, widget, err := d.buildListBox(group, up, down, thumb, items...) + if err != nil { + return nil, nil, err + } + + return untouched, widget, nil +} diff --git a/internal/ui/inventory_select.go b/internal/ui/inventory_select.go index 04d8d27..16847b4 100644 --- a/internal/ui/inventory_select.go +++ b/internal/ui/inventory_select.go @@ -4,10 +4,6 @@ import ( "code.ur.gs/lupine/ordoor/internal/menus" ) -func init() { - registerBuilder(menus.TypeInventorySelect, ownedByMenu) -} - // An inventory select is a sort of radio button. If 2 share the same menu, // selecting one deselects the other. Otherwise, they act like checkboxes. // @@ -20,33 +16,18 @@ 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.ObjectIdx, r.Share, 3) // unchecked, checked, disabled +func (d *Driver) buildInventorySelect(p *menus.Properties) (*inventorySelect, *Widget, error) { + c, widget, err := d.buildCheckbox(p) if err != nil { - return nil, err + return nil, nil, err } - element := &inventorySelect{ - checkbox: checkbox{ - button: button{ - path: r.Path(), - baseSpr: sprites[0], // unchecked - clickSpr: sprites[1], // checked - frozenSpr: sprites[2], // disabled - hoverImpl: hoverImpl{text: r.Text}, - }, + // In an inventorySelect, the frozen and click sprites are reversed + c.clickSpr, c.frozenSpr = c.frozenSpr, c.clickSpr - valueImpl: valueImpl{str: "0"}, - }, - } + element := &inventorySelect{checkbox: *c} - d.clickables = append(d.clickables, element) - d.freezables = append(d.freezables, element) - d.hoverables = append(d.hoverables, element) - d.paintables = append(d.paintables, element) - d.valueables = append(d.valueables, element) - - return element, nil + return element, widget, nil } func (i *inventorySelect) registerMouseClick() { diff --git a/internal/ui/list_box.go b/internal/ui/list_box.go index dd329db..f4e4bb4 100644 --- a/internal/ui/list_box.go +++ b/internal/ui/list_box.go @@ -8,15 +8,6 @@ import ( "code.ur.gs/lupine/ordoor/internal/menus" ) -func init() { - registerBuilder(menus.TypeLineKbd, ownedByMenu) - registerBuilder(menus.TypeLineBriefing, ownedByMenu) - - registerBuilder(menus.TypeThumb, ownedByMenu) - registerBuilder(menus.TypeListBoxUp, ownedByMenu) - registerBuilder(menus.TypeListBoxDown, ownedByMenu) -} - // listBox is a TListBox in VCL terms. It has a number of lines of text, one of // which may be selected, and a slider with up and down buttons to scroll if the // options in the box exceed its viewing capacity. @@ -30,7 +21,6 @@ type listBox struct { thumbBase *assetstore.Sprite // Bounds are given by this thumbImg *assetstore.Sprite // This is displayed at offset * (height / steps) - base *noninteractive // The menu itself has a sprite to display lines []*noninteractive // We display to these // The list box acts as a window onto these @@ -40,87 +30,31 @@ type listBox struct { offset int } -func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) { - var upBtn *menus.Record - var downBtn *menus.Record - var thumb *menus.Record - var items []*menus.Record - var otherChildren []*menus.Record - - for _, rec := range menu.Children { - switch rec.Type { - case menus.TypeListBoxUp: - if upBtn != nil { - return nil, fmt.Errorf("Duplicate up buttons in menu %v", menu.Locator()) - } - upBtn = rec - case menus.TypeListBoxDown: - if downBtn != nil { - return nil, fmt.Errorf("Duplicate down buttons in menu %v", menu.Locator()) - } - downBtn = rec - case menus.TypeLineKbd, menus.TypeLineBriefing: - items = append(items, rec) - case menus.TypeThumb: - if thumb != nil { - return nil, fmt.Errorf("Duplicate thumbs in menu %v", menu.Locator()) - } - thumb = rec - default: - // e.g. maingame:18.12 includes a button that is not part of the box - otherChildren = append(otherChildren, rec) - } - } - - if len(items) == 0 || thumb == nil || upBtn == nil || downBtn == nil { - return nil, fmt.Errorf("Missing items in menu %v", menu.Locator()) - } - - // Now build the wonderful thing - baseElem, err := registerNoninteractive(d, menu) +func (d *Driver) buildListBox(group *menus.Group, up, down, thumb *menus.Record, items ...*menus.Record) (*listBox, *Widget, error) { + upElem, upWidget, err := d.buildButton(up.Props()) if err != nil { - return nil, err + return nil, nil, err } - upSprId := upBtn.SpriteId[0] - if upSprId == -1 { - upSprId = upBtn.Share - } - - elemUp, err := registerButton(d, upBtn, upSprId) + downElem, downWidget, err := d.buildButton(down.Props()) if err != nil { - return nil, err + return nil, nil, err } - dnSprId := downBtn.SpriteId[0] - if dnSprId == -1 { - dnSprId = downBtn.Share - } - elemDown, err := registerButton(d, downBtn, dnSprId) + thumbBaseSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.Share) if err != nil { - return nil, err + return nil, nil, err } - thumbBaseSpr, err := d.menu.Sprite(menu.ObjectIdx, thumb.Share) + thumbImgSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.BaseSpriteID()) if err != nil { - return nil, err - } - - thumbSprId := thumb.SpriteId[0] - if thumbSprId == -1 { - thumbSprId = thumb.Share - } - - thumbImgSpr, err := d.menu.Sprite(menu.ObjectIdx, thumbSprId) - if err != nil { - return nil, err + return nil, nil, err } element := &listBox{ - base: baseElem, // TODO: upBtn needs to be frozen when offset == 0; downBtn when offset == max - upBtn: elemUp, - downBtn: elemDown, + upBtn: upElem, + downBtn: downElem, // TODO: need to be able to drag the thumb thumbBase: thumbBaseSpr, @@ -128,8 +62,8 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) { } // Internal wiring-up - elemUp.onClick(element.up) - elemDown.onClick(element.down) + upElem.onClick(element.up) + downElem.onClick(element.down) // FIXME: Test data for now for i := 0; i < 50; i++ { @@ -138,30 +72,34 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) { // Register everything. Since we're a composite of other controls, they are // mostly self-registered at the moment. - d.paintables = append(d.paintables, element) + widget := &Widget{ + Children: []*Widget{upWidget, downWidget}, + ownPaintables: []paintable{element}, + } // FIXME: we should be able to freeze/unfreeze as a group. // HURK: These need to be registered after the other elements so they are // drawn in the correct order to be visible for _, rec := range items { - ni, err := registerNoninteractive(d, rec) + ni, niWidget, err := d.buildStatic(rec.Props()) if err != nil { - return nil, err + return nil, nil, err } + // TODO: pick the correct font ni.label = &label{ align: AlignModeLeft, font: d.menu.Font(0), rect: ni.rect, } - element.lines = append(element.lines, ni) + widget.Children = append(widget.Children, niWidget) } element.refresh() - return otherChildren, nil + return element, widget, nil } func (l *listBox) SetStrings(to []string) { diff --git a/internal/ui/menus.go b/internal/ui/menus.go deleted file mode 100644 index f0f1743..0000000 --- a/internal/ui/menus.go +++ /dev/null @@ -1,72 +0,0 @@ -package ui - -import ( - "code.ur.gs/lupine/ordoor/internal/menus" -) - -func init() { - // These menu types don't need driving, so we can ignore them - registerBuilder(menus.TypeMenu, registerMenu) - registerBuilder(menus.TypeDragMenu, nil) // Menus are just containers -} - -func registerMenu(d *Driver, r *menus.Record) ([]*menus.Record, error) { - childrenLeft, err := listBoxFromMenu(d, r, r.Children) - if err != nil { - return nil, err - } - - childrenLeft, err = inventorySelectFromMenu(d, r, childrenLeft) - if err != nil { - return nil, err - } - - // Return all the unhandled children to be processed further - return childrenLeft, nil -} - -func listBoxFromMenu(d *Driver, menu *menus.Record, children []*menus.Record) ([]*menus.Record, error) { - ok := false - for _, rec := range children { - if rec.Type == menus.TypeThumb { // FIXME: we're using this to indicate a listbox - ok = true - break - } - } - - if !ok { - return children, nil - } - - return registerListBox(d, menu) -} - -// Group all inventory selects that share a menu together -func inventorySelectFromMenu(d *Driver, menu *menus.Record, children []*menus.Record) ([]*menus.Record, error) { - var childrenLeft []*menus.Record - var inventorySelects []*inventorySelect - - for _, child := range children { - switch child.Type { - case menus.TypeInventorySelect: - is, err := registerInventorySelect(d, child) - if err != nil { - return nil, err - } - - inventorySelects = append(inventorySelects, is) - default: - childrenLeft = append(childrenLeft, child) - } - } - - if len(inventorySelects) > 0 { - inventorySelects[0].setValue("1") // Always start with one selected - - for _, is := range inventorySelects { - is.others = inventorySelects - } - } - - return childrenLeft, nil -} diff --git a/internal/ui/noninteractive.go b/internal/ui/noninteractive.go index e413378..056f70f 100644 --- a/internal/ui/noninteractive.go +++ b/internal/ui/noninteractive.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "image" "log" @@ -15,22 +16,14 @@ const ( AlignModeLeft AlignMode = 1 ) -func init() { - registerBuilder(menus.TypeStatic, registerStatic) // MainGame has a hypertext child - registerBuilder(menus.TypeHypertext, noChildren(registerHypertext)) - registerBuilder(menus.TypeOverlay, noChildren(registerOverlay)) - registerBuilder(menus.TypeAnimationSample, noChildren(registerAnimation)) - registerBuilder(menus.TypeAnimationHover, noChildren(registerAnimationHover)) -} - // A non-interactive element is not a widget; it merely displays some pixels and // may optionally have a tooltip for display within bounds. // // For non-animated non-interactive elements, just give them a single frame. type noninteractive struct { - path string - frames animation - rect image.Rectangle + locator string + frames animation + rect image.Rectangle // Some non-interactives, e.g., overlays, are an image + text to be shown label *label @@ -58,145 +51,153 @@ type animationHover struct { closing bool } -func registerStatic(d *Driver, r *menus.Record) ([]*menus.Record, error) { - _, err := registerNoninteractive(d, r) - return r.Children, err -} - -func registerNoninteractive(d *Driver, r *menus.Record) (*noninteractive, error) { - // FIXME: SpriteID takes precedence over SHARE if present, but is that right? - spriteId := r.Share - if len(r.SpriteId) > 0 && r.SpriteId[0] != -1 { - spriteId = r.SpriteId[0] +func (d *Driver) buildNoninteractive(p *menus.Properties) (*noninteractive, error) { + // FIXME: SpriteID takes precedence over SHARE if present, but is that + // always right? + spriteId := p.BaseSpriteID() + if spriteId < 0 { + return nil, fmt.Errorf("No base sprite for %v", p.Locator) } - sprite, err := d.menu.Sprite(r.ObjectIdx, spriteId) + sprite, err := d.menu.Sprite(p.ObjectIdx, spriteId) if err != nil { return nil, err } ni := &noninteractive{ - path: r.Path(), - frames: animation{sprite.Image}, - hoverImpl: hoverImpl{text: r.Text}, - rect: sprite.Rect, + locator: p.Locator, + frames: animation{sprite.Image}, + rect: sprite.Rect, } - d.hoverables = append(d.hoverables, ni) - d.paintables = append(d.paintables, ni) - return ni, nil } -func registerHypertext(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.ObjectIdx, r.Share) +func (d *Driver) buildStatic(p *menus.Properties) (*noninteractive, *Widget, error) { + ni, err := d.buildNoninteractive(p) if err != nil { - return err + return nil, nil, err } - ni := &noninteractive{ - path: r.Path(), - hoverImpl: hoverImpl{text: r.Text}, - rect: sprite.Rect, + ni.hoverImpl.text = p.Text + + widget := &Widget{ + Locator: ni.locator, + ownHoverables: []hoverable{ni}, + ownPaintables: []paintable{ni}, } - d.clickables = append(d.clickables, ni) - d.hoverables = append(d.hoverables, ni) + return ni, widget, nil +} - return nil +func (d *Driver) buildHypertext(p *menus.Properties) (*noninteractive, *Widget, error) { + ni, err := d.buildNoninteractive(p) + if err != nil { + return nil, nil, err + } + + // FIXME: check if this is still needed on the bridge -> briefing transition + widget := &Widget{ + Locator: ni.locator, + ownClickables: []clickable{ni}, + ownHoverables: []hoverable{ni}, + } + + return ni, widget, nil } // 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.ObjectIdx, r.Share) +func (d *Driver) buildOverlay(p *menus.Properties) (*noninteractive, *Widget, error) { + ni, err := d.buildNoninteractive(p) if err != nil { - return err + return nil, nil, err } - ni := &noninteractive{ - path: r.Path(), - frames: animation{sprite.Image}, - rect: sprite.Rect, + widget := &Widget{ + Locator: ni.locator, + ownPaintables: []paintable{ni}, } - d.paintables = append(d.paintables, ni) - - if r.Text != "" { + if p.Text != "" { // FIXME: is this always right? Seems to make sense for Main.mnu - fnt := d.menu.Font(r.FontType/10 - 1) + fnt := d.menu.Font(p.FontType/10 - 1) ni.label = &label{ font: fnt, rect: ni.rect, // We will be centered by default - text: r.Text, + text: p.Text, } } else { - log.Printf("Overlay without text detected: %#+v", r) + log.Printf("Overlay without text detected in %v", p.Locator) } - return nil + return ni, widget, nil } // 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.ObjectIdx, r.SpriteId[0]) +func (d *Driver) buildAnimationSample(p *menus.Properties) (*noninteractive, *Widget, error) { + sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0]) if err != nil { - return err + return nil, nil, err } - frames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) + frames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType) if err != nil { - return err + return nil, nil, err } ani := &noninteractive{ - path: r.Path(), + locator: p.Locator, frames: animation(frames), - hoverImpl: hoverImpl{text: r.Text}, + hoverImpl: hoverImpl{text: p.Text}, rect: sprite.Rect, } - d.hoverables = append(d.hoverables, ani) - d.paintables = append(d.paintables, ani) + widget := &Widget{ + ownHoverables: []hoverable{ani}, + ownPaintables: []paintable{ani}, + } - return nil + return ani, widget, nil } -func registerAnimationHover(d *Driver, r *menus.Record) error { - sprite, err := d.menu.Sprite(r.ObjectIdx, r.SpriteId[0]) +func (d *Driver) buildAnimationHover(p *menus.Properties) (*animationHover, *Widget, error) { + sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0]) if err != nil { - return err + return nil, nil, err } - enterFrames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0], r.DrawType) + enterFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType) if err != nil { - return err + return nil, nil, err } - exitFrames, err := d.menu.Images(r.ObjectIdx, r.SpriteId[0]+r.DrawType, r.DrawType) + exitFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0]+p.DrawType, p.DrawType) if err != nil { - return err + return nil, nil, err } ani := &animationHover{ noninteractive: noninteractive{ - path: r.Path(), + locator: p.Locator, frames: animation(enterFrames), - hoverImpl: hoverImpl{text: r.Text}, + hoverImpl: hoverImpl{text: p.Text}, rect: sprite.Rect, }, exitFrames: animation(exitFrames), } - d.hoverables = append(d.hoverables, ani) - d.paintables = append(d.paintables, ani) + widget := &Widget{ + ownHoverables: []hoverable{ani}, + ownPaintables: []paintable{ani}, + } - return nil + return ani, widget, nil } func (n *noninteractive) id() string { - return n.path + return n.locator } func (n *noninteractive) bounds() image.Rectangle { diff --git a/internal/ui/selectors.go b/internal/ui/selectors.go index 6694cec..f80943b 100644 --- a/internal/ui/selectors.go +++ b/internal/ui/selectors.go @@ -9,11 +9,6 @@ import ( "code.ur.gs/lupine/ordoor/internal/menus" ) -func init() { - registerBuilder(menus.TypeCheckbox, noChildren(registerCheckbox)) - registerBuilder(menus.TypeSlider, noChildren(registerSlider)) -} - // A checkbox can be a fancy button type checkbox struct { button @@ -23,7 +18,7 @@ type checkbox struct { // A slider is harder. Two separate elements to render type slider struct { - path string + locator string baseSpr *assetstore.Sprite clickSpr *assetstore.Sprite @@ -38,52 +33,56 @@ 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.ObjectIdx, r.Share, 3) // unchecked, disabled, checked +func (d *Driver) buildCheckbox(p *menus.Properties) (*checkbox, *Widget, error) { + sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // unchecked, disabled, checked if err != nil { - return err + return nil, nil, err } checkbox := &checkbox{ button: button{ - path: r.Path(), + locator: p.Locator, baseSpr: sprites[0], // unchecked clickSpr: sprites[2], // checked - frozenSpr: sprites[1], - hoverImpl: hoverImpl{text: r.Text}, + frozenSpr: sprites[1], // disabled + hoverImpl: hoverImpl{text: p.Text}, }, valueImpl: valueImpl{str: "0"}, } - d.clickables = append(d.clickables, checkbox) - d.freezables = append(d.freezables, checkbox) - d.hoverables = append(d.hoverables, checkbox) - d.paintables = append(d.paintables, checkbox) - d.valueables = append(d.valueables, checkbox) + widget := &Widget{ + ownClickables: []clickable{checkbox}, + ownFreezables: []freezable{checkbox}, + ownHoverables: []hoverable{checkbox}, + ownPaintables: []paintable{checkbox}, + ownValueables: []valueable{checkbox}, + } - return nil + return checkbox, widget, nil } -func registerSlider(d *Driver, r *menus.Record) error { - sprites, err := d.menu.Sprites(r.ObjectIdx, r.Share, 3) // base, clicked, slider element +func (d *Driver) buildSlider(p *menus.Properties) (*slider, *Widget, error) { + sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // base, clicked, slider element if err != nil { - return err + return nil, nil, err } slider := &slider{ - path: r.Path(), + locator: p.Locator, baseSpr: sprites[0], clickSpr: sprites[1], sliderSpr: sprites[2], hv: sprites[0].Rect.Dy() > sprites[0].Rect.Dx(), // A best guess } - d.clickables = append(d.clickables, slider) - d.mouseables = append(d.mouseables, slider) - d.paintables = append(d.paintables, slider) - d.valueables = append(d.valueables, slider) + widget := &Widget{ + ownClickables: []clickable{slider}, + ownMouseables: []mouseable{slider}, + ownPaintables: []paintable{slider}, + ownValueables: []valueable{slider}, + } - return nil + return slider, widget, nil } func (c *checkbox) registerMouseClick() { @@ -107,7 +106,7 @@ func (c *checkbox) regions(tick int) []region { } func (s *slider) id() string { - return s.path + return s.locator } // The bounds of the slider are the whole thing diff --git a/internal/ui/value.go b/internal/ui/value.go new file mode 100644 index 0000000..a9ed43b --- /dev/null +++ b/internal/ui/value.go @@ -0,0 +1,119 @@ +package ui + +import ( + "fmt" + "strconv" +) + +func (d *Driver) realId(id string) string { + return fmt.Sprintf("%v:%v", d.menu.Name, id) +} + +func (d *Driver) Value(id string, into *string) error { + for _, valueable := range d.valueables() { + if valueable.id() == d.realId(id) { + *into = valueable.value() + return nil + } + } + + return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id) +} + +func (d *Driver) SetValue(id, value string) error { + for _, valueable := range d.valueables() { + if valueable.id() == d.realId(id) { + valueable.setValue(value) + return nil + } + } + + return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id) +} + +func (d *Driver) ValueBool(id string, into *bool) error { + var vStr string + if err := d.Value(id, &vStr); err != nil { + return err + } + + *into = vStr == "1" + return nil +} + +func (d *Driver) SetValueBool(id string, value bool) error { + vStr := "0" + if value { + vStr = "1" + } + + return d.SetValue(id, vStr) +} + +func (d *Driver) SetFreeze(id string, value bool) error { + for _, freezable := range d.freezables() { + if freezable.id() == d.realId(id) { + freezable.setFreezeState(value) + return nil + } + } + + return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id) +} + +func (d *Driver) OnClick(id string, f func()) error { + for _, clickable := range d.clickables() { + if clickable.id() == d.realId(id) { + clickable.onClick(f) + return nil + } + } + + // We need to be able to wire up items inside dialogues too + for _, dialogue := range d.dialogues { + for _, clickable := range dialogue.clickables() { + if clickable.id() == d.realId(id) { + clickable.onClick(f) + return nil + } + } + } + + return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id) +} + +// FIXME: HURK. Surely I'm missing something? steps is value:offset +func (d *Driver) ConfigureSlider(id string, steps map[int]int) error { + + for _, clickable := range d.clickables() { + if slider, ok := clickable.(*slider); ok && slider.id() == d.realId(id) { + slider.steps = steps + + return nil + } + } + + return fmt.Errorf("Couldn't find slider %v:%v", d.menu.Name, id) +} + +func (d *Driver) ValueInt(id string, into *int) error { + var vStr string + if err := d.Value(id, &vStr); err != nil { + return err + } + + value, err := strconv.Atoi(vStr) + if err != nil { + return err + } + + *into = value + + return nil +} + +func (d *Driver) SetValueInt(id string, value int) error { + vStr := strconv.Itoa(value) + + return d.SetValue(id, vStr) +} diff --git a/internal/ui/widget.go b/internal/ui/widget.go new file mode 100644 index 0000000..0dfad0e --- /dev/null +++ b/internal/ui/widget.go @@ -0,0 +1,73 @@ +package ui + +type Widget struct { + Locator string + Children []*Widget + + ownClickables []clickable + ownFreezables []freezable + ownHoverables []hoverable + ownMouseables []mouseable + ownPaintables []paintable + ownValueables []valueable +} + +func (w *Widget) clickables() []clickable { + out := w.ownClickables + + for _, widget := range w.Children { + out = append(out, widget.clickables()...) + } + + return out +} + +func (w *Widget) freezables() []freezable { + out := w.ownFreezables + + for _, widget := range w.Children { + out = append(out, widget.freezables()...) + } + + return out +} + +func (w *Widget) hoverables() []hoverable { + out := w.ownHoverables + + for _, widget := range w.Children { + out = append(out, widget.hoverables()...) + } + + return out +} + +func (w *Widget) mouseables() []mouseable { + out := w.ownMouseables + + for _, widget := range w.Children { + out = append(out, widget.mouseables()...) + } + + return out +} + +func (w *Widget) paintables() []paintable { + out := w.ownPaintables + + for _, widget := range w.Children { + out = append(out, widget.paintables()...) + } + + return out +} + +func (w *Widget) valueables() []valueable { + out := w.ownValueables + + for _, widget := range w.Children { + out = append(out, widget.valueables()...) + } + + return out +}