436 lines
16 KiB
Python
436 lines
16 KiB
Python
#!/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 engine’s 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()
|