diff --git a/assets/fonts/GlassAntiqua-Regular.ttf b/assets/fonts/GlassAntiqua-Regular.ttf
new file mode 100644
index 0000000..8f9a067
Binary files /dev/null and b/assets/fonts/GlassAntiqua-Regular.ttf differ
diff --git a/assets/textures/charmap.bmp b/assets/textures/charmap.bmp
index 73b27e7..a156b68 100644
Binary files a/assets/textures/charmap.bmp and b/assets/textures/charmap.bmp differ
diff --git a/assets/ui/helloworld.xml b/assets/ui/helloworld.xml
new file mode 100644
index 0000000..9a1965a
--- /dev/null
+++ b/assets/ui/helloworld.xml
@@ -0,0 +1,9 @@
+
+
+ 0
+
+
diff --git a/assets/ui/schema.xsd b/assets/ui/schema.xsd
new file mode 100644
index 0000000..67d40ce
--- /dev/null
+++ b/assets/ui/schema.xsd
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/game/main.go b/cmd/game/main.go
index 0732851..ff6ff2c 100644
--- a/cmd/game/main.go
+++ b/cmd/game/main.go
@@ -13,7 +13,7 @@ func main() {
l, err := level.Load("assets/levels/level1.bmlvl")
if err != nil {
- panic(err)
+ log.Fatal(err)
}
level.Set(l)
@@ -25,7 +25,6 @@ func main() {
// game loop
for core.Running() {
- firstperson.GetMouseInput()
firstperson.MovePlayer()
err = firstperson.RenderViewport()
if err != nil {
diff --git a/engine/core/window.go b/engine/core/window.go
index 033d68b..eeb4f3f 100644
--- a/engine/core/window.go
+++ b/engine/core/window.go
@@ -17,6 +17,8 @@
package core
import (
+ "runtime"
+
"github.com/charmbracelet/log"
"github.com/veandco/go-sdl2/sdl"
)
@@ -143,6 +145,8 @@ func Start(t string) {
log.Fatal("window already started")
}
+ runtime.LockOSThread()
+
var err error
title = t
@@ -229,6 +233,7 @@ func Stop() {
_ = window.Destroy()
_ = renderer.Destroy()
sdl.Quit()
+ log.Print("Engine stopped")
}
// Running returns whether the game loop should continue or not.
@@ -242,7 +247,33 @@ func Running() bool {
return running
}
+// Mouse states.
+var (
+ // MouseX is the current X position of the mouse.
+ MouseX int32
+ // MouseY is the current Y position of the mouse.
+ MouseY int32
+ // MouseDeltaX is the change in X position of the mouse since the last frame.
+ MouseDeltaX int32
+ // MouseDeltaY is the change in Y position of the mouse since the last frame.
+ MouseDeltaY int32
+ // MouseState is the current state of the mouse.
+ MouseState uint32
+)
+
func eventLoop() {
+ var mouseX, mouseY int32
+ mouseX, mouseY, MouseState = sdl.GetMouseState()
+ if cursorLocked {
+ MouseDeltaX = mouseX - MouseX
+ MouseDeltaY = mouseY - MouseY
+ } else {
+ MouseDeltaX = 0
+ MouseDeltaY = 0
+ }
+ MouseX = mouseX
+ MouseY = mouseY
+
keyStates = sdl.GetKeyboardState()
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
switch event.GetType() {
@@ -257,13 +288,30 @@ func eventLoop() {
// Present draws the frame to the screen.
// Call this at the very end of the game loop.
func Present() {
+ if cursorHover {
+ sdl.SetCursor(sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_HAND))
+ } else {
+ sdl.SetCursor(sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_ARROW))
+ }
renderer.Present()
frameTime := uint32(sdl.GetTicks64() - frameStartTime)
if frameTime < targetFrameTime {
sdl.Delay(targetFrameTime - frameTime)
}
+
+ cursorHover = false
}
func FPS() float32 {
return 1.0 / DeltaTime
}
+
+func ScreenRect() *sdl.Rect {
+ return &sdl.Rect{W: width, H: height}
+}
+
+var cursorHover = false
+
+func NotifyCursorHover() {
+ cursorHover = true
+}
diff --git a/engine/firstperson/input.go b/engine/firstperson/input.go
index 4b439e0..1257a41 100644
--- a/engine/firstperson/input.go
+++ b/engine/firstperson/input.go
@@ -33,28 +33,12 @@ const (
turnSpeed float32 = 0.1
)
-var (
- MouseX int32
- MouseDeltaX int32
- MouseState uint32
-)
-
-func GetMouseInput() {
- MouseX, _, MouseState = sdl.GetMouseState()
- if core.IsCursorLocked() {
- MouseDeltaX = MouseX - core.CenterX()
- core.Window().WarpMouseInWindow(core.CenterX(), core.CenterY())
- } else {
- MouseDeltaX = 0
- }
-}
-
func KeyDown(key uint8) bool {
return core.KeyStates()[key] != 0
}
func MovePlayer() {
- core.P.Angle += float32(MouseDeltaX) * turnSpeed * core.DeltaTime
+ core.P.Angle += float32(core.MouseDeltaX) * turnSpeed * core.DeltaTime
if KeyDown(KeyForward) {
core.P.Speed = 1
diff --git a/engine/firstperson/window.go b/engine/firstperson/window.go
index e1eb6ff..3b74287 100644
--- a/engine/firstperson/window.go
+++ b/engine/firstperson/window.go
@@ -18,13 +18,11 @@ package firstperson
import (
"github.com/bloodmagesoftware/bloodmage-engine/engine/core"
- "github.com/bloodmagesoftware/bloodmage-engine/engine/level"
"github.com/charmbracelet/log"
"github.com/chewxy/math32"
)
func Init() {
- level.CollisionRound = 0.125
f := func() {
log.Debug("firstperson window resize")
screenDist = core.HalfWidthF() / math32.Tan(halfFov)
diff --git a/engine/font/register.go b/engine/font/register.go
index ba4f3cf..38a0ef7 100644
--- a/engine/font/register.go
+++ b/engine/font/register.go
@@ -14,28 +14,101 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+// Package font is for dealing with ttf fonts in the ui.
+//
+// Call font.Init() before using any other font function.
+// Call font.Quit() at the end of your game.
package font
import (
- "github.com/bloodmagesoftware/bloodmage-engine/engine/textures"
+ "fmt"
+ "os"
+
+ "github.com/veandco/go-sdl2/ttf"
+)
+
+var (
+ // fonts holds all registered fonts wether they are currently loaded or not.
+ fonts map[string]*font
+ // defaultFont is the font that is used when no other font is specified.
+ defaultFont *font
)
type font struct {
- startChar uint32
- endChar uint32
- texture *textures.Texture
- charWidth int32
- charHeight int32
- collumnCount int32
-}
-
-func Load(texturePath string, startChar uint32, endChar uint32, charWidth int32, charHeight int32, collumnCount int32) *font {
- return &font{
- startChar: startChar,
- endChar: endChar,
- texture: textures.Unregistered(texturePath),
- charWidth: charWidth,
- charHeight: charHeight,
- collumnCount: collumnCount,
+ path string
+ ttf *ttf.Font
+}
+
+// Font ensures the font is loaded and returns it.
+func (f *font) Font() (*ttf.Font, error) {
+ if f.ttf != nil {
+ return f.ttf, nil
+ }
+
+ font, err := ttf.OpenFont(f.path, 32)
+ if err != nil {
+ return nil, err
+ }
+ f.ttf = font
+
+ return font, nil
+}
+
+func Init() error {
+ return ttf.Init()
+}
+
+// Quit closes all loaded fonts.
+// After calling this function you can no longer use any font functions before calling Init() again.
+func Quit() {
+ for _, font := range fonts {
+ if font.ttf != nil {
+ font.ttf.Close()
+ font.ttf = nil
+ }
+ }
+ ttf.Quit()
+}
+
+func Register(fontPath string, name string) error {
+ if fonts == nil {
+ fonts = make(map[string]*font)
+ }
+ if _, ok := fonts[name]; ok {
+ return fmt.Errorf("font %s already registered", name)
+ }
+ // check if file exists with os.Stat()
+ if _, err := os.Stat(fontPath); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("font %s does not exist", fontPath)
+ } else {
+ return fmt.Errorf("error checking if font %s exists: %s", fontPath, err)
+ }
+ }
+ fonts[name] = &font{
+ path: fontPath,
+ }
+ return nil
+}
+
+func SetDefault(name string) error {
+ if _, ok := fonts[name]; !ok {
+ return fmt.Errorf("font %s not registered", name)
+ }
+ defaultFont = fonts[name]
+ return nil
+}
+
+func Default() (*ttf.Font, error) {
+ if defaultFont != nil {
+ return defaultFont.Font()
+ }
+ return nil, fmt.Errorf("no default font set")
+}
+
+func Get(name string) (*ttf.Font, error) {
+ if font, ok := fonts[name]; ok {
+ return font.Font()
}
+ return nil, fmt.Errorf("font %s not registered", name)
}
diff --git a/engine/font/rune.go b/engine/font/rune.go
deleted file mode 100644
index df504fd..0000000
--- a/engine/font/rune.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package font
-
-import (
- "errors"
- "fmt"
-
- "github.com/veandco/go-sdl2/sdl"
-)
-
-func (f *font) Rune(r rune) (*sdl.Texture, *sdl.Rect, error) {
- // get rune as number
- rn := uint32(r)
-
- // check if rune is in range
- if rn < f.startChar || rn > f.endChar {
- return nil, nil, fmt.Errorf("rune %v (%v) is not in range %v-%v", rn, r, f.startChar, f.endChar)
- }
-
- // get rune index
- runeIndex := rn - f.startChar
-
- // get rune position in texture atlas raster (x, y)
- runeX := runeIndex % uint32(f.collumnCount)
- runeY := runeIndex / uint32(f.collumnCount)
-
- // get rune position in texture atlas (x, y, w, h)
- runeRect := &sdl.Rect{
- X: int32(runeX) * f.charWidth,
- Y: int32(runeY) * f.charHeight,
- W: f.charWidth,
- H: f.charHeight,
- }
-
- // get rune texture
- runeTexture, err := f.texture.Texture()
- if err != nil {
- return nil, nil, errors.Join(
- errors.New("Failed to get font texture"),
- err,
- )
- }
-
- return runeTexture, runeRect, nil
-}
diff --git a/engine/level/level.go b/engine/level/level.go
index ae17e3c..58336e3 100644
--- a/engine/level/level.go
+++ b/engine/level/level.go
@@ -21,6 +21,7 @@ import (
"path/filepath"
"github.com/bloodmagesoftware/bloodmage-engine/engine/textures"
+ "github.com/charmbracelet/log"
"github.com/veandco/go-sdl2/sdl"
"google.golang.org/protobuf/proto"
)
@@ -171,25 +172,13 @@ func Collision(x int, y int) bool {
return currentLevel.SaveCollision(x, y)
}
-var CollisionRound = float32(0.4921875)
-
-func CollisionF(x float32, y float32) bool {
- for y1 := int(y - CollisionRound); y1 <= int(y+CollisionRound); y1++ {
- for x1 := int(x - CollisionRound); x1 <= int(x+CollisionRound); x1++ {
- if Collision(x1, y1) {
- return true
- }
- }
- }
- return false
-}
-
func (self *Level) WallTexture(x int, y int) *textures.Texture {
if !InBounds(x, y) {
return textures.DefaultTexture()
}
return textures.Get(textures.Key(self.Walls[y*int(self.Width)+x]))
}
+
func (self *Level) SetWall(x int, y int, wall byte) {
if !self.InBounds(x, y) {
return
@@ -206,7 +195,7 @@ func (self *Level) FloorTexture() *sdl.Texture {
if err != nil {
t, err = textures.DefaultTexture().Texture()
if err != nil {
- panic(err)
+ log.Fatal(err)
}
}
return t
@@ -221,7 +210,7 @@ func (self *Level) CeilingTexture() *sdl.Texture {
if err != nil {
t, err = textures.DefaultTexture().Texture()
if err != nil {
- panic(err)
+ log.Fatal(err)
}
}
return t
diff --git a/engine/textures/register.go b/engine/textures/register.go
index df8ffb7..a5fc398 100644
--- a/engine/textures/register.go
+++ b/engine/textures/register.go
@@ -20,6 +20,7 @@ import (
"image/color"
"github.com/bloodmagesoftware/bloodmage-engine/engine/core"
+ "github.com/charmbracelet/log"
"github.com/veandco/go-sdl2/sdl"
)
@@ -33,7 +34,7 @@ var (
)
func Register(texturepath string, key Key) *Texture {
- t := Unregistered(texturepath)
+ t := unregistered(texturepath)
// add texture to registry
registry[key] = t
@@ -41,7 +42,7 @@ func Register(texturepath string, key Key) *Texture {
return t
}
-func Unregistered(texturepath string) *Texture {
+func unregistered(texturepath string) *Texture {
t := &Texture{
path: texturepath,
}
@@ -65,13 +66,13 @@ func DefaultTexture() *Texture {
// create pink texture for missing textures using sdl
s, err := sdl.CreateRGBSurface(0, 1, 1, 32, 0, 0, 0, 0)
if err != nil {
- panic(err)
+ log.Fatal(err)
}
s.Set(0, 0, color.RGBA{R: 255, G: 0, B: 255, A: 255})
t, err := core.Renderer().CreateTextureFromSurface(s)
if err != nil {
- panic(err)
+ log.Fatal(err)
}
s.Free()
diff --git a/engine/topdown/input.go b/engine/topdown/input.go
deleted file mode 100644
index 7ef9830..0000000
--- a/engine/topdown/input.go
+++ /dev/null
@@ -1,69 +0,0 @@
-// Bloodmage Engine
-// Copyright (C) 2024 Frank Mayer
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-package topdown
-
-import (
- "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
- "github.com/bloodmagesoftware/bloodmage-engine/engine/level"
- "github.com/chewxy/math32"
- "github.com/veandco/go-sdl2/sdl"
-)
-
-const (
- velocity = 2
-)
-
-func ProcessInput() {
- core.P.Speed = 0.0
- core.P.Strafe = 0.0
-
- if core.KeyStates()[sdl.SCANCODE_DOWN] == 1 {
- core.P.Speed = 1
- } else if core.KeyStates()[sdl.SCANCODE_UP] == 1 {
- core.P.Speed = -1
- }
-
- if core.KeyStates()[sdl.SCANCODE_LEFT] == 1 {
- core.P.Strafe = -1
- } else if core.KeyStates()[sdl.SCANCODE_RIGHT] == 1 {
- core.P.Strafe = 1
- }
-}
-
-func MovePlayer() {
- // calculate direction vector (normalized)
- length := math32.Sqrt(core.P.Speed*core.P.Speed + core.P.Strafe*core.P.Strafe)
- if length == 0 {
- return
- }
-
- dirX := core.P.Strafe / length * velocity
- dirY := core.P.Speed / length * velocity
-
- // calculate new position
- newX := core.P.X + dirX*core.DeltaTime
- newY := core.P.Y + dirY*core.DeltaTime
-
- // check if new position is valid
- if !level.CollisionF(newX, core.P.Y) {
- core.P.X = newX
- }
-
- if !level.CollisionF(core.P.X, newY) {
- core.P.Y = newY
- }
-}
diff --git a/engine/topdown/renderer.go b/engine/topdown/renderer.go
deleted file mode 100644
index 7626578..0000000
--- a/engine/topdown/renderer.go
+++ /dev/null
@@ -1,75 +0,0 @@
-// Bloodmage Engine
-// Copyright (C) 2024 Frank Mayer
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-package topdown
-
-import (
- "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
- "github.com/bloodmagesoftware/bloodmage-engine/engine/level"
- "github.com/veandco/go-sdl2/sdl"
-)
-
-func minInt32(a, b int32) int32 {
- if a < b {
- return a
- }
- return b
-}
-
-// RenderViewport renders a tow down view of the current level with the player in the center.
-func RenderViewport() {
- tileSize := minInt32(core.Width(), core.Height()) / 10
-
- // draw floor
- rect := sdl.Rect{X: 0, Y: 0, W: tileSize, H: tileSize}
- for y := 0; y != level.Height(); y++ {
- rect.Y = int32(y)*tileSize - int32(core.P.Y*float32(tileSize)) + int32(core.Height())/2
-
- if rect.Y > int32(core.Height()) {
- continue
- }
-
- if rect.Y+rect.H < 0 {
- continue
- }
-
- for x := 0; x != level.Width(); x++ {
- rect.X = int32(x)*tileSize - int32(core.P.X*float32(tileSize)) + int32(core.Width())/2
-
- if rect.X > int32(core.Width()) {
- continue
- }
-
- if rect.X+rect.W < 0 {
- continue
- }
-
- if level.Collision(x, y) {
- _ = core.Renderer().SetDrawColor(128, 128, 128, 255)
- } else {
- _ = core.Renderer().SetDrawColor(8, 8, 8, 255)
- }
-
- _ = core.Renderer().FillRect(&rect)
- }
- }
-
- // draw player
- rect.X = int32(core.Width())/2 - tileSize/2
- rect.Y = int32(core.Height())/2 - tileSize/2
- _ = core.Renderer().SetDrawColor(255, 255, 255, 255)
- _ = core.Renderer().FillRect(&rect)
-}
diff --git a/engine/topdown/topdown.go b/engine/topdown/topdown.go
deleted file mode 100644
index 4701cff..0000000
--- a/engine/topdown/topdown.go
+++ /dev/null
@@ -1,29 +0,0 @@
-// Bloodmage Engine
-// Copyright (C) 2024 Frank Mayer
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-package topdown
-
-import (
- "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
- "github.com/charmbracelet/log"
-)
-
-func Init() {
- f := func() {
- log.Debug("topdown window resize")
- }
- core.OnResize(&f)
-}
diff --git a/engine/ui/button.go b/engine/ui/button.go
new file mode 100644
index 0000000..9697a4a
--- /dev/null
+++ b/engine/ui/button.go
@@ -0,0 +1,105 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "errors"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+type Button struct {
+ doc *document
+ id string
+ mouseDown bool
+ content Element
+ rect sdl.Rect
+}
+
+func newButton() *Button {
+ return &Button{
+ doc: nil,
+ id: "",
+ mouseDown: false,
+ content: nil,
+ rect: sdl.Rect{},
+ }
+}
+
+func (b *Button) AppendChild(e Element) error {
+ if b.content != nil {
+ return errors.New("button already has content")
+ }
+ b.content = e
+ return nil
+}
+
+func (b *Button) SetAttribute(key, value string) error {
+ switch key {
+ case "id":
+ b.id = value
+ default:
+ return errors.New("unknown attribute: " + key)
+ }
+ return nil
+}
+
+func (b *Button) setDocument(doc *document) {
+ b.doc = doc
+}
+
+func (b *Button) setTextContent(content string) error {
+ return errors.New("button cannot have text content")
+}
+
+func (b *Button) setRect(rect *sdl.Rect) {
+ b.rect.X = rect.X
+ b.rect.Y = rect.Y
+ b.rect.W = rect.W
+ b.rect.H = rect.H
+}
+
+func (b *Button) MouseOver() bool {
+ if b.rect.X <= core.MouseX && core.MouseX <= b.rect.X+b.rect.W && b.rect.Y <= core.MouseY && core.MouseY <= b.rect.Y+b.rect.H {
+ core.NotifyCursorHover()
+ return true
+ }
+ b.mouseDown = false
+ return false
+}
+
+// Clicked returns true if these is a rising edge of the left mouse button
+func (b *Button) Clicked() bool {
+ lMouseDown := core.MouseState&sdl.ButtonLMask() != 0
+ if b.MouseOver() {
+ if lMouseDown {
+ b.mouseDown = true
+ return false
+ } else {
+ if b.mouseDown {
+ b.mouseDown = false
+ return true
+ } else {
+ return false
+ }
+ }
+ } else {
+ // b.mouseDown = false
+ return false
+ }
+}
diff --git a/engine/ui/document.go b/engine/ui/document.go
new file mode 100644
index 0000000..554e1ad
--- /dev/null
+++ b/engine/ui/document.go
@@ -0,0 +1,85 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+type document struct {
+ root Element
+ idMap map[string]Element
+}
+
+func newDocument() document {
+ return document{
+ idMap: make(map[string]Element),
+ }
+}
+
+func (d *document) RootElement() Element {
+ return d.root
+}
+
+func (d *document) GetElementById(id string) (Element, bool) {
+ e, ok := d.idMap[id]
+ return e, ok
+}
+
+func (d *document) GetTextElementById(id string) (*Text, bool) {
+ e, ok := d.idMap[id]
+ if !ok {
+ return nil, false
+ }
+ t, ok := e.(*Text)
+ if !ok {
+ return nil, false
+ }
+ return t, true
+}
+
+func (d *document) GetButtonElementById(id string) (*Button, bool) {
+ e, ok := d.idMap[id]
+ if !ok {
+ return nil, false
+ }
+ b, ok := e.(*Button)
+ if !ok {
+ return nil, false
+ }
+ return b, true
+}
+
+func (d *document) GetImageElementById(id string) (*Image, bool) {
+ e, ok := d.idMap[id]
+ if !ok {
+ return nil, false
+ }
+ i, ok := e.(*Image)
+ if !ok {
+ return nil, false
+ }
+ return i, true
+}
+
+func (d *document) GetListElementById(id string) (*List, bool) {
+ e, ok := d.idMap[id]
+ if !ok {
+ return nil, false
+ }
+ l, ok := e.(*List)
+ if !ok {
+ return nil, false
+ }
+ return l, true
+}
diff --git a/engine/ui/image.go b/engine/ui/image.go
new file mode 100644
index 0000000..a9df7c3
--- /dev/null
+++ b/engine/ui/image.go
@@ -0,0 +1,132 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "errors"
+ "strconv"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+type Image struct {
+ doc *document
+ id string
+ src string
+ width int32
+ height int32
+ texture *sdl.Texture
+}
+
+func newImage() *Image {
+ return &Image{
+ doc: nil,
+ id: "",
+ src: "",
+ width: -1,
+ height: -1,
+ texture: nil,
+ }
+}
+
+func (i *Image) AppendChild(e Element) error {
+ return errors.New("Image cannot have children")
+}
+
+func (i *Image) SetAttribute(key, value string) error {
+ switch key {
+ case "id":
+ i.id = value
+ case "src":
+ return i.SetSrc(value)
+ case "width":
+ if w, err := strconv.Atoi(value); err != nil {
+ return err
+ } else if w < 0 {
+ return errors.New("width must be greater than 0")
+ } else {
+ i.width = int32(w)
+ }
+ case "height":
+ if h, err := strconv.Atoi(value); err != nil {
+ return err
+ } else if h < 0 {
+ return errors.New("height must be greater than 0")
+ } else {
+ i.height = int32(h)
+ }
+ default:
+ return errors.New("unknown attribute: " + key)
+ }
+ return nil
+}
+
+func (i *Image) setDocument(doc *document) {
+ i.doc = doc
+}
+
+func (i *Image) Src() string {
+ return i.src
+}
+
+func (i *Image) SetSrc(src string) error {
+ if i.src == src {
+ return nil
+ }
+ if i.texture != nil {
+ err := i.texture.Destroy()
+ if err != nil {
+ return err
+ }
+ }
+ surface, err := sdl.LoadBMP(src)
+ if err != nil {
+ return err
+ }
+ i.texture, err = core.Renderer().CreateTextureFromSurface(surface)
+ if err != nil {
+ return err
+ }
+ surface.Free()
+ i.src = src
+ return nil
+}
+
+func (i *Image) Texture() (*sdl.Texture, error) {
+ if i.texture == nil {
+ return nil, errors.New("texture is nil")
+ }
+ return i.texture, nil
+}
+
+func (i *Image) rect() (*sdl.Rect, error) {
+ if i.width <= 0 {
+ return nil, errors.New("image width must be greater than 0")
+ }
+ if i.height <= 0 {
+ return nil, errors.New("image height must be greater than 0")
+ }
+ return &sdl.Rect{
+ X: 0, Y: 0,
+ W: i.width, H: i.height,
+ }, nil
+}
+
+func (i *Image) setTextContent(content string) error {
+ return errors.New("Image cannot have text content")
+}
diff --git a/engine/ui/list.go b/engine/ui/list.go
new file mode 100644
index 0000000..45fbd22
--- /dev/null
+++ b/engine/ui/list.go
@@ -0,0 +1,75 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import "errors"
+
+type orientation uint8
+
+const (
+ orientation_unset orientation = iota
+ orientation_vertical
+ orientation_horizontal
+)
+
+type List struct {
+ doc *document
+ orientation
+ items []Element
+}
+
+func newList() *List {
+ return &List{
+ doc: nil,
+ orientation: orientation_unset,
+ items: nil,
+ }
+}
+
+func (l *List) AppendChild(e Element) error {
+ l.items = append(l.items, e)
+ return nil
+}
+
+func (l *List) SetAttribute(key, value string) error {
+ switch key {
+ case "orientation":
+ switch value {
+ case "vertical":
+ l.orientation = orientation_vertical
+ case "horizontal":
+ l.orientation = orientation_horizontal
+ default:
+ return errors.New("unknown orientation: " + value)
+ }
+ default:
+ return errors.New("unknown attribute: " + key)
+ }
+ return nil
+}
+
+func (l *List) setDocument(doc *document) {
+ l.doc = doc
+}
+
+func (l *List) Items() []Element {
+ return l.items
+}
+
+func (l *List) setTextContent(content string) error {
+ return errors.New("list cannot have text content")
+}
diff --git a/engine/ui/parser.go b/engine/ui/parser.go
new file mode 100644
index 0000000..de35348
--- /dev/null
+++ b/engine/ui/parser.go
@@ -0,0 +1,131 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/utils"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+type Element interface {
+ AppendChild(Element) error
+ SetAttribute(string, string) error
+ setTextContent(string) error
+ setDocument(*document)
+ draw() (drawFn, *sdl.Rect, error)
+}
+
+func CreateElement(name string) (Element, error) {
+ switch name {
+ case "List":
+ return newList(), nil
+ case "Button":
+ return newButton(), nil
+ case "Text":
+ return newText(), nil
+ case "Image":
+ return newImage(), nil
+ default:
+ return nil, fmt.Errorf("unknown element type: %s", name)
+ }
+}
+
+func Parse(path string) (*document, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ decoder := xml.NewDecoder(file)
+
+ var root Element
+ doc := newDocument()
+ elStack := utils.NewStack[Element]()
+
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ break
+ }
+
+ switch t := token.(type) {
+ case xml.StartElement:
+ // create element
+ el, err := CreateElement(t.Name.Local)
+ if err != nil {
+ return nil, err
+ }
+ // might be root element
+ if root == nil {
+ root = el
+ doc.root = el
+ }
+ el.setDocument(&doc)
+ // set attributes
+ for _, attr := range t.Attr {
+ if attr.Name.Space != "" {
+ continue
+ }
+ if attr.Name.Local == "id" {
+ doc.idMap[attr.Value] = el
+ }
+ if err := el.SetAttribute(attr.Name.Local, attr.Value); err != nil {
+ return nil, errors.Join(
+ fmt.Errorf("error setting attribute %s=%s on element %s", attr.Name.Local, attr.Value, t.Name.Local),
+ err,
+ )
+ }
+ }
+ // append to parent if exists
+ if parent, hasParent := elStack.Peek(); hasParent {
+ if err := (*parent).AppendChild(el); err != nil {
+ return nil, err
+ }
+ }
+ // push to stack for future children
+ elStack.Push(el)
+
+ case xml.CharData:
+ text := strings.TrimSpace(string(t))
+ if text == "" {
+ continue
+ }
+ if el, hasElement := elStack.Peek(); hasElement {
+ if err := (*el).setTextContent(text); err != nil {
+ return nil, err
+ }
+ }
+
+ case xml.EndElement:
+ // pop from stack because we're done with this element
+ elStack.Pop()
+ }
+ }
+
+ if root == nil {
+ return nil, fmt.Errorf("no root element found")
+ }
+
+ return &doc, nil
+}
diff --git a/engine/ui/render.go b/engine/ui/render.go
new file mode 100644
index 0000000..423bfd4
--- /dev/null
+++ b/engine/ui/render.go
@@ -0,0 +1,205 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/font"
+ "github.com/charmbracelet/log"
+ "github.com/veandco/go-sdl2/sdl"
+ "github.com/veandco/go-sdl2/ttf"
+)
+
+type drawFn func(dest *sdl.Rect) error
+
+func (document *document) Draw() error {
+ fn, rect, err := document.root.draw()
+ if err != nil {
+ return err
+ }
+ if rect == nil {
+ rect = core.ScreenRect()
+ }
+ if err := fn(rect); err != nil {
+ return err
+ }
+ return nil
+}
+
+// draw functions
+
+func (image *Image) draw() (drawFn, *sdl.Rect, error) {
+ t, err := image.Texture()
+ if err != nil {
+ return nil, defaultRect, err
+ }
+ rect, err := image.rect()
+ if err != nil {
+ return nil, defaultRect, err
+ }
+
+ return func(dest *sdl.Rect) error {
+ return core.Renderer().Copy(t, nil, dest)
+ }, rect, nil
+}
+
+func (button *Button) draw() (drawFn, *sdl.Rect, error) {
+ if button.content == nil {
+ return nil, defaultRect, errors.New("Button content is nil")
+ }
+
+ fn, rect, err := button.content.draw()
+ return func(dest *sdl.Rect) error {
+ button.setRect(dest)
+ return fn(dest)
+ }, rect, err
+}
+
+func (list *List) draw() (drawFn, *sdl.Rect, error) {
+ fnList := make([]drawFn, len(list.items))
+ rectList := make([]*sdl.Rect, len(list.items))
+
+ expectedSize := sdl.Rect{
+ X: 0, Y: 0,
+ W: 0, H: 0,
+ }
+
+ for i, child := range list.items {
+ fn, srcRect, err := child.draw()
+ if err != nil {
+ return nil, defaultRect, errors.Join(
+ fmt.Errorf("Error retrieving draw function for list item %d", i),
+ err,
+ )
+ }
+ if srcRect == nil {
+ return nil, srcRect, fmt.Errorf("List item %d returned nil rect", i)
+ }
+ if fn == nil {
+ return nil, srcRect, fmt.Errorf("List item %d returned nil draw function", i)
+ }
+ fnList[i] = fn
+ rectList[i] = srcRect
+ switch list.orientation {
+ case orientation_horizontal:
+ expectedSize.W += srcRect.W
+ if srcRect.H > expectedSize.H {
+ expectedSize.H = srcRect.H
+ }
+ case orientation_vertical:
+ expectedSize.H += srcRect.H
+ if srcRect.W > expectedSize.W {
+ expectedSize.W = srcRect.W
+ }
+ }
+ }
+
+ switch list.orientation {
+ case orientation_horizontal:
+ return func(dest *sdl.Rect) error {
+ x := dest.X
+ for i, fn := range fnList {
+ itemRect := rectList[i]
+ width := itemRect.W + itemRect.X
+ itemDestRect := &sdl.Rect{
+ X: x, Y: dest.Y,
+ W: width, H: itemRect.H,
+ }
+ if x+width > dest.W {
+ log.Warnf("list item %d is too wide %d > %d", i, x+width, dest.W)
+ itemDestRect.W = 0
+ }
+ itemDestRect.X = x
+ itemDestRect.W = width
+ if err := fn(itemDestRect); err != nil {
+ return err
+ }
+ x += width
+ }
+ return nil
+ }, &expectedSize, nil
+ case orientation_vertical:
+ return func(dest *sdl.Rect) error {
+ y := dest.Y
+ for i, fn := range fnList {
+ itemRect := rectList[i]
+ height := itemRect.H + itemRect.Y
+ itemDestRect := &sdl.Rect{
+ X: dest.X, Y: y,
+ W: itemRect.W, H: height,
+ }
+ if y+height > dest.H {
+ log.Warnf("list item %d is too tall %d > %d", i, y+height, dest.H)
+ itemDestRect.H = 0
+ }
+ itemDestRect.Y = y
+ itemDestRect.H = height
+ if err := fn(itemDestRect); err != nil {
+ return err
+ }
+ y += height
+ }
+ return nil
+ }, &expectedSize, nil
+ default:
+ return nil, &expectedSize, errors.New("Invalid orientation")
+ }
+}
+
+func (text *Text) draw() (drawFn, *sdl.Rect, error) {
+ var err error
+ var f *ttf.Font
+ if text.font == "" {
+ if f, err = font.Default(); err != nil {
+ return nil, defaultRect, errors.Join(
+ errors.New("No font specified for text element"),
+ err,
+ )
+ }
+ } else {
+ if f, err = font.Get(text.font); err != nil {
+ return nil, defaultRect, errors.Join(
+ fmt.Errorf("Font %s not found", text.font),
+ err,
+ )
+ }
+ }
+
+ surface, err := f.RenderUTF8Solid(text.content, text.color)
+ if err != nil {
+ return nil, defaultRect, err
+ }
+ defer surface.Free()
+
+ scrRect := sdl.Rect{
+ X: 0, Y: 0,
+ W: surface.W, H: surface.H,
+ }
+
+ texture, err := core.Renderer().CreateTextureFromSurface(surface)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ return func(dest *sdl.Rect) error {
+ defer texture.Destroy()
+ return core.Renderer().Copy(texture, &scrRect, dest)
+ }, &scrRect, nil
+}
diff --git a/engine/ui/text.go b/engine/ui/text.go
new file mode 100644
index 0000000..e30e007
--- /dev/null
+++ b/engine/ui/text.go
@@ -0,0 +1,82 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "errors"
+
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+type Text struct {
+ doc *document
+ id string
+ content string
+ font string
+ color sdl.Color
+}
+
+func newText() *Text {
+ return &Text{
+ doc: nil,
+ id: "",
+ content: "",
+ font: "",
+ color: sdl.Color{R: 255, G: 255, B: 255, A: 255},
+ }
+}
+
+func (t *Text) AppendChild(e Element) error {
+ return errors.New("Text cannot have children")
+}
+
+func (t *Text) SetAttribute(key, value string) error {
+ switch key {
+ case "id":
+ t.id = value
+ case "content":
+ t.content = value
+ case "font":
+ t.font = value
+ case "color":
+ var err error
+ if t.color, err = ParseColor(value); err != nil {
+ return err
+ }
+ default:
+ return errors.New("unknown attribute: " + key)
+ }
+ return nil
+}
+
+func (t *Text) setDocument(doc *document) {
+ t.doc = doc
+}
+
+func (t *Text) Content() string {
+ return t.content
+}
+
+func (t *Text) SetContent(content string) error {
+ t.content = content
+ return nil
+}
+
+func (t *Text) setTextContent(content string) error {
+ t.content = content
+ return nil
+}
diff --git a/engine/ui/utils.go b/engine/ui/utils.go
new file mode 100644
index 0000000..02b1269
--- /dev/null
+++ b/engine/ui/utils.go
@@ -0,0 +1,147 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+var (
+ // default rect
+ defaultRect = &sdl.Rect{X: 0, Y: 0, W: 0, H: 0}
+)
+
+// parseColor parses a color string and returns the color channel values.
+//
+// Possible formats:
+// - #RRGGBB
+// - #RRGGBBAA
+// - rgb(R, G, B)
+// - rgba(R, G, B, A)
+func ParseColorChannels(color string) (r uint8, g uint8, b uint8, a uint8, err error) {
+ // Remove any whitespace
+ color = strings.ReplaceAll(color, " ", "")
+
+ // Check for hex color format
+ if strings.HasPrefix(color, "#") {
+ color = strings.TrimPrefix(color, "#")
+
+ switch len(color) {
+ case 6:
+ // #RRGGBB format
+ if r, err = parseHex(color[0:2]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ if g, err = parseHex(color[2:4]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ if b, err = parseHex(color[4:6]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ a = 255
+ case 8:
+ // #RRGGBBAA format
+ if r, err = parseHex(color[0:2]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ if g, err = parseHex(color[2:4]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ if b, err = parseHex(color[4:6]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ if a, err = parseHex(color[6:8]); err != nil {
+ return 0, 0, 0, 0, err
+ }
+ default:
+ return 0, 0, 0, 0, fmt.Errorf("invalid hex color format: %s", color)
+ }
+ } else if strings.HasPrefix(color, "rgb(") && strings.HasSuffix(color, ")") {
+ // cut off the "rgb(" and ")" using indexes
+ // split the values by comma
+ rgbValues := strings.Split(color[4:len(color)-1], ",")
+ if len(rgbValues) != 3 {
+ return 0, 0, 0, 0, fmt.Errorf("invalid rgb color format: %s", color)
+ }
+ if r64, err := strconv.ParseUint(rgbValues[0], 10, 8); err == nil {
+ r = uint8(r64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ if g64, err := strconv.ParseUint(rgbValues[1], 10, 8); err == nil {
+ g = uint8(g64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ if b64, err := strconv.ParseUint(rgbValues[2], 10, 8); err == nil {
+ b = uint8(b64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ a = 255
+ } else if strings.HasPrefix(color, "rgba(") && strings.HasSuffix(color, ")") {
+ // cut off the "rgba(" and ")" using indexes
+ // split the values by comma
+ rgbaValues := strings.Split(color[5:len(color)-1], ",")
+ if len(rgbaValues) != 4 {
+ return 0, 0, 0, 0, fmt.Errorf("invalid rgba color format: %s", color)
+ }
+ if r64, err := strconv.ParseUint(rgbaValues[0], 10, 8); err == nil {
+ r = uint8(r64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ if g64, err := strconv.ParseUint(rgbaValues[1], 10, 8); err == nil {
+ g = uint8(g64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ if b64, err := strconv.ParseUint(rgbaValues[2], 10, 8); err == nil {
+ b = uint8(b64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ if a64, err := strconv.ParseUint(rgbaValues[3], 10, 8); err == nil {
+ a = uint8(a64)
+ } else {
+ return 0, 0, 0, 0, err
+ }
+ } else {
+ return 0, 0, 0, 0, fmt.Errorf("unsupported color format: %s", color)
+ }
+
+ return r, g, b, a, nil
+}
+
+// Helper function to parse hexadecimal values
+func parseHex(hex string) (uint8, error) {
+ value, err := strconv.ParseUint(hex, 16, 8)
+ if err != nil {
+ return 0, err
+ }
+ return uint8(value), nil
+}
+
+// ParseColor parses a color string into an sdl.Color struct.
+func ParseColor(color string) (sdl.Color, error) {
+ r, g, b, a, err := ParseColorChannels(color)
+ return sdl.Color{R: r, G: g, B: b, A: a}, err
+}
diff --git a/engine/ui/utils_test.go b/engine/ui/utils_test.go
new file mode 100644
index 0000000..3f209a2
--- /dev/null
+++ b/engine/ui/utils_test.go
@@ -0,0 +1,87 @@
+// Bloodmage Engine
+// Copyright (C) 2024 Frank Mayer
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ui_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/ui"
+)
+
+func TestParseColorChannels(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ color string
+ r uint8
+ g uint8
+ b uint8
+ a uint8
+ err bool
+ }{
+ {"#000000", 0, 0, 0, 255, false},
+ {"#00000000", 0, 0, 0, 0, false},
+ {"#ffffff", 255, 255, 255, 255, false},
+ {"#ffffffff", 255, 255, 255, 255, false},
+ {"#ff0000", 255, 0, 0, 255, false},
+ {"#ff000000", 255, 0, 0, 0, false},
+ {"#00ff00", 0, 255, 0, 255, false},
+ {"#00ff0000", 0, 255, 0, 0, false},
+ {"#0000ff", 0, 0, 255, 255, false},
+ {"#0000ff00", 0, 0, 255, 0, false},
+ {"rgb(0, 0, 0)", 0, 0, 0, 255, false},
+ {"rgb(11,22,23)", 11, 22, 23, 255, false},
+ {"rgb(11, 22, 23)", 11, 22, 23, 255, false},
+ {"rgb(255, 255, 255)", 255, 255, 255, 255, false},
+ {"rgb(255, 0, 0)", 255, 0, 0, 255, false},
+ {"rgb(0, 255, 0)", 0, 255, 0, 255, false},
+ {"rgb(0, 0, 255)", 0, 0, 255, 255, false},
+ {"rgba(0, 0, 0, 0)", 0, 0, 0, 0, false},
+ {"rgba(255, 255, 255, 255)", 255, 255, 255, 255, false},
+ {"rgba(255, 0, 0, 255)", 255, 0, 0, 255, false},
+ {"rgba(0, 255, 0, 255)", 0, 255, 0, 255, false},
+ {"rgba(0, 0, 255, 255)", 0, 0, 255, 255, false},
+ {"#000", 0, 0, 0, 0, true},
+ {"#0000000", 0, 0, 0, 0, true},
+ {"#000000000", 0, 0, 0, 0, true},
+ {"#0000000000", 0, 0, 0, 0, true},
+ {"#00000000000", 0, 0, 0, 0, true},
+ {"#üdußnz", 0, 0, 0, 0, true},
+ {"#üdußnzßz", 0, 0, 0, 0, true},
+ {"rgb(foo, bar, baz)", 0, 0, 0, 0, true},
+ {"rgba(foo, bar, baz, qux)", 0, 0, 0, 0, true},
+ {"#ff1493", 255, 20, 147, 255, false},
+ {"#ff149380", 255, 20, 147, 128, false},
+ {"rgb(255, 20, 147)", 255, 20, 147, 255, false},
+ {"rgb(255, 20, 147, 128)", 0, 0, 0, 0, true},
+ {"rgba(255, 20, 147, 128)", 255, 20, 147, 128, false},
+ }
+
+ for i, test := range tests {
+ t.Run(fmt.Sprintf("test %d: %s", i, test.color), func(t *testing.T) {
+ r, g, b, a, err := ui.ParseColorChannels(test.color)
+ if err != nil && !test.err {
+ t.Errorf("unexpected error: %s", err)
+ } else if err == nil && test.err {
+ t.Errorf("expected error, got nil (%d, %d, %d, %d)", r, g, b, a)
+ } else if r != test.r || g != test.g || b != test.b || a != test.a {
+ t.Errorf("expected (%d, %d, %d, %d), got (%d, %d, %d, %d)", test.r, test.g, test.b, test.a, r, g, b, a)
+ }
+ })
+ }
+}
diff --git a/engine/utils/stack.go b/engine/utils/stack.go
new file mode 100644
index 0000000..22ea4f9
--- /dev/null
+++ b/engine/utils/stack.go
@@ -0,0 +1,39 @@
+package utils
+
+type Stack[T any] struct {
+ data []T
+}
+
+func NewStack[T any]() *Stack[T] {
+ return &Stack[T]{
+ data: make([]T, 0),
+ }
+}
+
+func (s *Stack[T]) Push(data T) {
+ s.data = append(s.data, data)
+}
+
+func (s *Stack[T]) Pop() (*T, bool) {
+ if len(s.data) == 0 {
+ return nil, false
+ }
+ data := s.data[len(s.data)-1]
+ s.data = s.data[:len(s.data)-1]
+ return &data, true
+}
+
+func (s *Stack[T]) Empty() bool {
+ return len(s.data) == 0
+}
+
+func (s *Stack[T]) Len() int {
+ return len(s.data)
+}
+
+func (s *Stack[T]) Peek() (*T, bool) {
+ if len(s.data) == 0 {
+ return nil, false
+ }
+ return &s.data[len(s.data)-1], true
+}
diff --git a/engine/utils/stack_test.go b/engine/utils/stack_test.go
new file mode 100644
index 0000000..09ee803
--- /dev/null
+++ b/engine/utils/stack_test.go
@@ -0,0 +1,56 @@
+package utils_test
+
+import (
+ "testing"
+
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/utils"
+)
+
+func TestStack(t *testing.T) {
+ t.Parallel()
+ s := utils.NewStack[int]()
+
+ if s.Len() != 0 {
+ t.Errorf("Stack should be empty")
+ }
+
+ s.Push(1)
+ s.Push(2)
+ s.Push(3)
+
+ if s.Len() != 3 {
+ t.Errorf("Stack should have 3 elements")
+ }
+
+ if v, b := s.Pop(); *v != 3 || !b {
+ t.Errorf("Stack should return 3")
+ }
+
+ if s.Len() != 2 {
+ t.Errorf("Stack should have 2 elements")
+ }
+
+ if v, b := s.Pop(); *v != 2 || !b {
+ t.Errorf("Stack should return 2")
+ }
+
+ if s.Len() != 1 {
+ t.Errorf("Stack should have 1 element")
+ }
+
+ if v, b := s.Pop(); *v != 1 || !b {
+ t.Errorf("Stack should return 1")
+ }
+
+ if s.Len() != 0 {
+ t.Errorf("Stack should be empty")
+ }
+
+ if v, b := s.Pop(); v != nil || b {
+ t.Errorf("Stack should return nil and false")
+ }
+
+ if s.Len() != 0 {
+ t.Errorf("Stack should be empty")
+ }
+}
diff --git a/examples/firstperson/main.go b/examples/firstperson/main.go
index 90f8c99..60615e8 100644
--- a/examples/firstperson/main.go
+++ b/examples/firstperson/main.go
@@ -1,43 +1,90 @@
package main
import (
+ "fmt"
+
"github.com/bloodmagesoftware/bloodmage-engine/engine/core"
"github.com/bloodmagesoftware/bloodmage-engine/engine/firstperson"
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/font"
"github.com/bloodmagesoftware/bloodmage-engine/engine/level"
"github.com/bloodmagesoftware/bloodmage-engine/engine/textures"
+ "github.com/bloodmagesoftware/bloodmage-engine/engine/ui"
"github.com/charmbracelet/log"
"github.com/veandco/go-sdl2/sdl"
+ "github.com/veandco/go-sdl2/ttf"
)
func main() {
+ var err error
core.InitOptions()
l := level.New()
level.Set(l)
+
+ // register textures
textures.Register("assets/textures/2.bmp", 2)
textures.Register("assets/textures/1.bmp", 1)
+ // register fonts
+ if err = font.Init(); err != nil {
+ log.Fatal(err)
+ }
+ if err = font.Register("./assets/fonts/GlassAntiqua-Regular.ttf", "Glass Antiqua"); err != nil {
+ log.Fatal(err)
+ }
+ if err = font.SetDefault("Glass Antiqua"); err != nil {
+ log.Fatal(err)
+ }
+
+ // set player start position
core.P.X = 1.5
core.P.Y = 1.5
+ // inet game mode
firstperson.Init()
core.Start("First Person Example")
defer core.Stop()
- var err error
+ core.LockCursor(false)
- core.LockCursor(true)
+ err = ttf.Init()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ document, err := ui.Parse("./assets/ui/helloworld.xml")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ btnEl, ok := document.GetButtonElementById("btn")
+ if !ok {
+ log.Fatal("Could not find element with id 'btn'")
+ }
+ i := 0
+
+ counterEl, ok := document.GetTextElementById("counter")
+ if !ok {
+ log.Fatal("Could not find element with id 'counter'")
+ }
// game loop
for core.Running() {
if core.KeyStates()[sdl.SCANCODE_ESCAPE] != 0 {
break
}
- firstperson.GetMouseInput()
firstperson.MovePlayer()
- err = firstperson.RenderViewport()
- if err != nil {
- log.Error(err)
+ if err = firstperson.RenderViewport(); err != nil {
+ log.Fatal(err)
+ }
+
+ if err = document.Draw(); err != nil {
+ log.Fatal(err)
+ }
+
+ if btnEl.Clicked() {
+ i++
+ _ = counterEl.SetContent(fmt.Sprintf("Clicked %d times", i))
}
// draw frame
diff --git a/examples/topdown/main.go b/examples/topdown/main.go
deleted file mode 100644
index f11cf20..0000000
--- a/examples/topdown/main.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "github.com/bloodmagesoftware/bloodmage-engine/engine/core"
- "github.com/bloodmagesoftware/bloodmage-engine/engine/level"
- "github.com/bloodmagesoftware/bloodmage-engine/engine/topdown"
-)
-
-func main() {
- core.InitOptions()
-
- l := level.New()
- level.Set(l)
-
- core.P.X = 1.5
- core.P.Y = 1.5
-
- topdown.Init()
- core.Start("Top Down Example")
- defer core.Stop()
-
- core.LockCursor(true)
-
- // game loop
- for core.Running() {
- topdown.ProcessInput()
- topdown.MovePlayer()
- topdown.RenderViewport()
-
- // draw frame
- core.Present()
- }
-}