small-projects/multiplyer-racing/main.py
2025-04-08 19:58:12 -05:00

436 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
A single-player pygame racing simulation where engine RPM and speed (in mph)
are modeled as independent—but coupled—systems. The engines RPM (tachometer)
is updated by throttle/brake input plus a damping effect that tends to “lock”
the engine to the value expected from the current vehicle speed and gear.
Vehicle acceleration is computed from the RPM difference (engine load)
minus drag, yielding a more realistic feel.
Controls:
- LEFT/RIGHT arrows: steer
- UP arrow: throttle (increases engine RPM and, via the dynamics, accelerates the car)
- DOWN arrow: brake
- E: upshift (gear +1 with rev-match drop)
- Q: downshift (gear -1 with a slight rev bump)
- R: toggle reverse (gear 0) versus forward (gear 1)
"""
import sys
import math
import random
import pygame
# ----- Screen & Road Configuration -----
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
ROAD_WIDTH = 150 # Road drawn as a wide filled polygon
FPS = 60
# ----- Engine & Vehicle Dynamics -----
IDLE_RPM = 800
REDLINE_RPM = 6000
# Engine dynamics (in rpm/sec)
THROTTLE_RPM_RATE = 8000 # When throttle is pressed, engine spins up
BRAKE_RPM_RATE = 2500 # When braking, engine RPM drops rapidly
DAMPING_FACTOR = 1 # How strongly engine RPM is forced toward the "ideal" RPM
# These dynamics model the connection between engine torque and vehicle acceleration:
# The vehicle's acceleration (in mph/s) is proportional to the difference between the
# current engine RPM and the ideal RPM that would be produced by the current speed in the given gear.
ACCELERATION_CONVERSION = 10 # (rpm difference) to mph/s conversion factor
DRAG_COEFF = 0.02 # Drag (per second) that always slows the vehicle
# Conversion constant (empirical) for what engine RPM should be for a given vehicle speed
# in a particular gear. In this model, ideal_rpm = speed × gear_ratio × K.
K = 0.1
# Maximum vehicle speed (in mph)
MAX_SPEED = 9000000
# ----- Transmission & Gear Ratios -----
# These gear ratios (including reverse = gear 0) are assumed to incorporate the effect
# of the transmission (and final drive) so that:
# ideal_rpm = speed (mph) × gear_ratio × K.
GEAR_RATIOS = {
0: 4.866, # Reverse
1: 4.696,
2: 2.985,
3: 2.146,
4: 1.769,
5: 1.520,
6: 1.275,
7: 1.000,
8: 0.854,
9: 0.689,
10: 0.636
}
# Turning rate (radians per second)
TURN_RATE = 2.0
# ----- Gauge Configuration -----
GAUGE_RADIUS = 50
SPEED_GAUGE_CENTER = (150, 100)
TACH_GAUGE_CENTER = (650, 100)
# ----- Helper Functions -----
def rotate_point(point, angle):
"""Rotate a 2D point by angle in radians."""
x, y = point
cos_a = math.cos(angle)
sin_a = math.sin(angle)
return (x * cos_a - y * sin_a, x * sin_a + y * cos_a)
def draw_gauge(surface, center, radius, min_val, max_val, current_val, label):
"""
Draw an analog gauge (circle with a needle) mapping a value from min_val to max_val
to an angle between -135° and +135°.
"""
pygame.draw.circle(surface, (255, 255, 255), center, radius, 2)
min_angle = -135
max_angle = 135
ratio = (current_val - min_val) / (max_val - min_val) if max_val != min_val else 0
angle_deg = min_angle + ratio * (max_angle - min_angle)
angle_rad = math.radians(angle_deg)
needle_length = radius - 10
needle_end = (center[0] + needle_length * math.cos(angle_rad),
center[1] + needle_length * math.sin(angle_rad))
pygame.draw.line(surface, (255, 0, 0), center, needle_end, 3)
font = pygame.font.SysFont(None, 24)
text_surf = font.render(f"{label}: {int(current_val)}", True, (255, 255, 255))
text_rect = text_surf.get_rect(center=(center[0], center[1] + radius + 20))
surface.blit(text_surf, text_rect)
def point_line_distance(P, A, B):
"""Return the shortest distance from point P to line segment AB."""
AB = (B[0] - A[0], B[1] - A[1])
AP = (P[0] - A[0], P[1] - A[1])
ab2 = AB[0]**2 + AB[1]**2
if ab2 == 0:
return math.hypot(P[0] - A[0], P[1] - A[1])
t = max(0, min(1, (AP[0]*AB[0] + AP[1]*AB[1]) / ab2))
proj = (A[0] + t*AB[0], A[1] + t*AB[1])
return math.hypot(P[0] - proj[0], P[1] - proj[1])
def closest_point_on_segment(P, A, B):
"""Return the closest point on line segment AB to point P."""
AB = (B[0] - A[0], B[1] - A[1])
AP = (P[0] - A[0], P[1] - A[1])
ab2 = AB[0]**2 + AB[1]**2
if ab2 == 0:
return A
t = max(0, min(1, (AP[0]*AB[0] + AP[1]*AB[1]) / ab2))
return (A[0] + t * AB[0], A[1] + t * AB[1])
# ----- Game Objects -----
class Car:
def __init__(self):
self.x = 0.0 # Position (in feet or arbitrary units that scale to mph)
self.y = 0.0
self.angle = 0.0 # Orientation (radians; 0 means facing right)
self.engine_rpm = IDLE_RPM
self.gear = 1 # 0 = reverse, 1..10 forward
self.speed = 0.0 # Vehicle speed in mph
def update(self, throttle, brake, dt):
"""
Update engine RPM and vehicle speed over time.
- The engine RPM is updated based on throttle input, brake input, and a damping term that
forces it toward the "ideal" RPM determined by the current speed.
- The vehicle acceleration is then computed from the difference between actual and ideal RPM.
"""
# Compute the ideal engine RPM given the current speed and gear.
# For realism, if the car is stopped, ideal RPM should be at least IDLE_RPM.
ideal_rpm = max(IDLE_RPM, self.speed * GEAR_RATIOS[self.gear] * K)
# --- Engine RPM Dynamics ---
throttle_effect = THROTTLE_RPM_RATE if throttle else 0
brake_effect = BRAKE_RPM_RATE if brake else 0
# Engine tends to return toward the ideal RPM.
d_rpm = throttle_effect - brake_effect - DAMPING_FACTOR * (self.engine_rpm - ideal_rpm)
self.engine_rpm += d_rpm * dt
# Clamp engine RPM between IDLE and REDLINE.
if self.engine_rpm < IDLE_RPM:
self.engine_rpm = IDLE_RPM
if self.engine_rpm > REDLINE_RPM:
self.engine_rpm = REDLINE_RPM
# --- Vehicle Acceleration Dynamics ---
accel = (self.engine_rpm - ideal_rpm) / ACCELERATION_CONVERSION
accel -= DRAG_COEFF * self.speed
self.speed += accel * dt
if self.speed < 0:
self.speed = 0
if self.speed > MAX_SPEED:
self.speed = MAX_SPEED
# --- Update Position ---
direction = 1 if self.gear != 0 else -1
ft_per_sec = self.speed * 1.46667 # Convert mph to ft/s
self.x += math.cos(self.angle) * ft_per_sec * dt * direction
self.y += math.sin(self.angle) * ft_per_sec * dt * direction
def rev_match_on_shift_up(self):
"""Simulate a realistic drop in RPM when upshifting (rev-match)."""
self.engine_rpm *= 0.85
if self.engine_rpm < IDLE_RPM:
self.engine_rpm = IDLE_RPM
def rev_match_on_shift_down(self):
"""Simulate a realistic bump in RPM when downshifting."""
self.engine_rpm *= 1.15
if self.engine_rpm > REDLINE_RPM:
self.engine_rpm = REDLINE_RPM
class Track:
"""
A procedural track that extends as the car moves, giving the illusion of an infinite road.
"""
def __init__(self):
self.segments = []
self.last_point = (0.0, 0.0)
self.last_angle = 0.0
self.generate_initial_track()
def generate_initial_track(self):
num_segments = 20
for _ in range(num_segments):
self.extend_segment()
def extend_segment(self):
length = 150
angle_change = random.uniform(-0.3, 0.3)
self.last_angle += angle_change
dx = length * math.cos(self.last_angle)
dy = length * math.sin(self.last_angle)
next_point = (self.last_point[0] + dx, self.last_point[1] + dy)
self.segments.append((self.last_point, next_point))
self.last_point = next_point
def ensure_track_length(self, car_pos, min_distance=500):
dist = math.hypot(self.last_point[0] - car_pos[0], self.last_point[1] - car_pos[1])
while dist < min_distance:
self.extend_segment()
dist = math.hypot(self.last_point[0] - car_pos[0], self.last_point[1] - car_pos[1])
def is_on_road(car, track, road_width):
"""
Checks whether the car's center is within half the road width of any track segment.
"""
P = (car.x, car.y)
hw = road_width / 2
for seg in track.segments:
A, B = seg
if point_line_distance(P, A, B) <= hw:
return True
return False
# ----- Drawing Functions -----
def draw_road(surface, track, offset_x, offset_y, road_width):
"""
Draw each track segment as a filled polygon representing a wide road.
"""
for seg in track.segments:
start, end = seg
dx = end[0] - start[0]
dy = end[1] - start[1]
seg_len = math.hypot(dx, dy)
if seg_len == 0:
continue
nx = -dy / seg_len
ny = dx / seg_len
half_w = road_width / 2
start_left = (start[0] + nx * half_w, start[1] + ny * half_w)
start_right = (start[0] - nx * half_w, start[1] - ny * half_w)
end_left = (end[0] + nx * half_w, end[1] + ny * half_w)
end_right = (end[0] - nx * half_w, end[1] - ny * half_w)
poly = [
(int(start_left[0] + offset_x), int(start_left[1] + offset_y)),
(int(end_left[0] + offset_x), int(end_left[1] + offset_y)),
(int(end_right[0] + offset_x), int(end_right[1] + offset_y)),
(int(start_right[0] + offset_x), int(start_right[1] + offset_y))
]
pygame.draw.polygon(surface, (100, 100, 100), poly)
center_start = (start[0] + offset_x, start[1] + offset_y)
center_end = (end[0] + offset_x, end[1] + offset_y)
pygame.draw.line(surface, (255, 255, 255), center_start, center_end, 2)
def draw_car(surface, car, offset_x, offset_y):
"""
Draw the car as a rotated, car-like polygon.
"""
center = (car.x + offset_x, car.y + offset_y)
half_length = 40 / 2
half_width = 20 / 2
points = [
( half_length, 0), # Front tip
( half_length * 0.2, -half_width), # Rear top
(-half_length, -half_width), # Rear left
(-half_length, half_width), # Rear right
( half_length * 0.2, half_width) # Rear bottom
]
rotated = []
for p in points:
rp = rotate_point(p, car.angle)
rotated.append((rp[0] + center[0], rp[1] + center[1]))
pygame.draw.polygon(surface, (255, 0, 0), rotated)
pygame.draw.polygon(surface, (255, 255, 255), rotated, 2)
def draw_map_view(surface, track, car):
"""
Draw a mini-map in the bottom-right corner displaying all generated road segments
and the car's current position.
"""
map_width = 200
map_height = 150
map_x = SCREEN_WIDTH - map_width - 10
map_y = SCREEN_HEIGHT - map_height - 10
map_rect = pygame.Rect(map_x, map_y, map_width, map_height)
# Draw mini-map background and border
pygame.draw.rect(surface, (30, 30, 30), map_rect)
pygame.draw.rect(surface, (255, 255, 255), map_rect, 2)
# Compute bounding box for all track segments
if track.segments:
xs = []
ys = []
for seg in track.segments:
A, B = seg
xs.extend([A[0], B[0]])
ys.extend([A[1], B[1]])
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
else:
min_x, max_x, min_y, max_y = 0, 1, 0, 1
# Add margins
margin = 10
min_x -= margin
min_y -= margin
max_x += margin
max_y += margin
world_width = max_x - min_x
world_height = max_y - min_y
if world_width == 0 or world_height == 0:
scale = 1
else:
scale = min((map_width - 20) / world_width, (map_height - 20) / world_height)
# Convert world coordinates to mini-map coordinates
def world_to_map(pos):
world_x, world_y = pos
map_pos_x = map_x + 10 + (world_x - min_x) * scale
map_pos_y = map_y + 10 + (world_y - min_y) * scale
return (int(map_pos_x), int(map_pos_y))
# Draw road segments on mini-map
for seg in track.segments:
A, B = seg
map_A = world_to_map(A)
map_B = world_to_map(B)
pygame.draw.line(surface, (200, 200, 200), map_A, map_B, 2)
# Draw the car's current position as a small circle
car_map_pos = world_to_map((car.x, car.y))
pygame.draw.circle(surface, (255, 0, 0), car_map_pos, 4)
# ----- Main Game Loop -----
def main():
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Super Realistic Car Simulation")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 24)
car = Car()
track = Track()
running = True
while running:
dt = clock.tick(FPS) / 1000.0
# ----- Event Handling -----
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_e and car.gear < 10:
car.gear += 1
car.rev_match_on_shift_up()
elif event.key == pygame.K_q and car.gear > 1:
car.gear -= 1
car.rev_match_on_shift_down()
elif event.key == pygame.K_r:
if car.gear == 0:
car.gear = 1
else:
car.gear = 0
# ----- Continuous Input -----
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
car.angle -= TURN_RATE * dt
if keys[pygame.K_RIGHT]:
car.angle += TURN_RATE * dt
throttle = keys[pygame.K_UP]
brake = keys[pygame.K_DOWN]
# ----- Update Car & Extend Track -----
car.update(throttle, brake, dt)
track.ensure_track_length((car.x, car.y), min_distance=500)
# Determine view offset to center the car.
offset_x = SCREEN_WIDTH / 2 - car.x
offset_y = SCREEN_HEIGHT / 2 - car.y
# ----- Drawing -----
screen.fill((0, 150, 0)) # Grass background.
draw_road(screen, track, offset_x, offset_y, ROAD_WIDTH)
draw_car(screen, car, offset_x, offset_y)
# Display status (on-road/off-road)
status_text = "On Road" if is_on_road(car, track, ROAD_WIDTH) else "Off Road"
status_surf = font.render(status_text, True, (255, 255, 255))
screen.blit(status_surf, (10, 10))
# If off road, draw blue line to nearest point on road.
if not is_on_road(car, track, ROAD_WIDTH):
P = (car.x, car.y)
min_distance = float('inf')
closest_point = None
for seg in track.segments:
A, B = seg
candidate = closest_point_on_segment(P, A, B)
d = math.hypot(P[0] - candidate[0], P[1] - candidate[1])
if d < min_distance:
min_distance = d
closest_point = candidate
if closest_point is not None:
start_screen = (int(car.x + offset_x), int(car.y + offset_y))
end_screen = (int(closest_point[0] + offset_x), int(closest_point[1] + offset_y))
pygame.draw.line(screen, (0, 0, 255), start_screen, end_screen, 2)
# Draw gauges.
draw_gauge(screen, SPEED_GAUGE_CENTER, GAUGE_RADIUS, 0, MAX_SPEED, car.speed, "Speed (mph)")
draw_gauge(screen, TACH_GAUGE_CENTER, GAUGE_RADIUS, 0, REDLINE_RPM, car.engine_rpm, "RPM")
# Display gear information.
gear_text = "Reverse" if (car.gear == 0) else f"Gear: {car.gear}"
gear_surf = font.render(gear_text, True, (255, 255, 255))
screen.blit(gear_surf, (SPEED_GAUGE_CENTER[0] - GAUGE_RADIUS, SPEED_GAUGE_CENTER[1] + GAUGE_RADIUS + 40))
# ----- Draw Map View -----
draw_map_view(screen, track, car)
pygame.display.flip()
pygame.quit()
sys.exit()
if __name__ == "__main__":
main()