diff --git a/src/scripts/cos_entities.py b/src/scripts/cos_entities.py index 2cbf080..fa366d2 100644 --- a/src/scripts/cos_entities.py +++ b/src/scripts/cos_entities.py @@ -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 diff --git a/src/scripts/cos_level.py b/src/scripts/cos_level.py index 4b0f77f..512940d 100644 --- a/src/scripts/cos_level.py +++ b/src/scripts/cos_level.py @@ -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"" - - 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"" - - 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 diff --git a/src/scripts/cos_solver.py b/src/scripts/cos_solver.py new file mode 100644 index 0000000..ff38292 --- /dev/null +++ b/src/scripts/cos_solver.py @@ -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 diff --git a/src/scripts/game.py b/src/scripts/game.py index f72fa76..7403221 100644 --- a/src/scripts/game.py +++ b/src/scripts/game.py @@ -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,