4
\$\begingroup\$

I am making a 2d minecraft clone for a hobby. Here is my code:

2D_MC.py:

import pygame
import world

TITLE = "2d_MC"
SIZE = (1365, 705)
MOVEMENT_SPEED = 5


def handle_events():
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return 1

    keys = pygame.key.get_pressed()

    if keys[pygame.K_UP]:
        overworld.change_pos(pygame.Vector2(0, -MOVEMENT_SPEED))
    if keys[pygame.K_DOWN]:
        overworld.change_pos(pygame.Vector2(0, MOVEMENT_SPEED))
    if keys[pygame.K_LEFT]:
        overworld.change_pos(pygame.Vector2(-MOVEMENT_SPEED, 0))
    if keys[pygame.K_RIGHT]:
        overworld.change_pos(pygame.Vector2(MOVEMENT_SPEED, 0))

    return 0


def draw():
    screen.fill((255, 255, 255))
    overworld.render(screen, True, other_offset=window_rect.center)


def game_logic():
    overworld.handle_chunk_loader()


def run_game():
    while True:
        if handle_events():
            break

        game_logic()

        draw()

        pygame.display.update(window_rect)

        clock.tick()
        print(clock.get_fps())

    pygame.quit()


if __name__ == "__main__":
    pygame.init()
    screen = pygame.display.set_mode(SIZE)
    window_rect = pygame.Rect((0, 0), SIZE)
    clock = pygame.time.Clock()
    pygame.display.set_caption(TITLE)
    pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT])

    overworld = world.World(pygame.Vector2(0, 0), 2, 8)
    run_game()

world.py:

import pygame

BLOCK_SIZE = 64
BLOCK_TEXTURE_NAMES = ["textures/void.png", 
                        "textures/air.png", 
                        "textures/grass_block.png", 
                        "textures/dirt.png", 
                        "textures/stone.png", 
                        "textures/sandstone.png", 
                        "textures/sand.png", 
                        "textures/bedrock.png", 
                        "textures/oak_log.png", 
                        "textures/oak_leaves.png", 
                        "textures/cobblestone.png"]


def block_texture(texture):
    return pygame.transform.scale(pygame.image.load(texture), (BLOCK_SIZE, BLOCK_SIZE)).convert_alpha()


def convert(pos, convert_value_x, convert_value_y):
    """Converts coordinate systems."""
    nx, rx = divmod(pos.x, convert_value_x)
    ny, ry = divmod(pos.y, convert_value_y)
    return ((nx, ny), (rx, ry))


class Chunk:
    def __init__(self, pos, size):
        self.size = size
        self.actual_size = size*BLOCK_SIZE
        self.block_textures = [block_texture(texture) for texture in BLOCK_TEXTURE_NAMES]
        self.backround_texture = block_texture("textures/backround.png")
        self.pos = pos
        self.actual_pos = pos*BLOCK_SIZE
        self.surface = pygame.Surface((self.actual_size, self.actual_size))
        self.x_range = range(0, self.size)
        self.y_range = range(0, self.size)
        self.blocks = {}

    def update(self, block_pos, new_block=None):
        if new_block == None:
            self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE)
            self.surface.blit(self.block_textures[self.blocks[block_pos]], pygame.Vector2(block_pos)*BLOCK_SIZE)
        else:
            self.blocks[block_pos] = new_block
            self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE)
            self.surface.blit(self.block_textures[new_block], pygame.Vector2(block_pos)*BLOCK_SIZE)

    def generate(self):
        for x in self.x_range:
            for y in self.y_range:
                x_pos, y_pos = int(self.pos.x)+x, int(self.pos.y)+y
                surface = 0
                bedrock = 16
                soil_amount = 3
                if y_pos == surface:
                    block = 2
                elif y_pos > surface and y_pos <= surface + soil_amount:
                    block = 3
                elif y_pos > surface + soil_amount and y_pos < bedrock:
                    block = 4
                elif y_pos == bedrock:
                    block = 7
                elif y_pos > bedrock:
                    block = 0
                else:
                    block = 1
                self.update((x, y), block)

    def set_blocks(self, blocks):
        self.blocks = blocks
        for x in self.x_range:
            for y in self.y_range:
                self.update((x, y))

    def __str__(self):
        return str([self.blocks[(x, y)] for x in self.x_range for y in self.y_range])


class World():
    def __init__(self, loader_pos, loader_distance, chunk_size):
        self.chunk_size = chunk_size
        self.actual_chunk_size = chunk_size*BLOCK_SIZE
        self.loaded_chunks = {}
        self.loader_pos = loader_pos
        self.loader_chunk_pos = pygame.Vector2(convert(loader_pos, self.actual_chunk_size, self.actual_chunk_size)[0])
        self.loader_distance = loader_distance
        self.rendered = pygame.Surface((1, 1)).convert_alpha()
        self.innactive_block_data = {}

    def handle_chunk_loader(self):
        chunks_needed = [chunk_pos for chunk_pos in self.chunks_to_load(self.loader_chunk_pos)]
        chunks_currently_loaded = [chunk_pos for chunk_pos in self.loaded_chunks]
        self.load_chunks([chunk_pos for chunk_pos in chunks_needed if chunk_pos not in chunks_currently_loaded])
        self.unload_chunks([chunk_pos for chunk_pos in chunks_currently_loaded if chunk_pos not in chunks_needed])

    def load_chunks(self, chunks_pos):
        for pos in chunks_pos:
            chunk_pos = (pos.x, pos.y)
            block_data = self.innactive_block_data.get(chunk_pos, False)
            self.loaded_chunks[chunk_pos] = Chunk(pos, self.chunk_size)
            if block_data:
                self.loaded_chunks[chunk_pos].set_blocks(block_data)
            else:
                self.loaded_chunks[chunk_pos].generate()

    def unload_chunks(self, chunks_pos):
        for chunk_pos in chunks_pos:
            chunk = self.loaded_chunks.pop(chunk_pos)
            if not self.innactive_block_data.get(chunk_pos, False) == chunk.blocks:
                self.innactive_block_data[chunk_pos] = chunk.blocks
    
    def get_block(self, pos):
        chunk_pos, block_pos = convert(pos, self.chunk_size, self.chunk_size)
        try:
            return self.loaded_chunks[chunk_pos].blocks[block_pos]
        except IndexError as e:
            return 0

    def chunks_to_load(self, loader_pos):
        return [pygame.Vector2(chunk_pos_x*self.chunk_size, chunk_pos_y*self.chunk_size) 
            for chunk_pos_x in range(int(loader_pos.x)-self.loader_distance, int(loader_pos.x)+self.loader_distance+1) 
            for chunk_pos_y in range(int(loader_pos.y)-self.loader_distance, int(loader_pos.y)+self.loader_distance+1)]

    def change_pos(self, pos):
        self.loader_pos += pos
        self.loader_chunk_pos = pygame.Vector2(convert(self.loader_pos, self.actual_chunk_size, self.actual_chunk_size)[0])

    def set_pos(self, pos):
        self.loader_pos = pos
        self.loader_chunk_pos = pygame.Vector2(convert(pos, self.actual_chunk_size, self.actual_chunk_size)[0])

    def render(self, screen, use_pos, other_offset=0):
        for ch in self.loaded_chunks.values():
            chunk_screen_pos = ch.actual_pos
            screen.blit(ch.surface, (chunk_screen_pos - self.loader_pos*int(use_pos))+other_offset)

Is there any redundancy? Can performance be improved? Is the code hard to read?

\$\endgroup\$
2
  • \$\begingroup\$ When handle_events() returns a 0 / 1 int it looks like C code. But this is python, where a predicate can return a True / False bool, and mypy would be happy to check that for you. The while True: loop would more naturally be a while handle_events(): loop. \$\endgroup\$
    – J_H
    Commented May 19, 2023 at 0:29
  • \$\begingroup\$ ok good idea :) \$\endgroup\$
    – coder
    Commented May 19, 2023 at 0:39

1 Answer 1

2
+50
\$\begingroup\$

Screen resolution

SIZE = (1365, 705) is plainly bad, users have different screen resolutions, setting a default resolution doesn't make sense at all, because on larger screens (i.e. 3840x2160) the window looks tiny, and users will want games to be full-screen. So it is better to get the user's screen resolution.

On Windows, use the following to get screen resolution:

from win32api import GetSystemMetrics

RESOLUTION = tuple(map(GetSystemMetrics, (0, 1)))

Note this doesn't work on Linux, most users will probably be on Windows anyway, but if a user is using Linux and runs the program, it will raise exceptions. But of course, the Linux user can most probably fix the issue if you haven't fixed it.

Repetition elif ladder

Your code has a lot of repetition, a lot of redundancy, you used the same expressions multiple times, have multiple if statements with similar conditions, et cetera, you should follow Don't Repeat Yourself principle and clean those up.

if keys[pygame.K_UP]:
    overworld.change_pos(pygame.Vector2(0, -MOVEMENT_SPEED))
if keys[pygame.K_DOWN]:
    overworld.change_pos(pygame.Vector2(0, MOVEMENT_SPEED))
if keys[pygame.K_LEFT]:
    overworld.change_pos(pygame.Vector2(-MOVEMENT_SPEED, 0))
if keys[pygame.K_RIGHT]:
    overworld.change_pos(pygame.Vector2(MOVEMENT_SPEED, 0))

In the above code, each if statement is checking if a key is pressed and the action is extremely similar.

This is a perfect candidate to refactor to a for loop, you just need to store the keys in a sequence, and loop through that sequence to check if the key is pressed and do the action associated with the key. In this way, you only need to write one if statement.

You can get the keys by doing this:

def get_key(key): return getattr(pygame, f"K_{key}")
KEYS = list(map(get_key, ("UP", "DOWN", "LEFT", "RIGHT")))

The keys all have a prefix 'K_', the second line loops through the key names and adds the prefix, then gets the value of the key name by getting the value of the attribute of pygame with the key as name by using getattr, and then assigns the result to the KEYS list.

Your movement vectors follow a clear pattern. They are of the form (0, n). The sign of n is dependent on the direction of movement, and the order of the two numbers is dependent on the axis of movement. So you can just write a function to get the vectors.

from itertools import product
MOVEMENT_SPEED = 5

def get_vector(bools):
    vertical, positive = bools
    a, b = MOVEMENT_SPEED * (-1, 1)[positive], 0
    if vertical:
        a, b = b, a
    return pygame.Vector2(a, b)


VECTORS = dict(zip(KEYS, map(get_vector, product((1, 0), (0, 1)))))

product((1, 0), (0, 1)) gives [(1, 0), (1, 1), (0, 0), (0, 1)] which is then passed to get_vector, the arguments are the four tuples and they are unpacked, then the sign of the vector is set according to positive flag using indexing, and the order of the two numbers is swapped if vertical flag is set, finally the pygame.Vector2 object is created and returned.

And then your code allows pressing opposite arrow keys at the same time. If both up and down or both left and right keys are pressed, the player is moved then moved back to the original position. This shouldn't be allowed and the second move should be invalidated.

Observe that opposite keys have indices in KEYS with opposite oddness, so we can detect if opposite keys are pressed by detecting if both odd index and even index is present.

def handle_events():
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return True

    keys = pygame.key.get_pressed()
    exclusive = [False, False]
    for i, key in enumerate(KEYS):
        if keys[key]:
            exclusive[i % 2] = True
            if all(exclusive):
                break
            overworld.change_pos(VECTORS[key])

    return False

while condition

def run_game():
    while True:
        if handle_events():
            break
    ...

Don't do the above. while True is used to start infinite loops with no single predicate exit condition, and while it has legitimate uses, here it is not the case. You have one specific exit condition that breaks the loop, you should use that condition for the while condition, in this way the code is more concise and it is clearer when the loop should exit.

def run_game():
    while not handle_events():
        ...

Making ranges

self.x_range = range(0, self.size)

Don't do range(0, n), just write range(n), if you pass a single number as argument, Python knows that is the end of the range, and the start is by default 0 unless explicitly stated otherwise. It is more concise not to pass it and everyone knows what it means.

Repetition yet again

def update(self, block_pos, new_block=None):
    if new_block == None:
        self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE)
        self.surface.blit(self.block_textures[self.blocks[block_pos]], pygame.Vector2(block_pos)*BLOCK_SIZE)
    else:
        self.blocks[block_pos] = new_block
        self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE)
        self.surface.blit(self.block_textures[new_block], pygame.Vector2(block_pos)*BLOCK_SIZE)

In the above code you used pygame.Vector2(block_pos)*BLOCK_SIZE four times, and self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE) twice, without the regard to the if statement.

pygame.Vector2(block_pos)*BLOCK_SIZE should be a named variable so that you don't have to recalculate it when you need to use it again, and self.surface.blit(self.backround_texture, pygame.Vector2(block_pos)*BLOCK_SIZE) should be moved outside of if else statements so that it will automatically executed without regard to the condition and you don't have to write it twice.

Then your second commands are very similar, the only difference is the key will be the default value if new_block is None, else the key will be new_block and the corresponding value will be updated.

You can just check the emptiness of the variable and assign it if non-empty in one-line by leveraging the or operator.

def update(self, block_pos, new_block=None):
    vector = pygame.Vector2(block_pos) * BLOCK_SIZE
    self.surface.blit(self.backround_texture, vector)
    self.blocks[block_pos] = block = new_block or self.blocks[block_pos]
    self.surface.blit(block, vector)

Repetition yet another elif ladder

def generate(self):
    for x in self.x_range:
        for y in self.y_range:
            x_pos, y_pos = int(self.pos.x)+x, int(self.pos.y)+y
            surface = 0
            bedrock = 16
            soil_amount = 3
            if y_pos == surface:
                block = 2
            elif y_pos > surface and y_pos <= surface + soil_amount:
                block = 3
            elif y_pos > surface + soil_amount and y_pos < bedrock:
                block = 4
            elif y_pos == bedrock:
                block = 7
            elif y_pos > bedrock:
                block = 0
            else:
                block = 1
            self.update((x, y), block)

You are making several mistakes here, first you are using nested for loops to iterate through two entire iterables, while nested for loops can have legitimate uses, it is not the case here, you need to use itertools.product to get rid of one unnecessary nesting.

Then you are repeatedly assigning these variables surface = 0; bedrock = 16; soil_amount = 3 inside the loop, the variables aren't mutated at all within the loop, they don't belong in a loop. This is completely pointless and hinders performance. They need to be moved outside of the loop. Or better yet, since you are using a class, they need to be become class variables, since they won't ever change (I guess).

Then the elif ladder, this is completely inefficient. Here you have three exact matches, (0, 3, 16), and three values for each of them (2, 3, 7). This is the perfect opportunity to use a dict, like this: {0: 2, 3: 3, 16: 7}, you can then check if a key is present by using in operator and get the corresponding value by querying the dict.

Then you have three explicit conditions, 0 < x < 3, 3 < x < 16 and 16 < x, with another implicit one that can only be tested if x is negative. What you are trying to do here is to find the closest element in the list [0, 3, 16] that is less than x. You can use bisect.bisect to find the index of that element (plus one).

Putting it all together, the code becomes:

class Chunk:
    surface = 0
    bedrock = 16
    ground = 3
    starts = (surface, ground, bedrock)
    levels = dict(zip(starts, (2, 3, 7)))
    block_sizes = (1, 3, 4, 0)

    def generate(self):
        for x, y in product(self.x_range, self.y_range):
            y_pos = int(self.pos.y) + y
            if y_pos in self.levels:
                block = self.levels[y_pos]
            else:
                i = bisect(self.starts, y_pos)
                block = self.block_sizes[i]
            self.update((x, y), block)

Nested loops, yet again

def set_blocks(self, blocks):
    self.blocks = blocks
    for x in self.x_range:
        for y in self.y_range:
            self.update((x, y))

I won't repeat myself, use the following:

def set_blocks(self, blocks):
    self.blocks = blocks
    for x, y in product(self.x_range, self.y_range):
        self.update((x, y))

Repetition floating assignments

self.loader_chunk_pos = pygame.Vector2(convert(loader_pos, self.actual_chunk_size, self.actual_chunk_size)[0])

You have used the above line three times, each time only changing the first argument to the convert function. Make a function for that.

class World:
    def __init__(self, loader_pos, blah, blahh):
        self.set_loader_chunk_pos(loader_pos)

    def set_loader_chunk_pos(self, pos):
        self.loader_chunk_pos = pygame.Vector2(
            convert(pos, self.actual_chunk_size, self.actual_chunk_size)[0]
        )

Unnecessary list comprehension and inefficient way to get difference of sets

def handle_chunk_loader(self):
    chunks_needed = [chunk_pos for chunk_pos in self.chunks_to_load(self.loader_chunk_pos)]
    chunks_currently_loaded = [chunk_pos for chunk_pos in self.loaded_chunks]
    self.load_chunks([chunk_pos for chunk_pos in chunks_needed if chunk_pos not in chunks_currently_loaded])
    self.unload_chunks([chunk_pos for chunk_pos in chunks_currently_loaded if chunk_pos not in chunks_needed])

Don't do a list comprehension without any conditions whatsoever just to get all the elements in a collection and build a list. It is terribly inefficient and bad practice in general.

[e for e in d] is functionally equivalent to list(d), the latter: list constructor is much more concise and it is exactly designed for this very reason, just use it.

However using a list is not the correct thing to do here, immediately after converting you are checking all elements that are members of A and not members of B, in other words you are calculating the set difference between A and B, and there is a basic Python data class that is exactly designed for this sort of thing: set. Use it. You can then calculate the difference by seta - setb (if they are not sets, convert them first).

def handle_chunk_loader(self):
    chunks_needed = set(self.chunks_to_load())
    loaded_chunks = set(self.loaded_chunks)
    self.load_chunks(chunks_needed - loaded_chunks)
    self.unload_chunks(loaded_chunks - chunks_needed)

As you can see, I have not passed self.loader_chunk_pos, because I of course have refactored that function as well, also because you most likely won't pass any other argument to that function. So it shouldn't take any arguments (other than self).

Update

My previous advice holds but very unfortunately that only works for hashable data types and for whatever reason pygame.math.Vector2 is unhashable.

So it doesn't work--that is, without modifications. So I patched the unhashable class by subclassing it and overriding __hash__ dunder method, and it worked.

class Vector(pygame.math.Vector2):
    def __hash__(self):
        return int(self.x * 10 + self.y)

def handle_chunk_loader(self):
    chunks_needed = {Vector(i) for i in self.chunks_to_load()}
    loaded_chunks = {Vector(i) for i in self.loaded_chunks}
    self.load_chunks(chunks_needed - loaded_chunks)
    self.unload_chunks(loaded_chunks - chunks_needed)

More repetition and other errors

def load_chunks(self, chunks_pos):
    for pos in chunks_pos:
        chunk_pos = (pos.x, pos.y)
        block_data = self.innactive_block_data.get(chunk_pos, False)
        self.loaded_chunks[chunk_pos] = Chunk(pos, self.chunk_size)
        if block_data:
            self.loaded_chunks[chunk_pos].set_blocks(block_data)
        else:
            self.loaded_chunks[chunk_pos].generate()

def unload_chunks(self, chunks_pos):
    for chunk_pos in chunks_pos:
        chunk = self.loaded_chunks.pop(chunk_pos)
        if not self.innactive_block_data.get(chunk_pos, False) == chunk.blocks:
            self.innactive_block_data[chunk_pos] = chunk.blocks

You have used self.innactive_block_data.get(chunk_pos, False) twice, make a function to that as well.

A = func()
if A:
    do_something()

Don't do the above, instead, by using the Walrus (:=) operator, you can check and assign at the same time:

if A := func():
    do_something()

And never ever use not A == B (unless you are overloading __ne__), it is unnecessary and against the fundamentals of Python. You are doing the inequality check and Python has an inequality operator !=. A != B is the same as not A == B but much more concise.

And you can use ternary instead of if else to reduce some nesting.

def get_data(self, chunk_pos):
    return self.innactive_block_data.get(chunk_pos, False)

def load_chunks(self, chunks_pos):
    for pos in chunks_pos:
        chunk_pos = (pos.x, pos.y)
        self.loaded_chunks[chunk_pos] = Chunk(pos, self.chunk_size)
        (
            self.loaded_chunks[chunk_pos].set_blocks(block_data)
            if (block_data := self.get_data(chunk_pos))
            else self.loaded_chunks[chunk_pos].generate()
        )

def unload_chunks(self, chunks_pos):
    for chunk_pos in chunks_pos:
        chunk = self.loaded_chunks.pop(chunk_pos)
        if self.get_data(chunk_pos) != chunk.blocks:
            self.innactive_block_data[chunk_pos] = chunk.blocks

Repetition nth time and nested for loops

def chunks_to_load(self, loader_pos):
    return [pygame.Vector2(chunk_pos_x*self.chunk_size, chunk_pos_y*self.chunk_size) 
        for chunk_pos_x in range(int(loader_pos.x)-self.loader_distance, int(loader_pos.x)+self.loader_distance+1) 
        for chunk_pos_y in range(int(loader_pos.y)-self.loader_distance, int(loader_pos.y)+self.loader_distance+1)]

Don't pass the loader_pos argument, since the function only gets called once in the entire script and with only one argument that is unlikely to be changed, you should access that variable directly.

And nested loops yet again. Use product.

Your ranges are constructed in the exact same way, the only difference is that you are accessing different attributes, you can actually make a function to create the ranges for you, and access the attributes with strs by using getattr.

def get_range(self, axis):
    n = int(getattr(self.loader_chunk_pos, axis))
    return range(
        n - self.loader_distance,
        n + self.loader_distance + 1,
    )

def chunks_to_load(self):
    return [
        pygame.Vector2(chunk_pos_x * self.chunk_size, chunk_pos_y * self.chunk_size)
        for chunk_pos_x, chunk_pos_y in product(
            self.get_range("x"), self.get_range("y")
        )
    ]

Repetition + 1

def change_pos(self, pos):
    self.loader_pos += pos
    self.loader_chunk_pos = pygame.Vector2(convert(self.loader_pos, self.actual_chunk_size, self.actual_chunk_size)[0])

def set_pos(self, pos):
    self.loader_pos = pos
    self.loader_chunk_pos = pygame.Vector2(convert(pos, self.actual_chunk_size, self.actual_chunk_size)[0])

Don't use the above, use below example:

def set_loader_chunk_pos(self, pos):
    self.loader_chunk_pos = pygame.Vector2(
        convert(pos, self.actual_chunk_size, self.actual_chunk_size)[0]
    )

def change_pos(self, pos):
    self.loader_pos += pos
    self.set_loader_chunk_pos(self.loader_pos)

def set_pos(self, pos):
    self.loader_pos = pos
    self.set_loader_chunk_pos(pos)

Unnecessary variable assignment

chunk_screen_pos = ch.actual_pos

While it is generally a good idea to assign named variables, here it is completely unnecessary as it is only used immediately after assignment once.

Your code is of very poor quality, though it is understandable considering you very likely copied from ChatGPT. If you didn't copy from ChatGPT, then the quality of your code is about the same level as what ChatGPT would generate. You have a long way to go. But I don't mean to discourage you, you just have a lot to learn and huge potential to improve.


2D_MC.py

import pygame
import world
from itertools import product
from win32api import GetSystemMetrics

TITLE = "2d_MC"
RESOLUTION  = tuple(map(GetSystemMetrics, (0, 1)))
MOVEMENT_SPEED = 5
def get_key(key): return getattr(pygame, f"K_{key}")
KEYS = list(map(get_key, ("UP", "DOWN", "LEFT", "RIGHT")))


def get_vector(bools):
    vertical, positive = bools
    a, b = MOVEMENT_SPEED * (-1, 1)[positive], 0
    if vertical:
        a, b = b, a
    return pygame.Vector2(a, b)


VECTORS = dict(zip(KEYS, map(get_vector, product((1, 0), (0, 1)))))


def handle_events():
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return True

    keys = pygame.key.get_pressed()
    exclusive = [False, False]
    for i, key in enumerate(KEYS):
        if keys[key]:
            exclusive[i % 2] = True
            if all(exclusive):
                break
            overworld.change_pos(VECTORS[key])

    return False


def draw():
    screen.fill((255, 255, 255))
    overworld.render(screen, True, other_offset=window_rect.center)


def game_logic():
    overworld.handle_chunk_loader()


def run_game():
    while not handle_events():
        game_logic()
        draw()
        pygame.display.update(window_rect)
        clock.tick()
        print(clock.get_fps())

    pygame.quit()


if __name__ == "__main__":
    pygame.init()
    screen = pygame.display.set_mode(RESOLUTION)
    window_rect = pygame.Rect((0, 0), RESOLUTION)
    clock = pygame.time.Clock()
    pygame.display.set_caption(TITLE)
    pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT])
    overworld = world.World(pygame.Vector2(0, 0), 2, 8)
    run_game()

world.py

import pygame
from bisect import bisect
from itertools import product

BLOCK_SIZE = 64
BLOCK_TEXTURE_NAMES = [
    "textures/void.png",
    "textures/air.png",
    "textures/grass_block.png",
    "textures/dirt.png",
    "textures/stone.png",
    "textures/sandstone.png",
    "textures/sand.png",
    "textures/bedrock.png",
    "textures/oak_log.png",
    "textures/oak_leaves.png",
    "textures/cobblestone.png",
]


def block_texture(texture):
    return pygame.transform.scale(
        pygame.image.load(texture), (BLOCK_SIZE, BLOCK_SIZE)
    ).convert_alpha()


def convert(pos, convert_value_x, convert_value_y):
    """Converts coordinate systems."""
    nx, rx = divmod(pos.x, convert_value_x)
    ny, ry = divmod(pos.y, convert_value_y)
    return ((nx, ny), (rx, ry))


class Chunk:
    surface = 0
    bedrock = 16
    ground = 3
    starts = (surface, ground, bedrock)
    levels = dict(zip(starts, (2, 3, 7)))
    block_sizes = (1, 3, 4, 0)

    def __init__(self, pos, size):
        self.size = size
        self.actual_size = size * BLOCK_SIZE
        self.block_textures = [
            block_texture(texture) for texture in BLOCK_TEXTURE_NAMES
        ]
        self.backround_texture = block_texture("textures/backround.png")
        self.pos = pos
        self.actual_pos = pos * BLOCK_SIZE
        self.surface = pygame.Surface((self.actual_size, self.actual_size))
        self.x_range = range(self.size)
        self.y_range = range(self.size)
        self.blocks = {}

    def update(self, block_pos, new_block=None):
        vector = pygame.Vector2(block_pos) * BLOCK_SIZE
        self.surface.blit(self.backround_texture, vector)
        self.blocks[block_pos] = block = new_block or self.blocks[block_pos]
        self.surface.blit(block, vector)

    def generate(self):
        for x, y in product(self.x_range, self.y_range):
            y_pos = int(self.pos.y) + y
            if y_pos in self.levels:
                block = self.levels[y_pos]
            else:
                i = bisect(self.starts, y_pos)
                block = self.block_sizes[i]
            self.update((x, y), block)

    def set_blocks(self, blocks):
        self.blocks = blocks
        for x, y in product(self.x_range, self.y_range):
            self.update((x, y))

    def __str__(self):
        return str([self.blocks[(x, y)] for x in self.x_range for y in self.y_range])


class Vector(pygame.math.Vector2):
    def __hash__(self):
        return int(self.x * 10 + self.y)

class World:
    def __init__(self, loader_pos, loader_distance, chunk_size):
        self.chunk_size = chunk_size
        self.actual_chunk_size = chunk_size * BLOCK_SIZE
        self.loaded_chunks = {}
        self.loader_pos = loader_pos
        self.set_loader_chunk_pos(loader_pos)
        self.loader_distance = loader_distance
        self.rendered = pygame.Surface((1, 1)).convert_alpha()
        self.innactive_block_data = {}

    def handle_chunk_loader(self):
        chunks_needed = {Vector(i) for i in self.chunks_to_load()}
        loaded_chunks = {Vector(i) for i in self.loaded_chunks}
        self.load_chunks(chunks_needed - loaded_chunks)
        self.unload_chunks(loaded_chunks - chunks_needed)

    def get_data(self, chunk_pos):
        return self.innactive_block_data.get(chunk_pos, False)

    def load_chunks(self, chunks_pos):
        for pos in chunks_pos:
            chunk_pos = (pos.x, pos.y)
            self.loaded_chunks[chunk_pos] = Chunk(pos, self.chunk_size)
            (
                self.loaded_chunks[chunk_pos].set_blocks(block_data)
                if (block_data := self.get_data(chunk_pos))
                else self.loaded_chunks[chunk_pos].generate()
            )

    def unload_chunks(self, chunks_pos):
        for chunk_pos in chunks_pos:
            chunk = self.loaded_chunks.pop(chunk_pos)
            if self.get_data(chunk_pos) != chunk.blocks:
                self.innactive_block_data[chunk_pos] = chunk.blocks

    def get_block(self, pos):
        chunk_pos, block_pos = convert(pos, self.chunk_size, self.chunk_size)
        try:
            return self.loaded_chunks[chunk_pos].blocks[block_pos]
        except IndexError:
            return False

    def get_range(self, axis):
        n = int(getattr(self.loader_chunk_pos, axis))
        return range(
            n - self.loader_distance,
            n + self.loader_distance + 1,
        )

    def chunks_to_load(self):
        return [
            pygame.Vector2(chunk_pos_x * self.chunk_size, chunk_pos_y * self.chunk_size)
            for chunk_pos_x, chunk_pos_y in product(
                self.get_range("x"), self.get_range("y")
            )
        ]

    def set_loader_chunk_pos(self, pos):
        self.loader_chunk_pos = pygame.Vector2(
            convert(pos, self.actual_chunk_size, self.actual_chunk_size)[0]
        )

    def change_pos(self, pos):
        self.loader_pos += pos
        self.set_loader_chunk_pos(self.loader_pos)

    def set_pos(self, pos):
        self.loader_pos = pos
        self.set_loader_chunk_pos(pos)

    def render(self, screen, use_pos, other_offset=0):
        for ch in self.loaded_chunks.values():
            screen.blit(
                ch.surface,
                (ch.actual_pos - self.loader_pos * int(use_pos)) + other_offset,
            )
\$\endgroup\$
1
  • 1
    \$\begingroup\$ I didn't copy from chatgpt (or any other chatbot for that matter) everything was my own work and you were a little too critical for my liking... That being said you have answered my question and it is detailed so to be fair I should give you the bounty and accept. \$\endgroup\$
    – coder
    Commented May 24, 2023 at 21:56

Not the answer you're looking for? Browse other questions tagged or ask your own question.