package windows import ( "strconv" "strings" "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/gamepad" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/native" "git.kirsle.net/apps/doodle/pkg/shmem" magicform "git.kirsle.net/apps/doodle/pkg/uix/magic-form" "git.kirsle.net/apps/doodle/pkg/usercfg" "git.kirsle.net/apps/doodle/pkg/userdir" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" "git.kirsle.net/go/ui/style" ) // Settings window. type Settings struct { // Settings passed in by doodle Supervisor *ui.Supervisor Engine render.Engine // Boolean bindings. DebugOverlay *bool DebugCollision *bool HorizontalToolbars *bool EnableFeatures *bool CrosshairSize *int CrosshairColor *render.Color HideTouchHints *bool DisableAutosave *bool ControllerStyle *int // Configuration options. SceneName string // name of scene which called this window ActiveTab string // specify the tab to open OnApply func() } // MakeSettingsWindow initializes a settings window for any scene. // The window width/height are the actual SDL2 window dimensions. func MakeSettingsWindow(windowWidth, windowHeight int, cfg Settings) *ui.Window { win := NewSettingsWindow(cfg) win.Compute(cfg.Engine) win.Supervise(cfg.Supervisor) // Center the window. size := win.Size() win.MoveTo(render.Point{ X: (windowWidth / 2) - (size.W / 2), Y: (windowHeight / 2) - (size.H / 2), }) return win } // NewSettingsWindow initializes the window. func NewSettingsWindow(cfg Settings) *ui.Window { var ( Width = 400 Height = 400 ) window := ui.NewWindow("Settings") window.SetButtons(ui.CloseButton) window.Configure(ui.Config{ Width: Width, Height: Height, Background: render.Grey, }) /////////// // Tab Bar tabFrame := ui.NewTabFrame("Tab Frame") tabFrame.SetBackground(render.DarkGrey) window.Pack(tabFrame, ui.Pack{ Side: ui.N, FillX: true, }) // Make the tabs cfg.makeOptionsTab(tabFrame, Width, Height) cfg.makeControlsTab(tabFrame, Width, Height) cfg.makeControllerTab(tabFrame, Width, Height) cfg.makeExperimentalTab(tabFrame, Width, Height) tabFrame.Supervise(cfg.Supervisor) return window } // saveGameSettings controls pkg/usercfg to write the user settings // to disk, based on the settings toggle-able from the UI window. func saveGameSettings() { log.Info("Saving game settings") if err := usercfg.Save(); err != nil { log.Error("Couldn't save game settings: %s", err) } } // Settings Window "Options" Tab func (c Settings) makeOptionsTab(tabFrame *ui.TabFrame, Width, Height int) *ui.Frame { tab := tabFrame.AddTab("Options", ui.NewLabel(ui.Label{ Text: "Options", Font: balance.TabFont, })) tab.Resize(render.NewRect(Width-4, Height-tab.Size().H-46)) // Common click handler for all settings, // so we can write the updated info to disk. onClick := func(ed ui.EventData) error { saveGameSettings() return nil } var inputBoxWidth = 120 rows := []struct { Header string Text string Boolean *bool Integer *int TextVariable *string Color *render.Color PadY int PadX int name string // for special cases }{ { Text: "Notice: all settings are temporary and controls are not editable.", PadY: 2, }, { Header: "Game Options", }, { Boolean: c.HorizontalToolbars, Text: "Editor: Horizontal instead of vertical toolbars", PadX: 4, name: "toolbars", }, { Boolean: c.HideTouchHints, Text: "Hide touchscreen control hints during Play Mode", PadX: 4, name: "toolbars", }, { Boolean: c.DisableAutosave, Text: "Disable auto-save in the Editor", PadX: 4, name: "autosave", }, { Integer: c.CrosshairSize, Text: "Editor: Crosshair size (0 to disable):", PadX: 4, }, { Color: c.CrosshairColor, Text: "Editor: Crosshair color:", PadX: 4, }, { Header: "Debug Options (temporary)", }, { Boolean: c.DebugOverlay, Text: "Show debug text overlay (F3)", PadX: 4, }, { Boolean: c.DebugCollision, Text: "Show collision hitboxes (F4)", PadX: 4, }, { Header: "My Custom Content", }, { Text: "Levels and doodads you create in-game are placed in your\n" + "Profile Directory, which you can access below:", }, } for _, row := range rows { row := row frame := ui.NewFrame("Frame") tab.Pack(frame, ui.Pack{ Side: ui.N, FillX: true, PadY: row.PadY, }) // Headers get their own row to themselves. if row.Header != "" { label := ui.NewLabel(ui.Label{ Text: row.Header, Font: balance.LabelFont, }) frame.Pack(label, ui.Pack{ Side: ui.W, PadX: row.PadX, }) continue } // Checkboxes get their own row. if row.Boolean != nil { cb := ui.NewCheckbox(row.Text, row.Boolean, ui.NewLabel(ui.Label{ Text: row.Text, Font: balance.UIFont, })) cb.Handle(ui.Click, onClick) cb.Supervise(c.Supervisor) // Add warning to the toolbars option if the EditMode is currently active. if row.name == "toolbars" && c.SceneName == "Edit" { ui.NewTooltip(cb, ui.Tooltip{ Text: "Note: reload your level after changing this option.\n" + "Playtesting and returning will do.", Edge: ui.Top, }) } frame.Pack(cb, ui.Pack{ Side: ui.W, PadX: row.PadX, }) continue } else { // Reserve indented space where the checkbox would have gone. spacer := ui.NewFrame("Spacer") spacer.Resize(render.NewRect(9, 9)) // TODO: ugly UI hack ;) frame.Pack(spacer, ui.Pack{ Side: ui.W, PadX: row.PadX, }) } // Any leftover Text gets packed to the left. if row.Text != "" { tf := ui.NewFrame("TextFrame") label := ui.NewLabel(ui.Label{ Text: row.Text, Font: balance.UIFont, }) tf.Pack(label, ui.Pack{ Side: ui.W, }) frame.Pack(tf, ui.Pack{ Side: ui.W, }) } // Int variables draw as a button to prompt for new value. // In future: TextVariable works here too. if row.Integer != nil { varButton := ui.NewButton("VarButton", ui.NewLabel(ui.Label{ IntVariable: row.Integer, Font: ui.MenuFont, })) varButton.Handle(ui.Click, func(ed ui.EventData) error { shmem.Prompt(row.Text+" ", func(answer string) { if answer == "" { return } a, err := strconv.Atoi(answer) if err != nil { shmem.FlashError(err.Error()) return } if a < 0 { a = 0 } else if a > 100 { a = 100 } *row.Integer = a shmem.Flash("Crosshair size set to %d%% (WIP)", a) // call onClick to save change to disk now onClick(ed) }) return nil }) varButton.Compute(c.Engine) varButton.Resize(render.Rect{ W: inputBoxWidth, H: varButton.Size().H, }) c.Supervisor.Add(varButton) frame.Pack(varButton, ui.Pack{ Side: ui.E, PadX: row.PadX, }) } // Color picker button. if row.Color != nil { btn := ui.NewButton("ColorBtn", ui.NewFrame("")) style := style.DefaultButton style.Background = *row.Color style.HoverBackground = style.Background.Lighten(20) btn.SetStyle(&style) btn.Handle(ui.Click, func(ed ui.EventData) error { // Open a ColorPicker widget. picker, err := ui.NewColorPicker(ui.ColorPicker{ Title: "Select a color", Supervisor: c.Supervisor, Engine: c.Engine, Color: *row.Color, OnManualInput: func(callback func(render.Color)) { // Prompt the user to enter a hex color using the developer shell. shmem.Prompt("New color in hex notation: ", func(answer string) { if answer != "" { // XXX: pure white renders as invisible, fudge it a bit. if answer == "FFFFFF" { answer = "FFFFFE" } color, err := render.HexColor(answer) if err != nil { shmem.Flash("Error with that color code: %s", err) return } callback(color) } }) }, }) if err != nil { log.Error("Couldn't open ColorPicker: %s", err) return err } picker.Then(func(color render.Color) { *row.Color = color style.Background = color style.HoverBackground = style.Background.Lighten(20) // call onClick to save change to disk now onClick(ed) }) picker.Center(shmem.CurrentRenderEngine.WindowSize()) picker.Show() return nil }) btn.Compute(c.Engine) btn.Resize(render.Rect{ W: inputBoxWidth, H: 20, // TODO }) c.Supervisor.Add(btn) frame.Pack(btn, ui.Pack{ Side: ui.E, PadX: row.PadX, }) } } // Button toolbar. btnFrame := ui.NewFrame("Button Frame") tab.Pack(btnFrame, ui.Pack{ Side: ui.N, FillX: true, PadY: 4, }) for _, button := range []struct { Label string Fn func() Style *style.Button }{ { Label: "Open profile directory", Fn: func() { path := strings.ReplaceAll(userdir.ProfileDirectory, "\\", "/") if path[0] != '/' { path = "/" + path } native.OpenURL("file://" + path) }, Style: &balance.ButtonPrimary, }, } { btn := ui.NewButton(button.Label, ui.NewLabel(ui.Label{ Text: button.Label, Font: balance.UIFont, })) if button.Style != nil { btn.SetStyle(button.Style) } btn.Handle(ui.Click, func(ed ui.EventData) error { button.Fn() return nil }) c.Supervisor.Add(btn) btnFrame.Pack(btn, ui.Pack{ Side: ui.W, Expand: true, }) } return tab } // Settings Window "Controls" Tab func (c Settings) makeControlsTab(tabFrame *ui.TabFrame, Width, Height int) *ui.Frame { frame := tabFrame.AddTab("Controls", ui.NewLabel(ui.Label{ Text: "Controls", Font: balance.TabFont, })) frame.Resize(render.NewRect(Width-4, Height-frame.Size().H-46)) var ( halfWidth = (Width - 4) / 2 // the 4 is for window borders, TODO shortcutTabWidth = float64(halfWidth) * 0.5 infoTabWidth = float64(halfWidth) * 0.5 rowHeight = 20 shortcutTabSize = render.NewRect(int(shortcutTabWidth), rowHeight) infoTabSize = render.NewRect(int(infoTabWidth), rowHeight) ) controls := []struct { Header string Label string Shortcut string }{ { Header: "Universal Shortcut Keys", }, { Shortcut: "Escape", Label: "Exit game", }, { Shortcut: "F1", Label: "Guidebook", }, { Shortcut: "`", Label: "Dev console", }, { Header: "Gameplay Controls (Play Mode)", }, { Shortcut: "Up or W", Label: "Jump", }, { Shortcut: "Space", Label: "Activate", }, { Shortcut: "Left or A", Label: "Move left", }, { Shortcut: "Right or D", Label: "Move right", }, { Header: "Level Editor Shortcuts", }, { Shortcut: "Ctrl-N", Label: "New level", }, { Shortcut: "Ctrl-O", Label: "Open drawing", }, { Shortcut: "Ctrl-S", Label: "Save drawing", }, { Shortcut: "Shift-Ctrl-S", Label: "Save a copy", }, { Shortcut: "Ctrl-Z", Label: "Undo stroke", }, { Shortcut: "Ctrl-Y", Label: "Redo stroke", }, { Shortcut: "P", Label: "Playtest", }, { Shortcut: "0", Label: "Scroll to origin", }, { Shortcut: "q", Label: "Doodads", }, { Shortcut: "f", Label: "Pencil Tool", }, { Shortcut: "l", Label: "Line Tool", }, { Shortcut: "r", Label: "Rectangle Tool", }, { Shortcut: "c", Label: "Ellipse Tool", }, { Shortcut: "x", Label: "Eraser Tool", }, } var curFrame = ui.NewFrame("Frame") frame.Pack(curFrame, ui.Pack{ Side: ui.N, FillX: true, }) var i = -1 // manually controlled for _, row := range controls { i++ row := row if row.Header != "" { // Close out a previous Frame? if i != 0 { curFrame = ui.NewFrame("Header Row") frame.Pack(curFrame, ui.Pack{ Side: ui.N, FillX: true, }) } label := ui.NewLabel(ui.Label{ Text: row.Header, Font: balance.LabelFont, }) curFrame.Pack(label, ui.Pack{ Side: ui.W, }) // Set up the next series of shortcut keys. i = -1 curFrame = ui.NewFrame("Frame") frame.Pack(curFrame, ui.Pack{ Side: ui.N, FillX: true, }) continue } // Cut a new frame every 2 items. if i > 0 && i%2 == 0 { curFrame = ui.NewFrame("Frame") frame.Pack(curFrame, ui.Pack{ Side: ui.N, FillX: true, PadY: 2, }) } keyLabel := ui.NewLabel(ui.Label{ Text: row.Shortcut, Font: balance.CodeLiteralFont, }) keyLabel.Configure(ui.Config{ Background: render.RGBA(255, 255, 220, 255), BorderSize: 1, BorderStyle: ui.BorderSunken, BorderColor: render.DarkGrey, }) keyLabel.Resize(shortcutTabSize) curFrame.Pack(keyLabel, ui.Pack{ Side: ui.W, PadX: 1, }) helpLabel := ui.NewLabel(ui.Label{ Text: row.Label, Font: balance.UIFont, }) helpLabel.Resize(infoTabSize) curFrame.Pack(helpLabel, ui.Pack{ Side: ui.W, PadX: 1, }) } return frame } // Settings Window "Experimental" Tab func (c Settings) makeExperimentalTab(tabFrame *ui.TabFrame, Width, Height int) *ui.Frame { tab := tabFrame.AddTab("Experimental", ui.NewLabel(ui.Label{ Text: "Experimental", Font: balance.TabFont, })) tab.Resize(render.NewRect(Width-4, Height-tab.Size().H-46)) // Common click handler for all settings, // so we can write the updated info to disk. onClick := func(ed ui.EventData) error { saveGameSettings() return nil } rows := []struct { Header string Text string Boolean *bool TextVariable *string PadY int PadX int name string // for special cases }{ { Header: "Enable Experimental Features", }, { Text: "The setting below can enable experimental features in this\n" + "game. These are features which are still in development and\n" + "may have unstable or buggy behavior.", PadY: 2, }, { Header: "Viewport window", }, { Text: "This option in the Level menu opens another view into\n" + "the level. Has glitchy wallpaper problems.", PadY: 2, }, { Boolean: c.EnableFeatures, Text: "Enable experimental features", PadX: 4, }, { Text: "Restart the game for changes to take effect.", PadY: 2, }, } for _, row := range rows { row := row frame := ui.NewFrame("Frame") tab.Pack(frame, ui.Pack{ Side: ui.N, FillX: true, PadY: row.PadY, }) // Headers get their own row to themselves. if row.Header != "" { label := ui.NewLabel(ui.Label{ Text: row.Header, Font: balance.LabelFont, }) frame.Pack(label, ui.Pack{ Side: ui.W, PadX: row.PadX, }) continue } // Checkboxes get their own row. if row.Boolean != nil { cb := ui.NewCheckbox(row.Text, row.Boolean, ui.NewLabel(ui.Label{ Text: row.Text, Font: balance.UIFont, })) cb.Handle(ui.Click, onClick) cb.Supervise(c.Supervisor) // Add warning to the toolbars option if the EditMode is currently active. if row.name == "toolbars" && c.SceneName == "Edit" { ui.NewTooltip(cb, ui.Tooltip{ Text: "Note: reload your level after changing this option.\n" + "Playtesting and returning will do.", Edge: ui.Top, }) } frame.Pack(cb, ui.Pack{ Side: ui.W, PadX: row.PadX, }) continue } // Any leftover Text gets packed to the left. if row.Text != "" { tf := ui.NewFrame("TextFrame") label := ui.NewLabel(ui.Label{ Text: row.Text, Font: balance.UIFont, }) tf.Pack(label, ui.Pack{ Side: ui.W, }) frame.Pack(tf, ui.Pack{ Side: ui.W, }) } } return tab } // Settings Window "Controller" Tab func (c Settings) makeControllerTab(tabFrame *ui.TabFrame, Width, Height int) *ui.Frame { tab := tabFrame.AddTab("Gamepad", ui.NewLabel(ui.Label{ Text: "Gamepad", Font: balance.TabFont, })) tab.Resize(render.NewRect(Width-4, Height-tab.Size().H-46)) // Render the form. form := magicform.Form{ Supervisor: c.Supervisor, Engine: c.Engine, Vertical: true, LabelWidth: 150, } form.Create(tab, []magicform.Field{ { Label: "About", Font: balance.LabelFont, }, { Label: "Play Sketchy Maze with an Xbox or Nintendo controller!\n\n" + "Full customization options aren't here yet, but you can\n" + "choose between the 'X Style' or 'N Style' profile below.\n" + "'N Style' will swap the A/B and X/Y buttons.", Font: balance.UIFont, }, { Label: "Button Style:", Font: balance.LabelFont, Type: magicform.Selectbox, Options: []magicform.Option{ { Label: "X Style (default)", Value: int(gamepad.XStyle), }, { Label: "N Style", Value: int(gamepad.NStyle), }, }, SelectValue: *c.ControllerStyle, OnSelect: func(v interface{}) { style, _ := v.(int) log.Error("style: %d", style) gamepad.SetStyle(gamepad.Style(style)) saveGameSettings() }, }, { Label: "\nThe gamepad controls vary between two modes:", Font: balance.UIFont, }, { Label: "Mouse Mode (outside of gameplay)", Font: balance.LabelFont, }, { Label: "The left analog stick moves a mouse cursor around.\n" + "The right analog stick scrolls the level around.\n" + "A or X: Left-click B or Y: Right-click\n" + "L1: Middle-click L2: Close window", Font: balance.UIFont, }, { Label: "Gameplay Mode", Font: balance.LabelFont, }, { Label: "Left stick or D-Pad to move the player around.\n" + "A or X: 'Use' B or Y: 'Jump'\n" + "R1: Toggle between Mouse and Gameplay controls.", Font: balance.UIFont, }, }) return tab }