doodle/pkg/windows/settings.go

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
}