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

156 lines
7.0 KiB
Python
Raw Permalink Normal View History

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