Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
db2832c
Simplifing FixAng function
MxPerrot Feb 25, 2024
59a93ad
fix: looking downard had no light collisions
MxPerrot Mar 18, 2025
4493431
backup: move old ray file for backup
MxPerrot Mar 18, 2025
4e4eb58
chore: update gitignore
MxPerrot Mar 18, 2025
7aaaa83
feat: use OOP to support multi player
MxPerrot Mar 18, 2025
0d9c82c
feat: add local multiplayer (separate windows)
MxPerrot Mar 18, 2025
761bccc
feat: add server/client communication
MxPerrot Mar 18, 2025
f23d77f
fix: players desynchronised when over 2 connected
MxPerrot Mar 18, 2025
5da4a7c
feat: make map server-side
MxPerrot Mar 19, 2025
159417e
docs: potential bug comment
MxPerrot Mar 19, 2025
80e7805
chore: delete old/unused files
MxPerrot Mar 19, 2025
afa4a5a
feat: made map savable/loadable
MxPerrot Mar 19, 2025
3808fc4
add some maps
MxPerrot Mar 19, 2025
6046084
docs: add TODO file
MxPerrot Mar 19, 2025
62b0d1d
docs: update README.md & TODO
MxPerrot Mar 19, 2025
e3ff618
feat: add sky/ground and colorMap to map
MxPerrot Mar 19, 2025
2cd6006
chore: update LICENSE
MxPerrot Mar 19, 2025
11ae268
logs: comment useless prints
MxPerrot Mar 19, 2025
c4e68a8
feat: working face direction shading (gray only)
MxPerrot Mar 19, 2025
f1882e6
feat: graphics: shading depends on distance & face
MxPerrot Mar 19, 2025
1058ad2
build: package multiplayer classes
MxPerrot Mar 19, 2025
f5a7452
build: fix multiplayer package import
MxPerrot Mar 19, 2025
b57b46c
feat: add menu for map choice before server start
MxPerrot Mar 19, 2025
3cfaf2a
feat: MapEditor: initial commit for the map editor
MxPerrot Mar 19, 2025
3f6b966
feat: MapEditor: add color pickers for textures
MxPerrot Mar 19, 2025
fadcc55
feat: MapEditor: improve texture picker
MxPerrot Mar 20, 2025
bfe540b
feat(maps): new maps
MxPerrot Mar 20, 2025
bb6b4bc
feat!: adding spawnpoint to maps & MapEditor
MxPerrot Mar 20, 2025
bd001fd
feat(map/renderer): add multiple tile types
MxPerrot Mar 20, 2025
4e25bde
new map
MxPerrot Mar 20, 2025
c7c0664
feat: static minimap size & map getters
MxPerrot Mar 20, 2025
ba1ffb7
feat(renderer): add dynamic screen size
MxPerrot Mar 21, 2025
2dd1cc6
remove default map creation in Map.py
MxPerrot Mar 21, 2025
6d4d2ec
feat(player): add config file for render/keybinds
MxPerrot Mar 21, 2025
a9c314d
fix: multiplayer minimap followed old behavior
MxPerrot Mar 21, 2025
f224446
new map
MxPerrot Mar 21, 2025
b2d7d7f
feat(minimap): add map opacity option
MxPerrot Mar 21, 2025
20c856c
feat(logging): add logging system
MxPerrot Mar 22, 2025
10cd8fc
feat(network): add server shutdown sig detection
MxPerrot Mar 23, 2025
3e7876f
update maps
MxPerrot Mar 23, 2025
3f5b641
fix(network): connectionResetError not recognised
MxPerrot Mar 23, 2025
17ed899
update TODO
MxPerrot Mar 23, 2025
a351dfb
chore: rename .env to .env.example
MxPerrot Mar 23, 2025
38f39bd
update .gitignore
MxPerrot Mar 25, 2025
3dd885e
remove useless import
MxPerrot Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# === GRAPHIC OPTIONS ===

# - RENDERING OPTIONS -
RAYCASTER_FOV=90 # Field Of View in degrees (°) Keep between 60 and 110 for better rendering
RAYCASTER_NUM_RAYS=200 # Resolution (the lower, the faster)
RAYCASTER_MAX_DISTANCE=500 # For distance shading,

# - MINIMAP -
MAP_MINIMAP_SIZE=12
MAP_MINIMAP_OPACITY=210 # range from 0 to 255

# === CONTROLS ===

# KEY BINDS
KEY_FORWARD=z
KEY_LEFT=q
KEY_BACKWARD=s
KEY_RIGHT=d

# === DEV OPTIONS ===

# LOGGER
LOG_LEVEL=INFO
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__
temp/
*.log
env
.env
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2020 8KSpaceInvader
Copyright (c) 2025 MxPerrot

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
# A Python raycasting engine made in openGL
This ia a simple python raycasting engine made in python.
# Raycaster2

This is a python raycasting game based on 8KSpaceInvader's [pythonRaycaster](https://github.com/ARYANTECH123/pythonRaycaster)

I've transformed it using OOP to make a multiplayer server/client based game.

I'm not yet sure what to do as game content, anything is possible from there, i'm thinking of a co-op maze game.

# Installation

To run it, you must have Python3 and PyOpenGL.

Python can be installed from the [website](python.org)

To install PyOpenGL, you can go to the [documentaion](http://pyopengl.sourceforge.net/documentation/index.html)

Once both are ready and configured, download or clone `ray.py` and use `python ray.py` to run it.

## Running the code
Once the program starts, you'll see 2 views : A 2D map with the player and all the rays, and a 3d, raycasted projection. Use WASD to move, and Q to teleport.
# Running the game

Once both are ready and configured, download or clone the project and run the server (`python3 server_network.py`) and then a client (`python3 main.py`).

Once the program starts, you'll see 2 views : A 2D map with the player, and a 3d, raycasted projection.

## Playing with the code
Use ZQSD to move (I'm using a french azerty keyboard, sorry, I'll later use automatic detection of key layout and/or easy keybindings ui).

The algorithm, though it may appear complex, is actually really simple and fast, and hence was used on many classic games like Wolfenstien 3D, to great effect on the limited hardware of the time. If you want to learn more about the algorithm, [this guide is a excellant starting point](https://permadi.com/1996/05/ray-casting-tutorial-1/#INTRODUCTION).
You can run as many clients as you wish. Other clients will render on the minimap as a green dot.

In order to change the map, simploy edit the 64 element long `world` list. it is formatted so thet a `0` is a space and a `1` is a block.
## About Raycasting

The algorithm, though it may appear complex, is actually really simple and fast, and hence was used on many classic games like Wolfenstien 3D, to great effect on the limited hardware of the time.

It consists of shooting rays from the camera until they intersect an object; and then draw a vertical line on the screen depending on the distance reached by the ray. This results

If you want to learn more about the algorithm, [this guide is a excellent starting point](https://permadi.com/1996/05/ray-casting-tutorial-1/#INTRODUCTION).

## Future additions

You can see planned additions in the [TODO](./TODO) file.
Currently I plan on making
- UI for map management/edition and key bindings
- Map generation (maybe mazelik/terrainlike)
- Game mechanics (not yet determined)
- Improve graphics
- Use texture mapping

## Screenshots
[img](https://github.com/ARYANTECH123/pythonRaycaster/blob/main/image.png)
![8kSpaceInvader's old version](https://github.com/ARYANTECH123/pythonRaycaster/blob/main/image.png)

![My version (IMAGE TO COME)]()
2 changes: 2 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[ ] Use texture mapping
[ ] Multiplayer: render other players
Binary file removed image.png
Binary file not shown.
64 changes: 64 additions & 0 deletions logger/CustomFormatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import logging
from logging.handlers import TimedRotatingFileHandler
from dotenv import load_dotenv
import os
import datetime

load_dotenv()

LOG_LEVEL = os.getenv('LOG_LEVEL','INFO')
LOG_FOLDER = os.path.join(os.getcwd(), "logs")
os.makedirs(LOG_FOLDER, exist_ok=True)

class CustomFormatter(logging.Formatter):
cyan = "\x1b[36;20m"
grey = "\x1b[38;20m"
yellow = "\x1b[33;20m"
red = "\x1b[31;20m"
bold_red = "\x1b[31;1m"
reset = "\x1b[0m"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)"

FORMATS = {
logging.DEBUG: cyan + format + reset,
logging.INFO: grey + format + reset,
logging.WARNING: yellow + format + reset,
logging.ERROR: red + format + reset,
logging.CRITICAL: bold_red + format + reset
}

def format(self, record):
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)

def get_logger(name: str, level=logging.INFO):
logger = logging.getLogger(name)
if not logger.handlers: # Avoid duplicate handlers

# Console handler (with colors)
console_handler = logging.StreamHandler()
console_handler.setFormatter(CustomFormatter())
logger.addHandler(console_handler)

# Get today's date in ISO 8601 format (YYYY-MM-DD)
date_str = datetime.date.today().isoformat()
log_file_path = os.path.join(LOG_FOLDER, f"{date_str}.log")

# File handler with daily rotation (even if we don't auto-rotate here, keeps it flexible)
file_handler = TimedRotatingFileHandler(
log_file_path,
when="midnight", # Rotate at midnight
interval=1,
backupCount=7, # Keep last 7 logs (optional)
encoding='utf-8'
)
file_formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)"
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)

logger.setLevel(level)

return logger
1 change: 1 addition & 0 deletions logger/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .CustomFormatter import get_logger
161 changes: 161 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
###############################################################################
# IMPORTS #
###############################################################################
#from alive_progress import alive_bar
from logger import get_logger
from raycaster import Map, Player, Renderer
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *
from multiplayer import ClientNetwork
import time
import threading
from dotenv import load_dotenv
import os


###############################################################################
# CONSTANTS #
###############################################################################

log = get_logger(__name__)

log.info("imports successful")

load_dotenv()

log.info("environment loaded")

def get_env_int(key, default):
try:
return int(os.getenv(key, default))
except ValueError:
return default

def get_env_str(key, default):
return os.getenv(key, default) # is str by default

FOV = get_env_int("RAYCASTER_FOV", 80)
NUM_RAYS = get_env_int("RAYCASTER_NUM_RAYS",120)
MAX_DISTANCE = get_env_int("RAYCASTER_MAX_DISTANCE",500)
KEY_FORWARD = get_env_str("KEY_FORWARD",'w')
KEY_LEFT = get_env_str("KEY_LEFT",'a')
KEY_BACKWARD = get_env_str("KEY_BACKWARD",'s')
KEY_RIGHT = get_env_str("KEY_RIGHT",'d')
MAP_MINIMAP_SIZE = get_env_int("MAP_MINIMAP_SIZE",4)
MAP_MINIMAP_OPACITY = get_env_int("MAP_MINIMAP_OPACITY",128)


###############################################################################
# INITIALIZATION #
###############################################################################

# === NETWORK INITIALIZATION ===
network = ClientNetwork() # Connects to server at localhost:5555 by default

# === GAME INITIALIZATION ===
# Wait for map data
log.info("Waiting for map...")
while network.map_data is None:
continue
log.info("Received map")

map_info = network.map_data
map_obj = Map(map_info['grid'], map_info['mapX'], map_info['mapY'], map_info['mapS'], map_info["colorMap"], map_info["spawnpoint"])

# You can adjust keybindings per client instance
key_bindings = {'FORWARD': KEY_FORWARD, 'BACKWARD': KEY_BACKWARD, 'LEFT': KEY_LEFT, 'RIGHT': KEY_RIGHT}
player = Player(map_info["spawnpoint"][0], map_info["spawnpoint"][1], 90, key_bindings, map_obj)

renderer = Renderer(map_obj=map_obj, fov=FOV, num_rays=NUM_RAYS, max_distance=MAX_DISTANCE, minimap_size=MAP_MINIMAP_SIZE, minimap_opacity=MAP_MINIMAP_OPACITY) # Local player only for now

last_time = time.time()


# === GLUT INITIALIZATION ===
glutInit()
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB)
glutInitWindowSize(1024, 512)
glutCreateWindow(b"Networked Raycaster Client")

glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

glutReshapeFunc(renderer.reshape)


# === INPUT HANDLERS ===
def keyboard_down(key, x, y):
try:
key_char = key.decode().lower()
if key_char in player.key_bindings.values():
player.keys_pressed.add(key_char)
except UnicodeDecodeError:
pass

def keyboard_up(key, x, y):
try:
key_char = key.decode().lower()
if key_char in player.keys_pressed:
player.keys_pressed.remove(key_char)
except UnicodeDecodeError:
pass


# === DISPLAY FUNCTION ===
def display():
if not network.running:
log.warning("Server disconnected. Exiting client.")
os._exit(0) # Force exit immediately (no need to keep GLUT running)

global last_time
current_time = time.time()
delta_time = current_time - last_time
last_time = current_time

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

# Move local player
player.move(delta_time)

# Send local player update to server
network.send_player_update(player.px, player.py, player.pa)

# Draw local scene
renderer.draw_scene(player)

# Draw remote players (optional, if desired)
for remote_id, remote_data in network.players_state.items():
# Skip rendering ourself
if remote_id == str(network.my_id): # IDs are string keys after JSON
continue
px = int(remote_data['px'])
py = int(remote_data['py'])
pa = int(remote_data['pa'])
renderer.draw_minimap_player((px,py,pa))

glutSwapBuffers()

log.debug(f"Rendering players_state: {network.players_state}")


# === GLUT INITIALIZATION PART 2 ===

glClearColor(0.3, 0.3, 0.3, 0)
gluOrtho2D(0, 1024, 512, 0)

glutKeyboardFunc(keyboard_down)
glutKeyboardUpFunc(keyboard_up)

glutDisplayFunc(display)
glutIdleFunc(display)

# === CLEANUP ===
def on_close():
network.close()

import atexit
atexit.register(on_close)

# === MAIN LOOP ===
glutMainLoop()
Loading