BurningTimesAi/공유/기획/FirstRising/몬스터보스/tools/make_pattern_diagrams.py

840 lines
50 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.

# 레이드 보스 패턴 탑뷰 도식 생성기 (BT24-Plan · 2026-09-24)
# 아레나 60m × 40m 를 1200×800px 로 그린다(20px/m). 빨강 = 피해 범위(예고), 주황 = 잔류 장판, 초록 = 안전지대,
# 파랑 화살표 = 플레이어 회피 이동, 빨강 화살표 = 보스 이동·투사체, 점선 원 = 참고 거리. 보스 그림은 에디터 미리보기.
import os, json, math
from PIL import Image, ImageDraw, ImageFont
BASE = r"E:\BurningTimes\공유\기획\FirstRising\몬스터보스"
MON = os.path.join(BASE, "img", "monsters_all")
OUT = os.path.join(BASE, "img", "patterns")
os.makedirs(OUT, exist_ok=True)
W, H, S = 1200, 800, 20.0 # px, px, px per m
FONT = "C:/Windows/Fonts/malgunbd.ttf"; FONT_R = "C:/Windows/Fonts/malgun.ttf"
RED = (220, 60, 60); ORANGE = (240, 140, 40); GREEN = (60, 170, 90); BLUE = (40, 140, 230); INK = (40, 40, 40); GRAY = (130, 130, 130)
def f(size, bold=True):
return ImageFont.truetype(FONT if bold else FONT_R, size)
def px(x, y):
return (x * S, H - y * S)
def sprite(name, size):
p = os.path.join(MON, name.replace(" ", "_") + ".png")
im = Image.open(p).convert("RGBA")
bg = im.getpixel((2, 2))
px_ = im.load()
for yy in range(im.height):
for xx in range(im.width):
r, g, b, a = px_[xx, yy]
if abs(r - bg[0]) < 14 and abs(g - bg[1]) < 14 and abs(b - bg[2]) < 14:
px_[xx, yy] = (r, g, b, 0)
return im.resize((size, size), Image.LANCZOS)
class Diagram:
def __init__(self, title, boss_name, boss=(30, 20), player=(18, 26), boss_size=150, sub=""):
self.im = Image.new("RGBA", (W, H), (255, 255, 255, 255))
self.ov = Image.new("RGBA", (W, H), (0, 0, 0, 0))
self.d = ImageDraw.Draw(self.ov)
self.title, self.sub, self.boss_name, self.boss, self.player, self.boss_size = title, sub, boss_name, boss, player, boss_size
self.labels = []
self.sprites = []
# ---- 도형(단위 m) ----
def circle(self, c, r, kind="danger", outline=True):
col = {"danger": RED, "zone": ORANGE, "safe": GREEN, "ref": GRAY}[kind]
a = {"danger": 95, "zone": 110, "safe": 90, "ref": 0}[kind]
x, y = px(*c); rr = r * S
if kind == "ref":
# 참고 원은 점선 테두리만 — 투명 채우기로 그리면 아래 도형이 지워진다(2026-09-24 검수)
self._dashed_circle(c, r, col)
return
self.d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=col + (a,), outline=(col + (230,)) if outline else None, width=3)
def _dashed_circle(self, c, r, col):
x, y = px(*c); rr = r * S
for i in range(0, 360, 12):
a0, a1 = math.radians(i), math.radians(i + 6)
self.d.line([(x + rr * math.cos(a0), y - rr * math.sin(a0)), (x + rr * math.cos(a1), y - rr * math.sin(a1))], fill=col + (200,), width=2)
def fan(self, c, r, direction, spread, kind="danger"):
col = RED if kind == "danger" else ORANGE
# 부채꼴은 다각형으로 직접 그린다(pieslice 는 음수 각도에서 그려지지 않았다)
steps = max(8, int(spread / 4))
pts = [px(*c)]
for i in range(steps + 1):
t = math.radians(direction - spread / 2 + spread * i / steps)
pts.append(px(c[0] + r * math.cos(t), c[1] + r * math.sin(t)))
self.d.polygon(pts, fill=col + (95,), outline=col + (230,), width=3)
def beam(self, c, direction, length, width, kind="danger", start=0.0):
col = RED if kind == "danger" else ORANGE
dx, dy = math.cos(math.radians(direction)), math.sin(math.radians(direction))
nx, ny = -dy * width / 2, dx * width / 2
sx, sy = c[0] + dx * start, c[1] + dy * start
ex, ey = c[0] + dx * (start + length), c[1] + dy * (start + length)
pts = [px(sx + nx, sy + ny), px(ex + nx, ey + ny), px(ex - nx, ey - ny), px(sx - nx, sy - ny)]
self.d.polygon(pts, fill=col + (95,), outline=col + (230,), width=3)
def ring(self, c, r_in, r_out, kind="danger"):
col = RED if kind == "danger" else (GREEN if kind == "safe" else ORANGE)
x, y = px(*c)
for rr, w in ((r_out * S, 0),):
pass
# 링은 바깥 원을 그리고 안쪽을 지우는 대신, 다각형 띠로 근사
pts_out = [(x + r_out * S * math.cos(math.radians(t)), y - r_out * S * math.sin(math.radians(t))) for t in range(0, 361, 4)]
pts_in = [(x + r_in * S * math.cos(math.radians(t)), y - r_in * S * math.sin(math.radians(t))) for t in range(360, -1, -4)]
self.d.polygon(pts_out + pts_in, fill=col + (95,), outline=col + (230,), width=2)
def rect(self, c, w, h, kind="zone", angle=0):
col = ORANGE if kind == "zone" else (GREEN if kind == "safe" else RED)
cx, cy = c
a = math.radians(angle)
corners = []
for sx, sy in ((-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)):
rx, ry = sx * math.cos(a) - sy * math.sin(a), sx * math.sin(a) + sy * math.cos(a)
corners.append(px(cx + rx, cy + ry))
self.d.polygon(corners, fill=col + (100,), outline=col + (230,), width=3)
def arrow(self, a, b, color=BLUE, width=7, dashed=False, head=22):
x1, y1 = px(*a); x2, y2 = px(*b)
if dashed:
n = int(math.hypot(x2 - x1, y2 - y1) / 14)
for i in range(0, n, 2):
t0, t1 = i / n, min((i + 1) / n, 1)
self.d.line([(x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0), (x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)], fill=color + (230,), width=width)
else:
self.d.line([(x1, y1), (x2, y2)], fill=color + (230,), width=width)
ang = math.atan2(y2 - y1, x2 - x1)
p1 = (x2 - head * math.cos(ang - 0.45), y2 - head * math.sin(ang - 0.45))
p2 = (x2 - head * math.cos(ang + 0.45), y2 - head * math.sin(ang + 0.45))
self.d.polygon([(x2, y2), p1, p2], fill=color + (240,))
def dot(self, c, r=0.6, color=RED, text=None, fill=True):
x, y = px(*c); rr = r * S
self.d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=(color + (220,)) if fill else None, outline=color + (255,), width=3)
if text:
self.labels.append((c, text, color, "center"))
def sprite_at(self, name, c, size):
self.sprites.append((name, c, size))
def label(self, c, text, color=INK, anchor="left", leader=None):
self.labels.append((c, text, color, anchor, leader))
def player_icon(self, c=None, facing=None):
c = c or self.player
x, y = px(*c)
self.d.ellipse([x - 20, y - 20, x + 20, y + 20], fill=(40, 120, 220, 255), outline=(255, 255, 255, 255), width=4)
if facing is not None:
a = math.radians(facing)
self.d.polygon([(x + 30 * math.cos(a), y - 30 * math.sin(a)), (x + 14 * math.cos(a + 2.2), y - 14 * math.sin(a + 2.2)), (x + 14 * math.cos(a - 2.2), y - 14 * math.sin(a - 2.2))], fill=(40, 120, 220, 255))
self.labels.append(((c[0], c[1] - 1.9), "플레이어", (30, 90, 180), "center", None))
# ---- 저장 ----
def save(self, filename, notes=()):
base = self.im.copy()
base.alpha_composite(self.ov)
d = ImageDraw.Draw(base)
d.rectangle([6, 6, W - 7, H - 7], outline=(20, 20, 20, 255), width=5)
# 보스·소환물 스프라이트
for name, c, size in [(self.boss_name, self.boss, self.boss_size)] + self.sprites:
sp = sprite(name, size)
x, y = px(*c)
base.alpha_composite(sp, (int(x - size / 2), int(y - size * 0.62)))
d = ImageDraw.Draw(base)
# 보스 위치 점
x, y = px(*self.boss)
d.ellipse([x - 8, y - 8, x + 8, y + 8], fill=(200, 20, 20, 255), outline=(255, 255, 255, 255), width=2)
# 라벨(배경 상자 + 리더선)
for item in self.labels:
c, text, color, anchor = item[0], item[1], item[2], item[3]
leader = item[4] if len(item) > 4 else None
fnt = f(22)
tw = d.textlength(text, font=fnt); th = 26
x, y = px(*c)
if anchor == "center":
bx = x - tw / 2
elif anchor == "right":
bx = x - tw
else:
bx = x
d.rectangle([bx - 6, y - th / 2 - 3, bx + tw + 6, y + th / 2 + 3], fill=(255, 255, 255, 235), outline=color + (255,), width=2)
d.text((bx, y - th / 2), text, font=fnt, fill=color + (255,))
if leader:
lx, ly = px(*leader)
d.line([(x if anchor == "center" else (bx if anchor == "left" else bx + tw), y), (lx, ly)], fill=color + (255,), width=3)
d.ellipse([lx - 5, ly - 5, lx + 5, ly + 5], fill=color + (255,))
# 제목·부제
d.rectangle([14, 14, 14 + d.textlength(self.title, font=f(30)) + 24, 60], fill=(255, 255, 255, 240))
d.text((26, 18), self.title, font=f(30), fill=(30, 30, 30, 255))
if self.sub:
d.text((26, 66), self.sub, font=f(21, False), fill=(90, 90, 90, 255))
# 하단 메모
yy = H - 36 * len(notes) - 16
for n in notes:
d.rectangle([14, yy - 4, 14 + d.textlength(n, font=f(21, False)) + 20, yy + 28], fill=(255, 255, 255, 235))
d.text((24, yy), n, font=f(21, False), fill=(60, 60, 60, 255))
yy += 36
# 축척(우상단 — 하단 메모와 겹치지 않게)
d.line([(W - 230, 44), (W - 230 + 10 * S, 44)], fill=(60, 60, 60, 255), width=4)
d.text((W - 215, 14), "10 m", font=f(20, False), fill=(60, 60, 60, 255))
base.convert("RGB").save(os.path.join(OUT, filename), quality=92)
def ang(a, b):
return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0]))
manifest = []
def add(boss, phase, code, title, spec, build):
fn = f"{boss}_{code}.png"
dg = build()
manifest.append({"boss": boss, "phase": phase, "code": code, "title": title, "file": fn, **spec})
B = (30, 20) # 보스 기본 위치(m)
P = (18, 26) # 플레이어 기본 위치(m)
# ======================================================================= R1 타락한 검성
R1 = "EvilUndead7 Skin 1"
def r1_p11():
dg = Diagram("P1-1 연속베기 2연", R1, sub="부채꼴 120° · 반경 6 m · 예고 1.0초 → 1타 0.4초 후 2타 → 후딜 1.2초(반격 창)")
a = ang(B, P)
dg.fan(B, 6, a, 120)
dg.circle(B, 6, "ref")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] - 1, P[1] + 5.5))
dg.circle((P[0] - 1, P[1] + 5.5), 1.6, "safe")
dg.label((P[0] - 1, P[1] + 8.3), "옆(각도 밖)으로 3 m 회피", BLUE, "center")
dg.arrow(P, (P[0] - 6, P[1] - 4), dashed=True)
dg.label((P[0] - 6, P[1] - 6.2), "또는 뒤(반경 밖)로", BLUE, "center")
dg.label((B[0] + 7.5, B[1] + 6), "피해 200% → 250% · 넉백 2~4", RED, "left", leader=(B[0] + 4.5, B[1] + 3.5))
dg.label((B[0] + 7, B[1] - 6), "후딜 1.2초 = 반격 창(그로기 게이지 +1)", GREEN, "left")
dg.save(f"R1_P1-1.png", notes=["기존 스킬 1007101·1007102 그대로 · 예고 ArcRange FX · 크기 ×4 기준 반경"])
def r1_p12():
dg = Diagram("P1-2 검기", R1, sub="직선 폭 2.5 m · 길이 18 m · 투사체 속도 6 m/s · 관통 · 예고 0.8초")
a = ang(B, P)
dg.beam(B, a, 18, 2.5)
dg.arrow(B, (B[0] + 12 * math.cos(math.radians(a)), B[1] + 12 * math.sin(math.radians(a))), RED, 6)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 3 * math.cos(math.radians(a + 90)), P[1] + 3 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.5, "safe")
dg.label((side[0] - 4, side[1] + 3), "좌우 3 m 회피(구르기 1회)", BLUE, "center")
dg.label((B[0] + 8, B[1] + 7), "피해 400% · 넉백 6 · 관통(막을 수 없음)", RED, "left", leader=(B[0] + 5, B[1] + 2))
dg.save("R1_P1-2.png", notes=["기존 스킬 1007103 · Penetration + Knock[6,16] · FX Lana Slash_fire_long"])
def r1_p13():
dg = Diagram("P1-3 발구르기(신규 · 그로기 지정 패턴)", R1, sub="원형 반경 5 m · 예고 0.8초 · 넉업(Bounce) · 후딜 1.5초")
dg.circle(B, 5)
dg.ring(B, 5, 5.6, "danger")
dg.player_icon((B[0] - 3.5, B[1] - 2.5), facing=ang((B[0] - 3.5, B[1] - 2.5), B))
dg.arrow((B[0] - 3.5, B[1] - 2.5), (B[0] - 8.5, B[1] - 5.5)); dg.circle((B[0] - 8.5, B[1] - 5.5), 1.5, "safe")
dg.label((B[0] - 8.5, B[1] - 8.2), "원 밖(5 m)으로 회피", BLUE, "center")
dg.arrow((B[0] - 8.5, B[1] - 5.5), (B[0] - 4, B[1] - 3), GREEN, 6, dashed=True)
dg.label((B[0] + 7, B[1] - 6), "후딜 1.5초에 반격 3회 → 스태거 → 기절 5초", GREEN, "left")
dg.label((B[0] + 6.5, B[1] + 6.5), "맞으면 넉업 1.2초(추가 피해 취약)", RED, "left", leader=(B[0] + 3.5, B[1] + 3.5))
dg.save("R1_P1-3.png", notes=["Skill4 슬롯(26프레임) 신규 · Bounce[Height:3] · FX Mirza ShockWave + Red_clue Crack_ground"])
def r1_p21():
dg = Diagram("P2-1 웨폰 소환 — 검 11자루", R1, sub="반경 1~10 m 무작위로 검이 꽂힘(예고 1.5초) → 0~1초 간격으로 플레이어 방향 순차 발사(속도 11)")
import random
random.seed(7)
for i in range(11):
r = 2 + random.random() * 8; t = random.random() * 360
c = (B[0] + r * math.cos(math.radians(t)), B[1] + r * math.sin(math.radians(t)))
dg.dot(c, 0.5, RED)
dg.beam(c, ang(c, P), 9, 0.9)
dg.circle(B, 10, "ref")
dg.player_icon(P, facing=ang(P, B))
dg.circle((P[0] - 5, P[1] - 2), 2, "safe")
dg.arrow(P, (P[0] - 5, P[1] - 2))
dg.label((P[0] - 5, P[1] - 5), "검과 검 사이 = 안전지대(꽂힌 순간 판단)", BLUE, "center")
dg.label((B[0] + 11, B[1] + 9), "검 1자루 = 피해 300% · 11번 판단", RED, "left")
dg.save("R1_P2-1.png", notes=["기존 스킬 1007100(SpawnCount 11 · SpawnRange 1~10 · ProjectileSpeed 11) · 꽂힘 위치 표시 = CircleRange 소형"])
def r1_p22():
dg = Diagram("P2-2 분신(Skin2)", R1, sub="본체 반대편 8 m 에 분신 1기 소환(HP 15%) · 분신은 P1-1 연속베기만 사용 · 본체 공격이 우선")
D2 = (B[0] + 8, B[1] + 4)
dg.sprite_at("EvilUndead7 Skin 2", D2, 120)
dg.circle(D2, 5, "danger")
dg.label((D2[0] + 5.5, D2[1] + 5), "분신 — 연속베기만 · 처치 시 P2 종료 없음", RED, "left")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (B[0] - 4, B[1] - 3), GREEN, 6)
dg.label((B[0] - 6, B[1] - 6.5), "본체 우선(분신은 거리 유지)", GREEN, "center")
dg.label((B[0], B[1] + 8), "본체", INK, "center", leader=B)
dg.save("R1_P2-2.png", notes=["신규 · SpawnCount 1 소환(Skin2 모델) · FX MagicalDark Dark_Gate 등장"])
def r1_p23():
dg = Diagram("P2-3 회전 검풍", R1, sub="검 6자루가 반경 4 m 궤도로 6초 회전(Tornado) · 접근하면 초당 피해 150% · 거리 5 m 이상 유지")
dg.ring(B, 3.2, 4.8, "danger")
for t in range(0, 360, 60):
dg.dot((B[0] + 4 * math.cos(math.radians(t)), B[1] + 4 * math.sin(math.radians(t))), 0.5, RED)
dg.arrow((B[0] + 4.8, B[1]), (B[0] + 4.6, B[1] + 1.6), RED, 5)
dg.circle(B, 5.5, "ref")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] - 3, P[1] - 2)); dg.circle((P[0] - 3, P[1] - 2), 1.5, "safe")
dg.label((P[0] - 3, P[1] - 4.8), "5 m 밖에서 원거리 스킬(활·마법) 창", BLUE, "center")
dg.save("R1_P2-3.png", notes=["신규 · EnableProjectileTornado(OrbitSpeed) · Duration 6 · FX Red_clue Slash"])
def r1_p31():
dg = Diagram("P3-1 검기 3연", R1, sub="검기 3발을 -18° / 0° / +18° 로 동시 발사 · 폭 2.5 m · 길이 18 m · 예고 0.8초")
a = ang(B, P)
for off in (-18, 0, 18):
dg.beam(B, a + off, 18, 2.5)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 10 * math.cos(math.radians(a - 9)), B[1] + 10 * math.sin(math.radians(a - 9)))
dg.arrow(P, mid); dg.circle(mid, 1.3, "safe")
dg.label((mid[0] + 1, mid[1] - 3.5), "부채꼴 사이(9°)로 파고들기 — 가까울수록 틈이 좁다", BLUE, "left")
dg.save("R1_P3-1.png", notes=["1007103 확장 · SpawnCount 3 · ProjectileAnglePerSpawn 18"])
def r1_p32():
dg = Diagram("P3-2 처형 일격(방어 무시)", R1, sub="직선 폭 3 m · 길이 20 m · 예고 1.5초(느리지만 치명) · 피해 = 최대 체력 60%(안, 방어 무시) · 출혈 3스택")
a = ang(B, P)
dg.beam(B, a, 20, 3)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 3.5 * math.cos(math.radians(a + 90)), P[1] + 3.5 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.6, "safe")
dg.label((side[0] - 3, side[1] + 3.2), "예고 중 좌우 3.5 m — 구르기 무적 프레임 활용", BLUE, "center")
dg.label((B[0] + 9, B[1] + 8), "맞으면 체력 60% + 출혈 → 사실상 즉사 구간", RED, "left")
dg.save("R1_P3-2.png", notes=["신규 · 방어 계산 무시 %피해(개발 필요) · DashRange 예고 1.5초 · HitFeelDirector 슬로모 0.3초"])
def r1_p33():
dg = Diagram("P3-3 검우(劍雨)", R1, sub="아레나 전체에 검 20자루 낙하 · 각 원형 반경 2 m · 예고 0.6초 · 3파(1.5초 간격) · 파마다 위치 갱신")
import random
random.seed(11)
for i in range(20):
c = (6 + random.random() * 48, 5 + random.random() * 30)
dg.circle(c, 2)
dg.player_icon(P, facing=ang(P, B))
safe = (P[0] + 4, P[1] - 6)
dg.arrow(P, safe); dg.circle(safe, 1.6, "safe")
dg.label((safe[0], safe[1] - 3.2), "빈 곳으로 짧게 이동(달리기 3.5 m/s)", BLUE, "center")
dg.save("R1_P3-3.png", notes=["신규 · CircleRange ×20 · DamagePercentage 150 · 티어 3+ 30자루"])
R1_ARENA = lambda: None
# ======================================================================= R2 원소 거신
G1, G2, G3 = "Golem_1_Stones", "Golem_2_Rock-Cracks", "Golem_3_Crystal"
def r2_p11():
dg = Diagram("P1-1 내려찍기 + 충격파", G1, boss_size=190, sub="전방 4 m 지점 원형 반경 6 m(예고 1.2초) → 착지 충격파 링 6~12 m 로 확장(넉백 · 피해 120%)")
a = ang(B, P)
c = (B[0] + 4 * math.cos(math.radians(a)), B[1] + 4 * math.sin(math.radians(a)))
dg.circle(c, 6)
dg.ring(c, 6, 12, "zone")
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 5 * math.cos(math.radians(a + 90)), P[1] + 5 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.6, "safe")
dg.label((side[0], side[1] + 3), "옆으로 벗어나기 · 충격파는 넉백만(피해 낮음)", BLUE, "center")
dg.label((c[0] + 7, c[1] - 8), "충격파 6→12 m 확장 0.6초", ORANGE, "left")
dg.save("R2_P1-1.png", notes=["Skill0 슬롯(34프레임) · Knock[3] · FX Mirza Shockwave 2 + ExtremeFX EpicStomp"])
def r2_p12():
dg = Diagram("P1-2 낙석", G1, boss_size=190, sub="상공에서 바위 8개 낙하 · 각 원형 반경 2.5 m · 예고 1.0초 · 피해 180% · 낙석 자리는 3초간 파편(이동 감속 30%)")
import random
random.seed(3)
for i in range(8):
c = (B[0] - 14 + random.random() * 22, B[1] - 10 + random.random() * 20)
dg.circle(c, 2.5)
dg.player_icon(P, facing=ang(P, B))
safe = (P[0] - 4, P[1] + 4)
dg.arrow(P, safe); dg.circle(safe, 1.8, "safe")
dg.label((safe[0], safe[1] + 3.6), "예고 원 사이 빈 곳으로", BLUE, "center")
dg.save("R2_P1-2.png", notes=["Skill1 슬롯(34프레임) · SpawnCount 8 · SpawnRange 3~12 · FX Red_clue Crack_fx + gibs"])
def r2_p13():
dg = Diagram("P1-3 지진 파동", G1, boss_size=190, sub="직선 3갈래(-30°/0°/+30°) · 폭 2 m · 길이 25 m · 지면을 따라 8 m/s 로 진행 · 예고 1.0초 · 피해 150% · 기절 1초")
a = ang(B, P)
for off in (-30, 0, 30):
dg.beam(B, a + off, 25, 2)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 11 * math.cos(math.radians(a + 15)), B[1] + 11 * math.sin(math.radians(a + 15)))
dg.arrow(P, mid); dg.circle(mid, 1.5, "safe")
dg.label((mid[0], mid[1] + 3.2), "갈래 사이(15°)에 서기 · 가까울수록 좁다", BLUE, "center")
dg.save("R2_P1-3.png", notes=["Skill2 슬롯(47프레임) · SpawnCount 3 · Angle 30 · Stun[1] · FX Red_clue Crack_ground"])
def r2_p21():
dg = Diagram("P2-1 용암 장판(누적)", G2, boss_size=190, sub="거신이 걸은 자리마다 용암 웅덩이(반경 3 m · 20초 지속 · 초당 5% 화상) — 페이즈가 길수록 안전지대가 준다")
trail = [(B[0] - 12, B[1] + 6), (B[0] - 8, B[1] + 3), (B[0] - 4, B[1] + 1), (B[0], B[1]), (B[0] + 4, B[1] - 3)]
for c in trail:
dg.circle(c, 3, "zone")
dg.arrow((B[0] - 12, B[1] + 6), B, RED, 5, dashed=True)
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] + 2, P[1] - 7)); dg.circle((P[0] + 2, P[1] - 7), 2, "safe")
dg.label((P[0] + 2, P[1] - 10), "장판이 없는 쪽으로 거신을 유도(끌고 다니기)", BLUE, "center")
dg.label((B[0] + 7, B[1] + 6), "밟으면 화상(HitType 9) · 잔류 20초", ORANGE, "left")
dg.save("R2_P2-1.png", notes=["신규 · 이동 중 InvokeSkill(장판 스폰) · DotDamage[1s] · FX SineVFX ConjureLavaPool"])
def r2_p22():
dg = Diagram("P2-2 불기둥 3연(추적)", G2, boss_size=190, sub="플레이어 현재 위치에 원형 반경 3 m 불기둥 · 예고 0.8초 · 0.8초 간격 3회 · 피해 220% + 화상")
pts = [P, (P[0] + 3.5, P[1] - 2), (P[0] + 7, P[1] - 4)]
for i, c in enumerate(pts):
dg.circle(c, 3)
dg.label((c[0], c[1] + 4.2), f"{i + 1}", RED, "center")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] + 9, P[1] - 5.5))
dg.circle((P[0] + 9, P[1] - 5.5), 1.5, "safe")
dg.label((P[0] + 9, P[1] - 8.5), "한 방향으로 계속 달리기(멈추면 맞는다)", BLUE, "center")
dg.save("R2_P2-2.png", notes=["신규 · PlayCount 3 · CoolTime 0.8 · FX PyroParticles Flamestrike / Piloto AOE_Explosion_Fire"])
def r2_p23():
dg = Diagram("P2-3 화염 방사(회전)", G2, boss_size=190, sub="전방 부채꼴 60° · 반경 14 m · 4초간 90° 회전(시계 방향) · 초당 80% 화상 · 기둥 뒤는 차단")
a = ang(B, P)
dg.fan(B, 14, a, 60)
dg.fan(B, 14, a + 45, 60, "zone")
dg.arrow((B[0] + 15 * math.cos(math.radians(a + 5)), B[1] + 15 * math.sin(math.radians(a + 5))), (B[0] + 15 * math.cos(math.radians(a + 40)), B[1] + 15 * math.sin(math.radians(a + 40))), RED, 5)
dg.player_icon(P, facing=ang(P, B))
opp = (P[0] + 6 * math.cos(math.radians(a - 90)), P[1] + 6 * math.sin(math.radians(a - 90)))
dg.arrow(P, opp); dg.circle(opp, 1.6, "safe")
dg.label((opp[0], opp[1] - 3.2), "회전 반대 방향으로 돌기", BLUE, "center")
pil = (B[0] + 9 * math.cos(math.radians(a - 25)), B[1] + 9 * math.sin(math.radians(a - 25)))
dg.dot(pil, 0.9, GRAY, None); dg.label((pil[0] + 1.5, pil[1] + 2), "기둥 = 차단(BlockProjectile)", GRAY, "left", leader=pil)
dg.save("R2_P2-3.png", notes=["신규 · Duration 4 · 회전은 RelativeLocation 갱신 · FX PyroParticles Flamethrower"])
def r2_p31():
dg = Diagram("P3-1 수정 창", G3, boss_size=190, sub="바닥에서 솟는 수정 창 5갈래(60° 부채 안 등간격) · 폭 1.5 m · 길이 20 m · 예고 0.7초 · 피해 200% + 빙결 감속")
a = ang(B, P)
for off in (-30, -15, 0, 15, 30):
dg.beam(B, a + off, 20, 1.5)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 12 * math.cos(math.radians(a + 7.5)), B[1] + 12 * math.sin(math.radians(a + 7.5)))
dg.arrow(P, mid); dg.circle(mid, 1.2, "safe")
dg.label((mid[0] + 1, mid[1] + 3), "갈래 사이(7.5°) — 멀수록 넓다 → 거리 두기", BLUE, "left")
dg.save("R2_P3-1.png", notes=["신규 · SpawnCount 5 · Angle 15 · FX BlizzardSnow projectile / Hovl frost"])
def r2_p32():
dg = Diagram("P3-2 서리 폭발 3중 링", G3, boss_size=190, sub="반경 3~5 / 7~9 / 11~13 m 링이 0.5초 간격으로 폭발 · 틈(5~7 · 9~11 m)은 안전 · 예고 1.0초 · 피해 180% + 빙결")
dg.ring(B, 3, 5); dg.ring(B, 7, 9); dg.ring(B, 11, 13)
dg.ring(B, 5, 7, "safe"); dg.ring(B, 9, 11, "safe")
dg.player_icon(P, facing=ang(P, B))
a = ang(B, P)
tgt = (B[0] + 10 * math.cos(math.radians(a)), B[1] + 10 * math.sin(math.radians(a)))
dg.arrow(P, tgt)
dg.label((tgt[0] - 2, tgt[1] - 3.5), "초록 틈(9~11 m)에 서기", BLUE, "center")
dg.save("R2_P3-2.png", notes=["신규 · 링 3개 = 스킬 3개 연계(InvokeSkill) · FX Lana top_down_ice_circle"])
def r2_p33():
dg = Diagram("P3-3 빙결 감옥", G3, boss_size=190, sub="플레이어 위치에 원형 반경 3 m · 2.5초 뒤 빙결(이동 불가 2초) → 이어서 낙석이 오면 치명 · 예고 중 원 밖으로")
dg.circle(P, 3)
dg.player_icon(P, facing=ang(P, B))
out = (P[0] - 5, P[1] - 1)
dg.arrow(P, out); dg.circle(out, 1.6, "safe")
dg.label((out[0], out[1] - 3.3), "2.5초 안에 원 밖으로(구르기 1회면 충분)", BLUE, "center")
dg.circle((P[0] + 1, P[1] + 1), 2.5, "zone")
dg.label((P[0] + 5, P[1] + 5), "후속 낙석(P1-2)과 겹치면 회피 불가", ORANGE, "left", leader=(P[0] + 1, P[1] + 1))
dg.save("R2_P3-3.png", notes=["신규 · Daze[100%, 2초] · Duration 2.5 · FX MagicArsenal Frost + SineVFX Amethyst"])
# ======================================================================= R3 거미 여왕
Q, K = "GiantSpider_1", "AlienSpider_1"
def r3_p11():
dg = Diagram("P1-1 물기", Q, boss_size=180, sub="부채꼴 85° · 반경 7 m · 예고 0.9초 · 피해 100% + 넉백 2 · 쿨 6초")
a = ang(B, P)
dg.fan(B, 7, a, 85)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 4 * math.cos(math.radians(a + 90)), P[1] + 4 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.5, "safe")
dg.label((side[0], side[1] + 3), "옆으로 4 m(각도 밖)", BLUE, "center")
dg.save("R3_P1-1.png", notes=["기존 스킬 1002600(HitRange 3.5 · HitAngle 85 · Knock[2,5]) · ArcRange 예고"])
def r3_p12():
dg = Diagram("P1-2 산란 — 새끼 9마리", Q, boss_size=180, sub="반경 2~4 m 에 독거미 9마리(0~2초 간격) · 새끼는 플레이어에게 접근해 자폭 → 독 장판 반경 2 m 5초")
import random
random.seed(5)
for i in range(9):
r = 2 + random.random() * 2; t = random.random() * 360
c = (B[0] + r * math.cos(math.radians(t)), B[1] + r * math.sin(math.radians(t)))
dg.sprite_at(K, c, 60)
dg.circle(B, 4, "ref")
dg.player_icon(P, facing=ang(P, B))
dg.circle((P[0] + 5, P[1] - 3), 2, "zone")
dg.label((P[0] + 5, P[1] - 6), "자폭 후 독 장판(밟지 않기)", ORANGE, "center")
dg.arrow(P, (B[0] - 4, B[1] - 1), GREEN, 6, dashed=True)
dg.label((B[0] - 4, B[1] - 4.5), "광역기로 5초 안에 전멸 → 여왕 그로기", GREEN, "center")
dg.save("R3_P1-2.png", notes=["기존 스킬 1002601(SpawnCount 9 · SpawnRange 2~4) + 새끼 DeathSkill 1001800(DotDamage)"])
def r3_p13():
dg = Diagram("P1-3 거미줄 뿌리기", Q, boss_size=180, sub="전방 원형 반경 7 m · 예고 1.0초 · 피해 300% + 이동 속도 -20% 3초 — 감속 중엔 다음 패턴 회피가 어렵다")
a = ang(B, P)
c = (B[0] + 5 * math.cos(math.radians(a)), B[1] + 5 * math.sin(math.radians(a)))
dg.circle(c, 7)
dg.player_icon(P, facing=ang(P, B))
out = (P[0] - 6, P[1] + 1)
dg.arrow(P, out); dg.circle(out, 1.6, "safe")
dg.label((out[0], out[1] + 3.2), "원 밖으로 — 감속 저항 옵션이면 3초 → 1.5초", BLUE, "center")
dg.save("R3_P1-3.png", notes=["기존 스킬 1002602(HitRange 7 · Daze[20%,3s]) · CircleRange 예고 · FX 1002602_Web"])
def r3_p21():
dg = Diagram("P2-1 거미줄 띠(아레나 변화)", Q, boss_size=180, sub="페이즈 2 진입 시 거미줄 띠 4개 고정(폭 3 m) · 밟으면 이동 속도 -50% · 거미줄 기둥 파괴 시 띠 1개 제거")
dg.rect((30, 8), 44, 3, "zone"); dg.rect((30, 32), 44, 3, "zone"); dg.rect((12, 20), 3, 26, "zone"); dg.rect((48, 20), 3, 26, "zone")
for c in ((8, 8), (52, 32), (48, 6)):
dg.dot(c, 0.9, GRAY); dg.label((c[0], c[1] + 2.5), "거미줄 기둥", GRAY, "center")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] + 6, P[1] - 4), dashed=True)
dg.label((P[0] + 6, P[1] - 7), "띠를 밟지 않는 경로로 이동 계획", BLUE, "center")
dg.save("R3_P2-1.png", notes=["신규 · 아레나 오브젝트(Gimmick 파생) · Daze[50%] 지속 접촉 · FX AnimatedSpiderwebCollection"])
def r3_p22():
dg = Diagram("P2-2 독액 탄막", Q, boss_size=180, sub="투사체 5발 부채꼴(16° 간격) · 속도 7 · 폭 0.8 m · 피해 100% + 중독(초당 5% × 5스택) · 2회 연사")
a = ang(B, P)
for off in (-32, -16, 0, 16, 32):
dg.beam(B, a + off, 16, 0.8)
dg.arrow(B, (B[0] + 11 * math.cos(math.radians(a + off)), B[1] + 11 * math.sin(math.radians(a + off))), RED, 4)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 9 * math.cos(math.radians(a + 8)), B[1] + 9 * math.sin(math.radians(a + 8)))
dg.arrow(P, mid); dg.circle(mid, 1.2, "safe")
dg.label((mid[0] + 1, mid[1] + 3), "탄 사이(8°)로 — 멀수록 틈이 넓다", BLUE, "left")
dg.save("R3_P2-2.png", notes=["신규 · 1007301 방식(SpawnCount 5 · Angle 16 · DotDamageAddiction) · FX GabrielAguiar Projectile_Poisoned"])
def r3_p23():
dg = Diagram("P2-3 급습 점프", Q, boss_size=180, sub="플레이어 위치에 원형 반경 4 m 예고 1.0초 → 여왕이 점프해 착지(넉업 · 피해 250%) · 착지 후 후딜 1.5초")
dg.circle(P, 4)
dg.arrow(B, (P[0] + 1, P[1] + 1), RED, 6, dashed=True)
dg.player_icon(P, facing=ang(P, B))
out = (P[0] - 6, P[1] - 2)
dg.arrow(P, out); dg.circle(out, 1.6, "safe")
dg.label((out[0], out[1] - 3.3), "원 밖으로 → 착지 후딜에 반격", BLUE, "center")
dg.save("R3_P2-3.png", notes=["1002201(켄타우로스 점프) 방식 재사용 · Bounce · CircleRange 예고 · Landing FX"])
def r3_p31():
dg = Diagram("P3-1 독안개(안전지대 축소)", Q, boss_size=180, sub="아레나 외곽에서 안쪽으로 독안개 확산 · 60초 동안 안전 반경 20 m → 10 m · 안개 안은 초당 4% 중독 · 새끼 산란은 계속")
dg.ring(B, 20, 32, "zone"); dg.ring(B, 10, 20, "zone")
dg.circle(B, 10, "safe")
dg.label((B[0] + 11, B[1] + 11), "10 m 안만 안전(60초 후)", GREEN, "left")
dg.label((B[0] - 26, B[1] + 14), "독안개 — 밖에 있으면 중독 누적", ORANGE, "left")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (B[0] - 6, B[1] - 3))
dg.save("R3_P3-1.png", notes=["신규 · 링형 DotDamage 장판 반경 시간 축소 · FX Piloto Mist AOE"])
def r3_p32():
dg = Diagram("P3-2 연속 돌진 3회", Q, boss_size=180, sub="직선 돌진 폭 3 m · 길이 25 m · 예고 0.6초 · 3회(방향은 매번 플레이어 재조준) · 피해 200% · 거미줄 위에서는 회피 불가")
a = ang(B, P)
dg.beam(B, a, 25, 3)
dg.arrow(B, (B[0] + 20 * math.cos(math.radians(a)), B[1] + 20 * math.sin(math.radians(a))), RED, 7)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 4 * math.cos(math.radians(a + 90)), P[1] + 4 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.5, "safe")
dg.label((side[0], side[1] + 3), "좌우 회피 × 3 — 거미줄 띠 밖에서만 가능", BLUE, "center")
dg.save("R3_P3-2.png", notes=["1006500(거인 돌진) 방식 · Move[Speed:14,Distance:25] · PlayCount 3 · DashRange 예고"])
def r3_p33():
dg = Diagram("P3-3 여왕의 포옹(끌어당김)", Q, boss_size=180, sub="플레이어를 여왕 앞으로 끌어당김(Pull 1.2초) → 물기 부채꼴 · 끌리는 동안 구르기 입력이면 탈출(회피 성공 = 그로기 게이지 +1)")
dg.arrow(P, (B[0] - 3, B[1] - 1), RED, 7)
dg.fan(B, 6, ang(B, P), 85)
dg.player_icon(P, facing=ang(P, B))
esc = (P[0] - 3, P[1] + 4)
dg.arrow(P, esc); dg.circle(esc, 1.5, "safe")
dg.label((esc[0], esc[1] + 3.2), "끌리는 중 구르기 = 탈출(타이밍 0.4초 창)", BLUE, "center")
dg.save("R3_P3-3.png", notes=["신규 · Pull[Duration:1.2] → InvokeSkill(물기) · 회피 입력 판정은 개발 필요"])
# ======================================================================= R4 역병 군주
M8, ZG = "Mutant_Monster_8_3", "ZombieGrounded 1"
def r4_p11():
dg = Diagram("P1-1 포자 폭발", M8, boss_size=170, sub="군주 중심 원형 반경 5 m · 예고 0.9초 · 피해 250% + 중독(초당 5% × 5스택) · 쿨 15초")
dg.circle(B, 5)
dg.player_icon((B[0] - 3, B[1] - 2.5), facing=ang((B[0] - 3, B[1] - 2.5), B))
out = (B[0] - 8.5, B[1] - 5)
dg.arrow((B[0] - 3, B[1] - 2.5), out); dg.circle(out, 1.6, "safe")
dg.label((out[0], out[1] - 3.3), "거리 5 m 밖으로 — 근접 무기는 자주 빠져야 한다", BLUE, "center")
dg.save("R4_P1-1.png", notes=["기존 스킬 1007300(HitRange 5 · DotDamageAddiction) · CircleRange 예고 · FX 06_HitPoision"])
def r4_p12():
dg = Diagram("P1-2 포자탄 5 × 3", M8, boss_size=170, sub="투사체 5발 부채꼴(18°) 을 3회 연사(1.2배속) · 속도 7 · 폭 0.8 m · 피해 100% + 중독 · 떨어진 자리는 P2 부터 오염 장판")
a = ang(B, P)
for off in (-36, -18, 0, 18, 36):
dg.beam(B, a + off, 15, 0.8)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 9 * math.cos(math.radians(a + 9)), B[1] + 9 * math.sin(math.radians(a + 9)))
dg.arrow(P, mid); dg.circle(mid, 1.2, "safe")
dg.label((mid[0] + 1, mid[1] + 3), "탄 사이(9°) · 3연사이므로 같은 틈을 유지", BLUE, "left")
dg.save("R4_P1-2.png", notes=["기존 스킬 1007301(SpawnCount 5 · PlayCount 3 · Angle 18) 그대로"])
def r4_p13():
dg = Diagram("P1-3 할퀴기", M8, boss_size=170, sub="전방 부채꼴 50° · 반경 5 m · 예고 0.7초 · 피해 150% + 중독 · 쿨 7초 · 후딜 1.0초(반격 창)")
a = ang(B, P)
dg.fan(B, 5, a, 50)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 3 * math.cos(math.radians(a + 90)), P[1] + 3 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.4, "safe")
dg.label((side[0], side[1] + 2.9), "좁은 각(50°) — 옆으로 3 m 면 충분 · 후딜 반격", BLUE, "center")
dg.save("R4_P1-3.png", notes=["기존 스킬 1007302(HitRange 5 · HitAngle 50) · ArcRange 예고"])
def r4_p21():
dg = Diagram("P2-1 감염체 소환(시체에서 부활)", M8, boss_size=170, sub="쓰러진 몬스터 자리마다 기어오는 좀비(A2)가 일어남 · 접근 후 자폭(시체 폭발 반경 2 m 150%) · 시체 위치를 기억할 것")
for c in ((B[0] - 10, B[1] + 6), (B[0] - 6, B[1] - 8), (B[0] + 9, B[1] + 7), (B[0] + 12, B[1] - 5)):
dg.sprite_at(ZG, c, 70)
dg.circle(c, 2, "zone")
dg.player_icon(P, facing=ang(P, B))
dg.label((B[0] - 10, B[1] + 10), "시체 자리 = 부활 예고 2초", ORANGE, "center")
dg.arrow(P, (P[0] + 3, P[1] - 8)); dg.circle((P[0] + 3, P[1] - 8), 1.8, "safe")
dg.label((P[0] + 3, P[1] - 11), "시체가 없는 곳에서 싸우기 · 원거리로 처리", BLUE, "center")
dg.save("R4_P2-1.png", notes=["신규 · 시체 위치 기록 → SpawnCount N(ZombieGrounded) · DeadBodyExplosion · FX gibs"])
def r4_p22():
dg = Diagram("P2-2 오염 장판(잔류)", M8, boss_size=170, sub="포자탄이 떨어진 자리에 오염 장판 반경 2.5 m · 30초 잔류 · 밟으면 초당 4% 중독 · 페이즈 2 동안 안전지대가 계속 준다")
import random
random.seed(9)
for i in range(9):
c = (B[0] - 14 + random.random() * 26, B[1] - 10 + random.random() * 18)
dg.circle(c, 2.5, "zone")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] - 4, P[1] + 5)); dg.circle((P[0] - 4, P[1] + 5), 1.8, "safe")
dg.label((P[0] - 4, P[1] + 8), "장판 사이로 이동 · 독 저항 옵션이면 4% → 2%", BLUE, "center")
dg.save("R4_P2-2.png", notes=["신규 · 포자탄 InvokeSkillOnDestroy → 장판(DotDamage 30s) · FX Piloto AcidSplash"])
def r4_p23():
dg = Diagram("P2-3 역병 돌진", M8, boss_size=170, sub="직선 돌진 폭 3 m · 길이 20 m · 속도 14 · 예고 0.7초 · 피해 200% · 지나간 자리는 오염 장판(폭 3 m · 15초)")
a = ang(B, P)
dg.beam(B, a, 20, 3)
dg.beam(B, a, 20, 3, "zone")
dg.arrow(B, (B[0] + 16 * math.cos(math.radians(a)), B[1] + 16 * math.sin(math.radians(a))), RED, 7)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 4 * math.cos(math.radians(a + 90)), P[1] + 4 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.5, "safe")
dg.label((side[0], side[1] + 3), "좌우 4 m — 돌진 경로는 밟지 말 것(오염)", BLUE, "center")
dg.save("R4_P2-3.png", notes=["1006500 방식 · Move[14,20] · 경로 장판은 InvokeSkill · DashRange 예고"])
def r4_p31():
dg = Diagram("P3-1 포자 구름 + 정화의 불", M8, boss_size=170, sub="아레나 전체 중독 +1스택/초(최대 5) · 정화의 불 3곳 반경 4 m 안에서만 스택이 감소 · 불은 60초마다 꺼지며 '점화' 상호작용 필요")
dg.rect((30, 20), 58, 38, "zone")
fires = [(10, 10), (50, 12), (30, 34)]
for c in fires:
dg.circle(c, 4, "safe"); dg.dot(c, 0.8, ORANGE); dg.label((c[0], c[1] - 5.5), "정화의 불(점화 필요)", GREEN, "center")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (12, 12))
dg.label((B[0] + 8, B[1] - 12), "구름 안 = 스택 누적 → 5스택이면 초당 10%", ORANGE, "left")
dg.save("R4_P3-1.png", notes=["신규 · 전역 DotDamageAddiction + 정화 구역 · Gimmick AddHealth 재정의 · FX Piloto Mist / PyroParticles CampFire"])
def r4_p32():
dg = Diagram("P3-2 감염체 대폭발", M8, boss_size=170, sub="살아 있는 감염체 전부가 동시에 폭발(반경 3 m · 피해 300%) · 예고 2초(감염체가 붉게 부풀음) · 감염체 근처 3 m 밖으로")
for c in ((B[0] - 9, B[1] + 5), (B[0] - 4, B[1] - 7), (B[0] + 8, B[1] + 6)):
dg.sprite_at(ZG, c, 70); dg.circle(c, 3)
dg.player_icon(P, facing=ang(P, B))
out = (P[0] - 2, P[1] - 7)
dg.arrow(P, out); dg.circle(out, 1.8, "safe")
dg.label((out[0], out[1] - 3.4), "예고 2초 — 감염체 3 m 밖(정화의 불 쪽)으로", BLUE, "center")
dg.save("R4_P3-2.png", notes=["신규 · 전체 감염체 DeadBodyExplosion 트리거 · FX Piloto AcidicExplosion"])
def r4_p33():
dg = Diagram("P3-3 군주의 숨결", M8, boss_size=170, sub="전방 부채꼴 90° · 반경 12 m · 4초 지속(회전 없음) · 초당 60% + 중독 · 예고 1.0초 · 숨결 중 군주 정지 = 등 뒤 반격 창")
a = ang(B, P)
dg.fan(B, 12, a, 90)
dg.player_icon(P, facing=ang(P, B))
back = (B[0] - 5 * math.cos(math.radians(a)), B[1] - 5 * math.sin(math.radians(a)))
dg.arrow(P, back, BLUE, 7); dg.circle(back, 1.6, "safe")
dg.label((back[0] - 6, back[1] - 3.5), "등 뒤로 돌아가 4초간 반격(약점 ×1.5)", GREEN, "center")
dg.save("R4_P3-3.png", notes=["신규 · Duration 4 · HitAngle 90 · FX Piloto AOECone_Mist 재색(독)"])
# ======================================================================= R5 혈흔의 사형집행인
M9, M10 = "Mutant_Monster_9_1", "Mutant_Monster_10_1"
def r5_p11():
dg = Diagram("P1-1 회전 도끼 던지기 5방향", M9, boss_size=170, sub="회전 도끼 5개를 18° 간격 부채꼴로 투척 · 속도 9 · 폭 1.6 m · 길이 15 m · 피해 200% · 도끼는 착지 지점에 남는다(P2-1 위험선)")
a = ang(B, P)
for off in (-36, -18, 0, 18, 36):
dg.beam(B, a + off, 15, 1.6)
end = (B[0] + 15 * math.cos(math.radians(a + off)), B[1] + 15 * math.sin(math.radians(a + off)))
dg.dot(end, 0.5, RED)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 8 * math.cos(math.radians(a + 9)), B[1] + 8 * math.sin(math.radians(a + 9)))
dg.arrow(P, mid); dg.circle(mid, 1.2, "safe")
dg.label((mid[0] + 1, mid[1] + 3), "도끼 사이(9°) · 착지한 도끼 위치를 기억", BLUE, "left")
dg.save("R5_P1-1.png", notes=["기존 스킬 1007400(SpawnCount 5 · Angle 18 · Speed 9) · FX 06_SpinningAxe 재활용"])
def r5_p12():
dg = Diagram("P1-2 혈흔비(장판)", M9, boss_size=170, sub="반경 10 m 안 무작위 지점에 원형 반경 4 m 혈흔비 · 7초 지속 · 초당 120% × 최대 7회 · 예고 1.0초")
c = (B[0] - 6, B[1] + 5)
dg.circle(B, 10, "ref")
dg.circle(c, 4, "zone")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] - 3, P[1] - 6)); dg.circle((P[0] - 3, P[1] - 6), 1.6, "safe")
dg.label((P[0] - 3, P[1] - 9), "장판 밖으로 — 7초간 그 자리는 버린다", BLUE, "center")
dg.label((c[0], c[1] + 6), "혈흔비 · 출혈 저항이면 회당 120% → 80%", ORANGE, "center")
dg.save("R5_P1-2.png", notes=["기존 스킬 1007401(SpawnRange 10 · HitRange 4 · DotDamage 7회) · FX EffectCore Blood_Dripping"])
def r5_p13():
dg = Diagram("P1-3 처형 강타(방어 무시)", M9, boss_size=170, sub="전방 직선 폭 3 m · 길이 9 m · 예고 1.2초 · 피해 = 최대 체력 60%(안, 방어 무시) + 출혈 3스택 · 회피 성공 후딜 1.4초 = 그로기 지정 패턴")
a = ang(B, P)
dg.beam(B, a, 9, 3)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 3.5 * math.cos(math.radians(a + 90)), P[1] + 3.5 * math.sin(math.radians(a + 90)))
dg.arrow(P, side); dg.circle(side, 1.6, "safe")
dg.label((side[0], side[1] + 3.2), "좌우 3.5 m 구르기 → 후딜 반격(3회 누적 = 무릎)", BLUE, "center")
dg.label((B[0] + 7, B[1] - 6), "맞으면 체력 60% + 출혈 = 사실상 즉사", RED, "left")
dg.save("R5_P1-3.png", notes=["1007402 재정의(HitRange 4.5 → 직선 9 m) · 방어 무시 %피해 개발 필요 · DashRange 예고"])
def r5_p21():
dg = Diagram("P2-1 도끼 회수 돌진", M9, boss_size=170, sub="P1-1 에서 착지한 도끼 위치로 직선 돌진(폭 3 m · 속도 12) · 예고 0.8초 · 도끼 사이에 서 있으면 경로에 걸린다 · 피해 180%")
a = ang(B, P)
axe = (B[0] + 13 * math.cos(math.radians(a + 18)), B[1] + 13 * math.sin(math.radians(a + 18)))
for off in (-36, -18, 0, 18, 36):
dg.dot((B[0] + 13 * math.cos(math.radians(a + off)), B[1] + 13 * math.sin(math.radians(a + off))), 0.5, RED)
dg.beam(B, a + 18, 13, 3)
dg.arrow(B, axe, RED, 7)
dg.label((axe[0] + 1, axe[1] + 2.5), "도끼 = 위험선(회수 목표)", RED, "left", leader=axe)
dg.player_icon(P, facing=ang(P, B))
side = (P[0] + 4 * math.cos(math.radians(a - 60)), P[1] + 4 * math.sin(math.radians(a - 60)))
dg.arrow(P, side); dg.circle(side, 1.5, "safe")
dg.label((side[0], side[1] - 3), "도끼와 보스를 잇는 선 밖으로", BLUE, "center")
dg.save("R5_P2-1.png", notes=["신규 · Move[12, 도끼 거리] · 도끼 오브젝트 위치 참조(개발) · DashRange 예고"])
def r5_p22():
dg = Diagram("P2-2 혈흔 폭우 3개", M9, boss_size=170, sub="혈흔비 장판 3개 동시(반경 4 m · 10초) · 겹치는 곳은 피해 2배 · 안전지대가 1/3로 준다 · 예고 1.0초")
for c in ((B[0] - 8, B[1] + 4), (B[0] + 2, B[1] - 8), (B[0] + 9, B[1] + 6)):
dg.circle(c, 4, "zone")
dg.player_icon(P, facing=ang(P, B))
dg.arrow(P, (P[0] - 5, P[1] + 4)); dg.circle((P[0] - 5, P[1] + 4), 1.8, "safe")
dg.label((P[0] - 5, P[1] + 7), "장판 사이 빈 곳 — 10초 버티기(이때 원거리 딜)", BLUE, "center")
dg.save("R5_P2-2.png", notes=["1007401 확장 · SpawnCount 3 · Duration 10 · FX FXVille_BloodPack"])
def r5_p23():
dg = Diagram("P2-3 처형 2연(강타 → 회전 베기)", M9, boss_size=170, sub="처형 강타(직선 9 m · 예고 1.2초) 직후 0.6초 뒤 360° 회전 베기(반경 4 m · 250%) — 첫 회피 뒤 곧바로 거리 벌리기")
a = ang(B, P)
dg.beam(B, a, 9, 3)
dg.circle(B, 4)
dg.player_icon(P, facing=ang(P, B))
s1 = (P[0] + 3.5 * math.cos(math.radians(a + 90)), P[1] + 3.5 * math.sin(math.radians(a + 90)))
s2 = (s1[0] - 4 * math.cos(math.radians(a)), s1[1] - 4 * math.sin(math.radians(a)))
dg.arrow(P, s1); dg.arrow(s1, s2); dg.circle(s2, 1.6, "safe")
dg.label((s2[0] - 1, s2[1] - 3.3), "① 옆 회피 → ② 4 m 밖으로(회전 베기 반경 밖)", BLUE, "center")
dg.save("R5_P2-3.png", notes=["신규 · InvokeSkill 연계(강타 → 360° 스킬) · HitAngle 360 · FX Red_clue Combo_slash"])
def r5_p31():
dg = Diagram("P3-1 거대 변이체 난입", M9, boss_size=170, sub="아레나 가장자리에서 거대 변이체(주먹 좀비, HP 30%)가 난입 · 강타 반경 3 m · 감염 좀비 3마리 소환 · 우선순위 판단")
E = (48, 12)
dg.sprite_at(M10, E, 150)
dg.circle(E, 3)
dg.arrow((57, 6), E, RED, 6, dashed=True)
dg.label((E[0] - 1, E[1] - 5.5), "난입 — 강타(1007500) · 소환 3(1007501)", RED, "center")
dg.player_icon(P, facing=ang(P, B))
dg.label((B[0] - 2, B[1] + 8), "사형집행인 — 패턴 계속", INK, "center", leader=B)
dg.arrow(P, (E[0] - 6, E[1] + 2), GREEN, 6, dashed=True)
dg.label((E[0] - 8, E[1] + 5.5), "선택: 변이체를 먼저 정리(회복 대가) vs 무시", GREEN, "center")
dg.save("R5_P3-1.png", notes=["신규 · SpawnCount 1(Mutant_10_1) · 기존 스킬 1007500~03 그대로"])
def r5_p32():
dg = Diagram("P3-2 피의 계약", M9, boss_size=170, sub="변이체가 죽으면 사형집행인이 체력 10%(안) 회복하고 30초간 공격 속도 +20% — 변이체를 잡을지, 살려 두고 본체를 칠지 판단")
E = (44, 12)
dg.sprite_at(M10, E, 150)
dg.arrow(E, (B[0] + 2, B[1] - 1), ORANGE, 8)
dg.label((B[0] + 9, B[1] - 6), "변이체 사망 → 본체 +10% 회복 · 공속 +20%", ORANGE, "left")
dg.player_icon(P, facing=ang(P, B))
dg.label((P[0] - 2, P[1] + 5), "판단: 변이체 무시 + 본체 DPS 컷 vs 정리 후 안정", BLUE, "center")
dg.save("R5_P3-2.png", notes=["신규 · 변이체 사망 이벤트 → 보스 회복(InvokeSkillOnDestroy 응용)"])
def r5_p33():
dg = Diagram("P3-3 최후의 처형 5갈래", M9, boss_size=170, sub="직선 5갈래(120° 안 등간격 30°) · 폭 3 m · 길이 30 m(아레나 관통) · 예고 1.5초 · 피해 = 최대 체력 60% + 출혈 — 갈래 사이에 서기")
a = ang(B, P)
for off in (-60, -30, 0, 30, 60):
dg.beam(B, a + off, 30, 3)
dg.player_icon(P, facing=ang(P, B))
mid = (B[0] + 12 * math.cos(math.radians(a + 15)), B[1] + 12 * math.sin(math.radians(a + 15)))
dg.arrow(P, mid); dg.circle(mid, 1.6, "safe")
dg.label((mid[0] + 1, mid[1] + 3.3), "갈래 사이(15°) — 거리 10 m 이상이면 틈 5 m", BLUE, "left")
dg.save("R5_P3-3.png", notes=["신규 · SpawnCount 5 · Angle 30 · 방어 무시 % · DTT Line Region 예고"])
# ======================================================================= 아레나 도식
def arena(boss_name, code, title, sub, objects, notes, boss_size=190):
dg = Diagram(title, boss_name, boss=(30, 20), player=(14, 10), boss_size=boss_size, sub=sub)
for kind, c, r, text in objects:
if kind == "pillar":
dg.dot(c, r, GRAY); dg.label((c[0], c[1] + r + 1.8), text, GRAY, "center")
elif kind == "safe":
dg.circle(c, r, "safe"); dg.label((c[0], c[1] - r - 1.6), text, GREEN, "center")
elif kind == "zone":
dg.circle(c, r, "zone"); dg.label((c[0], c[1] - r - 1.6), text, ORANGE, "center")
elif kind == "strip":
dg.rect(c, r[0], r[1], "zone"); dg.label((c[0], c[1] + r[1] / 2 + 1.8), text, ORANGE, "center")
elif kind == "fence":
dg.ring(c, r, r + 1.2, "danger"); dg.label((c[0], c[1] + r + 2.2), text, RED, "center")
dg.player_icon((14, 10), facing=ang((14, 10), (30, 20)))
dg.label((30, 20 - 8), "보스 시작 위치", INK, "center", leader=(30, 20))
dg.save(f"{code}_ARENA.png", notes=notes)
def build_all():
for fn in (r1_p11, r1_p12, r1_p13, r1_p21, r1_p22, r1_p23, r1_p31, r1_p32, r1_p33,
r2_p11, r2_p12, r2_p13, r2_p21, r2_p22, r2_p23, r2_p31, r2_p32, r2_p33,
r3_p11, r3_p12, r3_p13, r3_p21, r3_p22, r3_p23, r3_p31, r3_p32, r3_p33,
r4_p11, r4_p12, r4_p13, r4_p21, r4_p22, r4_p23, r4_p31, r4_p32, r4_p33,
r5_p11, r5_p12, r5_p13, r5_p21, r5_p22, r5_p23, r5_p31, r5_p32, r5_p33):
fn()
arena(R1, "R1", "아레나 — 납골당 원형 홀", "지름 40 m · 배틀펜스 봉쇄 · 기둥 4(파괴 시 회복 기믹 1회) · 그로기: P1-3 발구르기 회피+반격 3회",
[("fence", (30, 20), 19, "배틀펜스(경계 · 밖으로 못 나감)"), ("pillar", (16, 30), 1.2, "기둥(회복 1회)"), ("pillar", (44, 30), 1.2, "기둥"), ("pillar", (16, 10), 1.2, "기둥"), ("pillar", (44, 10), 1.2, "기둥")],
["페이즈 70/40/15% · 시간 제한 10분(안) · 티어 3+ 4페이즈"])
arena(G1, "R2", "아레나 — 망루 폐허 지하 광장", "40 × 30 m · 기둥 6(용암 페이즈 화염 차단) · 그로기: 강타 회피 후 등 뒤 반격 3회",
[("fence", (30, 20), 19, "배틀펜스"), ("pillar", (18, 30), 1.4, "기둥(차단)"), ("pillar", (30, 32), 1.4, "기둥"), ("pillar", (42, 30), 1.4, "기둥"), ("pillar", (18, 10), 1.4, "기둥"), ("pillar", (30, 8), 1.4, "기둥"), ("pillar", (42, 10), 1.4, "기둥")],
["페이즈마다 모델 교체(Dissolve) · 용암 장판은 페이즈 3까지 잔류"])
arena(Q, "R3", "아레나 — 다리목 아래 동굴", "지름 40 m · 거미줄 기둥 3(파괴 시 띠 1개 제거) · P2 부터 거미줄 띠 4 · 그로기: 산란 5초 내 새끼 전멸",
[("fence", (30, 20), 19, "배틀펜스"), ("strip", (30, 8), (36, 3), "거미줄 띠(P2)"), ("strip", (30, 32), (36, 3), "거미줄 띠(P2)"), ("pillar", (12, 20), 1.2, "거미줄 기둥"), ("pillar", (48, 20), 1.2, "거미줄 기둥"), ("pillar", (30, 36), 1.2, "거미줄 기둥")],
["P3 독안개는 외곽에서 중심으로 60초 축소"], boss_size=180)
arena(M8, "R4", "아레나 — 던전 심부", "40 × 30 m · 정화의 불 3(점화 상호작용 · 반경 4 m 안전) · 그로기: 불 3개 켠 채 대폭발 넘기기",
[("fence", (30, 20), 19, "배틀펜스"), ("safe", (14, 12), 4, "정화의 불"), ("safe", (46, 12), 4, "정화의 불"), ("safe", (30, 33), 4, "정화의 불")],
["P2 오염 장판 · P3 포자 구름은 정화의 불 밖 전역"], boss_size=170)
arena(M9, "R5", "아레나 — 강 건너 마을 처형장", "40 × 30 m · 단두대(기믹, 파괴 시 도끼 방향 -1) · P3 난입 진입로 · 그로기: 처형 강타 3연속 회피+반격",
[("fence", (30, 20), 19, "배틀펜스"), ("pillar", (30, 33), 1.6, "단두대(파괴 가능)"), ("zone", (50, 8), 3, "난입 진입로(P3)")],
["혈흔 장판은 최대 3개 동시 · 시간 제한 12분(안)"], boss_size=170)
print("diagrams:", len(os.listdir(OUT)))
if __name__ == "__main__":
build_all()