doodle/pkg/windows/settings.go
Noah Petherbridge 4de0126b19 Game Controller Support
Adds support for Xbox and Nintendo style game controllers. The gamepad
controls are documented on the README and in the game's Settings window.

The buttons are not customizable yet, except that the player can choose
between two button styles:

* X Style (default): "A" button is on the bottom and "B" on the right.
* N Style: swaps the A/B and the X/Y buttons to use a Nintendo-style
  layout instead of an Xbox-style.
2022-02-19 18:31:22 -08:00

807 lines
17 KiB
Go

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
}