372 lines
13 KiB
Python
372 lines
13 KiB
Python
|
import pygame
|
||
|
import sys
|
||
|
import math
|
||
|
import random
|
||
|
|
||
|
# -----------------------------
|
||
|
# World and Pygame initialization
|
||
|
# -----------------------------
|
||
|
pygame.init()
|
||
|
SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
|
||
|
WORLD_WIDTH, WORLD_HEIGHT = 2000, 2000 # Larger world
|
||
|
BLOCK_SIZE = 40
|
||
|
|
||
|
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
|
||
|
pygame.display.set_caption("2D Voxel Game with Debug View Frustum & BVH")
|
||
|
clock = pygame.time.Clock()
|
||
|
font = pygame.font.Font(None, 20)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Global Options
|
||
|
# -----------------------------
|
||
|
debug_mode = False # Toggle debug overlay (F3)
|
||
|
FOV = 90 # Field of view in degrees for ray casting
|
||
|
num_rays = 360 # Number of rays cast within the FOV
|
||
|
max_distance = 1000 # Maximum ray distance
|
||
|
ray_intersect_count = 0 # Reset each frame
|
||
|
|
||
|
# -----------------------------
|
||
|
# Voxel World Generation (each block gets a random color)
|
||
|
# -----------------------------
|
||
|
def generate_blocks():
|
||
|
blocks = []
|
||
|
cols = WORLD_WIDTH // BLOCK_SIZE
|
||
|
rows = WORLD_HEIGHT // BLOCK_SIZE
|
||
|
for i in range(cols):
|
||
|
for j in range(rows):
|
||
|
# 20% chance to place a block in this grid cell.
|
||
|
if random.random() < 0.2:
|
||
|
rect = pygame.Rect(i * BLOCK_SIZE, j * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE)
|
||
|
color = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255))
|
||
|
blocks.append((rect, color))
|
||
|
return blocks
|
||
|
|
||
|
blocks = generate_blocks()
|
||
|
|
||
|
# -----------------------------
|
||
|
# BVH Data Structures and Build
|
||
|
# -----------------------------
|
||
|
class BVHNode:
|
||
|
def __init__(self, bbox, left=None, right=None, block=None):
|
||
|
self.bbox = bbox # (min_x, min_y, max_x, max_y)
|
||
|
self.left = left
|
||
|
self.right = right
|
||
|
self.block = block # For leaf nodes, store the block (tuple: (pygame.Rect, color))
|
||
|
|
||
|
def rect_to_bbox(block):
|
||
|
rect = block[0] if isinstance(block, tuple) else block
|
||
|
return (rect.left, rect.top, rect.right, rect.bottom)
|
||
|
|
||
|
def union_bbox(b1, b2):
|
||
|
return (min(b1[0], b2[0]), min(b1[1], b2[1]),
|
||
|
max(b1[2], b2[2]), max(b1[3], b2[3]))
|
||
|
|
||
|
def build_bvh(block_list):
|
||
|
if not block_list:
|
||
|
return None
|
||
|
if len(block_list) == 1:
|
||
|
return BVHNode(rect_to_bbox(block_list[0]), block=block_list[0])
|
||
|
block_list.sort(key=lambda block: block[0].centerx)
|
||
|
mid = len(block_list) // 2
|
||
|
left = build_bvh(block_list[:mid])
|
||
|
right = build_bvh(block_list[mid:])
|
||
|
if left and right:
|
||
|
bbox = union_bbox(left.bbox, right.bbox)
|
||
|
elif left:
|
||
|
bbox = left.bbox
|
||
|
else:
|
||
|
bbox = right.bbox
|
||
|
return BVHNode(bbox, left, right)
|
||
|
|
||
|
bvh_root = build_bvh(blocks)
|
||
|
|
||
|
# -----------------------------
|
||
|
# BVH Statistics Functions
|
||
|
# -----------------------------
|
||
|
def get_bvh_stats(node):
|
||
|
if node is None:
|
||
|
return (0, 0)
|
||
|
if node.block is not None:
|
||
|
return (1, 1)
|
||
|
left_count, left_depth = get_bvh_stats(node.left)
|
||
|
right_count, right_depth = get_bvh_stats(node.right)
|
||
|
total = 1 + left_count + right_count
|
||
|
depth = 1 + max(left_depth, right_depth)
|
||
|
return (total, depth)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Ray-AABB Intersection (Slab Method)
|
||
|
# -----------------------------
|
||
|
def ray_intersect_aabb(origin, direction, bbox):
|
||
|
global ray_intersect_count
|
||
|
ray_intersect_count += 1
|
||
|
|
||
|
tmin = -math.inf
|
||
|
tmax = math.inf
|
||
|
ox, oy = origin
|
||
|
dx, dy = direction
|
||
|
|
||
|
# X slab
|
||
|
if dx != 0:
|
||
|
tx1 = (bbox[0] - ox) / dx
|
||
|
tx2 = (bbox[2] - ox) / dx
|
||
|
tmin = max(tmin, min(tx1, tx2))
|
||
|
tmax = min(tmax, max(tx1, tx2))
|
||
|
else:
|
||
|
if not (bbox[0] <= ox <= bbox[2]):
|
||
|
return None
|
||
|
|
||
|
# Y slab
|
||
|
if dy != 0:
|
||
|
ty1 = (bbox[1] - oy) / dy
|
||
|
ty2 = (bbox[3] - oy) / dy
|
||
|
tmin = max(tmin, min(ty1, ty2))
|
||
|
tmax = min(tmax, max(ty1, ty2))
|
||
|
else:
|
||
|
if not (bbox[1] <= oy <= bbox[3]):
|
||
|
return None
|
||
|
|
||
|
if tmax >= tmin and tmax >= 0:
|
||
|
return tmin if tmin >= 0 else tmax
|
||
|
return None
|
||
|
|
||
|
# -----------------------------
|
||
|
# BVH Ray Casting
|
||
|
# -----------------------------
|
||
|
def ray_cast_bvh(node, origin, direction):
|
||
|
if node is None:
|
||
|
return None, None
|
||
|
t_bbox = ray_intersect_aabb(origin, direction, node.bbox)
|
||
|
if t_bbox is None:
|
||
|
return None, None
|
||
|
|
||
|
if node.block is not None:
|
||
|
t_hit = ray_intersect_aabb(origin, direction, rect_to_bbox(node.block))
|
||
|
if t_hit is not None:
|
||
|
return t_hit, node.block
|
||
|
else:
|
||
|
return None, None
|
||
|
|
||
|
t_left, block_left = ray_cast_bvh(node.left, origin, direction)
|
||
|
t_right, block_right = ray_cast_bvh(node.right, origin, direction)
|
||
|
|
||
|
if t_left is not None and t_right is not None:
|
||
|
return (t_left, block_left) if t_left < t_right else (t_right, block_right)
|
||
|
elif t_left is not None:
|
||
|
return t_left, block_left
|
||
|
elif t_right is not None:
|
||
|
return t_right, block_right
|
||
|
return None, None
|
||
|
|
||
|
# -----------------------------
|
||
|
# Single Ray Casting (No Bouncing)
|
||
|
# -----------------------------
|
||
|
def cast_ray_single(origin, direction):
|
||
|
t, hit_block = ray_cast_bvh(bvh_root, origin, direction)
|
||
|
if t is None or t > max_distance:
|
||
|
return None
|
||
|
hit_point = (origin[0] + direction[0] * t,
|
||
|
origin[1] + direction[1] * t)
|
||
|
block_color = hit_block[1] if isinstance(hit_block, tuple) else (255, 255, 255)
|
||
|
return (hit_point, block_color)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Persistent Light Map Setup (World-Sized Surface with Per-Pixel Alpha)
|
||
|
# -----------------------------
|
||
|
persistent_light_map = pygame.Surface((WORLD_WIDTH, WORLD_HEIGHT), pygame.SRCALPHA)
|
||
|
persistent_light_map.fill((0, 0, 0, 255)) # Start fully black (opaque)
|
||
|
|
||
|
def fade_light_map():
|
||
|
# Multiply each pixel by ~254/255 (about 0.99608) per frame.
|
||
|
persistent_light_map.fill((254, 254, 254, 254), special_flags=pygame.BLEND_RGBA_MULT)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Update the Light Map from the Current Light Source within a FOV
|
||
|
# -----------------------------
|
||
|
def update_light_map(light_pos, base_angle):
|
||
|
angle_step = FOV / num_rays
|
||
|
for i in range(num_rays):
|
||
|
angle = base_angle - FOV / 2 + i * angle_step
|
||
|
rad = math.radians(angle)
|
||
|
direction = (math.cos(rad), math.sin(rad))
|
||
|
result = cast_ray_single(light_pos, direction)
|
||
|
if result is not None:
|
||
|
hit_point, hit_color = result
|
||
|
persistent_light_map.set_at((int(hit_point[0]), int(hit_point[1])), hit_color)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Debug Draw Functions
|
||
|
# -----------------------------
|
||
|
def draw_bvh(node, surface, cam_offset):
|
||
|
if node is None:
|
||
|
return
|
||
|
x, y, x2, y2 = node.bbox
|
||
|
rect = pygame.Rect(x - cam_offset[0], y - cam_offset[1], x2 - x, y2 - y)
|
||
|
color = (0, 255, 0) if node.block is not None else (255, 0, 0)
|
||
|
pygame.draw.rect(surface, color, rect, 1)
|
||
|
draw_bvh(node.left, surface, cam_offset)
|
||
|
draw_bvh(node.right, surface, cam_offset)
|
||
|
|
||
|
def draw_view_frustum(light_pos, base_angle, cam_offset):
|
||
|
# Compute left and right boundary rays
|
||
|
left_angle = math.radians(base_angle - FOV / 2)
|
||
|
right_angle = math.radians(base_angle + FOV / 2)
|
||
|
end_left = (light_pos[0] + math.cos(left_angle) * max_distance,
|
||
|
light_pos[1] + math.sin(left_angle) * max_distance)
|
||
|
end_right = (light_pos[0] + math.cos(right_angle) * max_distance,
|
||
|
light_pos[1] + math.sin(right_angle) * max_distance)
|
||
|
# Convert to screen coords
|
||
|
lp_screen = (light_pos[0] - cam_offset[0], light_pos[1] - cam_offset[1])
|
||
|
el_screen = (end_left[0] - cam_offset[0], end_left[1] - cam_offset[1])
|
||
|
er_screen = (end_right[0] - cam_offset[0], end_right[1] - cam_offset[1])
|
||
|
# Draw boundary rays
|
||
|
pygame.draw.line(screen, (0, 255, 255), lp_screen, el_screen, 1)
|
||
|
pygame.draw.line(screen, (0, 255, 255), lp_screen, er_screen, 1)
|
||
|
# Optionally draw the frustum polygon
|
||
|
pygame.draw.polygon(screen, (0, 255, 255, 50), [lp_screen, el_screen, er_screen], 1)
|
||
|
|
||
|
def debug_draw_rays(light_pos, base_angle, cam_offset):
|
||
|
angle_step = FOV / num_rays
|
||
|
for i in range(num_rays):
|
||
|
angle = base_angle - FOV / 2 + i * angle_step
|
||
|
rad = math.radians(angle)
|
||
|
direction = (math.cos(rad), math.sin(rad))
|
||
|
result = cast_ray_single(light_pos, direction)
|
||
|
if result is not None:
|
||
|
hit_point, _ = result
|
||
|
start = (light_pos[0] - cam_offset[0], light_pos[1] - cam_offset[1])
|
||
|
end = (hit_point[0] - cam_offset[0], hit_point[1] - cam_offset[1])
|
||
|
else:
|
||
|
# No hit; extend ray to max_distance.
|
||
|
end_pt = (light_pos[0] + direction[0] * max_distance,
|
||
|
light_pos[1] + direction[1] * max_distance)
|
||
|
start = (light_pos[0] - cam_offset[0], light_pos[1] - cam_offset[1])
|
||
|
end = (end_pt[0] - cam_offset[0], end_pt[1] - cam_offset[1])
|
||
|
pygame.draw.line(screen, (255, 255, 255), start, end, 1)
|
||
|
|
||
|
# -----------------------------
|
||
|
# Advanced Debug Menu (No Background)
|
||
|
# -----------------------------
|
||
|
def draw_debug_menu(surface, fps):
|
||
|
bvh_node_count, bvh_depth = get_bvh_stats(bvh_root)
|
||
|
debug_lines = [
|
||
|
"DEBUG MODE ACTIVE",
|
||
|
f"FPS: {fps:.1f}",
|
||
|
f"Blocks: {len(blocks)}",
|
||
|
f"BVH Nodes: {bvh_node_count}",
|
||
|
f"BVH Depth: {bvh_depth}",
|
||
|
f"Player Pos: ({player_rect.centerx}, {player_rect.centery})",
|
||
|
f"Rays Cast (FOV): {num_rays}",
|
||
|
f"Ray Intersection Tests: {ray_intersect_count}",
|
||
|
f"Avg Tests per Ray: {ray_intersect_count / num_rays:.2f}",
|
||
|
"Toggles: F3 - Debug, R - Regenerate World"
|
||
|
]
|
||
|
y_offset = 10
|
||
|
for line in debug_lines:
|
||
|
text_surf = font.render(line, True, (255, 255, 255))
|
||
|
surface.blit(text_surf, (10, y_offset))
|
||
|
y_offset += text_surf.get_height() + 2
|
||
|
|
||
|
# -----------------------------
|
||
|
# Player Setup and Collision
|
||
|
# -----------------------------
|
||
|
player_speed = 4
|
||
|
player_rect = pygame.Rect(WORLD_WIDTH // 2, WORLD_HEIGHT // 2, 20, 20)
|
||
|
|
||
|
def move_player(dx, dy):
|
||
|
player_rect.x += dx
|
||
|
for block, _ in blocks:
|
||
|
if player_rect.colliderect(block):
|
||
|
if dx > 0:
|
||
|
player_rect.right = block.left
|
||
|
elif dx < 0:
|
||
|
player_rect.left = block.right
|
||
|
player_rect.y += dy
|
||
|
for block, _ in blocks:
|
||
|
if player_rect.colliderect(block):
|
||
|
if dy > 0:
|
||
|
player_rect.bottom = block.top
|
||
|
elif dy < 0:
|
||
|
player_rect.top = block.bottom
|
||
|
|
||
|
# -----------------------------
|
||
|
# Camera Setup
|
||
|
# -----------------------------
|
||
|
def get_camera_offset():
|
||
|
cam_x = player_rect.centerx - SCREEN_WIDTH // 2
|
||
|
cam_y = player_rect.centery - SCREEN_HEIGHT // 2
|
||
|
return cam_x, cam_y
|
||
|
|
||
|
# -----------------------------
|
||
|
# Main Game Loop
|
||
|
# -----------------------------
|
||
|
running = True
|
||
|
while running:
|
||
|
ray_intersect_count = 0 # Reset each frame
|
||
|
|
||
|
for event in pygame.event.get():
|
||
|
if event.type == pygame.QUIT:
|
||
|
running = False
|
||
|
elif event.type == pygame.KEYDOWN:
|
||
|
if event.key == pygame.K_F3:
|
||
|
debug_mode = not debug_mode
|
||
|
elif event.key == pygame.K_r:
|
||
|
blocks = generate_blocks()
|
||
|
bvh_root = build_bvh(blocks)
|
||
|
|
||
|
# Process player movement.
|
||
|
keys = pygame.key.get_pressed()
|
||
|
dx = dy = 0
|
||
|
if keys[pygame.K_LEFT] or keys[pygame.K_a]:
|
||
|
dx = -player_speed
|
||
|
if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
|
||
|
dx = player_speed
|
||
|
if keys[pygame.K_UP] or keys[pygame.K_w]:
|
||
|
dy = -player_speed
|
||
|
if keys[pygame.K_DOWN] or keys[pygame.K_s]:
|
||
|
dy = player_speed
|
||
|
move_player(dx, dy)
|
||
|
|
||
|
# Light source is the player's center.
|
||
|
light_source = player_rect.center
|
||
|
camera_offset = get_camera_offset()
|
||
|
|
||
|
# Compute the world coordinate of the mouse cursor.
|
||
|
mouse_screen = pygame.mouse.get_pos()
|
||
|
mouse_world = (mouse_screen[0] + camera_offset[0], mouse_screen[1] + camera_offset[1])
|
||
|
delta_x = mouse_world[0] - light_source[0]
|
||
|
delta_y = mouse_world[1] - light_source[1]
|
||
|
base_angle = math.degrees(math.atan2(delta_y, delta_x))
|
||
|
|
||
|
# Fade the persistent light map.
|
||
|
fade_light_map()
|
||
|
# Update the persistent light map with new collision pixels (only within the FOV).
|
||
|
update_light_map(light_source, base_angle)
|
||
|
|
||
|
# Clear the screen.
|
||
|
screen.fill((30, 30, 30))
|
||
|
# Blit the persistent light map with camera offset.
|
||
|
screen.blit(persistent_light_map, (-camera_offset[0], -camera_offset[1]))
|
||
|
|
||
|
# Draw the player.
|
||
|
pygame.draw.rect(screen, (255, 255, 0),
|
||
|
(player_rect.x - camera_offset[0], player_rect.y - camera_offset[1],
|
||
|
player_rect.width, player_rect.height))
|
||
|
|
||
|
if debug_mode:
|
||
|
# Draw BVH visualizer.
|
||
|
draw_bvh(bvh_root, screen, camera_offset)
|
||
|
# Draw the view frustum.
|
||
|
draw_view_frustum(light_source, base_angle, camera_offset)
|
||
|
# Draw each ray cast within the FOV.
|
||
|
debug_draw_rays(light_source, base_angle, camera_offset)
|
||
|
# Draw debug menu.
|
||
|
fps = clock.get_fps()
|
||
|
draw_debug_menu(screen, fps)
|
||
|
|
||
|
pygame.display.flip()
|
||
|
clock.tick(60)
|
||
|
|
||
|
pygame.quit()
|
||
|
sys.exit()
|