Crypt of Sokoban remaster: BSP, FOV, enemy AI, solvability checker

Replaces jam-quality code with production engine features (addresses #248):

- cos_level.py: Replace custom BinaryRoomNode/RoomGraph with mcrfpy.BSP
  for room generation, using adjacency graph for corridor placement
- cos_solver.py: New Sokoban BFS solvability checker; levels retry up
  to 10 times if unsolvable
- game.py: Add ColorLayer fog of war with room-reveal mechanic
  (visible/discovered/unknown states), compute_fov per player move
- cos_entities.py: Enemy AI state machine (idle/aggressive/fleeing)
  with A* pathfinding, fix duplicate direction bug on line 428/444

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-14 20:34:14 -05:00
commit 99f439930d
4 changed files with 442 additions and 304 deletions

View file

@ -389,6 +389,9 @@ class EnemyEntity(COSEntity):
self.sight = sight
self.move_cooldown = move_cooldown
self.moved_last = 0
self.max_hp = hp
self.state = "idle"
self.turns_since_seen = 0
def bump(self, other, dx, dy, test=False):
if self.hp == 0:
@ -420,56 +423,150 @@ class EnemyEntity(COSEntity):
other.do_move(*old_pos)
return True
def act(self):
if self.hp > 0:
# if player nearby: attack
x, y = self.draw_pos.x, self.draw_pos.y
px, py = self.game.player.draw_pos.x, self.game.player.draw_pos.y
for d in ((1, 0), (0, 1), (-1, 0), (1, 0)):
if int(x + d[0]) == int(px) and int(y + d[1]) == int(py):
self.try_move(*d)
return
def can_see_player(self):
"""Check if this enemy can see the player (symmetric FOV + range)."""
x, y = int(self.draw_pos.x), int(self.draw_pos.y)
px, py = int(self.game.player.draw_pos.x), int(self.game.player.draw_pos.y)
dist = abs(x - px) + abs(y - py)
if dist > self.sight:
return False
# Use player's computed FOV for symmetric visibility
try:
return self.grid.is_in_fov((x, y))
except:
# FOV not yet computed - fall back to distance check
return dist <= self.sight
# slow movement (doesn't affect ability to attack)
if self.moved_last > 0:
self.moved_last -= 1
#print(f"Deducting move cooldown, now {self.moved_last} / {self.move_cooldown}")
return
else:
#print(f"Restaring move cooldown - {self.move_cooldown}")
self.moved_last = self.move_cooldown
def wander(self):
"""Random cardinal movement with cooldown."""
if self.moved_last > 0:
self.moved_last -= 1
return
self.moved_last = self.move_cooldown
d = random.choice(((1, 0), (0, 1), (-1, 0), (0, -1)))
self.try_move(*d)
# if player is not nearby, wander
if abs(x - px) + abs(y - py) > self.sight:
d = random.choice(((1, 0), (0, 1), (-1, 0), (1, 0)))
def pursue(self):
"""Pathfind toward player, attacking if adjacent."""
x, y = int(self.draw_pos.x), int(self.draw_pos.y)
px, py = int(self.game.player.draw_pos.x), int(self.game.player.draw_pos.y)
# Attack if adjacent
for d in ((1, 0), (0, 1), (-1, 0), (0, -1)):
if x + d[0] == px and y + d[1] == py:
self.try_move(*d)
return
# if can_push and player in a line: KICK
if self.can_push:
if int(x) == int(px):# vertical kick
self.try_move(0, 1 if y < py else -1)
elif int(y) == int(py):# horizontal kick
self.try_move(1 if x < px else -1, 0)
# Movement cooldown
if self.moved_last > 0:
self.moved_last -= 1
return
self.moved_last = self.move_cooldown
# else, nearby pursue
towards = []
dist = lambda dx, dy: abs(px - (x + dx)) + abs(py - (y + dy))
#current_dist = dist(0, 0)
#for d in ((1, 0), (0, 1), (-1, 0), (1, 0)):
# if dist(*d) <= current_dist + 0.75: towards.append(d)
#print(current_dist, towards)
if px >= x:
towards.append((1, 0))
if px <= x:
towards.append((-1, 0))
if py >= y:
towards.append((0, 1))
if py <= y:
towards.append((0, -1))
towards = [p for p in towards if self.game.grid.at((int(x + p[0]), int(y + p[1]))).walkable]
towards.sort(key = lambda p: dist(*p))
target_dir = towards[0]
self.try_move(*target_dir)
# Cyclops boulder-kick: charge in a line toward player
if self.can_push:
if x == px:
self.try_move(0, 1 if y < py else -1)
return
elif y == py:
self.try_move(1 if x < px else -1, 0)
return
# A* pathfinding toward player
try:
path = self.grid.find_path((x, y), (px, py), diagonal_cost=0.0)
if path:
for step in path:
dx = int(step.x) - x
dy = int(step.y) - y
self.try_move(dx, dy)
return
except:
pass
# Fallback: simple directional movement
towards = []
if px > x: towards.append((1, 0))
if px < x: towards.append((-1, 0))
if py > y: towards.append((0, 1))
if py < y: towards.append((0, -1))
towards = [p for p in towards
if self.grid.at((x + p[0], y + p[1])).walkable]
if towards:
self.try_move(*towards[0])
else:
self.wander()
def flee(self):
"""Move away from player using Dijkstra gradient."""
if self.moved_last > 0:
self.moved_last -= 1
return
self.moved_last = self.move_cooldown
x, y = int(self.draw_pos.x), int(self.draw_pos.y)
px, py = int(self.game.player.draw_pos.x), int(self.game.player.draw_pos.y)
# Pick the neighbor that maximizes distance from player
best_dir = None
best_dist = -1
try:
dijkstra = self.grid.get_dijkstra_map((px, py), diagonal_cost=0.0)
for dx, dy in ((1, 0), (0, 1), (-1, 0), (0, -1)):
nx, ny = x + dx, y + dy
if nx < 0 or ny < 0:
continue
if nx >= int(self.grid.grid_size.x) or ny >= int(self.grid.grid_size.y):
continue
if not self.grid.at((nx, ny)).walkable:
continue
d = dijkstra.distance((nx, ny))
if d is not None and d > best_dist:
best_dist = d
best_dir = (dx, dy)
except:
pass
if best_dir:
self.try_move(*best_dir)
else:
self.wander()
def act(self):
if self.hp <= 0:
return
can_see = self.can_see_player()
# State transitions
if self.state == "idle":
if can_see:
self.state = "aggressive"
self.turns_since_seen = 0
elif self.state == "aggressive":
if not can_see:
self.turns_since_seen += 1
if self.turns_since_seen >= 5:
self.state = "idle"
else:
self.turns_since_seen = 0
if self.hp < self.max_hp * 0.25:
self.state = "fleeing"
elif self.state == "fleeing":
x, y = int(self.draw_pos.x), int(self.draw_pos.y)
px = int(self.game.player.draw_pos.x)
py = int(self.game.player.draw_pos.y)
dist = abs(x - px) + abs(y - py)
if dist > self.sight * 2:
self.state = "idle"
# Actions based on state
if self.state == "idle":
self.wander()
elif self.state == "aggressive":
self.pursue()
elif self.state == "fleeing":
self.flee()
def get_zapped(self, d):
self.hp -= d

View file

@ -4,284 +4,178 @@ import cos_tiles as ct
t = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
def binary_space_partition(x, y, w, h):
d = random.choices(["vert", "horiz"], weights=[w/(w+h), h/(w+h)])[0]
split = random.randint(30, 70) / 100.0
if d == "vert":
coord = int(w * split)
return (x, y, coord, h), (x+coord, y, w-coord, h)
else: # horizontal
coord = int(h * split)
return (x, y, w, coord), (x, y+coord, w, h-coord)
room_area = lambda x, y, w, h: w * h
class BinaryRoomNode:
def __init__(self, xywh):
self.data = xywh
self.left = None
self.right = None
def __repr__(self):
return f"<RoomNode {self.data}>"
def center(self):
x, y, w, h = self.data
return (x + w // 2, y + h // 2)
def split(self):
new_data = binary_space_partition(*self.data)
self.left = BinaryRoomNode(new_data[0])
self.right = BinaryRoomNode(new_data[1])
def walk(self):
if self.left and self.right:
return self.left.walk() + self.right.walk()
return [self]
def contains(self, pt):
x, y, w, h = self.data
tx, ty = pt
return x <= tx <= x + w and y <= ty <= y + h
class RoomGraph:
def __init__(self, xywh):
self.root = BinaryRoomNode(xywh)
def __repr__(self):
return f"<RoomGraph, root={self.root}, {len(self.walk())} rooms>"
def walk(self):
w = self.root.walk() if self.root else []
#print(w)
return w
def room_coord(room, margin=0):
x, y, w, h = room.data
#print(x,y,w,h, f'{margin=}', end=';')
w -= 1
h -= 1
margin += 1
x += margin
y += margin
w -= margin
h -= margin
if w < 0: w = 0
if h < 0: h = 0
#print(x,y,w,h, end=' -> ')
tx = x if w==0 else random.randint(x, x+w)
ty = y if h==0 else random.randint(y, y+h)
#print((tx, ty))
return (tx, ty)
def adjacent_rooms(r, rooms):
x, y, w, h = r.data
adjacents = {}
for i, other_r in enumerate(rooms):
rx, ry, rw, rh = other_r.data
if (rx, ry, rw, rh) == r:
continue # Skip self
# Check vertical adjacency (above or below)
if rx < x + w and x < rx + rw: # Overlapping width
if ry + rh == y: # Above
adjacents[i] = (x + w // 2, y - 1)
elif y + h == ry: # Below
adjacents[i] = (x + w // 2, y + h + 1)
# Check horizontal adjacency (left or right)
if ry < y + h and y < ry + rh: # Overlapping height
if rx + rw == x: # Left
adjacents[i] = (x - 1, y + h // 2)
elif x + w == rx: # Right
adjacents[i] = (x + w + 1, y + h // 2)
return adjacents
class Level:
def __init__(self, width, height):
self.width = width
self.height = height
#self.graph = [(0, 0, width, height)]
self.graph = RoomGraph( (0, 0, width, height) )
self.grid = mcrfpy.Grid(grid_size=(width, height), texture=t, pos=(10, 5), size=(1014, 700))
self.highlighted = -1 #debug view feature
self.walled_rooms = [] # for tracking "hallway rooms" vs "walled rooms"
def fill(self, xywh, highlight = False):
if highlight:
ts = 0
else:
ts = room_area(*xywh) % 131
X, Y, W, H = xywh
for x in range(X, X+W):
for y in range(Y, Y+H):
self.grid.at((x, y)).tilesprite = ts
def highlight(self, delta):
rooms = self.graph.walk()
if self.highlighted < len(rooms):
#print(f"reset {self.highlighted}")
self.fill(rooms[self.highlighted].data) # reset
self.highlighted += delta
print(f"highlight {self.highlighted}")
self.highlighted = self.highlighted % len(rooms)
self.fill(rooms[self.highlighted].data, highlight = True)
self.grid = mcrfpy.Grid(grid_size=(width, height), texture=t,
pos=(10, 5), size=(1014, 700))
self.bsp = None
self.leaves = []
self.room_map = [[None] * height for _ in range(width)]
def reset(self):
self.graph = RoomGraph( (0, 0, self.width, self.height) )
"""Initialize all cells as walls (unwalkable, opaque)."""
for x in range(self.width):
for y in range(self.height):
self.grid.at((x, y)).walkable = True
self.grid.at((x, y)).transparent = True
self.grid.at((x, y)).tilesprite = 0 #random.choice([40, 28])
self.grid.at((x, y)).walkable = False
self.grid.at((x, y)).transparent = False
self.grid.at((x, y)).tilesprite = 0
self.room_map = [[None] * self.height for _ in range(self.width)]
def split(self, single=False):
if single:
areas = {g.data: room_area(*g.data) for g in self.graph.walk()}
largest = sorted(self.graph.walk(), key=lambda g: areas[g.data])[-1]
largest.split()
else:
for room in self.graph.walk(): room.split()
self.fill_rooms()
def leaf_center(self, leaf):
"""Get center coordinates of a BSP leaf."""
x, y = int(leaf.pos[0]), int(leaf.pos[1])
w, h = int(leaf.size[0]), int(leaf.size[1])
return (x + w // 2, y + h // 2)
def fill_rooms(self, features=None):
rooms = self.graph.walk()
#print(f"rooms: {len(rooms)}")
for i, g in enumerate(rooms):
X, Y, W, H = g.data
#c = [random.randint(0, 255) for _ in range(3)]
ts = room_area(*g.data) % 131 + i # modulo - consistent tile pick
for x in range(X, X+W):
for y in range(Y, Y+H):
self.grid.at((x, y)).tilesprite = ts
def room_coord(self, leaf, margin=0):
"""Get a random walkable coordinate inside a leaf's carved room area."""
x, y = int(leaf.pos[0]), int(leaf.pos[1])
w, h = int(leaf.size[0]), int(leaf.size[1])
# Room interior starts at (x+1, y+1) due to wall carving margin
inner_x = x + 1 + margin
inner_y = y + 1 + margin
inner_max_x = x + w - 2 - margin
inner_max_y = y + h - 2 - margin
if inner_max_x <= inner_x:
inner_x = inner_max_x = x + w // 2
if inner_max_y <= inner_y:
inner_y = inner_max_y = y + h // 2
return (random.randint(inner_x, inner_max_x),
random.randint(inner_y, inner_max_y))
def wall_rooms(self):
self.walled_rooms = []
rooms = self.graph.walk()
for i, g in enumerate(rooms):
# unwalled / hallways: not selected for small dungeons, first, last, and 65% of all other rooms
if len(rooms) > 3 and i > 1 and i < len(rooms) - 2 and random.random() < 0.35:
self.walled_rooms.append(False)
continue
self.walled_rooms.append(True)
X, Y, W, H = g.data
for x in range(X, X+W):
self.grid.at((x, Y)).walkable = False
#self.grid.at((x, Y+H-1)).walkable = False
for y in range(Y, Y+H):
self.grid.at((X, y)).walkable = False
#self.grid.at((X+W-1, y)).walkable = False
# boundary of entire level
for x in range(0, self.width):
# self.grid.at((x, 0)).walkable = False
self.grid.at((x, self.height-1)).walkable = False
for y in range(0, self.height):
# self.grid.at((0, y)).walkable = False
self.grid.at((self.width-1, y)).walkable = False
def dig_path(self, start, end):
"""Dig an L-shaped corridor between two points."""
x1, x2 = min(start[0], end[0]), max(start[0], end[0])
y1, y2 = min(start[1], end[1]), max(start[1], end[1])
def dig_path(self, start:"Tuple[int, int]", end:"Tuple[int, int]", walkable=True, sprite=None):
# get x1,y1 and x2,y2 coordinates: top left and bottom right points on the rect formed by two random points, one from each of the 2 rooms
x1 = min([start[0], end[0]])
x2 = max([start[0], end[0]])
dw = x2 - x1
y1 = min([start[1], end[1]])
y2 = max([start[1], end[1]])
dh = y2 - y1
# random: top left or bottom right as the corner between the paths
# Random L-shape corner
tx, ty = (x1, y1) if random.random() >= 0.5 else (x2, y2)
for x in range(x1, x1+dw):
try:
if walkable:
self.grid.at((x, ty)).walkable = walkable
if sprite is not None:
self.grid.at((x, ty)).tilesprite = sprite
except:
pass
for y in range(y1, y1+dh):
try:
if walkable:
self.grid.at((tx, y)).walkable = True
if sprite is not None:
self.grid.at((tx, y)).tilesprite = sprite
except:
pass
for x in range(x1, x2 + 1):
if 0 <= x < self.width and 0 <= ty < self.height:
self.grid.at((x, ty)).walkable = True
self.grid.at((x, ty)).transparent = True
for y in range(y1, y2 + 1):
if 0 <= tx < self.width and 0 <= y < self.height:
self.grid.at((tx, y)).walkable = True
self.grid.at((tx, y)).transparent = True
def generate(self, level_plan): #target_rooms = 5, features=None):
self.reset()
target_rooms = len(level_plan)
def generate(self, level_plan):
"""Generate a level using BSP room placement and corridor digging.
Args:
level_plan: list of tuples, each tuple is the features for one room.
Can also be a set of alternative plans.
Returns:
List of (feature_name, (x, y)) tuples for entity placement.
"""
if type(level_plan) is set:
level_plan = random.choice(list(level_plan))
while len(self.graph.walk()) < target_rooms:
self.split(single=len(self.graph.walk()) > target_rooms * .5)
# Player path planning
#self.fill_rooms()
self.wall_rooms()
rooms = self.graph.walk()
feature_coords = []
prev_room = None
print(level_plan)
for room_num, room in enumerate(rooms):
room_plan = level_plan[room_num]
if type(room_plan) == str: room_plan = [room_plan] # single item plans became single-character plans...
for f in room_plan:
#feature_coords.append((f, room_coord(room, margin=4 if f in ("boulder",) else 1)))
# boulders are breaking my brain. If I can't get boulders away from walls with margin, I'm just going to dig them out.
#if f == "boulder":
# x, y = room_coord(room, margin=0)
# if x < 2: x += 1
# if y < 2: y += 1
# if x > self.grid.grid_size[0] - 2: x -= 1
# if y > self.grid.grid_size[1] - 2: y -= 1
# for _x in (1, 0, -1):
# for _y in (1, 0, -1):
# self.grid.at((x + _x, y + _y)).walkable = True
# feature_coords.append((f, (x, y)))
#else:
# feature_coords.append((f, room_coord(room, margin=0)))
fcoord = None
while not fcoord:
fc = room_coord(room, margin=0)
if not self.grid.at(fc).walkable: continue
if fc in [_i[1] for _i in feature_coords]: continue
fcoord = fc
feature_coords.append((f, fcoord))
print(feature_coords[-1])
level_plan = list(random.choice(list(level_plan)))
target_rooms = len(level_plan)
## Hallway generation
# plow an inelegant path
if prev_room:
start = room_coord(prev_room, margin=2)
end = room_coord(room, margin=2)
self.dig_path(start, end)
prev_room = room
for attempt in range(10):
self.reset()
# Tile painting
# 1. Create and split BSP
self.bsp = mcrfpy.BSP(pos=(1, 1),
size=(self.width - 2, self.height - 2))
depth = max(2, target_rooms.bit_length() + 1)
self.bsp.split_recursive(
depth=depth,
min_size=(4, 4),
max_ratio=1.5
)
# 2. Get leaves - retry if not enough
self.leaves = list(self.bsp.leaves())
if len(self.leaves) < target_rooms:
continue
# 3. Carve rooms (1-cell wall margin from leaf edges)
for leaf in self.leaves:
lx, ly = int(leaf.pos[0]), int(leaf.pos[1])
lw, lh = int(leaf.size[0]), int(leaf.size[1])
for cx in range(lx + 1, lx + lw - 1):
for cy in range(ly + 1, ly + lh - 1):
if 0 <= cx < self.width and 0 <= cy < self.height:
self.grid.at((cx, cy)).walkable = True
self.grid.at((cx, cy)).transparent = True
# 4. Build room map (cell -> leaf index)
for i, leaf in enumerate(self.leaves):
lx, ly = int(leaf.pos[0]), int(leaf.pos[1])
lw, lh = int(leaf.size[0]), int(leaf.size[1])
for cx in range(lx, lx + lw):
for cy in range(ly, ly + lh):
if 0 <= cx < self.width and 0 <= cy < self.height:
self.room_map[cx][cy] = i
# 5. Carve corridors using BSP adjacency
adj = self.bsp.adjacency
connected = set()
for i in range(len(adj)):
for j in adj[i]:
edge = (min(i, j), max(i, j))
if edge in connected:
continue
connected.add(edge)
ci = self.leaf_center(self.leaves[i])
cj = self.leaf_center(self.leaves[j])
self.dig_path(ci, cj)
# 6. Place features using level_plan
feature_coords = []
for room_num in range(min(target_rooms, len(self.leaves))):
leaf = self.leaves[room_num]
room_plan = level_plan[room_num]
if type(room_plan) == str:
room_plan = [room_plan]
for f in room_plan:
fcoord = None
for _ in range(100):
fc = self.room_coord(leaf)
if not self.grid.at(fc).walkable:
continue
if fc in [c[1] for c in feature_coords]:
continue
fcoord = fc
break
if fcoord is None:
fcoord = self.leaf_center(leaf)
feature_coords.append((f, fcoord))
# 7. Solvability check
spawn_pos = None
boulder_positions = []
button_positions = []
exit_pos = None
for f, pos in feature_coords:
if f == "spawn":
spawn_pos = pos
elif f == "boulder":
boulder_positions.append(pos)
elif f == "button":
button_positions.append(pos)
elif f == "exit":
exit_pos = pos
if spawn_pos and boulder_positions and button_positions and exit_pos:
from cos_solver import is_solvable
if is_solvable(self.grid, spawn_pos, boulder_positions,
button_positions, exit_pos):
break
print(f"Level attempt {attempt + 1}: unsolvable, retrying...")
else:
break # No puzzle elements to verify
# 8. Tile painting (WFC)
possibilities = None
while possibilities or possibilities is None:
possibilities = ct.wfc_pass(self.grid, possibilities)
## "hallway room" repainting
#for i, hall_room in enumerate(rooms):
# if self.walled_rooms[i]:
# print(f"walled room: {hall_room}")
# continue
# print(f"hall room: {hall_room}")
# x, y, w, h = hall_room.data
# for _x in range(x+1, x+w-1):
# for _y in range(y+1, y+h-1):
# self.grid.at((_x, _y)).walkable = False
# self.grid.at((_x, _y)).tilesprite = -1
# self.grid.at((_x, _y)).color = (0, 0, 0) # pit!
# targets = adjacent_rooms(hall_room, rooms)
# print(targets)
# for k, v in targets.items():
# self.dig_path(hall_room.center(), v, color=(64, 32, 32))
# for _, p in feature_coords:
# if hall_room.contains(p): self.dig_path(hall_room.center(), p, color=(92, 48, 48))
return feature_coords

90
src/scripts/cos_solver.py Normal file
View file

@ -0,0 +1,90 @@
"""Sokoban puzzle solvability checker using BFS."""
from collections import deque
def can_reach(grid, start, goal, boulders, w, h):
"""BFS check if start can reach goal, treating boulders as walls."""
if start == goal:
return True
visited = {start}
queue = deque([start])
while queue:
px, py = queue.popleft()
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nx, ny = px + dx, py + dy
if (nx, ny) == goal:
return True
if nx < 0 or nx >= w or ny < 0 or ny >= h:
continue
if not grid.at((nx, ny)).walkable:
continue
if (nx, ny) in boulders:
continue
if (nx, ny) not in visited:
visited.add((nx, ny))
queue.append((nx, ny))
return False
def is_solvable(grid, player_pos, boulder_positions, button_positions, exit_pos,
max_states=500000):
"""BFS through Sokoban state space. Returns True if solvable.
Args:
grid: mcrfpy.Grid with walkability set
player_pos: (x, y) tuple for player start
boulder_positions: list of (x, y) tuples for boulders
button_positions: list of (x, y) tuples for buttons
exit_pos: (x, y) tuple for exit
max_states: maximum states to explore before giving up
Returns:
True if the puzzle is solvable, False otherwise
"""
w, h = int(grid.grid_size.x), int(grid.grid_size.y)
def is_walkable(x, y, boulders):
if x < 0 or x >= w or y < 0 or y >= h:
return False
if not grid.at((x, y)).walkable:
return False
if (x, y) in boulders:
return False
return True
initial = (player_pos, frozenset(boulder_positions))
target_buttons = frozenset(button_positions)
visited = {initial}
queue = deque([initial])
states_explored = 0
while queue:
if states_explored >= max_states:
return True # Too many states - assume solvable
states_explored += 1
(px, py), boulders = queue.popleft()
# Win condition: all buttons covered by boulders
if target_buttons <= boulders:
if can_reach(grid, (px, py), exit_pos, boulders, w, h):
return True
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nx, ny = px + dx, py + dy
if (nx, ny) in boulders:
# Push boulder
bx, by = nx + dx, ny + dy
if is_walkable(bx, by, boulders):
new_boulders = frozenset(boulders - {(nx, ny)} | {(bx, by)})
state = ((nx, ny), new_boulders)
if state not in visited:
visited.add(state)
queue.append(state)
elif is_walkable(nx, ny, boulders):
state = ((nx, ny), boulders)
if state not in visited:
visited.add(state)
queue.append(state)
return False

View file

@ -132,6 +132,9 @@ class Crypt:
self.grid = liminal_void
self.player = ce.PlayerEntity(game=self)
self.spawn_point = (0, 0)
self.discovered_rooms = set()
self.discovered_cells = set()
self.fog_layer = None
# level creation moves player to the game level at the generated spawn point
self.create_level(self.depth)
@ -314,8 +317,16 @@ class Crypt:
elif k == "big rat":
ce.EnemyEntity(*v, game=self, base_damage=2, hp=4, sprite=130)
elif k == "cyclops":
ce.EnemyEntity(*v, game=self, base_damage=3, hp=8, sprite=109, base_defense=2)
ce.EnemyEntity(*v, game=self, base_damage=3, hp=8, sprite=109,
base_defense=2, can_push=True)
# Create fog layer for FOV
self.fog_layer = mcrfpy.ColorLayer(name="fog", z_index=10)
self.grid.add_layer(self.fog_layer)
self.fog_layer.fill(mcrfpy.Color(0, 0, 0, 220))
self.discovered_rooms = set()
self.discovered_cells = set()
#if self.depth > 2:
#for i in range(10):
# self.spawn_test_rat()
@ -391,7 +402,52 @@ class Crypt:
self.player.try_move(*d)
self.enemy_turn()
def update_fov(self):
"""Update fog of war based on player position."""
if not self.fog_layer or not hasattr(self.level, 'bsp') or not self.level.bsp:
return
px = int(self.player.draw_pos.x)
py = int(self.player.draw_pos.y)
# Compute FOV from player position
self.grid.compute_fov((px, py), radius=12)
# Pass 1: discover rooms and track seen cells
for x in range(self.level.width):
for y in range(self.level.height):
if self.grid.is_in_fov((x, y)):
self.discovered_cells.add((x, y))
room_idx = self.level.room_map[x][y]
if room_idx is not None:
self.discovered_rooms.add(room_idx)
# Pass 2: update fog overlay
for x in range(self.level.width):
for y in range(self.level.height):
if self.grid.is_in_fov((x, y)):
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 0))
elif self.cell_in_discovered(x, y):
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 128))
else:
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 220))
def cell_in_discovered(self, x, y):
"""Check if a cell has been discovered (room reveal or individually seen)."""
if (x, y) in self.discovered_cells:
return True
if hasattr(self.level, 'room_map'):
room_idx = self.level.room_map[x][y]
if room_idx is not None and room_idx in self.discovered_rooms:
return True
return False
def enemy_turn(self):
self.update_fov()
try:
self.grid.clear_dijkstra_maps()
except:
pass
self.entities.sort(key = lambda e: e.draw_order, reverse=False)
for e in self.entities:
e.act()
@ -446,6 +502,7 @@ class Crypt:
self.level_caption.text = f"Level: {self.depth}"
self.ui.append(self.sidebar)
self.gui_update()
self.update_fov()
class SweetButton:
def __init__(self, ui:mcrfpy.UICollection,