Replace forward BFS solver with reverse-pull puzzle generation

The BFS solver couldn't account for obstacles blocking push paths -
knowing the button is reachable doesn't mean the player can get to
the correct side of the boulder. Reverse-pull guarantees solvability
by construction: start with boulder on button, simulate valid
un-pushes to move it away. Each un-push verifies both the new boulder
cell and the player's required push position are walkable.

Also fixes chest clumping: level 2 previously crammed 3 treasures +
boulder + button into a single room. Redesigned all level plans to
spread treasures across rooms (max 1 per room). Updated lv_planner
for procedural levels 9+ with the same constraint.

Level plans no longer specify "boulder" - it's auto-generated from
the button position with min_pulls scaling by depth (2-8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-15 08:36:43 -05:00
commit 686e4fc1b2
2 changed files with 162 additions and 85 deletions

View file

@ -63,12 +63,125 @@ class Level:
self.grid.at((tx, y)).walkable = True
self.grid.at((tx, y)).transparent = True
def generate(self, level_plan):
def place_feature(self, leaf, feature_coords):
"""Find a unique walkable coordinate in the given leaf for a feature."""
used_coords = [c[1] for c in feature_coords]
# Try random positions first
for _ in range(100):
fc = self.room_coord(leaf)
if not self.grid.at(fc).walkable:
continue
if fc in used_coords:
continue
return fc
# Fallback: leaf center
fc = self.leaf_center(leaf)
if fc not in used_coords:
return fc
# Last resort: scan all walkable cells in the room
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 (cx, cy) not in used_coords and \
0 <= cx < self.width and \
0 <= cy < self.height and \
self.grid.at((cx, cy)).walkable:
return (cx, cy)
return None
def generate_boulder_by_pull(self, button_pos, min_pulls=3, max_pulls=20,
min_distance=3, obstacles=None):
"""Generate a boulder position by reverse-solving from the button.
Places the boulder on the button, then simulates reverse pushes
(un-pushes) to move it away. The resulting puzzle is guaranteed
solvable by reversing the sequence.
A forward push in direction d means: player at B-d pushes boulder
from B to B+d. To reverse this, boulder goes from B+d back to B,
requiring B to be walkable and B-d (player's push position) to be
walkable.
Args:
button_pos: (x, y) of the button
min_pulls: minimum successful un-pushes for interesting puzzle
max_pulls: maximum un-pushes to attempt
min_distance: minimum manhattan distance from button to boulder
obstacles: set of positions that block boulder movement
Returns:
(x, y) boulder position, or None if puzzle too trivial
"""
w, h = self.width, self.height
if obstacles is None:
obstacles = set()
boulder = button_pos
pull_count = 0
visited = {boulder}
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
for _ in range(max_pulls * 3):
random.shuffle(directions)
pulled = False
for dx, dy in directions:
# Reversing a push in direction (dx, dy):
# Boulder goes from current pos to current - d
# Player was at current - 2d (needed to push)
new_boulder = (boulder[0] - dx, boulder[1] - dy)
player_push_pos = (boulder[0] - 2*dx, boulder[1] - 2*dy)
nbx, nby = new_boulder
ppx, ppy = player_push_pos
# Bounds check
if not (0 <= nbx < w and 0 <= nby < h):
continue
if not (0 <= ppx < w and 0 <= ppy < h):
continue
# Walkability
if not self.grid.at(new_boulder).walkable:
continue
if not self.grid.at(player_push_pos).walkable:
continue
# Obstacles block boulder landing
if new_boulder in obstacles:
continue
# Avoid loops
if new_boulder in visited:
continue
boulder = new_boulder
visited.add(boulder)
pull_count += 1
pulled = True
break
if not pulled:
break
if pull_count >= max_pulls:
break
dist = abs(boulder[0] - button_pos[0]) + abs(boulder[1] - button_pos[1])
if pull_count < min_pulls or dist < min_distance:
return None
return boulder
def generate(self, level_plan, min_pulls=3):
"""Generate a level using BSP room placement and corridor digging.
Boulder placement is handled automatically via reverse-pull from
the button position, guaranteeing solvability by construction.
Any "boulder" entries in the level_plan are ignored.
Args:
level_plan: list of tuples, each tuple is the features for one room.
Can also be a set of alternative plans.
min_pulls: minimum reverse-pull steps for the puzzle
Returns:
List of (feature_name, (x, y)) tuples for entity placement.
@ -127,7 +240,7 @@ class Level:
cj = self.leaf_center(self.leaves[j])
self.dig_path(ci, cj)
# 6. Place features using level_plan
# 6. Place non-boulder features from level_plan
feature_coords = []
for room_num in range(min(target_rooms, len(self.leaves))):
leaf = self.leaves[room_num]
@ -136,75 +249,36 @@ class Level:
room_plan = [room_plan]
for f in room_plan:
fcoord = None
used_coords = [c[1] for c in feature_coords]
for _ in range(100):
fc = self.room_coord(leaf)
if not self.grid.at(fc).walkable:
continue
if fc in used_coords:
continue
fcoord = fc
break
if f == "boulder":
continue # Boulder auto-placed via reverse-pull
fcoord = self.place_feature(leaf, feature_coords)
if fcoord is None:
# Fallback: leaf center, but only if not already used
fc = self.leaf_center(leaf)
if fc not in used_coords:
fcoord = fc
else:
# Last resort: scan all walkable cells in the room
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 (cx, cy) not in used_coords and \
0 <= cx < self.width and \
0 <= cy < self.height and \
self.grid.at((cx, cy)).walkable:
fcoord = (cx, cy)
break
if fcoord is not None:
break
if fcoord is None:
print(f"WARNING: Could not place '{f}' in room {room_num} - no free cells!")
fcoord = self.leaf_center(leaf) # absolute last resort
print(f"WARNING: Could not place '{f}' in room {room_num}")
fcoord = self.leaf_center(leaf)
feature_coords.append((f, fcoord))
# 7. Solvability check
spawn_pos = None
boulder_positions = []
button_positions = []
exit_pos = None
obstacle_positions = []
# 7. Generate boulder via reverse-pull from button
button_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
elif f == "treasure":
obstacle_positions.append(pos)
if spawn_pos and boulder_positions and button_positions and exit_pos:
# Check that no obstacle sits on a button
buttons_blocked = any(
bp in obstacle_positions for bp in button_positions
)
if buttons_blocked:
print(f"Level attempt {attempt + 1}: button blocked by obstacle, retrying...")
continue
from cos_solver import is_solvable
if is_solvable(self.grid, spawn_pos, boulder_positions,
button_positions, exit_pos,
obstacles=obstacle_positions):
if f == "button":
button_pos = pos
break
print(f"Level attempt {attempt + 1}: unsolvable, retrying...")
else:
break # No puzzle elements to verify
if button_pos:
obstacles = {pos for _, pos in feature_coords}
boulder_pos = self.generate_boulder_by_pull(
button_pos,
min_pulls=min_pulls,
max_pulls=max(min_pulls * 4, 20),
min_distance=min_pulls,
obstacles=obstacles
)
if boulder_pos is None:
print(f"Level attempt {attempt + 1}: puzzle too trivial, retrying...")
continue
feature_coords.append(("boulder", boulder_pos))
break
# 8. Tile painting (WFC)
possibilities = None

View file

@ -77,19 +77,18 @@ class Crypt:
self.stuck_btn = SweetButton(self.ui, (810, 700), "Stuck", icon=19, box_width=150, box_height = 60, click=self.stuck)
self.level_plan = {
1: [("spawn", "button", "boulder"), ("exit")],
2: [("spawn", "button", "treasure", "treasure", "treasure", "rat", "rat", "boulder"), ("exit")],
#2: [("spawn", "button", "boulder"), ("rat"), ("exit")],
3: [("spawn", "button", "boulder"), ("rat"), ("exit")],
4: [("spawn", "button", "rat"), ("boulder", "rat", "treasure"), ("exit")],
5: [("spawn", "button", "rat"), ("boulder", "rat"), ("exit")],
6: {(("spawn", "button"), ("boulder", "treasure", "exit")),
(("spawn", "boulder"), ("button", "treasure", "exit"))},
7: {(("spawn", "button"), ("boulder", "treasure", "exit")),
(("spawn", "boulder"), ("button", "treasure", "exit"))},
8: {(("spawn", "treasure", "button"), ("boulder", "treasure", "exit")),
(("spawn", "treasure", "boulder"), ("button", "treasure", "exit"))}
#9: self.lv_planner
# Boulder auto-generated via reverse-pull from button
1: [("spawn", "button"), ("exit",)],
2: [("spawn", "button", "rat"), ("treasure",), ("treasure", "rat"), ("treasure", "exit")],
3: [("spawn", "button"), ("rat",), ("exit",)],
4: [("spawn", "button", "rat"), ("treasure",), ("rat", "exit")],
5: [("spawn", "button", "rat"), ("rat",), ("treasure", "exit")],
6: {(("spawn", "button"), ("treasure", "exit")),
(("spawn",), ("button", "treasure", "exit"))},
7: {(("spawn", "button"), ("treasure", "exit")),
(("spawn",), ("button", "treasure", "exit"))},
8: {(("spawn", "button"), ("treasure",), ("treasure", "exit")),
(("spawn",), ("treasure",), ("button", "treasure", "exit"))}
}
# empty void for the player to initialize into
@ -217,11 +216,12 @@ class Crypt:
"""Plan room sequence in levels > 9"""
monsters = (target_level - 6) // 2
target_rooms = min(int(target_level // 2), 6)
target_treasure = min(int(target_level // 3), 4)
target_treasure = min(int(target_level // 3), target_rooms)
rooms = []
for i in range(target_rooms):
rooms.append([])
for o in ("spawn", "boulder", "button", "exit"):
# Boulder auto-generated via reverse-pull from button
for o in ("spawn", "button", "exit"):
r = random.randint(0, target_rooms-1)
rooms[r].append(o)
monster_table = {
@ -236,9 +236,11 @@ class Crypt:
r = random.randint(0, target_rooms - 1)
rooms[r].append(random.choices(monster_names, weights = monster_weights)[0])
# Treasures: at most one per room
available_rooms = list(range(target_rooms))
random.shuffle(available_rooms)
for t in range(target_treasure):
r = random.randint(0, target_rooms - 1)
rooms[r].append("treasure")
rooms[available_rooms[t]].append("treasure")
return rooms
@ -290,7 +292,8 @@ class Crypt:
plan = self.level_plan[depth]
else:
plan = self.lv_planner(depth)
coords = self.level.generate(plan)
min_pulls = min(max(2, depth), 8)
coords = self.level.generate(plan, min_pulls=min_pulls)
self.entities = []
if self.player:
luck = self.player.luck
@ -584,7 +587,7 @@ class MainMenu:
gw, gh = int(self.grid.grid_size.x), int(self.grid.grid_size.y)
self.grid.center = (gw * 16 / 2, gh * 16 / 2)
coords = self.demo.generate(
[("boulder", "boulder", "rat", "cyclops", "boulder"), ("spawn"), ("rat", "big rat"), ("button", "boulder", "exit")]
[("rat", "cyclops"), ("spawn",), ("rat", "big rat"), ("button", "exit")]
)
self.entities = []
self.add_entity = lambda e: self.entities.append(e)