Swift noob looking for help

Hi, I am new to swift. very new...im trying to make a bouncing ball simulation. balls will bounce off walls, floor, and each other. most of the program I've figured out on my own, but I still have one issue -

Im going to skip explaining how they bounce off the floor and the walls, and dive straight into them bouncing off each other.

I have a class called balls with three balls in it. then, I have a for loop updating each ball. I have to somehow reference any ball that could potentially hit the current ball that's being looped over. I asked chat gpt, and their answer was to declare "other" balls. I will share my code now so you can see (but keep in mind there are probably tons of errors. I am brand new to swift and kind of skipped a lot of fundamentals. I plan to continue studying swift alongside this simulation project, but I wanted to have some fun with it.) So the one main concern right now is referencing these "other " balls so that when they get within a certain distance of each other, they know to bounce off in the other direction

please help me understand how and when to reference the "other" balls that aren't being looped over at the current iteration

import SwiftUI
import Foundation
import CoreGraphics



class Balls
{
    //var color: String
    var xPosition: Int
    var yPosition: Int
    var xVelocity: Int
    var yVelocity: Int
    var radius: Int
    var gravity: CGFloat
    var restitution: Int
    
    var other: Balls?
    
    init(xPosition: Int, yPosition: Int, xVelocity: Int, yVelocity: Int, radius: Int, gravity: CGFloat, restitution: Int)
    //ADD COLOR
    {
        //self.color = color
        self.xPosition = xPosition
        self.yPosition = yPosition
        self.xVelocity = xVelocity
        self.yVelocity = yVelocity
        self.radius = radius
        self.gravity = gravity
        self.restitution = restitution
    }
}
    let ball1: Balls = Balls (xPosition: 100, yPosition: 100, xVelocity: 3, yVelocity: 0, radius: 3, gravity: 0.3, restitution: 1)
    let ball2: Balls = Balls (xPosition: 200, yPosition: 50, xVelocity: -2, yVelocity: 2, radius: 3, gravity: 0.3, restitution: 1)
    let ball3: Balls = Balls  (xPosition: 300, yPosition: 150, xVelocity: 4, yVelocity: -3, radius: 3, gravity: 0.3, restitution: 1)
    
        
        var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
        @State var balls: [Balls]
    
    init()
        {
           balls = [ball1, ball2, ball3]
        }
//struct ContentView: View
//{
    var body: some View
    {
        VStack
        {
            Color.gray.edgesIgnoringSafeArea(.all)
            
            ForEach($balls) { $ball in
                {
                    Circle()
                        .fill(Color.black)
                        .frame(width: 50, height: 50)
                        .position($ball.xPosition, $ball.yPosition)
                    
                    
                        .onReceive(timer) { _in
                            
                            ball.yVelocity += ball.gravity
                            ball.xPosition = CGPoint(ball.xPosition + ball.xVelocity)
                            ball .yPosition = CGPoint (ball.yPosition + ball.yVelocity)
                            
                            if ball.yPosition >= 500 - 25
                            {
                                ball.yPosition = 500 - 25
                                ball.yVelocity = -ball.yVelocity * ball.restitution
                            }
                            
                            if ball.xPosition <= 25
                            {
                                ball.xPosition = 25
                                ball.xVelocity = -ball.xVelocity
                            }
                            
                            if ball.xPosition >= 375
                            {
                                ball.xPosition = 375
                                ball  .xVelocity = -ball.velocityX
                            }
                            
                            
                           let dx: int = other.xPosition - ball.xPosition
                           let dy: int = other.yPosition - ball.yPosition
                           let distance: int = sqrt (dx * dx + dy * dy)
                           if distance < ball.radius + other.radius
                             {
                               ball.xVelocity = -ball.xVelocity * ball.restitution
                               ball.yVelocity = -ball.yVelocity * ball.restitution
                               other.xVelocity = -other.xVelocity * ball.restitution
                               other.yVelocity = -other.yVelocity * ball.restitution
                        }
                }
            }
        }
    }
}
    
        #Preview
        {
            ContentView()
        }
    

Probably not an answer to your question, but have you tried SpriteKit ? This framework is certainly what you need, not SwiftUI.

If considering other approaches, one might just use UIKit Dynamics (UIDynamicAnimator), which handles physics of motion, collisions with varying degrees of elasticity, gravity, rotation, etc., for dynamic items.

But, there’s a good chance that the OP isn’t asking for completely different (and likely superior) approaches, but rather how to refine the existing approach. Ignoring the typographical issues (e.g., the View has been commented out), the basic problem is that this is attempting to have the timer update the location of the three balls independently (which makes it hard to refer to the “other” balls).

I might suggest, instead, that you remove the individual timer handler for each ball, but perhaps have a single one for the whole View. Then that can iterate through the balls array, updating all of their positions, detect collisions, etc.

5 Likes

can you show me how to do this?

Perhaps something like:

import SwiftUI

class Ball: ObservableObject, Identifiable {
    let id = UUID()

    let color: Color
    @Published var xPosition: CGFloat
    @Published var yPosition: CGFloat
    var xVelocity: CGFloat
    var yVelocity: CGFloat
    let radius: CGFloat
    let gravity: CGFloat
    let restitution: CGFloat
    
    init(
        color: Color,
        xPosition: CGFloat, 
        yPosition: CGFloat, 
        xVelocity: CGFloat, 
        yVelocity: CGFloat, 
        radius: CGFloat, 
        gravity: CGFloat, 
        restitution: CGFloat
    ) {
        self.color = color
        self.xPosition = xPosition
        self.yPosition = yPosition
        self.xVelocity = xVelocity
        self.yVelocity = yVelocity
        self.radius = radius
        self.gravity = gravity
        self.restitution = restitution
    }
}

let ball1 = Ball(color: .red, xPosition: 100, yPosition: 100, xVelocity: 3, yVelocity: 0, radius: 25, gravity: 0.3, restitution: 1)
let ball2 = Ball(color: .yellow, xPosition: 200, yPosition: 50, xVelocity: -2, yVelocity: 2, radius: 25, gravity: 0.3, restitution: 1)
let ball3 = Ball(color: .blue, xPosition: 300, yPosition: 150, xVelocity: 4, yVelocity: -3, radius: 25, gravity: 0.3, restitution: 1)

struct ContentView: View {
    var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
    @State var balls: [Ball] = [ball1, ball2, ball3]
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Color.gray.edgesIgnoringSafeArea(.all)
                
                ForEach(balls) { ball in
                    BallView(ball: ball)
                }
            }            
            .onReceive(timer) { _ in
                var otherIndices = Set(balls.indices) 
                
                for index in balls.indices {
                    let ball = balls[index]
                    otherIndices.remove(index)        // for every ball, i, we only need to worry about balls i+1…
                    
                    ball.yVelocity += ball.gravity
                    ball.xPosition = ball.xPosition + ball.xVelocity
                    ball.yPosition = ball.yPosition + ball.yVelocity
                    
                    if ball.yPosition >= geometry.size.height - ball.radius {
                        ball.yPosition = geometry.size.height - ball.radius
                        ball.yVelocity = -ball.yVelocity * ball.restitution
                    }
                    
                    if ball.xPosition <= ball.radius {
                        ball.xPosition = ball.radius
                        ball.xVelocity = -ball.xVelocity
                    }
                    
                    if ball.xPosition >= geometry.size.width - ball.radius {
                        ball.xPosition = geometry.size.width - ball.radius
                        ball.xVelocity = -ball.xVelocity
                    }
                    
                    for otherIndex in otherIndices {
                        let other = balls[otherIndex]
                        let dx = other.xPosition - ball.xPosition
                        let dy = other.yPosition - ball.yPosition
                        let distance = hypot(dx, dy)
                        if distance < ball.radius + other.radius {
                            ball.xVelocity = -ball.xVelocity * ball.restitution
                            ball.yVelocity = -ball.yVelocity * ball.restitution
                            other.xVelocity = -other.xVelocity * ball.restitution
                            other.yVelocity = -other.yVelocity * ball.restitution
                        }
                    }
                }
            }
        }
    }
}

struct BallView: View {
    @ObservedObject var ball: Ball
    
    var body: some View {
        Circle()
            .fill(ball.color)
            .frame(width: ball.radius * 2, height: ball.radius * 2)
            .position(CGPoint(x: ball.xPosition, y: ball.yPosition))
    }
}

IMHO, the ball collision/bouncing logic is not right (it would be updating the position based upon the point of contact so the balls never overlap, like you do with the sides; it should be updating the rebound velocity based upon the angle of incidence and the relative mass of the two balls; etc.), but this illustrates one way in which you can iterate through all the remaining “other” balls, looking for collisions.

2 Likes

thank you!!! I will definitely spend some time reviewing my errors and your solutions because I tried your code in Xcode and it works great.

Since your class models one ball, IMO, Ball is a more suitable name.

You could have var other: [Ball] inside your class, but this would lead to circular references. This can be fixed using weak or unowned references here, but IMO, it is simpler to move most of the logic from the Ball to another entity, which holds all the balls, but is not one of them.

Let's call it Scene.

It's possible to use other framework for displaying the simulation, but let's stick to the SwiftUI for now to keep things simple.

With SwiftUI you use ObservableObject to implement class that holds mutable observable state. But I would not recommend conforming Ball to ObservableObject, because balls don't change independently. They all change in sync, as part of the Scene. So let's make Scene conform to ObservableObject. We can keep Ball as class if they need identity, but so far I don't see a need for that, so actually we can make Ball a struct.

This gives us something like this:


class Scene: ObservableObject {
    let bounds: CGRect
    @Published private(set) var balls: [Ball]

    init(balls: [Ball], bounds: CGRect) {
        self.balls = balls
        self.bounds = bounds
    }

    public func update() {
        // Run one step of the simulation
        for index in balls.indices {
             // We don't need Set to skip previous index
            for otherIndices in (index+1..<balls.count) {
            }
        }
    }
}

We can keep function update() public and call it from the outside (e.g. from .onReceive(timer)). Or we can have Timer inside the Scene itself and make update() private. The first approach is better for writing unit tests. The second helps to keep view code simpler. I'll stick for the first approach for now.

If Ball is a class, we can use it's identity for ForEach:

class Ball: Identifiable {
    // Identifiable has default implementation for classes
    // No need for UUID
    
    let color: Color
    var xPosition: CGFloat
    var yPosition: CGFloat
    var xVelocity: CGFloat
    var yVelocity: CGFloat
    let radius: CGFloat
    let gravity: CGFloat
    let restitution: CGFloat
}

...
ForEach(balls) { ball in
    ...
}

It Balls is a struct, then ForEach still needs some identity, but there is API that allows to provide it without conforming to Identifiable. Since balls won't change their order in the array, index of the ball in the array is a pretty good source of identity:

struct Ball {
    let color: Color
    var xPosition: CGFloat
    var yPosition: CGFloat
    var xVelocity: CGFloat
    var yVelocity: CGFloat
    let radius: CGFloat
    let gravity: CGFloat
    let restitution: CGFloat
}

...
ForEach(Array(balls.enumerated(), id: \.offset) { pair in
    let ball = pair.element
    ...
}

I like the second approach more, as it is more honest. Nonobservable mutable state can cause issues in the architecture. With the second approach balls are stateless values, and all mutable state lives inside observable Scene.

The code that updates single ball, without requiring any knowledge about other balls can be placed inside the Ball type:

struct Ball {
    ...
    mutating func update() {
        yVelocity += gravity
        xPosition += xVelocity
        yPosition += yVelocity
    }
}

Note that the code above uses new value of yVelocity to update yPosition. This probably will be good enough for your purposes, but in more general case this can negatively affect precious of the simulation. Using average between old value and new value can give more precise results:

struct Ball {
    ...
    mutating func update() {
        xPosition += xVelocity
        yPosition += (yVelocity + gravity * 0.5)
        yVelocity += gravity
    }
}

Calling this method inside Scene.update() will look something like this:

class Scene: ObservableObject {
    ...
    func update() {
        for index in balls.indices {
            balls[index].update()
        }
    }
}

Which Swift compiles into something like this:

class Scene: ObservableObject {
    ...
    func update() {
        for index in balls.indices {
            var tmp: [Balls] = self.balls
            var tmp2: Ball = tmp[index]
            tmp2.update()
            tmp[index] = tmp2
            self.balls = tmp
        }
    }
}

Every time when self.balls = tmp is called, @Published will do its work and trigger objectWillChange. SwiftUI will combine all of this notifications into a single update of the views. But we can be even more efficient because we know that entire Scene.update() is a single mutation operation. So instead of using @Published we can trigger objectWillChange.send() manually:

class Scene: ObservableObject {
    private(set) var balls: [Ball] // not published
    func update() {
        // It is WILL change, so we send it before making changes
        objectWillChange.send()
        for index in balls.indices {
            balls[index].update()
        }
    }
}

Now we need to deal with the hit-testing. Your original approach compares one ball that have been updated, with another ball that have not been updated yet. Which can also lead to imprecise results. If you need more accurate simulation, you can think in terms of events.

Center of each ball moves according to simple analytical equation until it collides with another ball or a wall:

xc(t) = x0 + vx * t
yc(t) = y0 + vy*t + (gravity / 2) * t^2

When two balls collide at the moment of time t distance between their centres is equal to sum of their radii:

(r1 + r2)^2 = (xc1(t) - xc2(t))^2 + (yc1(t) - yc2(t))^2

If we solve this for t and take the smallest solution which exists in the future (t >= tNow), we can find a moment of collision. From there we can calculate positions of two balls at the moment of collision, their contact points, normals, and everything else.

EDIT: With the current form of the equations we would need to find roots of a 4th degree polynomial. But if all balls have the same gravity, then quadratic terms in (yc1(t) - yc2(t)) cancel out, and the entire equation for t becomes quadratic. To reflect this in code, we can move gravity from Ball to Scene.

For N balls we have N(N - 1)/2 candidates for collisions between balls, and N candidates for collisions between ball and the bounds (or N * 4 if we consider each side separately).

From all of these candidates we need to find the one that happens the soonest and process it. Processing changes trajectory of one or two balls, and affected collision candidates need to be recalculated.

As a bonus point, we now don't need to run simulation in equal steps. If our timer animation lags, we can account for that in the simulation code:

struct Ball {
    let color: Color
    var xPosition: CGFloat
    var yPosition: CGFloat
    var xZeroPosition: CGFloat
    var yZeroPosition: CGFloat
    var xVelocity: CGFloat
    var yVelocity: CGFloat
    let radius: CGFloat
    let gravity: CGFloat
    let restitution: CGFloat

    func collisionTime(with another: Ball, after tNow: CGFloat) -> CGFloat { ... }
    func collisionTime(with bounds: CGRect, after tNow: CGFloat) -> CGFloat { ... }

    mutating func update(to time: CGFloat) {
        xPosition = xZeroPosition + xVelocity * time
        yPosition = yZeroPosition + (yVelocity + (gravity / 2) * time) * time  
    }
}

class Scene: ObservableObject {
    let bounds: CGRect
    private(set) var balls: [Ball]

    // @Published to use time as a source of objectWillChange
    @Published var time: CGFloat {
        didSet {
            update(from: oldValue, to: time)
        }
    }
    private var ballToBallCollisions: [CGFloat]
    private var ballToBoundsCollisions: [CGFloat]

    init(balls: [Ball], bounds: CGRect, time: CGFloat) {
        self.balls = balls
        self.bounds = bounds
        self.time = time
        self.ballToBallCollisions = [CGFloat](repeating: .nan, count: balls.count * (balls.count - 1) / 2)
        self.ballToBoundsCollisions = [CGFloat](repeating: .nan, count: balls.count)
        // Init ballToBallCollisions
        // Init ballToBoundsCollisions
    }
    
    private func update(from oldTime: CGFloat, to newTime: CGFloat) {
        var time = oldTime
        while true {
            let t =  // Find nearest collision moment among ballToBallCollisions and ballToBoundsCollisions
            if t > newTime { break }
            // Recalculate equations of movement for the balls involved
            // Recalculate affected collisions
        }
        for index in balls.indices {
            balls[index].update(to: newTime)
        }
    }
}
4 Likes

SKScene .

Please don't force a noob to reinvent a framework.

@Nickolas_Pohilets is only being generous and gently guiding them in the right direction. :slight_smile:

4 Likes

Guiding in the right direction is exactly what I am trying to do here.

And yes, writing a bunch of code on behalf of original poster is generous :slight_smile:

Give a person a fish and you feed them for a day. Teach a person to fish and you feed them for a lifetime.