From a60f5ddee4f1f2a9dc22888fec122a343d43b7b0 Mon Sep 17 00:00:00 2001 From: Aymeric Wibo Date: Fri, 26 Jul 2024 21:50:38 +0200 Subject: [PATCH] ep13: Reorganize code and fix issues reported by LSP --- episode-13/main.py | 92 +++++----- episode-13/pyproject.toml | 9 + episode-13/shaders/frag.glsl | 18 ++ episode-13/shaders/vert.glsl | 18 ++ episode-13/src/__init__.py | 0 episode-13/src/chunk/__init__.py | 0 episode-13/src/chunk/chunk.py | 188 +++++++++++++++++++++ episode-13/src/chunk/subchunk.py | 105 ++++++++++++ episode-13/src/entity/__init__.py | 0 episode-13/src/entity/entity.py | 163 ++++++++++++++++++ episode-13/src/entity/player.py | 82 +++++++++ episode-13/src/physics/__init__.py | 0 episode-13/src/physics/collider.py | 65 +++++++ episode-13/src/physics/hit.py | 107 ++++++++++++ episode-13/src/renderer/__init__.py | 0 episode-13/src/renderer/block_type.py | 72 ++++++++ episode-13/src/renderer/matrix.py | 148 ++++++++++++++++ episode-13/src/renderer/shader.py | 71 ++++++++ episode-13/src/renderer/texture_manager.py | 57 +++++++ episode-13/src/save.py | 100 +++++++++++ episode-13/src/world.py | 178 +++++++++++++++++++ 21 files changed, 1426 insertions(+), 47 deletions(-) create mode 100644 episode-13/shaders/frag.glsl create mode 100644 episode-13/shaders/vert.glsl create mode 100644 episode-13/src/__init__.py create mode 100644 episode-13/src/chunk/__init__.py create mode 100644 episode-13/src/chunk/chunk.py create mode 100644 episode-13/src/chunk/subchunk.py create mode 100644 episode-13/src/entity/__init__.py create mode 100644 episode-13/src/entity/entity.py create mode 100644 episode-13/src/entity/player.py create mode 100644 episode-13/src/physics/__init__.py create mode 100644 episode-13/src/physics/collider.py create mode 100644 episode-13/src/physics/hit.py create mode 100644 episode-13/src/renderer/__init__.py create mode 100644 episode-13/src/renderer/block_type.py create mode 100644 episode-13/src/renderer/matrix.py create mode 100644 episode-13/src/renderer/shader.py create mode 100644 episode-13/src/renderer/texture_manager.py create mode 100644 episode-13/src/save.py create mode 100644 episode-13/src/world.py diff --git a/episode-13/main.py b/episode-13/main.py index a368a9fa..645d0843 100644 --- a/episode-13/main.py +++ b/episode-13/main.py @@ -6,15 +6,13 @@ pyglet.options["debug_gl"] = False import pyglet.gl as gl +import pyglet.window.mouse -import shader -import player - - -import chunk -import world - -import hit +from src.entity.player import SPRINTING_SPEED, WALKING_SPEED, Player +from src.physics.hit import HIT_RANGE, HitRay +from src.renderer.shader import Shader +from src.world import World +from src.chunk.chunk import CHUNK_HEIGHT, CHUNK_WIDTH, CHUNK_LENGTH class Window(pyglet.window.Window): @@ -23,11 +21,11 @@ def __init__(self, **args): # create world - self.world = world.World() + self.world = World() # create shader - self.shader = shader.Shader("vert.glsl", "frag.glsl") + self.shader = Shader("shaders/vert.glsl", "shaders/frag.glsl") self.shader_sampler_location = self.shader.find_uniform(b"texture_array_sampler") self.shader.use() @@ -38,7 +36,7 @@ def __init__(self, **args): # player stuff - self.player = player.Player(self.world, self.shader, self.width, self.height) + self.player = Player(self.world, self.shader, self.width, self.height) # misc stuff @@ -101,54 +99,54 @@ def hit_callback(current_block, next_block): x, y, z = self.player.position y += self.player.eyelevel - hit_ray = hit.Hit_ray(self.world, self.player.rotation, (x, y, z)) + hit_ray = HitRay(self.world, self.player.rotation, (x, y, z)) - while hit_ray.distance < hit.HIT_RANGE: + while hit_ray.distance < HIT_RANGE: if hit_ray.step(hit_callback): break - def on_mouse_motion(self, x, y, delta_x, delta_y): + def on_mouse_motion(self, x, y, dx, dy): if self.mouse_captured: sensitivity = 0.004 - self.player.rotation[0] += delta_x * sensitivity - self.player.rotation[1] += delta_y * sensitivity + self.player.rotation[0] += dx * sensitivity + self.player.rotation[1] += dy * sensitivity self.player.rotation[1] = max(-math.tau / 4, min(math.tau / 4, self.player.rotation[1])) - def on_mouse_drag(self, x, y, delta_x, delta_y, buttons, modifiers): - self.on_mouse_motion(x, y, delta_x, delta_y) + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + self.on_mouse_motion(x, y, dx, dy) - def on_key_press(self, key, modifiers): + def on_key_press(self, symbol, modifiers): if not self.mouse_captured: return - if key == pyglet.window.key.D: + if symbol == pyglet.window.key.D: self.player.input[0] += 1 - elif key == pyglet.window.key.A: + elif symbol == pyglet.window.key.A: self.player.input[0] -= 1 - elif key == pyglet.window.key.W: + elif symbol == pyglet.window.key.W: self.player.input[2] += 1 - elif key == pyglet.window.key.S: + elif symbol == pyglet.window.key.S: self.player.input[2] -= 1 - elif key == pyglet.window.key.SPACE: + elif symbol == pyglet.window.key.SPACE: self.player.input[1] += 1 - elif key == pyglet.window.key.LSHIFT: + elif symbol == pyglet.window.key.LSHIFT: self.player.input[1] -= 1 - elif key == pyglet.window.key.LCTRL: - self.player.target_speed = player.SPRINTING_SPEED + elif symbol == pyglet.window.key.LCTRL: + self.player.target_speed = SPRINTING_SPEED - elif key == pyglet.window.key.F: + elif symbol == pyglet.window.key.F: self.player.flying = not self.player.flying - elif key == pyglet.window.key.G: + elif symbol == pyglet.window.key.G: self.holding = random.randint(1, len(self.world.block_types) - 1) - elif key == pyglet.window.key.O: + elif symbol == pyglet.window.key.O: self.world.save.save() - elif key == pyglet.window.key.R: + elif symbol == pyglet.window.key.R: # how large is the world? max_y = 0 @@ -159,13 +157,13 @@ def on_key_press(self, key, modifiers): for pos in self.world.chunks: x, y, z = pos - max_y = max(max_y, (y + 1) * chunk.CHUNK_HEIGHT) + max_y = max(max_y, (y + 1) * CHUNK_HEIGHT) - max_x = max(max_x, (x + 1) * chunk.CHUNK_WIDTH) - min_x = min(min_x, x * chunk.CHUNK_WIDTH) + max_x = max(max_x, (x + 1) * CHUNK_WIDTH) + min_x = min(min_x, x * CHUNK_WIDTH) - max_z = max(max_z, (z + 1) * chunk.CHUNK_LENGTH) - min_z = min(min_z, z * chunk.CHUNK_LENGTH) + max_z = max(max_z, (z + 1) * CHUNK_LENGTH) + min_z = min(min_z, z * CHUNK_LENGTH) # get random X & Z coordinates to teleport the player to @@ -174,36 +172,36 @@ def on_key_press(self, key, modifiers): # find height at which to teleport to, by finding the first non-air block from the top of the world - for y in range(chunk.CHUNK_HEIGHT - 1, -1, -1): + for y in range(CHUNK_HEIGHT - 1, -1, -1): if not self.world.get_block_number((x, y, z)): continue self.player.teleport((x, y + 1, z)) break - elif key == pyglet.window.key.ESCAPE: + elif symbol == pyglet.window.key.ESCAPE: self.mouse_captured = False self.set_exclusive_mouse(False) - def on_key_release(self, key, modifiers): + def on_key_release(self, symbol, modifiers): if not self.mouse_captured: return - if key == pyglet.window.key.D: + if symbol == pyglet.window.key.D: self.player.input[0] -= 1 - elif key == pyglet.window.key.A: + elif symbol == pyglet.window.key.A: self.player.input[0] += 1 - elif key == pyglet.window.key.W: + elif symbol == pyglet.window.key.W: self.player.input[2] -= 1 - elif key == pyglet.window.key.S: + elif symbol == pyglet.window.key.S: self.player.input[2] += 1 - elif key == pyglet.window.key.SPACE: + elif symbol == pyglet.window.key.SPACE: self.player.input[1] -= 1 - elif key == pyglet.window.key.LSHIFT: + elif symbol == pyglet.window.key.LSHIFT: self.player.input[1] += 1 - elif key == pyglet.window.key.LCTRL: - self.player.target_speed = player.WALKING_SPEED + elif symbol == pyglet.window.key.LCTRL: + self.player.target_speed = WALKING_SPEED class Game: diff --git a/episode-13/pyproject.toml b/episode-13/pyproject.toml index 522cda6b..3cb0ccd2 100644 --- a/episode-13/pyproject.toml +++ b/episode-13/pyproject.toml @@ -31,3 +31,12 @@ ruff = "^0.5.5" exclude = [".venv"] venvPath = "." venv = ".venv" + +# For some reason, pyright thinks '__setitem__' is not defined on 'pyglet.options' when it totally is. + +reportIndexIssue = false + +# From https://github.com/obiwac/python-minecraft-clone/pull/107: +# F405 * may be undefined, or defined from star imports: These are indeed defined from star imports. I guess we could import all the symbols in '__all__' explicitly, but if there's a mistake here and it causes a runtime error, that's not the end of the world. + +ignore = ["models/__init__.py"] diff --git a/episode-13/shaders/frag.glsl b/episode-13/shaders/frag.glsl new file mode 100644 index 00000000..46c3d2a2 --- /dev/null +++ b/episode-13/shaders/frag.glsl @@ -0,0 +1,18 @@ +#version 330 + +out vec4 fragment_colour; + +uniform sampler2DArray texture_array_sampler; + +in vec3 local_position; +in vec3 interpolated_tex_coords; +in float interpolated_shading_value; + +void main(void) { + vec4 texture_colour = texture(texture_array_sampler, interpolated_tex_coords); + fragment_colour = texture_colour * interpolated_shading_value; + + if (texture_colour.a == 0.0) { // discard if texel's alpha component is 0 (texel is transparent) + discard; + } +} \ No newline at end of file diff --git a/episode-13/shaders/vert.glsl b/episode-13/shaders/vert.glsl new file mode 100644 index 00000000..5a945d25 --- /dev/null +++ b/episode-13/shaders/vert.glsl @@ -0,0 +1,18 @@ +#version 330 + +layout(location = 0) in vec3 vertex_position; +layout(location = 1) in vec3 tex_coords; +layout(location = 2) in float shading_value; + +out vec3 local_position; +out vec3 interpolated_tex_coords; +out float interpolated_shading_value; + +uniform mat4 matrix; + +void main(void) { + local_position = vertex_position; + interpolated_tex_coords = tex_coords; + interpolated_shading_value = shading_value; + gl_Position = matrix * vec4(vertex_position, 1.0); +} \ No newline at end of file diff --git a/episode-13/src/__init__.py b/episode-13/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/episode-13/src/chunk/__init__.py b/episode-13/src/chunk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/episode-13/src/chunk/chunk.py b/episode-13/src/chunk/chunk.py new file mode 100644 index 00000000..91d07b01 --- /dev/null +++ b/episode-13/src/chunk/chunk.py @@ -0,0 +1,188 @@ +import ctypes +import math + +import pyglet.gl as gl + +from src.chunk.subchunk import SUBCHUNK_HEIGHT, SUBCHUNK_LENGTH, SUBCHUNK_WIDTH, Subchunk + +CHUNK_WIDTH = 16 +CHUNK_HEIGHT = 128 +CHUNK_LENGTH = 16 + + +class Chunk: + def __init__(self, world, chunk_position): + self.world = world + + self.modified = False + self.chunk_position = chunk_position + + self.position = ( + self.chunk_position[0] * CHUNK_WIDTH, + self.chunk_position[1] * CHUNK_HEIGHT, + self.chunk_position[2] * CHUNK_LENGTH, + ) + + self.blocks = [[[0 for _ in range(CHUNK_LENGTH)] for _ in range(CHUNK_HEIGHT)] for _ in range(CHUNK_WIDTH)] + + self.subchunks = {} + + for x in range(int(CHUNK_WIDTH / SUBCHUNK_WIDTH)): + for y in range(int(CHUNK_HEIGHT / SUBCHUNK_HEIGHT)): + for z in range(int(CHUNK_LENGTH / SUBCHUNK_LENGTH)): + self.subchunks[(x, y, z)] = Subchunk(self, (x, y, z)) + + # mesh variables + + self.mesh_vertex_positions = [] + self.mesh_tex_coords = [] + self.mesh_shading_values = [] + + self.mesh_index_counter = 0 + self.mesh_indices = [] + + # create VAO and VBO's + + self.vao = gl.GLuint(0) + gl.glGenVertexArrays(1, self.vao) + gl.glBindVertexArray(self.vao) + + self.vertex_position_vbo = gl.GLuint(0) + gl.glGenBuffers(1, self.vertex_position_vbo) + + self.tex_coord_vbo = gl.GLuint(0) + gl.glGenBuffers(1, self.tex_coord_vbo) + + self.shading_values_vbo = gl.GLuint(0) + gl.glGenBuffers(1, self.shading_values_vbo) + + self.ibo = gl.GLuint(0) + gl.glGenBuffers(1, self.ibo) + + def update_subchunk_meshes(self): + for subchunk_position in self.subchunks: + subchunk = self.subchunks[subchunk_position] + subchunk.update_mesh() + + def update_at_position(self, position): + x, y, z = position + + lx = int(x % SUBCHUNK_WIDTH) + ly = int(y % SUBCHUNK_HEIGHT) + lz = int(z % SUBCHUNK_LENGTH) + + clx, cly, clz = self.world.get_local_position(position) + + sx = math.floor(clx / SUBCHUNK_WIDTH) + sy = math.floor(cly / SUBCHUNK_HEIGHT) + sz = math.floor(clz / SUBCHUNK_LENGTH) + + self.subchunks[(sx, sy, sz)].update_mesh() + + def try_update_subchunk_mesh(subchunk_position): + if subchunk_position in self.subchunks: + self.subchunks[subchunk_position].update_mesh() + + if lx == SUBCHUNK_WIDTH - 1: + try_update_subchunk_mesh((sx + 1, sy, sz)) + if lx == 0: + try_update_subchunk_mesh((sx - 1, sy, sz)) + + if ly == SUBCHUNK_HEIGHT - 1: + try_update_subchunk_mesh((sx, sy + 1, sz)) + if ly == 0: + try_update_subchunk_mesh((sx, sy - 1, sz)) + + if lz == SUBCHUNK_LENGTH - 1: + try_update_subchunk_mesh((sx, sy, sz + 1)) + if lz == 0: + try_update_subchunk_mesh((sx, sy, sz - 1)) + + def update_mesh(self): + # combine all the small subchunk meshes into one big chunk mesh + + self.mesh_vertex_positions = [] + self.mesh_tex_coords = [] + self.mesh_shading_values = [] + + self.mesh_index_counter = 0 + self.mesh_indices = [] + + for subchunk_position in self.subchunks: + subchunk = self.subchunks[subchunk_position] + + self.mesh_vertex_positions.extend(subchunk.mesh_vertex_positions) + self.mesh_tex_coords.extend(subchunk.mesh_tex_coords) + self.mesh_shading_values.extend(subchunk.mesh_shading_values) + + mesh_indices = [index + self.mesh_index_counter for index in subchunk.mesh_indices] + + self.mesh_indices.extend(mesh_indices) + self.mesh_index_counter += subchunk.mesh_index_counter + + # send the full mesh data to the GPU and free the memory used client-side (we don't need it anymore) + # don't forget to save the length of 'self.mesh_indices' before freeing + + self.mesh_indices_length = len(self.mesh_indices) + self.send_mesh_data_to_gpu() + + del self.mesh_vertex_positions + del self.mesh_tex_coords + del self.mesh_shading_values + + del self.mesh_indices + + def send_mesh_data_to_gpu(self): # pass mesh data to gpu + if not self.mesh_index_counter: + return + + gl.glBindVertexArray(self.vao) + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vertex_position_vbo) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, + ctypes.sizeof(gl.GLfloat * len(self.mesh_vertex_positions)), + (gl.GLfloat * len(self.mesh_vertex_positions))(*self.mesh_vertex_positions), + gl.GL_STATIC_DRAW, + ) + + gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) + gl.glEnableVertexAttribArray(0) + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.tex_coord_vbo) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, + ctypes.sizeof(gl.GLfloat * len(self.mesh_tex_coords)), + (gl.GLfloat * len(self.mesh_tex_coords))(*self.mesh_tex_coords), + gl.GL_STATIC_DRAW, + ) + + gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) + gl.glEnableVertexAttribArray(1) + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.shading_values_vbo) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, + ctypes.sizeof(gl.GLfloat * len(self.mesh_shading_values)), + (gl.GLfloat * len(self.mesh_shading_values))(*self.mesh_shading_values), + gl.GL_STATIC_DRAW, + ) + + gl.glVertexAttribPointer(2, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) + gl.glEnableVertexAttribArray(2) + + gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.ibo) + gl.glBufferData( + gl.GL_ELEMENT_ARRAY_BUFFER, + ctypes.sizeof(gl.GLuint * self.mesh_indices_length), + (gl.GLuint * self.mesh_indices_length)(*self.mesh_indices), + gl.GL_STATIC_DRAW, + ) + + def draw(self): + if not self.mesh_index_counter: + return + + gl.glBindVertexArray(self.vao) + + gl.glDrawElements(gl.GL_TRIANGLES, self.mesh_indices_length, gl.GL_UNSIGNED_INT, None) diff --git a/episode-13/src/chunk/subchunk.py b/episode-13/src/chunk/subchunk.py new file mode 100644 index 00000000..4ef16a7a --- /dev/null +++ b/episode-13/src/chunk/subchunk.py @@ -0,0 +1,105 @@ +SUBCHUNK_WIDTH = 4 +SUBCHUNK_HEIGHT = 4 +SUBCHUNK_LENGTH = 4 + + +class Subchunk: + def __init__(self, parent, subchunk_position): + self.parent = parent + self.world = self.parent.world + + self.subchunk_position = subchunk_position + + self.local_position = ( + self.subchunk_position[0] * SUBCHUNK_WIDTH, + self.subchunk_position[1] * SUBCHUNK_HEIGHT, + self.subchunk_position[2] * SUBCHUNK_LENGTH, + ) + + self.position = ( + self.parent.position[0] + self.local_position[0], + self.parent.position[1] + self.local_position[1], + self.parent.position[2] + self.local_position[2], + ) + + # mesh variables + + self.mesh_vertex_positions = [] + self.mesh_tex_coords = [] + self.mesh_shading_values = [] + + self.mesh_index_counter = 0 + self.mesh_indices = [] + + def update_mesh(self): + self.mesh_vertex_positions = [] + self.mesh_tex_coords = [] + self.mesh_shading_values = [] + + self.mesh_index_counter = 0 + self.mesh_indices = [] + + def add_face(face): + vertex_positions = block_type.vertex_positions[face].copy() + + for i in range(4): + vertex_positions[i * 3 + 0] += x + vertex_positions[i * 3 + 1] += y + vertex_positions[i * 3 + 2] += z + + self.mesh_vertex_positions.extend(vertex_positions) + + indices = [0, 1, 2, 0, 2, 3] + for i in range(6): + indices[i] += self.mesh_index_counter + + self.mesh_indices.extend(indices) + self.mesh_index_counter += 4 + + self.mesh_tex_coords.extend(block_type.tex_coords[face]) + self.mesh_shading_values.extend(block_type.shading_values[face]) + + for local_x in range(SUBCHUNK_WIDTH): + for local_y in range(SUBCHUNK_HEIGHT): + for local_z in range(SUBCHUNK_LENGTH): + parent_lx = self.local_position[0] + local_x + parent_ly = self.local_position[1] + local_y + parent_lz = self.local_position[2] + local_z + + block_number = self.parent.blocks[parent_lx][parent_ly][parent_lz] + + if block_number: + block_type = self.world.block_types[block_number] + + x, y, z = (self.position[0] + local_x, self.position[1] + local_y, self.position[2] + local_z) + + def can_render_face(position): + if not self.world.is_opaque_block(position): + if block_type.glass and self.world.get_block_number(position) == block_number: + return False + + return True + + return False + + # if block is cube, we want it to check neighbouring blocks so that we don't uselessly render faces + # if block isn't a cube, we just want to render all faces, regardless of neighbouring blocks + # since the vast majority of blocks are probably anyway going to be cubes, this won't impact performance all that much; the amount of useless faces drawn is going to be minimal + + if block_type.is_cube: + if can_render_face((x + 1, y, z)): + add_face(0) + if can_render_face((x - 1, y, z)): + add_face(1) + if can_render_face((x, y + 1, z)): + add_face(2) + if can_render_face((x, y - 1, z)): + add_face(3) + if can_render_face((x, y, z + 1)): + add_face(4) + if can_render_face((x, y, z - 1)): + add_face(5) + + else: + for i in range(len(block_type.vertex_positions)): + add_face(i) diff --git a/episode-13/src/entity/__init__.py b/episode-13/src/entity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/episode-13/src/entity/entity.py b/episode-13/src/entity/entity.py new file mode 100644 index 00000000..6497de0f --- /dev/null +++ b/episode-13/src/entity/entity.py @@ -0,0 +1,163 @@ +import math + +from src.physics.collider import Collider + +FLYING_ACCEL = (0, 0, 0) +GRAVITY_ACCEL = (0, -32, 0) + +# these values all come (loosely) from Minecraft, but are multiplied by 20 (since Minecraft runs at 20 TPS) + +FRICTION = (20, 20, 20) + +DRAG_FLY = (5, 5, 5) +DRAG_JUMP = (1.8, 0, 1.8) +DRAG_FALL = (1.8, 0.4, 1.8) + + +class Entity: + def __init__(self, world): + self.world = world + + # physical variables + + self.jump_height = 1.25 + self.flying = False + + self.position = [0., 80., 0.] + self.rotation = [-math.tau / 4, 0.] + + self.velocity = [0., 0., 0.] + self.accel = [0., 0., 0.] + + # collision variables + + self.width = 0.6 + self.height = 1.8 + + self.collider = Collider() + self.grounded = False + + def update_collider(self): + x, y, z = self.position + + self.collider.x1 = x - self.width / 2 + self.collider.x2 = x + self.width / 2 + + self.collider.y1 = y + self.collider.y2 = y + self.height + + self.collider.z1 = z - self.width / 2 + self.collider.z2 = z + self.width / 2 + + def teleport(self, pos): + self.position = list(pos) + self.velocity = [0., 0., 0.] # to prevent collisions + + def jump(self, height=None): + # obviously, we can't initiate a jump while in mid-air + + if not self.grounded: + return + + if height is None: + height = self.jump_height + + self.velocity[1] = math.sqrt(-2 * GRAVITY_ACCEL[1] * height) + + @property + def friction(self): + if self.flying: + return DRAG_FLY + + elif self.grounded: + return FRICTION + + elif self.velocity[1] > 0: + return DRAG_JUMP + + return DRAG_FALL + + def update(self, delta_time): + # apply input acceleration, and adjust for friction/drag + + self.velocity = [v + a * f * delta_time for v, a, f in zip(self.velocity, self.accel, self.friction)] + self.accel = [0., 0., 0.] + + # compute collisions + + self.update_collider() + self.grounded = False + + for _ in range(3): + adjusted_velocity = [v * delta_time for v in self.velocity] + vx, vy, vz = adjusted_velocity + + # find all the blocks we could potentially be colliding with + # this step is known as "broad-phasing" + + step_x = 1 if vx > 0 else -1 + step_y = 1 if vy > 0 else -1 + step_z = 1 if vz > 0 else -1 + + steps_xz = int(self.width / 2) + steps_y = int(self.height) + + x, y, z = map(int, self.position) + cx, cy, cz = [int(x + v) for x, v in zip(self.position, adjusted_velocity)] + + potential_collisions = [] + + for i in range(x - step_x * (steps_xz + 1), cx + step_x * (steps_xz + 2), step_x): + for j in range(y - step_y * (steps_y + 2), cy + step_y * (steps_y + 3), step_y): + for k in range(z - step_z * (steps_xz + 1), cz + step_z * (steps_xz + 2), step_z): + pos = (i, j, k) + num = self.world.get_block_number(pos) + + if not num: + continue + + for _collider in self.world.block_types[num].colliders: + entry_time, normal = self.collider.collide(_collider + pos, adjusted_velocity) + + if normal is None: + continue + + potential_collisions.append((entry_time, normal)) + + # get first collision + + if not potential_collisions: + break + + entry_time, normal = min(potential_collisions, key=lambda x: x[0]) + entry_time -= 0.001 + + if normal[0]: + self.velocity[0] = 0 + self.position[0] += vx * entry_time + + if normal[1]: + self.velocity[1] = 0 + self.position[1] += vy * entry_time + + if normal[2]: + self.velocity[2] = 0 + self.position[2] += vz * entry_time + + if normal[1] == 1: + self.grounded = True + + self.position = [x + v * delta_time for x, v in zip(self.position, self.velocity)] + + # apply gravity acceleration + + gravity = FLYING_ACCEL if self.flying else GRAVITY_ACCEL + self.velocity = [v + a * delta_time for v, a in zip(self.velocity, gravity)] + + # apply friction/drag + + self.velocity = [v - min(v * f * delta_time, v, key=abs) for v, f in zip(self.velocity, self.friction)] + + # make sure we can rely on the entity's collider outside of this function + + self.update_collider() diff --git a/episode-13/src/entity/player.py b/episode-13/src/entity/player.py new file mode 100644 index 00000000..573d5c2a --- /dev/null +++ b/episode-13/src/entity/player.py @@ -0,0 +1,82 @@ +import math +from src.entity.entity import Entity +from src.renderer.matrix import Matrix + +WALKING_SPEED = 4.317 +SPRINTING_SPEED = 7 # faster than in Minecraft, feels better + + +class Player(Entity): + def __init__(self, world, shader, width, height): + super().__init__(world) + + self.view_width = width + self.view_height = height + + # create matrices + + self.mv_matrix = Matrix() + self.p_matrix = Matrix() + + # shaders + + self.shader = shader + self.shader_matrix_location = self.shader.find_uniform(b"matrix") + + # camera variables + + self.eyelevel = self.height - 0.2 + self.input = [0, 0, 0] + + self.target_speed = WALKING_SPEED + self.speed = self.target_speed + + def update(self, delta_time: float): + # process input + + if delta_time * 20 > 1: + self.speed = self.target_speed + + else: + self.speed += (self.target_speed - self.speed) * delta_time * 20 + + multiplier = self.speed * (1, 2)[self.flying] + + if self.flying and self.input[1]: + self.accel[1] = self.input[1] * multiplier + + if self.input[0] or self.input[2]: + angle = self.rotation[0] - math.atan2(self.input[2], self.input[0]) + math.tau / 4 + + self.accel[0] = math.cos(angle) * multiplier + self.accel[2] = math.sin(angle) * multiplier + + if not self.flying and self.input[1] > 0: + self.jump() + + # process physics & collisions &c + + super().update(delta_time) + + def update_matrices(self): + # create projection matrix + + self.p_matrix.load_identity() + + self.p_matrix.perspective( + 90 + 10 * (self.speed - WALKING_SPEED) / (SPRINTING_SPEED - WALKING_SPEED), + float(self.view_width) / self.view_height, + 0.1, + 500, + ) + + # create modelview matrix + + self.mv_matrix.load_identity() + self.mv_matrix.rotate_2d(self.rotation[0] + math.tau / 4, self.rotation[1]) + self.mv_matrix.translate(-self.position[0], -self.position[1] - self.eyelevel, -self.position[2]) + + # modelviewprojection matrix + + mvp_matrix = self.p_matrix * self.mv_matrix + self.shader.uniform_matrix(self.shader_matrix_location, mvp_matrix) diff --git a/episode-13/src/physics/__init__.py b/episode-13/src/physics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/episode-13/src/physics/collider.py b/episode-13/src/physics/collider.py new file mode 100644 index 00000000..75dbd8db --- /dev/null +++ b/episode-13/src/physics/collider.py @@ -0,0 +1,65 @@ +class Collider: + def __init__(self, pos1=(None,) * 3, pos2=(None,) * 3): + # pos1: position of the collider vertex in the -X, -Y, -Z direction + # pos2: position of the collider vertex in the +X, +Y, +Z direction + + self.x1, self.y1, self.z1 = pos1 + self.x2, self.y2, self.z2 = pos2 + + def __add__(self, pos): + x, y, z = pos + + return Collider((self.x1 + x, self.y1 + y, self.z1 + z), (self.x2 + x, self.y2 + y, self.z2 + z)) + + def __and__(self, collider): + x = min(self.x2, collider.x2) - max(self.x1, collider.x1) + y = min(self.y2, collider.y2) - max(self.y1, collider.y1) + z = min(self.z2, collider.z2) - max(self.z1, collider.z1) + + return x > 0 and y > 0 and z > 0 + + def collide(self, collider, velocity): + # self: the dynamic collider, which moves + # collider: the static collider, which stays put + + no_collision = 1, None + + # find entry & exit times for each axis + + vx, vy, vz = velocity + + def time(x, y): + return x / y if y else float("-" * (x > 0) + "inf") + + x_entry = time(collider.x1 - self.x2 if vx > 0 else collider.x2 - self.x1, vx) + x_exit = time(collider.x2 - self.x1 if vx > 0 else collider.x1 - self.x2, vx) + + y_entry = time(collider.y1 - self.y2 if vy > 0 else collider.y2 - self.y1, vy) + y_exit = time(collider.y2 - self.y1 if vy > 0 else collider.y1 - self.y2, vy) + + z_entry = time(collider.z1 - self.z2 if vz > 0 else collider.z2 - self.z1, vz) + z_exit = time(collider.z2 - self.z1 if vz > 0 else collider.z1 - self.z2, vz) + + # make sure we actually got a collision + + if x_entry < 0 and y_entry < 0 and z_entry < 0: + return no_collision + + if x_entry > 1 or y_entry > 1 or z_entry > 1: + return no_collision + + # on which axis did we collide first? + + entry = max(x_entry, y_entry, z_entry) + exit_ = min(x_exit, y_exit, z_exit) + + if entry > exit_: + return no_collision + + # find normal of surface we collided with + + nx = (0, -1 if vx > 0 else 1)[entry == x_entry] + ny = (0, -1 if vy > 0 else 1)[entry == y_entry] + nz = (0, -1 if vz > 0 else 1)[entry == z_entry] + + return entry, (nx, ny, nz) diff --git a/episode-13/src/physics/hit.py b/episode-13/src/physics/hit.py new file mode 100644 index 00000000..55ff385c --- /dev/null +++ b/episode-13/src/physics/hit.py @@ -0,0 +1,107 @@ +import math + +HIT_RANGE = 3 + + +class HitRay: + def __init__(self, world, rotation, starting_position): + self.world = world + + # get the ray unit vector based on rotation angles + # sqrt(ux ^ 2 + uy ^ 2 + uz ^ 2) must always equal 1 + + self.vector = ( + math.cos(rotation[0]) * math.cos(rotation[1]), + math.sin(rotation[1]), + math.sin(rotation[0]) * math.cos(rotation[1]), + ) + + # point position + self.position = list(starting_position) + + # block position in which point currently is + self.block = tuple(map(lambda x: int(round(x)), self.position)) + + # current distance the point has traveled + self.distance = 0 + + # 'check' and 'step' both return 'True' if something is hit, and 'False' if not + + def check(self, hit_callback, distance, current_block, next_block): + if self.world.get_block_number(next_block): + hit_callback(current_block, next_block) + return True + + else: + self.position = list(map(lambda x: self.position[x] + self.vector[x] * distance, range(3))) + + self.block = next_block + self.distance += distance + + return False + + def step(self, hit_callback): + bx, by, bz = self.block + + # point position relative to block centre + local_position = list(map(lambda x: self.position[x] - self.block[x], range(3))) + + # we don't want to deal with negatives, so remove the sign + # this is also cool because it means we don't need to take into account the sign of our ray vector + # we do need to remember which components were negative for later on, however + + sign = [1, 1, 1] # '1' for positive, '-1' for negative + absolute_vector = list(self.vector) + + for component in range(3): + if self.vector[component] < 0: + sign[component] = -1 + + absolute_vector[component] = -absolute_vector[component] + local_position[component] = -local_position[component] + + lx, ly, lz = local_position + vx, vy, vz = absolute_vector + + # calculate intersections + # I only detail the math for the first component (X) because the rest is pretty self-explanatory + + # ray line (passing through the point) r ≡ (x - lx) / vx = (y - ly) / lz = (z - lz) / vz (parametric equation) + + # +x face fx ≡ x = 0.5 (y & z can be any real number) + # r ∩ fx ≡ (0.5 - lx) / vx = (y - ly) / vy = (z - lz) / vz + + # x: x = 0.5 + # y: (y - ly) / vy = (0.5 - lx) / vx IFF y = (0.5 - lx) / vx * vy + ly + # z: (z - lz) / vz = (0.5 - lx) / vx IFF z = (0.5 - lx) / vx * vz + lz + + if vx: + x = 0.5 + y = (0.5 - lx) / vx * vy + ly + z = (0.5 - lx) / vx * vz + lz + + if y >= -0.5 and y <= 0.5 and z >= -0.5 and z <= 0.5: + distance = math.sqrt((x - lx) ** 2 + (y - ly) ** 2 + (z - lz) ** 2) + + # we can return straight away here + # if we intersect with one face, we know for a fact we're not intersecting with any of the others + + return self.check(hit_callback, distance, (bx, by, bz), (bx + sign[0], by, bz)) + + if vy: + x = (0.5 - ly) / vy * vx + lx + y = 0.5 + z = (0.5 - ly) / vy * vz + lz + + if x >= -0.5 and x <= 0.5 and z >= -0.5 and z <= 0.5: + distance = math.sqrt((x - lx) ** 2 + (y - ly) ** 2 + (z - lz) ** 2) + return self.check(hit_callback, distance, (bx, by, bz), (bx, by + sign[1], bz)) + + if vz: + x = (0.5 - lz) / vz * vx + lx + y = (0.5 - lz) / vz * vy + ly + z = 0.5 + + if x >= -0.5 and x <= 0.5 and y >= -0.5 and y <= 0.5: + distance = math.sqrt((x - lx) ** 2 + (y - ly) ** 2 + (z - lz) ** 2) + return self.check(hit_callback, distance, (bx, by, bz), (bx, by, bz + sign[2])) diff --git a/episode-13/src/renderer/__init__.py b/episode-13/src/renderer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/episode-13/src/renderer/block_type.py b/episode-13/src/renderer/block_type.py new file mode 100644 index 00000000..bc3338cd --- /dev/null +++ b/episode-13/src/renderer/block_type.py @@ -0,0 +1,72 @@ +from typing import Any +from src.physics.collider import Collider +import models.cube # default model + + +class BlockType: + # new optional model argument (cube model by default) + def __init__(self, texture_manager, name="unknown", block_face_textures={"all": "cobblestone"}, model: Any=models.cube): + self.name = name + self.block_face_textures = block_face_textures + self.model = model + + # create members based on model attributes + + self.transparent = model.transparent + self.is_cube = model.is_cube + self.glass = model.glass + + # create colliders + + self.colliders = [] + + for _collider in model.colliders: + self.colliders.append(Collider(*_collider)) + + # replace data contained in numbers.py with model specific data + + self.vertex_positions = model.vertex_positions + self.tex_coords = model.tex_coords.copy() + self.shading_values = model.shading_values + + def set_block_face(face, texture): + # make sure we don't add inexistent faces + + if face > len(self.tex_coords) - 1: + return + + self.tex_coords[face] = self.tex_coords[face].copy() + + for vertex in range(4): + self.tex_coords[face][vertex * 3 + 2] = texture + + for face in block_face_textures: + texture = block_face_textures[face] + texture_manager.add_texture(texture) + + texture_index = texture_manager.textures.index(texture) + + if face == "all": + for i in range(len(self.tex_coords)): + set_block_face(i, texture_index) + + elif face == "sides": + set_block_face(0, texture_index) + set_block_face(1, texture_index) + set_block_face(4, texture_index) + set_block_face(5, texture_index) + + elif face == "x": + set_block_face(0, texture_index) + set_block_face(1, texture_index) + + elif face == "y": + set_block_face(2, texture_index) + set_block_face(3, texture_index) + + elif face == "z": + set_block_face(4, texture_index) + set_block_face(5, texture_index) + + else: + set_block_face(["right", "left", "top", "bottom", "front", "back"].index(face), texture_index) diff --git a/episode-13/src/renderer/matrix.py b/episode-13/src/renderer/matrix.py new file mode 100644 index 00000000..16ecf04a --- /dev/null +++ b/episode-13/src/renderer/matrix.py @@ -0,0 +1,148 @@ +import copy +import math + + +def copy_matrix(matrix): + return copy.deepcopy(matrix) # we need to use deepcopy since we're dealing with 2D arrays + + +clean_matrix = [[0.0] * 4 for _ in range(4)] +identity_matrix = copy_matrix(clean_matrix) + +identity_matrix[0][0] = 1.0 +identity_matrix[1][1] = 1.0 +identity_matrix[2][2] = 1.0 +identity_matrix[3][3] = 1.0 + + +def multiply_matrices(x_matrix, y_matrix): + result_matrix = copy_matrix(clean_matrix) + + for i in range(4): + for j in range(4): + result_matrix[i][j] = ( + (x_matrix[0][j] * y_matrix[i][0]) + + (x_matrix[1][j] * y_matrix[i][1]) + + (x_matrix[2][j] * y_matrix[i][2]) + + (x_matrix[3][j] * y_matrix[i][3]) + ) + + return result_matrix + + +class Matrix: + def __init__(self, base=None): + if isinstance(base, Matrix): + self.data = copy_matrix(base.data) + elif isinstance(base, list): + self.data = copy_matrix(base) + else: + self.data = copy_matrix(clean_matrix) + + def load_identity(self): + self.data = copy_matrix(identity_matrix) + + def __mul__(self, matrix): + return Matrix(multiply_matrices(self.data, matrix.data)) + + def __imul__(self, matrix): + self.data = multiply_matrices(self.data, matrix.data) + + def scale(self, x, y, z): + for i in range(4): + self.data[0][i] *= x + for i in range(4): + self.data[1][i] *= y + for i in range(4): + self.data[2][i] *= z + + def translate(self, x, y, z): + for i in range(4): + self.data[3][i] = self.data[3][i] + (self.data[0][i] * x + self.data[1][i] * y + self.data[2][i] * z) + + def rotate(self, angle, x, y, z): + magnitude = math.sqrt(x * x + y * y + z * z) + + x /= -magnitude + y /= -magnitude + z /= -magnitude + + sin_angle = math.sin(angle) + cos_angle = math.cos(angle) + one_minus_cos = 1.0 - cos_angle + + xx = x * x + yy = y * y + zz = z * z + + xy = x * y + yz = y * z + zx = z * x + + xs = x * sin_angle + ys = y * sin_angle + zs = z * sin_angle + + rotation_matrix = copy_matrix(clean_matrix) + + rotation_matrix[0][0] = (one_minus_cos * xx) + cos_angle + rotation_matrix[0][1] = (one_minus_cos * xy) - zs + rotation_matrix[0][2] = (one_minus_cos * zx) + ys + + rotation_matrix[1][0] = (one_minus_cos * xy) + zs + rotation_matrix[1][1] = (one_minus_cos * yy) + cos_angle + rotation_matrix[1][2] = (one_minus_cos * yz) - xs + + rotation_matrix[2][0] = (one_minus_cos * zx) - ys + rotation_matrix[2][1] = (one_minus_cos * yz) + xs + rotation_matrix[2][2] = (one_minus_cos * zz) + cos_angle + + rotation_matrix[3][3] = 1.0 + self.data = multiply_matrices(self.data, rotation_matrix) + + def rotate_2d(self, x, y): + self.rotate(x, 0, 1.0, 0) + self.rotate(-y, math.cos(x), 0, math.sin(x)) + + def frustum(self, left, right, bottom, top, near, far): + deltax = right - left + deltay = top - bottom + deltaz = far - near + + frustum_matrix = copy_matrix(clean_matrix) + + frustum_matrix[0][0] = 2 * near / deltax + frustum_matrix[1][1] = 2 * near / deltay + + frustum_matrix[2][0] = (right + left) / deltax + frustum_matrix[2][1] = (top + bottom) / deltay + frustum_matrix[2][2] = -(near + far) / deltaz + + frustum_matrix[2][3] = -1.0 + frustum_matrix[3][2] = -2 * near * far / deltaz + + self.data = multiply_matrices(self.data, frustum_matrix) + + def perspective(self, fovy, aspect, near, far): + frustum_y = math.tan(math.radians(fovy) / 2) + frustum_x = frustum_y * aspect + + self.frustum(-frustum_x * near, frustum_x * near, -frustum_y * near, frustum_y * near, near, far) + + def orthographic(self, left, right, bottom, top, near, far): + deltax = right - left + deltay = top - bottom + deltaz = far - near + + orthographic_matrix = copy_matrix(identity_matrix) + + orthographic_matrix[0][0] = 2.0 / deltax + orthographic_matrix[3][0] = -(right + left) / deltax + + orthographic_matrix[1][1] = 2.0 / deltay + orthographic_matrix[3][1] = -(top + bottom) / deltay + + orthographic_matrix[2][2] = 2.0 / deltax + orthographic_matrix[3][2] = -(near + far) / deltaz + + self.data = multiply_matrices(self.data, orthographic_matrix) diff --git a/episode-13/src/renderer/shader.py b/episode-13/src/renderer/shader.py new file mode 100644 index 00000000..aa7780f1 --- /dev/null +++ b/episode-13/src/renderer/shader.py @@ -0,0 +1,71 @@ +import ctypes +import pyglet.gl as gl + + +class ShaderError(Exception): + ... + +def create_shader(target, source_path): + # read shader source + + with open(source_path, "rb") as source_file: + source = source_file.read() + + source_length = ctypes.c_int(len(source) + 1) + source_buffer = ctypes.create_string_buffer(source) + + buffer_pointer = ctypes.cast( + ctypes.pointer(ctypes.pointer(source_buffer)), ctypes.POINTER(ctypes.POINTER(ctypes.c_char)) + ) + + # compile shader + + gl.glShaderSource(target, 1, buffer_pointer, ctypes.byref(source_length)) + gl.glCompileShader(target) + + # handle potential errors + + log_length = gl.GLint(0) + gl.glGetShaderiv(target, gl.GL_INFO_LOG_LENGTH, ctypes.byref(log_length)) + + log_buffer = ctypes.create_string_buffer(log_length.value) + gl.glGetShaderInfoLog(target, log_length, None, log_buffer) + + if log_length.value > 1: + raise ShaderError(str(log_buffer.value)) + + +class Shader: + def __init__(self, vert_path, frag_path): + self.program = gl.glCreateProgram() + + # create vertex shader + + self.vert_shader = gl.glCreateShader(gl.GL_VERTEX_SHADER) + create_shader(self.vert_shader, vert_path) + gl.glAttachShader(self.program, self.vert_shader) + + # create fragment shader + + self.frag_shader = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) + create_shader(self.frag_shader, frag_path) + gl.glAttachShader(self.program, self.frag_shader) + + # link program and clean up + + gl.glLinkProgram(self.program) + + gl.glDeleteShader(self.vert_shader) + gl.glDeleteShader(self.frag_shader) + + def __del__(self): + gl.glDeleteProgram(self.program) + + def find_uniform(self, name): + return gl.glGetUniformLocation(self.program, ctypes.create_string_buffer(name)) + + def uniform_matrix(self, location, matrix): + gl.glUniformMatrix4fv(location, 1, gl.GL_FALSE, (gl.GLfloat * 16)(*sum(matrix.data, []))) + + def use(self): + gl.glUseProgram(self.program) diff --git a/episode-13/src/renderer/texture_manager.py b/episode-13/src/renderer/texture_manager.py new file mode 100644 index 00000000..2a493c4c --- /dev/null +++ b/episode-13/src/renderer/texture_manager.py @@ -0,0 +1,57 @@ +import pyglet + +import pyglet.gl as gl + + +class TextureManager: + def __init__(self, texture_width, texture_height, max_textures): + self.texture_width = texture_width + self.texture_height = texture_height + + self.max_textures = max_textures + + self.textures = [] + + self.texture_array = gl.GLuint(0) + gl.glGenTextures(1, self.texture_array) + gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, self.texture_array) + + gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) + + gl.glTexImage3D( + gl.GL_TEXTURE_2D_ARRAY, + 0, + gl.GL_RGBA, + self.texture_width, + self.texture_height, + self.max_textures, + 0, + gl.GL_RGBA, + gl.GL_UNSIGNED_BYTE, + None, + ) + + def generate_mipmaps(self): + gl.glGenerateMipmap(gl.GL_TEXTURE_2D_ARRAY) + + def add_texture(self, texture): + if texture not in self.textures: + self.textures.append(texture) + + texture_image = pyglet.image.load(f"textures/{texture}.png").get_image_data() + gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, self.texture_array) + + gl.glTexSubImage3D( + gl.GL_TEXTURE_2D_ARRAY, + 0, + 0, + 0, + self.textures.index(texture), + self.texture_width, + self.texture_height, + 1, + gl.GL_RGBA, + gl.GL_UNSIGNED_BYTE, + texture_image.get_data("RGBA", texture_image.width * 4), + ) diff --git a/episode-13/src/save.py b/episode-13/src/save.py new file mode 100644 index 00000000..1e6d4e81 --- /dev/null +++ b/episode-13/src/save.py @@ -0,0 +1,100 @@ +import nbtlib as nbt +import base36 + +from src.chunk.chunk import Chunk, CHUNK_HEIGHT, CHUNK_LENGTH, CHUNK_WIDTH + + +class Save: + def __init__(self, world, path="save"): + self.world = world + self.path = path + + def chunk_position_to_path(self, chunk_position): + x, _, z = chunk_position + + chunk_path = "/".join( + (self.path, base36.dumps(x % 64), base36.dumps(z % 64), f"c.{base36.dumps(x)}.{base36.dumps(z)}.dat") + ) + + return chunk_path + + def load_chunk(self, chunk_position): + # load the chunk file + + chunk_path = self.chunk_position_to_path(chunk_position) + + try: + chunk_blocks = nbt.load(chunk_path)["Level"]["Blocks"] + + except FileNotFoundError: + return + + # create chunk and fill it with the blocks from our chunk file + + self.world.chunks[chunk_position] = Chunk(self.world, chunk_position) + + for x in range(CHUNK_WIDTH): + for y in range(CHUNK_HEIGHT): + for z in range(CHUNK_LENGTH): + self.world.chunks[chunk_position].blocks[x][y][z] = chunk_blocks[ + x * CHUNK_LENGTH * CHUNK_HEIGHT + z * CHUNK_HEIGHT + y + ] + + def save_chunk(self, chunk_position): + x, y, z = chunk_position + + # try to load the chunk file + # if it doesn't exist, create a new one + + chunk_path = self.chunk_position_to_path(chunk_position) + + try: + chunk_data = nbt.load(chunk_path) + + except FileNotFoundError: + chunk_data = nbt.File({"": nbt.Compound({"Level": nbt.Compound()})}) + + chunk_data["Level"]["xPos"] = x + chunk_data["Level"]["zPos"] = z + + # fill the chunk file with the blocks from our chunk + + chunk_blocks = nbt.ByteArray([0] * (CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_LENGTH)) + + for x in range(CHUNK_WIDTH): + for y in range(CHUNK_HEIGHT): + for z in range(CHUNK_LENGTH): + chunk_blocks[ + x * CHUNK_LENGTH * CHUNK_HEIGHT + z * CHUNK_HEIGHT + y + ] = self.world.chunks[ + chunk_position + ].blocks[x][y][z] + + # save the chunk file + + chunk_data["Level"]["Blocks"] = chunk_blocks + chunk_data.save(chunk_path, gzipped=True) + + def load(self): + # for x in range(-16, 15): + # for y in range(-15, 16): + # self.load_chunk((x, 0, y)) + + # for x in range(-4, 4): + # for y in range(-4, 4): + # self.load_chunk((x, 0, y)) + + for x in range(-1, 1): + for y in range(-1, 1): + self.load_chunk((x, 0, y)) + + def save(self): + for chunk_position in self.world.chunks: + if chunk_position[1] != 0: # reject all chunks above and below the world limit + continue + + chunk = self.world.chunks[chunk_position] + + if chunk.modified: + self.save_chunk(chunk_position) + chunk.modified = False diff --git a/episode-13/src/world.py b/episode-13/src/world.py new file mode 100644 index 00000000..d808dfde --- /dev/null +++ b/episode-13/src/world.py @@ -0,0 +1,178 @@ +import math +from src.chunk.chunk import CHUNK_HEIGHT, CHUNK_LENGTH, CHUNK_WIDTH, Chunk +from src.save import Save +from src.renderer.block_type import BlockType +from src.renderer.texture_manager import TextureManager + +# import custom block models + +import models + + +class World: + def __init__(self): + self.texture_manager = TextureManager(16, 16, 256) + self.block_types: list[BlockType | None] = [None] + + # parse block type data file + + with open("data/blocks.mcpy") as f: + blocks_data = f.readlines() + + for block in blocks_data: + if block[0] in ["\n", "#"]: # skip if empty line or comment + continue + + number, props = block.split(":", 1) + number = int(number) + + # default block + + name = "Unknown" + model = models.cube + texture = {"all": "unknown"} + + # read properties + + for prop in props.split(","): + prop = prop.strip() + prop = list(filter(None, prop.split(" ", 1))) + + if prop[0] == "sameas": + sameas_number = int(prop[1]) + sameas = self.block_types[sameas_number] + + if sameas is not None: + name = sameas.name + texture = sameas.block_face_textures + model = sameas.model + + elif prop[0] == "name": + name = eval(prop[1]) + + elif prop[0][:7] == "texture": + _, side = prop[0].split(".") + texture[side] = prop[1].strip() + + elif prop[0] == "model": + model = eval(prop[1]) + + # add block type + + block_type = BlockType(self.texture_manager, name, texture, model) + + if number < len(self.block_types): + self.block_types[number] = block_type + + else: + self.block_types.append(block_type) + + self.texture_manager.generate_mipmaps() + + # load the world + + self.save = Save(self) + + self.chunks = {} + self.save.load() + + for chunk_position in self.chunks: + self.chunks[chunk_position].update_subchunk_meshes() + self.chunks[chunk_position].update_mesh() + + def get_chunk_position(self, position): + x, y, z = position + + return ( + math.floor(x / CHUNK_WIDTH), + math.floor(y / CHUNK_HEIGHT), + math.floor(z / CHUNK_LENGTH), + ) + + def get_local_position(self, position): + x, y, z = position + + return (int(x % CHUNK_WIDTH), int(y % CHUNK_HEIGHT), int(z % CHUNK_LENGTH)) + + def get_block_number(self, position): + chunk_position = self.get_chunk_position(position) + + if chunk_position not in self.chunks: + return 0 + + lx, ly, lz = self.get_local_position(position) + + block_number = self.chunks[chunk_position].blocks[lx][ly][lz] + return block_number + + def is_opaque_block(self, position): + # get block type and check if it's opaque or not + # air counts as a transparent block, so test for that too + + block_type = self.block_types[self.get_block_number(position)] + + if not block_type: + return False + + return not block_type.transparent + + def set_block(self, position, number): # set number to 0 (air) to remove block + x, y, z = position + chunk_position = self.get_chunk_position(position) + + if chunk_position not in self.chunks: # if no chunks exist at this position, create a new one + if number == 0: + return # no point in creating a whole new chunk if we're not gonna be adding anything + + self.chunks[chunk_position] = Chunk(self, chunk_position) + + if self.get_block_number(position) == number: # no point updating mesh if the block is the same + return + + lx, ly, lz = self.get_local_position(position) + + self.chunks[chunk_position].blocks[lx][ly][lz] = number + self.chunks[chunk_position].modified = True + + self.chunks[chunk_position].update_at_position((x, y, z)) + self.chunks[chunk_position].update_mesh() + + cx, cy, cz = chunk_position + + def try_update_chunk_at_position(chunk_position, position): + if chunk_position in self.chunks: + self.chunks[chunk_position].update_at_position(position) + self.chunks[chunk_position].update_mesh() + + if lx == CHUNK_WIDTH - 1: + try_update_chunk_at_position((cx + 1, cy, cz), (x + 1, y, z)) + if lx == 0: + try_update_chunk_at_position((cx - 1, cy, cz), (x - 1, y, z)) + + if ly == CHUNK_HEIGHT - 1: + try_update_chunk_at_position((cx, cy + 1, cz), (x, y + 1, z)) + if ly == 0: + try_update_chunk_at_position((cx, cy - 1, cz), (x, y - 1, z)) + + if lz == CHUNK_LENGTH - 1: + try_update_chunk_at_position((cx, cy, cz + 1), (x, y, z + 1)) + if lz == 0: + try_update_chunk_at_position((cx, cy, cz - 1), (x, y, z - 1)) + + def try_set_block(self, pos, num, collider): + # if we're trying to remove a block, whatever let it go through + + if not num: + return self.set_block(pos, 0) + + # make sure the block doesn't intersect with the passed collider + + for block_collider in self.block_types[num].colliders: + if collider & (block_collider + pos): + return + + self.set_block(pos, num) + + def draw(self): + for chunk_position in self.chunks: + self.chunks[chunk_position].draw()