leadingedge
Attribution
Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
Original Python code
# Leading Edge - Code the Classics Volume 2
# Code by Andrew Gillett
# Graphics by Dan Malone
# Music and sound effects by Allister Brimble
# https://github.com/raspberrypipress/Code-the-Classics-Vol2.git
# https://store.rpipress.cc/products/code-the-classics-volume-ii
import pygame, pgzero, pgzrun, math, sys, time, platform
from abc import ABC, abstractmethod
from enum import Enum
from random import randint, uniform, choice
from pygame.math import Vector3, Vector2
# If the game window doesn't fit on the screen, you may need to turn off or reduce display scaling in the Windows/macOS settings
# On Windows, you can uncomment the following two lines to fix the issue. It sets the program as "DPI aware"
# meaning that display scaling won't be applied to it.
#import ctypes
#ctypes.windll.user32.SetProcessDPIAware()
# Enable this to use Pygame's 'gfxdraw' module for displaying polygons. This is faster in some older versions of Pygame,
# but in the latest version at the time of writing (2.2.0) it may actually be slightly slower than the default drawing
# module. See further down for more performance options
USE_GFXDRAW = False
if USE_GFXDRAW:
import pygame.gfxdraw
# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,6):
print("This game requires at least version 3.6 of Python. Please download it from www.python.org")
sys.exit()
# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# This uses a Python feature called list comprehension
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
print(f"This game requires at least version 1.2 of Pygame Zero. You have version {pgzero.__version__}. Please upgrade using the command 'pip3 install --upgrade pgzero'")
sys.exit()
# For a better frame rate, try width/height of 640x480, or even lower
WIDTH = 960
HEIGHT = 540
TITLE = "Leading Edge"
# Set to True improve frame rate by turning off scenery, drawing unfilled polygons and changing the draw distance
PERFORMANCE_MODE = False
if not PERFORMANCE_MODE:
SHOW_SCENERY = True
SHOW_TRACKSIDE = True
SHOW_RUMBLE_STRIPS = True
SHOW_YELLOW_LINES = True
OUTLINE_W = 0 # Change to 1 for unfilled polygons, which are a bit faster to draw
VIEW_DISTANCE = 200 # This is in units of number of track pieces, try 60 for a better frame rate, try 2000 for a bad frame rate but impressive draw distance
else:
SHOW_SCENERY = False
SHOW_TRACKSIDE = False
SHOW_RUMBLE_STRIPS = False
SHOW_YELLOW_LINES = False
OUTLINE_W = 1 # Change to 1 for unfilled polygons, which are a bit faster to draw
VIEW_DISTANCE = 150 # This is in units of number of track pieces, try 60 for a better frame rate, try 2000 for a bad frame rate but impressive draw distance
CLIPPING_PLANE = -0.25 # too close to 0 = frame rate issues (drawing huge polygons which are mostly off-screen), too far = stuff just in front of camera not being drawn
CLIPPING_PLANE_CARS = -0.08 # bring closer to zero to fix occasional flickering of CPU cars when very close to the camera, at the potential cost of frame rate
SCALE_FUNC = pygame.transform.scale # Which scale function to use - pygame.transform.smoothscale is better quality but slower
MAX_SCENERY_SCALED_WIDTH = WIDTH * 2 # When scaling scenery based on distance from camera, don't try to draw anything that would be scaled to wider than this
MAX_CAR_SCALED_WIDTH = WIDTH * 1 # As above but for cars
# Constants for track
SPACING = 1
TRACK_W = 3000
HALF_STRIPE_W = 25
HALF_RUMBLE_STRIP_W = 250
HALF_YELLOW_LINE_W = 80
YELLOW_LINE_DISTANCE_FROM_EDGE = 150
TRACK_COLOUR = (35, 96, 198)
TRACKSIDE_COLOUR_1 = (0, 77, 180)
TRACKSIDE_COLOUR_2 = (50, 77, 170)
STRIPE_COLOUR = (70, 192, 255)
YELLOW_LINE_COL = (0, 161, 88) # Yes, it's actually green, not yellow. It looks yellow because it's night.
RUMBLE_COLOUR_1 = (0, 116, 255)
RUMBLE_COLOUR_2 = (0, 58, 135)
SECTION_VERY_SHORT = 25
SECTION_SHORT = 50
SECTION_MEDIUM = 100
SECTION_LONG = 200
LAMP_X = TRACK_W//2 + 300
BILLBOARD_X = TRACK_W//2 + 600
CAMERA_FOLLOW_DISTANCE = 2
# Player car gameplay settings
LOSE_GRIP_SPEED = 50
ZERO_GRIP_SPEED = 100
PLAYER_ACCELERATION_MAX = 20
PLAYER_ACCELERATION_MIN = 10
HIGH_ACCEL_THRESHOLD = 30
CORNER_OFFSET_MULTIPLIER = 5.8 # Higher = harder to corner
STEERING_STRENGTH = 72 # Higher = steering has a stronger effect
# Min/max CPU car target speeds - see also track generation, some track pieces have target speed overrides set
CPU_CAR_MIN_TARGET_SPEED = 40
CPU_CAR_MAX_TARGET_SPEED = 65
NUM_LAPS = 5
NUM_CARS = 20
GRID_CAR_SPACING = 0.55 # How spaced out the cars are on the starting grid
# Half-width and height used during point transform, to save having to calculate them each time
HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2
# Skid sound starts fading in when grip goes below this level
SKID_SOUND_START_GRIP = 0.8
# Debug options
SHOW_TRACK_PIECE_INDEX = False
SHOW_TRACK_PIECE_OFFSETS = False
SHOW_CPU_CAR_SPEEDS = False
SHOW_DEBUG_TEXT = False
SHOW_PROFILE_TIMINGS = False
FIXED_TIMESTEP = 1/60
# These symbols substitute for the controller button images when displaying text.
# The symbols representing these images must be ones that aren't actually used themselves, e.g. we don't use the
# percent sign in text
SPECIAL_FONT_SYMBOLS = {'xb_a':'%'}
# Create a version of SPECIAL_FONT_SYMBOLS where the keys and values are swapped
SPECIAL_FONT_SYMBOLS_INVERSE = dict((v,k) for k,v in SPECIAL_FONT_SYMBOLS.items())
# A black image whose alpha (transparency) we vary, to fade the screen to black during the title screen
fade_to_black_image = pygame.Surface((WIDTH, HEIGHT))
# Class used for timing how long certain bits of code take to run
class Profiler:
def __init__(self, name=""):
self.start_time = time.perf_counter()
self.name = name
def get_ms(self):
endTime = time.perf_counter()
diff = endTime - self.start_time
return diff * 1000
def __str__(self):
return f"{self.name}: {self.get_ms()}ms"
# Utility functions
def remap(old_val, old_min, old_max, new_min, new_max):
# todo explain
return (new_max - new_min)*(old_val - old_min) / (old_max - old_min) + new_min
def remap_clamp(old_val, old_min, old_max, new_min, new_max):
# todo explain
# These first two lines are in case new_min and new_max are inverted
lower_limit = min(new_min, new_max)
upper_limit = max(new_min, new_max)
return min(upper_limit, max(lower_limit, remap(old_val, old_min, old_max, new_min, new_max)))
def inverse_lerp(a, b, value):
# todo explain
if a != b:
return min(1, max(0, ((value - a) / (b - a))))
return 0
def sign(x):
# Returns 1, 0 or -1 depending on whether number is positive, zero or negative
if x == 0:
return 0
else:
return -1 if x < 0 else 1
def move_towards(n, target, speed):
if n < target:
return min(n + speed, target)
else:
return max(n - speed, target)
def format_time(seconds):
# Return time string in the form "minutes:seconds.milliseconds"
# 06.3f ensures that we always show 2 digits for the whole part of the seconds
# 6 refers to the total number of characters including the decimal point
# We want to display times like "1:05.123" not "1:5.123"
return f"{int(seconds // 60)}:{seconds % 60:06.3f}"
def get_char_image_and_width(char, font):
# Return width of given character. ord() gives the ASCII/Unicode code for the given character.
if char == " ":
return None, 30
else:
if char in SPECIAL_FONT_SYMBOLS_INVERSE:
image = getattr(images, SPECIAL_FONT_SYMBOLS_INVERSE[char])
else:
image = getattr(images, font + "0" + str(ord(char)))
return image, image.get_width()
TEXT_GAP_X = {"font":-6, "status1b_":0, "status2_":0} # Characters in main font are italic so should overlap a little
def text_width(text, font):
return sum([get_char_image_and_width(c, font)[1] for c in text]) + TEXT_GAP_X[font] * (len(text)-1)
def draw_text(text, x, y, centre=False, font="font"):
if centre:
x -= text_width(text, font) // 2
for char in text:
image, width = get_char_image_and_width(char, font)
if image is not None:
screen.blit(image, (x, y))
x += width + TEXT_GAP_X[font]
class Controls(ABC):
NUM_BUTTONS = 2
def __init__(self):
self.button_previously_down = [False for i in range(Controls.NUM_BUTTONS)]
self.is_button_pressed = [False for i in range(Controls.NUM_BUTTONS)]
def update(self):
# Call each frame to update button status
for button in range(Controls.NUM_BUTTONS):
button_down = self.button_down(button)
self.is_button_pressed[button] = button_down and not self.button_previously_down[button]
self.button_previously_down[button] = button_down
@abstractmethod
def get_x(self):
# Overridden by subclasses
pass
@abstractmethod
def button_down(self, button):
# Overridden by subclasses
pass
def button_pressed(self, button):
return self.is_button_pressed[button]
class KeyboardControls(Controls):
def get_x(self):
if keyboard.left:
return -1
elif keyboard.right:
return 1
else:
return 0
def button_down(self, button):
if button == 0:
return keyboard.lctrl or keyboard.z
elif button == 1:
return keyboard.lshift or keyboard.x
class JoystickControls(Controls):
def __init__(self, joystick):
super().__init__()
self.joystick = joystick
joystick.init() # Not necessary in Pygame 2.0.0 onwards
def get_axis(self, axis_num):
if self.joystick.get_numhats() > 0 and self.joystick.get_hat(0)[axis_num] != 0:
# For some reason, dpad up/down are inverted when getting inputs from
# an Xbox controller, so need to negate the value if axis_num is 1
return self.joystick.get_hat(0)[axis_num] * (-1 if axis_num == 1 else 1)
axis_value = self.joystick.get_axis(axis_num)
if abs(axis_value) < 0.6:
# Dead-zone
return 0
else:
return axis_value
def get_x(self):
return self.get_axis(0)
def get_y(self):
return self.get_axis(1)
def button_down(self, button):
# Before checking button, check to make sure that the controller actually has enough buttons
# There are some weird devices out there which could cause a crash if this check were not present
if self.joystick.get_numbuttons() <= button:
print("Warning: main controller does not have enough buttons!")
return False
return self.joystick.get_button(button) != 0
class Scenery:
def __init__(self, x, image, min_draw_distance=0, max_draw_distance=VIEW_DISTANCE // 2, scale=1, collision_zones=()):
self.x = x
self.image = image
self.min_draw_distance = min_draw_distance
self.max_draw_distance = max_draw_distance
self.scale = scale
self.collision_zones = collision_zones
def get_image(self):
return self.image
class StartGantry(Scenery):
def __init__(self):
super().__init__(0, images.start0, min_draw_distance=1, max_draw_distance=VIEW_DISTANCE, scale=4, collision_zones=((-3000,-2400),(2400,3000)))
def get_image(self):
# Before we draw, update our billboard image to the appropriate one based on the game's start timer
# Images go from start0 to start4, then we alternate between start4 and start5 every half second
if game.start_timer > 0:
index = int(remap(game.start_timer, 4, 0, 0, 4))
else:
index = 4 if int(game.timer * 2) % 2 == 0 else 5
image = "start" + str(index)
self.image = getattr(images, image)
return self.image
class Billboard(Scenery):
def __init__(self, x, image):
half_width = image.get_width() / 2
scale = 2
super().__init__(x, image, scale=scale, collision_zones=((-half_width*scale,half_width*scale),))
class LampLeft(Scenery):
def __init__(self):
super().__init__(LAMP_X, images.left_light, scale=2, collision_zones=((350,1200),))
class LampRight(Scenery):
def __init__(self):
super().__init__(-LAMP_X, images.right_light, scale=2, collision_zones=((-1200,-350),))
# The track is defined as list of track pieces. A track piece is technically just a line, but a polygon will be
# drawn to connect it to the next piece. So being 'on' a track piece means being in between that track piece and the
# next one. Each track piece has X and Y offsets, which define how its position differs from the previous piece.
# When the track is drawn, the changes in offset accumulate, so that a series of track pieces with X offsets of 10
# will lead to a curve to the left, from the camera's point of view. (Left rather than right because the camera
# points along the negative Z axis)
# The X and Y offsets are the offsets from the previous track piece, so if, for example, track pieces 0 to 5 have an
# X offset of zero and track piece 6 has a very large offset of 1000, it's while moving from 5 to 6 that the car
# will start to move to the left
class TrackPiece:
def __init__(self, scenery=(), offset_x=0, offset_y=0, cpu_max_target_speed=None, col=TRACK_COLOUR, width=TRACK_W):
self.scenery = scenery
self.offset_x = offset_x
self.offset_y = offset_y
self.cpu_max_target_speed = cpu_max_target_speed
self.col = col
self.width = width
self.cars = [] # Cars currently on this track piece
class TrackPieceStartLine(TrackPiece):
def __init__(self):
super().__init__(scenery = [StartGantry()], col=(255,255,255))
class Car:
def __init__(self, pos, car_letter):
self.pos = pos
self.image = f"car_{car_letter}_0_0"
self.speed = 0
self.grip = 1
self.car_letter = car_letter
self.track_piece = None
self.tyre_rotation = 0
def update(self, delta_time):
self.pos.z -= self.speed * delta_time
self.update_current_track_piece()
self.tyre_rotation += delta_time * self.speed * 0.75
def update_current_track_piece(self):
# Which track piece are we on?
current_track_piece = self.track_piece
idx = game.get_track_piece_for_z(self.pos.z)
if idx is not None:
self.track_piece = game.track[idx]
if self.track_piece is not current_track_piece:
# Remove myself from the old track piece, add myself to the new one
if current_track_piece is not None:
current_track_piece.cars.remove(self)
self.track_piece.cars.append(self)
def update_sprite(self, angle, braking, boost=False):
if self.speed == 0:
frame = 0
elif braking:
frame = 3
elif boost:
frame = int(self.tyre_rotation % 2) + 4
else:
frame = int(self.tyre_rotation % 2) + 1
self.image = f"car_{self.car_letter}_{angle}_{frame}"
class CPUCar(Car):
def __init__(self, pos, accel, speed):
super().__init__(pos, choice(('b','c','d','e')))
# CPU cars accelerate faster than player but have a lower top speed
self.accel = PLAYER_ACCELERATION_MAX * accel
self.target_speed = speed
self.target_x = pos.x
# Set based on track curvature, so we can display an angled variant of the car sprite
self.steering = 0
self.change_speed_timer = uniform(2, 4)
def update(self, delta_time):
if game.race_complete:
self.target_speed = game.player_car.speed
self.speed = move_towards(self.speed, self.target_speed, self.accel * delta_time)
self.pos.x = move_towards(self.pos.x, self.target_x, 400 * delta_time)
super().update(delta_time)
track_piece_idx, _ = game.get_first_track_piece_ahead(self.pos.z)
if track_piece_idx is not None:
self.steering = game.track[track_piece_idx].offset_x
# Every few seconds we'll change target speed by a random amount, but upwards on average, so that slow cars
# have a chance to catch up, and so that we can see CPU cars overtaking each other
self.change_speed_timer -= delta_time
if self.change_speed_timer <= 0 and not game.race_complete:
self.target_speed += uniform(-4, 6)
self.target_speed = min(max(self.target_speed, CPU_CAR_MIN_TARGET_SPEED), CPU_CAR_MAX_TARGET_SPEED)
# If we're on a sharp corner and speed is above a certain level, reduce target speed
if track_piece_idx is not None:
target_speed_override = game.track[track_piece_idx].cpu_max_target_speed
if target_speed_override is not None and self.target_speed > target_speed_override:
# Make it slightly random
self.target_speed = uniform(target_speed_override-3, target_speed_override)
# Also change target X pos to a random value
# Ensure not too close to values for nearby cars, to avoid cars driving through each other
def is_target_x_too_close_to_nearby_cars():
for car in game.cars:
if car is not self and abs(self.pos.z - car.pos.z) < 20 and abs(self.target_x - car.pos.x) < 300:
return True
return False
# Limit number of attempts to ensure no chance of infinite loop
for attempt in range(0,20):
self.target_x = uniform(-1000, 1000)
if not is_target_x_too_close_to_nearby_cars():
break
# Reset timer
self.change_speed_timer = uniform(2, 4)
class PlayerCar(Car):
def __init__(self, pos, controls):
super().__init__(pos, 'a')
self.pos = pos
self.controls = controls
self.offset_x_change = 0
self.resetting = False
self.explode_timer = None
self.last_checkpoint_idx = None
self.lap = 1
self.lap_time = 0
self.race_time = 0
self.fastest_lap = None
self.last_lap_was_fastest = False
self.braking = False
# Load engine and skid sounds. These are not played with Game.play_sound as they require custom behaviour.
# Enclosed in a try/except section to deal with the case where the sound files can't be loaded, which can
# occur if there is no sound hardware or sound is disabled
try:
self.engine_sounds = [getattr(sounds, "engine_short" + str(i)) for i in range(40)]
self.skid_sound = sounds.skid_loop0
except Exception:
self.engine_sounds = []
self.skid_sound = None
self.current_engine_sound = None
self.current_engine_sound_idx = -1
self.update_engine_sound()
self.skid_sound_playing = False
self.grass_sound_repeat_timer = 0
self.on_grass = False
# Last known position in the race, indexed from 0 - used to decide when to play overtaking sounds
self.prev_position = NUM_CARS - 1
def stop_engine_sound(self):
if self.current_engine_sound is not None:
try:
self.current_engine_sound.stop()
except Exception:
# Ignore errors - e.g. no sound hardware, or sound mixer has been shut down
pass
def update(self, delta_time):
if not game.race_complete:
self.lap_time += delta_time
self.race_time += delta_time
self.grass_sound_repeat_timer -= delta_time
self.update_engine_sound()
# Play overtaking sounds? See if our position in the race has changed since last frame
current_position = game.cars.index(self)
if current_position != self.prev_position:
# Only play sound if speed difference is high enough
if abs(self.speed - game.cars[self.prev_position].speed) > 4:
game.play_sound("overtake",6)
self.prev_position = current_position
if self.resetting:
if self.explode_timer is not None:
self.explode_timer += 1
if self.explode_timer > 31:
self.explode_timer = None
else:
# Reset player to centre of track over about 2 seconds
self.pos.x = move_towards(self.pos.x, 0, 2000 * delta_time)
self.resetting = self.pos.x != 0
x_move = 0
accel = 0
if not self.resetting:
# Not resetting - do normal movement & controls
self.braking = False
# Only get control inputs if race is not complete
if not game.race_complete:
self.controls.update()
if self.controls.button_down(0):
accel = PLAYER_ACCELERATION_MAX if self.speed < HIGH_ACCEL_THRESHOLD else PLAYER_ACCELERATION_MIN
self.speed += accel * delta_time
elif self.controls.button_down(1):
# Brake
self.braking = True
self.speed = max(0, self.speed - delta_time * 10)
# Apply drag in a frame-rate independent way
drag_factor = 0.9975
if self.on_grass:
# More drag on grass
drag_factor -= 0.0025
# Apply drag to speed. ** = power, e.g. 3 ** 5 is 3 to the power of 5
# Check out this superb video which explains the uses and misuses of delta times, including more advanced
# uses as seen in this case: https://www.youtube.com/watch?v=yGhfUcPjXuE
self.speed *= drag_factor ** (delta_time / (1 / 60))
# If we're going round a corner, shift X pos so that failing to steer will take you off the track
# This is necessary because in this game, the corners are just illusions!
if self.offset_x_change != 0:
# We also set self.grip to less than 1 if we're cornering at high speed (but only if we're steering
# in same direction as corner)
if self.speed > LOSE_GRIP_SPEED and sign(self.get_x_input()) == -sign(self.offset_x_change):
self.grip = remap_clamp(self.speed, LOSE_GRIP_SPEED, ZERO_GRIP_SPEED, 1, 0)
else:
self.grip = 1
# Apply corner offset - grip will be used to alter steering movement in Car.update
# We don't multiply by delta_time here as offset_x_change is partly based on the total amount of forward
# motion that has taken place since the previous frame, which already takes delta_time into account
# We don't do this if the race is complete - just let car go around the corners with no steering needed
if not game.race_complete:
self.pos.x -= self.offset_x_change * CORNER_OFFSET_MULTIPLIER
else:
# Not going around a corner
self.grip = 1
# Get track piece we were on before forward motion was applied
previous_track_piece_idx, _ = game.get_first_track_piece_ahead(self.pos.z)
# Apply steering
if self.speed > 0 and not game.race_complete:
x_move = self.get_x_input() * self.speed * STEERING_STRENGTH * self.grip * delta_time
self.pos.x -= x_move
# Call parent (Car) update method, which includes applying motion
super().update(delta_time)
# Check for collisions with other cars
for car in game.cars:
if car is not self:
# Note - axes are not uniform in scale (1 unit in X axis is much smaller than 1 unit in Z axis),
# so we can't do a normal distance calculation.
# Instead we just check X and Z differences separately (Y is irrelevant as cars are always
# on the ground)
vec = self.pos - car.pos
COLLIDE_FRONT_DISTANCE_Z = 0.6
COLLIDE_BACK_DISTANCE_Z = 1.2
if abs(vec.x) < 260 and vec.z < COLLIDE_FRONT_DISTANCE_Z and vec.z > -COLLIDE_BACK_DISTANCE_Z:
midpoint = (self.pos.z - car.pos.z) / 2 + car.pos.z
# Which side did we collide on?
# An alternative way to do this would be to use the speed difference, e.g. if player speed
# is faster, we hit the car in front
if abs(vec.z) < 0.2:
# Side collision
self.pos.x += sign(vec.x) * 50
car.pos.x -= sign(vec.x) * 50
elif vec.z > 0:
# Colliding with the back of the car in front
self.speed = max(car.speed - 3, 0)
car.speed = max(car.speed, self.speed + 3)
car.target_speed = car.speed
# Shift us back and other car forward so we're not longer overlapping
self.pos.z = midpoint + COLLIDE_FRONT_DISTANCE_Z * 0.6
car.pos.z = midpoint - COLLIDE_FRONT_DISTANCE_Z * 0.6
game.play_sound("bump", 6)
else:
# Car behind collided with us - get a speed boost
self.speed = max(self.speed, car.speed + 3)
car.speed = max(self.speed - 3, 0)
# Shift other car back and us forward so we're not longer overlapping
self.pos.z = midpoint - COLLIDE_BACK_DISTANCE_Z * 0.6
car.pos.z = midpoint + COLLIDE_BACK_DISTANCE_Z * 0.6
game.play_sound("bump_behind")
# Check for collisions with scenery, driving on grass and passing a checkpoint
track_piece_idx, _ = game.get_first_track_piece_ahead(self.pos.z)
if track_piece_idx is not None:
track_piece = game.track[track_piece_idx]
for scenery in track_piece.scenery:
for collision_zone in scenery.collision_zones:
zone_left = scenery.x + collision_zone[0]
zone_right = scenery.x + collision_zone[1]
if zone_left < self.pos.x < zone_right:
self.speed = 0
self.resetting = True
self.explode_timer = 0 # Start explosion animation
game.play_sound("explosion")
# Are we on, or have we passed, a checkpoint?
for i in range(previous_track_piece_idx, track_piece_idx+1):
if isinstance(game.track[i], TrackPieceStartLine):
# It's a checkpoint. If it's the first one, ignore it (passing the start line at the start of
# the race is not of interest). If we've already dealt with this checkpoint, ignore it.
# Otherwise update lap count and lap time
if self.last_checkpoint_idx is not None and self.last_checkpoint_idx != i:
self.lap += 1
# Was this the fastest lap?
if self.fastest_lap is None or self.lap_time < self.fastest_lap:
self.fastest_lap = self.lap_time
self.last_lap_was_fastest = True
game.play_sound("fastlap")
else:
self.last_lap_was_fastest = False
# Play final lap sound effect?
if self.lap == NUM_LAPS:
game.play_sound("final_lap")
# Set lap time back to 0 for new lap
self.lap_time = 0
self.last_checkpoint_idx = i
# Are we on the grass?
if abs(self.pos.x) + 100 > track_piece.width / 2:
self.on_grass = True
if self.grass_sound_repeat_timer <= 0:
game.play_sound("hit_grass")
self.grass_sound_repeat_timer = 0.15
# Are we way too far off the track? Reset if so
if abs(self.pos.x) > 6000:
self.speed = 0
self.resetting = True
else:
self.on_grass = False
# End of "if not self.resetting" block
# Depending on grip, turn skid sound on/off or vary volume
if self.skid_sound is not None:
# Determine volume to play skid sound at
if self.resetting or self.grip >= SKID_SOUND_START_GRIP or self.get_x_input() == 0:
volume = 0
else:
volume = remap_clamp(self.grip, SKID_SOUND_START_GRIP, 0.5, 0, 1)
# Scale volume based on track curvature - higher volume for tighter corners
if track_piece_idx is not None:
track_piece = game.track[track_piece_idx]
volume *= remap_clamp(abs(track_piece.offset_x), 0, 15, 0, 1)
if volume > 0:
if not self.skid_sound_playing:
self.skid_sound.play(loops=-1, fade_ms=100) # Loop indefinitely
self.skid_sound_playing = True
self.skid_sound.set_volume(volume)
else:
self.skid_sound_playing = False
self.skid_sound.fadeout(250)
# Set sprite
if self.explode_timer is not None:
self.image = f"explode{self.explode_timer//2:02}"
else:
direction = 0
if x_move < 0:
direction = -1
elif x_move > 0:
direction = 1
boost = accel > 0 and self.speed < HIGH_ACCEL_THRESHOLD and self.speed > 0
self.update_sprite(direction, self.braking, boost)
def update_engine_sound(self):
sound_index = min(int(self.speed * 0.6), len(self.engine_sounds) - 1)
if sound_index != self.current_engine_sound_idx:
self.current_engine_sound_idx = sound_index
old_sound = self.current_engine_sound
self.current_engine_sound = self.engine_sounds[sound_index]
self.current_engine_sound.set_volume(0.3)
# Stop the old sound and play the new sound - ignore errors (e.g. no sound hardware)
try:
if old_sound is not None:
old_sound.fadeout(150)
self.current_engine_sound.play(loops=-1, fade_ms=100)
except Exception:
pass
def get_x_input(self):
return self.controls.get_x()
def set_offset_x_change(self, value):
self.offset_x_change = value
def generate_scenery(track_i, image=images.billboard00, interval=40, lamps=True):
if track_i % interval == 0:
# Billboards
return [Billboard(BILLBOARD_X, image), Billboard(-BILLBOARD_X, image)]
elif lamps and track_i % 30 == 0:
# Lamps
return [LampLeft(), LampRight()]
else:
return []
def make_track():
# Each track piece in the list represents a line with a particular width, with optional attached scenery.
# When the track is drawn, we draw a polygon for each track piece, connecting this line with the line of the
# previous track piece.
track = []
for lap in range(NUM_LAPS + 1):
track.extend([TrackPiece(scenery=generate_scenery(i,images.billboard02)) for i in range(15)])
# Start gantry
track.append(TrackPieceStartLine())
track.extend([TrackPiece() for i in range(SECTION_SHORT)])
# Because the camera is pointing down the negative Z axis, negative/positive X mean right/left from
# camera's perspective
# Mild right turn followed by short straight
track.extend([TrackPiece(offset_x=-4, offset_y=0, scenery=generate_scenery(i)) for i in range(SECTION_MEDIUM)])
track.extend([TrackPiece(scenery=generate_scenery(i,images.billboard01)) for i in range(SECTION_SHORT)])
# Slight downward slope, going into moderate right hand turn
track.extend([TrackPiece(offset_x=0, offset_y=-1, scenery=generate_scenery(i)) for i in range(SECTION_VERY_SHORT)])
track.extend([TrackPiece(offset_x=0, offset_y=-2, scenery=generate_scenery(i)) for i in range(SECTION_VERY_SHORT)])
track.extend([TrackPiece(offset_x=-2, offset_y=-1, scenery=generate_scenery(i)) for i in range(SECTION_VERY_SHORT)])
track.extend([TrackPiece(offset_x=-5, offset_y=0, scenery=generate_scenery(i,images.billboard03)) for i in range(SECTION_VERY_SHORT)])
track.extend([TrackPiece(offset_x=-10, offset_y=0, scenery=generate_scenery(i,images.billboard03)) for i in range(SECTION_MEDIUM)])
# Short straight
track.extend([TrackPiece(scenery=generate_scenery(i)) for i in range(SECTION_SHORT)])
# Medium-sharp turn left, slight upward slope
track.extend([TrackPiece(offset_x=13, offset_y=1, scenery=generate_scenery(i, images.arrow_left, interval=10)) for i in range(SECTION_MEDIUM)])
track.extend([TrackPiece(offset_x=0, offset_y=0, scenery=generate_scenery(i,images.billboard02)) for i in range(SECTION_MEDIUM)])
# Small hill
track.extend([TrackPiece(offset_x=0, offset_y=2, scenery=generate_scenery(i,images.billboard02)) for i in range(SECTION_MEDIUM)])
# Slightly down and to the right
track.extend([TrackPiece(offset_x=-3, offset_y=-1, scenery=generate_scenery(i,images.billboard01)) for i in range(SECTION_LONG)])
# Crazy downward curve
track.extend([TrackPiece(offset_x=0, offset_y=-4, scenery=generate_scenery(i)) for i in range(SECTION_MEDIUM)])
# Upward slope
track.extend([TrackPiece(offset_x=0, offset_y=2, scenery=generate_scenery(i,images.billboard03)) for i in range(SECTION_LONG)])
# Turn to left and up, gradually increasing curve
for j in range(1,10):
track.extend([TrackPiece(offset_x=j, offset_y=j, scenery=generate_scenery(i)) for i in range(SECTION_VERY_SHORT)])
# Downward curve, increasing then decreasing in intensity
for j in range(1,10):
track.extend([TrackPiece(offset_x=0, offset_y=-j, scenery=generate_scenery(i)) for i in range(SECTION_VERY_SHORT)])
# straight with chevron billboards at end, CPU cars will slow down in this section
track.extend([TrackPiece(cpu_max_target_speed=60, scenery=[]) for i in range(SECTION_MEDIUM)])
track.extend([TrackPiece(cpu_max_target_speed=58, scenery=generate_scenery(i, images.arrow_right, interval=10, lamps=False)) for i in range(SECTION_SHORT)])
track.extend([TrackPiece(cpu_max_target_speed=58, scenery=generate_scenery(i, images.arrow_right, interval=10, lamps=False)) for i in range(SECTION_SHORT)])
# sharp turn right, easing off slightly at end
track.extend([TrackPiece(offset_x=-15, cpu_max_target_speed=55, scenery=generate_scenery(i, images.arrow_right, interval=10, lamps=False)) for i in range(SECTION_SHORT)])
track.extend([TrackPiece(offset_x=-13, cpu_max_target_speed=57, scenery=generate_scenery(i, images.arrow_right, interval=10, lamps=False)) for i in range(SECTION_SHORT)])
track.extend([TrackPiece(offset_x=-11, offset_y=0, scenery=generate_scenery(i)) for i in range(SECTION_SHORT)])
track.extend([TrackPiece(offset_x=-9, offset_y=0, scenery=generate_scenery(i)) for i in range(SECTION_SHORT)])
# straight
track.extend([TrackPiece(offset_x=0, offset_y=0, scenery=generate_scenery(i)) for i in range(SECTION_MEDIUM)])
# cosine hills
track.extend([TrackPiece(offset_y=math.cos(i/20) * 5, scenery=generate_scenery(i)) for i in range(SECTION_LONG)])
# Mild upward slope - the purpose is to reset the Y scrolling of the background so it roughly matches the
# background position at the start of the lap
track.extend([TrackPiece(offset_x=0, offset_y=0.25, scenery=generate_scenery(i,images.billboard03)) for i in range(SECTION_LONG)])
# short straight
track.extend([TrackPiece(offset_x=0, offset_y=0, scenery=generate_scenery(i,images.billboard03)) for i in range(SECTION_SHORT)])
return track
class Game:
def __init__(self, controls=None):
self.track = make_track()
# We only create a player car (in setup_cars) when there is a controls object
self.player_car = None
self.camera_follow_car = None
self.setup_cars(controls)
self.camera = Vector3(0, 400, 0)
self.background = images.background
self.bg_offset = Vector2(-self.background.get_width() // 2, 30)
self.first_frame = True
self.on_screen_debug_strs = []
self.frame_counter = 0
self.timer = 0
self.race_complete = False
self.time_up = False
if self.player_car is not None:
self.start_timer = 3.999
play_music("engines_startline")
else:
# Race starts immediately on title screen
self.start_timer = 0
def setup_cars(self, controls):
self.cars = [] # Will be kept in sorted order of position
for i in range(NUM_CARS):
z = -3 - i * GRID_CAR_SPACING
x = -400 if i % 2 == 0 else 400
if i == 0 and controls is not None:
# Don't create player car on title screen
self.player_car = PlayerCar(Vector3(x, 0, z), controls)
self.cars.append(self.player_car)
else:
target_speed = remap(i, 0, NUM_CARS - 1, CPU_CAR_MIN_TARGET_SPEED, CPU_CAR_MAX_TARGET_SPEED)
accel = remap(i, 0, NUM_CARS - 1, 1.5, 2)
self.cars.append(CPUCar(Vector3(x, 0, z), speed=target_speed, accel=accel))
if self.player_car is not None:
self.camera_follow_car = self.player_car
else:
self.camera_follow_car = self.cars[0]
def update(self, delta_time):
self.timer += delta_time
self.frame_counter += 1
# Race start sequence
if self.start_timer > 0:
# Ensure cars are added to the appropriate track piece's car list, so that
# they're displayed during the start countdown (during which time their update is not called)
for car in self.cars:
car.update_current_track_piece()
timer_old = self.start_timer
self.start_timer = max(0, self.start_timer - delta_time)
# Every second of the countdown, make a sound effect
if self.start_timer == 0:
# Go!
# Ambience is stereo so is treated as music
play_music("ambience")
game.play_sound("gobeep")
elif int(timer_old) != int(self.start_timer):
game.play_sound("startbeep")
old_camera_z = self.camera.z
prev_ahead, _ = self.get_first_track_piece_ahead(old_camera_z)
# If race has started, update all cars
if self.start_timer == 0:
for car in self.cars:
car.update(delta_time)
# Is the race complete?
if not self.race_complete and self.player_car is not None :
# End the game if lap time reaches 4 mins
# This serves two purposes:
# 1) Prevent lap time text from overflowing its area (would happen after 10 mins)
# 2) If the game is being demoed in public, and someone starts playing and then leaves before finishing
# a race, the game will eventually end so that the next player can start a fresh race without having
# to quit and re-run the game
# Also allow player to end the game by pressing Escape
if self.player_car.lap_time >= 60 * 4 or keyboard.escape:
stop_music()
self.time_up = True
self.race_complete = True
elif self.player_car.lap > NUM_LAPS:
stop_music()
self.race_complete = True
self.play_sound("game_complete")
# Sort cars in the list based on race positions
self.cars.sort(key=lambda car: car.pos.z)
# Update camera position to follow player car
self.camera.x = self.camera_follow_car.pos.x
self.camera.z = self.camera_follow_car.pos.z + CAMERA_FOLLOW_DISTANCE
# As camera moves around corners, add to bg_offset and shift car X position so that steering is required on corners
# Get the new camera pos and determine which track piece it's on. The logic is different depending on whether
# the position change goes from one track piece to the next, or is within one track piece
new_camera_z = self.camera.z
new_ahead, _ = self.get_first_track_piece_ahead(new_camera_z)
# We need to deal with not just interpolating during movement within one track piece, but also when we pass the
# boundary of a track piece.
# We need to know how far the camera has travelled since the start of the frame and work out which portion of the
# movement covers which track piece.
# It's also possible for the movement to be across more than two track pieces.
# Example
# track i z offset_x
# 5 -5 0
# 6 -6 1000
# 7 -7 0
# Car start Z = -5 First track piece ahead = 5 (offset 0)
# Car end Z = -5.5 First track piece ahead = 6 (offset 1000)
# Offset change = 500
# Car start Z = -5.4 First track piece ahead = 6 (offset 1000)
# Car end Z = -5.5 First track piece ahead = 6 (offset 1000)
# Offset change = 100
# Car start Z = -5 First track piece ahead = 5 (offset 0)
# Spans whole of piece 6 (offset 1000)
# Car end Z = -6.1 First track piece ahead = 7 (offset 0)
# Offset change = 1000
# Car start Z = -5.001 First track piece ahead = 6 (offset 1000)
# Car end Z = -6.1 First track piece ahead = 7 (offset 0)
# Offset change = 999.9
# Get distance from here to next SPACING increment or new camera z, whichever is a smaller change (higher number)
# Ignore if camera moved backwards (debug camera only), or the camera is before the start of the track
# Don't do this on first frame, as camera won't have its correct initial Z position at the beginning of the frame
distance = old_camera_z - new_camera_z
offset_change = Vector2(0, 0)
if distance > 0 and not self.first_frame and prev_ahead >= 0 and new_ahead >= 0:
old_z_next_spacing_boundary = (old_camera_z // SPACING) * SPACING
new_z_prev_spacing_boundary = ((new_camera_z // SPACING) * SPACING) + SPACING
prev_track = self.track[prev_ahead]
new_track = self.track[new_ahead]
if new_ahead > prev_ahead:
# Movement touches at least two track pieces
# Figure out how much of the movement was within the old and new track pieces, plus whether there
# are any intermediate track pieces between them (whose offsets will be fully applied)
# What proportion of the old and new track pieces have we covered?
distance_first = old_camera_z - old_z_next_spacing_boundary
distance_last = new_z_prev_spacing_boundary - new_camera_z
fraction_first = distance_first / SPACING
fraction_last = distance_last / SPACING
# assert stops the program with an AssertionError if the specified condition is false. Both fractions
# should always be between zero and one, and if they aren't then we want to know about it. This assertion
# may trigger with very low values of SPACING, possibly due to floating point inaccuracy.
assert (0 <= fraction_first <= 1 and 0 <= fraction_last <= 1)
offset_change = Vector2(prev_track.offset_x, prev_track.offset_y) * fraction_first \
+ Vector2(new_track.offset_x, new_track.offset_y) * fraction_last
# If difference between prev_ahead and new_ahead is more than 1, that means the movement involves
# three or more track pieces. We will have passed 100% of each of the in-between track pieces, so we
# fully add their offsets
if new_ahead - prev_ahead > 1:
for i in range(prev_ahead + 1, new_ahead):
piece = self.track[i]
offset_change += Vector2(piece.offset_x, piece.offset_y)
else:
# Movement was just within one track piece
fraction = distance / SPACING
assert(0 <= fraction <= 1)
offset_change = Vector2(prev_track.offset_x, prev_track.offset_y) * fraction
# Shift background by the calculated offset
self.bg_offset += offset_change
# Keep bg_offset.x within the range -backgroundwidth to +backgroundwidth
while self.bg_offset.x < -self.background.get_width():
self.bg_offset.x += self.background.get_width()
while self.bg_offset.x > self.background.get_width():
self.bg_offset.x -= self.background.get_width()
# Shift player car's X offset - this means the car will go off the track if you go around a corner without
# steering. Without this, the car would magically stick to the track as if the corner wasn't there - because
# the curvature is really just a visual effect!
if self.player_car is not None:
self.player_car.set_offset_x_change(offset_change.x)
# This deals with moving the background when the camera is moving backwards, which will only happen if the
# player uses the down arrow key debug mode
if new_ahead < prev_ahead:
self.bg_offset.x -= self.track[prev_ahead].offset_x
self.bg_offset.y -= self.track[prev_ahead].offset_y
self.first_frame = False
def draw(self):
# Fill background with single colour
# We use a different background colour depending on the Y offset of the background image, because
# the top and bottom of that image are different colours
if self.bg_offset.y > 0:
screen.fill( (0,20,117) )
else:
screen.fill( (0,77,180) )
# Profiling times
times = {"scenery_scale": 0, "car_scale": 0, "prepare_draw_cars": 0}
# Draw background
# Need to draw either one or two backgrounds - second copy is for wrapping (when bg_offset.x changes enough that
# we'd see the edge of the image)
profile_bg = Profiler()
self.on_screen_debug_strs.append(str(self.bg_offset))
screen.blit(self.background, self.bg_offset)
if self.bg_offset.x > 0:
screen.blit(self.background, self.bg_offset - Vector2(self.background.get_width(), 0))
if self.bg_offset.x + self.background.get_width() < WIDTH:
screen.blit(self.background, self.bg_offset + Vector2(self.background.get_width(), 0))
times["bg"] = profile_bg.get_ms()
def transform(point_v3, w=None, h=None, clipping_plane=CLIPPING_PLANE):
# This local function receives a point as a Vector3 and transforms it into a Vector2 point in screen space
# When called for a car or scenery item, w and h are specified, referring to the size of the original
# sprite, so it also calculates and returns the scaled width and height, based on the distance from the camera
newpoint = point_v3 - self.camera
if newpoint.z > clipping_plane:
return None if w is None else (None, None, None)
# Apply perspective and centre on the screen
point_v2 = pygame.math.Vector2((newpoint.x / newpoint.z) + HALF_WIDTH,
(newpoint.y / newpoint.z) + HALF_HEIGHT)
if w is None:
return point_v2
else:
return point_v2, w / -newpoint.z, h / -newpoint.z
# offset and offset_delta keep track of the cumulative changes in track offsets (X and Y - Z remains as 0), so
# that each track piece is drawn in the correct position
offset = Vector3(0, 0, 0)
offset_delta = Vector3(0, 0, 0)
# Tuples of pairs of Vector2s storing screen positions of left and right edges of the track, central
# stripes and left/right rumble strips. We remember them so they don't need to be recalculated when joining up
# a track piece or stripe with the previous one
prev_track_screen = None
prev_stripe_screen = None
prev_rumble_left_outer_screen = None
prev_rumble_right_outer_screen = None
# Instead of drawing track pieces etc as we come across them, we store draw calls in this list. Then we once
# we've finished going through track pieces, we execute the draw calls in reverse order, so that track
# pieces, cars and scenery in the distance are drawn before things which are closer
draw_list = []
def add_to_draw_list(drawcall, type="?"):
draw_list.append((drawcall, type))
is_first_track_piece_ahead = True
prof_track = Profiler("track")
# Get index of first track piece that starts at or just in front of the camera Z position
# This means the track piece we're currently part-way through won't be displayed, but that doesn't matter
# as it would be off the bottom of the camera.
first_track_piece_idx, current_piece_z = self.get_first_track_piece_ahead(self.camera.z)
# Index of the track piece that we're drawing, relative to first_track_piece_idx
track_ahead_i = 0
# At the start of the loop body below, we subtract SPACING from current_piece_z. Therefore we must add SPACING
# before the loop so that current_piece_z is correct for the first track piece.
current_piece_z += SPACING
# Go through each track piece ahead
for i in range(first_track_piece_idx, len(self.track)):
# Stop when we've displayed VIEW_DISTANCE number of track pieces
track_ahead_i += 1
if track_ahead_i > VIEW_DISTANCE:
break
track_piece = self.track[i]
current_piece_z -= SPACING
# Because the camera is pointing down the negative Z axis, negative/positive X mean right/left from
# camera's perspective
left = Vector3(track_piece.width / 2, 0, current_piece_z)
right = Vector3(-track_piece.width / 2, 0, current_piece_z)
# Interpolate for X offset between first and next track piece. Without this, going around corners would
# look very juddery
if is_first_track_piece_ahead:
# Get fraction between this and next
# Current track piece is actually the first track piece IN FRONT of Z
# And next is the one after that
# So to find the fraction we need to add spacing
adjusted_camera_z = self.camera.z - SPACING
fraction = inverse_lerp(current_piece_z - SPACING, current_piece_z, adjusted_camera_z)
offset_delta = Vector3(fraction * track_piece.offset_x, fraction * track_piece.offset_y, 0)
else:
offset_delta += Vector3(track_piece.offset_x, track_piece.offset_y, 0)
is_first_track_piece_ahead = False
offset += offset_delta
left += offset
right += offset
# Calculate screen positions of track boundaries
left_screen = transform(left)
right_screen = transform(right)
# Calculate screen pos of central stripe
# Always work out stripe points even for pieces which don't need them, because the next track piece may
# make use of the calculated points to connect up to
stripe_left = Vector3(HALF_STRIPE_W, 0, current_piece_z) + offset
stripe_right = Vector3(-HALF_STRIPE_W, 0, current_piece_z) + offset
stripe_left_screen = transform(stripe_left)
stripe_right_screen = transform(stripe_right)
# Calculate screen pos of outer parts of left/right rumble strips (can just use left/right track positions
# for inner part that touches track)
rumble_strip_left_outer = left + Vector3(HALF_RUMBLE_STRIP_W, 0, 0)
rumble_strip_right_outer = right - Vector3(HALF_RUMBLE_STRIP_W, 0, 0)
rumble_strip_left_outer_screen = transform(rumble_strip_left_outer)
rumble_strip_right_outer_screen = transform(rumble_strip_right_outer)
# Calculate screen pos of left and right yellow lines, which are just inside the outer edges of the track
yellow_line_left_outer = left - Vector3(YELLOW_LINE_DISTANCE_FROM_EDGE, 0, 0)
yellow_line_left_inner = yellow_line_left_outer - Vector3(HALF_YELLOW_LINE_W, 0, 0)
yellow_line_right_outer = right + Vector3(YELLOW_LINE_DISTANCE_FROM_EDGE, 0, 0)
yellow_line_right_inner = yellow_line_right_outer + Vector3(HALF_YELLOW_LINE_W, 0, 0)
yellow_line_left_outer_screen = transform(yellow_line_left_outer)
yellow_line_left_inner_screen = transform(yellow_line_left_inner)
yellow_line_right_outer_screen = transform(yellow_line_right_outer)
yellow_line_right_inner_screen = transform(yellow_line_right_inner)
# Only draw if both points are in front of clipping plane
if left_screen is not None and right_screen is not None:
# To draw, there must be a previous track piece that we can connect to
if prev_track_screen is not None:
def any_on_screen(points):
# point[1] gets Y for both tuple pair and Vector2
on_screen = [point for point in points if point[1] < HEIGHT]
return any(on_screen)
def draw_polygon(points, col):
if USE_GFXDRAW:
if OUTLINE_W == 0:
pygame.gfxdraw.filled_polygon(screen.surface, points, col)
else:
pygame.gfxdraw.polygon(screen.surface, points, col)
else:
pygame.draw.polygon(screen.surface, col, points, OUTLINE_W)
def draw_points(points, col, id):
if any_on_screen(points):
add_to_draw_list( lambda col=col, points=points: draw_polygon(points, col), id)
# Draw stripe (3m on/off)
if i // 3 % 2 == 0:
points = (stripe_left_screen, stripe_right_screen, prev_stripe_screen[1], prev_stripe_screen[0])
draw_points(points, STRIPE_COLOUR, "stripe")
# Draw yellow lines
# This is before the drawing of the track as we want to draw on top of the track, and items in the
# draw list are drawn in reverse order
if SHOW_YELLOW_LINES:
left_yellow_line_points = (prev_yellow_line_left_outer_screen,
yellow_line_left_outer_screen,
yellow_line_left_inner_screen,
prev_yellow_line_left_inner_screen)
draw_points(left_yellow_line_points, YELLOW_LINE_COL, "yellow line L")
right_yellow_line_points = (prev_yellow_line_right_outer_screen,
yellow_line_right_outer_screen,
yellow_line_right_inner_screen,
prev_yellow_line_right_inner_screen)
draw_points(right_yellow_line_points, YELLOW_LINE_COL, "yellow line R")
# Draw track
points = (prev_track_screen[0], left_screen, right_screen, prev_track_screen[1])
draw_points(points, track_piece.col, "track")
# Draw rumble strip
# This is before trackside as it draws on top of trackside, and items in the draw list are drawn
# in reverse order
if SHOW_RUMBLE_STRIPS:
# Alternating colours
rumble_col = RUMBLE_COLOUR_1 if (i // 2) % 2 == 0 else RUMBLE_COLOUR_2
rumble_left_points = (prev_rumble_left_outer_screen, prev_track_screen[0], left_screen, rumble_strip_left_outer_screen)
rumble_right_points = (prev_rumble_right_outer_screen, prev_track_screen[1], right_screen, rumble_strip_right_outer_screen)
draw_points(rumble_left_points, rumble_col, "rumble L")
draw_points(rumble_right_points, rumble_col, "rumble R")
# Draw trackside
if SHOW_TRACKSIDE:
# Alternating colours
trackside_col = TRACKSIDE_COLOUR_1 if (i // 5) % 2 == 0 else TRACKSIDE_COLOUR_2
trackside_left_points = (points[2], points[3], (0, points[3].y), (0, points[2].y))
trackside_right_points = (points[0], points[1], (WIDTH - 1, points[1].y), (WIDTH - 1, points[0].y))
draw_points(trackside_left_points, trackside_col, "trackside left")
draw_points(trackside_right_points, trackside_col, "trackside right")
# Store screen positions of various parts of the track, as they form half of the polygon for the next
# track piece
prev_track_screen = (left_screen, right_screen)
prev_stripe_screen = (stripe_left_screen, stripe_right_screen)
prev_rumble_left_outer_screen = rumble_strip_left_outer_screen
prev_rumble_right_outer_screen = rumble_strip_right_outer_screen
prev_yellow_line_left_outer_screen = yellow_line_left_outer_screen
prev_yellow_line_left_inner_screen = yellow_line_left_inner_screen
prev_yellow_line_right_outer_screen = yellow_line_right_outer_screen
prev_yellow_line_right_inner_screen = yellow_line_right_inner_screen
# Show debug info for this track piece
if SHOW_TRACK_PIECE_INDEX or SHOW_TRACK_PIECE_OFFSETS:
items = []
if SHOW_TRACK_PIECE_INDEX:
items.append(str(i))
if SHOW_TRACK_PIECE_OFFSETS:
items.extend([str(track_piece.offset_x), str(track_piece.offset_y)])
text = ",".join(items)
add_to_draw_list(lambda left_screen=left_screen, text=text:
screen.draw.text(text, (left_screen[0], left_screen[1] - 30)))
# Draw scenery for the current track piece
if SHOW_SCENERY:
for obj in track_piece.scenery:
if track_ahead_i * SPACING < obj.max_draw_distance:
pos_v3 = Vector3(obj.x, 0, current_piece_z) + offset
if self.camera.z - current_piece_z > obj.min_draw_distance:
billboard = obj.get_image()
pos, scaled_w, scaled_h = transform(pos_v3, billboard.get_width() * obj.scale,
billboard.get_height() * obj.scale)
# If a piece of scenery is very close to the camera, the scaled size may become enormous.
# Don't try to draw such scenery, due to memory and frame rate issues
if pos is not None and scaled_w < MAX_SCENERY_SCALED_WIDTH:
# Anchor point at bottom
pos -= Vector2(scaled_w // 2, scaled_h)
try:
profile_scale = Profiler()
scaled = SCALE_FUNC(billboard, (int(scaled_w), int(scaled_h)))
times["scenery_scale"] += profile_scale.get_ms()
add_to_draw_list(lambda scaled=scaled, pos=pos: screen.blit(scaled, pos),
"scenery_draw")
except pygame.error:
# Have experienced out of memory errors with a too-small clipping plane, due to trying to
# scale to too big a size. In extreme cases Pygame may try to allocate bitmaps over 1GB
# in size!
print(f"SCALE ERROR, w/h: {scaled_w} {scaled_h}")
# Draw cars
profile_prepare_draw_cars = Profiler()
cars_to_draw = []
for car in track_piece.cars:
# Each car needs to be drawn during the track piece it is on, but with an additional offset interpolated
# towards the next track piece, so that it starts turning a corner as it reaches the piece
# Also, the order of drawing needs to be correct if there is more than one car per track piece
car_offset = Vector3(offset)
if car.pos.z % SPACING != 0:
# Interpolate offset between this and next track piece
# Note that "Interpolate for X offset between first and next track piece"
# will already have happened! Does that matter?
# The following lines deal with the car when it's moving onto a track piece with an offset
fraction = inverse_lerp(current_piece_z, current_piece_z - SPACING, car.pos.z)
next_track_piece = self.track[i + 1]
car_offset += Vector3(fraction * next_track_piece.offset_x,
fraction * next_track_piece.offset_y, -fraction * SPACING)
# This ensures that the car's forward motion is correct on pieces following a piece with an offset
car_offset += offset_delta * fraction
# The rules for drawing the player car (or whichever car the camera is following, in demo mode) are
# a bit different. If we drew it in the same way, its position on the screen would be a bit off as
# it would start going around corners before the camera does. So don't apply any offset.
# (For Y offset, you can achieve an interesting effect by changing 0 to -car_offset.y / 2, but
# it is a bit glitchy sometimes so we've left it at zero)
if car is self.camera_follow_car:
car_offset.x = 0
car_offset.y = 0
pos_v3 = Vector3(car.pos.x, 0, current_piece_z) + car_offset
scale = 2
# For CPU cars, choose the sprite to use based on the car's angle in relation to the camera
if isinstance(car, CPUCar):
# Approximate the angle we're seeing the car from, to determine the sprite
# The further the car is ahead, the smaller the effect
# The car sprite filenames end in a number in the range -4 to 4, where 0 is the car not turning,
# -1 is the car turning slightly to the left, 1 is turning slightly to the right, etc
z_distance = max(1, -(pos_v3.z - self.camera.z))
offset_for_angle = (pos_v3.x - self.camera.x) / z_distance
offset_for_angle += -car.steering * 10
angle_sprite_idx = int(remap_clamp(offset_for_angle, -200, 200, -4, 4))
# If this is the camera follow car (which for a CPU car will only be the case during
# the title screen), limit to only the shallowest angles (-1 to 1), as this car is a stand-in
# for the plyaer car and the player car only uses angles between -1 and 1
if car is self.camera_follow_car:
angle_sprite_idx = min(max(angle_sprite_idx, -1), 1)
car.update_sprite(angle_sprite_idx, braking=False)
# Calculate screen pos and scaled sprite size for car
img = getattr(images, car.image)
pos, scaled_w, scaled_h = transform(pos_v3,
img.get_width() * scale,
img.get_height() * scale,
clipping_plane=CLIPPING_PLANE_CARS)
if pos is not None and scaled_w < MAX_CAR_SCALED_WIDTH:
# Anchor point at bottom, centre
pos -= Vector2(scaled_w // 2, scaled_h)
profile_scale = Profiler()
scaled = SCALE_FUNC(img, (int(scaled_w), int(scaled_h)))
times["car_scale"] += profile_scale.get_ms()
# We can't send it to the draw list just yet as there might be more than one car on this track
# piece and we need to draw them in order starting from the one furthest from the camera.
# So we'll add it to a list to sort and draw later
cars_to_draw.append({"z": car.pos.z, "drawcall": lambda scaled=scaled, pos=pos: screen.blit(scaled, pos)})
if SHOW_CPU_CAR_SPEEDS and isinstance(car, CPUCar):
output = f"{car.target_speed:.0f}"
add_to_draw_list(lambda pos=pos, output=output: draw_text(output, pos.x, pos.y - 40))
times["prepare_draw_cars"] += profile_prepare_draw_cars.get_ms()
# Draw the cars that are on the current track piece,starting from the one with the lowest Z position
cars_to_draw.sort(key=lambda entry: entry["z"], reverse=True)
for entry in cars_to_draw:
add_to_draw_list(entry["drawcall"], "cars")
# Draw everything in draw_list, in reverse order - so that items furthest ahead are drawn first
for draw_call, type in reversed(draw_list):
profiler = Profiler()
draw_call()
if type not in times:
times[type] = profiler.get_ms()
else:
times[type] += profiler.get_ms()
# Is there an actual player car, or are we in demo mode?
if self.player_car is not None:
# Show info text
# Adapt to varying window widths by using fractions of WIDTH instead of absolute coordinates
player_pos = self.cars.index(self.player_car) + 1
# Show race complete or time up screens if relevant
if self.time_up:
draw_text("TIME UP!", WIDTH // 2, HEIGHT * 0.4, centre=True)
elif self.race_complete:
draw_text("RACE COMPLETE!", WIDTH // 2, HEIGHT * 0.15, centre=True)
draw_text("POSITION", WIDTH // 2, HEIGHT * 0.3, centre=True)
draw_text(str(player_pos), WIDTH // 2, HEIGHT * 0.42, centre=True)
draw_text("FASTEST LAP", WIDTH * 0.25, HEIGHT * 0.55, centre=True)
draw_text(format_time(self.player_car.fastest_lap), WIDTH * 0.25, HEIGHT * 0.68, centre=True)
draw_text("RACE TIME", WIDTH * 0.75, HEIGHT * 0.55, centre=True)
draw_text(format_time(self.player_car.race_time), WIDTH * 0.75, HEIGHT * 0.68, centre=True)
else:
# Race not complete - show status text at top of screen
# Show status background
status_x = (WIDTH /2) - (565 / 2)
screen.blit("status", (status_x, 0))
# Show lap
draw_text(f"{self.player_car.lap:02}", status_x + 30, 37, font="status1b_")
# Show position
draw_text(f"{player_pos:02}", status_x + 116, 37, font="status1b_")
# Show speed
draw_text(f"{int(self.player_car.speed):03}", status_x + 197, 37, font="status1b_")
# Show lap time
draw_text(format_time(self.player_car.lap_time), status_x + 299, 37, font="status2_")
# Show fastest lap
if self.player_car.last_lap_was_fastest and self.player_car.lap_time < 4:
y = HEIGHT * 0.4
draw_text("FASTEST LAP!", WIDTH // 2, y, centre=True)
draw_text(format_time(self.player_car.fastest_lap), WIDTH // 2, y + 60, centre=True)
# Show final lap text
# If we're currently showing fastest lap text, wait for that to disappear before showing the final
# lap text
if self.player_car.last_lap_was_fastest:
begin_time, end_time = 4, 8
else:
begin_time, end_time = 0, 4
if self.player_car.lap == NUM_LAPS and begin_time < self.player_car.lap_time < end_time:
y = HEIGHT * 0.4
draw_text("FINAL LAP!", WIDTH // 2, y, centre=True)
# Show debug text
if SHOW_DEBUG_TEXT:
for i in range(len(self.on_screen_debug_strs)):
screen.draw.text(self.on_screen_debug_strs[i], (0, 50 + i * 20))
self.on_screen_debug_strs.clear()
if SHOW_PROFILE_TIMINGS:
print(prof_track, sum(times.values()))
# if sum(times.values()) > 16:
print(self.frame_counter, times)
# test drawing a very large polygon
# pygame.draw.polygon(screen.surface, (255,0,0), (Vector2(-4000,test), Vector2(WIDTH*4,test), Vector2(0,test+500)))
# Returns index of track piece at the specified Z position, or None if the specified position is off the end
# of the track
# e.g. track piece 0 goes from Z 0 to -0.999, etc
def get_track_piece_for_z(self, z):
idx = -int(z / SPACING)
if idx >= len(self.track):
return None
else:
return idx
# Returns index and Z position of first track piece ahead of or exactly at the specified Z position, or None,None
# if the specified position is off the end of the track
def get_first_track_piece_ahead(self, z):
idx = -int(math.floor(z / SPACING))
first_piece_z = -idx * SPACING
if idx >= len(self.track):
return None, None
else:
return idx, first_piece_z
def play_sound(self, name, count=1):
# Some sounds have multiple varieties. If count > 1, we'll randomly choose one from those
# We don't play any sounds if there is no player (e.g. if we're on the menu)
try:
# Pygame Zero allows you to write things like 'sounds.explosion.play()'
# This automatically loads and plays a file named 'explosion.wav' (or .ogg) from the sounds folder (if
# such a file exists)
# But what if you have files named 'explosion0.ogg' to 'explosion5.ogg' and want to randomly choose
# one of them to play? You can generate a string such as 'explosion3', but to use such a string
# to access an attribute of Pygame Zero's sounds object, we must use Python's built-in function getattr
sound = getattr(sounds, name + str(randint(0, count - 1)))
sound.play()
except Exception as e:
# If no sound file of that name was found, print the error that Pygame Zero provides, which
# includes the filename.
# Also occurs if sound fails to play for another reason (e.g. if this machine has no sound hardware)
print(e)
def get_joystick_if_exists():
return pygame.joystick.Joystick(0) if pygame.joystick.get_count() > 0 else None
def setup_joystick_controls():
# We call this on startup, and keep calling it if no controller is present,
# so a controller can be connected while the game is open
global joystick_controls
joystick = get_joystick_if_exists()
joystick_controls = JoystickControls(joystick) if joystick is not None else None
def update_controls():
keyboard_controls.update()
# Allow a controller to be connected while the game is open
if joystick_controls is None:
setup_joystick_controls()
if joystick_controls is not None:
joystick_controls.update()
class State(Enum):
TITLE = 1
PLAY = 2
GAME_OVER = 3
# Pygame Zero calls the update and draw functions each frame
def update(delta_time):
# delta_time is the time passed (in seconds) since the previous frame
global state, game, accumulated_time, demo_reset_timer, demo_start_timer
update_controls()
def button_pressed_controls(button_num):
# Local function for detecting button 0 being pressed on either keyboard or controller, returns the controls
# object which was used to press it, or None if button was not pressed
for controls in (keyboard_controls, joystick_controls):
# Check for fire button being pressed on each controls object
# joystick_controls will be None if there no controller was connected on game startup,
# so must check for that
if controls is not None and controls.button_pressed(button_num):
return controls
return None
if state == State.TITLE:
# Check for player starting game with either keyboard or controller
controls = button_pressed_controls(0)
if controls is not None:
# Switch to play state, and create a new Game object, passing it a controls object
state = State.PLAY
game = Game(controls)
# If the demo race has been running for a while, reset it, otherwise the AI cars will run out of track!
demo_reset_timer -= delta_time
demo_start_timer += delta_time
if demo_reset_timer <= 0:
game = Game()
demo_reset_timer = 60 * 2
demo_start_timer = 0
elif state == State.PLAY:
if game.race_complete:
state = State.GAME_OVER
elif state == State.GAME_OVER:
if button_pressed_controls(0) is not None:
# Go back into demo/title screen mode - create a new Game object without a player
# First stop the player car's skid sound
game.player_car.stop_engine_sound()
state = State.TITLE
game = Game()
play_music("title_theme")
# Call game.update each time while accumulated_time is above FIXED_TIMESTEP. If it is double or more of FIXED_TIMESTEP,
# which would occur if the frame rate is low, we call game.update two or more times per frame
accumulated_time += delta_time
while accumulated_time >= FIXED_TIMESTEP:
accumulated_time -= FIXED_TIMESTEP
game.update(FIXED_TIMESTEP)
def draw():
game.draw()
if state == State.TITLE:
if demo_reset_timer < 1 or demo_start_timer < 1:
# Fade out screen prior to resetting demo game, and fade in whenever demo (re)starts
# Draw a black image with gradually increasing/decreasing opacity
# An alpha value of 255 is fully opaque, 0 is fully transparent
value = demo_reset_timer if demo_reset_timer < 1 else demo_start_timer
alpha = min(255, 255-(value*255))
fade_to_black_image.set_alpha(alpha)
fade_to_black_image.fill((0,0,0))
screen.blit(fade_to_black_image, (0, 0))
# Construct start game text
# On macOS, encourage the user to use Z instead of left control to accelerate, because
# Ctrl+arrow is the keyboard shortcut to switch desktop
text = f"PRESS {SPECIAL_FONT_SYMBOLS['xb_a']} OR {'Z' if 'Darwin' in platform.version() else 'LEFT CONTROL'}"
# Draw start game text
draw_text(text, WIDTH//2, HEIGHT - 82, True)
# Draw logo - centred on X axis, centred on top third of the screen on Y axis
logo_img = images.logo
screen.blit(logo_img, (WIDTH//2 - logo_img.get_width() // 2, HEIGHT//3 - logo_img.get_height() // 2))
def play_music(name):
try:
music.play(name)
except Exception:
# If an error occurs (e.g. no sound hardware), ignore it
pass
def stop_music():
try:
music.stop()
except Exception:
# If an error occurs (e.g. no sound hardware), ignore it
pass
##############################################################################
# Set up sound system and start music
try:
# Restart the Pygame audio mixer which Pygame Zero sets up by default. We find that the default settings
# cause issues with delayed or non-playing sounds on some devices
pygame.mixer.quit()
pygame.mixer.init(44100, -16, 2, 1024)
play_music("title_theme")
except Exception:
# If an error occurs (e.g. no sound hardware), ignore it
pass
# Set up controls
keyboard_controls = KeyboardControls()
setup_joystick_controls()
# Set up initial state and Game object
state = State.TITLE
game = Game()
demo_reset_timer = 2 * 60 # Demo race resets after 2 mins
demo_start_timer = 0
accumulated_time = 0
# Tell Pygame Zero to take over
pgzrun.go()