# -*- coding: utf-8 -*- # 2026-09-28 BT38 — 오염된 던전(PlagueCatacomb) 바닥을 '던전 타일'로 다시 짠다. # PD "오염된 던전의 바닥 텍스쳐가 너무 크고 퀄리티가 다른 맵에 비해 떨어지는 느낌 … 좀 더 자연스러운 던전 타일처럼" # 원인(실측): 팩 텍스처가 PC 에서 512 픽셀로 들어오고(1 m 에 64~109 픽셀 — 다른 맵의 주 바닥은 223~256), # 바탕 층은 무늬 없는 바위를 8 m 로, 공터 층은 깨진 타일을 4.7 m 로 깔아 돌 한 장이 1~2 m 였다. # 방법: 같은 팩의 던전 타일 세 가지(FloorA 온전 · FloorA3 얼룩 · FloorA2 깨짐)는 줄눈 배치가 같다(높이 맵 상관 0.74~0.82). # → 네 층을 모두 같은 배치 · 같은 크기로 깔면 층이 바뀌어도 줄눈이 이어져 '한 바닥이 닳고 더러워진' 모습이 된다. # 게임 에셋의 들여오기 설정은 건드리지 않고, 1024 픽셀 사본을 맵 폴더에 만든다(빌더가 1024 · 고화질 압축으로 들여온다). # 만드는 것(맵 폴더 Textures): FRMap_dun_tile(온전) · FRMap_dun_tile_worn(얼룩) · FRMap_dun_tile_slime(점액) · FRMap_dun_dirt(흙 · 자갈) + 노멀맵(_N) # 1차 화면 확인: 타일 세 가지는 줄눈 배치가 같아, 덧칠해도 4 m 마다 같은 무늬가 격자처럼 보였다(넓은 보스 자리에서 뚜렷) # → 깨진 타일 대신 무늬가 다른 흙 층을 넣어 얼룩으로 덮고, 타일 안의 밝기 차(눈에 띄는 밝은 돌)를 더 편다 # 실행: python texderive_dungeon.py [--project] (--project 를 주면 FR 프로젝트 맵 폴더로 복사한다) import os, sys, io, json, shutil sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") import numpy as np from PIL import Image, ImageFilter Image.MAX_IMAGE_PIXELS = None PROJECT = r"C:\BurningTimes\FirstRising" PACK = os.path.join(PROJECT, "Assets", "LevelAssets", "Toby Fredson", "Modular Dungeon Catacombs - Mobile", "Textures", "Textures_Terrain") OUT = r"C:\BurningTimes\FR_maps_staging\_work\dungeon_floor" DST = os.path.join(PROJECT, "Assets", "BurningTimes", "Maps", "PlagueCatacomb", "Textures") SIZE = 1024 def load(name, mode="RGB"): return Image.open(os.path.join(PACK, name)).convert(mode) def down(img): """2048 → 1024. 줄인 뒤 조금 또렷하게(줄눈 · 돌결이 뭉개지지 않게).""" small = img.resize((SIZE, SIZE), Image.LANCZOS) return small.filter(ImageFilter.UnsharpMask(radius=1.2, percent=70, threshold=2)) def down_normal(img): """노멀맵은 줄인 뒤 길이를 1 로 다시 맞춘다.""" a = np.asarray(img.resize((SIZE, SIZE), Image.LANCZOS)).astype(np.float64) / 255.0 * 2.0 - 1.0 n = np.linalg.norm(a, axis=2, keepdims=True) a = a / np.maximum(n, 1e-6) return a def save_normal(a, path): n = np.linalg.norm(a, axis=2, keepdims=True) a = a / np.maximum(n, 1e-6) Image.fromarray(np.clip((a * 0.5 + 0.5) * 255.0 + 0.5, 0, 255).astype(np.uint8)).save(path) def wrap_blur(x, radius): """가장자리가 이어지는 흐림 — 둘레를 되풀이해 붙인 뒤 흐리고 가운데만 잘라 낸다.""" pad = int(radius * 3) + 2 p = np.pad(x, pad, mode="wrap") im = Image.fromarray(np.clip(p * 255.0, 0, 255).astype(np.uint8)) b = np.asarray(im.filter(ImageFilter.GaussianBlur(radius))).astype(np.float64) / 255.0 return b[pad:-pad, pad:-pad] def pnoise(size, scale, octaves=4, seed=0): """주기 노이즈(0~1) — 가장자리가 정확히 이어진다.""" r = np.random.default_rng(seed) out = np.zeros((size, size), np.float64) amp = 1.0 tot = 0.0 fy = np.fft.fftfreq(size)[:, None] * size fx = np.fft.fftfreq(size)[None, :] * size f = np.hypot(fx, fy) for o in range(octaves): w = r.standard_normal((size, size)) k = scale * (2 ** o) n = np.real(np.fft.ifft2(np.fft.fft2(w) * np.exp(-(f / k) ** 2))) n = (n - n.min()) / (n.max() - n.min() + 1e-9) out += n * amp tot += amp amp *= 0.5 out /= tot return (out - out.min()) / (out.max() - out.min() + 1e-9) def smooth(x, a, b): t = np.clip((x - a) / (b - a + 1e-9), 0, 1) return t * t * (3 - 2 * t) def seam(img): a = np.asarray(img).astype(np.float32) inner = (np.abs(a[:, 1:] - a[:, :-1]).mean() + np.abs(a[1:, :] - a[:-1, :]).mean()) * 0.5 s = (np.abs(a[:, 0] - a[:, -1]).mean() + np.abs(a[0, :] - a[-1, :]).mean()) * 0.5 return float(s / max(1e-3, inner)) def flatten(img, radius=64.0, amount=0.65): """큰 밝기 얼룩을 편다 — 텍스처 안의 어두운 돌 무리 · 밝은 자리가 4 m 마다 되풀이되어 눈에 띄는 것을 줄인다(돌결 · 줄눈은 그대로).""" a = np.asarray(img).astype(np.float64) / 255.0 l = (a * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2) low = wrap_blur(l, radius) k = (low.mean() / np.maximum(low, 1e-3)) ** amount a = np.clip(a * k[..., None], 0, 1) return Image.fromarray((a * 255.0 + 0.5).astype(np.uint8)) def grade(img, gain=1.0, gamma=1.0, sat=1.0): """밝기 · 감마 · 채도를 맞춘다(세 타일의 밝기 차가 너무 크면 층 경계가 얼룩처럼 보인다).""" a = np.asarray(img).astype(np.float64) / 255.0 a = np.clip(a * gain, 0, 1) ** gamma l = (a * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2, keepdims=True) a = np.clip(l + (a - l) * sat, 0, 1) return Image.fromarray((a * 255.0 + 0.5).astype(np.uint8)) def main(): os.makedirs(OUT, exist_ok=True) rec = [] # ---------- 온전한 타일 · 얼룩진 타일 · 깨진 타일 src = { "tile": ("CatacombsFloorA_Albedo.png", "CatacombsFloorA_Normal.png", dict(gain=1.00, gamma=1.0, sat=0.95)), "tile_worn": ("CatacombsFloorA3_Albedo.png", "CatacombsFloorA3_Normal.png", dict(gain=1.22, gamma=0.96, sat=0.95)), } made = {} normals = {} for key, (alb, nrm, g) in src.items(): img = grade(flatten(flatten(down(load(alb)), 64.0, 0.8), 18.0, 0.45), **g) # 큰 얼룩(64) + 돌 한 장 크기(18)의 밝기 차를 편다 n = down_normal(load(nrm)) img.save(os.path.join(OUT, "FRMap_dun_%s.png" % key)) save_normal(n, os.path.join(OUT, "FRMap_dun_%s_N.png" % key)) made[key] = img normals[key] = n mean = np.asarray(img).reshape(-1, 3).mean(axis=0) nmean = np.asarray(load(nrm).resize((64, 64))).reshape(-1, 3).mean(axis=0) rec.append({"name": key, "from": alb, "mean": [round(float(v), 1) for v in mean], "seam": round(seam(img), 2), "normal_mean": [round(float(v)) for v in nmean]}) # ---------- 흙 · 자갈(무늬가 다른 층 — 타일 위를 얼룩으로 덮어 되풀이를 깬다) dirt = grade(flatten(down(load("PebblesCATA_Albedo.png")), 64.0, 0.7), gain=1.05, gamma=1.0, sat=0.9) dirt.save(os.path.join(OUT, "FRMap_dun_dirt.png")) save_normal(down_normal(load("PebblesCATA_Normal.png")), os.path.join(OUT, "FRMap_dun_dirt_N.png")) rec.append({"name": "dirt", "from": "PebblesCATA_Albedo.png", "mean": [round(float(v), 1) for v in np.asarray(dirt).reshape(-1, 3).mean(axis=0)], "seam": round(seam(dirt), 2)}) # ---------- 점액이 고인 타일(얼룩진 타일 + 줄눈에 고인 녹색 점액 + 드문 웅덩이) base = np.asarray(made["tile_worn"]).astype(np.float64) / 255.0 h = np.asarray(load("CatacombsFloorA3_Height.png", "L").resize((SIZE, SIZE), Image.LANCZOS)).astype(np.float64) / 255.0 low = wrap_blur(h, 7.0) - h # 둘레보다 낮은 곳(줄눈 · 패인 곳) joints = smooth(low, 0.012, 0.05) joints = np.clip(wrap_blur(joints, 1.6) * 1.5, 0, 1) # 줄눈에서 조금 번지게 patch = smooth(pnoise(SIZE, 3.0, 4, 381), 0.42, 0.66) # 점액이 도는 구역(큰 얼룩) pool = smooth(pnoise(SIZE, 5.0, 4, 977), 0.70, 0.82) * smooth(pnoise(SIZE, 2.0, 3, 55), 0.40, 0.65) # 드문 웅덩이 slime = np.clip(joints * patch * 0.95 + pool * 0.9, 0, 1) fine = pnoise(SIZE, 24.0, 3, 412) lum = (base * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2) green = np.stack([0.20 + 0.08 * fine, 0.34 + 0.12 * fine, 0.09 + 0.04 * fine], axis=2) # 탁한 올리브빛(선명한 녹색은 페인트처럼 보였다) body = green * (0.55 + 0.9 * lum[..., None]) rim = smooth(slime, 0.15, 0.5) - smooth(slime, 0.5, 0.95) # 점액 가장자리는 조금 밝게(젖은 테) out = base * (1 - slime[..., None] * 0.78) + body * (slime[..., None] * 0.78) out = np.clip(out + rim[..., None] * np.array([0.02, 0.045, 0.008]), 0, 1) img = Image.fromarray((out * 255.0 + 0.5).astype(np.uint8)) img.save(os.path.join(OUT, "FRMap_dun_tile_slime.png")) n = normals["tile_worn"].copy() flat = np.zeros_like(n) flat[..., 2] = 1.0 k = (pool * 0.85 + joints * patch * 0.4)[..., None] k = np.clip(k, 0, 0.9) n = n * (1 - k) + flat * k # 웅덩이는 매끈하게 save_normal(n, os.path.join(OUT, "FRMap_dun_tile_slime_N.png")) made["tile_slime"] = img rec.append({"name": "tile_slime", "from": "tile_worn + 점액", "mean": [round(float(v), 1) for v in np.asarray(img).reshape(-1, 3).mean(axis=0)], "seam": round(seam(img), 2), "slime_share": round(float((slime > 0.5).mean()), 3)}) # ---------- 미리 보기: 16 m × 12 m 바닥을 1 m = 64 픽셀로(타일 한 장 = TILE m) — 사람 키(1.8 m) 막대와 함께 TILE = float(os.environ.get("BT_TILE", "4.0")) ppm = 64 W, H = 16, 12 rep = int(round(TILE * ppm)) def tiled(key): t = np.asarray(made[key].resize((rep, rep), Image.LANCZOS)).astype(np.float64) ny, nx = H * ppm // rep + 2, W * ppm // rep + 2 return np.tile(t, (ny, nx, 1))[:H * ppm, :W * ppm] yy, xx = np.mgrid[0:H * ppm, 0:W * ppm] r = np.random.default_rng(7) def blot(scale, seed): n = pnoise(1024, scale, 4, seed) return np.tile(n, (2, 2))[:H * ppm, :W * ppm] canvas = tiled("tile_worn") # 방 = 얼룩진 타일 path = smooth(np.abs(yy / ppm - 6.0), 3.4, 2.2) # 가운데 가로 통로 = 온전한 타일 canvas = canvas * (1 - path[..., None]) + tiled("tile") * path[..., None] dt = np.asarray(dirt.resize((int(round(5.0 * ppm)), int(round(5.0 * ppm))), Image.LANCZOS)).astype(np.float64) dt = np.tile(dt, (H * ppm // dt.shape[0] + 2, W * ppm // dt.shape[1] + 2, 1))[:H * ppm, :W * ppm] m = smooth(blot(2.0, 3), 0.55, 0.66)[..., None] * 0.92 # 흙 얼룩(5 m 로 깐다) canvas = canvas * (1 - m) + dt * m m = smooth(blot(3.0, 9), 0.62, 0.74)[..., None] * 0.9 # 점액 얼룩 canvas = canvas * (1 - m) + tiled("tile_slime") * m prev = Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8)) from PIL import ImageDraw d = ImageDraw.Draw(prev) d.rectangle([40, 40, 40 + int(0.6 * ppm), 40 + int(1.8 * ppm)], outline=(255, 60, 60), width=3) # 사람(0.6 × 1.8 m) d.line([40, 20, 40 + ppm, 20], fill=(255, 255, 0), width=4) # 1 m prev.save(os.path.join(OUT, "preview_floor_%sm.png" % ("%.1f" % TILE).replace(".", "_"))) for x in rec: print(json.dumps(x, ensure_ascii=False)) if "--project" in sys.argv: os.makedirs(DST, exist_ok=True) n = 0 for f in sorted(os.listdir(OUT)): # 1차에 만들었던 깨진 타일(tile_broken)은 쓰지 않는다 — 폴더에 남아 있어도 옮기지 않는다 if (f.startswith("FRMap_dun_tile") or f.startswith("FRMap_dun_dirt")) and f.endswith(".png") and "broken" not in f: shutil.copy2(os.path.join(OUT, f), os.path.join(DST, f)) n += 1 print("프로젝트로 복사", n, "→", DST) main()