├── .gitignore ├── LICENSE ├── README.md ├── core ├── __init__.py ├── camera.py ├── cube.py ├── rotation.py ├── window.py └── world.py ├── display ├── __init__.py ├── app.py ├── colors.py └── player.py ├── main.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | venv/ 3 | **/__pycache__/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hakan Karakuş 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pygame 3D 2 | 3 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hakkush-07/pygame-3D/9026eb80a670457255ca98bf96cbec792ce719c2/core/__init__.py -------------------------------------------------------------------------------- /core/camera.py: -------------------------------------------------------------------------------- 1 | from math import tan, pi 2 | from core.window import WindowPoint, WindowLine, WindowPolygon 3 | from core.world import WorldPoint, WorldLine, WorldPolygon 4 | from core.rotation import Rotation 5 | 6 | class Camera: 7 | """ 8 | camera 9 | position is a WorldPoint 10 | rotation is a Rotation 11 | clipping_planes is a tuple of two elements for near and far 12 | fov is the field of view angle in radians 13 | """ 14 | def __init__(self): 15 | self.position = WorldPoint(0, 0, 0) 16 | self.rotation = Rotation(0, 0) 17 | self.clipping_planes = (1, 5) 18 | self.fov = 0.6 * pi 19 | 20 | def __repr__(self): 21 | return f"Camera(position: {self.position}, rotation: {self.rotation}, clippings: {self.clipping_planes}, fov: {self.fov})" 22 | 23 | def render_point(self, world_point): 24 | """ 25 | converts WorldPoint to WindowPoint 26 | """ 27 | clipped_relative_point = self.relative_position(world_point).clipped(*self.clipping_planes) 28 | if clipped_relative_point: 29 | return self.perspective(clipped_relative_point) 30 | 31 | def render_line(self, world_line): 32 | """ 33 | converts WorldLine to WindowLine 34 | """ 35 | clipped_relative_line = WorldLine(self.relative_position(world_line.p1), self.relative_position(world_line.p2)).clipped(*self.clipping_planes) 36 | if clipped_relative_line: 37 | return WindowLine(self.perspective(clipped_relative_line.p1), self.perspective(clipped_relative_line.p2)) 38 | 39 | def render_polygon(self, world_polygon): 40 | """ 41 | converts WorldPolygon to WindowPolygon 42 | """ 43 | clipped_relative_polygon = WorldPolygon([self.relative_position(p) for p in world_polygon.vertices]).clipped(*self.clipping_planes) 44 | if clipped_relative_polygon: 45 | return WindowPolygon([self.perspective(p) for p in clipped_relative_polygon.vertices]) 46 | 47 | def relative_position(self, world_point): 48 | """ 49 | relative world position of the WorldPoint 50 | """ 51 | return (world_point - self.position).rotate_z(-self.rotation.a).rotate_y(self.rotation.b) 52 | 53 | def perspective(self, relative_point): 54 | """ 55 | converts relative WorldPoint to WindowPoint 56 | """ 57 | x, y, z = relative_point 58 | k = 0.5 / tan(0.5 * self.fov) 59 | return WindowPoint(-k * y / x, k * z / x) 60 | -------------------------------------------------------------------------------- /core/cube.py: -------------------------------------------------------------------------------- 1 | from core.world import WorldPoint, WorldLine, WorldPolygon 2 | 3 | class Cube: 4 | """ 5 | cube in the world 6 | center coordinates are WorldPoint 7 | s is scale 8 | """ 9 | def __init__(self, cx, cy, cz, s): 10 | self.x = cx 11 | self.y = cy 12 | self.z = cz 13 | self.s = s 14 | sl = [-s / 2, s / 2] 15 | self.corners = [WorldPoint(self.x + x, self.y + y, self.z + z) for x in sl for y in sl for z in sl] 16 | el = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3), (2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)] 17 | self.edges = [WorldLine(self.corners[i], self.corners[j]) for i, j in el] 18 | fl = [(0, 1, 3, 2), (0, 1, 5, 4), (0, 2, 6, 4), (1, 3, 7, 5), (2, 3, 7, 6), (4, 5, 7, 6)] 19 | self.faces = [WorldPolygon([self.corners[i], self.corners[j], self.corners[k], self.corners[l]]) for i, j, k, l in fl] 20 | -------------------------------------------------------------------------------- /core/rotation.py: -------------------------------------------------------------------------------- 1 | class Rotation: 2 | def __init__(self, alpha, beta): 3 | """ 4 | rotation 5 | alpha: rotation in xy plane (0 means +x, pi/2 means +y while beta is 0) 6 | beta: rotation up and down (0 means ahead, pi/2 means up, -pi/2 means down) 7 | """ 8 | self.alpha = self.a = alpha 9 | self.beta = self.b = beta 10 | 11 | def __repr__(self): 12 | return f"Rotation({self.a}, {self.b}))" 13 | 14 | def __add__(self, other): 15 | return Rotation(self.a + other.a, self.b + other.b) 16 | 17 | def __iadd__(self, other): 18 | return self + other 19 | -------------------------------------------------------------------------------- /core/window.py: -------------------------------------------------------------------------------- 1 | class WindowPoint: 2 | """ 3 | point on the window 4 | coordinates are in terms of window width 5 | (0, 0): center 6 | (0.5, 0): middle of the right border 7 | (-0.5, 0): middle of the left border 8 | (0, 0.5): probably a bit above middle of top border 9 | """ 10 | def __init__(self, x, y): 11 | self.x = x 12 | self.y = y 13 | 14 | def __repr__(self): 15 | return f"WindowPoint({self.x}, {self.y})" 16 | 17 | def __iter__(self): 18 | return iter((self.x, self.y)) 19 | 20 | def __add__(self, other): 21 | return WindowPoint(self.x + other.x, self.y + other.y) 22 | 23 | def __sub__(self, other): 24 | return self + (-other) 25 | 26 | def __neg__(self): 27 | return WindowPoint(-self.x, -self.y) 28 | 29 | def __iadd__(self, other): 30 | return self + other 31 | 32 | def __mul__(self, other): 33 | return WindowPoint(other * self.x, other * self.y) 34 | 35 | def code(self, min_x, min_y, max_x, max_y): 36 | """ 37 | region code in Cohen Sutherland Line Clipping Algorithm 38 | window is the rectangle defined by the lines 39 | x = min_x, y = min_y, x = max_x, y = max_y 40 | 41 | 1001 | 1000 | 1010 42 | -----|------|----- 43 | 0001 | 0000 | 0010 44 | -----|------|----- 45 | 0101 | 0100 | 0110 46 | 47 | """ 48 | return (self.x < min_x) * 1 + (self.x > max_x) * 2 + (self.y < min_y) * 4 + (self.y > max_y) * 8 49 | 50 | def clipped(self, min_x, min_y, max_x, max_y): 51 | return self if min_x < self.x < max_x and min_y < self.y < max_y else None 52 | 53 | class WindowLine: 54 | def __init__(self, p1, p2): 55 | """ 56 | line on the window 57 | endpoints are WindowPoint 58 | """ 59 | self.p1 = p1 60 | self.p2 = p2 61 | 62 | def __repr__(self): 63 | return f"WindowLine({self.p1}, {self.p2})" 64 | 65 | def __iter__(self): 66 | return iter((self.p1, self.p2)) 67 | 68 | def intersection_x(self, a): 69 | """ 70 | intersection point with the line x = a 71 | """ 72 | dx, dy = self.p2 - self.p1 73 | s = dy / dx if dx else 1000 74 | y = self.p1.y + (a - self.p1.x) * s 75 | return WindowPoint(a, y) 76 | 77 | def intersection_y(self, b): 78 | """ 79 | intersection point with the line y = b 80 | """ 81 | dx, dy = self.p2 - self.p1 82 | s = dx / dy if dy else 1000 83 | x = self.p1.x + (b - self.p1.y) * s 84 | return WindowPoint(x, b) 85 | 86 | def clipped(self, min_x, min_y, max_x, max_y): 87 | """ 88 | Cohen Sutherland Line Clipping Algorithm 89 | returns clipped line to the rectangle defined by the lines 90 | x = min_x, y = min_y, x = max_x, y = max_y 91 | """ 92 | c1 = self.p1.code(min_x, min_y, max_x, max_y) 93 | c2 = self.p2.code(min_x, min_y, max_x, max_y) 94 | if c1 == 0 and c2 == 0: 95 | # line is visible 96 | return self 97 | if c1 & c2 != 0: 98 | # line is invisible 99 | return 100 | 101 | # line is clipping candidate 102 | 103 | # find new p1 candidate 104 | p1 = self.p1 105 | if c1 & 8: 106 | p1 = self.intersection_y(max_y) 107 | elif c1 & 4: 108 | p1 = self.intersection_y(min_y) 109 | elif c1 & 2: 110 | p1 = self.intersection_x(max_x) 111 | elif c1 & 1: 112 | p1 = self.intersection_x(min_x) 113 | 114 | # find new p2 candidate 115 | p2 = self.p2 116 | if c2 & 8: 117 | p2 = self.intersection_y(max_y) 118 | elif c2 & 4: 119 | p2 = self.intersection_y(min_y) 120 | elif c2 & 2: 121 | p2 = self.intersection_x(max_x) 122 | elif c2 & 1: 123 | p2 = self.intersection_x(min_x) 124 | 125 | # rerun the algorithm to remove all overflowed line parts 126 | cc1 = p1.code(min_x, min_y, max_x, max_y) 127 | cc2 = p2.code(min_x, min_y, max_x, max_y) 128 | if c1 == 0 and c2 == 0: 129 | # newly constructed line is visible 130 | return WindowLine(p1, p2) 131 | if c1 & c2 != 0: 132 | # newly constructed line is invisible 133 | return 134 | 135 | # newly constructed line needs to be clipped one more time 136 | line = WindowLine(p1, p2) 137 | 138 | # find clipped p1 139 | pp1 = p1 140 | if cc1 & 8: 141 | pp1 = line.intersection_y(max_y) 142 | elif cc1 & 4: 143 | pp1 = line.intersection_y(min_y) 144 | elif cc1 & 2: 145 | pp1 = line.intersection_x(max_x) 146 | elif cc1 & 1: 147 | pp1 = line.intersection_x(min_x) 148 | 149 | # find clipped p2 150 | pp2 = p2 151 | if cc2 & 8: 152 | pp2 = self.intersection_y(max_y) 153 | elif cc2 & 4: 154 | pp2 = self.intersection_y(min_y) 155 | elif cc2 & 2: 156 | pp2 = self.intersection_x(max_x) 157 | elif cc2 & 1: 158 | pp2 = self.intersection_x(min_x) 159 | 160 | return WindowPoint(pp1, pp2) 161 | 162 | class WindowPolygon: 163 | def __init__(self, vertices): 164 | """ 165 | polygon on the window 166 | vertices are WindowPoint 167 | """ 168 | self.vertices = vertices 169 | 170 | def clip_min_x(self, border): 171 | """ 172 | Sutherland Hodgman Polygon Clipping Algorithm one line clipping 173 | removes left side of the line x = border 174 | """ 175 | new_vertices = [] 176 | for i in range(-1, len(self.vertices) - 1): 177 | x1, _ = p1 = self.vertices[i] 178 | x2, _ = p2 = self.vertices[i + 1] 179 | line = WindowLine(p1, p2) 180 | if x1 < border and x2 < border: 181 | pass 182 | elif x1 < border and x2 > border: 183 | new_vertices.append(line.intersection_x(border)) 184 | new_vertices.append(p2) 185 | elif x1 > border and x2 < border: 186 | new_vertices.append(line.intersection_x(border)) 187 | elif x1 > border and x2 > border: 188 | new_vertices.append(p2) 189 | return WindowPolygon(new_vertices) 190 | 191 | def clip_max_x(self, border): 192 | """ 193 | Sutherland Hodgman polygon Clipping Algorithm one line clipping 194 | removes right side of the line x = border 195 | """ 196 | new_vertices = [] 197 | for i in range(-1, len(self.vertices) - 1): 198 | x1, _ = p1 = self.vertices[i] 199 | x2, _ = p2 = self.vertices[i + 1] 200 | line = WindowLine(p1, p2) 201 | if x1 > border and x2 > border: 202 | pass 203 | elif x1 > border and x2 < border: 204 | new_vertices.append(line.intersection_x(border)) 205 | new_vertices.append(p2) 206 | elif x1 < border and x2 > border: 207 | new_vertices.append(line.intersection_x(border)) 208 | elif x1 < border and x2 < border: 209 | new_vertices.append(p2) 210 | return WindowPolygon(new_vertices) 211 | 212 | def clip_min_y(self, border): 213 | """ 214 | Sutherland Hodgman Polygon Clipping Algorithm one line clipping 215 | removes bottom of the line y = border 216 | """ 217 | new_vertices = [] 218 | for i in range(-1, len(self.vertices) - 1): 219 | _, y1 = p1 = self.vertices[i] 220 | _, y2 = p2 = self.vertices[i + 1] 221 | line = WindowLine(p1, p2) 222 | if y1 < border and y2 < border: 223 | pass 224 | elif y1 < border and y2 > border: 225 | new_vertices.append(line.intersection_y(border)) 226 | new_vertices.append(p2) 227 | elif y1 > border and y2 < border: 228 | new_vertices.append(line.intersection_y(border)) 229 | elif y1 > border and y2 > border: 230 | new_vertices.append(p2) 231 | return WindowPolygon(new_vertices) 232 | 233 | def clip_max_y(self, border): 234 | """ 235 | Sutherland Hodgman polygon Clipping Algorithm one line clipping 236 | removes top of the line y = border 237 | """ 238 | new_vertices = [] 239 | for i in range(-1, len(self.vertices) - 1): 240 | _, y1 = p1 = self.vertices[i] 241 | _, y2 = p2 = self.vertices[i + 1] 242 | line = WindowLine(p1, p2) 243 | if y1 > border and y2 > border: 244 | pass 245 | elif y1 > border and y2 < border: 246 | new_vertices.append(line.intersection_y(border)) 247 | new_vertices.append(p2) 248 | elif y1 < border and y2 > border: 249 | new_vertices.append(line.intersection_y(border)) 250 | elif y1 < border and y2 < border: 251 | new_vertices.append(p2) 252 | return WindowPolygon(new_vertices) 253 | 254 | def clipped(self, min_x, min_y, max_x, max_y): 255 | """ 256 | Sutherland Hodgman Polygon Clipping Algorithm 257 | returns clipped polygon to the rectangle defined by the lines 258 | x = min_x, y = min_y, x = max_x, y = max_y 259 | """ 260 | clipped_polygon = self.clip_min_x(min_x).clip_min_y(min_y).clip_max_x(max_x).clip_max_y(max_y) 261 | return clipped_polygon if len(clipped_polygon.vertices) > 2 else None 262 | 263 | -------------------------------------------------------------------------------- /core/world.py: -------------------------------------------------------------------------------- 1 | from math import sin, cos 2 | 3 | class WorldPoint: 4 | """ 5 | point in the world 6 | """ 7 | def __init__(self, x, y, z): 8 | self.x = x 9 | self.y = y 10 | self.z = z 11 | 12 | def __repr__(self): 13 | return f"WorldPoint({self.x}, {self.y}, {self.z})" 14 | 15 | def __iter__(self): 16 | return iter((self.x, self.y, self.z)) 17 | 18 | def __add__(self, other): 19 | return WorldPoint(self.x + other.x, self.y + other.y, self.z + other.z) 20 | 21 | def __sub__(self, other): 22 | return self + (-other) 23 | 24 | def __neg__(self): 25 | return WorldPoint(-self.x, -self.y, -self.z) 26 | 27 | def __iadd__(self, other): 28 | return self + other 29 | 30 | def rotate_z(self, a): 31 | """ 32 | rotates around z axis 33 | a is the rotation angle in radians 34 | """ 35 | x, y, z = self 36 | s, c = sin(a), cos(a) 37 | return WorldPoint(x * c - y * s, y * c + x * s, z) 38 | 39 | def rotate_y(self, a): 40 | """ 41 | rotates around y axis 42 | a is the rotation angle in radians 43 | """ 44 | x, y, z = self 45 | s, c = sin(a), cos(a) 46 | return WorldPoint(x * c + z * s, y, z * c - x * s) 47 | 48 | def clipped(self, near, far): 49 | return self if near < self.x < far else None 50 | 51 | class WorldLine: 52 | """ 53 | line in the world 54 | endpoints are WorldPoint 55 | """ 56 | def __init__(self, p1, p2): 57 | self.p1 = p1 58 | self.p2 = p2 59 | 60 | def __repr__(self): 61 | return f"WorldLine({self.p1}, {self.p2})" 62 | 63 | def intersection(self, a): 64 | """ 65 | intersection point with the plane x = a 66 | """ 67 | dx, dy, dz = self.p2 - self.p1 68 | syx = dy / dx if dx else 1000 69 | szx = dz / dx if dx else 1000 70 | y = self.p1.y + (a - self.p1.x) * syx 71 | z = self.p1.z + (a - self.p1.x) * szx 72 | return WorldPoint(a, y, z) 73 | 74 | def clipped(self, near, far): 75 | """ 76 | returns clipped to the volume defined by the planes 77 | x = near, x = far 78 | """ 79 | if self.p1.x < near and self.p2.x < near: 80 | # line is outside of the volume 81 | return 82 | if self.p1.x > far and self.p2.x > far: 83 | # line is outside of the volume 84 | return 85 | if near <= self.p1.x <= far and near <= self.p2.x <= far: 86 | # line is inside of the volume 87 | return self 88 | 89 | # line needs to be clipped 90 | 91 | # find new p1 92 | p1 = self.p1 93 | if self.p1.x < near: 94 | p1 = self.intersection(near) 95 | elif self.p1.x > far: 96 | p1 = self.intersection(far) 97 | 98 | # find new p2 99 | p2 = self.p2 100 | if self.p2.x < near: 101 | p2 = self.intersection(near) 102 | elif self.p2.x > far: 103 | p2 = self.intersection(far) 104 | 105 | return WorldLine(p1, p2) 106 | 107 | class WorldPolygon: 108 | """ 109 | polygon in the world 110 | vertices are WorldPoint 111 | """ 112 | def __init__(self, vertices): 113 | self.vertices = vertices 114 | 115 | def clip_min_x(self, border): 116 | """ 117 | removes left of the plane x = border 118 | """ 119 | new_vertices = [] 120 | for i in range(-1, len(self.vertices) - 1): 121 | x1, _, _ = p1 = self.vertices[i] 122 | x2, _, _ = p2 = self.vertices[i + 1] 123 | line = WorldLine(p1, p2) 124 | 125 | if x1 < border and x2 < border: 126 | pass 127 | elif x1 < border and x2 > border: 128 | new_vertices.append(line.intersection(border)) 129 | new_vertices.append(p2) 130 | elif x1 > border and x2 < border: 131 | new_vertices.append(line.intersection(border)) 132 | elif x1 > border and x2 > border: 133 | new_vertices.append(p2) 134 | return WorldPolygon(new_vertices) 135 | 136 | def clip_max_x(self, border): 137 | """ 138 | removes right of the plane x = border 139 | """ 140 | new_vertices = [] 141 | for i in range(-1, len(self.vertices) - 1): 142 | x1, _, _ = p1 = self.vertices[i] 143 | x2, _, _ = p2 = self.vertices[i + 1] 144 | line = WorldLine(p1, p2) 145 | 146 | if x1 > border and x2 > border: 147 | pass 148 | elif x1 > border and x2 < border: 149 | new_vertices.append(line.intersection(border)) 150 | new_vertices.append(p2) 151 | elif x1 < border and x2 > border: 152 | new_vertices.append(line.intersection(border)) 153 | elif x1 < border and x2 < border: 154 | new_vertices.append(p2) 155 | return WorldPolygon(new_vertices) 156 | 157 | def clipped(self, near, far): 158 | """ 159 | returns clipped polygon to the volume defined by the planes 160 | x = near, x = far 161 | """ 162 | if all([p.x < near for p in self.vertices]) or all([p.x > far for p in self.vertices]): 163 | # polygon is inside the volume 164 | return 165 | if all([near <= p.x <= far for p in self.vertices]): 166 | # polygon is outside the volume 167 | return self 168 | 169 | # polygon needs to be clipped 170 | clipped_polygon = self.clip_min_x(near).clip_max_x(far) 171 | return clipped_polygon if len(clipped_polygon.vertices) > 2 else None 172 | -------------------------------------------------------------------------------- /display/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hakkush-07/pygame-3D/9026eb80a670457255ca98bf96cbec792ce719c2/display/__init__.py -------------------------------------------------------------------------------- /display/app.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | from pygame.locals import * 3 | import sys 4 | from display.colors import * 5 | from display.player import Player 6 | from core.camera import Camera 7 | 8 | class App: 9 | FPS = 120 10 | 11 | def __init__(self, objects=None): 12 | pygame.init() 13 | info = pygame.display.Info() 14 | self.w = info.current_w 15 | self.h = info.current_h 16 | self.window = pygame.display.set_mode((self.w, self.h), FULLSCREEN) 17 | self.clock = pygame.time.Clock() 18 | self.camera = Camera() 19 | self.player = Player() 20 | self.objects = objects if objects else [] 21 | self.timer = 0 22 | 23 | def to_window_tuple(self, window_point): 24 | """ 25 | converts WindowPoint to pygame window pixel coordinates 26 | """ 27 | return window_point.x * self.w + 0.5 * self.w, -window_point.y * self.w + 0.5 * self.h 28 | 29 | def handle_quit(self): 30 | """ 31 | handles quit events, esc key and exit button 32 | """ 33 | for event in pygame.event.get(): 34 | if event.type == QUIT: 35 | pygame.quit() 36 | sys.exit() 37 | elif event.type == KEYDOWN: 38 | if event.key == K_ESCAPE: 39 | pygame.quit() 40 | sys.exit() 41 | 42 | def draw_things(self): 43 | """ 44 | draws objects on the window 45 | """ 46 | self.window.fill(LIGHT_BLUE) 47 | for obj in self.objects: 48 | # draw faces 49 | for polygon in obj.faces: 50 | f = self.camera.render_polygon(polygon) 51 | if f: 52 | clipped_f = f.clipped(-1, -self.h / self.w, 1, self.h / self.w) 53 | if clipped_f: 54 | pygame.draw.polygon(self.window, YELLOW, [self.to_window_tuple(p) for p in clipped_f.vertices]) 55 | # draw edges 56 | for line in obj.edges: 57 | l = self.camera.render_line(line) 58 | if l: 59 | clipped_l = l.clipped(-1, -self.h / self.w, 1, self.h / self.w) 60 | if clipped_l: 61 | clipped_p1, clipped_p2 = clipped_l 62 | pygame.draw.line(self.window, BLACK, self.to_window_tuple(clipped_p1), self.to_window_tuple(clipped_p2)) 63 | # draw corners 64 | for point in obj.corners: 65 | p = self.camera.render_point(point) 66 | if p: 67 | clipped_p = p.clipped(-1, -self.h / self.w, 1, self.h / self.w) 68 | if clipped_p: 69 | pygame.draw.circle(self.window, BLACK, self.to_window_tuple(clipped_p), 3) 70 | self.window.blit(pygame.font.Font(None, 32).render(str(int(App.FPS)), True, BLACK), (10, 10)) 71 | pygame.display.update() 72 | 73 | def handle_movement(self, dt): 74 | """ 75 | handles movement based on WASD key presses 76 | """ 77 | keys = pygame.key.get_pressed() 78 | a = keys[K_a] - keys[K_d] 79 | b = keys[K_w] - keys[K_s] 80 | self.camera.position += self.player.move(a, b, dt, self.camera.rotation.a) 81 | 82 | def handle_rotation(self, dt): 83 | """ 84 | handles rotation based on mouse movements 85 | """ 86 | a, b = pygame.mouse.get_rel() 87 | self.camera.rotation += self.player.rotate(a, b, dt) 88 | 89 | def handle_jump(self, dt): 90 | """ 91 | handles jumping and space key presses 92 | """ 93 | space = pygame.key.get_pressed()[K_SPACE] 94 | self.camera.position += self.player.jump(space, dt) 95 | if self.camera.position.z < 0: 96 | self.player.end_jump() 97 | self.camera.position.z = 0 98 | 99 | def time_calculations(self): 100 | fps = self.clock.get_fps() 101 | dt = self.clock.tick() * 0.001 102 | self.timer += dt 103 | # update fps every second 104 | if self.timer > 1: 105 | self.timer = 0 106 | App.FPS = fps if fps else 120 107 | return dt 108 | 109 | def run(self): 110 | pygame.mouse.set_visible(False) 111 | pygame.event.set_grab(True) 112 | while True: 113 | dt = self.time_calculations() 114 | self.handle_quit() 115 | self.draw_things() 116 | self.handle_movement(dt) 117 | self.handle_rotation(dt) 118 | self.handle_jump(dt) 119 | 120 | -------------------------------------------------------------------------------- /display/colors.py: -------------------------------------------------------------------------------- 1 | WHITE = (255, 255, 255) 2 | RED = (255, 0, 0) 3 | GREEN = (0, 255, 0) 4 | BLUE = (0, 0, 255) 5 | BLACK = (0, 0, 0) 6 | YELLOW = (255, 255, 0) 7 | GRAY = (150, 150, 150) 8 | LIGHT_BLUE = (150, 150, 255) 9 | -------------------------------------------------------------------------------- /display/player.py: -------------------------------------------------------------------------------- 1 | from math import sqrt, sin, cos 2 | from core.world import WorldPoint 3 | from core.rotation import Rotation 4 | 5 | class Player: 6 | G = 9.81 7 | VV = 5.0 8 | def __init__(self): 9 | self.velocity = 4.0 10 | self.sensitivity = 2.0 11 | self.moving = False 12 | self.rotating = False 13 | self.jumping = False 14 | self.vertical_velocity = Player.VV 15 | 16 | def rotate(self, horizontal, vertical, dt): 17 | """ 18 | horizontal is horizontal mouse movement 19 | vertical is vertical mouse movement 20 | dt is delta time 21 | returns delta rotation 22 | """ 23 | self.rotating = horizontal != 0 or vertical != 0 24 | return Rotation(-horizontal * self.sensitivity * dt, -vertical * self.sensitivity * dt) 25 | 26 | def move(self, lr, ud, dt, rotation_xy): 27 | """ 28 | lr is -1, 0, 1 for right, no movement, left 29 | ud is -1, 0, 1 for forward, no movement, backward 30 | dt is delta time 31 | rotation_xy is the xy plane rotation of the camera 32 | returns delta movement 33 | """ 34 | if lr == 0 and ud == 0: 35 | # no movement 36 | self.moving = False 37 | return WorldPoint(0, 0, 0) 38 | 39 | self.moving = True 40 | distance = self.velocity * dt / sqrt(lr ** 2 + ud ** 2) 41 | s, c = sin(rotation_xy), cos(rotation_xy) 42 | dx = c * ud - s * lr 43 | dy = s * ud + c * lr 44 | return WorldPoint(dx * distance, dy * distance, 0) 45 | 46 | def end_jump(self): 47 | self.jumping = False 48 | self.vertical_velocity = Player.VV 49 | 50 | def jump(self, space, dt): 51 | if self.jumping: 52 | self.vertical_velocity -= dt * 9.81 53 | return WorldPoint(0, 0, dt * self.vertical_velocity) 54 | else: 55 | if space: 56 | self.jumping = True 57 | self.vertical_velocity -= dt * 9.81 58 | return WorldPoint(0, 0, dt * self.vertical_velocity) 59 | else: 60 | return WorldPoint(0, 0, 0) 61 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from display.app import App 2 | from core.cube import Cube 3 | 4 | app = App(objects=[Cube(5, 0, 0, 1)]) 5 | app.run() 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pygame==2.1.2 2 | --------------------------------------------------------------------------------