changes for stuff
This commit is contained in:
367
scripts/build_room_graph.py
Normal file
367
scripts/build_room_graph.py
Normal file
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Builds a queryable room adjacency graph from .tscn files with BFS pathfinding.
|
||||
|
||||
Parses all scene files to extract TransitionPiece exits, builds an adjacency
|
||||
graph, and provides shortest-path queries between any two rooms.
|
||||
|
||||
Usage:
|
||||
python scripts/build_room_graph.py # print full graph
|
||||
python scripts/build_room_graph.py --from kq4_001_beach --to kq4_092_lolottes_throne_room
|
||||
|
||||
Can also be imported as a module for use by other tools.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransitionInfo:
|
||||
"""A single room exit / TransitionPiece."""
|
||||
|
||||
exit_node_name: str # Node name in source room's scene tree (e.g., "kq4_010_forest_path")
|
||||
target_uid: str # UID of destination .tscn file
|
||||
appear_at_node: str # Node name in destination room where player appears
|
||||
label: str # Human-readable label (e.g., "Forest Path")
|
||||
polygon: list[tuple[float, float]] # Polygon vertices relative to transition node position
|
||||
position: tuple[float, float] = (0.0, 0.0) # Node position in parent space
|
||||
scale: tuple[float, float] = (1.0, 1.0) # Node scale factors
|
||||
|
||||
def viewport_centroid(self) -> tuple[float, float]:
|
||||
"""Compute the polygon centroid transformed to viewport coordinates."""
|
||||
if not self.polygon:
|
||||
return self.position
|
||||
cx = sum(p[0] for p in self.polygon) / len(self.polygon)
|
||||
cy = sum(p[1] for p in self.polygon) / len(self.polygon)
|
||||
return (
|
||||
self.scale[0] * (self.position[0] + cx),
|
||||
self.scale[1] * (self.position[1] + cy),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInfo:
|
||||
"""Metadata for a single room."""
|
||||
|
||||
name: str # e.g., "kq4_004_ogres_cottage"
|
||||
uid: Optional[str] # Scene UID from .tscn header
|
||||
path: Path # Absolute path to .tscn file
|
||||
transitions: list[TransitionInfo] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NavigationStep:
|
||||
"""One step in a navigation plan (source room → destination room)."""
|
||||
|
||||
from_room: str # Source room name
|
||||
exit_node_name: str # TransitionPiece node name to click on
|
||||
to_room: str # Destination room name
|
||||
label: str # Human-readable label
|
||||
polygon: list[tuple[float, float]]
|
||||
position: tuple[float, float] = (0.0, 0.0)
|
||||
scale: tuple[float, float] = (1.0, 1.0)
|
||||
|
||||
def viewport_centroid(self) -> tuple[float, float]:
|
||||
"""Compute the polygon centroid transformed to viewport coordinates."""
|
||||
if not self.polygon:
|
||||
return self.position
|
||||
cx = sum(p[0] for p in self.polygon) / len(self.polygon)
|
||||
cy = sum(p[1] for p in self.polygon) / len(self.polygon)
|
||||
return (
|
||||
self.scale[0] * (self.position[0] + cx),
|
||||
self.scale[1] * (self.position[1] + cy),
|
||||
)
|
||||
|
||||
|
||||
def find_uid_files(root: Path) -> dict[str, Path]:
|
||||
"""Build a mapping of UID → file path from all .uid files."""
|
||||
uid_map = {}
|
||||
for uid_file in root.rglob("*.uid"):
|
||||
content = uid_file.read_text().strip()
|
||||
if content.startswith("uid://"):
|
||||
resource_path = uid_file.with_suffix("")
|
||||
uid_map[content] = resource_path
|
||||
return uid_map
|
||||
|
||||
|
||||
def parse_transitions(body: str, room_name: str) -> list[TransitionInfo]:
|
||||
"""Extract TransitionPiece transitions from a .tscn file body."""
|
||||
transitions = []
|
||||
|
||||
transition_pattern = re.compile(
|
||||
r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n'
|
||||
r"(([^\[]+(?:\n|$))*?)(?=\[|$)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
for match in transition_pattern.finditer(body):
|
||||
node_name = match.group(1)
|
||||
body_text = match.group(2)
|
||||
|
||||
target_match = re.search(r'^target = "([^"]+)"', body_text, re.MULTILINE)
|
||||
appear_match = re.search(
|
||||
r'^(?:appear_at_node = "([^"]+)"|appear_at_node = &"([^"]+)")',
|
||||
body_text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
if not (target_match and appear_match):
|
||||
continue
|
||||
|
||||
target_uid = target_match.group(1)
|
||||
appear_at = appear_match.group(1) or appear_match.group(2)
|
||||
|
||||
label_match = re.search(r'^label = "([^"]+)"', body_text, re.MULTILINE)
|
||||
label = label_match.group(1) if label_match else node_name
|
||||
|
||||
# Parse position (defaults to 0, 0)
|
||||
pos_match = re.search(r"^position\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE)
|
||||
if pos_match:
|
||||
pos_nums = [float(x.strip()) for x in pos_match.group(1).split(",")]
|
||||
node_position = (pos_nums[0], pos_nums[1])
|
||||
else:
|
||||
node_position = (0.0, 0.0)
|
||||
|
||||
# Parse scale (defaults to 1, 1)
|
||||
scale_match = re.search(r"^scale\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE)
|
||||
if scale_match:
|
||||
scale_nums = [float(x.strip()) for x in scale_match.group(1).split(",")]
|
||||
node_scale = (scale_nums[0], scale_nums[1])
|
||||
else:
|
||||
node_scale = (1.0, 1.0)
|
||||
|
||||
polygon_str_match = re.search(
|
||||
r"^polygon = PackedVector2Array\((.+?)\)",
|
||||
body_text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
polygon: list[tuple[float, float]] = []
|
||||
if polygon_str_match:
|
||||
nums = re.findall(r"[-+]?[0-9]*\.?[0-9]+", polygon_str_match.group(1))
|
||||
floats = [float(n) for n in nums]
|
||||
polygon = [(floats[i], floats[i + 1]) for i in range(0, len(floats), 2)]
|
||||
|
||||
transitions.append(
|
||||
TransitionInfo(
|
||||
exit_node_name=node_name,
|
||||
target_uid=target_uid,
|
||||
appear_at_node=appear_at,
|
||||
label=label,
|
||||
polygon=polygon,
|
||||
position=node_position,
|
||||
scale=node_scale,
|
||||
)
|
||||
)
|
||||
|
||||
return transitions
|
||||
|
||||
|
||||
def build_graph(scenes_dir: Path) -> dict[str, RoomInfo]:
|
||||
"""Parse all room .tscn files and return a dict keyed by room name."""
|
||||
scene_files = sorted(scenes_dir.glob("kq4_*/kq4_*.tscn"))
|
||||
|
||||
graph: dict[str, RoomInfo] = {}
|
||||
for sf in scene_files:
|
||||
if "placeholder_template" in str(sf):
|
||||
continue
|
||||
|
||||
content = sf.read_text()
|
||||
uid_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content)
|
||||
room_uid = uid_match.group(1) if uid_match else None
|
||||
|
||||
room_name = sf.stem
|
||||
transitions = parse_transitions(content, room_name)
|
||||
|
||||
graph[room_name] = RoomInfo(
|
||||
name=room_name,
|
||||
uid=room_uid,
|
||||
path=sf,
|
||||
transitions=transitions,
|
||||
)
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def _resolve_target_room(trans: TransitionInfo, graph: dict[str, RoomInfo]) -> Optional[str]:
|
||||
"""Given a transition and the room graph, find which room it targets."""
|
||||
for room_name, room_info in graph.items():
|
||||
if room_info.uid == trans.target_uid:
|
||||
return room_name
|
||||
return None
|
||||
|
||||
|
||||
def find_path(
|
||||
graph: dict[str, RoomInfo], start_room: str, end_room: str
|
||||
) -> Optional[list[NavigationStep]]:
|
||||
"""BFS from start to end. Returns ordered list of steps or None."""
|
||||
if start_room not in graph:
|
||||
raise ValueError(f"Start room not found in graph: {start_room}")
|
||||
if end_room not in graph:
|
||||
raise ValueError(f"End room not found in graph: {end_room}")
|
||||
|
||||
if start_room == end_room:
|
||||
return []
|
||||
|
||||
from collections import deque
|
||||
|
||||
adj: dict[str, list[tuple[str, TransitionInfo]]] = {}
|
||||
for rname, rinfo in graph.items():
|
||||
adj[rname] = []
|
||||
for t in rinfo.transitions:
|
||||
target = _resolve_target_room(t, graph)
|
||||
if target:
|
||||
adj[rname].append((target, t))
|
||||
|
||||
visited = {start_room}
|
||||
queue = deque([(start_room, [])])
|
||||
|
||||
while queue:
|
||||
current, path_steps = queue.popleft()
|
||||
for neighbor, trans in adj.get(current, []):
|
||||
if neighbor in visited:
|
||||
continue
|
||||
|
||||
step = NavigationStep(
|
||||
from_room=current,
|
||||
exit_node_name=trans.exit_node_name,
|
||||
to_room=neighbor,
|
||||
label=trans.label,
|
||||
polygon=trans.polygon,
|
||||
position=trans.position,
|
||||
scale=trans.scale,
|
||||
)
|
||||
new_steps = path_steps + [step]
|
||||
|
||||
if neighbor == end_room:
|
||||
return new_steps
|
||||
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, new_steps))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_connected_components(graph: dict[str, RoomInfo]) -> list[set[str]]:
|
||||
"""Return list of connected components in the room graph."""
|
||||
adj: dict[str, set[str]] = {r: set() for r in graph}
|
||||
for rname, rinfo in graph.items():
|
||||
for t in rinfo.transitions:
|
||||
target = _resolve_target_room(t, graph)
|
||||
if target:
|
||||
adj[rname].add(target)
|
||||
adj[target].add(rname)
|
||||
|
||||
visited = set()
|
||||
components = []
|
||||
|
||||
for room in graph:
|
||||
if room in visited:
|
||||
continue
|
||||
component = set()
|
||||
stack = [room]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
component.add(current)
|
||||
stack.extend(adj[current] - visited)
|
||||
components.append(component)
|
||||
|
||||
return sorted(components, key=len, reverse=True)
|
||||
|
||||
|
||||
def print_graph_summary(graph: dict[str, RoomInfo]) -> None:
|
||||
"""Print a human-readable summary of the room graph."""
|
||||
total_rooms = len(graph)
|
||||
total_exits = sum(len(r.transitions) for r in graph.values())
|
||||
components = get_connected_components(graph)
|
||||
|
||||
print(f"Rooms: {total_rooms}")
|
||||
print(f"Total transition exits: {total_exits}")
|
||||
print(f"Connected components: {len(components)}")
|
||||
|
||||
if len(components) > 1:
|
||||
for i, comp in enumerate(components):
|
||||
print(f" Component {i + 1} ({len(comp)} rooms): {', '.join(sorted(comp)[:6])}{'...' if len(comp) > 6 else ''}")
|
||||
else:
|
||||
print(" All rooms connected!")
|
||||
|
||||
|
||||
def print_room(graph: dict[str, RoomInfo], room_name: str) -> None:
|
||||
"""Print exits for a specific room."""
|
||||
if room_name not in graph:
|
||||
print(f"Room not found: {room_name}")
|
||||
return
|
||||
|
||||
room = graph[room_name]
|
||||
print(f"Room: {room_name} (uid: {room.uid or 'unknown'})")
|
||||
print(f" Exits ({len(room.transitions)}):")
|
||||
for t in room.transitions:
|
||||
target_room = _resolve_target_room(t, graph)
|
||||
target_str = target_room or t.target_uid[:12]
|
||||
print(f" {t.exit_node_name} → {target_str} [{t.label}] (polygon: {len(t.polygon)} pts)")
|
||||
|
||||
|
||||
def find_and_print_path(
|
||||
graph: dict[str, RoomInfo], start_room: str, end_room: str
|
||||
) -> None:
|
||||
"""Find and print a path between two rooms."""
|
||||
steps = find_path(graph, start_room, end_room)
|
||||
|
||||
if not steps:
|
||||
print(f"No path from {start_room} to {end_room}")
|
||||
components = get_connected_components(graph)
|
||||
for comp in components:
|
||||
if start_room in comp:
|
||||
print(f" {start_room} is in component with {len(comp)} rooms")
|
||||
break
|
||||
for comp in components:
|
||||
if end_room in comp:
|
||||
print(f" {end_room} is in component with {len(comp)} rooms")
|
||||
break
|
||||
return
|
||||
|
||||
print(f"Path from {start_room} → {end_room} ({len(steps)} step{'s' if len(steps) != 1 else ''}):")
|
||||
for i, step in enumerate(steps):
|
||||
coord_str = ""
|
||||
cx, cy = step.viewport_centroid()
|
||||
coord_str = f" (click at: {cx:.0f}, {cy:.0f})"
|
||||
print(
|
||||
f" {i + 1}. Click '{step.exit_node_name}' "
|
||||
f"in {step.from_room} "
|
||||
f"→ {step.to_room} [{step.label}]{coord_str}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="KQ4 room graph builder and BFS pathfinder")
|
||||
parser.add_argument("--from", dest="from_room", help="Starting room name (e.g., kq4_001_beach)")
|
||||
parser.add_argument("--to", dest="to_room", help="Destination room name")
|
||||
parser.add_argument("--room", help="Show exits for a specific room")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
scenes_dir = root / "scenes"
|
||||
|
||||
if not scenes_dir.exists():
|
||||
print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
graph = build_graph(scenes_dir)
|
||||
|
||||
if args.room:
|
||||
print_room(graph, args.room)
|
||||
elif args.from_room and args.to_room:
|
||||
find_and_print_path(graph, args.from_room, args.to_room)
|
||||
else:
|
||||
print_graph_summary(graph)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user