840 lines
50 KiB
Python
840 lines
50 KiB
Python
# 레이드 보스 패턴 탑뷰 도식 생성기 (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()
|