Geo Shatter — 도형 조각 퍼즐 게임
설명
흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임
필요 데이터
필요 없음 (데이터 없이 바로 실행)
출력
퍼즐 게임 창 — 좌클릭 드래그 이동 · 휠 회전 · 우클릭 뒤집기
필요 패키지
- matplotlib
- numpy
- pandas
태그
game, creative
작성자
MDS 메이커 | MDS 1.0
흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임
A 10-level wooden puzzle game: drag, rotate and flip shards to rebuild the shape
🔍 클릭하면 원본 크기로 보기README
흩어진 나무 조각을 끌고 돌리고 뒤집어 원래 도형으로 맞추는 10단계 퍼즐 게임
필요 없음 (데이터 없이 바로 실행)
퍼즐 게임 창 — 좌클릭 드래그 이동 · 휠 회전 · 우클릭 뒤집기
game, creative
MDS 메이커 | MDS 1.0
# 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)