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:
parent
7855a7ad80
commit
99f439930d
4 changed files with 442 additions and 304 deletions
|
|
@ -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)
|
||||
def wander(self):
|
||||
"""Random cardinal movement with cooldown."""
|
||||
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
|
||||
|
||||
# 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)))
|
||||
d = random.choice(((1, 0), (0, 1), (-1, 0), (0, -1)))
|
||||
self.try_move(*d)
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
# else, nearby pursue
|
||||
# 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
|
||||
|
||||
# Movement cooldown
|
||||
if self.moved_last > 0:
|
||||
self.moved_last -= 1
|
||||
return
|
||||
self.moved_last = self.move_cooldown
|
||||
|
||||
# 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 = []
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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
|
||||
if sprite is not None:
|
||||
self.grid.at((tx, y)).tilesprite = sprite
|
||||
except:
|
||||
pass
|
||||
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)
|
||||
level_plan = list(random.choice(list(level_plan)))
|
||||
target_rooms = len(level_plan)
|
||||
|
||||
# Player path planning
|
||||
#self.fill_rooms()
|
||||
self.wall_rooms()
|
||||
rooms = self.graph.walk()
|
||||
for attempt in range(10):
|
||||
self.reset()
|
||||
|
||||
# 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 = []
|
||||
prev_room = None
|
||||
print(level_plan)
|
||||
for room_num, room in enumerate(rooms):
|
||||
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] # single item plans became single-character plans...
|
||||
if type(room_plan) == str:
|
||||
room_plan = [room_plan]
|
||||
|
||||
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
|
||||
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))
|
||||
print(feature_coords[-1])
|
||||
|
||||
## 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
|
||||
# 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
|
||||
|
||||
# Tile painting
|
||||
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
90
src/scripts/cos_solver.py
Normal 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
|
||||
|
|
@ -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,7 +317,15 @@ 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):
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue