BurningTimesAi/공유/기획/FirstRising/맵/tools/walk_audit.py

156 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()