# -*- coding: utf-8 -*- # 이동 가능 영역 점검(2026-09-28 BT35) — 빌드 기록(걸을 수 있는 마스크 · 소품 기록 · 맵 정의)과 씬의 실제 충돌체 목록을 겹쳐 본다. # ① 걸을 수 있는 칸 안에 놓인 '실제 충돌체'(소품에 붙어 있던 것) — 자잘한 것이 길을 막는 자리 # ② 걸을 수 있는 경계 가운데 '눈에 막는 것이 없는' 구간 — 비어 보이는데 보이지 않는 벽에 막히는 자리 # ③ 막는 소품인데 걸을 수 있는 칸 안에 발자국이 없는 것(보이는데 통과) # 사용: python walk_audit.py <맵 이름> [--img] import io, json, math, os, sys from PIL import Image, ImageDraw ROOT = "C:/BurningTimes/FirstRising/" SP = os.path.dirname(os.path.abspath(__file__)) CELL = 0.5 def load(name): low = name.lower() d = json.loads(open(ROOT + "Assets/BurningTimes/Maps/%s/%s.map.json" % (name, name), "rb").read().decode("utf-8-sig")) props = json.load(open(ROOT + "Logs/bt_map_%s_props.json" % low, encoding="utf-8"))["items"] mask = Image.open(ROOT + "Logs/bt_map_%s_walkable.png" % low).convert("L") cols = [] p = os.path.join(SP, "colliders_%s.json" % name) if os.path.exists(p): cols = json.load(open(p, encoding="utf-8"))["items"] return d, props, mask, cols def main(): name = sys.argv[1] d, props, mask, cols = load(name) x0, x1, z0, z1 = d["bounds"] nx, nz = mask.size px = mask.load() # 유니티 텍스처는 아래에서 위로 — PNG 로 저장되면 첫 줄이 맨 위(z 가 큰 쪽)다 def walk(i, j): if i < 0 or j < 0 or i >= nx or j >= nz: return False return px[i, nz - 1 - j] > 127 def cell(x, z): return int(math.floor((x - x0) / CELL)), int(math.floor((z - z0) / CELL)) total_walk = sum(1 for j in range(nz) for i in range(nx) if walk(i, j)) # ① 걸을 수 있는 칸 안의 실제 충돌체(맵 서브씬의 것만 — 메인 씬의 충돌체는 엔티티 물리와 무관하다) inside = {} for c in cols: if not c["scene"].endswith("_Map"): continue i, j = cell(c["x"], c["z"]) if walk(i, j): key = c["prop"] e = inside.setdefault(key, {"n": 0, "sx": 0.0, "sy": 0.0, "sz": 0.0}) e["n"] += 1 e["sx"] += c["sx"]; e["sy"] += c["sy"]; e["sz"] += c["sz"] print("== %s 걸을 수 있는 면적 %.0f m2" % (name, total_walk * CELL * CELL)) print("① 걸을 수 있는 칸 안의 소품 충돌체: %d개(%d종)" % (sum(e["n"] for e in inside.values()), len(inside))) for k, e in sorted(inside.items(), key=lambda kv: -kv[1]["n"])[:20]: print(" x%d %s 평균 %.2f×%.2f×%.2f" % (e["n"], k, e["sx"] / e["n"], e["sy"] / e["n"], e["sz"] / e["n"])) # ② 경계 가운데 눈에 막는 것이 없는 구간 blockers = [p for p in props if p["how"].startswith("block")] # 눈에 부피가 보이는 통과 소품(덤불 · 큰 풀 · 거미줄)도 '막혀 보이는 것'으로 친다 bulky = [p for p in props if p["how"].startswith("pass") and p["sy"] >= 0.9 and min(p["sx"], p["sz"]) >= 0.9] liquids = d.get("liquids", []) def liquid_dist(x, z): best = 1e9 for l in liquids: pts = l.get("pts") or [] if len(pts) >= 4: for k in range(0, len(pts) - 2, 2): ax, az, bx, bz = pts[k], pts[k + 1], pts[k + 2], pts[k + 3] dx, dz = bx - ax, bz - az t = 0.0 if dx == 0 and dz == 0 else max(0.0, min(1.0, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz))) best = min(best, math.hypot(x - (ax + dx * t), z - (az + dz * t)) - l.get("half", 0.0)) elif l.get("r", 0) > 0: best = min(best, math.hypot(x - l["x"], z - l["z"]) - l["r"]) elif l.get("hx", 0) > 0: best = min(best, max(abs(x - l["x"]) - l["hx"], abs(z - l["z"]) - l["hz"])) return best def near_prop(x, z, reach): for p in blockers + bulky: hx, hz = p["sx"] * 0.5, p["sz"] * 0.5 dx = max(abs(x - p["x"]) - hx, 0.0) dz = max(abs(z - p["z"]) - hz, 0.0) if dx * dx + dz * dz <= reach * reach: return True return False boundary = [] for j in range(nz): for i in range(nx): if not walk(i, j): continue if walk(i + 1, j) and walk(i - 1, j) and walk(i, j + 1) and walk(i, j - 1): continue boundary.append((i, j)) bare = [] REACH = 3.0 for i, j in boundary: x, z = x0 + (i + 0.5) * CELL, z0 + (j + 0.5) * CELL if near_prop(x, z, REACH): continue if liquid_dist(x, z) <= 2.0: continue # 맵 범위의 끝(그 밖은 지형이 끝난다)은 따로 센다 bare.append((i, j)) print("② 걸을 수 있는 경계 %d칸(%.0f m) 가운데, %.0f m 안에 막는 소품 · 부피 있는 소품 · 액체가 없는 칸: %d칸(%.0f m · %.0f%%)" % (len(boundary), len(boundary) * CELL, REACH, len(bare), len(bare) * CELL, 100.0 * len(bare) / max(1, len(boundary)))) # ③ 막는 소품인데 그 자리가 걸을 수 있는 칸(보이는데 통과) through = {} for p in blockers: i, j = cell(p["x"], p["z"]) if walk(i, j): through[p["name"]] = through.get(p["name"], 0) + 1 print("③ 막는 소품인데 중심이 걸을 수 있는 칸인 것: %d개" % sum(through.values())) for k, n in sorted(through.items(), key=lambda kv: -kv[1])[:12]: print(" x%d %s" % (n, k)) if "--img" in sys.argv: scale = 4 img = Image.new("RGB", (nx * scale, nz * scale), (0, 0, 0)) dr = ImageDraw.Draw(img) for j in range(nz): for i in range(nx): if walk(i, j): dr.rectangle([i * scale, (nz - 1 - j) * scale, (i + 1) * scale - 1, (nz - j) * scale - 1], fill=(70, 70, 70)) def rect(p, color, shrink=1.0, outline=None): hx, hz = p["sx"] * 0.5 * shrink, p["sz"] * 0.5 * shrink ax, bx = (p["x"] - hx - x0) / CELL * scale, (p["x"] + hx - x0) / CELL * scale az, bz = (nz - (p["z"] + hz - z0) / CELL) * scale, (nz - (p["z"] - hz - z0) / CELL) * scale dr.rectangle([ax, az, bx, bz], fill=color, outline=outline) for p in bulky: rect(p, (40, 110, 50)) for p in blockers: rect(p, (150, 110, 60)) for i, j in bare: dr.rectangle([i * scale, (nz - 1 - j) * scale, (i + 1) * scale - 1, (nz - j) * scale - 1], fill=(255, 40, 40)) for c in cols: if not c["scene"].endswith("_Map"): continue i, j = cell(c["x"], c["z"]) if walk(i, j): cx, cz = (c["x"] - x0) / CELL * scale, (nz - (c["z"] - z0) / CELL) * scale dr.ellipse([cx - 3, cz - 3, cx + 3, cz + 3], fill=(60, 200, 255)) out = os.path.join(SP, "cap", "audit_%s.png" % name) img.save(out) print("saved", out, img.size) main()