Ray Casting 3D game in Python + PyGame
Introduction
We all remember the old games that first introduced the 3D dimension.
The founder of 3D games was the game wolfenstein 3d, released in 1992
and after her Doom 1993 of the year.
These two games were developed by the same company: ID Software
She created her own engine specifically for this game, and the result was a 3D game, which was considered almost impossible at that time.
But what happens if I say that this is not a 3D game, but just a simulation and the game actually looks something like this?
In fact, technology is used here Ray Castingthe third dimension simply does not exist here.
What is this very RayCasting, which is relevant even in our times, but is already used not for games, but for ray tracing technology in modern games.
If translated into Russian, then:
Ray casting method(Ray Casting) – one of the rendering methods in computer graphics, in which the scene is built based on measurements of the intersection of rays with the rendered surface.
I was wondering how difficult it is to implement.
And I set to writing technology RayCasting.
I will do it in conjunction 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 using symbols to separate when drawing where is the block and where is the 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 this result:
Next, we need to represent our map as a grid. And in the entire interval of the viewing angle, throw a certain number of 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 size and stops its movement, then the loop goes to the next ray.
You also need to calculate the distance to the vertical and horizontal lines with which the beam intersected.
We recall school trigonometry and consider this using the example of vertical lines
We know the side k is the player’s distance to the block
a is the angle of each beam
Next, we just add the length since we know the size of our grid box.
And when the beam hits the wall, the loop will stop.
Then apply this to all axes with minor changes
For horizontal lines, the same thing only with a sine.
In the distance of the beam, we write the horizontal or vertical distance, depending on what is closer
We add a couple of variables for height, depth, size, which 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 beam number, and the height will be equal to the ratio of the given coefficient to the beam length.
#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 now it turns out to be some kind of illusion of a 3D dimension.
Textures
1 block has 4 sides and each should 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 onto a strip on the block.
So the width will vary depending on the distance of the side of the block. And the position of the strip is calculated by multiplying the padding by the size of the texture.
If the beam falls on the vertical, then the indent 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 ability to draw several textures on one map by adding special characters to the map, each will be assigned its own texture.
Here is an example of what the 2nd level looks like in the game as a 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 …
Adding a collision. We add a so-called collider to each block position and add the same collider to the player. If it continues to go as it was going and at such a pace on the next frame, according to the prediction, it enters the block, then we simply zero out the acceleration along the desired axis.
To do this, let’s add a class Player. I decided to immediately add camera control with the mouse. This is how the 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
Spawn 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 look to see if the player enters these coordinates. Each block, it turns out, has a certain area around (without corners) a few tens of pixels in size, and when you enter it, it is considered that you are next to a certain block.
I’m sure there is a better way to detect a block next to the player, but I decided not to invent the wheel and did as I did).
Next, we implement a quest system and change levels depending on whether the quest is completed or not. As well as a level switch, with a picture for the plot 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 couple of mechanics:
The first mechanic is to simply put the right color in the right cell. No explanation required.
The second mechanic – teleportation – a new map is created in the form of a sheet and the blocks in it are mixed once in a while, a feeling of teleportation of colors is created.
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 adding a B&W 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()
Implementing saves:
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 in conclusion, a mini philosophical story with a deep meaning and an unexpected ending.
Conclusion
So we get a game with 2.5D dimension, hundreds of rays, low FPS and uncomplicated gameplay, which required only 4 libraries, 68 textures, and 1018 lines of code.
Also, you can always get acquainted with the full code of this project or download the game from my github.
I hope this article helped you in some way and you found this information useful to some extent. Thank you for your attention <3