← .mpy 공유
사용자 공유

리듬게임

음악 파일을 고르면 그 곡을 분석해서 채보를 그 자리에서 만들어 냅니다. 미리 짜둔 패턴이 아니라서 곡을 바꾸면 노트도 달라집니다.

리듬게임 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

리듬게임

설명

음악 파일을 고르면 그 곡을 분석해서 채보를 그 자리에서 만들어 냅니다. 미리 짜둔 패턴이 아니라서 곡을 바꾸면 노트도 달라집니다.

필요 데이터

음악파일

필요 패키지

  • miniaudio

작성자

메이커-4b22 | MDS 1.0

Source · 605줄 (실행 전에 꼭 확인하세요 — 펼치기)
# =============================================================
# MDS 리듬게임 — 음악 파일에서 채보를 자동으로 뽑아 4레인으로 떨어뜨린다
#
# 조작: D F J K  (왼쪽부터 1~4번 레인)
#       창을 한 번 클릭해서 focus 를 준 뒤 시작하세요.
#
# 핵심 구조
#   · 시계는 mAudio.get_position() — 오디오 재생 위치를 기준으로 삼는다.
#     프레임이 끊겨도 판정이 밀리지 않는다 (실제 리듬게임과 같은 방식).
#   · 키 핸들러는 "누른 시각"만 기록하고 끝낸다. 판정은 update() 에서 한다.
#     (키 이벤트는 메인 스레드에서 오므로 무거운 일을 시키지 않는다)
#   · 채보는 스펙트럼 플럭스(소리가 갑자기 커지는 지점)로 자동 검출한다.
#     추가 라이브러리 없이 numpy 만 쓴다.
#
# 설치 필요: MDS 메인 화면 → 확장 관리 → PyPI에서 만들기... → "패키지 이름"에 `miniaudio` 입력
#           → 설치 후 MDS 를 다시 시작해야 mAudio(오디오 런타임)가 잡힙니다.
# MDS_REQUIRES: miniaudio
# =============================================================
import time

KEYS = ["d", "f", "j", "k"]
LANE_COLORS = ["#2563EB", "#16A34A", "#F59E0B", "#E11D48"]

# ── 화면 배치 — 여기 숫자만 바꾸면 레인 간격·크기가 조절된다 ──────────
LANE_STEP = 0.62        # 레인 사이 간격 (작을수록 촘촘하게 모인다)
LANE_W = 0.56           # 레인 한 칸의 폭 (LANE_STEP 보다 작아야 사이가 벌어진다)
NOTE_H = 0.045          # 노트 높이 (낙하 거리를 1 로 본 비율)
NOTE_POOL = 64          # 동시에 그릴 수 있는 노트 최대 개수 (미리 만들어 재사용)
VIEW_X = 2.0            # 가로 시야 — 레인이 이 안에서 가운데 정렬된다
LANE_X = [(i - 1.5) * LANE_STEP for i in range(4)]   # 레인 중심 x 좌표

# 판정 창 (초) — (PERFECT, GREAT, GOOD). 앞뒤 대칭으로 적용된다.
JUDGE_PRESETS = {
    "빡빡": (0.050, 0.100, 0.150),
    "보통": (0.080, 0.140, 0.220),
    "넉넉": (0.110, 0.190, 0.300),
}
JUDGE_DEFAULT = "보통"

COUNT_SEC = 3.0         # 시작 전 카운트다운 (3 · 2 · 1)

SCORE = {"PERFECT": 300, "GREAT": 200, "GOOD": 100, "MISS": 0}
JUDGE_COLOR = {"PERFECT": "#22D3EE", "GREAT": "#4ADE80",
               "GOOD": "#FACC15", "MISS": "#F87171"}

# sens  : 낮을수록 작은 소리까지 노트로 잡는다 (노트가 많아짐)
# gap   : 노트 사이 최소 간격(초)
# hand  : "같은 레인"을 다시 칠 수 있는 최소 간격(초) — 한 손가락이 돌아올 시간
DIFFICULTY = {
    "EASY":   {"sens": 1.80, "gap": 0.30, "hand": 0.40},
    "NORMAL": {"sens": 1.50, "gap": 0.20, "hand": 0.28},
    "HARD":   {"sens": 1.25, "gap": 0.13, "hand": 0.18},
}
MAX_RUN = 3          # 한 레인을 연속으로 칠 수 있는 최대 횟수
LOCAL_WIN = 16       # 선호 레인을 정할 때 비교하는 이웃 노트 수 (곡 전체가 아니라 주변끼리)

# 설정 폼의 라벨 — mIO.form 은 이 문자열을 그대로 결과 딕셔너리의 키로 쓴다.
# 라벨과 조회 키가 어긋나면 KeyError 가 나므로 반드시 이 상수를 양쪽에서 쓴다.
L_PLAY = "플레이 길이(초)"
L_DIFF = "난이도"
L_FALL = "노트 낙하 시간(ms) · 클수록 느리게 내려옴"
L_TICK = "화면 갱신 간격(ms)"
L_JUDGE = "판정 여유"

FFT_WIN = 1024          # 온셋 검출용 분석 창 (샘플)
HOP_SEC = 0.010         # 분석 간격 10ms
CHUNK = 512             # 한 번에 FFT 하는 프레임 수 (메모리 절약)


# ---------------- 입력 받기 ----------------

def pick_music_file() -> str:
    path = mIO.open_file_single(
        dialog_title="리듬게임에 쓸 음악 파일 선택",
        file_filter="오디오 파일 (*.wav *.mp3 *.flac *.ogg)",
        initial_dir=None,
    )
    return path or ""


def ask_game_options() -> dict:
    values = mIO.form([
        {"type": "spin", "label": L_PLAY, "min": 10, "max": 300,
         "default": 60, "step": 10},
        {"type": "radio", "label": L_DIFF, "options": ["EASY", "NORMAL", "HARD"],
         "default": "NORMAL"},
        {"type": "spin", "label": L_FALL, "min": 600, "max": 5000,
         "default": 1800, "step": 100},
        {"type": "radio", "label": L_JUDGE, "options": ["빡빡", "보통", "넉넉"],
         "default": JUDGE_DEFAULT},
        {"type": "spin", "label": L_TICK, "min": 16, "max": 100,
         "default": 33, "step": 1},
    ], title="리듬게임 설정 — 곡의 처음부터 '플레이 길이'만큼 재생합니다")
    return values or {}


# ---------------- 채보 자동 생성 (numpy 만 사용) ----------------

def to_mono(audio: object) -> "np.ndarray":
    """인터리브된 샘플에서 첫 채널만 뽑아 모노로 만든다 (전체 복사 안 함)."""
    nch = max(1, int(audio.nchannels))
    mono = np.asarray(audio.samples[::nch], dtype=np.float32)
    peak = float(np.max(np.abs(mono))) if mono.size else 0.0
    return mono / peak if peak > 0 else mono


def spectral_flux(mono: "np.ndarray", sample_rate: int) -> tuple:
    """프레임별 (스펙트럼 플럭스, 주파수 무게중심)을 구한다.

    플럭스 = 직전 프레임보다 커진 성분의 합 → 소리가 '치고 들어온' 정도.
    무게중심 = 그 순간 소리가 저음 쪽인지 고음 쪽인지 → 레인 배정에 쓴다.
    """
    hop = max(1, int(sample_rate * HOP_SEC))
    if mono.size < FFT_WIN + hop:
        return np.zeros(0), np.zeros(0), hop

    frames = np.lib.stride_tricks.sliding_window_view(mono, FFT_WIN)[::hop]
    n_frames = frames.shape[0]
    window = np.hanning(FFT_WIN).astype(np.float32)
    freqs = np.fft.rfftfreq(FFT_WIN, d=1.0 / sample_rate)

    flux = np.zeros(n_frames, dtype=np.float32)
    centroid = np.zeros(n_frames, dtype=np.float32)
    prev_mag = None

    for s in range(0, n_frames, CHUNK):
        e = min(s + CHUNK, n_frames)
        mag = np.abs(np.fft.rfft(frames[s:e] * window, axis=1)).astype(np.float32)
        if prev_mag is not None:
            head = np.maximum(mag[0] - prev_mag, 0.0).sum()
            flux[s] = head
        body = np.maximum(mag[1:] - mag[:-1], 0.0).sum(axis=1)
        flux[s + 1:e] = body
        total = mag.sum(axis=1)
        centroid[s:e] = np.where(total > 1e-9, (mag * freqs).sum(axis=1) / np.maximum(total, 1e-9), 0.0)
        prev_mag = mag[-1].copy()
        del mag

    return flux, centroid, hop


def moving_average(x: "np.ndarray", size: int) -> "np.ndarray":
    """가장자리를 그대로 살리는 이동평균 — 임계선을 만드는 데 쓴다."""
    if x.size == 0:
        return x
    size = max(3, int(size) | 1)
    pad = size // 2
    padded = np.pad(x, pad, mode="edge")
    kernel = np.ones(size, dtype=np.float32) / size
    return np.convolve(padded, kernel, mode="valid")


def pick_peaks(flux: "np.ndarray", hop: int, sample_rate: int,
               sens: float, min_gap: float) -> "np.ndarray":
    """플럭스가 주변 평균보다 확 튀는 지점을 노트 시각으로 고른다."""
    if flux.size < 3:
        return np.zeros(0, dtype=int)
    baseline = moving_average(flux, int(0.30 / HOP_SEC))
    threshold = baseline * sens + flux.mean() * 0.10
    louder = flux > threshold
    local_max = np.r_[False, (flux[1:-1] >= flux[:-2]) & (flux[1:-1] > flux[2:]), False]
    candidates = np.where(louder & local_max)[0]

    gap_frames = max(1, int(min_gap / HOP_SEC))
    picked = []
    last = -gap_frames * 2
    for i in candidates:
        if i - last >= gap_frames:
            picked.append(int(i))
            last = i
    return np.asarray(picked, dtype=int)


def preferred_lanes(centroid_at: "np.ndarray", win: int = LOCAL_WIN) -> "np.ndarray":
    """음색(주파수 무게중심)으로 '선호 레인'을 정한다 — 단, 곡 전체가 아니라
    **주변 노트끼리만 비교**한다.

    곡 전체 기준으로 4등분하면, 하이햇만 이어지는 구간은 전부 '고음'이라
    같은 레인으로 몰려버린다. 주변 win 개 안에서 몇 번째로 밝은지를 보면
    그 구간 안에서 상대적으로 낮은 소리는 왼쪽, 높은 소리는 오른쪽으로 갈려
    어떤 구간에서도 네 레인이 고르게 쓰인다.
    음색이 완전히 똑같은 구간(같은 하이햇 반복)에서는 순서대로 배분된다.
    """
    n = int(centroid_at.size)
    if n == 0:
        return np.zeros(0, dtype=int)
    if n < 8:
        return np.arange(n) % 4

    half = max(2, int(win) // 2)
    span = float(np.max(centroid_at) - np.min(centroid_at))
    flat = span * 0.05          # 이보다 음색 차이가 작으면 '구별 불가'로 본다
    out = np.zeros(n, dtype=int)
    for i in range(n):
        lo = max(0, i - half)
        hi = min(n, i + half + 1)
        seg = centroid_at[lo:hi]
        if float(np.max(seg) - np.min(seg)) <= flat:
            out[i] = i % 4      # 음색으로 못 가르는 구간(같은 킥 반복 등) → 순서대로 돌린다
            continue
        order = np.argsort(seg, kind="stable")
        pos = int(np.where(order == (i - lo))[0][0])
        out[i] = min(3, pos * 4 // len(seg))
    return out


def assign_lanes(centroid_at: "np.ndarray", times: "np.ndarray",
                 hand_gap: float, max_run: int = MAX_RUN) -> "np.ndarray":
    """선호 레인을 바탕으로 하되, 한쪽에 몰리지 않게 분산한다.

    음색만으로 정하면 하이햇만 이어지는 구간이 전부 같은 레인으로 가버린다.
    그래서 두 가지 제약을 건다:
      · 같은 레인은 hand_gap 초 안에 다시 쓰지 않는다 (손가락이 돌아올 시간)
      · 한 레인을 max_run 번 연속으로 쓰지 않는다
    제약에 걸리면 선호 레인에서 가까운 레인으로 밀어낸다 — 음색 느낌은 최대한 남긴다.
    """
    n = int(centroid_at.size)
    if n == 0:
        return np.zeros(0, dtype=int)

    pref = preferred_lanes(centroid_at)
    out = np.zeros(n, dtype=int)
    last_t = [-99.0, -99.0, -99.0, -99.0]
    run_lane, run_len = -1, 0

    for i in range(n):
        t = float(times[i])
        p = int(pref[i])
        # 선호 레인 → 거리가 가까운 레인 순. 거리가 같으면 '가장 오래 쉰' 레인을 먼저
        # 고른다 (그냥 번호 순으로 고르면 왼쪽 레인만 계속 쓰게 되어 분포가 쏠린다)
        order = sorted(range(4), key=lambda lane: (abs(lane - p), last_t[lane]))
        chosen = -1
        for lane in order:
            if t - last_t[lane] < hand_gap:
                continue                       # 이 레인은 아직 손이 안 돌아옴
            if lane == run_lane and run_len >= max_run:
                continue                       # 한 레인만 계속 치게 두지 않는다
            chosen = lane
            break
        if chosen < 0:                          # 전부 막히면 가장 오래 쉰 레인으로
            chosen = int(np.argmin(last_t))

        out[i] = chosen
        last_t[chosen] = t
        run_len = run_len + 1 if chosen == run_lane else 1
        run_lane = chosen
    return out


def build_chart(audio: object, sens: float, min_gap: float, hand_gap: float) -> tuple:
    """음악에서 (노트 시각[초], 레인 번호) 를 뽑는다."""
    sample_rate = int(audio.sample_rate)
    mono = to_mono(audio)
    flux, centroid, hop = spectral_flux(mono, sample_rate)
    del mono
    idx = pick_peaks(flux, hop, sample_rate, sens, min_gap)
    if idx.size == 0:
        return np.zeros(0), np.zeros(0, dtype=int)
    times = idx * hop / float(sample_rate)
    lanes = assign_lanes(centroid[idx], times, hand_gap)
    return times, lanes


# ---------------- 판정 ----------------

def judge_of(dt: float, win: tuple) -> str:
    """판정선과의 시간 차(절대값)로 판정 이름을 정한다. 창 밖이면 빈 문자열.
    win = (PERFECT 한계, GREAT 한계, GOOD 한계) 초."""
    adt = abs(dt)
    if adt <= win[0]:
        return "PERFECT"
    if adt <= win[1]:
        return "GREAT"
    if adt <= win[2]:
        return "GOOD"
    return ""


def grade_of(accuracy: float) -> str:
    for limit, name in ((0.95, "S"), (0.90, "A"), (0.80, "B"), (0.70, "C")):
        if accuracy >= limit:
            return name
    return "D"


# ---------------- 게임 본체 ----------------

def run_game(audio: object, times: "np.ndarray", lanes: "np.ndarray",
             approach: float, interval_ms: int, play_sec: float,
             win: tuple) -> dict:
    """realtime_plot 한 창 안에서 게임을 돌린다.

    화면은 세 단계로 흐른다:
      ready → (클릭 또는 아무 키) → count(3·2·1) → play(음악 시작)
    """
    w_good = float(win[2])
    stat = {"PERFECT": 0, "GREAT": 0, "GOOD": 0, "MISS": 0}
    box = {
        "done": np.zeros(len(times), dtype=bool),   # 이미 처리된 노트
        "presses": [],        # 키 핸들러가 남기는 (레인, 벽시계) — update 에서 비운다
        "pos": 0.0,           # 마지막으로 읽은 오디오 위치
        "wall": time.perf_counter(),
        "score": 0, "combo": 0, "best_combo": 0,
        "last_judge": ("", 0.0),
        "lane_flash": [-9.0, -9.0, -9.0, -9.0],
        "errors": [],         # 판정된 노트의 시간 오차 (정확도 분석용)
        "phase": "ready",     # ready → count → play
        "count_t0": 0.0,      # 카운트다운을 시작한 시각 (realtime_plot 의 elapsed 기준)
        "go": False,          # 시작 신호 (클릭이나 키 입력)
    }

    def setup(fig, ax) -> dict:
        plt.rcParams["font.family"] = "Malgun Gothic"
        plt.rcParams["axes.unicode_minus"] = False
        fig.patch.set_facecolor("#0F172A")
        ax.set_facecolor("#0F172A")
        ax.set_xlim(-VIEW_X, VIEW_X)
        ax.set_ylim(-0.35, 1.15)
        ax.axis("off")

        # 레인 배경 · 키 눌림 하이라이트
        lane_bg = []
        for i in range(4):
            left = LANE_X[i] - LANE_W / 2
            ax.add_patch(plt.Rectangle((left, -0.30), LANE_W, 1.42,
                                       facecolor="#1E293B", edgecolor="#334155", lw=1))
            flash = plt.Rectangle((left, -0.30), LANE_W, 1.42,
                                  facecolor=LANE_COLORS[i], alpha=0.0, edgecolor="none")
            ax.add_patch(flash)
            lane_bg.append(flash)
            ax.text(LANE_X[i], -0.23, KEYS[i].upper(), ha="center", va="center",
                    fontsize=15, fontweight="bold", color="#94A3B8")

        # 판정선
        edge = LANE_W / 2 + 0.03
        ax.plot([LANE_X[0] - edge, LANE_X[3] + edge], [0, 0],
                color="#F8FAFC", lw=2.5, zorder=3)

        # 노트는 데이터 좌표 사각형으로 그린다 — 창 크기가 변해도 레인과 비율이 유지된다
        note_w = LANE_W * 0.88
        notes = []
        for _ in range(NOTE_POOL):
            rect = plt.Rectangle((0, 0), note_w, NOTE_H, zorder=4,
                                 edgecolor="white", linewidth=1.0, visible=False)
            ax.add_patch(rect)
            notes.append(rect)

        hud_x = LANE_X[3] + LANE_W / 2
        score_txt = ax.text(LANE_X[0] - LANE_W / 2, 1.09, "", fontsize=13,
                            color="#F8FAFC", fontweight="bold", va="top")
        combo_txt = ax.text(0.0, 0.62, "", ha="center", fontsize=26,
                            fontweight="bold", color="#FDE68A")
        judge_txt = ax.text(0.0, 0.40, "", ha="center", fontsize=22, fontweight="bold")
        time_txt = ax.text(hud_x, 1.09, "", ha="right", fontsize=11,
                           color="#94A3B8", va="top")

        # 시작 안내 — ready/count 단계에서만 보인다
        big_txt = ax.text(0.0, 0.58, "", ha="center", va="center",
                          fontsize=64, fontweight="bold", color="#F8FAFC")
        hint_txt = ax.text(0.0, 0.30, "", ha="center", va="center",
                           fontsize=15, color="#CBD5E1")

        def on_key(event):
            key = (event.key or "").lower()
            box["go"] = True                       # 어떤 키든 시작 신호가 된다
            if key in KEYS:
                box["presses"].append((KEYS.index(key), time.perf_counter()))

        def on_click(event):
            box["go"] = True                       # 클릭하면 포커스도 같이 잡힌다

        fig.canvas.mpl_connect("key_press_event", on_key)
        fig.canvas.mpl_connect("button_press_event", on_click)
        try:
            fig.canvas.setFocus()
        except Exception:
            pass

        return {"notes": notes, "score_txt": score_txt, "combo_txt": combo_txt,
                "judge_txt": judge_txt, "time_txt": time_txt, "flash": lane_bg,
                "big_txt": big_txt, "hint_txt": hint_txt}

    def start(state: dict) -> None:
        # 음악은 여기서 틀지 않는다 — 카운트다운이 끝나야 시작한다
        box["wall"] = time.perf_counter()

    def apply_judge(name: str, now: float, err: float = None) -> None:
        stat[name] += 1
        if name == "MISS":
            box["combo"] = 0
        else:
            box["combo"] += 1
            box["best_combo"] = max(box["best_combo"], box["combo"])
            box["score"] += SCORE[name] + min(box["combo"], 50) * 2
            if err is not None:
                box["errors"].append(err)
        box["last_judge"] = (name, now)

    def consume_presses(pos: float) -> None:
        """키 입력을 오디오 시각으로 환산해 가장 가까운 노트와 맞춰본다."""
        if not box["presses"]:
            return None
        pending = box["presses"][:]
        del box["presses"][:len(pending)]
        for lane, wall in pending:
            # 프레임 사이에 눌린 키도 정확히 잡도록 벽시계 차이만큼 보간한다
            hit_t = pos + (wall - box["wall"])
            box["lane_flash"][lane] = hit_t
            pool = np.where((~box["done"]) & (lanes == lane)
                            & (np.abs(times - hit_t) <= w_good))[0]
            if pool.size == 0:
                continue
            near = pool[np.argmin(np.abs(times[pool] - hit_t))]
            name = judge_of(times[near] - hit_t, win)
            if name:
                box["done"][near] = True
                apply_judge(name, hit_t, float(hit_t - times[near]))
        return None

    def draw_notes(pos: float, state: dict) -> None:
        """판정선 기준으로 보이는 노트만 그린다. pos 가 음수면 아직 시작 전(리드인)."""
        live = np.where((~box["done"]) & (times > pos - w_good)
                        & (times < pos + approach))[0]
        pool = state["notes"]
        shown = 0
        for i in live[:NOTE_POOL]:
            lane = int(lanes[i])
            rect = pool[shown]
            rect.set_xy((LANE_X[lane] - rect.get_width() / 2,
                         (times[i] - pos) / approach - NOTE_H / 2))
            rect.set_facecolor(LANE_COLORS[lane])
            rect.set_visible(True)
            shown += 1
        for slot in range(shown, NOTE_POOL):
            pool[slot].set_visible(False)
        return None

    def update(elapsed: float, frame_index: int, state: dict, ax) -> bool:
        # ── 시작 전: 클릭이나 키를 기다렸다가 3 · 2 · 1 ────────────────
        if box["phase"] != "play":
            if box["phase"] == "ready":
                state["big_txt"].set_text("READY")
                state["hint_txt"].set_text("화면을 클릭하거나 아무 키나 누르면 시작합니다\n"
                                           "조작:  D   F   J   K")
                state["time_txt"].set_text("곡 길이 %.0f초 · 노트 %d개" % (play_sec, len(times)))
                draw_notes(-approach, state)       # 첫 노트가 위에서 대기
                if box["go"]:
                    box["phase"] = "count"
                    box["count_t0"] = elapsed
                    del box["presses"][:]          # 시작 신호로 쓴 키는 판정하지 않는다
                return True

            left = COUNT_SEC - (elapsed - box["count_t0"])
            if left > 0:
                state["big_txt"].set_text("%d" % max(1, int(np.ceil(left))))
                state["hint_txt"].set_text("")
                del box["presses"][:]              # 카운트 중 입력은 무시
                draw_notes(-left, state)           # 노트가 미리 내려오기 시작한다
                return True

            # 카운트 끝 — 여기서 음악을 튼다
            state["big_txt"].set_text("")
            state["hint_txt"].set_text("")
            box["phase"] = "play"
            box["wall"] = time.perf_counter()
            del box["presses"][:]
            mAudio.play(audio)
            return True

        # ── 플레이 ────────────────────────────────────────────────
        pos = float(mAudio.get_position())
        box["pos"] = pos
        box["wall"] = time.perf_counter()

        consume_presses(pos)

        # 판정선을 지나쳐버린 노트는 MISS
        gone = np.where((~box["done"]) & (times < pos - w_good))[0]
        for _ in gone:
            apply_judge("MISS", pos)
        box["done"][gone] = True

        draw_notes(pos, state)

        # 키를 누른 레인을 0.12초간 밝힌다
        for i in range(4):
            age = pos - box["lane_flash"][i]
            state["flash"][i].set_alpha(0.35 * max(0.0, 1.0 - age / 0.12) if age >= 0 else 0.0)

        total = stat["PERFECT"] + stat["GREAT"] + stat["GOOD"] + stat["MISS"]
        state["score_txt"].set_text("SCORE %d      %d / %d" % (box["score"], total, len(times)))
        state["combo_txt"].set_text("%d COMBO" % box["combo"] if box["combo"] >= 3 else "")

        name, when = box["last_judge"]
        if name and pos - when < 0.45:
            state["judge_txt"].set_text(name)
            state["judge_txt"].set_color(JUDGE_COLOR[name])
        else:
            state["judge_txt"].set_text("")
        state["time_txt"].set_text("%.1f / %.0f초" % (pos, play_sec))

        # 음악이 끝나거나 노트를 다 소진하면 종료
        if pos >= play_sec - 0.05:
            return False
        if bool(box["done"].all()) and len(times) > 0 and pos > float(times[-1]) + 1.0:
            return False
        return bool(mAudio.is_playing())

    def stop(state: dict, reason: str = "") -> None:
        try:
            mAudio.stop()
        except Exception:
            pass
        return None

    info = mIO.realtime_plot(setup, update, start_func=start, stop_func=stop,
                             title="MDS 리듬게임 — D F J K",
                             interval_ms=int(interval_ms), duration_sec=None)
    return {"stat": stat, "box": box, "info": info}


# ---------------- 결과 정리 ----------------

def summarize(result: dict, times: "np.ndarray", music_name: str,
              judge_name: str = "", win: tuple = None) -> pd.DataFrame:
    stat, box, info = result["stat"], result["box"], result["info"]
    judged = stat["PERFECT"] + stat["GREAT"] + stat["GOOD"]
    total = judged + stat["MISS"]
    weighted = stat["PERFECT"] * 1.0 + stat["GREAT"] * 0.7 + stat["GOOD"] * 0.4
    accuracy = (weighted / total) if total else 0.0
    errs = np.asarray(box["errors"], dtype=float)

    rows = [
        {"항목": "곡", "값": music_name},
        {"항목": "점수", "값": "%d 점" % box["score"]},
        {"항목": "등급", "값": grade_of(accuracy)},
        {"항목": "정확도", "값": "%.1f %%" % (accuracy * 100)},
        {"항목": "최대 콤보", "값": "%d" % box["best_combo"]},
        {"항목": "PERFECT", "값": "%d" % stat["PERFECT"]},
        {"항목": "GREAT", "값": "%d" % stat["GREAT"]},
        {"항목": "GOOD", "값": "%d" % stat["GOOD"]},
        {"항목": "MISS", "값": "%d" % stat["MISS"]},
        {"항목": "처리한 노트", "값": "%d / %d" % (total, len(times))},
    ]
    if win:
        rows.append({"항목": "판정 여유",
                     "값": "%s (PERFECT ±%.0fms · GREAT ±%.0fms · GOOD ±%.0fms)"
                           % (judge_name, win[0] * 1000, win[1] * 1000, win[2] * 1000)})
    if errs.size:
        early = int((errs < 0).sum())
        rows.append({"항목": "평균 오차",
                     "값": "%+.0f ms (%s)" % (errs.mean() * 1000,
                                              "빠르게 누르는 편" if errs.mean() < 0 else "늦게 누르는 편")})
        rows.append({"항목": "빠름 / 늦음", "값": "%d / %d" % (early, errs.size - early)})
    rows.append({"항목": "평균 FPS", "값": "%.1f" % float(info.get("average_fps", 0.0))})
    rows.append({"항목": "종료 사유", "값": str(info.get("stopped_by", ""))})
    if info.get("error"):
        rows.append({"항목": "오류", "값": str(info["error"])})
    return pd.DataFrame(rows)


# ---------------- 실행 ----------------

def run_rhythm_game(df: pd.DataFrame) -> pd.DataFrame:
    path = pick_music_file()
    if not path:
        mIO.print("음악 파일을 선택하지 않아 종료합니다.")
        return pd.DataFrame()

    opts = ask_game_options()
    if not opts:
        mIO.print("설정이 취소되어 종료합니다.")
        return pd.DataFrame()

    play_sec = float(opts[L_PLAY])
    level = DIFFICULTY.get(str(opts[L_DIFF]), DIFFICULTY["NORMAL"])
    approach = float(opts[L_FALL]) / 1000.0
    interval_ms = int(opts[L_TICK])
    judge_name = str(opts[L_JUDGE])
    win = JUDGE_PRESETS.get(judge_name, JUDGE_PRESETS[JUDGE_DEFAULT])

    mIO.notify("음악을 분석해 채보를 만드는 중입니다...", duration_ms=2500)
    audio = mAudio.load(path, duration_sec=play_sec)
    times, lanes = build_chart(audio, level["sens"], level["gap"], level["hand"])

    if len(times) < 4:
        mIO.print("이 음악에서는 노트를 거의 찾지 못했습니다.\n"
                  "난이도를 HARD 로 올리거나 리듬이 뚜렷한 곡으로 바꿔보세요.")
        return pd.DataFrame()

    mIO.print("노트 %d개를 만들었습니다 (약 %.1f개/초).\n\n"
              "D F J K 로 각각 1~4번 레인을 칩니다.\n"
              "창이 뜨면 화면을 클릭하거나 아무 키나 누르세요 — 3·2·1 뒤에 음악이 시작됩니다."
              % (len(times), len(times) / max(play_sec, 1.0)))

    result = run_game(audio, times, lanes, approach, interval_ms, play_sec, win)

    import os
    summary = summarize(result, times, os.path.basename(path), judge_name, win)
    mIO.show_df(summary, title="리듬게임 결과")
    return summary


result = run_rhythm_game(df)

댓글