Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- That sounds like the classic **Raycasting** engine, the same technique used in the original *Wolfenstein 3D*. Those Javascript snippets you saw likely cast "rays" from the player's eye across a 2D grid, calculating the distance to the nearest wall and drawing a vertical line that grows shorter as the distance increases. It tricks the brain into seeing 3D depth using only text characters.
- To bring that elegance to Ubuntu, we need to control the terminal buffer directly to redraw the frame without the lag of standard output. Python's `curses` library is perfect for this, as it handles terminal input (like arrow keys) and screen rendering natively.
- Here is a complete, self-contained script that recreates that specific vibe. I've tuned the wall rendering to use characters that vary based on depth, giving you that "dots and slashes" perspective shift you remember.
- ### The Code (maze_3d.py)
- Save this file as `maze_3d.py` and run it with `python3 maze_3d.py`.
- ```python
- import curses
- import math
- import time
- import sys
- # Configuration
- MAP_SIZE = 30
- SCREEN_WIDTH = 80
- SCREEN_HEIGHT = 40
- FOV = math.pi / 3
- SPEED = 0.05
- ROTATION_SPEED = 0.03
- # 2D Map: 1 = Wall, 0 = Empty
- # A simple square maze
- MAZE = [
- [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
- [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1],
- [1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1],
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
- [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
- [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1],
- [1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1],
- [1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1],
- [1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1],
- [1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1],
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1],
- [1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1],
- [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1],
- [1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
- [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- ]
- # Wall texture characters based on face type
- WALL_CHARS = ['#', '|', '/', '\\', '.', 'o', '=', ' ']
- class Raycaster:
- def __init__(self, stdscr, maze):
- self.screen = stdscr
- self.maze = maze
- self.x = 5
- self.y = 5
- self.angle = 0
- self.draw()
- def move(self, direction):
- # Basic collision detection
- dx = math.cos(self.angle) * SPEED
- dy = math.sin(self.angle) * SPEED
- next_x = self.x + dx * direction
- next_y = self.y + dy * direction
- # Check grid bounds and walls
- mx = int(next_x)
- my = int(next_y)
- if 0 < mx < MAP_SIZE - 1 and 0 < my < MAP_SIZE - 1:
- if self.maze[my][mx] == 0:
- self.x, self.y = next_x, next_y
- def rotate(self, delta):
- self.angle += delta
- def cast_rays(self):
- # For each column in the terminal
- for col in range(SCREEN_WIDTH):
- # Calculate ray angle for this column
- # FOV is pi/3, so we spread from -FOV/2 to +FOV/2
- ray_angle = (self.angle + (col / SCREEN_WIDTH - 0.5) * FOV)
- # Normalize angle to 0-2pi
- ray_angle %= 2 * math.pi
- # Step ray forward until it hits a wall
- distance = 0.0
- hit = False
- # Using step size based on distance for performance
- # Small steps are more accurate, large are faster.
- # 0.05 is a good balance for text rendering.
- step = 0.05
- while not hit and distance < 50:
- distance += step
- # Check the grid index
- check_x = int(self.x + math.cos(ray_angle) * distance)
- check_y = int(self.y + math.sin(ray_angle) * distance)
- if 0 <= check_y < MAP_SIZE and 0 <= check_x < MAP_SIZE:
- if self.maze[check_y][check_x] == 1:
- hit = True
- break
- if hit:
- # Correct for fisheye effect
- # Distance needs to be divided by the cosine of the difference
- # between the ray angle and the player's viewing angle
- corrected_dist = distance * math.cos(ray_angle - self.angle)
- # Calculate wall height on screen
- # Closer walls = taller lines
- # Avoid division by zero
- wall_height = min(SCREEN_HEIGHT, int((SCREEN_HEIGHT * MAP_SIZE) / corrected_dist))
- # Calculate top position
- top = int((SCREEN_HEIGHT - wall_height) / 2)
- # Pick character based on distance and angle
- # Creates the "dots and slashes" texture variation
- char_idx = int(distance) % len(WALL_CHARS)
- char = WALL_CHARS[char_idx]
- # Draw the vertical line for this column
- for row in range(top, top + wall_height):
- self.screen.addch(row, col, char)
- def draw(self):
- # Clear screen
- self.screen.clear()
- # Render the rays
- self.cast_rays()
- # Draw player arrow
- # Just a static indicator at the bottom center roughly
- # self.screen.addch(SCREEN_HEIGHT - 1, SCREEN_WIDTH // 2, '>')
- self.screen.refresh()
- time.sleep(0.02)
- def main(stdscr):
- # Setup curses
- curses.curs_set(0) # Hide cursor
- stdscr.nodelay(True) # Non-blocking input
- stdscr.clear()
- # Center the screen logic
- y, x = stdscr.getmaxyx()
- # Initialize raycaster
- rc = Raycaster(stdscr, MAZE)
- running = True
- while running:
- # Check input
- key = stdscr.getch()
- if key == curses.KEY_UP:
- rc.move(1)
- elif key == curses.KEY_DOWN:
- rc.move(-1)
- elif key == curses.KEY_LEFT:
- rc.rotate(-ROTATION_SPEED)
- elif key == curses.KEY_RIGHT:
- rc.rotate(ROTATION_SPEED)
- elif key == ord('q'):
- running = False
- elif key == ord('p'):
- rc.x = 5
- rc.y = 5
- rc.angle = 0
- # Update display
- rc.draw()
- if __name__ == "__main__":
- curses.wrapper(main)
- ```
- ### The Math Behind the Illusion
- You mentioned you got into coding because of the elegance of this logic. The "magic" here is purely trigonometry and geometry. Here is the breakdown of the calculation used in `cast_rays()`:
- 1. **The Field of View (FOV):**
- We want the player to see a cone. We define this angle mathematically. If our FOV is \( \frac{\pi}{3} \) (60 degrees), we need to divide that cone into \( SCREEN\_WIDTH \) slices. For each column \( col \), we calculate the specific angle:
- \[
- \theta_{ray} = \theta_{player} + \left(\frac{col}{SCREEN\_WIDTH} - 0.5\right) \times FOV
- \]
- This ensures the rays spread out correctly from the center of the player's vision.
- 2. **Fisheye Correction:**
- When casting rays, a ray shooting straight ahead at a wall is shorter than a ray shooting at a 45-degree angle to a wall that is geometrically "parallel" to the center. To make the walls look flat and not like a wide-angle lens, we must correct the distance. We divide the raw hit distance by the cosine of the angle difference:
- \[
- D_{corrected} = \frac{D_{raw}}{\cos(\theta_{ray} - \theta_{player})}
- \]
- 3. **Wall Height Projection:**
- This is a simple similar triangle problem. We know the map unit size and the screen size. As the distance \( D_{corrected} \) gets smaller, the wall height \( H \) gets larger.
- \[
- H = \frac{C}{D_{corrected}}
- \]
- Where \( C \) is a constant scaling factor to map units to pixels.
- 4. **Texture Generation:**
- By using the modulo operator on the distance `int(distance) % len(WALL_CHARS)`, we cycle through different ASCII characters (`#`, `|`, `/`, `\`) as the ray gets closer or further away. This creates the illusion of different lighting or wall textures without needing an actual 2D texture map.
- ### How to Run on Ubuntu
- 1. Open your terminal.
- 2. Install Python if not already installed (usually `sudo apt install python3`).
- 3. Copy the code above into a file named `maze_3d.py`.
- 4. Run it:
- ```bash
- python3 maze_3d.py
- ```
- 5. **Controls:**
- * **Up/Down Arrow**: Move forward/backward.
- * **Left/Right Arrow**: Turn.
- * **P**: Reset position.
- * **Q**: Quit.
- This should bring back that spark. It's raw, efficient, and relies on math rather than heavy graphics libraries. Enjoy the trip back to the terminal days!
Advertisement
Add Comment
Please, Sign In to add comment