Exemplo n.º 1
0
def get_parallel_line(p1: Coordinates, p2: Coordinates, dist: float, top: bool) -> (Coordinates, Coordinates):
    """
    Get the line parallel to a given line for a certain distance away.

    :param p1: point 1 of original line
    :param p2: point 2 of original line
    :param dist: distance between lines
    :param top: top or bottom parallel line
    :return: pair of points representing the new, parallel line
    """

    # Get perpendicular angle
    normal_angle = get_angle(p2, p1) + 90

    if top:
        p3 = Coordinates(
            p1.x + dist * np.cos(np.radians(normal_angle)),
            p1.y - dist * np.sin(np.radians(normal_angle))
        )
        p4 = Coordinates(
            p2.x + dist * np.cos(np.radians(normal_angle)),
            p2.y - dist * np.sin(np.radians(normal_angle))
        )

    else:
        p3 = Coordinates(
            p1.x - dist * np.cos(np.radians(normal_angle)),
            p1.y + dist * np.sin(np.radians(normal_angle))
        )
        p4 = Coordinates(
            p2.x - dist * np.cos(np.radians(normal_angle)),
            p2.y + dist * np.sin(np.radians(normal_angle))
        )

    return p3, p4
Exemplo n.º 2
0
    def get_balls(game: GameType):
        m = BALL_MASS
        r = BALL_RADIUS

        ball_c = PoolBall(BallType.CUE, Coordinates(0, 0), m, r)
        ball_1 = PoolBall(BallType.ONE, Coordinates(0, 0), m, r)
        ball_2 = PoolBall(BallType.TWO, Coordinates(0, 0), m, r)
        ball_3 = PoolBall(BallType.THREE, Coordinates(0, 0), m, r)
        ball_4 = PoolBall(BallType.FOUR, Coordinates(0, 0), m, r)
        ball_5 = PoolBall(BallType.FIVE, Coordinates(0, 0), m, r)
        ball_6 = PoolBall(BallType.SIX, Coordinates(0, 0), m, r)
        ball_7 = PoolBall(BallType.SEVEN, Coordinates(0, 0), m, r)
        ball_8 = PoolBall(BallType.EIGHT, Coordinates(0, 0), m, r)
        ball_9 = PoolBall(BallType.NINE, Coordinates(0, 0), m, r)

        balls = {
            BallType.CUE: ball_c,
            BallType.ONE: ball_1,
            BallType.TWO: ball_2,
            BallType.THREE: ball_3,
            BallType.FOUR: ball_4,
            BallType.FIVE: ball_5,
            BallType.SIX: ball_6,
            BallType.SEVEN: ball_7,
            BallType.EIGHT: ball_8,
            BallType.NINE: ball_9,
        }

        return balls
def check_ball_ball_collision(a: PoolBall, b: PoolBall) -> bool:
    """
    Check if two balls have collided.

    :param a: ball A
    :param b: ball B
    :return: whether these two balls are in collision
    """
    a_pos = Coordinates(a.pos.x + a.vel.x, a.pos.y + a.vel.y)
    b_pos = Coordinates(b.pos.x + b.vel.x, b.pos.y + b.vel.y)

    d = get_distance(a_pos, b_pos)
    is_colliding = d <= (a.radius + b.radius)

    return is_colliding
Exemplo n.º 4
0
    def set_cv_cue_stick(self, cv_cue_stick: CVCueStick):
        if cv_cue_stick is None or cv_cue_stick.tip is None:
            print(
                'SpeedDetection.set_cv_cue_stick - NO CV CUE STICK, RESETTING SPEED'
            )
            self.cue_stick_speeds.clear()  # Reset speed state
            return

        curr_loc = Coordinates(cv_cue_stick.tip[0], cv_cue_stick.tip[1])

        # Don't consider locations that are 'thrashing'
        if len(self.cue_stick_tip_locations) > 0:
            prev_loc = self.cue_stick_tip_locations[-1]
            dist = get_distance(prev_loc, curr_loc)
            if dist < MOVING_THRESHOLD:
                # Didn't move far enough (probably thrashing)
                return

            # Append to history
            self.cue_stick_tip_locations.append(curr_loc)
            self.cue_stick_tip_distances.append(dist)

            # Update cue stick speed (FIXME: Probably need to tune this 'formula')
            curr_speed = dist / ITERATION_TIME
            print('SpeedDetection.set_cv_cue_stick CURR SPEED:', curr_speed)
            self.cue_stick_speeds.append(curr_speed)
    def test_check_ray_circle_intersection_straight(self):
        p1 = Coordinates(0, 0)
        p2 = Coordinates(0, 1)

        circle_mid = Coordinates(1, 0)

        # Test no intersecting
        circle_r = 0.99
        self.assertFalse(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))

        # Test touching
        circle_r = 1.0
        self.assertTrue(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))

        # Test intersecting
        circle_r = 1.1
        self.assertTrue(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))
Exemplo n.º 6
0
def get_distance(a: Coordinates, b=Coordinates(0, 0)) -> float:
    """
    Calculate the distance between two points.

    :param a: point a
    :param b: point b, default is origin (0, 0)
    :return: distance
    """

    return np.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
    def test_check_ray_circle_intersection_angled(self):
        p1 = Coordinates(0, 0)
        p2 = Coordinates(1, 1)

        circle_mid = Coordinates(1, 0)

        dist = get_distance(Coordinates(0.5, 0.5), circle_mid)

        # Test no intersecting
        circle_r = dist - 0.1
        self.assertFalse(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))

        # Test touching
        circle_r = dist
        self.assertTrue(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))

        # Test intersecting
        circle_r = dist + 0.1
        self.assertTrue(check_ray_circle_intersection(p1, p2, circle_mid, circle_r))
    def test_check_ray_line_intersection(self):
        # Parallel, vertical lines
        p1 = Coordinates(0, 0)
        p2 = Coordinates(0, 10)
        p3 = Coordinates(1, 0)
        p4 = Coordinates(1, 10)

        self.assertEqual(check_ray_line_intersection(p1, p2, p3, p4), None)

        # Perpendicular lines
        p1 = Coordinates(0, -10)
        p2 = Coordinates(0, 10)
        p3 = Coordinates(-10, 0)
        p4 = Coordinates(10, 0)

        self.assertEqual(check_ray_line_intersection(p1, p2, p3, p4), Coordinates(0, 0))
Exemplo n.º 9
0
def get_line_endpoint_within_box(p1: Coordinates, angle: float, nw: Coordinates, se: Coordinates,
                                 offset: float) -> Coordinates:
    """
    For the given box (given by NW and SE coordinates), find the endpoint for a given point and angle,
    'radius' distance away.

    :param p1: start point of the line
    :param angle: angle of line, north of x-axis, in degrees
    :param nw: upper-left border of the box
    :param se: lower-right border of the box
    :param offset: offset; usually radius of ball
    :return: end point of the line
    """

    assert all(param is not None for param in [p1, angle, nw, se, offset]), 'None param passed in'

    # Angle to degrees
    angle_rad = np.radians(angle)

    # North, East, South, West
    n, e, s, w = nw.y, se.x, se.y, nw.x

    # Quadrant angles of rectangle
    top_start = np.degrees(np.arctan((n - p1.y) / (e - p1.x))) if (e - p1.x) > 0 else np.radians(90)
    left_start = np.degrees(np.arctan((n - p1.y) / (p1.x - w))) if (p1.x - w) > 0 else np.radians(90)
    bottom_start = np.degrees(np.arctan((p1.y - s) / (p1.x - w))) if (p1.x - w) > 0 else np.radians(90)
    right_start = np.degrees(np.arctan((p1.y - s) / (e - p1.x))) if (e - p1.x) > 0 else np.radians(90)

    # Calculate length of the line, limited by the containing box
    if top_start < angle < 180 - left_start:
        # Top quadrant
        y = n - p1.y - offset
        x = y / np.tan(angle_rad)

    elif 180 - left_start <= angle < 180 + bottom_start:
        # Left quadrant
        x = p1.x - w - offset
        y = x * np.tan(angle_rad)

    elif 180 + bottom_start <= angle < 360 - right_start:
        # Bottom quadrant
        y = p1.y - s - offset
        x = y / np.tan(angle_rad)

    else:
        # Right quadrant
        x = e - p1.x - offset
        y = x * np.tan(angle_rad)

    line_length = np.sqrt(x ** 2 + y ** 2)

    result = Coordinates(p1.x + line_length * np.cos(angle_rad), p1.y + line_length * np.sin(angle_rad))

    return result
Exemplo n.º 10
0
    def __init__(self, gui_mode=False):
        self.cue_stick_tip_locations = deque(
            maxlen=5
        )  # List of Coordinates; only need prev loc to get distances
        self.cue_stick_tip_locations.append(Coordinates(0.5, 0.5))
        self.cue_stick_tip_distances = deque(
            maxlen=5)  # List of floats (distances); to get speed
        self.cue_stick_speeds = deque(maxlen=5)  # Last elem is current speed

        self.cue_ball_locations = deque(maxlen=5)  # List of Coordinates
        self.cue_ball_is_moving = False

        self.gui_mode = gui_mode  # DEBUG
Exemplo n.º 11
0
    def set_cv_cue_stick(self, cv_cue_stick: CVCueStick):
        """
        Use 2 points to get the cue stick angle.

        :return:
        """

        if cv_cue_stick is None or cv_cue_stick.tip is None or cv_cue_stick.back is None:
            self.cue_stick_tip = self.cue_stick_back = None
            self.floating_cue_stick = False
            self.cue_angle = None
            self.floating_cue_stick_line_end = None
            return

        # Check if cue stick line intersects the cue ball
        self.cue_stick_tip = self.convert_cv_coords(cv_cue_stick.tip[0],
                                                    cv_cue_stick.tip[1],
                                                    for_cue_stick=True)
        self.cue_stick_back = self.convert_cv_coords(cv_cue_stick.back[0],
                                                     cv_cue_stick.back[1],
                                                     for_cue_stick=True)

        # ¯\_(ツ)_/¯
        self.cue_angle = get_angle(self.cue_stick_tip, self.cue_stick_back)

        nw = Coordinates(self.left, self.top)
        se = Coordinates(self.right, self.bottom)
        cue_stick_line_extended_end = get_line_endpoint_within_box(
            self.cue_stick_back, self.cue_angle, nw, se, 1.0)

        if check_ray_circle_intersection(self.cue_stick_tip,
                                         cue_stick_line_extended_end,
                                         self.cue_ball.pos,
                                         self.cue_ball.radius):
            self.floating_cue_stick = False
            self.floating_cue_stick_line_end = None
        else:
            self.floating_cue_stick = True
            self.floating_cue_stick_line_end = cue_stick_line_extended_end
Exemplo n.º 12
0
def check_ray_line_intersection(p1: Coordinates, p2: Coordinates,
                                p3: Coordinates, p4: Coordinates) -> Optional[Coordinates]:
    """
    Check whether a ray intersections a line.

    Source: https://stackoverflow.com/a/19550879

    :param p1: point A of line segment A
    :param p2: point B of line segment A
    :param p3: point A of line segment B
    :param p4: point B of line segment B

    :return: the point of intersection; None otherwise
    """

    s10_x = p2.x - p1.x
    s10_y = p2.y - p1.y
    s32_x = p4.x - p3.x
    s32_y = p4.y - p3.y

    denom = s10_x * s32_y - s32_x * s10_y

    if denom == 0: return None  # collinear

    denom_is_positive = denom > 0

    s02_x = p1.x - p3.x
    s02_y = p1.y - p3.y

    s_numer = s10_x * s02_y - s10_y * s02_x

    if (s_numer < 0) == denom_is_positive:
        return None  # no collision

    t_numer = s32_x * s02_y - s32_y * s02_x

    if (t_numer < 0) == denom_is_positive:
        return None  # no collision

    if (s_numer > denom) == denom_is_positive or (t_numer > denom) == denom_is_positive:
        return None  # no collision

    # collision detected
    t = t_numer / denom
    intersection_point = Coordinates(p1.x + (t * s10_x), p1.y + (t * s10_y))
    return intersection_point
Exemplo n.º 13
0
    def get_pockets(self) -> List[Coordinates]:
        """
        Get 6 coordinates for the center of the pockets.
        """

        return [
            Coordinates(self.left, self.top),
            Coordinates(self.left + self.length / 2, self.top),
            Coordinates(self.left + self.length, self.top),
            Coordinates(self.right, self.bottom),
            Coordinates(self.right - self.length / 2, self.bottom),
            Coordinates(self.right - self.length, self.bottom),
        ]
Exemplo n.º 14
0
def get_point_on_line_distance_from_point(line_start, line_end, point, distance) -> Coordinates:
    a_side = distance
    c_side = get_distance(line_start, point)

    v_point = Vector(point.x - line_start.x, point.y - line_start.y)
    v_line = Vector(line_end.x - line_start.x, line_end.y - line_start.y)
    dot = v_point.dot_product(v_line)

    a_angle = np.arccos(dot / v_point.get_magnitude() / v_line.get_magnitude())

    from pool.src.physics.trianglesolver import solve
    (a_side, b_side, c_side, a_angle, b_angle, c_angle) = solve(a=a_side, c=c_side, A=a_angle, ssa_flag='obtuse')
    assert a_angle + b_angle + c_angle == np.radians(180), 'a_angle: {} \nb_angle: {}\nc_angle: {}\n'.format(a_angle,
                                                                                                             b_angle,
                                                                                                             c_angle)
    # Compute exact point
    angle = np.radians(get_angle(line_end, line_start))
    x = line_start.x + b_side * np.cos(angle)
    y = line_start.y + b_side * np.sin(angle)

    return Coordinates(x, y)
Exemplo n.º 15
0
    def convert_cv_coords(self,
                          cv_x,
                          cv_y,
                          for_cue_stick=False) -> Coordinates:
        """
        Convert coordinates (cv_ball coordinates go from [0, 1.0])

        :param cv_coords: tuple of cv coordinates as floats
        :return: proper Coordinates for the pool table
        """

        # print("convert_cv_coords input: ({}, {})".format(cv_x, cv_y))

        # Hard-coded offsets to align projector output
        if False:  # Tweak for cue stick
            pass
        else:  # Tweak for pool balls
            if cv_x < 0.6:
                X_OFFSET = 12
                X_SCALE = .995
            else:
                X_OFFSET = 10
                X_SCALE = .99
            # Y_OFFSET = -13
            # Y_SCALE = 1.0

            # X_OFFSET = +12
            # X_SCALE = .995
            Y_OFFSET = 0
            Y_SCALE = 1.0

        # Apply the offsets
        new_x = self.left + X_OFFSET + self.length * cv_x * X_SCALE
        new_y = self.bottom - Y_OFFSET + self.width * (1.0 - cv_y) * Y_SCALE

        # DEBUG: Don't do offsets
        # new_x = self.left + self.length * cv_x
        # new_y = self.bottom + self.width * (1.0 - cv_y)

        return Coordinates(new_x, new_y)
Exemplo n.º 16
0
def get_angle(a: Coordinates, b=Coordinates(0, 0)) -> float:
    """
    Calculate the angle (relative to positive x-axis) of a relative to b.
    i.e. b becomes the origin

    :param a: point a
    :param b: point b, default is origin (0, 0)
    :return: angle of a to b (degrees)
    """

    y = a.y - b.y
    x = a.x - b.x

    # print('get_angle, relative point is ({}, {})'.format(x, y))

    if x == y == 0:
        return None  # FIXME: Best return value for 'no angle'?
    elif x == 0:
        if y > 0:
            return 90.0
        elif y < 0:
            return 270.0
    elif y == 0:
        if x > 0:
            return 0.0
        else:
            return 180.0

    # Compute raw angle (between -90 and 90)
    raw_angle = np.degrees(np.arctan(y / x))

    if x > 0 and y > 0:  # Quadrant 1
        return raw_angle
    elif x < 0 and y > 0:  # Quadrant 2
        return 180.0 + raw_angle
    elif x < 0 and y < 0:  # Quadrant 3
        return 180.0 + raw_angle
    else:  # Quadrant 4
        return (360.0 + raw_angle) % 360.0
Exemplo n.º 17
0
    def set_cv_balls(self, cv_balls: List[CVBall]):
        if cv_balls is None or cv_balls is []:
            return

        for cv_ball in cv_balls:
            if cv_ball.color is 'white':
                curr_loc = Coordinates(cv_ball.x, cv_ball.y)

                # Don't consider locations that are 'thrashing'
                if len(self.cue_ball_locations) > 0:
                    prev_loc = self.cue_ball_locations[-1]
                    dist = get_distance(prev_loc, curr_loc)
                    if dist < MOVING_THRESHOLD:
                        # Didn't move far enough (probably thrashing)
                        self.cue_ball_is_moving = False
                        return

                # Append to history
                self.cue_ball_locations.append(curr_loc)

                # Update whether cue ball is moving
                self.cue_ball_is_moving = True
    def test_get_ray_circle_intersection(self):
        # Miss
        p1 = Coordinates(0, 0)
        p2 = Coordinates(10, 0)
        c = Coordinates(10, 1)
        r = 0.9

        result = get_ray_circle_intersection(p1, p2, c, r)

        self.assertEqual(result, None)

        # Tangent
        p1 = Coordinates(0, 0)
        p2 = Coordinates(10, 0)
        c = Coordinates(10, 1)
        r = 1

        result = get_ray_circle_intersection(p1, p2, c, r)

        self.assertEqual(result, Coordinates(10, 0))


        # Intersect
        p1 = Coordinates(0, 0)
        p2 = Coordinates(10, 0)
        c = Coordinates(10, 0)
        r = 1

        result = get_ray_circle_intersection(p1, p2, c, r)

        self.assertEqual(result, Coordinates(9, 0))
    def test_get_parallel_line(self):
        # def get_parallel_line(p1: Coordinates, p2: Coordinates, dist: float, top: bool) -> (Coordinates, Coordinates):

        ######################
        # Parallel to x-axis #
        ######################
        p1 = Coordinates(-1, 0)
        p2 = Coordinates(1, 0)
        dist = 1.0

        expected_angle = get_angle(p2, p1)

        result_top = get_parallel_line(p1, p2, dist, True)
        result_bot = get_parallel_line(p1, p2, dist, False)
        result_top_angle = get_angle(result_top[1], result_top[0])
        result_bot_angle = get_angle(result_bot[1], result_bot[0])

        # Check angles are the same
        self.assertEqual(expected_angle, result_top_angle)
        self.assertEqual(expected_angle, result_bot_angle)

        # Check distance apart
        self.assertAlmostEqual(dist, get_distance(p1, result_top[0]), FLOAT_PLACES)
        self.assertAlmostEqual(dist, get_distance(p2, result_top[1]), FLOAT_PLACES)

        ######################
        # Parallel to y-axis #
        ######################
        p1 = Coordinates(0, -1)
        p2 = Coordinates(0, 1)
        dist = 1.0

        expected_angle = get_angle(p2, p1)

        result_top = get_parallel_line(p1, p2, dist, True)
        result_bot = get_parallel_line(p1, p2, dist, False)

        result_top_angle = get_angle(result_top[1], result_top[0])
        result_bot_angle = get_angle(result_bot[1], result_bot[0])

        # Check angles are the same
        self.assertEqual(expected_angle, result_top_angle)
        self.assertEqual(expected_angle, result_bot_angle)

        # Check distance apart
        self.assertAlmostEqual(dist, get_distance(p1, result_top[0]), FLOAT_PLACES)
        self.assertAlmostEqual(dist, get_distance(p2, result_top[1]), FLOAT_PLACES)

        #####################
        # Parallel to y = x #
        #####################
        p1 = Coordinates(-1, -1)
        p2 = Coordinates(1, 1)
        dist = 1.0

        expected_angle = get_angle(p2, p1)

        result_top = get_parallel_line(p1, p2, dist, True)
        result_bot = get_parallel_line(p1, p2, dist, False)

        result_top_angle = get_angle(result_top[1], result_top[0])
        result_bot_angle = get_angle(result_bot[1], result_bot[0])

        # Check angles are the same
        self.assertEqual(expected_angle, result_top_angle)
        self.assertEqual(expected_angle, result_bot_angle)

        # Check distance apart
        self.assertAlmostEqual(dist, get_distance(p1, result_top[0]), FLOAT_PLACES)
        self.assertAlmostEqual(dist, get_distance(p2, result_top[1]), FLOAT_PLACES)
Exemplo n.º 20
0
    def ball_hit(
            self, v_start: Vector, struck_ball: PoolBall
    ) -> (Coordinates, Coordinates, PoolBall, Vector):
        """
        Take in the starting position of a ball and the Force it will be struck with.
        Output is where the ghost ball will begin and end to be displayed.
        If there was another ball hit along the way, that ball will also be returned with the expectation
        that this function will be called for that ball again, for as many iterations desired.

        :param v_start: velocity that struck_ball starts with
        :param struck_ball: PoolBall being struck
        :return: 0 - Ghost ball start
                 1 - Ghost ball end
                 2 - Collided pool ball if there was one, None otherwise
                 3 - Collided pool ball velocity if there was one, None otherwise
        """

        assert all(param is not None
                   for param in [v_start, struck_ball]), 'None param passed in'
        assert self.cue_angle is not None, 'self.cue_angle is None'

        if v_start is None or v_start.get_magnitude() == 0.0:
            # For some reason, speed was 0, so just make it 1
            v_start = Vector(1.0 * np.cos(np.radians(self.cue_angle)),
                             1.0 * np.sin(np.radians(self.cue_angle)))

        # Return values
        ghost_start, ghost_end, collided_ball, collided_vel = None, None, None, None

        radius = struck_ball.radius
        nw = Coordinates(self.left, self.top)
        se = Coordinates(self.right, self.bottom)

        mid_start = struck_ball.pos
        mid_end = ghost_start = get_line_endpoint_within_box(
            mid_start, v_start.get_angle(), nw, se, radius)

        # Iterate through pool balls, from closest to farthest
        for object_ball in self.get_balls_by_distance(mid_start):
            if object_ball.ball_type is struck_ball.ball_type:
                continue  # Skip self

            # Found a ball-ball collision
            if PoolTable.check_ball_intersects_ball(struck_ball, mid_end,
                                                    object_ball):
                # TODO: Check if enough there's velocity to make it to the ball
                d, vf_mag = PoolTable.check_enough_speed(
                    struck_ball.pos, object_ball.pos, v_start)
                if d is not None:  # Not enough speed
                    ghost_start = ghost_end = Coordinates(
                        struck_ball.pos.x +
                        d * np.cos(np.radians(v_start.get_angle())),
                        struck_ball.pos.y +
                        d * np.sin(np.radians(v_start.get_angle())))
                    return ghost_start, ghost_end, None, None

                # Ghost line start
                ghost_start = get_point_on_line_distance_from_point(
                    mid_start, mid_end, object_ball.pos,
                    2 * object_ball.radius)
                ##########################################
                # Calculate struck ball deflection angle #
                ##########################################

                # Need to calculate object ball angle to get struck ball deflection angle
                object_ball_angle = get_angle(object_ball.pos, ghost_start)
                object_ball_ghost_end = get_line_endpoint_within_box(
                    object_ball.pos, object_ball_angle, nw, se,
                    object_ball.radius)

                # 90 degrees will be added or subtracted to this to get the final result
                struck_deflect_angle = get_angle(object_ball_ghost_end,
                                                 object_ball.pos)

                # Used to see if struck ball is hit to the left or right of object ball
                struck_object_angle = get_angle(object_ball.pos,
                                                self.cue_ball.pos)

                # Hotfix
                if struck_object_angle is None:
                    struck_object_angle = 0.0

                if self.cue_angle % 360 == 0:  # Edge case when perfectly to the right
                    struck_deflect_angle = (struck_deflect_angle + 90) % 360
                elif self.cue_angle < struck_object_angle:  # Struck ball to the RIGHT of object ball
                    struck_deflect_angle = (struck_deflect_angle - 90) % 360
                else:  # Struck ball to the LEFT of object ball
                    struck_deflect_angle = (struck_deflect_angle + 90) % 360

                # Update return values and return
                ghost_cushion = get_line_endpoint_within_box(
                    ghost_start, struck_deflect_angle, nw, se,
                    struck_ball.radius)

                # Check if deflected struck ball will reach the cushion
                vf = Vector(vf_mag * np.cos(np.radians(struck_deflect_angle)),
                            vf_mag * np.sin(np.radians(struck_deflect_angle)))

                d, vf_mag = PoolTable.check_enough_speed(
                    ghost_start, ghost_cushion, vf)
                if d is not None:  # Didn't make it to the cushion
                    ghost_end = Coordinates(
                        ghost_start.x +
                        d * np.cos(np.radians(struck_deflect_angle)),
                        ghost_start.y +
                        d * np.sin(np.radians(struck_deflect_angle)))
                    collided_vel = Vector(v_start.x / 2, v_start.y /
                                          2)  # FIXME: What to return here?
                else:
                    ghost_end = ghost_cushion
                    collided_vel = Vector(
                        vf_mag * np.cos(np.radians(object_ball_angle)),
                        vf_mag * np.sin(np.radians(object_ball_angle)))

                assert ghost_start is not None
                assert ghost_end is not None
                assert object_ball is not None
                assert object_ball_angle is not None

                return ghost_start, ghost_end, object_ball, collided_vel

        # No ball collisions, wall collision

        # FIXME: HACKY - Create a pseudo-pool ball (where the ball would end up on the cushion)
        struck_ball_on_cushion = PoolBall(
            None,
            Coordinates(ghost_start.x, ghost_start.y),
            0.0,
            struck_ball.radius,
            vel=Vector(ghost_start.x - struck_ball.pos.x,
                       ghost_start.y - struck_ball.pos.y).unit())

        # Check if struck ball makes it to the cushion
        d, vf_mag = PoolTable.check_enough_speed(struck_ball.pos,
                                                 struck_ball_on_cushion.pos,
                                                 v_start)
        if d is not None:  # Not enough speed
            ghost_start = ghost_end = Coordinates(
                struck_ball.pos.x +
                d * np.cos(np.radians(v_start.get_angle())),
                struck_ball.pos.y +
                d * np.sin(np.radians(v_start.get_angle())))
            return ghost_start, ghost_end, None, None

        # Otherwise, struck ball makes it to the cushion
        ball_wall_collision = check_ball_wall_collision(
            struck_ball_on_cushion, self.top, self.left, self.bottom,
            self.right)
        assert ball_wall_collision is not None, "there should be a ball-wall collision, bc there were no ball-ball collisions"
        resolve_ball_wall_collision(struck_ball_on_cushion,
                                    ball_wall_collision)

        # The ghost cue ball now has a new velocity vector we can use to draw the deflection line
        deflection_angle = struck_ball_on_cushion.vel.get_angle()
        ghost_end = get_line_endpoint_within_box(ghost_start, deflection_angle,
                                                 nw, se, self.cue_ball.radius)

        # Check if deflected struck ball will reach the cushion
        vf = Vector(vf_mag * np.cos(np.radians(deflection_angle)),
                    vf_mag * np.sin(np.radians(deflection_angle)))
        d, vf_mag = PoolTable.check_enough_speed(ghost_start, ghost_end, vf)

        if d is not None:  # Didn't make it to the cushion
            ghost_end = Coordinates(
                ghost_start.x + d * np.cos(np.radians(deflection_angle)),
                ghost_start.y + d * np.sin(np.radians(deflection_angle)))
            return ghost_start, ghost_end, None, None

        return ghost_start, ghost_end, None, None
    def test_get_line_endpoint_within_box(self):
        # def get_line_endpoint_within_box(p1: Coordinates, angle: float, nw, se):

        # Establish box
        nw = Coordinates(0, 0)
        se = Coordinates(100, 100)

        # Point at the middle
        p1 = Coordinates(50, 50)

        # Test with different angles
        angle = 0
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(100, 50)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 45
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(100, 100)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 90
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(50, 100)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 135
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(0, 100)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 180
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(0, 50)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 225
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(0, 0)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 270
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(50, 0)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 315
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(100, 0)
        self.assertCoordinatesAlmostEqual(result, expected)

        angle = 360
        result = get_line_endpoint_within_box(p1, angle, nw, se)
        expected = Coordinates(100, 50)
        self.assertCoordinatesAlmostEqual(result, expected)