Compare commits

...

10 Commits

12 changed files with 439 additions and 3 deletions

BIN
assets/fonts/Ubuntu-M.ttf Normal file

Binary file not shown.

BIN
assets/fonts/Ubuntu-R.ttf Normal file

Binary file not shown.

View File

@@ -4,6 +4,7 @@ from src.game import Game
def main(): def main():
print("Welcome to Rally Racer !") print("Welcome to Rally Racer !")
game: Game = Game() game: Game = Game()
game.mainloop()
if __name__ == "__main__": if __name__ == "__main__":

43
src/camera.py Normal file
View File

@@ -0,0 +1,43 @@
from src.vec import Vec
class Camera:
UNIT_RATIO = 150
def __init__(self) -> None:
self.pos: Vec = Vec()
self.up: Vec = Vec(0, -1)
self.size: Vec = Vec(600, 600)
self.zoom: float = 1
def set_pos(self, pos: Vec):
self.pos = pos
def set_direction(self, up: Vec):
self.up = up.normalized
def set_size(self, size: Vec):
self.size = size
@property
def center(self) -> Vec:
return self.size / 2
def screen2world(self, screen_pos: Vec) -> Vec:
delta: Vec = screen_pos - self.center
delta /= self.zoom * self.UNIT_RATIO
dx: float = delta.x
dy: float = delta.y
v1: Vec = self.up.perp * dx
v2: Vec = self.up * dy
return self.pos + v1 + v2
def world2screen(self, world_pos: Vec) -> Vec:
delta: Vec = world_pos - self.pos
dy: float = -delta.dot(self.up)
dx: float = delta.dot(self.up.perp)
screen_delta: Vec = Vec(dx, dy) * self.zoom * self.UNIT_RATIO
screen_pos: Vec = self.center + screen_delta
return screen_pos

96
src/car.py Normal file
View File

@@ -0,0 +1,96 @@
from math import radians
import pygame
from src.camera import Camera
from src.utils import segments_intersect
from src.vec import Vec
class Car:
MAX_SPEED = 0.05
MAX_BACK_SPEED = -0.025
ROTATE_SPEED = radians(1)
COLOR = (230, 150, 80)
WIDTH = 0.4
LENGTH = 0.6
COLLISION_MARGIN = 0.4
def __init__(self, pos: Vec, direction: Vec) -> None:
self.pos: Vec = pos
self.direction: Vec = direction
self.speed: float = 0
self.forward: bool = False
self.backward: bool = False
self.left: bool = False
self.right: bool = False
self.colliding: bool = False
def update(self):
if self.forward:
self.speed += 0.001
self.speed = min(self.MAX_SPEED, self.speed)
if self.backward:
self.speed -= 0.002
self.speed = max(self.MAX_BACK_SPEED, self.speed)
rotate_angle: float = 0
if self.left:
rotate_angle -= self.ROTATE_SPEED
if self.right:
rotate_angle += self.ROTATE_SPEED
# if self.backward:
# rotate_angle *= -1
if rotate_angle != 0:
self.direction = self.direction.rotate(rotate_angle)
self.speed *= 0.98
if abs(self.speed) < 1e-8:
self.speed = 0
self.pos += self.direction * self.speed
def render(self, surf: pygame.Surface, camera: Camera):
pts: list[Vec] = self.get_corners()
pts = [camera.world2screen(p) for p in pts]
pygame.draw.polygon(surf, self.COLOR, pts)
def get_corners(self) -> list[Vec]:
u: Vec = self.direction * self.LENGTH / 2
v: Vec = self.direction.perp * self.WIDTH / 2
pt: Vec = self.pos
p1: Vec = pt + u + v
p2: Vec = pt - u + v
p3: Vec = pt - u - v
p4: Vec = pt + u - v
return [p1, p2, p3, p4]
def check_collisions(self, polygons: list[list[Vec]]):
self.colliding = False
corners: list[Vec] = self.get_corners()
sides: list[tuple[Vec, Vec]] = [
(corners[i], corners[(i + 1) % 4]) for i in range(4)
]
for polygon in polygons:
n_pts: int = len(polygon)
for i in range(n_pts):
pt1: Vec = polygon[i]
pt2: Vec = polygon[(i + 1) % n_pts]
d: Vec = pt2 - pt1
for s1, s2 in sides:
if segments_intersect(s1, s2, pt1, pt2):
self.colliding = True
self.direction = d.normalized
n: Vec = self.direction.perp
dist: float = (self.pos - pt1).dot(n)
if dist < 0:
n *= -1
dist = -dist
self.speed = 0
self.pos = self.pos + n * (self.COLLISION_MARGIN - dist)
return

View File

@@ -1,9 +1,94 @@
import pygame import pygame
from src.camera import Camera
from src.car import Car
from src.track import Track
from src.utils import ROOT
from src.vec import Vec
class Game: class Game:
DEFAULT_SIZE = (1280, 720) DEFAULT_SIZE = (1280, 720)
BACKGROUND_COLOR = (80, 80, 80)
MAX_FPS = 60
FPS_COLOR = (255, 0, 0)
def __init__(self): def __init__(self) -> None:
pygame.init() pygame.init()
self.win: pygame.Surface = pygame.display.set_mode(self.DEFAULT_SIZE) self.win: pygame.Surface = pygame.display.set_mode(
self.DEFAULT_SIZE, pygame.RESIZABLE
)
pygame.display.set_caption("Rally Racer")
self.running: bool = True
self.track: Track = Track.load("simple")
self.car: Car = Car(self.track.start_pos, self.track.start_dir)
self.camera: Camera = Camera()
self.clock: pygame.time.Clock = pygame.time.Clock()
self.font: pygame.font.Font = pygame.font.Font(
str(ROOT / "assets" / "fonts" / "Ubuntu-M.ttf"), 20
)
self.show_fps: bool = True
def mainloop(self):
while self.running:
self.process_pygame_events()
self.car.update()
self.car.check_collisions(self.track.get_collision_polygons())
self.render()
self.clock.tick(60)
def process_pygame_events(self):
self.camera.set_pos(self.car.pos)
self.camera.set_direction(self.car.direction)
self.camera.set_size(Vec(*self.win.get_size()))
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.quit()
elif event.type == pygame.VIDEORESIZE:
self.camera.set_size(Vec(event.w, event.h))
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.quit()
else:
self.on_key_down(event)
elif event.type == pygame.KEYUP:
self.on_key_up(event)
def quit(self):
self.running = False
def render(self):
self.win.fill(self.BACKGROUND_COLOR)
self.track.render(self.win, self.camera)
self.car.render(self.win, self.camera)
if self.show_fps:
self.render_fps()
pygame.display.flip()
def on_key_down(self, event: pygame.event.Event):
if event.key == pygame.K_w:
self.car.forward = True
elif event.key == pygame.K_s:
self.car.backward = True
elif event.key == pygame.K_a:
self.car.left = True
elif event.key == pygame.K_d:
self.car.right = True
def on_key_up(self, event: pygame.event.Event):
if event.key == pygame.K_w:
self.car.forward = False
elif event.key == pygame.K_s:
self.car.backward = False
elif event.key == pygame.K_a:
self.car.left = False
elif event.key == pygame.K_d:
self.car.right = False
def render_fps(self):
txt: pygame.Surface = self.font.render(
f"{self.clock.get_fps():.1f}", True, self.FPS_COLOR
)
self.win.blit(txt, (self.win.get_width() - txt.get_width(), 0))

0
src/objects/__init__.py Normal file
View File

66
src/objects/road.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import pygame
from src.camera import Camera
from src.track_object import TrackObject, TrackObjectType
from src.vec import Vec
class Road(TrackObject):
type = TrackObjectType.Road
def __init__(self, pts: list[RoadPoint]) -> None:
super().__init__()
self.pts: list[RoadPoint] = pts
@classmethod
def load(cls, data: dict) -> Road:
return Road([RoadPoint.load(pt) for pt in data["pts"]])
def render(self, surf: pygame.Surface, camera: Camera):
side1: list[Vec] = []
side2: list[Vec] = []
for i, pt in enumerate(self.pts):
p1: Vec = pt.pos
p2: Vec = p1 + pt.normal * pt.width
p3: Vec = p1 - pt.normal * pt.width
side1.append(camera.world2screen(p2))
side2.append(camera.world2screen(p3))
col: tuple[float, float, float] = (i * 10 + 150, 100, 100)
pygame.draw.circle(surf, col, camera.world2screen(p1), 5)
n: int = len(self.pts)
for i in range(n):
pygame.draw.polygon(
surf,
(100, 100, 100),
[side1[i], side1[(i + 1) % n], side2[(i + 1) % n], side2[i]],
)
pygame.draw.lines(surf, (255, 255, 255), True, side1)
pygame.draw.lines(surf, (255, 255, 255), True, side2)
def get_collision_polygons(self) -> list[list[Vec]]:
side1: list[Vec] = []
side2: list[Vec] = []
for pt in self.pts:
p1: Vec = pt.pos
p2: Vec = p1 + pt.normal * pt.width
p3: Vec = p1 - pt.normal * pt.width
side1.append(p2)
side2.append(p3)
return [side1, side2]
class RoadPoint:
def __init__(self, pos: Vec, normal: Vec, width: float) -> None:
self.pos: Vec = pos
self.normal: Vec = normal.normalized
self.width: float = width
@staticmethod
def load(data: list[float]) -> RoadPoint:
return RoadPoint(Vec(data[0], data[1]), Vec(data[2], data[3]), data[4])

54
src/track.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import pygame
from src.camera import Camera
from src.track_object import TrackObject
from src.utils import ROOT
from src.vec import Vec
TrackObject.init()
class Track:
TRACKS_DIRECTORY = ROOT / "assets" / "tracks"
def __init__(self, id: str, name: str, start_pos: Vec, start_dir: Vec) -> None:
self.id: str = id
self.name: str = name
self.start_pos: Vec = start_pos
self.start_dir: Vec = start_dir
self.objects: list[TrackObject] = []
self.load_objects()
@staticmethod
def load(name: str) -> Track:
with open(Track.TRACKS_DIRECTORY / name / "meta.json", "r") as f:
meta: dict = json.load(f)
return Track(
name,
meta["name"],
Vec(*meta["start"]["pos"]),
Vec(*meta["start"]["direction"]),
)
def load_objects(self):
with open(Track.TRACKS_DIRECTORY / self.id / "track.json", "r") as f:
data: list = json.load(f)
self.objects = []
for obj_data in data:
self.objects.append(TrackObject.load(obj_data))
def render(self, surf: pygame.Surface, camera: Camera):
for object in self.objects:
object.render(surf, camera)
def get_collision_polygons(self) -> list[list[Vec]]:
polygons: list[list[Vec]] = []
for obj in self.objects:
polygons.extend(obj.get_collision_polygons())
return polygons

46
src/track_object.py Normal file
View File

@@ -0,0 +1,46 @@
import importlib
import pkgutil
from enum import StrEnum
from typing import Optional, Self
import pygame
import src.objects
from src.camera import Camera
from src.vec import Vec
class TrackObjectType(StrEnum):
Road = "road"
Unknown = "unknown"
class TrackObject:
REGISTRY = {}
type: TrackObjectType = TrackObjectType.Unknown
@staticmethod
def init():
package = src.objects
for _, modname, _ in pkgutil.walk_packages(
package.__path__, package.__name__ + "."
):
importlib.import_module(modname)
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
TrackObject.REGISTRY[cls.type] = cls
@classmethod
def load(cls, data: dict) -> Self:
obj_type: Optional[TrackObjectType] = data.get("type")
if obj_type not in cls.REGISTRY:
raise ValueError(f"Unknown object tyoe: {obj_type}")
return cls.REGISTRY[obj_type].load(data)
def render(self, surf: pygame.Surface, camera: Camera):
pass
def get_collision_polygons(self) -> list[list[Vec]]:
return []

34
src/utils.py Normal file
View File

@@ -0,0 +1,34 @@
import os
from pathlib import Path
from src.vec import Vec
ROOT = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
def orientation(a: Vec, b: Vec, c: Vec) -> float:
return (b - a).cross(c - a)
def segments_intersect(a1: Vec, a2: Vec, b1: Vec, b2: Vec) -> bool:
o1 = orientation(a1, a2, b1)
o2 = orientation(a1, a2, b2)
o3 = orientation(b1, b2, a1)
o4 = orientation(b1, b2, a2)
# General case: segments straddle each other
if (o1 * o2 < 0) and (o3 * o4 < 0):
return True
# Special cases: Collinear overlaps
if o1 == 0 and b1.within(a1, a2):
return True
if o2 == 0 and b2.within(a1, a2):
return True
if o3 == 0 and a1.within(b1, b2):
return True
if o4 == 0 and a2.within(b1, b2):
return True
return False

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from math import sqrt from math import cos, sin, sqrt
class Vec: class Vec:
@@ -55,3 +55,14 @@ class Vec:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Vec({self.x}, {self.y})" return f"Vec({self.x}, {self.y})"
def rotate(self, angle: float) -> Vec:
return Vec(
cos(angle) * self.x - sin(angle) * self.y,
sin(angle) * self.x + cos(angle) * self.y,
)
def within(self, p1: Vec, p2: Vec) -> bool:
x1, x2 = min(p1.x, p2.x), max(p1.x, p2.x)
y1, y2 = min(p1.y, p2.y), max(p1.y, p2.y)
return (x1 <= self.x <= x2) and (y1 <= self.y <= y2)