Ray Casting 3D game on Python + PyGame / Hebrew
Introduction
We all remember the old games that introduced the 3D dimension for the first time.
The game became the founder of 3D games Wolfenstein 3D, released in 1992
and behind her Doom 1993 year
These two games were developed by the same company: id Software
She created her engine specifically for this game, and the result was a 3D game, which was considered almost impossible at the time.
But what if I say that this is not a 3D game, but only a simulation and the game actually looks like this?
In fact, technology is used here Ray CastingThe third dimension simply does not exist here.
What is this RayCasting, which is relevant even in our time, but is already used not for games, but for ray tracing technology in modern games.
If translated into Russian, then:
The method of throwing rays(Ray Casting) – one of the methods of rendering in computer graphics, in which the scene is built based on the measurements of the intersection of rays with the surface being visualized.
I became interested in how difficult it is to implement.
And I started writing technology RayCasting.
I will do it on a connection python + pygame
Pygame allows you to draw simple 2D shapes on a plane, and by dancing with a tambourine around them I will create a 3D illusion
Implementation of Ray Casting
To begin with, we create the simplest map with the help of symbols to separate when drawing where is a block and where is an empty space.
We draw a map in 2D, and a player with the ability to control and calculate the point of view.
player.delta = delta_time()
player.move(enableMoving)
display.fill((0, 0, 0))
pg.draw.circle(display, pg.Color("yellow"), (player.x, player.y), 0)
drawing.world(player)
class Drawing:
def __init__(self, surf, surf_map):
self.surf = surf
self.surf_map = surf_map
self.font = pg.font.SysFont('Arial', 25, bold=True)
def world(self, player):
rayCasting(self.surf, player)
def rayCasting(display, player):
inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
'right': blockSize - (player.x - player.x // blockSize * blockSize),
'top': player.y - player.y // blockSize * blockSize,
'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}
for ray in range(numRays):
cur_angle = player.angle - halfFOV + deltaRays * ray
cos_a, sin_a = cos(cur_angle), sin(cur_angle)
vl, hl = 0, 0
The movement will be carried out by adding the cosine of the horizontal angle of view and the sine of the vertical angle of view.
class Player:
def init(self)
self.x = 0
self.y = 0
self.angle = 0
self.delta = 0
self.speed = 100
self.mouse_sense = settings.mouse_sensivity
def move(self, active):
self.rect.center = self.x, self.y
key = pygame.key.get_pressed()
key2 = pygame.key.get_pressed()
cos_a, sin_a = cos(self.angle), sin(self.angle)
if key2[pygame.K_LSHIFT]:
self.speed += 5
if self.speed >= 200:
self.speed = 200
else:
self.speed = 100
if key[pygame.K_w]:
dx = cos_a * self.delta * self.speed
dy = sin_a * self.delta * self.speed
if key[pygame.K_s]:
dx = cos_a * self.delta * -self.speed
dy = sin_a * self.delta * -self.speed
if key[pygame.K_a]:
dx = sin_a * self.delta * self.speed
dy = cos_a * self.delta * -self.speed
if key[pygame.K_d]:
dx = sin_a * self.delta * -self.speed
dy = cos_a * self.delta * self.speed
We get the following result:
Next, we need to present our map as a grid. And in the entire span of the viewing angle throw several rays, the more of them, the better the picture will be, but fewer frames per second.
Each ray must intersect with each vertical and horizontal grid line. As soon as it finds a collision with a block, it draws it to the required dimensions and stops its movement, then the cycle moves to the next beam.
You also need to calculate the distance to the vertical and horizontal lines with which the beam crossed.
Let’s recall school trigonometry and consider it on the example of vertical lines
We know the side k is the distance of the player to the block
a is the angle of each ray
Next we just add the length since we know the size of our grid block.
And when the beam hits the wall, the cycle will stop.
Then we apply this to all axes with small changes
For horizontal lines, it is the same with sine.
In the distance of the beam, we write the horizontal or vertical distance, depending on what is closer
We add a couple of height, depth, and size variables that are calculated from fairly simple formulas
def rayCasting(display, player):
inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
'right': blockSize - (player.x - player.x // blockSize * blockSize),
'top': player.y - player.y // blockSize * blockSize,
'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}
for ray in range(numRays):
cur_angle = player.angle - halfFOV + deltaRays * ray
cos_a, sin_a = cos(cur_angle), sin(cur_angle)
vl, hl = 0, 0
#Вертикали
for k in range(mapWidth):
if cos_a > 0:
vl = inBlockPos['right'] / cos_a + blockSize / cos_a * k + 1
elif cos_a < 0:
vl = inBlockPos['left'] / -cos_a + blockSize / -cos_a * k + 1
xw, yw = vl * cos_a + player.x, vl * sin_a + player.y
fixed = xw // blockSize * blockSize, yw // blockSize * blockSize
if fixed in blockMap:
textureV = blockMapTextures[fixed]
break
#Горизонтали
for k in range(mapHeight):
if sin_a > 0:
hl = inBlockPos['bottom'] / sin_a + blockSize / sin_a * k + 1
elif sin_a < 0:
hl = inBlockPos['top'] / -sin_a + blockSize / -sin_a * k + 1
xh, yh = hl * cos_a + player.x, hl * sin_a + player.y
fixed = xh // blockSize * blockSize, yh // blockSize * blockSize
if fixed in blockMap:
textureH = blockMapTextures[fixed]
break
ray_size = min(vl, hl) * depthCoef
toX, toY = ray_size * cos(cur_angle) + player.x, ray_size * sin(cur_angle) + player.y
pg.draw.line(display, pg.Color("yellow"), (player.x, player.y), (toX, toY))
We draw rectangles in the center of the screen, the horizontal position will depend on the number of the beam, and the height will be equal to the specified coefficient for the length of the beam.
#def rayCasting
ray_size += cos(player.angle - cur_angle)
height_c = coef / (ray_size + 0.0001)
c = 255 / (1 + ray_size ** 2 * 0.0000005)
color = (c, c, c)
block = pg.draw.rect(display, color, (ray * scale, half_height - height_c // 2, scale, height_c))
And here comes the illusion of 3D measurement.
Textures
1 block has 4 sides and each must be covered with a texture.
We divide each side into strips with a small width, the main thing is that the number of rays falling on the block coincides with the number of strips on the side, and we divide our texture by the number of these strips and alternately draw a strip from the texture to a strip on the block.
So the width will change depending on the distance of the side of the block. And the position of the strip is calculated by multiplying the indentation by the size of the texture.
If the beam falls on the vertical, then the offset is calculated from the top point, if on the horizontal, then from the left point.
#def rayCasting
if hl > vl:
ray_size = vl
mr = yw
textNum = textureV
else:
ray_size = hl
mr = xh
textNum = textureH
mr = int(mr) % blockSize
textures[textNum].set_alpha(c)
wallLine = textures[textNum].subsurface(mr * textureScale, 0, textureScale, textureSize)
wallLine = pg.transform.scale(wallLine, (scale, int(height_c))).convert_alpha()
display.blit(wallLine, (ray * scale, half_height - height_c // 2))
We also add the possibility of drawing several textures on one map by adding special marks to the map, each will be assigned its own texture.
Here is an example of what the second level in the game looks like in the form of code:
textMaplvl2 = [
"111111111111111111111111",
"1111................1111",
"11.........1....11...111",
"11....151..1....31...111",
"1111............331...11",
"11111.....115..........1",
"1111.....11111....1113.1",
"115.......111......333.1",
"15....11.......11......1",
"11....11.......11..11111",
"111...................51",
"111........1......115551",
"11111...11111...11111111",
"11111%<@1111111111111111",
]
As a result, we get an adequate display of textures:
Collision
Where is it seen that we can pass through the blocks.
We add a collision. We add a so-called collider to each position of the block and add the same collider to the player. If it continues to go the way it was going and at this pace in the next frame it is predicted to enter the block, we simply reset the acceleration along the desired axis to zero.
For this, we will add a little class Player. I decided to add mouse control for the camera right away. This is how this class ended up looking like:
class Player:
def __init__(self):
self.x = 0
self.y = 0
self.angle = 0
self.delta = 0
self.speed = 100
self.mouse_sense = settings.mouse_sensivity
#collision
self.side = 50
self.rect = pygame.Rect(*(self.x, self.y), self.side, self.side)
def detect_collision_wall(self, dx, dy):
next_rect = self.rect.copy()
next_rect.move_ip(dx, dy)
hit_indexes = next_rect.collidelistall(collision_walls)
if len(hit_indexes):
delta_x, delta_y = 0, 0
for hit_index in hit_indexes:
hit_rect = collision_walls[hit_index]
if dx > 0:
delta_x += next_rect.right - hit_rect.left
else:
delta_x += hit_rect.right - next_rect.left
if dy > 0:
delta_y += next_rect.bottom - hit_rect.top
else:
delta_y += hit_rect.bottom - next_rect.top
if abs(delta_x - delta_y) < 50:
dx, dy = 0, 0
elif delta_x > delta_y:
dy = 0
elif delta_y > delta_x:
dx = 0
self.x += dx
self.y += dy
def move(self, active):
self.rect.center = self.x, self.y
key = pygame.key.get_pressed()
key2 = pygame.key.get_pressed()
cos_a, sin_a = cos(self.angle), sin(self.angle)
if key2[pygame.K_LSHIFT]:
self.speed += 5
if self.speed >= 200:
self.speed = 200
else:
self.speed = 100
self.mouse_control(active=active)
if key[pygame.K_w]:
dx = cos_a * self.delta * self.speed
dy = sin_a * self.delta * self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_s]:
dx = cos_a * self.delta * -self.speed
dy = sin_a * self.delta * -self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_a]:
dx = sin_a * self.delta * self.speed
dy = cos_a * self.delta * -self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_d]:
dx = sin_a * self.delta * -self.speed
dy = cos_a * self.delta * self.speed
self.detect_collision_wall(dx, dy)
def mouse_control(self, active):
if active:
if pygame.mouse.get_focused():
diff = pygame.mouse.get_pos()[0] - half_width
pygame.mouse.set_pos((half_width, half_height))
self.angle += diff * self.delta * self.mouse_sense
Gameplay
We sing the colors on the map, and make it so that the player can take them and paint any blocks. In order to understand whether the character is next to the block or not, we write a tricky chain of conditions:
for blockNow in blockMapTextures:
questBlock = False
if (blockNow[0] - blockSize // 2 < player.x < blockNow[0] + blockSize * 1.5 and blockNow[1] < player.y < blockNow[1] + blockSize) or \
(blockNow[1] - blockSize // 2 < player.y < blockNow[1] + blockSize * 1.5 and blockNow[0] < player.x < blockNow[0] + blockSize):
if countOfDraw < len(blocksActive) and doubleDrawOff:
display.blit(
pg.transform.scale(ui['mouse2'], (ui['mouse2'].get_width() // 2, ui['mouse2'].get_height() // 2)),
(130, 750))
if event.type == pg.MOUSEBUTTONDOWN and pg.mouse.get_pressed()[2]:
if blockMapTextures[blockNow] == '<':
questBlock = True
if questBlock == False:
try:
tempbackup_color.clear()
tempbackup.clear()
coloredBlocks.clear()
block_in_bag.pop(-1)
tempbackup.append(blockMapTextures[blockNow])
tempbackup_color.append(blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]])
print('tempbackup_color : ', tempbackup_color)
blockMapTextures[blockNow] = blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]]
coloredBlocks.append(blockNow)
blocks_draw_avaliable.pop(list(blocks_draw_avaliable.keys())[-1])
countOfDraw += 1
doubleDrawOff = False
doubleBack = False
except:
print('Error in color drawing')
Roughly speaking, we conditionally increase the range of coordinates that one block captures, and constantly see if the player enters these coordinates. Each block, it turns out, has some area around (without corners) the size of several tens of pixels, and when entering it, it is considered that you are near a certain block.
I’m sure there is a better way to find the block near the player, but I decided not to reinvent the wheel and did as I did).
Next, we will implement the quest system and change levels depending on whether the quest is completed or not. And also a level switcher, with a story picture at the beginning of each level.
def lvlSwitch():
settings.textMap = levels.levelsList[str(settings.numOfLvl)]
with open("game/settings/settings.json", 'w') as f:
settings.sett['numL'] = settings.numOfLvl
js.dump(settings.sett, f)
print(settings.numOfLvl)
main.tempbackup.clear()
main.coloredBlocks.clear()
main.blocksActive.clear()
main.tempbackup_color.clear()
main.block_in_bag.clear()
main.blocks_draw_avaliable.clear()
main.countOfDraw = 0
main.blockClickAvaliable = 0
def switcher():
global lvlSwitches
main.display.blit(ui[f'lvl{settings.numOfLvl+1}'], (0,0))
main.timer = False
if pg.key.get_pressed()[pg.K_SPACE]:
level5_quest.clear()
main.doubleQuest = True
settings.numOfLvl += 1
lvlSwitch()
main.timer = True
level5_quest.clear()
lvlSwitches = False
def quest(lvl):
global lvlSwitches
tmp = []
for blockNeed in blockQuest:
if blockQuest[blockNeed] == '@':
if blockMapTextures[blockNeed] == '3':
tmp.append(1)
if settings.numOfLvl == 5:
level5_quest.add(1)
if blockQuest[blockNeed] == '!':
if blockMapTextures[blockNeed] == '2':
tmp.append(2)
if settings.numOfLvl == 5:
level5_quest.add(2)
if blockQuest[blockNeed] == '$':
if blockMapTextures[blockNeed] == '4':
tmp.append(3)
if settings.numOfLvl == 5:
level5_quest.add(3)
if blockQuest[blockNeed] == '%':
if blockMapTextures[blockNeed] == '5':
tmp.append(4)
if settings.numOfLvl == 5:
level5_quest.add(4)
We implement a pair of mechanics:
The first mechanic is to simply put the right color in the right cell. No explanation needed.
The second mechanic – teleportation, a new map is created in the form of a letter and the blocks in it are mixed once in a while, creating a feeling of teleportation of colors.
def randomColorBlockMap(textMap):
timer = t.perf_counter()
text = textMap
newTextMap = []
generatedMap = []
for row in text:
roww = []
for column in row:
roww.append(column)
newTextMap.append(roww)
textsForShuffle = []
for row in text:
for column in row:
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
textsForShuffle.append(column)
xy_original = []
for y, row in enumerate(text):
for x, column in enumerate(row):
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):
xy_original.append([x,y])
xy_tmp = xy_original
for y, row in enumerate(newTextMap):
for x, column in enumerate(row):
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):
ch = rn.choice(textsForShuffle)
newTextMap[y][x] = ch
textsForShuffle.remove(ch)
for row in newTextMap:
generatedMap.append(''.join(row))
initMap(generatedMap)
The third mechanic is to add a black and white filter to each texture.
def toBlack():
settings.textures['2'] = pygame.image.load('textures/colorYellowWallBlack.png').convert()
settings.textures['3'] = pygame.image.load('textures/colorBlueWallBlack.png').convert()
settings.textures['4'] = pygame.image.load('textures/colorRedWallBlack.png').convert()
settings.textures['5'] = pygame.image.load('textures/colorGreenWallBlack.png').convert()
settings.textures['<'] = pygame.image.load('textures/robotBlack.png').convert()
ui['3'] = pygame.image.load("textures/blue_uiBlack.png")
ui['2'] = pygame.image.load("textures/yellow_uiBlack.png")
ui['4'] = pygame.image.load("textures/red_uiBlack.png")
ui['5'] = pygame.image.load("textures/green_uiBlack.png")
Next, I made the menu as a class to conveniently add options when needed.
class Menu:
def __init__(self):
self.option_surface = []
self.callbacks = []
self.current_option_index = 0
def add_option(self, option, callback):
self.option_surface.append(f1.render(option, True, (255, 255, 255)))
self.callbacks.append(callback)
def switch(self, direction):
self.current_option_index = max(0, min(self.current_option_index + direction, len(self.option_surface) - 1))
def select(self):
self.callbacks[self.current_option_index]()
def draw(self, surf, x, y, option_y):
for i, option in enumerate(self.option_surface):
option_rect = option.get_rect()
option_rect.topleft = (x, y + i * option_y)
if i == self.current_option_index:
pg.draw.rect(surf, (0, 100, 0), option_rect)
b = surf.blit(option, option_rect)
pos = pygame.mouse.get_pos()
if b.collidepoint(pos):
self.current_option_index = i
for event in pg.event.get():
if pg.mouse.get_pressed()[0]:
self.select()
We implement saving:
try:
with open("game/settings/settings.json", 'r') as f:
sett = js.load(f)
except:
with open("game/settings/settings.json", 'w') as f:
sett = {
'FOV' : pi / 2,
'numRays' : 400,
'MAPSCALE' : 10,
'numL' : 1,
'mouse_sensivity' : 0.15
}
js.dump(sett, f)
numOfLvl = sett['numL']
textMap = levels.levelsList[str(numOfLvl)]
mouse_sensivity = sett['mouse_sensivity']
And finally, a mini philosophical story with a deep meaning and an unexpected ending.
Conclusion
Here comes a game with 2.5D dimensions, hundreds of rays, low FPS and stupid gameplay, which required only 4 libraries, 68 textures, and 1018 lines of code.
Also, you can always read the full code of this project or download the game from me on GitHub.
I hope I helped you with this article and you found this information somewhat useful. Thank you for your attention <3