리듬게임
설명
음악 파일을 고르면 그 곡을 분석해서 채보를 그 자리에서 만들어 냅니다. 미리 짜둔 패턴이 아니라서 곡을 바꾸면 노트도 달라집니다.
필요 데이터
음악파일
필요 패키지
- miniaudio
작성자
메이커-4b22 | MDS 1.0
음악 파일을 고르면 그 곡을 분석해서 채보를 그 자리에서 만들어 냅니다. 미리 짜둔 패턴이 아니라서 곡을 바꾸면 노트도 달라집니다.
README
음악 파일을 고르면 그 곡을 분석해서 채보를 그 자리에서 만들어 냅니다. 미리 짜둔 패턴이 아니라서 곡을 바꾸면 노트도 달라집니다.
음악파일
메이커-4b22 | MDS 1.0
# =============================================================
# 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)
댓글