Can you Make it so A turns the ship left and D turns the ship right, and add joystick support? Output full code.


import glfw
from OpenGL.GL import *
from OpenGL.GLU import *
import numpy as np
import sys
import math
from PIL import Image
import random

# Function to load materials from the .mtl file
def load_mtl(filename):
    materials = {}
    current_mtl = None

    try:
        with open(filename, 'r') as f:
            for line in f:
                if line.startswith('newmtl'):
                    parts = line.strip().split(None, 1)
                    if len(parts) > 1:
                        current_mtl = parts[1]
                        materials[current_mtl] = {
                            'Kd': [1.0, 1.0, 1.0],  # Diffuse color default to white
                            'map_Kd': None          # Texture map
                        }
                elif line.startswith('Kd') and current_mtl:
                    parts = line.strip().split()
                    materials[current_mtl]['Kd'] = list(map(float, parts[1:4]))
                elif line.startswith('map_Kd') and current_mtl:
                    parts = line.strip().split(None, 1)
                    if len(parts) > 1:
                        materials[current_mtl]['map_Kd'] = parts[1]
    except IOError:
        print(f"Error: Unable to open {filename}")
        sys.exit(1)

    return materials

# OBJ loader with materials and textures
def load_obj(filename):
    vertices = []
    tex_coords = []
    normals = []
    faces = []
    materials = {}
    current_mtl = None
    mtl_files = []

    try:
        with open(filename, 'r') as f:
            for line in f:
                if line.startswith('mtllib'):
                    parts = line.strip().split(None, 1)
                    if len(parts) > 1:
                        mtl_files.append(parts[1])
                elif line.startswith('v '):
                    parts = line.strip().split()
                    vertex = list(map(float, parts[1:4]))
                    vertices.append(vertex)
                elif line.startswith('vt '):
                    parts = line.strip().split()
                    tex_coord = list(map(float, parts[1:3]))
                    tex_coords.append(tex_coord)
                elif line.startswith('vn '):
                    parts = line.strip().split()
                    normal = list(map(float, parts[1:4]))
                    normals.append(normal)
                elif line.startswith('usemtl'):
                    parts = line.strip().split(None, 1)
                    if len(parts) > 1:
                        current_mtl = parts[1]
                elif line.startswith('f '):
                    parts = line.strip().split()
                    face = []
                    for part in parts[1:]:
                        indices = part.split('/')
                        v_idx = int(indices[0]) - 1
                        vt_idx = int(indices[1]) - 1 if len(indices) > 1 and indices[1] else None
                        vn_idx = int(indices[2]) - 1 if len(indices) > 2 and indices[2] else None
                        face.append({'v': v_idx, 'vt': vt_idx, 'vn': vn_idx})
                    face.append(current_mtl)  # Append material to face
                    faces.append(face)
            if not vertices or not faces:
                print(f"Error: {filename} contains no vertices or faces.")
                sys.exit(1)
            print(f"Loaded {len(vertices)} vertices and {len(faces)} faces from {filename}")
    except IOError:
        print(f"Error: Unable to open {filename}")
        sys.exit(1)

    # Load materials from the material files
    for mtl_file in mtl_files:
        mtl_data = load_mtl(mtl_file)
        materials.update(mtl_data)

    return {
        'vertices': np.array(vertices, dtype='float32'),
        'tex_coords': np.array(tex_coords, dtype='float32'),
        'normals': np.array(normals, dtype='float32'),
        'faces': faces,
        'materials': materials
    }

# Function to load textures from materials
def load_textures(materials):
    texture_ids = {}
    for mtl_name, mtl_props in materials.items():
        texture_file = mtl_props.get('map_Kd')
        if texture_file:
            texture_id = load_texture(texture_file)
            texture_ids[mtl_name] = texture_id
        else:
            texture_ids[mtl_name] = None
    return texture_ids

# Function to load a texture image
def load_texture(filename):
    try:
        texture_image = Image.open(filename)
        texture_image = texture_image.convert('RGB')
        texture_data = texture_image.tobytes("raw", "RGB", 0, -1)
        width, height = texture_image.size

        texture_id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, texture_id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, texture_data)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)

        return texture_id
    except IOError:
        print(f"Error: Unable to load texture {filename}")
        sys.exit(1)

# Quaternion class for handling rotations
class Quaternion:
    def __init__(self, w=1.0, x=0.0, y=0.0, z=0.0):
        self.w = w
        self.x = x
        self.y = y
        self.z = z

    @staticmethod
    def from_axis_angle(axis, angle_deg):
        angle_rad = np.radians(angle_deg)
        half_angle = angle_rad / 2.0
        sin_half = np.sin(half_angle)
        axis = axis / np.linalg.norm(axis)
        return Quaternion(np.cos(half_angle),
                          axis[0] * sin_half,
                          axis[1] * sin_half,
                          axis[2] * sin_half)

    def multiply(self, other):
        w = self.w * other.w - self.x * other.x - self.y * other.y - self.z * other.z
        x = self.w * other.x + self.x * other.w + self.y * other.z - self.z * other.y
        y = self.w * other.y - self.x * other.z + self.y * other.w + self.z * other.x
        z = self.w * other.z + self.x * other.y - self.y * other.x + self.z * other.w
        return Quaternion(w, x, y, z)

    def to_rotation_matrix(self):
        """Convert the quaternion to a 4x4 rotation matrix."""
        w, x, y, z = self.w, self.x, self.y, self.z
        return np.array([
            [1 - 2*(y**2 + z**2),     2*(x*y - z*w),     2*(x*z + y*w), 0],
            [    2*(x*y + z*w), 1 - 2*(x**2 + z**2),     2*(y*z - x*w), 0],
            [    2*(x*z - y*w),     2*(y*z + x*w), 1 - 2*(x**2 + y**2), 0],
            [                0,                 0,                 0, 1]
        ], dtype='float32')

    def normalize(self):
        norm = math.sqrt(self.w**2 + self.x**2 + self.y**2 + self.z**2)
        if norm == 0:
            raise ValueError("Cannot normalize a quaternion with zero norm.")
        self.w /= norm
        self.x /= norm
        self.y /= norm
        self.z /= norm

# Camera class for handling position and orientation
class Camera:
    def __init__(self, distance=15.0, height=5.0):
        self.distance = distance
        self.height = height
        self.position = np.array([0.0, 0.0, 0.0])
        self.target = np.array([0.0, 0.0, 0.0])
        self.up = np.array([0.0, 1.0, 0.0])

    def update(self, ship_position, ship_orientation):
        # Camera offset in local space (behind the ship and above)
        camera_offset = np.array([0.0, self.height, -self.distance, 1.0])
        # Transform to world space using ship's orientation
        cam_offset_world = ship_orientation.to_rotation_matrix() @ camera_offset
        self.position = ship_position + cam_offset_world[:3]
        self.target = ship_position
        # Up vector transformed by ship's rotation
        # Use only the upper-left 3x3 rotation matrix for vector transformation
        self.up = ship_orientation.to_rotation_matrix()[:3, :3] @ np.array([0.0, 1.0, 0.0])

    def apply_view(self):
        gluLookAt(
            self.position[0], self.position[1], self.position[2],
            self.target[0], self.target[1], self.target[2],
            self.up[0], self.up[1], self.up[2]
        )

# Function to draw a sphere without using GLUT
def draw_sphere(radius, slices, stacks):
    quadric = gluNewQuadric()
    gluSphere(quadric, radius, slices, stacks)
    gluDeleteQuadric(quadric)

# Projectile class for handling bullets
class Projectile:
    def __init__(self, position, direction, speed=1.0):
        self.position = np.array(position, dtype='float32')
        self.direction = np.array(direction, dtype='float32')
        norm = np.linalg.norm(self.direction)
        if norm != 0:
            self.direction /= norm
        self.speed = speed
        self.alive = True  # Used for removing projectile when out of range or time

    def update(self, delta_time):
        self.position += self.direction * self.speed * delta_time

    def draw(self):
        glPushMatrix()
        glTranslatef(self.position[0], self.position[1], self.position[2])
        glColor3f(1.0, 1.0, 0.0)  # Yellow color for projectiles
        draw_sphere(0.2, 8, 8)  # Use the new draw_sphere function
        glPopMatrix()

# Initialize GLFW and create a window
if not glfw.init():
    print("Failed to initialize GLFW")
    sys.exit()

window = glfw.create_window(800, 600, "3D Ship Viewer", None, None)
if not window:
    print("Failed to create GLFW window")
    glfw.terminate()
    sys.exit()

glfw.make_context_current(window)

# Set up viewport and projection
def window_resize(window, width, height):
    if height == 0:
        height = 1
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(45, width / height, 0.1, 1000.0)
    glMatrixMode(GL_MODELVIEW)

glfw.set_window_size_callback(window, window_resize)
# Initial setup
window_size = glfw.get_window_size(window)
window_resize(window, window_size[0], window_size[1])

# Enable depth testing and lighting
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
# Set light properties
glLightfv(GL_LIGHT0, GL_POSITION, [0, 100, 100, 1])
glLightfv(GL_LIGHT0, GL_AMBIENT, [0.2, 0.2, 0.2, 1.0])
glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.8, 0.8, 0.8, 1.0])
glLightfv(GL_LIGHT0, GL_SPECULAR, [1.0, 1.0, 1.0, 1.0])
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glShadeModel(GL_SMOOTH)

# Enable texturing
glEnable(GL_TEXTURE_2D)

# Load ship model
ship_data = load_obj('Low Poly Spaceship.obj')
ship_vertices = ship_data['vertices']
tex_coords = ship_data['tex_coords']
normals = ship_data['normals']
ship_faces = ship_data['faces']
materials = ship_data['materials']

# Load textures from materials
texture_ids = load_textures(materials)

# Generate stars within a smaller range for visibility
num_stars = 1000
star_positions = np.random.uniform(-50, 50, (num_stars, 3))

# Initialize ship position and orientation
ship_pos = np.array([0.0, 0.0, 0.0], dtype='float32')
ship_vel = np.array([0.0, 0.0, 0.0], dtype='float32')  # Ship's velocity

# Initialize ship orientation as a quaternion
ship_orientation = Quaternion()

# Initialize camera
camera = Camera(distance=15.0, height=5.0)

# Movement variables
acceleration = 5.0        # Increased acceleration rate for better responsiveness
rotate_speed = 90.0       # Degrees per second
friction = 0.5            # Friction coefficient for slowing down
max_speed = 20.0          # Maximum speed

# Turbulence variables
turbulence_intensity = 0.2  # Adjust this value for more or less turbulence

# Key state
keys = {}

def key_callback(window, key, scancode, action, mods):
    global keys
    if action == glfw.PRESS:
        keys[key] = True
    elif action == glfw.RELEASE:
        keys[key] = False

glfw.set_key_callback(window, key_callback)

# Initialize projectiles list
projectiles = []

# Timing
last_time = glfw.get_time()

# Main loop
while not glfw.window_should_close(window):
    # Calculate delta time
    current_time = glfw.get_time()
    delta_time = current_time - last_time
    last_time = current_time

    # Handle input
    # Update orientation based on key presses
    rotation_quaternions = []  # List to accumulate rotation quaternions

    # Define a helper function to get ship's local axes in global coordinates
    def get_local_axis(local_axis):
        """
        Transforms a local axis to global coordinates based on current ship orientation.

        :param local_axis: Local axis as a numpy array [x, y, z]
        :return: Transformed global axis as a numpy array [x, y, z]
        """
        rotation_matrix = ship_orientation.to_rotation_matrix()[:3, :3]
        return rotation_matrix @ local_axis

    # Check each rotation key and create corresponding rotation quaternions
    if glfw.KEY_UP in keys and keys[glfw.KEY_UP]:
        # Pitch up (rotate around local X-axis)
        local_axis = np.array([1.0, 0.0, 0.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    if glfw.KEY_DOWN in keys and keys[glfw.KEY_DOWN]:
        # Pitch down
        local_axis = np.array([1.0, 0.0, 0.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, -rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    if glfw.KEY_LEFT in keys and keys[glfw.KEY_LEFT]:
        # Roll left (rotate around local Z-axis)
        local_axis = np.array([0.0, 0.0, 1.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, -rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    if glfw.KEY_RIGHT in keys and keys[glfw.KEY_RIGHT]:
        # Roll right
        local_axis = np.array([0.0, 0.0, 1.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    if glfw.KEY_Q in keys and keys[glfw.KEY_Q]:
        # Yaw left (rotate around local Y-axis)
        local_axis = np.array([0.0, 1.0, 0.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    if glfw.KEY_E in keys and keys[glfw.KEY_E]:
        # Yaw right
        local_axis = np.array([0.0, 1.0, 0.0])
        global_axis = get_local_axis(local_axis)
        rot_quat = Quaternion.from_axis_angle(global_axis, -rotate_speed * delta_time)
        rotation_quaternions.append(rot_quat)

    # Apply all accumulated rotations
    for rot_quat in rotation_quaternions:
        ship_orientation = rot_quat.multiply(ship_orientation)
        ship_orientation.normalize()

    # Compute ship's forward and right directions based on current orientation
    # Forward is +Z, Right is +X in local space
    rotation_matrix = ship_orientation.to_rotation_matrix()[:3, :3]
    forward_direction = rotation_matrix @ np.array([0.0, 0.0, 1.0])
    right_direction = rotation_matrix @ np.array([1.0, 0.0, 0.0])

    # Normalize directions
    forward_norm = np.linalg.norm(forward_direction)
    if forward_norm != 0:
        forward_direction /= forward_norm
    right_norm = np.linalg.norm(right_direction)
    if right_norm != 0:
        right_direction /= right_norm

    # Movement
    # Apply acceleration
    if glfw.KEY_W in keys and keys[glfw.KEY_W]:
        ship_vel += acceleration * forward_direction * delta_time
    if glfw.KEY_S in keys and keys[glfw.KEY_S]:
        ship_vel -= acceleration * forward_direction * delta_time
    if glfw.KEY_A in keys and keys[glfw.KEY_A]:
        # Strafe left
        ship_vel -= acceleration * right_direction * delta_time
    if glfw.KEY_D in keys and keys[glfw.KEY_D]:
        # Strafe right
        ship_vel += acceleration * right_direction * delta_time

    # Apply turbulence
    turbulence = turbulence_intensity * (np.random.rand(3) - 0.5) * delta_time
    ship_vel += turbulence

    # Limit speed
    speed = np.linalg.norm(ship_vel)
    if speed > max_speed:
        ship_vel = (ship_vel / speed) * max_speed

    # Apply friction
    ship_vel -= ship_vel * friction * delta_time

    # Update ship position
    ship_pos += ship_vel * delta_time

    # Shooting
    if glfw.KEY_SPACE in keys and keys[glfw.KEY_SPACE]:
        # Fire projectile
        # Reset the key state to prevent continuous firing
        keys[glfw.KEY_SPACE] = False

        # Create a new projectile
        projectile_speed = 50.0
        projectile = Projectile(
            position=ship_pos.copy(),
            direction=forward_direction.copy(),
            speed=projectile_speed
        )
        projectiles.append(projectile)

    # Update projectiles
    for projectile in projectiles[:]:
        projectile.update(delta_time)
        # Remove projectile if it's too far away
        if np.linalg.norm(projectile.position - ship_pos) > 500:
            projectiles.remove(projectile)

    # Update camera based on ship's new position and orientation
    camera.update(ship_position=ship_pos, ship_orientation=ship_orientation)

    # Clear buffers
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glLoadIdentity()

    # Apply camera view
    camera.apply_view()

    # Draw stars
    glDisable(GL_LIGHTING)  # Disable lighting for stars
    glPointSize(2.0)
    glColor3f(1.0, 1.0, 1.0)
    glBegin(GL_POINTS)
    for star in star_positions:
        glVertex3f(star[0], star[1], star[2])
    glEnd()
    glEnable(GL_LIGHTING)  # Re-enable lighting

    # Draw projectiles
    glDisable(GL_TEXTURE_2D)  # Disable texturing for projectiles
    for projectile in projectiles:
        projectile.draw()
    glEnable(GL_TEXTURE_2D)

    # Convert quaternion to 4x4 rotation matrix
    ship_rotation_matrix_4x4 = ship_orientation.to_rotation_matrix()

    # Draw ship
    glPushMatrix()
    glTranslatef(ship_pos[0], ship_pos[1], ship_pos[2])

    # Apply ship rotations using the 4x4 matrix
    glMultMatrixf(ship_rotation_matrix_4x4.T)

    current_mtl = None

    glBegin(GL_TRIANGLES)
    for face in ship_faces:
        mtl_name = face[-1]  # Last element is the material name
        if mtl_name != current_mtl:
            glEnd()
            current_mtl = mtl_name
            mtl_props = materials.get(mtl_name, {})
            texture_id = texture_ids.get(mtl_name)
            if texture_id:
                glBindTexture(GL_TEXTURE_2D, texture_id)
                glEnable(GL_TEXTURE_2D)
                glColor3f(1.0, 1.0, 1.0)
            else:
                glDisable(GL_TEXTURE_2D)
                Kd = mtl_props.get('Kd', [1.0, 1.0, 1.0])
                glColor3fv(Kd)
            glBegin(GL_TRIANGLES)
        for vertex_info in face[:-1]:  # Exclude the material name
            v_idx = vertex_info['v']
            vt_idx = vertex_info['vt']
            vn_idx = vertex_info['vn']
            if vn_idx is not None and len(normals) > vn_idx:
                glNormal3fv(normals[vn_idx])
            else:
                # If normals are not provided, compute or use a default
                glNormal3f(0.0, 0.0, 1.0)
            if vt_idx is not None and len(tex_coords) > vt_idx:
                glTexCoord2fv(tex_coords[vt_idx])
            glVertex3fv(ship_vertices[v_idx])
    glEnd()
    glPopMatrix()

    # Swap buffers and poll events
    glfw.swap_buffers(window)
    glfw.poll_events()

# Terminate GLFW
glfw.terminate()