1
0
Fork 0

feat(ui): implement new ui system

This commit is contained in:
Henrik Hautakoski 2025-10-27 20:17:13 +01:00
parent d407eaad8b
commit 6cf0e4abb4
11 changed files with 430 additions and 113 deletions

View file

@ -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()
}

26
game/ui/base/focus.go Normal file
View file

@ -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
}

View file

@ -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)
}

14
game/ui/component.go Normal file
View file

@ -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()
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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
}

48
game/ui/widgets/button.go Normal file
View file

@ -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)
}

39
game/ui/widgets/label.go Normal file
View file

@ -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)
}

136
game/ui/widgets/slider.go Normal file
View file

@ -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)
}