Full Blog TOC

Full Blog Table Of Content with Keywords Available HERE

Wednesday, December 4, 2024

Graphical Game in Go



In this post we review how to create a graphical application in Go.

The graphics is base on Ebitengine - a great game framework. For this post we create a bouncing balls animation. To use this library, first install the dependencies.

The main simply runs the game:

package main

import (
"balls/game"
"github.com/hajimehoshi/ebiten/v2"
"log"
)

func main() {
g := game.ProduceGame()
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
ebiten.SetWindowTitle("Balls")

if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}


The game class is the interaction with the ebittengine framework. It implements the Layout, Update, and Draw methods.


package game

import (
"balls/balls"
"balls/circle"
"balls/point"
"balls/rectangle"
"balls/velocity"
"github.com/hajimehoshi/ebiten/v2"
"image/color"
"math/rand/v2"
)

type Game struct {
balls []*balls.Ball
size *point.Point
bounds []*rectangle.Rectangle
}

func ProduceGame() *Game {
g := Game{
size: point.ProducePoint(1000, 1000),
}

wall := rectangle.ProduceRectangle(
&color.RGBA{
R: 40,
G: 40,
B: 40,
A: 0,
},
point.ProducePoint(0, 0),
g.size,
)
innerBox := rectangle.ProduceRectangle(
g.randomColor(),
point.ProducePoint(300, 300),
point.ProducePoint(600, 600),
)

g.bounds = []*rectangle.Rectangle{
wall,
innerBox,
}

for range 30 {
g.addBall()
}

return &g
}

func (g *Game) randomShort() uint8 {
return uint8(rand.Uint() % 255)
}

func (g *Game) randomColor() color.Color {
return &color.RGBA{
R: g.randomShort(),
G: g.randomShort(),
B: g.randomShort(),
A: 0,
}
}

func (g *Game) addBall() {
radiusLimit := rand.Float32() * g.size.GetX() / 10
radius := radiusLimit
center := point.RandomPoint(g.size.AddAxis(-2 * radius))
center = center.AddAxis(radius)

ballCircle := circle.ProduceCircle(center, radius)
newBall := balls.ProduceBall(
ballCircle,
g.randomColor(),
velocity.ProduceRandomVelocity(4+radiusLimit-radius),
)
g.balls = append(g.balls, newBall)
}

func (g *Game) Update() error {
for _, ball := range g.balls {
ball.Update(g.bounds)
}
return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
for _, bound := range g.bounds {
bound.Draw(screen)
}
for _, ball := range g.balls {
ball.Draw(screen)
}
}

func (g *Game) Layout(_, _ int) (screenWidth, screenHeight int) {
return g.size.GetXTruncated(), g.size.GetYTruncated()
}


We have basic point class:

package point

import (
"fmt"
"math"
"math/rand/v2"
)

type Point struct {
x float32
y float32
}

func ProducePoint(
x float32,
y float32,
) *Point {
return &Point{
x: x,
y: y,
}
}

func RandomPoint(
limits *Point,
) *Point {
x := rand.Float32() * limits.GetX()
y := rand.Float32() * limits.GetY()
return ProducePoint(x, y)
}

func (p *Point) GetX() float32 {
return p.x
}

func (p *Point) GetY() float32 {
return p.y
}

func (p *Point) GetXTruncated() int {
return int(p.x)
}

func (p *Point) GetYTruncated() int {
return int(p.y)
}

func (p *Point) AddPoint(
other *Point,
) *Point {
return ProducePoint(p.x+other.x, p.y+other.y)
}

func (p *Point) AddAxis(
value float32,
) *Point {
return ProducePoint(p.x+value, p.y+value)
}

func (p *Point) DivideAxis(
value float32,
) *Point {
return ProducePoint(p.x/value, p.y/value)
}

func (p *Point) MultiplyAxis(
value float32,
) *Point {
return ProducePoint(p.x*value, p.y*value)
}

func (p *Point) SubtractPoint(
other *Point,
) *Point {
return ProducePoint(p.x-other.x, p.y-other.y)
}

func (p *Point) String() string {
return fmt.Sprintf("(%.2f, %.2f)", p.x, p.y)
}

func (p *Point) Distance(
other *Point,
) float32 {
delta := p.SubtractPoint(other)
return float32(math.Sqrt(float64(delta.x*delta.x + delta.y*delta.y)))
}


And a circle class:


package circle

import (
"balls/point"
"fmt"
)

type Circle struct {
center *point.Point
radius float32
}

func ProduceCircle(
center *point.Point,
radius float32,
) *Circle {
return &Circle{
center: center,
radius: radius,
}
}

func (c *Circle) GetCenter() *point.Point {
return c.center
}

func (c *Circle) GetRadius() float32 {
return c.radius
}

func (c *Circle) String() string {
return fmt.Sprintf("center %v, radius %v", c.center, c.radius)
}


The line class is more complex as it need to find intersection points:


package line

import (
"balls/circle"
"balls/kitmath"
"balls/point"
"fmt"
"math"
)

type Line struct {
start *point.Point
end *point.Point
}

func ProduceLine(
start *point.Point,
end *point.Point,
) *Line {
return &Line{
start: start,
end: end,
}
}

func (l *Line) Extend(
distance float32,
) *Line {
delta := l.end.SubtractPoint(l.start)
magnitude := math.Sqrt(float64(delta.GetX()*delta.GetX() + delta.GetY()*delta.GetY()))
normalized := delta.DivideAxis(float32(magnitude))
extendedVector := normalized.MultiplyAxis(distance)

newEnd := point.ProducePoint(
l.end.GetX()+extendedVector.GetX(),
l.end.GetY()+extendedVector.GetY(),
)

return ProduceLine(
l.start,
newEnd,
)
}

func (l *Line) GetStart() *point.Point {
return l.start

}

func (l *Line) IsVertical() bool {
return l.start.GetX() == l.end.GetX()
}

func (l *Line) FindIntersectionLine(
other *Line,
) *point.Point {
if l.IsVertical() || other.IsVertical() {
return l.findIntersectionAtLeastOneVertical(other)
}
return l.findIntersectionNonVertical(other)
}

func (l *Line) findIntersectionAtLeastOneVertical(
other *Line,
) *point.Point {
if l.IsVertical() && other.IsVertical() {
// if we have multiple crossing point - ignore
return nil
}

if l.IsVertical() {
return l.findIntersectionOnlyIVertical(other)
}
return other.findIntersectionOnlyIVertical(l)
}

func (l *Line) findIntersectionNonVertical(
other *Line,
) *point.Point {
mySlope := l.getEquationSlope()
otherSlope := other.getEquationSlope()
if kitmath.Float32Equal(mySlope, otherSlope) {
// if we have multiple crossing point - ignore
return nil
}

myB := l.getEquationB()
otherB := other.getEquationB()

x := (otherB - myB) / (mySlope - otherSlope)
y := mySlope*x + myB

intersection := point.ProducePoint(x, y)
if l.containsPoint(intersection) && other.containsPoint(intersection) {
return intersection
}

return nil
}

func (l *Line) containsY(
y float32,
) bool {
minY := min(l.start.GetY(), l.end.GetY())
maxY := max(l.start.GetY(), l.end.GetY())
return minY <= y && y <= maxY
}

func (l *Line) containsX(
x float32,
) bool {
minX := min(l.start.GetX(), l.end.GetX())
maxX := max(l.start.GetX(), l.end.GetX())
return minX <= x && x <= maxX
}

func (l *Line) getEquationSlope() float32 {
delta := l.start.SubtractPoint(l.end)
return delta.GetY() / delta.GetX()
}

func (l *Line) getEquationB() float32 {
slope := l.getEquationSlope()
return l.start.GetY() - slope*l.start.GetX()
}

func (l *Line) findIntersectionOnlyIVertical(
other *Line,
) *point.Point {
x := l.start.GetX()

if !other.containsX(x) {
return nil
}

y := other.getEquationSlope()*x + other.getEquationB()
if !l.containsY(y) {
return nil
}

return point.ProducePoint(x, y)
}

func (l *Line) containsPoint(
location *point.Point,
) bool {
return l.containsX(location.GetX()) && l.containsY(location.GetY())
}

func (l *Line) FindIntersectionCircle(
circle *circle.Circle,
) []*point.Point {
var limitedIntersections []*point.Point
intersections := l.findIntersectionCircleUnlimited(circle)
for _, intersection := range intersections {
if !l.containsPoint(intersection) {
continue
}
limitedIntersections = append(limitedIntersections, intersection)
}

return limitedIntersections
}

func (l *Line) findIntersectionCircleUnlimited(
circle *circle.Circle,
) []*point.Point {
if l.IsVertical() {
return l.findIntersectionCircleVertical(circle)
}
return l.findIntersectionCircleHorizontal(circle)
}

func (l *Line) findIntersectionCircleVertical(
circle *circle.Circle,
) []*point.Point {
c := l.start.GetX()
x0 := circle.GetCenter().GetX()
y0 := circle.GetCenter().GetY()
r := circle.GetRadius()

inner := r*r - (c-x0)*(c-x0)
if inner < 0 {
return nil
}

if inner == 0 {
return []*point.Point{
point.ProducePoint(c, y0),
}
}

innerSqrt := float32(math.Sqrt(float64(inner)))
return []*point.Point{
point.ProducePoint(c, y0+innerSqrt),
point.ProducePoint(c, y0-innerSqrt),
}
}

func (l *Line) findIntersectionCircleHorizontal(
circle *circle.Circle,
) []*point.Point {
c := l.start.GetY()
x0 := circle.GetCenter().GetX()
y0 := circle.GetCenter().GetY()
r := circle.GetRadius()

inner := r*r - (c-y0)*(c-y0)
if inner < 0 {
return nil
}

if inner == 0 {
return []*point.Point{
point.ProducePoint(x0, c),
}
}

innerSqrt := float32(math.Sqrt(float64(inner)))
return []*point.Point{
point.ProducePoint(x0+innerSqrt, c),
point.ProducePoint(x0-innerSqrt, c),
}
}

func (l *Line) String() string {
return fmt.Sprintf("start %v end %v", l.start, l.end)
}


The rectangle class is used to represent both the screen edges, and obstacles.


package rectangle

import (
"balls/circle"
"balls/line"
"balls/point"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"image/color"
)

type Rectangle struct {
color color.Color
topLeft *point.Point
bottomRight *point.Point
}

func ProduceRectangle(
color color.Color,
topLeft *point.Point,
bottomRight *point.Point,
) *Rectangle {
return &Rectangle{
color: color,
topLeft: topLeft,
bottomRight: bottomRight,
}
}

func (r *Rectangle) getLines() []*line.Line {
topRight := point.ProducePoint(r.bottomRight.GetX(), r.topLeft.GetY())
bottomLeft := point.ProducePoint(r.topLeft.GetX(), r.bottomRight.GetY())

return []*line.Line{
line.ProduceLine(r.topLeft, topRight),
line.ProduceLine(topRight, r.bottomRight),
line.ProduceLine(r.bottomRight, bottomLeft),
line.ProduceLine(bottomLeft, r.topLeft),
}
}

func (r *Rectangle) FindIntersectionCircle(
circle *circle.Circle,
) (*point.Point, *line.Line) {
var minDistance float32
var intersectionLine *line.Line
var intersectionPoint *point.Point
for _, lineBound := range r.getLines() {
boundIntersections := lineBound.FindIntersectionCircle(circle)
for _, boundIntersection := range boundIntersections {
distance := circle.GetCenter().Distance(boundIntersection)
if intersectionLine == nil || distance < minDistance {
minDistance = distance
intersectionLine = lineBound
intersectionPoint = boundIntersection
}
}
}
return intersectionPoint, intersectionLine
}

func (r *Rectangle) FindIntersectionLine(
moveLine *line.Line,
) (*point.Point, *line.Line) {
var minDistance float32
var intersectionLine *line.Line
var intersectionPoint *point.Point
for _, lineBound := range r.getLines() {
boundIntersection := lineBound.FindIntersectionLine(moveLine)
if boundIntersection == nil {
continue
}
distance := moveLine.GetStart().Distance(boundIntersection)
if intersectionLine == nil || distance < minDistance {
minDistance = distance
intersectionLine = lineBound
intersectionPoint = boundIntersection
}
}
return intersectionPoint, intersectionLine
}

func (r *Rectangle) Draw(screen *ebiten.Image) {
delta := r.bottomRight.SubtractPoint(r.topLeft)
vector.DrawFilledRect(
screen,
r.topLeft.GetX(),
r.topLeft.GetY(),
delta.GetX(),
delta.GetY(),
r.color,
false,
)
}



The velocity class is:


package velocity

import (
"balls/line"
"balls/point"
"fmt"
"math"
"math/rand/v2"
)

type Velocity struct {
speed float32
angleRadian float32
}

func ProduceVelocity(
speed float32,
angle float32,
) *Velocity {
return &Velocity{
speed: speed,
angleRadian: angle,
}
}
func ProduceRandomVelocity(
speedLimit float32,
) *Velocity {
speed := rand.Float32() * speedLimit
angle := rand.Float32() * 2 * math.Pi
return ProduceVelocity(speed, angle)
}

func (v *Velocity) Apply(
location *point.Point,
) *point.Point {
x := location.GetX() + v.speed*float32(math.Cos(float64(v.angleRadian)))
y := location.GetY() + v.speed*float32(math.Sin(float64(v.angleRadian)))
return point.ProducePoint(x, y)
}

func (v *Velocity) isLeft() bool {
return v.angleRadian > math.Pi/2 && v.angleRadian < 1.5*math.Pi
}

func (v *Velocity) isRight() bool {
return v.angleRadian < math.Pi/2 || v.angleRadian > 1.5*math.Pi
}

func (v *Velocity) Bounce(
line *line.Line,
) *Velocity {
if line.IsVertical() {
return v.bounceVertical()
}
return v.bounceHorizontal()
}

func (v *Velocity) bounceVertical() *Velocity {
if v.angleRadian < math.Pi {
return ProduceVelocity(v.speed, math.Pi-v.angleRadian)
}
return ProduceVelocity(v.speed, 3*math.Pi-v.angleRadian)
}

func (v *Velocity) bounceHorizontal() *Velocity {
return ProduceVelocity(v.speed, 2*math.Pi-v.angleRadian)
}

func (v *Velocity) String() string {
return fmt.Sprintf("speed %.2f angle %.2f", v.speed, v.angleRadian)
}


And the ball class represents a ball with circle an velocity. It handles it own movement.


package balls

import (
"balls/circle"
"balls/line"
"balls/rectangle"
"balls/velocity"
"fmt"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"image/color"
)

type Ball struct {
circle *circle.Circle
color color.Color
velocity *velocity.Velocity
}

func ProduceBall(
circle *circle.Circle,
color color.Color,
velocity *velocity.Velocity,
) *Ball {
return &Ball{
circle: circle,
color: color,
velocity: velocity,
}
}

func (b *Ball) Update(
bounds []*rectangle.Rectangle,
) {
newCenter := b.velocity.Apply(b.circle.GetCenter())
newCircle := circle.ProduceCircle(newCenter, b.circle.GetRadius())

for _, bound := range bounds {
currentIntersection, _ := bound.FindIntersectionCircle(b.circle)
if currentIntersection != nil {
// while we are intersecting, we do not bounce
b.circle = circle.ProduceCircle(newCenter, b.circle.GetRadius())
return
}
}

var minDistance *float32
var bouncedLine *line.Line
for _, bound := range bounds {
intersectionPoint, intersectionLine := bound.FindIntersectionCircle(newCircle)
if intersectionPoint == nil {
continue
}

distance := b.circle.GetCenter().Distance(intersectionPoint)
if minDistance == nil || distance < *minDistance {
minDistance = &distance
bouncedLine = intersectionLine
}
}

if minDistance != nil {
b.velocity = b.velocity.Bounce(bouncedLine)
newCenter = b.velocity.Apply(b.circle.GetCenter())
}

b.circle = circle.ProduceCircle(newCenter, b.circle.GetRadius())
}

func (b *Ball) Draw(screen *ebiten.Image) {
vector.DrawFilledCircle(
screen,
b.circle.GetCenter().GetX(),
b.circle.GetCenter().GetY(),
b.circle.GetRadius(),
b.color,
false,
)
}

func (b *Ball) String() string {
return fmt.Sprintf("%v velocity %v color %v", b.circle, b.velocity, b.color)
}


We also have a math helper class:

package kitmath

var grace = float32(0.001)

func Float32Equal(
f1 float32,
f2 float32,
) bool {
delta := f1 - f2
return -grace <= delta && delta <= grace
}



No comments:

Post a Comment