Compare commits
10 Commits
12319fc1ab
...
09f70223b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
09f70223b8
|
|||
|
45ed1c85c8
|
|||
|
2b20582b87
|
|||
|
6805e69509
|
|||
|
9c5f39b669
|
|||
|
adb25e6ef6
|
|||
|
6276f97cce
|
|||
|
da8c64624f
|
|||
|
e154a1fde9
|
|||
|
eb10933f4b
|
BIN
assets/fonts/Ubuntu-M.ttf
Normal file
BIN
assets/fonts/Ubuntu-M.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Ubuntu-R.ttf
Normal file
BIN
assets/fonts/Ubuntu-R.ttf
Normal file
Binary file not shown.
1
main.py
1
main.py
@@ -4,6 +4,7 @@ from src.game import Game
|
||||
def main():
|
||||
print("Welcome to Rally Racer !")
|
||||
game: Game = Game()
|
||||
game.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
43
src/camera.py
Normal file
43
src/camera.py
Normal 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
96
src/car.py
Normal 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
|
||||
89
src/game.py
89
src/game.py
@@ -1,9 +1,94 @@
|
||||
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:
|
||||
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()
|
||||
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
0
src/objects/__init__.py
Normal file
66
src/objects/road.py
Normal file
66
src/objects/road.py
Normal 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
54
src/track.py
Normal 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
46
src/track_object.py
Normal 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
34
src/utils.py
Normal 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
|
||||
13
src/vec.py
13
src/vec.py
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import sqrt
|
||||
from math import cos, sin, sqrt
|
||||
|
||||
|
||||
class Vec:
|
||||
@@ -55,3 +55,14 @@ class Vec:
|
||||
|
||||
def __repr__(self) -> str:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user