From 6cf0e4abb4ae67ac5ad1f1ec833caf2abd73b4c5 Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Mon, 27 Oct 2025 20:17:13 +0100 Subject: [PATCH] feat(ui): implement new ui system --- game/state/handlers/menu.go | 28 +++---- game/ui/base/focus.go | 26 ++++++ game/ui/button.go | 33 -------- game/ui/component.go | 14 ++++ game/ui/layouts/input_control.go | 57 +++++++++++++ game/ui/layouts/list_box.go | 84 +++++++++++++++++++ game/ui/menu.go | 62 -------------- game/ui/widget.go | 16 +++- game/ui/widgets/button.go | 48 +++++++++++ game/ui/widgets/label.go | 39 +++++++++ game/ui/widgets/slider.go | 136 +++++++++++++++++++++++++++++++ 11 files changed, 430 insertions(+), 113 deletions(-) create mode 100644 game/ui/base/focus.go delete mode 100644 game/ui/button.go create mode 100644 game/ui/component.go create mode 100644 game/ui/layouts/input_control.go create mode 100644 game/ui/layouts/list_box.go delete mode 100644 game/ui/menu.go create mode 100644 game/ui/widgets/button.go create mode 100644 game/ui/widgets/label.go create mode 100644 game/ui/widgets/slider.go diff --git a/game/state/handlers/menu.go b/game/state/handlers/menu.go index e026e84..ef5b389 100644 --- a/game/state/handlers/menu.go +++ b/game/state/handlers/menu.go @@ -6,33 +6,37 @@ import ( "tetris/game" "tetris/game/state" "tetris/game/ui" + "tetris/game/ui/layouts" + "tetris/game/ui/widgets" "tetris/game/uievents" rl "github.com/gen2brain/raylib-go/raylib" ) type MainMenu struct { - menu *ui.Menu + list *layouts.ListBox } func NewMainMenu(fsm state.Transitioner) *MainMenu { return &MainMenu{ - menu: ui.NewMenu([]ui.Widget{ - ui.NewButton("Start", func() { fsm.Switch("gameplay") }), - ui.NewButton("Quit", func() { fsm.Switch("quit") }), - }).OnSelect(uievents.MenuSelect), + list: layouts.NewListBox([]ui.InputWidget{ + widgets.NewButton("Start", 32, func() { fsm.Switch("gameplay") }), + widgets.NewButton("Options", 32, func() { fsm.Switch("options") }), + widgets.NewButton("Quit", 32, func() { fsm.Switch("quit") }), + }).Spacing(10). + OnSelect(uievents.MenuSelect), } } func (main *MainMenu) Enter() { - main.menu.Select(0) + main.list.Select(0) } func (MainMenu) Exit() { } func (menu *MainMenu) Update(fsm state.Transitioner, delta float32) { - menu.menu.HandleInput() + menu.list.HandleInput() } func (MainMenu) renderLogo(offset_x, offset_y int32) { @@ -57,19 +61,11 @@ func (MainMenu) renderLogo(offset_x, offset_y int32) { } } -func (menu MainMenu) renderEntries(offset_x, offset_y int32) { - y := offset_y - for i, entry := range menu.menu.Entries() { - entry.Draw(offset_x, y, menu.menu.IsSelected(i)) - y += 40 - } -} - func (menu MainMenu) Render() { render.Begin(rl.Black) menu.renderLogo(20, 150) - menu.renderEntries(340, 400) + menu.list.Draw(340, 400) render.End() } diff --git a/game/ui/base/focus.go b/game/ui/base/focus.go new file mode 100644 index 0000000..e0e0b10 --- /dev/null +++ b/game/ui/base/focus.go @@ -0,0 +1,26 @@ +package base + +// WithFocus is a base component that implements the Focusable interface +type WithFocus struct { + focus bool +} + +// SetFocus - Set the focus value on this component +func (f *WithFocus) SetFocus(value bool) { + f.focus = value +} + +// Focus - focus this component, syntactic sugar for f.SetFocus(true) +func (f *WithFocus) Focus() { + f.SetFocus(true) +} + +// Unfocus - unfocus this component, syntactic sugar for f.SetFocus(false) +func (f *WithFocus) Unfocus() { + f.SetFocus(false) +} + +// HasFocus - Returns true if this component has focus. +func (f WithFocus) HasFocus() bool { + return f.focus +} diff --git a/game/ui/button.go b/game/ui/button.go deleted file mode 100644 index 5677142..0000000 --- a/game/ui/button.go +++ /dev/null @@ -1,33 +0,0 @@ -package ui - -import ( - "tetris/engine/render" - - rl "github.com/gen2brain/raylib-go/raylib" -) - -type Button struct { - Text string - Action func() -} - -func NewButton(text string, action func()) Button { - return Button{ - Text: text, - Action: action, - } -} - -func (b Button) HandleInput() { - if rl.IsKeyPressed(rl.KeyEnter) { - b.Action() - } -} - -func (b Button) Draw(x, y int32, selected bool) { - col := rl.White - if selected { - col = rl.Red - } - render.DrawTextCenter(x, y, 32, b.Text, col) -} diff --git a/game/ui/component.go b/game/ui/component.go new file mode 100644 index 0000000..083c732 --- /dev/null +++ b/game/ui/component.go @@ -0,0 +1,14 @@ +package ui + +// Focusable is a component that can have focus +type Focusable interface { + SetFocus(value bool) + Focus() + Unfocus() + HasFocus() bool +} + +// ReceivesInput is a component that can receive user input. +type ReceivesInput interface { + HandleInput() +} diff --git a/game/ui/layouts/input_control.go b/game/ui/layouts/input_control.go new file mode 100644 index 0000000..ca256a6 --- /dev/null +++ b/game/ui/layouts/input_control.go @@ -0,0 +1,57 @@ +package layouts + +import ( + "tetris/engine/core" + "tetris/game/ui" + "tetris/game/ui/base" + "tetris/game/ui/widgets" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +// InputControl is a layout that has a label and an a widget associated with it. +type InputControl struct { + *base.WithFocus + label *widgets.Label + widget ui.InputWidget + spacing int +} + +func NewInputControl(label *widgets.Label, wiget ui.InputWidget) *InputControl { + return &InputControl{ + WithFocus: &base.WithFocus{}, + label: label, + widget: wiget, + spacing: 10, + } +} + +func (ic InputControl) Size() core.Vec2i { + return core.Vec2i{ + X: ic.label.Size().X + ic.widget.Size().X + ic.spacing, + Y: max(ic.label.Size().Y, ic.widget.Size().Y), + } +} + +func (ic *InputControl) SetFocus(value bool) { + ic.WithFocus.SetFocus(value) + ic.widget.SetFocus(value) + + // TODO: use a theme system here so colors are not hardcoded. + if value { + ic.label.SetColor(rl.Red) + } else { + ic.label.SetColor(rl.White) + } +} + +func (ic InputControl) HandleInput() { + ic.widget.HandleInput() +} + +func (ic InputControl) Draw(x, y int32) { + size := ic.Size() + label_y := (int32(size.Y) - int32(ic.label.Size().Y)) / 2 + ic.label.Draw(x, y+label_y) + ic.widget.Draw(x+int32(ic.label.Size().X+ic.spacing), y) +} diff --git a/game/ui/layouts/list_box.go b/game/ui/layouts/list_box.go new file mode 100644 index 0000000..4a058cf --- /dev/null +++ b/game/ui/layouts/list_box.go @@ -0,0 +1,84 @@ +package layouts + +import ( + "tetris/game/ui" + "tetris/game/ui/base" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type OnSelectCallback func() + +type ListBox struct { + *base.WithFocus + selected int + entries []ui.InputWidget + onSelect OnSelectCallback + spacing int +} + +func NewListBox(entries []ui.InputWidget) *ListBox { + lb := &ListBox{ + WithFocus: &base.WithFocus{}, + entries: entries, + onSelect: func() {}, + } + lb.entries[0].SetFocus(true) + return lb +} + +func (lb *ListBox) Spacing(value int) *ListBox { + lb.spacing = value + return lb +} + +func (lb *ListBox) OnSelect(callback OnSelectCallback) *ListBox { + lb.onSelect = callback + return lb +} + +func (lb ListBox) Entries() []ui.InputWidget { + return lb.entries +} + +func (lb *ListBox) Select(index int) { + if index >= 0 && index < len(lb.entries) { + lb.entries[lb.selected].SetFocus(false) + lb.selected = index + lb.entries[lb.selected].SetFocus(true) + lb.onSelect() + } +} + +func (lb ListBox) Selected() ui.InputWidget { + return lb.entries[lb.selected] +} + +func (lb ListBox) IsSelected(index int) bool { + return lb.selected == index +} + +func (lb *ListBox) Next() { + lb.Select(lb.selected + 1) +} + +func (lb *ListBox) Previous() { + lb.Select(lb.selected - 1) +} + +func (lb *ListBox) HandleInput() { + if rl.IsKeyPressed(rl.KeyDown) { + lb.Next() + } else if rl.IsKeyPressed(rl.KeyUp) { + lb.Previous() + } else { + lb.Selected().HandleInput() + } +} + +func (lb *ListBox) Draw(x, y int32) { + for _, ent := range lb.entries { + ent.Draw(x, y) + y += int32(ent.Size().Y + lb.spacing) + } +} diff --git a/game/ui/menu.go b/game/ui/menu.go deleted file mode 100644 index 9a5bc83..0000000 --- a/game/ui/menu.go +++ /dev/null @@ -1,62 +0,0 @@ -package ui - -import ( - rl "github.com/gen2brain/raylib-go/raylib" -) - -type OnSelectCallback func() - -type Menu struct { - selected int - entries []Widget - onSelect OnSelectCallback -} - -func NewMenu(entries []Widget) *Menu { - return &Menu{ - entries: entries, - onSelect: func() {}, - } -} - -func (menu *Menu) OnSelect(callback OnSelectCallback) *Menu { - menu.onSelect = callback - return menu -} - -func (menu Menu) Entries() []Widget { - return menu.entries -} - -func (menu *Menu) Select(index int) { - if index >= 0 && index < len(menu.entries) { - menu.selected = index - menu.onSelect() - } -} - -func (menu Menu) Selected() Widget { - return menu.entries[menu.selected] -} - -func (menu Menu) IsSelected(index int) bool { - return menu.selected == index -} - -func (menu *Menu) Next() { - menu.Select(menu.selected + 1) -} - -func (menu *Menu) Previous() { - menu.Select(menu.selected - 1) -} - -func (menu *Menu) HandleInput() { - if rl.IsKeyPressed(rl.KeyDown) { - menu.Next() - } else if rl.IsKeyPressed(rl.KeyUp) { - menu.Previous() - } else { - menu.Selected().HandleInput() - } -} diff --git a/game/ui/widget.go b/game/ui/widget.go index 3f03541..da9cd17 100644 --- a/game/ui/widget.go +++ b/game/ui/widget.go @@ -1,6 +1,18 @@ package ui +import ( + "tetris/engine/core" +) + +// Widget is a base widget (Can be drawn on screen) type Widget interface { - HandleInput() - Draw(x, y int32, selected bool) + Size() core.Vec2i + Draw(x, y int32) +} + +// InputWidget is a widget that also handles user input and can have focus. +type InputWidget interface { + Widget + Focusable + ReceivesInput } diff --git a/game/ui/widgets/button.go b/game/ui/widgets/button.go new file mode 100644 index 0000000..62efd5d --- /dev/null +++ b/game/ui/widgets/button.go @@ -0,0 +1,48 @@ +package widgets + +import ( + "tetris/engine/core" + "tetris/engine/render" + "tetris/game/ui/base" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type Button struct { + *base.WithFocus + Text string + TextSize int32 + Action func() +} + +func NewButton(text string, size int32, action func()) Button { + return Button{ + WithFocus: &base.WithFocus{}, + Text: text, + TextSize: size, + Action: action, + } +} + +func (b Button) Size() core.Vec2i { + s := int(b.TextSize) + return core.Vec2i{ + X: len(b.Text) * s, + Y: s, + } +} + +func (b Button) HandleInput() { + if rl.IsKeyPressed(rl.KeyEnter) { + b.Action() + } +} + +func (b Button) Draw(x, y int32) { + // TODO: use a theme system here so colors are not hardcoded. + col := rl.White + if b.HasFocus() { + col = rl.Red + } + render.DrawTextCenter(x, y, b.TextSize, b.Text, col) +} diff --git a/game/ui/widgets/label.go b/game/ui/widgets/label.go new file mode 100644 index 0000000..027e3d5 --- /dev/null +++ b/game/ui/widgets/label.go @@ -0,0 +1,39 @@ +package widgets + +import ( + "image/color" + + "tetris/engine/core" + "tetris/engine/render" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type Label struct { + text string + size int32 + color color.RGBA +} + +func NewLabel(text string, size int32) *Label { + return &Label{ + text: text, + size: size, + color: rl.White, + } +} + +func (label Label) Size() core.Vec2i { + return core.Vec2i{ + X: int(label.size) * len(label.text), + Y: int(label.size), + } +} + +func (label *Label) SetColor(col color.RGBA) { + label.color = col +} + +func (label Label) Draw(x, y int32) { + render.DrawText(x, y, label.size, label.text, label.color) +} diff --git a/game/ui/widgets/slider.go b/game/ui/widgets/slider.go new file mode 100644 index 0000000..1ae833f --- /dev/null +++ b/game/ui/widgets/slider.go @@ -0,0 +1,136 @@ +package widgets + +import ( + "fmt" + "image/color" + + "tetris/engine/core" + "tetris/engine/input" + "tetris/engine/render" + "tetris/game/ui/base" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +const ( + sliderBorderSize = 4 + sliderWidth = 200 + sliderHeight = 30 + sliderTextSize = 12 + sliderTextOffset = 6 +) + +type SliderOnChange func(this *Slider) + +type Slider struct { + *base.WithFocus + Min int + Max int + Step int + Value int + borderSize int32 + onChange SliderOnChange +} + +func NewSlider(min, max, step int) *Slider { + return &Slider{ + WithFocus: &base.WithFocus{}, + Min: min, + Max: max, + Step: step, + borderSize: 4, + } +} + +func (slider *Slider) WithOnChange(callback SliderOnChange) *Slider { + slider.onChange = callback + return slider +} + +func (slider Slider) Size() core.Vec2i { + w := sliderWidth + (sliderBorderSize * 2) + return core.Vec2i{ + X: w + sliderTextOffset + (sliderTextSize * len(slider.ValueText())), + Y: sliderHeight + (sliderBorderSize * 2), + } +} + +func (slider *Slider) HandleInput() { + if input.KeyPressedWithRepeat(rl.KeyLeft) { + slider.Decrement() + } else if input.KeyPressedWithRepeat(rl.KeyRight) { + slider.Increment() + } +} + +func (slider *Slider) SetValue(value int) { + if value != slider.Value { + slider.Value = value + slider.onChange(slider) + } +} + +func (slider *Slider) Increment() { + slider.SetValue(min(slider.Max, slider.Value+slider.Step)) +} + +func (slider *Slider) Decrement() { + slider.SetValue(max(slider.Min, slider.Value-slider.Step)) +} + +func (slider Slider) Percent() float32 { + return float32(slider.Min+slider.Value) / float32(slider.Max) +} + +func (slider Slider) drawTrack(rect rl.RectangleInt32) { + spacing := int32(2) + num_marks := int32(10) + mark_width := float32(rect.Width-((num_marks+1)*spacing)) / float32(num_marks) + + markRect := rl.Rectangle{ + X: float32(rect.X + spacing), + Y: float32(rect.Y + spacing), + Width: mark_width, + Height: float32(rect.Height - (spacing * 2)), + } + + for range num_marks { + rl.DrawRectangleRec(markRect, rl.DarkGray) + markRect.X += mark_width + float32(spacing) + } +} + +func (slider Slider) drawHandle(x, y, width int32, height int32, col color.RGBA) { + handleWidth := int32(10) + x = x + int32(float32(width-handleWidth-2)*slider.Percent()) + rl.DrawRectangle(x, y, handleWidth, height, col) +} + +func (slider Slider) ValueText() string { + return fmt.Sprintf("%d", int(slider.Percent()*100)) +} + +func (slider Slider) Draw(x, y int32) { + // rl.DrawRectangle(x, y, int32(slider.Size().X), int32(slider.Size().Y), rl.Green) + + // TODO: use a theme system here so colors are not hardcoded. + handleColor := rl.White + if slider.HasFocus() { + handleColor = rl.Red + } + + rect := rl.RectangleInt32{ + X: x + sliderBorderSize, + Y: y + sliderBorderSize, + Width: sliderWidth, + Height: sliderHeight, + } + + render.DrawRectOutlineBorder(rect, slider.borderSize, handleColor) + slider.drawTrack(rect) + slider.drawHandle(rect.X+1, rect.Y+1, int32(rect.Width), sliderHeight-2, handleColor) + + textOffset := x + int32(rect.Width) + (sliderBorderSize * 2) + sliderTextOffset + + render.DrawText(textOffset, rect.Y+8, sliderTextSize, slider.ValueText(), rl.White) +}