import pygame
import math
import sys
from PIL import Image
from bobbing import Bobbing
from Impulse import Impulse

# Initialize Pygame
pygame.init()

# Set up display
WIDTH, HEIGHT = 1920, 1080
HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("3D Raycasting Demo")

# Clock to control frame rate
clock = pygame.time.Clock()

# Initialize Bobbing
bobbing = Bobbing(amplitude=10, frequency=5, damping=0.9)
# Initialize Impulse
impulse = Impulse(amplitude=3, frequency=15, damping=0.85, max_duration=0.5)

# Simple map (2D grid)
world_map = [
    '##########',
    '#........#',
    '#........#',
    '#........#',
    '#........#',
    '#........#',
    '#........#',
    '#........#',
    '##########',
]

# Map dimensions and tile size
MAP_WIDTH = len(world_map[0]) * 64
MAP_HEIGHT = len(world_map) * 64    
TILE_SIZE = 64

# Player settings
player_x = TILE_SIZE * 1.5
player_y = TILE_SIZE * 1.5
player_angle = 0
player_speed = 5

# Load wall texture
wall_texture = pygame.image.load('wall.png').convert()
wall_texture = pygame.transform.scale(wall_texture, (TILE_SIZE, TILE_SIZE))

# Function to load animation frames from GIF
def load_animation(path):
    frames = []
    img = Image.open(path)
    try:
        while True:
            frame = img.copy()
            frame = frame.convert('RGBA')
            mode = frame.mode
            size = frame.size
            data = frame.tobytes()
            py_image = pygame.image.fromstring(data, size, mode)
            frames.append(py_image)
            img.seek(img.tell() + 1)
    except EOFError:
        pass
    return frames

# Load shooting animation frames
fire_frames = load_animation('fire.gif')
fire_frame_index = 0
fire_animation_speed = 0.05  # Adjust animation speed as needed
fire_animation_timer = 0
fire_rect = fire_frames[0].get_rect()
fire_rect.center = (HALF_WIDTH, HEIGHT - fire_rect.height // 2)

shooting = False

# Colors
BLACK = (0, 0, 0)

# Raycasting parameters
FOV = math.pi / 3
HALF_FOV = FOV / 2
NUM_RAYS = WIDTH // 2
MAX_DEPTH = 800
DELTA_ANGLE = FOV / NUM_RAYS
DIST = NUM_RAYS / (2 * math.tan(HALF_FOV))
PROJ_COEFF = 3 * DIST * TILE_SIZE
SCALE = WIDTH // NUM_RAYS

def mapping(x, y):
    return int(x) // TILE_SIZE * TILE_SIZE, int(y) // TILE_SIZE * TILE_SIZE

def ray_casting():
    global player_x, player_y, player_angle
    cur_angle = player_angle - HALF_FOV
    for ray in range(NUM_RAYS):
        sin_a = math.sin(cur_angle)
        cos_a = math.cos(cur_angle)

        # Vertical lines
        x, dx = (int(player_x // TILE_SIZE) * TILE_SIZE + TILE_SIZE, 1) if cos_a > 0 else (int(player_x // TILE_SIZE) * TILE_SIZE, -1)
        for i in range(0, MAX_DEPTH, TILE_SIZE):
            depth_v = (x - player_x) / cos_a
            yv = player_y + depth_v * sin_a
            tile_v = mapping(x + dx, yv)
            if 0 <= tile_v[0] < MAP_WIDTH and 0 <= tile_v[1] < MAP_HEIGHT:
                if world_map[int(tile_v[1] / TILE_SIZE)][int(tile_v[0] / TILE_SIZE)] == '#':
                    break
            x += dx * TILE_SIZE

        # Horizontal lines
        y, dy = (int(player_y // TILE_SIZE) * TILE_SIZE + TILE_SIZE, 1) if sin_a > 0 else (int(player_y // TILE_SIZE) * TILE_SIZE, -1)
        for i in range(0, MAX_DEPTH, TILE_SIZE):
            depth_h = (y - player_y) / sin_a
            xh = player_x + depth_h * cos_a
            tile_h = mapping(xh, y + dy)
            if 0 <= tile_h[0] < MAP_WIDTH and 0 <= tile_h[1] < MAP_HEIGHT:
                if world_map[int(tile_h[1] / TILE_SIZE)][int(tile_h[0] / TILE_SIZE)] == '#':
                    break
            y += dy * TILE_SIZE

        # Depth selection
        depth = depth_v if depth_v < depth_h else depth_h
        offset = yv % TILE_SIZE if depth_v < depth_h else xh % TILE_SIZE

        # Remove fishbowl effect
        depth *= math.cos(player_angle - cur_angle)

        # Projection
        proj_height = min(int(PROJ_COEFF / (depth + 0.0001)), 2 * HEIGHT)

        # Wall slice
        wall_column = wall_texture.subsurface(int(offset), 0, 1, TILE_SIZE)
        wall_column = pygame.transform.scale(wall_column, (SCALE, proj_height))
        screen.blit(wall_column, (ray * SCALE, HALF_HEIGHT - proj_height // 2))

        cur_angle += DELTA_ANGLE

# Main game loop
while True:
    delta_time = clock.get_time() / 1000  # Time since last frame in seconds

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                pygame.quit()
                sys.exit()
            if event.key == pygame.K_SPACE:
                if not shooting:
                    shooting = True
                    fire_frame_index = 0
                    fire_animation_timer = 0
                    impulse.trigger()  # Trigger impulse when shooting

        if event.type == pygame.KEYUP:
            if event.key == pygame.K_SPACE:
                pass  # Shooting animation will complete even after key is released

    # Movement
    keys = pygame.key.get_pressed()
    sin_a = math.sin(player_angle)
    cos_a = math.cos(player_angle)

    dx, dy = 0, 0
    is_moving = False  # Flag to check if the player is moving
    if keys[pygame.K_w]:
        dx += player_speed * cos_a
        dy += player_speed * sin_a
        is_moving = True
    if keys[pygame.K_s]:
        dx -= player_speed * cos_a
        dy -= player_speed * sin_a
        is_moving = True
    if keys[pygame.K_a]:
        dx += player_speed * sin_a
        dy -= player_speed * cos_a
        is_moving = True
    if keys[pygame.K_d]:
        dx -= player_speed * sin_a
        dy += player_speed * cos_a
        is_moving = True

    # Collision detection
    next_x = player_x + dx
    next_y = player_y + dy
    if world_map[int(next_y / TILE_SIZE)][int(next_x / TILE_SIZE)] == '.':
        player_x = next_x
        player_y = next_y

    if keys[pygame.K_LEFT]:
        player_angle -= 0.05
    if keys[pygame.K_RIGHT]:
        player_angle += 0.05

    # Update Bobbing
    bobbing.update(delta_time, is_moving)
    offset_x, offset_y = bobbing.get_offset()

    # Update Impulse
    impulse.update(delta_time)
    impulse_offset_x, impulse_offset_y = impulse.get_offset()

    # Drawing
    screen.fill(BLACK)
    ray_casting()

    # Update shooting animation
    if shooting:
        fire_animation_timer += delta_time
        if fire_animation_timer >= fire_animation_speed:
            fire_animation_timer = 0
            fire_frame_index += 1
            if fire_frame_index >= len(fire_frames) - 1:
                shooting = False
                fire_frame_index = len(fire_frames) - 1  # Set to idle frame
    else:
        fire_frame_index = len(fire_frames) - 1  # Idle frame index

    # Apply bobbing and impulse offsets to the weapon's position
    fire_rect.center = (
        HALF_WIDTH + offset_x + impulse_offset_x,
        HEIGHT - fire_rect.height // 2 + offset_y + impulse_offset_y
    )

    # Draw shooting animation or idle sprite
    screen.blit(fire_frames[fire_frame_index], fire_rect)

    pygame.display.flip()
    clock.tick(60)

Add ball (ball.png) to my game that have gravity and can be pushed by player, the ball interact with floor and walls