← .mpy 공유
사용자 공유게임창의·예술

Geo Shatter — 도형 조각 퍼즐 게임

흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임

English

A 10-level wooden puzzle game: drag, rotate and flip shards to rebuild the shape

Geo Shatter — 도형 조각 퍼즐 게임 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

Geo Shatter — 도형 조각 퍼즐 게임

설명

흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임

필요 데이터

필요 없음 (데이터 없이 바로 실행)

출력

퍼즐 게임 창 — 좌클릭 드래그 이동 · 휠 회전 · 우클릭 뒤집기

필요 패키지

  • matplotlib
  • numpy
  • pandas

태그

game, creative

작성자

MDS 메이커 | MDS 1.0

Source · 1041줄 (실행 전에 꼭 확인하세요 — 펼치기)
# MDS_TITLE: Geo Shatter — 도형 조각 퍼즐 게임
# MDS_REQUIRES: matplotlib, numpy, pandas
# MDS_DESC: 흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임
# MDS_DESC[en]: A 10-level wooden puzzle game: drag, rotate and flip shards to rebuild the shape
# MDS_TAGS: game, creative
# MDS_INPUT: 필요 없음 (데이터 없이 바로 실행)
# MDS_OUTPUT: 퍼즐 게임 창 — 좌클릭 드래그 이동 · 휠 회전 · 우클릭 뒤집기
# MDS_AUTHOR: MDS 메이커
# MDS_VERSION: 1.0
# MDS_DATE: 2026-09-17
# Geo Shatter Puzzle — 기하 도형 조각 재조립 퍼즐 (우드 테마, 10레벨)
# MDS PandasExecutor 전용: 코드창에 붙여넣고 ▶ Run
# 조작: 좌클릭 드래그=이동 / 마우스 휠=회전 / 우클릭=좌우 뒤집기

from matplotlib.patches import Polygon, FancyBboxPatch
from matplotlib.collections import LineCollection
from matplotlib.path import Path
from matplotlib.colors import to_rgba


# ─────────────────────────────────────────────
# 레이아웃 · 테마 · 레벨 설정
# ─────────────────────────────────────────────

def get_layout() -> dict:
    return {
        "x_max": 24.0,
        "y_max": 11.6,
        "target_box": (0.3, 0.3, 10.0, 10.0),   # x, y, w, h
        "tray_box": (10.8, 0.3, 12.9, 10.0),
        "target_center": (5.3, 5.3),
        "hud_y": 11.1,
        "max_level": 10,
    }


def get_theme() -> dict:
    return {
        "table_bg": "#2B1B10",       # 짙은 월넛 테이블
        "target_panel": "#3A2717",
        "panel_edge": "#1F140B",
        "recess": "#1C110A",         # 음각 홈
        "recess_shadow": "#100905",
        "outline": "#E8D5B0",        # 연한 베이지 윤곽
        "tray_board": "#6B4A2E",     # 오크 판자
        "tray_seam": "#523720",
        "tray_grain": "#7C5836",
        "text": "#F3E6CC",           # 크림
        "text_dim": "#CDB894",
        "brass": "#D9A547",          # 황동
        "msg_bg": "#24170D",
        "wood_tones": [              # 밝은 → 짙은 순
            "#E6C79A",  # 메이플
            "#DDBB88",  # 자작
            "#D2AC76",  # 애쉬
            "#C69A62",  # 오크
            "#B98552",  # 티크
            "#B87146",  # 체리
            "#A06E47",  # 월넛
            "#9A5A38",  # 마호가니
        ],
    }


def get_level_config(level: int) -> dict:
    # sides=None → 정다각형(3·4·5·6·8각) 중 무작위
    # hint: outline=윤곽만 / faint=흐린 윤곽 (조각 경계선은 어느 레벨에도 표시하지 않음)
    table = {
        1:  (4,    3,  0,  False, "outline",  0.90, None, 0.18,  25.0),
        2:  (4,    4,  90, False, "outline",  0.90, None, 0.14,  24.0),
        3:  (3,    5,  60, False, "outline",  0.65, None, 0.11,  22.0),
        4:  (6,    6,  30, False, "outline",  0.65, None, 0.09,  21.0),
        5:  (5,    7,  15, False, "outline",  0.65, 180,  0.08,  20.0),
        6:  (8,    8,  15, True,  "outline",  0.45, 150,  0.065, 19.0),
        7:  (None, 9,  15, True,  "faint",    0.45, 120,  0.055, 18.0),
        8:  (None, 10, 15, True,  "faint",    0.45, 110,  0.05,  17.0),
        9:  (None, 11, 15, True,  "faint",    0.45, 100,  0.045, 16.0),
        10: (None, 12, 15, True,  "faint",    0.45, 90,   0.04,  15.0),
    }
    lv = int(min(max(level, 1), 10))
    sides, n_pieces, rot_step, flip, hint, snap_dist, time_limit, min_area, min_angle = table[lv]
    return {
        "level": lv,
        "sides": sides,
        "n_pieces": n_pieces,
        "rot_step": rot_step,
        "flip": flip,
        "hint": hint,
        "snap_dist": snap_dist,                    # 이웃 맞춤 스냅 거리
        "void_tol": 0.02 if lv <= 5 else 0.015,    # 허용 공극률
        "time_limit": time_limit,
        "min_area": min_area,
        "min_angle": min_angle,
        "min_edge": 0.35,
    }


def get_shape_name(sides: int) -> str:
    names = {3: "정삼각형", 4: "정사각형", 5: "정오각형", 6: "정육각형", 8: "정팔각형"}
    return names.get(sides, f"정{sides}각형")


# ─────────────────────────────────────────────
# 색 유틸
# ─────────────────────────────────────────────

def shade_color(hex_color: str, factor: float) -> str:
    rgb = np.array(to_rgba(hex_color)[:3])
    if factor < 1.0:
        rgb = rgb * factor
    else:
        rgb = rgb + (1.0 - rgb) * (factor - 1.0)
    rgb = np.clip(rgb, 0.0, 1.0)
    return "#{:02X}{:02X}{:02X}".format(*(int(round(c * 255)) for c in rgb))


def mix_color(hex_a: str, hex_b: str, ratio_b: float) -> str:
    a = np.array(to_rgba(hex_a)[:3])
    b = np.array(to_rgba(hex_b)[:3])
    rgb = np.clip(a * (1.0 - ratio_b) + b * ratio_b, 0.0, 1.0)
    return "#{:02X}{:02X}{:02X}".format(*(int(round(c * 255)) for c in rgb))


# ─────────────────────────────────────────────
# 기하 함수
# ─────────────────────────────────────────────

def make_base_shape(sides: int, radius: float) -> np.ndarray:
    start_deg = {3: 90.0, 4: 45.0, 5: 90.0, 6: 0.0, 8: 22.5}.get(sides, 90.0)
    ang = np.radians(start_deg + 360.0 * np.arange(sides) / sides)
    return np.column_stack([radius * np.cos(ang), radius * np.sin(ang)])


def polygon_area(poly: np.ndarray) -> float:
    if len(poly) < 3:
        return 0.0
    x, y = poly[:, 0], poly[:, 1]
    return float(0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))))


def min_interior_angle(poly: np.ndarray) -> float:
    k = len(poly)
    if k < 3:
        return 0.0
    angles = []
    for i in range(k):
        v1 = poly[i - 1] - poly[i]
        v2 = poly[(i + 1) % k] - poly[i]
        n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
        if n1 < 1e-9 or n2 < 1e-9:
            return 0.0
        cosv = np.clip(np.dot(v1, v2) / (n1 * n2), -1.0, 1.0)
        angles.append(np.degrees(np.arccos(cosv)))
    return float(min(angles))


def min_edge_length(poly: np.ndarray) -> float:
    if len(poly) < 2:
        return 0.0
    return float(np.linalg.norm(np.roll(poly, -1, axis=0) - poly, axis=1).min())


def clean_polygon(pts: list) -> np.ndarray:
    if len(pts) == 0:
        return np.zeros((0, 2))
    out = []
    for p in pts:
        if not out or np.linalg.norm(p - out[-1]) > 1e-7:
            out.append(p)
    if len(out) > 1 and np.linalg.norm(out[0] - out[-1]) <= 1e-7:
        out.pop()
    return np.array(out) if out else np.zeros((0, 2))


def clip_polygon_by_line(poly: np.ndarray, p: np.ndarray, n: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    d = (poly - p) @ n
    side_a, side_b = [], []
    k = len(poly)
    for i in range(k):
        cur, nxt = poly[i], poly[(i + 1) % k]
        dc, dn = d[i], d[(i + 1) % k]
        if dc >= 0:
            side_a.append(cur)
        if dc <= 0:
            side_b.append(cur)
        if (dc > 0 > dn) or (dc < 0 < dn):
            t = dc / (dc - dn)
            ip = cur + t * (nxt - cur)
            side_a.append(ip)
            side_b.append(ip)
    return clean_polygon(side_a), clean_polygon(side_b)


def is_good_piece(piece: np.ndarray, total_area: float, min_area: float,
                  min_angle: float, min_edge: float) -> bool:
    if len(piece) < 3:
        return False
    if polygon_area(piece) < min_area * total_area:
        return False
    if min_interior_angle(piece) < min_angle:
        return False
    return min_edge_length(piece) >= min_edge


def try_shatter(poly: np.ndarray, n_pieces: int, min_area: float, min_angle: float,
                min_edge: float, rng: np.random.Generator, force: bool = False) -> list | None:
    total = polygon_area(poly)
    pieces = [poly]
    for _ in range(n_pieces - 1):
        order = sorted(range(len(pieces)), key=lambda i: -polygon_area(pieces[i]))
        split_done = False
        for idx in order:
            target = pieces[idx]
            if not force and polygon_area(target) < 2.0 * min_area * total:
                continue
            for _ in range(80):
                if force:
                    point = target.mean(axis=0)
                else:
                    weights = rng.dirichlet(np.full(len(target), 2.0))
                    point = weights @ target
                theta = rng.uniform(0.0, np.pi)
                normal = np.array([np.cos(theta), np.sin(theta)])
                a, b = clip_polygon_by_line(target, point, normal)
                if force:
                    ok = len(a) >= 3 and len(b) >= 3 and polygon_area(a) > 1e-4 and polygon_area(b) > 1e-4
                else:
                    ok = (is_good_piece(a, total, min_area, min_angle, min_edge)
                          and is_good_piece(b, total, min_area, min_angle, min_edge))
                if ok:
                    pieces[idx] = a
                    pieces.append(b)
                    split_done = True
                    break
            if split_done:
                break
        if not split_done:
            return None
    return pieces


def shatter_shape(poly: np.ndarray, n_pieces: int, cfg: dict, rng: np.random.Generator) -> list:
    min_area, min_angle, min_edge = cfg["min_area"], cfg["min_angle"], cfg["min_edge"]
    for attempt in range(12):
        relax = 0.88 ** attempt
        pieces = try_shatter(poly, n_pieces, min_area * relax, min_angle * relax,
                             min_edge * relax, rng)
        if pieces is not None:
            return pieces
    pieces = try_shatter(poly, n_pieces, 0.0, 0.0, 0.0, rng, force=True)
    return pieces if pieces is not None else [poly]


def transform_points(local: np.ndarray, center: np.ndarray, angle_deg: float, flipped: bool) -> np.ndarray:
    pts = np.array(local, dtype=float).reshape(-1, 2).copy()
    if flipped:
        pts[:, 0] *= -1.0
    a = np.radians(angle_deg)
    rot = np.array([[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]])
    return pts @ rot.T + center


def polygon_edges(poly: np.ndarray) -> np.ndarray:
    return np.stack([poly, np.roll(poly, -1, axis=0)], axis=1)   # (k, 2, 2)


def convex_contains(poly: np.ndarray, points: np.ndarray, margin: float) -> np.ndarray:
    # 볼록 다각형 안에 있는 점 판정. margin > 0 이면 테두리 바깥으로 그만큼 여유, < 0 이면 안쪽으로 축소
    x, y = poly[:, 0], poly[:, 1]
    sign = 1.0 if (np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) >= 0 else -1.0
    inside = np.ones(len(points), dtype=bool)
    k = len(poly)
    for i in range(k):
        a, b = poly[i], poly[(i + 1) % k]
        e = b - a
        length = float(np.hypot(e[0], e[1]))
        if length < 1e-12:
            continue
        signed_dist = (e[0] * (points[:, 1] - a[1]) - e[1] * (points[:, 0] - a[0])) / length
        inside &= sign * signed_dist >= -margin
    return inside


def point_segment_distance(points: np.ndarray, edges: np.ndarray) -> np.ndarray:
    a = edges[None, :, 0, :]
    b = edges[None, :, 1, :]
    pnt = points[:, None, :]
    ab = b - a
    denom = np.maximum(np.sum(ab * ab, axis=2), 1e-12)
    t = np.clip(np.sum((pnt - a) * ab, axis=2) / denom, 0.0, 1.0)
    proj = a + t[..., None] * ab
    return np.linalg.norm(pnt - proj, axis=2)   # (점 수, 변 수)


def contact_score(piece_pts: np.ndarray, target_edges: np.ndarray, edge_weights: np.ndarray,
                  eps: float = 0.03) -> float:
    # 조각의 꼭짓점·변 중점이 대상 변 위에 닿은 정도 (많을수록 잘 맞물림)
    # 목표 윤곽 변에 닿으면 가중치를 더 줌 — 잘못 놓인 다른 조각보다 윤곽을 우선 기준으로 삼기 위함
    probes = np.vstack([piece_pts, (piece_pts + np.roll(piece_pts, -1, axis=0)) / 2.0])
    touching = point_segment_distance(probes, target_edges) <= eps
    return float(np.max(np.where(touching, edge_weights[None, :], 0.0), axis=1).sum())


def collect_snap_targets(level_state: dict, moving_piece: dict, layout: dict) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    tx, ty, tw, th = layout["target_box"]
    polys = [level_state["outline"]]
    for piece in level_state["pieces"]:
        if piece is moving_piece:
            continue
        cx, cy = piece["center"]
        if tx <= cx <= tx + tw and ty <= cy <= ty + th:
            polys.append(piece["pts"])
    vertices = np.vstack(polys)
    edges = np.vstack([polygon_edges(poly) for poly in polys])
    weights = np.ones(len(edges))
    weights[:len(level_state["outline"])] = 2.0
    return vertices, edges, weights


def find_snap_offset(piece_pts: np.ndarray, target_vertices: np.ndarray, target_edges: np.ndarray,
                     edge_weights: np.ndarray, snap_dist: float) -> np.ndarray | None:
    candidates = []

    # 1) 꼭짓점 맞춤 후보
    diff = target_vertices[None, :, :] - piece_pts[:, None, :]
    dist = np.linalg.norm(diff, axis=2)
    for i, j in zip(*np.nonzero(dist <= snap_dist)):
        candidates.append(diff[i, j])

    # 2) 변 맞춤 후보: 거의 평행한 대상 변에 수직으로 붙이고, 그 변 방향으로 꼭짓점까지 밀기
    sin_tol = np.sin(np.radians(2.0))
    lines = []
    for pa, pb in polygon_edges(piece_pts):
        pu = pb - pa
        pl = float(np.hypot(pu[0], pu[1]))
        if pl < 1e-9:
            continue
        pu = pu / pl
        for ta, tb in target_edges:
            tu = tb - ta
            tl = float(np.hypot(tu[0], tu[1]))
            if tl < 1e-9:
                continue
            tu = tu / tl
            if abs(pu[0] * tu[1] - pu[1] * tu[0]) > sin_tol:
                continue
            normal = np.array([-tu[1], tu[0]])
            gap = float(np.dot(ta - pa, normal))
            if abs(gap) > snap_dist:
                continue
            s0, s1 = sorted([float(np.dot(pa - ta, tu)), float(np.dot(pb - ta, tu))])
            if s1 < -snap_dist or s0 > tl + snap_dist:
                continue
            base = gap * normal
            candidates.append(base)
            lines.append((normal, gap))
            for pv in (pa, pb):
                for tv in (ta, tb):
                    slide = float(np.dot(tv - pv, tu))
                    if abs(slide) <= snap_dist:
                        candidates.append(base + slide * tu)

    # 3) 방향이 다른 두 변에 동시에 붙이기 (모서리 끼워 넣기)
    for i in range(len(lines)):
        n1, g1 = lines[i]
        for j in range(i + 1, len(lines)):
            n2, g2 = lines[j]
            det = n1[0] * n2[1] - n1[1] * n2[0]
            if abs(det) < 0.2:
                continue
            offset = np.linalg.solve(np.array([n1, n2]), np.array([g1, g2]))
            if np.hypot(offset[0], offset[1]) <= snap_dist * 1.5:
                candidates.append(offset)

    if not candidates:
        return None
    base_score = contact_score(piece_pts, target_edges, edge_weights)
    best, best_key = None, None
    for offset in candidates:
        key = (contact_score(piece_pts + offset, target_edges, edge_weights),
               -float(np.hypot(offset[0], offset[1])))
        if best_key is None or key > best_key:
            best, best_key = offset, key
    if best_key[0] <= base_score:
        return None
    return best


def build_fill_grid(outline: np.ndarray, spacing: float = 0.05, max_points: int = 15000) -> np.ndarray:
    area = polygon_area(outline)
    spacing = max(spacing, float(np.sqrt(area / max_points)))
    lo, hi = outline.min(axis=0), outline.max(axis=0)
    xs = np.arange(lo[0] + spacing / 2.0, hi[0], spacing)
    ys = np.arange(lo[1] + spacing / 2.0, hi[1], spacing)
    gx, gy = np.meshgrid(xs, ys)
    pts = np.column_stack([gx.ravel(), gy.ravel()])
    return pts[convex_contains(outline, pts, margin=-spacing * 0.25)]


def calc_void_ratio(grid_pts: np.ndarray, pieces: list, margin: float = 0.02) -> float:
    if len(grid_pts) == 0:
        return 1.0
    covered = np.zeros(len(grid_pts), dtype=bool)
    for piece in pieces:
        covered |= convex_contains(piece["pts"], grid_pts, margin)
    return float(1.0 - covered.mean())


# ─────────────────────────────────────────────
# 우드 톤 · 나뭇결
# ─────────────────────────────────────────────

def assign_wood_tones(n_pieces: int, theme: dict, rng: np.random.Generator) -> list[dict]:
    tones = theme["wood_tones"]
    m = len(tones)
    interleave = []
    lo, hi = 0, m - 1
    while lo <= hi:
        interleave.append(lo)
        if lo != hi:
            interleave.append(hi)
        lo, hi = lo + 1, hi - 1
    offset = int(rng.integers(0, m))
    result = []
    for i in range(n_pieces):
        base = shade_color(tones[interleave[(i + offset) % m]], float(rng.uniform(0.96, 1.04)))
        result.append({
            "base": base,
            "lifted": shade_color(base, 1.12),
            "edge": shade_color(base, 0.45),
            "grain": shade_color(base, 0.74),
            "finished": shade_color(base, 0.82),
            "finished_edge": shade_color(base, 0.36),
        })
    return result


def make_wood_grain(local_poly: np.ndarray, rng: np.random.Generator) -> dict:
    r = float(np.linalg.norm(local_poly, axis=1).max()) * 1.05 + 0.1
    grain_angle = rng.uniform(0.0, np.pi)
    rot = np.array([[np.cos(grain_angle), -np.sin(grain_angle)],
                    [np.sin(grain_angle), np.cos(grain_angle)]])
    spacing = rng.uniform(0.22, 0.32)
    offsets = np.arange(-r, r + spacing, spacing)
    t = np.linspace(-r, r, 28)
    freq = rng.uniform(0.6, 1.4)
    amp = rng.uniform(0.04, 0.12)
    segments = np.zeros((len(offsets), len(t), 2))
    for k, off in enumerate(offsets):
        phase = rng.uniform(0.0, 2.0 * np.pi)
        y = off + amp * np.sin(freq * t + phase) + 0.35 * amp * np.sin(2.7 * freq * t + 1.3 * phase)
        segments[k] = np.column_stack([t, y]) @ rot.T
    return {
        "segments": segments,
        "widths": rng.uniform(0.5, 1.3, len(offsets)),
        "alphas": rng.uniform(0.25, 0.6, len(offsets)),
    }


# ─────────────────────────────────────────────
# 레벨 상태
# ─────────────────────────────────────────────

def scatter_pieces(pieces: list, cfg: dict, layout: dict, rng: np.random.Generator) -> list:
    tx, ty, tw, th = layout["tray_box"]
    placed = []
    for piece in sorted(pieces, key=lambda p: -p["radius"]):
        r = piece["radius"]
        mx = min(r * 0.85, tw / 2.0 - 0.1)
        my = min(r * 0.85, th / 2.0 - 0.1)
        best_c, best_score = None, -np.inf
        for _ in range(150):
            c = np.array([rng.uniform(tx + mx, tx + tw - mx), rng.uniform(ty + my, ty + th - my)])
            if not placed:
                score = np.inf
            else:
                score = min(np.linalg.norm(c - pc) - 0.8 * (r + pr) for pc, pr in placed)
            if score > best_score:
                best_c, best_score = c, score
            if score >= 0:
                break
        placed.append((best_c, r))
        piece["center"] = best_c
        step = cfg["rot_step"]
        piece["angle"] = float(step * rng.integers(1, 360 // step)) if step > 0 else 0.0
        piece["flipped"] = bool(rng.random() < 0.5) if cfg["flip"] else False
        piece["finished"] = False
    return pieces


def calc_level_score(cfg: dict, n_pieces: int, elapsed_sec: float, n_actions: int, n_retries: int) -> int:
    base = n_pieces * 100
    if cfg["time_limit"]:
        bonus = max(0.0, cfg["time_limit"] - elapsed_sec) * 5
    else:
        bonus = max(0.0, n_pieces * 20 - elapsed_sec) * 5
    penalty = max(0, n_actions - n_pieces * 3) * 10
    floor = base * 0.2
    score = max(base + bonus - penalty, floor)
    score = max(score * max(0.0, 1.0 - 0.2 * n_retries), floor)
    return int(round(score))


def build_level_state(level: int, layout: dict, theme: dict, rng: np.random.Generator) -> dict:
    cfg = get_level_config(level)
    sides = cfg["sides"] if cfg["sides"] else int(rng.choice([3, 4, 5, 6, 8]))
    radius = {3: 4.6, 4: 3.8, 5: 3.5, 6: 3.3, 8: 3.1}[sides]
    base = make_base_shape(sides, radius)
    bbox_center = (base.min(axis=0) + base.max(axis=0)) / 2.0
    outline = base - bbox_center + np.array(layout["target_center"])

    polys = shatter_shape(outline, cfg["n_pieces"], cfg, rng)
    tc = np.array(layout["target_center"])
    polys = sorted(polys, key=lambda p: np.arctan2(*(p.mean(axis=0) - tc)[::-1]))
    tones = assign_wood_tones(len(polys), theme, rng)

    total_area = polygon_area(outline)
    min_piece_ratio = min(polygon_area(poly) for poly in polys) / total_area
    pieces = []
    for i, poly in enumerate(polys):
        local = poly - poly.mean(axis=0)
        pieces.append({
            "id": i,
            "local": local,
            "radius": float(np.linalg.norm(local, axis=1).max()),
            "tone": tones[i],
            "grain": make_wood_grain(local, rng),
            "home_center": poly.mean(axis=0),
            "center": poly.mean(axis=0),
            "angle": 0.0,
            "flipped": False,
            "finished": False,
            "z": 10.0 + i,
            "pts": poly.copy(),
        })
    scatter_pieces(pieces, cfg, layout, rng)
    for piece in pieces:
        piece["pts"] = transform_points(piece["local"], piece["center"], piece["angle"], piece["flipped"])
    grid = build_fill_grid(outline)
    return {
        "level": cfg["level"],
        "cfg": cfg,
        "sides": sides,
        "shape_name": get_shape_name(sides),
        "outline": outline,
        "pieces": pieces,
        "grid": grid,
        "void_allowed": min(cfg["void_tol"], 0.5 * min_piece_ratio),
        "void_ratio": calc_void_ratio(grid, pieces),
        "n_actions": 0,
        "n_retries": 0,
    }


def reset_level_for_retry(level_state: dict, layout: dict, rng: np.random.Generator) -> dict:
    scatter_pieces(level_state["pieces"], level_state["cfg"], layout, rng)
    for i, piece in enumerate(level_state["pieces"]):
        piece["z"] = 10.0 + i
        piece["pts"] = transform_points(piece["local"], piece["center"], piece["angle"], piece["flipped"])
    level_state["void_ratio"] = calc_void_ratio(level_state["grid"], level_state["pieces"])
    level_state["n_actions"] = 0
    level_state["n_retries"] += 1
    return level_state


# ─────────────────────────────────────────────
# 그리기
# ─────────────────────────────────────────────

def draw_static_board(ax: plt.Axes, layout: dict, theme: dict, rng: np.random.Generator) -> None:
    x, y, w, h = layout["target_box"]
    ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0,rounding_size=0.25",
                                facecolor=theme["target_panel"], edgecolor=theme["panel_edge"],
                                linewidth=1.5, zorder=0.5))
    tx, ty, tw, th = layout["tray_box"]
    tray = FancyBboxPatch((tx, ty), tw, th, boxstyle="round,pad=0,rounding_size=0.25",
                          facecolor=theme["tray_board"], edgecolor=theme["panel_edge"],
                          linewidth=1.5, zorder=0.5)
    ax.add_patch(tray)

    t = np.linspace(tx, tx + tw, 60)
    grain_lines = []
    for off in np.arange(ty + 0.15, ty + th, 0.33):
        phase = rng.uniform(0, 2 * np.pi)
        grain_lines.append(np.column_stack([t, off + 0.06 * np.sin(0.9 * t + phase)]))
    grain_lc = LineCollection(grain_lines, colors=theme["tray_grain"], linewidths=0.7,
                              alpha=0.35, zorder=0.55)
    ax.add_collection(grain_lc)
    grain_lc.set_clip_path(tray)

    seams = [np.array([[tx, sy], [tx + tw, sy]]) for sy in np.arange(ty + 2.0, ty + th, 2.0)]
    seam_lc = LineCollection(seams, colors=theme["tray_seam"], linewidths=1.6, zorder=0.6)
    ax.add_collection(seam_lc)
    seam_lc.set_clip_path(tray)


def refresh_piece(piece: dict, lifted: bool = False) -> None:
    pts = transform_points(piece["local"], piece["center"], piece["angle"], piece["flipped"])
    piece["pts"] = pts
    tone = piece["tone"]
    segs = piece["grain"]["segments"]
    flat = transform_points(segs.reshape(-1, 2), piece["center"], piece["angle"], piece["flipped"])

    piece["patch"].set_xy(pts)
    piece["edge_patch"].set_xy(pts)
    piece["grain_lc"].set_segments(flat.reshape(segs.shape))

    if piece["finished"]:
        face, edge, lw = tone["finished"], tone["finished_edge"], 1.4
    elif lifted:
        face, edge, lw = tone["lifted"], tone["edge"], 2.6
    else:
        face, edge, lw = tone["base"], tone["edge"], 1.6
    piece["patch"].set_facecolor(face)
    piece["edge_patch"].set_edgecolor(edge)
    piece["edge_patch"].set_linewidth(lw)

    z = piece["z"]
    piece["patch"].set_zorder(z)
    piece["grain_lc"].set_zorder(z + 0.01)
    piece["edge_patch"].set_zorder(z + 0.02)


def create_level_artists(ax: plt.Axes, level_state: dict, theme: dict) -> list:
    artists = []
    cfg = level_state["cfg"]
    outline = level_state["outline"]
    faint = cfg["hint"] == "faint"

    shadow = Polygon(outline + np.array([0.12, -0.12]), closed=True,
                     facecolor=theme["recess_shadow"] if not faint else theme["target_panel"],
                     edgecolor="none", zorder=1.0)
    recess_face = mix_color(theme["target_panel"], theme["recess"], 0.35) if faint else theme["recess"]
    recess = Polygon(outline, closed=True, facecolor=recess_face,
                     edgecolor=to_rgba(theme["outline"], 0.35 if faint else 0.95),
                     linewidth=2.0, zorder=1.1)
    ax.add_patch(shadow)
    ax.add_patch(recess)
    artists += [shadow, recess]

    for piece in level_state["pieces"]:
        grain = piece["grain"]
        patch = Polygon(piece["pts"], closed=True, facecolor=piece["tone"]["base"],
                        edgecolor="none", zorder=piece["z"])
        ax.add_patch(patch)
        colors = [to_rgba(piece["tone"]["grain"], a) for a in grain["alphas"]]
        grain_lc = LineCollection(grain["segments"], colors=colors,
                                  linewidths=grain["widths"], zorder=piece["z"] + 0.01)
        ax.add_collection(grain_lc)
        grain_lc.set_clip_path(patch)
        edge_patch = Polygon(piece["pts"], closed=True, fill=False,
                             edgecolor=piece["tone"]["edge"], linewidth=1.6,
                             joinstyle="round", zorder=piece["z"] + 0.02)
        ax.add_patch(edge_patch)
        piece["patch"], piece["grain_lc"], piece["edge_patch"] = patch, grain_lc, edge_patch
        refresh_piece(piece)
        artists += [patch, grain_lc, edge_patch]
    return artists


def format_mmss(seconds: float) -> str:
    s = max(0, int(np.ceil(seconds)))
    return f"{s // 60:02d}:{s % 60:02d}"


# ─────────────────────────────────────────────
# 게임 실행
# ─────────────────────────────────────────────

def read_start_settings() -> dict | None:
    values = mIO.form([
        {"type": "spin", "label": "시작 레벨", "min": 1, "max": 10, "default": 1, "step": 1},
        {"type": "text", "label": "랜덤 시드 (빈칸이면 무작위)", "default": ""},
        {"type": "radio", "label": "제한시간 초과 시", "options": ["게임 종료", "같은 레벨 재도전"],
         "default": "게임 종료"},
        {"type": "check", "label": "상단에 채움률 표시", "default": False},
    ], title="Geo Shatter Puzzle — 게임 설정")
    if values is None:
        return None
    seed_text = str(values.get("랜덤 시드 (빈칸이면 무작위)", "") or "").strip()
    if seed_text == "":
        seed = None
    else:
        try:
            seed = int(seed_text)
        except ValueError:
            seed = sum((i + 1) * ord(ch) for i, ch in enumerate(seed_text))
    return {
        "start_level": int(min(max(int(float(values.get("시작 레벨", 1))), 1), 10)),
        "seed": seed,
        "timeout_mode": values.get("제한시간 초과 시", "게임 종료") or "게임 종료",
        "show_fill": bool(values.get("상단에 채움률 표시", False)),
    }


def run_game(df: pd.DataFrame) -> str:
    settings = read_start_settings()
    if settings is None:
        return "사용자가 취소했습니다."

    layout = get_layout()
    theme = get_theme()
    game = {
        "rng": np.random.default_rng(settings["seed"]),
        "start_level": settings["start_level"],
        "timeout_mode": settings["timeout_mode"],
        "show_fill": settings["show_fill"],
        "level_state": None,
        "phase": "playing",
        "after_message": None,
        "phase_until": 0.0,
        "now": 0.0,
        "level_start": None,
        "frozen_time": 0.0,
        "records": [],
        "total_score": 0,
        "recorded_current": False,
        "pending_clear": False,
        "finished_all": False,
        "drag": None,
        "z": 20.0,
        "artists": [],
        "cids": [],
        "fig": None,
        "ax": None,
        "hud": {},
        "stopped": False,
    }

    def level_elapsed() -> float:
        if game["phase"] != "playing" or game["level_start"] is None:
            return game["frozen_time"]
        return max(0.0, game["now"] - game["level_start"])

    def add_record(result_label: str, elapsed: float, score: int) -> None:
        ls = game["level_state"]
        game["records"].append({
            "레벨": ls["level"],
            "도형": ls["shape_name"],
            "조각 수": len(ls["pieces"]),
            "결과": result_label,
            "소요시간(초)": round(float(elapsed), 1),
            "조작 횟수": ls["n_actions"],
            "재도전 횟수": ls["n_retries"],
            "레벨 점수": int(score),
        })
        game["total_score"] += int(score)
        game["recorded_current"] = True

    def load_level(level: int) -> None:
        for artist in game["artists"]:
            try:
                artist.remove()
            except Exception:
                pass
        game["artists"] = []
        game["level_state"] = build_level_state(level, layout, theme, game["rng"])
        game["artists"] = create_level_artists(game["ax"], game["level_state"], theme)
        game["z"] = 20.0 + len(game["level_state"]["pieces"])
        game["recorded_current"] = False
        game["pending_clear"] = False
        game["drag"] = None
        game["frozen_time"] = 0.0

    def show_message(text: str, color: str) -> None:
        msg = game["hud"]["message"]
        msg.set_text(text)
        msg.set_color(color)
        msg.get_bbox_patch().set_edgecolor(color)
        msg.set_visible(True)

    def hide_message() -> None:
        game["hud"]["message"].set_visible(False)

    def update_hud() -> None:
        ls = game["level_state"]
        cfg = ls["cfg"]
        t = level_elapsed()
        if cfg["time_limit"]:
            time_txt = f"남은 시간 {format_mmss(cfg['time_limit'] - t)}"
        else:
            time_txt = f"경과 {format_mmss(t)}"
        game["hud"]["main"].set_text(
            f"레벨 {ls['level']} / {layout['max_level']}  ·  {ls['shape_name']}   |   {time_txt}   |   "
            f"조작 {ls['n_actions']}   |   "
            + (f"채움률 {(1.0 - ls['void_ratio']) * 100:.1f}%   |   " if game["show_fill"] else "")
            + f"총점 {game['total_score']:,}"
        )
        rot_txt = f"휠: 회전({cfg['rot_step']}°)" if cfg["rot_step"] > 0 else "회전 없음"
        flip_txt = "   우클릭: 뒤집기" if cfg["flip"] else ""
        game["hud"]["help"].set_text(f"좌클릭 드래그: 이동   {rot_txt}{flip_txt}")

    def bump_z(piece: dict) -> None:
        game["z"] += 1.0
        piece["z"] = game["z"]

    def find_piece_at(x: float, y: float) -> dict | None:
        candidates = [p for p in game["level_state"]["pieces"]
                      if Path(p["pts"]).contains_point((x, y))]
        if not candidates:
            return None
        return max(candidates, key=lambda p: p["z"])

    def settle_piece(piece: dict) -> None:
        # 이웃 맞춤 스냅 → 공극률 계산 → 허용 공극률 이하이면 클리어 예약
        ls = game["level_state"]
        vertices, edges, weights = collect_snap_targets(ls, piece, layout)
        offset = find_snap_offset(piece["pts"], vertices, edges, weights, ls["cfg"]["snap_dist"])
        if offset is not None:
            piece["center"] = clamp_center(piece["center"] + offset)
        refresh_piece(piece)
        ls["void_ratio"] = calc_void_ratio(ls["grid"], ls["pieces"])
        if ls["void_ratio"] <= ls["void_allowed"]:
            game["pending_clear"] = True

    def clamp_center(c: np.ndarray) -> np.ndarray:
        return np.array([min(max(c[0], 0.2), layout["x_max"] - 0.2),
                         min(max(c[1], 0.2), layout["y_max"] - 1.0)])

    def on_press(event: object) -> None:
        if game["phase"] != "playing" or event.inaxes is not game["ax"] or event.xdata is None:
            return
        if game["drag"] is not None:
            return
        piece = find_piece_at(event.xdata, event.ydata)
        if piece is None:
            return
        ls = game["level_state"]
        if event.button == 1:
            bump_z(piece)
            game["drag"] = {"piece": piece,
                            "offset": piece["center"] - np.array([event.xdata, event.ydata])}
            refresh_piece(piece, lifted=True)
        elif event.button == 3 and ls["cfg"]["flip"]:
            piece["flipped"] = not piece["flipped"]
            piece["angle"] = -piece["angle"]
            ls["n_actions"] += 1
            settle_piece(piece)

    def on_motion(event: object) -> None:
        drag = game["drag"]
        if drag is None or event.inaxes is not game["ax"] or event.xdata is None:
            return
        piece = drag["piece"]
        piece["center"] = clamp_center(np.array([event.xdata, event.ydata]) + drag["offset"])
        refresh_piece(piece, lifted=True)

    def on_release(event: object) -> None:
        drag = game["drag"]
        if drag is None or event.button != 1:
            return
        game["drag"] = None
        piece = drag["piece"]
        if game["phase"] != "playing":
            refresh_piece(piece)
            return
        game["level_state"]["n_actions"] += 1
        settle_piece(piece)

    def on_scroll(event: object) -> None:
        if game["phase"] != "playing" or event.inaxes is not game["ax"] or event.xdata is None:
            return
        ls = game["level_state"]
        step = ls["cfg"]["rot_step"]
        if step <= 0:
            return
        dragging = game["drag"] is not None
        piece = game["drag"]["piece"] if dragging else find_piece_at(event.xdata, event.ydata)
        if piece is None:
            return
        delta = step if event.button == "up" else -step
        piece["angle"] = (piece["angle"] + delta) % 360.0
        if dragging:
            refresh_piece(piece, lifted=True)
        else:
            settle_piece(piece)

    def setup(fig: plt.Figure, ax: plt.Axes) -> dict:
        plt.rcParams["font.family"] = "Malgun Gothic"
        plt.rcParams["axes.unicode_minus"] = False
        game["fig"], game["ax"] = fig, ax
        fig.patch.set_facecolor(theme["table_bg"])
        ax.set_facecolor(theme["table_bg"])
        ax.set_xlim(0, layout["x_max"])
        ax.set_ylim(0, layout["y_max"])
        ax.set_aspect("equal", adjustable="box")
        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_navigate(False)
        for spine in ax.spines.values():
            spine.set_visible(False)
        try:
            fig.subplots_adjust(left=0.01, right=0.99, bottom=0.01, top=0.99)
        except Exception:
            pass

        draw_static_board(ax, layout, theme, game["rng"])
        game["hud"]["main"] = ax.text(0.3, layout["hud_y"], "", ha="left", va="center",
                                      fontsize=11, color=theme["text"], zorder=1000)
        game["hud"]["help"] = ax.text(layout["x_max"] - 0.3, layout["hud_y"], "", ha="right",
                                      va="center", fontsize=9.5, color=theme["text_dim"], zorder=1000)
        game["hud"]["message"] = ax.text(
            layout["x_max"] / 2.0, layout["target_center"][1], "", ha="center", va="center",
            fontsize=20, fontweight="bold", color=theme["brass"], zorder=1001, visible=False,
            bbox=dict(boxstyle="round,pad=0.6", facecolor=theme["msg_bg"],
                      edgecolor=theme["brass"], linewidth=2.0, alpha=0.93))

        load_level(game["start_level"])
        update_hud()

        canvas = fig.canvas
        game["cids"] = [
            canvas.mpl_connect("button_press_event", on_press),
            canvas.mpl_connect("motion_notify_event", on_motion),
            canvas.mpl_connect("button_release_event", on_release),
            canvas.mpl_connect("scroll_event", on_scroll),
        ]
        return game

    def update(elapsed: float, frame: int, state: dict, ax: plt.Axes) -> bool:
        game["now"] = float(elapsed)
        if game["level_start"] is None:
            game["level_start"] = game["now"]
        ls = game["level_state"]
        cfg = ls["cfg"]

        if game["phase"] == "playing":
            t = game["now"] - game["level_start"]
            if game["pending_clear"]:
                game["pending_clear"] = False
                ls["void_ratio"] = calc_void_ratio(ls["grid"], ls["pieces"])
            if ls["void_ratio"] <= ls["void_allowed"] and game["drag"] is None:
                for piece in ls["pieces"]:
                    piece["finished"] = True
                    refresh_piece(piece)
                game["frozen_time"] = t
                score = calc_level_score(cfg, len(ls["pieces"]), t, ls["n_actions"], ls["n_retries"])
                add_record("클리어", t, score)
                game["phase"] = "message"
                game["phase_until"] = game["now"] + 1.8
                if ls["level"] >= layout["max_level"]:
                    game["after_message"] = "all_clear"
                else:
                    game["after_message"] = "next_level"
                show_message(f"레벨 {ls['level']} 클리어!  +{score:,}점", theme["brass"])
            elif cfg["time_limit"] and t >= cfg["time_limit"]:
                game["frozen_time"] = float(cfg["time_limit"])
                if game["drag"] is not None:
                    piece = game["drag"]["piece"]
                    game["drag"] = None
                    refresh_piece(piece)
                game["phase"] = "message"
                if game["timeout_mode"] == "같은 레벨 재도전":
                    game["after_message"] = "retry"
                    game["phase_until"] = game["now"] + 1.6
                    show_message(f"시간 초과 — 레벨 {ls['level']} 재도전", theme["text"])
                else:
                    add_record("실패", cfg["time_limit"], 0)
                    game["after_message"] = "end"
                    game["phase_until"] = game["now"] + 2.2
                    show_message("시간 초과 — 게임 종료", theme["text"])

        elif game["phase"] == "message" and game["now"] >= game["phase_until"]:
            after = game["after_message"]
            if after == "next_level":
                load_level(ls["level"] + 1)
                hide_message()
                game["phase"] = "playing"
                game["level_start"] = game["now"]
            elif after == "retry":
                reset_level_for_retry(ls, layout, game["rng"])
                for piece in ls["pieces"]:
                    piece["finished"] = False
                    refresh_piece(piece)
                game["z"] = 20.0 + len(ls["pieces"])
                hide_message()
                game["phase"] = "playing"
                game["level_start"] = game["now"]
            elif after == "all_clear":
                game["finished_all"] = True
                game["after_message"] = "end"
                game["phase_until"] = game["now"] + 2.8
                show_message(f"{layout['max_level']}레벨 전체 클리어!\n총점 {game['total_score']:,}점",
                             theme["brass"])
            else:
                game["phase"] = "ended"
                return False

        elif game["phase"] == "ended":
            return False

        update_hud()
        return True

    def stop(state: dict, reason: str) -> None:
        if game["stopped"]:
            return
        game["stopped"] = True
        fig = game["fig"]
        if fig is not None:
            for cid in game["cids"]:
                try:
                    fig.canvas.mpl_disconnect(cid)
                except Exception:
                    pass
        game["cids"] = []
        game["drag"] = None
        if game["level_state"] is not None and not game["recorded_current"] and game["phase"] != "ended":
            add_record("중단", level_elapsed(), 0)

    info = mIO.realtime_plot(
        setup, update, stop_func=stop,
        title="Geo Shatter Puzzle — 우드 도형 재조립",
        interval_ms=40, duration_sec=None,
    ) or {}

    if info.get("error"):
        mIO.print(f"게임 실행 중 오류가 발생했습니다.\n{info['error']}")

    records = game["records"]
    if not records:
        return "기록 없이 종료되었습니다."

    record_df = pd.DataFrame(records)
    mIO.show_df(record_df, title="레벨별 기록")

    total_sec = int(round(float(record_df["소요시간(초)"].sum())))
    time_txt = f"총 {total_sec // 60}분 {total_sec % 60:02d}초"
    n_cleared = int((record_df["결과"] == "클리어").sum())
    if game["finished_all"]:
        if game["start_level"] > 1:
            head = f"레벨 {game['start_level']}~{layout['max_level']} 전체 클리어"
        else:
            head = f"{layout['max_level']}레벨 전체 클리어"
        return f"{head}, 총점 {game['total_score']:,}점, {time_txt}"
    last = records[-1]
    return (f"레벨 {last['레벨']}에서 종료({last['결과']}), 클리어 {n_cleared}개 레벨, "
            f"총점 {game['total_score']:,}점, {time_txt}")


result = run_game(df)