commit 70cee9e3f016bf5c8e7741887d73e0751d87993b Author: iegod Date: Thu Nov 27 22:50:36 2025 -0500 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e68436c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.exe +*.sum +working/ +.vscode_build/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7921202 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "output": "${workspaceFolder}/.vscode_build/debug_bin" + } + ] +} \ No newline at end of file diff --git a/colliders/baserectcollider.go b/colliders/baserectcollider.go new file mode 100644 index 0000000..9c89d8f --- /dev/null +++ b/colliders/baserectcollider.go @@ -0,0 +1,50 @@ +package colliders + +import ( + "fluids/gamedata" + "image" +) + +type BaseRectCollider struct { + position gamedata.Vector + dimensions gamedata.Vector + iscontainer bool +} + +func (b *BaseRectCollider) SetContainer(v bool) { + b.iscontainer = v +} + +func (b *BaseRectCollider) SetPosition(p gamedata.Vector) { + b.position = p +} + +func (b *BaseRectCollider) GetPosition() gamedata.Vector { + return b.position +} + +func (b *BaseRectCollider) SetDimensions(d gamedata.Vector) { + b.dimensions = d +} + +func (b *BaseRectCollider) GetDimensions() gamedata.Vector { + return b.dimensions +} + +func (b *BaseRectCollider) GetBounds() image.Rectangle { + r := image.Rectangle{ + Min: image.Point{ + X: int(b.position.X - b.dimensions.X/2), + Y: int(b.position.Y - b.dimensions.Y/2), + }, + Max: image.Point{ + X: int(b.position.X + b.dimensions.X/2), + Y: int(b.position.Y + b.dimensions.Y/2), + }, + } + return r +} + +func (b *BaseRectCollider) IsContainer() bool { + return b.iscontainer +} diff --git a/colliders/collider.go b/colliders/collider.go new file mode 100644 index 0000000..7958a90 --- /dev/null +++ b/colliders/collider.go @@ -0,0 +1,15 @@ +package colliders + +import ( + "fluids/gamedata" +) + +type Collider interface { + GetPosition() gamedata.Vector + GetDimensions() gamedata.Vector + SetPosition(gamedata.Vector) + //GetBounds() image.Rectangle + + //indicates whether this collider is inverted, i.e. 'contains' elements + //IsContainer() bool +} diff --git a/elements/box.go b/elements/box.go new file mode 100644 index 0000000..53b6dd4 --- /dev/null +++ b/elements/box.go @@ -0,0 +1,8 @@ +package elements + +import "fluids/gamedata" + +type Box struct { + Position gamedata.Vector + Dimensions gamedata.Vector +} diff --git a/elements/particle.go b/elements/particle.go new file mode 100644 index 0000000..e819134 --- /dev/null +++ b/elements/particle.go @@ -0,0 +1,25 @@ +package elements + +import "fluids/gamedata" + +type Particle struct { + Position gamedata.Vector + Velocity gamedata.Vector + Radius float64 +} + +func (p Particle) GetDimensions() gamedata.Vector { + dim := gamedata.Vector{ + X: p.Radius * 2, + Y: p.Radius * 2, + } + return dim +} + +func (p Particle) GetPosition() gamedata.Vector { + return p.Position +} + +func (p *Particle) SetPosition(position gamedata.Vector) { + p.Position = position +} diff --git a/game/game.go b/game/game.go new file mode 100644 index 0000000..f289569 --- /dev/null +++ b/game/game.go @@ -0,0 +1,351 @@ +package game + +import ( + "fluids/elements" + "fluids/gamedata" + "fluids/quadtree" + "fmt" + "image/color" + "math" + "math/rand" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +const ( + GameWidth = 640 + GameHeight = 360 + GameParticleCount = 1000 + GameGravity = 2 + GameParticleRadius = 5 + GameDamping = .7 + GameDeltaTimeStep = 0.5 + + GameInfluenceRadius = 30 +) + +type Game struct { + particles []*elements.Particle + cycle int + particlebox *gamedata.Vector + particlebuff *ebiten.Image + quadtree *quadtree.Quadtree + collisionquad quadtree.Quadrant + paused bool + renderquads bool + resolvecollisions bool +} + +func NewGame() *Game { + g := &Game{ + particlebuff: ebiten.NewImage(GameParticleRadius*2, GameParticleRadius*2), + paused: false, + renderquads: false, + resolvecollisions: false, + } + + g.particlebox = &gamedata.Vector{ + X: GameWidth - 50, + Y: GameHeight - 50, + } + + quad := quadtree.Quadrant{ + Position: gamedata.Vector{X: GameWidth / 2, Y: GameHeight / 2}, + Dimensions: gamedata.Vector{X: GameWidth, Y: GameHeight}, + } + g.quadtree = quadtree.New(quad) + + vector.FillCircle(g.particlebuff, GameParticleRadius, GameParticleRadius, GameParticleRadius, color.White, true) + + g.collisionquad = quadtree.Quadrant{ + Position: gamedata.Vector{ + X: 0, + Y: 0, + }, + Dimensions: gamedata.Vector{ + X: GameParticleRadius * 2, + Y: GameParticleRadius * 2, + }, + } + + //g.InitializeColliders() + g.InitializeParticles() + + return g +} + +func (g *Game) Update() error { + + g.ParseInputs() + g.RebuildQuadtree() + + if !g.paused { + g.UpdateParticles() + } + + g.cycle++ + + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + screen.Clear() + g.RenderParticles(screen) + g.RenderBox(screen) + + if g.renderquads { + g.RenderQuadrants(screen) + } +} + +func (g *Game) Layout(x, y int) (int, int) { + return GameWidth, GameHeight +} + +func (g *Game) RenderParticles(img *ebiten.Image) { + // mx, my := ebiten.CursorPosition() + //clr := color.RGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} + + for _, particle := range g.particles { + + x0 := particle.Position.X - GameParticleRadius + y0 := particle.Position.Y - GameParticleRadius + + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(x0, y0) + img.DrawImage(g.particlebuff, op) + + //vector.FillCircle(img, float32(particle.Position.X), float32(particle.Position.Y), float32(particle.Radius), color.White, true) + // vector.StrokeCircle(img, float32(particle.Position.X), float32(particle.Position.Y), GameInfluenceRadius, 1, clr, true) + // vector.StrokeLine(img, float32(mx), float32(my), float32(particle.Position.X), float32(particle.Position.Y), 2, clr, true) + } +} + +func (g *Game) RenderBox(img *ebiten.Image) { + x0 := (GameWidth - g.particlebox.X) / 2 + y0 := (GameHeight - g.particlebox.Y) / 2 + vector.StrokeRect(img, float32(x0), float32(y0), float32(g.particlebox.X), float32(g.particlebox.Y), 2, color.White, true) +} + +func (g *Game) InitializeColliders() { + /* + //box container + box := &colliders.BaseRectCollider{} + box.SetDimensions(gamedata.Vector{ + X: GameWidth - 50, + Y: GameHeight - 50, + }) + box.SetPosition(gamedata.Vector{ + X: GameWidth / 2, + Y: GameHeight / 2, + }) + box.SetContainer(true) + g.rectColliders = append(g.rectColliders, box) + */ +} + +func (g *Game) InitializeParticles() { + + g.particles = g.particles[:0] + + xmin := (GameWidth-g.particlebox.X)/2 + GameParticleRadius + xmax := g.particlebox.X - GameParticleRadius*2 + ymin := (GameHeight-g.particlebox.Y)/2 + GameParticleRadius + ymax := g.particlebox.Y - GameParticleRadius*2 + + for i := 0; i < GameParticleCount; i++ { + + p := &elements.Particle{ + Position: gamedata.Vector{ + X: xmin + rand.Float64()*xmax, + Y: ymin + rand.Float64()*ymax, + }, + Velocity: gamedata.Vector{ + X: 0, + Y: 0, + }, + Radius: GameParticleRadius, + } + + g.particles = append(g.particles, p) + } + +} + +func (g *Game) UpdateParticles() { + + dt := GameDeltaTimeStep + + /* + mx, my := ebiten.CursorPosition() + mpos := gamedata.Vector{X: float64(mx), Y: float64(my)} + maxdeflect := 40 * GameDeltaTimeStep * GameGravity + */ + + for _, particle := range g.particles { + particle.Velocity.Y += GameGravity * dt + + //if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + /* + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { + delta := gamedata.Vector{ + X: mpos.X - particle.Position.X, + Y: mpos.Y - particle.Position.Y, + } + + dist := math.Sqrt(delta.X*delta.X + delta.Y*delta.Y) + theta := math.Atan2(delta.Y, delta.X) + + if dist < GameInfluenceRadius { + + dx := dist * math.Cos(theta) + dy := dist * math.Sin(theta) + + if dx != 0 { + gainx := (-1./GameInfluenceRadius)*math.Abs(dx) + 1. + particle.Velocity.X += 20 * gainx * -1 * math.Copysign(1, dx) + } + + if dy != 0 { + gainy := (-1./GameInfluenceRadius)*math.Abs(dy) + 1. + particle.Velocity.Y += maxdeflect * gainy * -1 * math.Copysign(1, dy) + } + } + + } + */ + + particle.Position.X += particle.Velocity.X * dt + particle.Position.Y += particle.Velocity.Y * dt + + if g.resolvecollisions { + g.ResolveCollisions(particle) + } + + g.BoundParticle(particle) + } + +} + +func (g *Game) BoundParticle(p *elements.Particle) { + + xmin := (GameWidth-g.particlebox.X)/2 + p.Radius + xmax := xmin + g.particlebox.X - p.Radius*2 + + if p.Position.X > xmax { + p.Velocity.X *= -1 * GameDamping + p.Position.X = xmax + } + + if p.Position.X < xmin { + p.Velocity.X *= -1 * GameDamping + p.Position.X = xmin + } + + ymin := (GameHeight-g.particlebox.Y)/2 + p.Radius + ymax := ymin + g.particlebox.Y - p.Radius*2 + + if p.Position.Y > ymax { + p.Velocity.Y *= -1 * GameDamping + p.Position.Y = ymax + } + + if p.Position.Y < ymin { + p.Velocity.Y *= -1 * GameDamping + p.Position.Y = ymin + } + +} + +func (g *Game) RenderQuadrants(img *ebiten.Image) { + clr := color.RGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} + + quadrants := g.quadtree.GetQuadrants() + for _, quad := range quadrants { + ox := float32(quad.Position.X - quad.Dimensions.X/2) + oy := float32(quad.Position.Y - quad.Dimensions.Y/2) + vector.StrokeRect(img, ox, oy, float32(quad.Dimensions.X), float32(quad.Dimensions.Y), 1, clr, true) + } + +} + +func (g *Game) ParseInputs() { + if inpututil.IsKeyJustPressed(ebiten.KeyR) { + g.InitializeParticles() + } + + if inpututil.IsKeyJustPressed(ebiten.KeyP) { + g.paused = !g.paused + } + + if inpututil.IsKeyJustPressed(ebiten.KeyQ) { + g.renderquads = !g.renderquads + } + + if inpututil.IsKeyJustPressed(ebiten.KeyC) { + g.resolvecollisions = !g.resolvecollisions + } + +} + +func (g *Game) RebuildQuadtree() { + g.quadtree.Clear() + + for _, p := range g.particles { + if !g.quadtree.Insert(p) { + fmt.Println("quadtree insertion failed") + } + } +} + +func (g *Game) ResolveCollisions(particle *elements.Particle) { + //construct search quadrant from current particle + quadrant := quadtree.Quadrant{ + Position: particle.Position, + Dimensions: particle.GetDimensions(), + } + + //find list of possible maybe collisions, we inspect those in more detail + maybes := g.quadtree.FindAll(quadrant) + + sqdist := float64(GameParticleRadius*GameParticleRadius) * 4 + + for _, p := range maybes { + if p == particle { + continue + } + + pos := p.GetPosition() + delta := gamedata.Vector{ + X: pos.X - particle.Position.X, + Y: pos.Y - particle.Position.Y, + } + dist2 := delta.X*delta.X + delta.Y*delta.Y + + if dist2 == 0 { + // Same position: pick a fallback direction to avoid NaN + delta.X = 1 + delta.Y = 0 + dist2 = 1 + } + + if dist2 < sqdist { + d := math.Sqrt(dist2) + nx, ny := delta.X/d, delta.Y/d + overlap := GameParticleRadius*2 - d + + pos.X += nx * overlap + pos.Y += ny * overlap + p.SetPosition(pos) + /* + newpos := gamedata.Vector{ + X: pos.X + delta.X, + Y: pos.Y + delta.Y, + } + p.SetPosition(newpos) + */ + } + } +} diff --git a/gamedata/vector.go b/gamedata/vector.go new file mode 100644 index 0000000..becdff4 --- /dev/null +++ b/gamedata/vector.go @@ -0,0 +1,6 @@ +package gamedata + +type Vector struct { + X float64 + Y float64 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a2c6df --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module fluids + +go 1.24.1 + +require github.com/hajimehoshi/ebiten/v2 v2.9.4 + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..168e6e5 --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fluids/game" + "fmt" + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + ScreenWidth = 640 + ScreenHeight = 360 +) + +func main() { + fmt.Println("fluid experiments") + + g := game.NewGame() + + ebiten.SetWindowTitle("fluids") + ebiten.SetWindowSize(ScreenWidth*2, ScreenHeight*2) + + err := ebiten.RunGame(g) + if err != nil { + log.Fatal(err) + } + +} diff --git a/quadtree/quadtree.go b/quadtree/quadtree.go new file mode 100644 index 0000000..c7e5d6f --- /dev/null +++ b/quadtree/quadtree.go @@ -0,0 +1,192 @@ +package quadtree + +import ( + "fluids/colliders" + "fluids/gamedata" +) + +const ( + QuadtreeMaxColliders = 10 +) + +type Quadrant struct { + Position gamedata.Vector + Dimensions gamedata.Vector +} + +type Quadtree struct { + quadrant Quadrant + children []*Quadtree + colliders []colliders.Collider +} + +func New(quadrant Quadrant) *Quadtree { + qt := &Quadtree{ + quadrant: quadrant, + } + return qt +} + +func (q *Quadtree) Insert(obj colliders.Collider) bool { + + var result bool = false + //check that object meets containment criteria + if q.ContainsPoint(obj.GetPosition()) { + + //if we have children, we go deeper + if len(q.children) > 0 { + for _, node := range q.children { + if node.Insert(obj) { + result = true + break + } + } + } else if len(q.colliders) < QuadtreeMaxColliders { + //otherwise, if we have space, add + q.colliders = append(q.colliders, obj) + result = true + } else { + //otherwise we have to subdivide, reorganize, and add + result = q.SubdivideAndInsert(obj) + } + } + return result +} + +func (q *Quadtree) ContainsPoint(p gamedata.Vector) bool { + var result bool = false + if (q.quadrant.Position.X-q.quadrant.Dimensions.X/2) <= p.X && + p.X <= (q.quadrant.Position.X+q.quadrant.Dimensions.X/2) && + (q.quadrant.Position.Y-q.quadrant.Dimensions.Y/2) <= p.Y && + p.Y <= (q.quadrant.Position.Y+q.quadrant.Dimensions.Y/2) { + result = true + } + return result +} + +func (q *Quadtree) Remove(obj colliders.Collider) bool { + var result bool = false + + if q.ContainsPoint(obj.GetPosition()) { + + if len(q.colliders) > 0 { + //examine existing colliders + var collection []colliders.Collider + for _, node := range q.colliders { + if node == obj { + result = true + } else { + collection = append(collection, node) + } + } + q.colliders = collection + } else { + //need to check children + for _, child := range q.children { + if child.Remove(obj) { + result = true + break + } + } + } + } + + return result +} + +func (q *Quadtree) SubdivideAndInsert(obj colliders.Collider) bool { + + var result bool = false + + //initialize up children + q.children = q.children[:0] + q.children = append(q.children, New(Quadrant{Position: gamedata.Vector{X: q.quadrant.Position.X - q.quadrant.Dimensions.X/4, Y: q.quadrant.Position.Y - q.quadrant.Dimensions.Y/4}, Dimensions: gamedata.Vector{X: q.quadrant.Dimensions.X / 2, Y: q.quadrant.Dimensions.Y / 2}})) + q.children = append(q.children, New(Quadrant{Position: gamedata.Vector{X: q.quadrant.Position.X + q.quadrant.Dimensions.X/4, Y: q.quadrant.Position.Y - q.quadrant.Dimensions.Y/4}, Dimensions: gamedata.Vector{X: q.quadrant.Dimensions.X / 2, Y: q.quadrant.Dimensions.Y / 2}})) + q.children = append(q.children, New(Quadrant{Position: gamedata.Vector{X: q.quadrant.Position.X - q.quadrant.Dimensions.X/4, Y: q.quadrant.Position.Y + q.quadrant.Dimensions.Y/4}, Dimensions: gamedata.Vector{X: q.quadrant.Dimensions.X / 2, Y: q.quadrant.Dimensions.Y / 2}})) + q.children = append(q.children, New(Quadrant{Position: gamedata.Vector{X: q.quadrant.Position.X + q.quadrant.Dimensions.X/4, Y: q.quadrant.Position.Y + q.quadrant.Dimensions.Y/4}, Dimensions: gamedata.Vector{X: q.quadrant.Dimensions.X / 2, Y: q.quadrant.Dimensions.Y / 2}})) + + //move colliders into child nodes + var failed bool = false + for _, collider := range q.colliders { + var added bool = false + for _, node := range q.children { + if node.Insert(collider) { + added = true + break + } + } + + //couldn't add collider into any subdivided node; failure + if !added { + failed = true + break + } + } + + //if all went well: + // a. need to wipe colliders from current node + // b. need to now insert new collider into one of the children + if !failed { + q.colliders = q.colliders[:0] + + for _, node := range q.children { + if node.Insert(obj) { + result = true + break + } + } + } + + return result +} + +func (q *Quadtree) Clear() { + q.colliders = q.colliders[:0] + q.children = q.children[:0] +} + +func (q *Quadtree) GetQuadrants() []Quadrant { + var result []Quadrant + + //if we have children, traverse and add their quadrants + if len(q.children) > 0 { + for _, child := range q.children { + result = append(result, child.GetQuadrants()...) + } + } + + result = append(result, q.quadrant) + + return result +} + +// find and retrieve all colliders in given quadrant +func (q *Quadtree) FindAll(quadrant Quadrant) []colliders.Collider { + var result []colliders.Collider + + //search coordinates + sx0 := quadrant.Position.X - quadrant.Dimensions.X/2 + sx1 := quadrant.Position.X + quadrant.Dimensions.X/2 + sy0 := quadrant.Position.Y - quadrant.Dimensions.Y/2 + sy1 := quadrant.Position.Y + quadrant.Dimensions.Y/2 + + //quadtree coordinates + qx0 := q.quadrant.Position.X - q.quadrant.Dimensions.X/2 + qx1 := q.quadrant.Position.X + q.quadrant.Dimensions.X/2 + qy0 := q.quadrant.Position.Y - q.quadrant.Dimensions.Y/2 + qy1 := q.quadrant.Position.Y + q.quadrant.Dimensions.Y/2 + + //AABB check + if sx0 < qx1 && sx1 > qx0 && + sy0 < qy1 && sy1 > qy0 { + + //if we have children, check each of those + for _, node := range q.children { + result = append(result, node.FindAll(quadrant)...) + } + + result = append(result, q.colliders...) + } + + return result +}