From 930fa1f4c61131f6d2a429a4a13bac2cfa6ad3d5 Mon Sep 17 00:00:00 2001 From: AnthonyDuan Date: Thu, 1 Jan 2026 14:35:14 +0800 Subject: [PATCH] Initial commit: Bcut-style Taiko Editor --- .gitignore | 6 + editor_core.py | 129 ++++++++++ main.py | 81 +++++++ requirements.txt | 1 + test_ui_mock.py | 64 +++++ tja_parser.py | 293 ++++++++++++++++++++++ ui.py | 617 +++++++++++++++++++++++++++++++++++++++++++++++ ui_utils.py | 106 ++++++++ 8 files changed, 1297 insertions(+) create mode 100644 .gitignore create mode 100644 editor_core.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 test_ui_mock.py create mode 100644 tja_parser.py create mode 100644 ui.py create mode 100644 ui_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb1c74f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +.idea/ +.vscode/ +*.log diff --git a/editor_core.py b/editor_core.py new file mode 100644 index 0000000..74becd9 --- /dev/null +++ b/editor_core.py @@ -0,0 +1,129 @@ +import pygame +import os +from tja_parser import TJA, Course, Note + +class EditorState: + def __init__(self): + self.tja = None + self.current_course_name = 'Oni' + self.current_time = 0.0 # ms + self.is_playing = False + self.zoom_x = 0.2 # pixels per ms + self.scroll_y = 0 + self.selected_notes = [] # List of Note objects + self.file_path = None + self.audio_path = None + self.snap_grid = 4 # 1/4 beat (16th note) + + def load_file(self, path): + if path.lower().endswith('.tja'): + self.file_path = path + self.tja = TJA(path) + # Find audio + wave = self.tja.headers.get('WAVE', '') + base_dir = os.path.dirname(path) + self.audio_path = os.path.join(base_dir, wave) + elif path.lower().endswith('.ogg'): + self.audio_path = path + # Check for TJA + base_name = os.path.splitext(path)[0] + tja_path = base_name + '.tja' + if os.path.exists(tja_path): + self.file_path = tja_path + self.tja = TJA(tja_path) + else: + self.tja = TJA() + self.tja.headers['WAVE'] = os.path.basename(path) + self.tja.headers['TITLE'] = os.path.basename(base_name) + + self.load_audio() + + def load_audio(self): + if self.audio_path and os.path.exists(self.audio_path): + try: + pygame.mixer.music.load(self.audio_path) + except Exception as e: + print(f"Failed to load audio: {e}") + + def get_current_course(self): + if not self.tja: return None + if self.current_course_name not in self.tja.courses: + self.tja.courses[self.current_course_name] = Course(self.current_course_name) + return self.tja.courses[self.current_course_name] + + def toggle_play(self): + if self.is_playing: + pygame.mixer.music.pause() + self.is_playing = False + else: + # Sync pygame music to current time + try: + # pygame.mixer.music.play(start=self.current_time / 1000.0) + # Note: 'start' in play() is usually working, but set_pos might be needed depending on implementation + if self.current_time < 0: + # Start from 0 if negative (pre-song offset) + pygame.mixer.music.play(start=0) + else: + pygame.mixer.music.play(start=self.current_time / 1000.0) + self.is_playing = True + except: + pass + + def update(self, dt): + if self.is_playing: + self.current_time += dt + # Optional: Sync with mixer position to avoid drift + # mixer_pos = pygame.mixer.music.get_pos() # Returns ms played since start of 'play' + # This is tricky because get_pos resets on play(). + # Simple dt addition is often smoother for short edits. + + def save(self): + if self.tja and self.file_path: + self.tja.save(self.file_path) + elif self.tja and self.audio_path: + # Save as .tja next to ogg + base = os.path.splitext(self.audio_path)[0] + self.file_path = base + ".tja" + self.tja.save(self.file_path) + + def record_note(self, note_type): + """Adds a note at current_time, snapped to grid""" + course = self.get_current_course() + if not course: return + + # Calculate snapped time + bpm = self.tja.headers.get('BPM', 120) + beat_ms = 60000 / bpm + snap_ms = beat_ms / self.snap_grid # e.g. 1/4 beat = 16th note + + # Quantize current time + raw_time = self.current_time + snapped_time = round(raw_time / snap_ms) * snap_ms + + # Calculate Measure/Beat + beat_total = snapped_time / beat_ms + measure = int(beat_total / 4) # Assuming 4/4 + beat = beat_total % 4 + + # Avoid duplicate at same time? + # For now, just add. Or remove existing note at this spot? + # Simple overwrite logic: + existing = [n for n in course.notes if abs(n.time - snapped_time) < 5] + for n in existing: + course.notes.remove(n) + + self.add_note(note_type, snapped_time, measure, beat) + + def add_note(self, note_type, time, measure, beat): + course = self.get_current_course() + if course: + new_note = Note(note_type, time, measure, beat) + course.notes.append(new_note) + + def remove_selected(self): + course = self.get_current_course() + if course and self.selected_notes: + for n in self.selected_notes: + if n in course.notes: + course.notes.remove(n) + self.selected_notes = [] diff --git a/main.py b/main.py new file mode 100644 index 0000000..6a193fc --- /dev/null +++ b/main.py @@ -0,0 +1,81 @@ +import pygame +import sys +import tkinter as tk +from tkinter import filedialog +from editor_core import EditorState +from ui import UI + +def main(): + # Initialize Tkinter for file dialog + try: + root = tk.Tk() + root.withdraw() + # Ensure dialog comes to front + root.attributes('-topmost', True) + + print("Please select an OGG file to start editing...") + file_path = filedialog.askopenfilename( + title="Select Audio File", + filetypes=[("Audio Files", "*.ogg"), ("TJA Files", "*.tja"), ("All Files", "*.*")] + ) + root.destroy() + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + return + except Exception as e: + print(f"Error opening file dialog: {e}") + return + + if not file_path: + print("No file selected. Exiting.") + return + + # Initialize Pygame + pygame.init() + pygame.mixer.init() + + SCREEN_WIDTH = 1024 + SCREEN_HEIGHT = 600 + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption("Taiko Editor (Python)") + + clock = pygame.time.Clock() + + # Initialize Editor + editor = EditorState() + editor.load_file(file_path) + + ui = UI(editor, SCREEN_WIDTH, SCREEN_HEIGHT) + + running = True + while running: + dt = clock.tick(60) # ms since last frame + + # Event Handling + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_SPACE: + editor.toggle_play() + elif event.key == pygame.K_s and (pygame.key.get_mods() & pygame.KMOD_CTRL): + editor.save() + print("Saved!") + elif event.key == pygame.K_DELETE: + editor.remove_selected() + + ui.handle_event(event) + + # Update + editor.update(dt) + + # Draw + ui.draw(screen) + + pygame.display.flip() + + pygame.quit() + sys.exit() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0cb7ff1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame diff --git a/test_ui_mock.py b/test_ui_mock.py new file mode 100644 index 0000000..d0d38c6 --- /dev/null +++ b/test_ui_mock.py @@ -0,0 +1,64 @@ +import pygame +import sys +from ui import UI +from editor_core import EditorState + +class MockEditorState(EditorState): + def __init__(self): + super().__init__() + # Initialize with dummy data + self.current_course_name = 'Oni' + self.tja = type('obj', (object,), { + 'headers': {'BPM': 120, 'OFFSET': 0, 'TITLE': 'Test Song'}, + 'courses': {} + }) + self.tja.courses['Oni'] = type('obj', (object,), { + 'level': 10, + 'balloon': [], + 'score_init': 0, + 'score_diff': 0, + 'notes': [] + }) + + def get_current_course(self): + return self.tja.courses['Oni'] + + def save(self): + print("Mock Save Triggered") + +def main(): + pygame.init() + SCREEN_WIDTH = 1200 + SCREEN_HEIGHT = 800 + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption("Taiko Editor UI Test (Mock)") + + editor = MockEditorState() + ui = UI(editor, SCREEN_WIDTH, SCREEN_HEIGHT) + + clock = pygame.time.Clock() + running = True + + while running: + dt = clock.tick(60) + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # Pass event to UI + ui.handle_event(event) + + ui.draw(screen) + + # Draw debug cursor + mx, my = pygame.mouse.get_pos() + pygame.draw.circle(screen, (0, 255, 0), (mx, my), 3) + + pygame.display.flip() + + pygame.quit() + sys.exit() + +if __name__ == "__main__": + main() diff --git a/tja_parser.py b/tja_parser.py new file mode 100644 index 0000000..12198f9 --- /dev/null +++ b/tja_parser.py @@ -0,0 +1,293 @@ +import os +import re +import math + +class Note: + def __init__(self, note_type, time_ms, measure_idx, beat_pos, duration_ms=0): + self.type = int(note_type) # 1=Don, 2=Ka, 3=BigDon, 4=BigKa, etc. + self.time = time_ms + self.measure = measure_idx + self.beat = beat_pos # Beat index within the measure (e.g. 0.0, 0.5, 1.0) + self.duration = duration_ms # For rolls/balloons + + def __repr__(self): + return f"Note(type={self.type}, time={self.time:.1f}, m={self.measure}, b={self.beat:.2f})" + +class Event: + def __init__(self, event_type, value, time_ms, measure_idx, beat_pos): + self.type = event_type # 'bpm', 'scroll', 'measure', 'barline' + self.value = value + self.time = time_ms + self.measure = measure_idx + self.beat = beat_pos + +class Course: + def __init__(self, difficulty): + self.difficulty = difficulty + self.level = 0 + self.balloon = [] + self.score_init = 0 + self.score_diff = 0 + self.notes = [] + self.events = [] + # Raw lines for reference or partial parsing + self.lines = [] + +class TJA: + def __init__(self, file_path=None): + self.headers = { + 'TITLE': 'New Song', + 'SUBTITLE': '', + 'BPM': 120.0, + 'WAVE': 'song.ogg', + 'OFFSET': 0.0, + 'DEMOSTART': 0.0, + 'SONGVOL': 100, + 'SEVOL': 100 + } + self.courses = {} # 'Easy', 'Normal', 'Hard', 'Oni', 'Edit' + self.common_lines = [] # Lines before any COURSE: command + + if file_path and os.path.exists(file_path): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError: + with open(file_path, 'r', encoding='shift_jis', errors='replace') as f: + content = f.read() + self.parse(content) + else: + # Initialize default courses + for diff in ['Easy', 'Normal', 'Hard', 'Oni', 'Edit']: + self.courses[diff] = Course(diff) + + def parse(self, text): + lines = text.splitlines() + current_course_obj = None + current_lines = self.common_lines + + # First pass: Separate lines by COURSE + for line in lines: + line = line.strip() + if not line: + continue + + if line.startswith('COURSE:'): + course_name = line.split(':', 1)[1].strip() + # Normalize course name + c_map = {'0': 'Easy', 'Easy': 'Easy', '1': 'Normal', 'Normal': 'Normal', + '2': 'Hard', 'Hard': 'Hard', '3': 'Oni', 'Oni': 'Oni', + '4': 'Edit', 'Edit': 'Edit'} + c_name = c_map.get(course_name, course_name) + + if c_name not in self.courses: + self.courses[c_name] = Course(c_name) + current_course_obj = self.courses[c_name] + current_lines = current_course_obj.lines + elif line.startswith('#START'): + # Start of chart data, but we just store lines for now + if current_course_obj: + current_lines.append(line) + elif ':' in line and not line.startswith('#'): + # Header + key, val = line.split(':', 1) + key = key.strip().upper() + val = val.strip() + + if current_course_obj is None: + # Global header + if key in ['TITLE', 'SUBTITLE', 'WAVE']: + self.headers[key] = val + elif key in ['BPM', 'OFFSET', 'DEMOSTART', 'SONGVOL', 'SEVOL']: + try: + self.headers[key] = float(val) + except: + pass + else: + # Course specific header + if key == 'LEVEL': + try: current_course_obj.level = int(val) + except: pass + elif key == 'BALLOON': + current_course_obj.balloon = [int(x) for x in val.split(',') if x.strip()] + elif key == 'SCOREINIT': + current_course_obj.score_init = int(val.split(',')[0]) + elif key == 'SCOREDIFF': + current_course_obj.score_diff = int(val) + else: + current_lines.append(line) + + # Second pass: Process notes for each course + base_bpm = self.headers['BPM'] + base_offset = self.headers['OFFSET'] + + for course_name, course in self.courses.items(): + self._parse_course_notes(course, base_bpm, base_offset) + + def _parse_course_notes(self, course, bpm, offset): + course.notes = [] + course.events = [] + + current_bpm = bpm + current_measure_frac = 4.0 / 4.0 # 4/4 time + + # Calculate start time based on OFFSET + # TJA OFFSET: "A negative value makes the song start earlier (notes appear later)" + # Wait, TJA spec says: OFFSET is the time (in seconds) that the music starts relative to the first beat. + # If OFFSET is -1.9, music starts 1.9s BEFORE the first beat. + # So first beat is at +1.9s relative to music start. + # Current Time Pointer + current_time = -offset * 1000 # Convert to ms + + # We need to process lines sequentially + # Notes are comma-separated. + # Commands start with # + + measure_buffer = "" + measure_idx = 0 + + for line in course.lines: + line = line.split('//')[0].strip() # Remove comments + if not line: continue + + if line.startswith('#START'): + current_time = -offset * 1000 + measure_idx = 0 + continue + + if line.startswith('#END'): + break + + if line.startswith('#'): + # Command + if line.startswith('#BPMCHANGE'): + try: + val = float(line.split()[1]) + course.events.append(Event('bpm', val, current_time, measure_idx, 0)) + current_bpm = val + except: pass + elif line.startswith('#MEASURE'): + try: + m_str = line.split()[1] + num, den = map(int, m_str.split('/')) + current_measure_frac = num / den + course.events.append(Event('measure', current_measure_frac, current_time, measure_idx, 0)) + except: pass + elif line.startswith('#SCROLL'): + try: + val = float(line.split()[1]) + course.events.append(Event('scroll', val, current_time, measure_idx, 0)) + except: pass + elif line.startswith('#GOGOSTART'): + course.events.append(Event('gogo', True, current_time, measure_idx, 0)) + elif line.startswith('#GOGOEND'): + course.events.append(Event('gogo', False, current_time, measure_idx, 0)) + elif line.startswith('#BARLINEOFF'): + course.events.append(Event('barline', False, current_time, measure_idx, 0)) + elif line.startswith('#BARLINEON'): + course.events.append(Event('barline', True, current_time, measure_idx, 0)) + continue + + # Accumulate measure string + measure_buffer += line + if measure_buffer.endswith(','): + # Process measure + notes_str = measure_buffer[:-1] # Remove comma + measure_buffer = "" + + # Calculate duration of this measure + # beats per measure = 4 * current_measure_frac (e.g. 4/4 -> 4 beats, 3/4 -> 3 beats) + beats_in_measure = 4 * current_measure_frac + measure_duration = (60000 / current_bpm) * beats_in_measure + + num_notes = len(notes_str) + if num_notes > 0: + time_per_char = measure_duration / num_notes + beat_per_char = beats_in_measure / num_notes + + for i, char in enumerate(notes_str): + if char == '0': continue + if char in '1234': # Don, Ka, BigDon, BigKa + note_time = current_time + (i * time_per_char) + note_beat = i * beat_per_char + course.notes.append(Note(char, note_time, measure_idx, note_beat)) + # Note: Logic for rolls (5,6,7,8) is more complex (start/end), simplified here for MVP + elif char in '5679': # Rolls/Balloon Start + note_time = current_time + (i * time_per_char) + note_beat = i * beat_per_char + course.notes.append(Note(char, note_time, measure_idx, note_beat)) + elif char == '8': # End roll + note_time = current_time + (i * time_per_char) + note_beat = i * beat_per_char + course.notes.append(Note(char, note_time, measure_idx, note_beat)) + + current_time += measure_duration + measure_idx += 1 + + def save(self, file_path): + # Basic reconstruction logic + with open(file_path, 'w', encoding='utf-8') as f: + # Write Headers + for k, v in self.headers.items(): + if v: + f.write(f"{k}:{v}\n") + f.write("\n") + + # Write Courses + for name, course in self.courses.items(): + if not course.notes and not course.events: + continue # Skip empty courses + + f.write(f"COURSE:{name}\n") + f.write(f"LEVEL:{course.level}\n") + if course.balloon: + f.write(f"BALLOON:{','.join(map(str, course.balloon))}\n") + f.write(f"SCOREINIT:{course.score_init}\n") + f.write(f"SCOREDIFF:{course.score_diff}\n") + f.write("\n#START\n") + + # Reconstruct measures from notes + # This is the hard part: "Export" logic + # For MVP, if we haven't edited the structure deeply, we could just dump the parsed events? + # But the prompt implies editing. + # Let's try a simple generation: + # Group notes by measure index. + + max_measure = 0 + if course.notes: + max_measure = max(n.measure for n in course.notes) + if course.events: + max_measure = max(max_measure, max(e.measure for e in course.events)) + + current_bpm = self.headers['BPM'] + + for m in range(max_measure + 1): + # Find events in this measure + m_events = [e for e in course.events if e.measure == m] + for e in m_events: + if e.type == 'bpm': f.write(f"#BPMCHANGE {e.value}\n") + if e.type == 'measure': f.write(f"#MEASURE {int(e.value*4)}/4\n") # Simplified + if e.type == 'gogo': f.write("#GOGOSTART\n" if e.value else "#GOGOEND\n") + + # Construct note string + # Default to 16 divisions per measure (standard) or higher if needed + # Find notes in this measure + m_notes = sorted([n for n in course.notes if n.measure == m], key=lambda x: x.beat) + + # Determine required resolution + # Simple approach: Use 16 chars per measure (4 chars per beat) + grid_size = 16 + line = ['0'] * grid_size + + for n in m_notes: + # map beat (0..4) to index (0..16) + # beat is 0.0 to 4.0 (in 4/4) + # index = (beat / 4.0) * 16 = beat * 4 + idx = int(round(n.beat * (grid_size / 4.0))) # Assuming 4/4 + if 0 <= idx < grid_size: + line[idx] = str(n.type) + + f.write("".join(line) + ",\n") + + f.write("#END\n\n") + diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..b9dff65 --- /dev/null +++ b/ui.py @@ -0,0 +1,617 @@ +import pygame +import pygame.gfxdraw +from editor_core import EditorState +import ui_utils +from ui_utils import FontManager, COLOR_BG_ROOT, COLOR_BG_PANEL, COLOR_BORDER, COLOR_ACCENT, COLOR_TEXT_MAIN, COLOR_TEXT_DIM, COLOR_TIMELINE_BG, COLOR_TRACK_BG + +# Note Colors +COLOR_DON = (255, 71, 87) +COLOR_KA = (65, 166, 245) +COLOR_ROLL = (255, 200, 50) + +class UI: + def __init__(self, editor: EditorState, screen_width, screen_height): + self.editor = editor + self.width = screen_width + self.height = screen_height + self.font_mgr = FontManager() + + # State + self.dragging_note = None + self.is_dragging_timeline = False + self.last_mouse_x = 0 + self.hovered_ui_item = None + + # Input/Edit State + self.input_active = None # 'BPM' or 'TITLE' or None + self.input_text = "" + self.input_rect = None + + # Key Recording State + self.key_buffer = {} # {key: press_time} + + # Layout Rects (calculated in update_layout) + self.update_layout(screen_width, screen_height) + + def update_layout(self, w, h): + self.width = w + self.height = h + + # 1. Header (Top) + self.h_header = 40 + self.rect_header = pygame.Rect(0, 0, w, self.h_header) + + # 2. Main Body (Middle) + self.h_timeline = int(h * 0.38) + self.h_main = h - self.h_header - self.h_timeline + self.y_main = self.h_header + + # 3. Columns in Main Body + self.w_lib = 240 + self.w_prop = 260 + self.w_preview = w - self.w_lib - self.w_prop + + self.rect_lib = pygame.Rect(0, self.y_main, self.w_lib, self.h_main) + self.rect_preview = pygame.Rect(self.w_lib, self.y_main, self.w_preview, self.h_main) + self.rect_prop = pygame.Rect(self.w_lib + self.w_preview, self.y_main, self.w_prop, self.h_main) + + # 4. Timeline (Bottom) + self.rect_timeline = pygame.Rect(0, self.y_main + self.h_main, w, self.h_timeline) + + def draw(self, screen): + screen.fill(COLOR_BG_ROOT) + + self.draw_header(screen) + self.draw_library(screen) + self.draw_preview(screen) + self.draw_properties(screen) + self.draw_timeline(screen) + + # Drag Overlay + if self.dragging_note: + mx, my = pygame.mouse.get_pos() + ntype = self.dragging_note['type'] + color = COLOR_DON if ntype in [1, 3] else COLOR_KA + r = 24 if ntype in [3, 4] else 16 + + # Draw ghost + pygame.gfxdraw.filled_circle(screen, mx, my, r+4, (255, 255, 255, 100)) + pygame.gfxdraw.aacircle(screen, mx, my, r, color) + pygame.gfxdraw.filled_circle(screen, mx, my, r, color) + + def draw_panel_bg(self, screen, rect): + # Fill + pygame.draw.rect(screen, COLOR_BG_PANEL, rect) + # Border + pygame.draw.rect(screen, COLOR_BORDER, rect, 1) + + def draw_header(self, screen): + r = self.rect_header + pygame.draw.rect(screen, COLOR_BG_PANEL, r) + pygame.draw.line(screen, COLOR_BORDER, r.bottomleft, r.bottomright) + + # Title + font_title = self.font_mgr.get_font(18, bold=True) + title_surf = font_title.render("Taiko Editor", True, COLOR_ACCENT) + screen.blit(title_surf, (15, (self.h_header - title_surf.get_height())//2)) + + # Difficulty Tabs (Pills) + diffs = ['Easy', 'Normal', 'Hard', 'Oni', 'Edit'] + cx = 160 + cy = self.h_header // 2 + self.tab_rects = {} # Store rects for hit testing + + for d in diffs: + is_active = (d == self.editor.current_course_name) + txt_font = self.font_mgr.get_font(12) + tw, th = txt_font.size(d) + btn_w = tw + 20 + btn_h = 24 + btn_rect = pygame.Rect(cx, cy - btn_h//2, btn_w, btn_h) + self.tab_rects[d] = btn_rect + + ui_utils.draw_pill_btn(screen, d, btn_rect, is_active, self.font_mgr) + + cx += btn_w + 10 + + # Export Button (Right) + btn_exp_w = 80 + btn_exp_h = 26 + self.rect_exp = pygame.Rect(self.width - btn_exp_w - 15, cy - btn_exp_h//2, btn_exp_w, btn_exp_h) + # Draw rounded rect + pygame.draw.rect(screen, (0, 180, 210), self.rect_exp, border_radius=4) + exp_txt = self.font_mgr.get_font(12, bold=True).render("导出", True, (0, 0, 0)) + screen.blit(exp_txt, (self.rect_exp.x + (btn_exp_w - exp_txt.get_width())//2, self.rect_exp.y + 4)) + + def draw_library(self, screen): + r = self.rect_lib + self.draw_panel_bg(screen, r) + + # Sidebar (Icons) + sidebar_w = 40 + pygame.draw.rect(screen, (25, 25, 25), (r.x, r.y, sidebar_w, r.h)) + pygame.draw.line(screen, COLOR_BORDER, (r.x + sidebar_w, r.y), (r.x + sidebar_w, r.y + r.h)) + + # Main Area Title + font = self.font_mgr.get_font(14) + lbl = font.render("素材库", True, COLOR_TEXT_MAIN) + screen.blit(lbl, (r.x + sidebar_w + 10, r.y + 10)) + + # Assets Grid + start_x = r.x + sidebar_w + 15 + start_y = r.y + 40 + + assets = [ + {'name': 'Don', 'type': 1, 'color': COLOR_DON}, + {'name': 'Ka', 'type': 2, 'color': COLOR_KA}, + {'name': 'Big Don', 'type': 3, 'color': COLOR_DON}, + {'name': 'Big Ka', 'type': 4, 'color': COLOR_KA}, + {'name': 'Roll', 'type': 5, 'color': COLOR_ROLL}, + ] + + x, y = start_x, start_y + for item in assets: + # Card Bg + card_w, card_h = 80, 80 + card_rect = pygame.Rect(x, y, card_w, card_h) + + mx, my = pygame.mouse.get_pos() + hover = card_rect.collidepoint(mx, my) + + bg_col = (50, 50, 50) if hover else (40, 40, 40) + pygame.draw.rect(screen, bg_col, card_rect, border_radius=6) + + # Icon + cx, cy = card_rect.centerx, card_rect.centery - 10 + rad = 18 if item['type'] in [3, 4] else 12 + pygame.gfxdraw.aacircle(screen, cx, cy, rad, item['color']) + pygame.gfxdraw.filled_circle(screen, cx, cy, rad, item['color']) + + # Text + name_surf = self.font_mgr.get_font(12).render(item['name'], True, COLOR_TEXT_DIM) + screen.blit(name_surf, (cx - name_surf.get_width()//2, cy + 20)) + + x += card_w + 10 + if x + card_w > r.right: + x = start_x + y += card_h + 10 + + # Handle Drag Start + if hover and pygame.mouse.get_pressed()[0]: + self.dragging_note = {'type': item['type']} + + def draw_preview(self, screen): + r = self.rect_preview + # Draw pure black bg for player + pygame.draw.rect(screen, (0, 0, 0), r) + # Border + pygame.draw.rect(screen, COLOR_BORDER, r, 1) + + # Placeholder Waveform/Visual + cx, cy = r.center + pygame.draw.line(screen, (50, 50, 50), (r.x, cy), (r.right, cy)) + + # Song Title + title = self.editor.tja.headers.get('TITLE', 'No Title') + font_big = self.font_mgr.get_font(24, bold=True) + surf = font_big.render(title, True, (100, 100, 100)) + screen.blit(surf, (cx - surf.get_width()//2, cy - 50)) + + # Controls Bar (Bottom of preview) + ctrl_h = 40 + ctrl_y = r.bottom - ctrl_h + pygame.draw.rect(screen, (20, 20, 20), (r.x, ctrl_y, r.w, ctrl_h)) + + # Play Button + p_center = (r.x + 30, ctrl_y + 20) + color_play = COLOR_TEXT_MAIN + if self.editor.is_playing: + ui_utils.draw_icon_pause(screen, p_center, 16, color_play) + else: + ui_utils.draw_icon_play(screen, p_center, 16, color_play) + + # Timecode + ms = self.editor.current_time + if ms < 0: ms = 0 + secs = int(ms / 1000) + mins = secs // 60 + secs = secs % 60 + msecs = int(ms % 1000) // 10 + time_str = f"{mins:02}:{secs:02}:{msecs:02}" + + font_time = self.font_mgr.get_font(14, bold=True) # Monospaced ideally + tsurf = font_time.render(time_str, True, COLOR_ACCENT) + screen.blit(tsurf, (r.x + 60, ctrl_y + 10)) + + def draw_properties(self, screen): + r = self.rect_prop + self.draw_panel_bg(screen, r) + + font_head = self.font_mgr.get_font(14, bold=True) + font_lbl = self.font_mgr.get_font(12) + + pad = 15 + y = r.y + pad + + # Title + screen.blit(font_head.render("属性", True, COLOR_TEXT_MAIN), (r.x + pad, y)) + y += 30 + + # Content + if self.editor.selected_notes: + # Note Properties + note = self.editor.selected_notes[0] + props = [ + ("类型", str(note.type)), + ("时间", f"{note.time:.1f}ms"), + ("位置", f"{note.measure + 1} - {note.beat + 1:.1f}"), + ] + + for k, v in props: + screen.blit(font_lbl.render(k, True, COLOR_TEXT_DIM), (r.x + pad, y)) + screen.blit(font_lbl.render(v, True, COLOR_TEXT_MAIN), (r.x + pad + 60, y)) + y += 25 + + # Delete Hint + y += 20 + hint = font_lbl.render("右键删除 / Del键", True, (150, 50, 50)) + screen.blit(hint, (r.x + pad, y)) + + else: + # Global Properties + # Use stored rects for click detection + self.prop_rects = {} + + props = [ + ("BPM", str(self.editor.tja.headers.get('BPM')), 'BPM'), + ("OFFSET", str(self.editor.tja.headers.get('OFFSET')), 'OFFSET'), + ("标题", str(self.editor.tja.headers.get('TITLE')), 'TITLE'), + ("难度", self.editor.current_course_name, None), + ] + + for k, v, key_id in props: + screen.blit(font_lbl.render(k, True, COLOR_TEXT_DIM), (r.x + pad, y)) + + # Value box + val_rect = pygame.Rect(r.x + pad + 60, y - 2, 140, 24) + if key_id: self.prop_rects[key_id] = val_rect + + is_editing = (self.input_active == key_id) + bg_col = (50, 50, 70) if is_editing else (20, 20, 20) + + pygame.draw.rect(screen, bg_col, val_rect, border_radius=4) + + disp_text = self.input_text if is_editing else v + screen.blit(font_lbl.render(disp_text, True, COLOR_TEXT_MAIN), (val_rect.x + 5, val_rect.y + 4)) + + if is_editing: + # Cursor + tw = font_lbl.size(disp_text)[0] + pygame.draw.line(screen, COLOR_ACCENT, (val_rect.x + 5 + tw, val_rect.y+4), (val_rect.x + 5 + tw, val_rect.y+20)) + + y += 35 + + def draw_timeline(self, screen): + r = self.rect_timeline + pygame.draw.rect(screen, COLOR_TIMELINE_BG, r) + pygame.draw.line(screen, COLOR_BORDER, r.topleft, r.topright) + + # Header (Track Names) + w_header = 100 + rect_track_header = pygame.Rect(r.x, r.y, w_header, r.h) + pygame.draw.rect(screen, (30, 30, 30), rect_track_header) + pygame.draw.line(screen, COLOR_BORDER, rect_track_header.topright, rect_track_header.bottomright) + + # Tracks + track_h = 80 + y_track = r.y + 30 # 30px ruler + + # Ruler + self.draw_ruler(screen, r.x + w_header, r.y, r.w - w_header, 30) + + # Note Track + rect_track_bg = pygame.Rect(r.x + w_header, y_track, r.w - w_header, track_h) + pygame.draw.rect(screen, COLOR_TRACK_BG, rect_track_bg) + pygame.draw.line(screen, COLOR_BORDER, (r.x, y_track+track_h), (r.right, y_track+track_h)) + + # Label + lbl = self.font_mgr.get_font(12).render("音符轨", True, COLOR_TEXT_DIM) + screen.blit(lbl, (r.x + 10, y_track + 30)) + + # Draw Grid & Notes + self.draw_track_content(screen, rect_track_bg) + + def draw_ruler(self, screen, x, y, w, h): + pygame.draw.rect(screen, (40, 40, 40), (x, y, w, h)) + pygame.draw.line(screen, COLOR_BORDER, (x, y+h), (x+w, y+h)) + + bpm = self.editor.tja.headers.get('BPM', 120) + beat_ms = 60000 / bpm + + # Visible range + t_start = self.x_to_time(x, x, w) + t_end = self.x_to_time(x+w, x, w) + + beat_start = int(t_start / beat_ms) + beat_end = int(t_end / beat_ms) + 1 + + for i in range(beat_start, beat_end): + t = i * beat_ms + draw_x = self.time_to_x(t, x, w) + + if x <= draw_x <= x + w: + is_measure = (i % 4 == 0) + h_line = 15 if is_measure else 8 + col = (200, 200, 200) if is_measure else (100, 100, 100) + pygame.draw.line(screen, col, (draw_x, y + h - h_line), (draw_x, y + h)) + + if is_measure: + num = i // 4 + txt = self.font_mgr.get_font(10).render(str(num), True, (150, 150, 150)) + screen.blit(txt, (draw_x + 2, y + 2)) + + def draw_track_content(self, screen, rect): + # Clip area + prev_clip = screen.get_clip() + screen.set_clip(rect) + + bpm = self.editor.tja.headers.get('BPM', 120) + beat_ms = 60000 / bpm + + center_y = rect.centery + + # Grid Lines + t_start = self.x_to_time(rect.x, rect.x, rect.w) + t_end = self.x_to_time(rect.right, rect.x, rect.w) + + beat_start = int(t_start / beat_ms) + beat_end = int(t_end / beat_ms) + 1 + + for i in range(beat_start, beat_end): + t = i * beat_ms + draw_x = self.time_to_x(t, rect.x, rect.w) + is_measure = (i % 4 == 0) + col = (60, 60, 60) if is_measure else (45, 45, 45) + pygame.draw.line(screen, col, (draw_x, rect.top), (draw_x, rect.bottom)) + + # Notes + course = self.editor.get_current_course() + if course: + visible_notes = [n for n in course.notes if t_start - 500 < n.time < t_end + 500] + for note in visible_notes: + draw_x = self.time_to_x(note.time, rect.x, rect.w) + + color = COLOR_DON if note.type in [1, 3] else COLOR_KA + if note.type == 5: color = COLOR_ROLL + + radius = 22 if note.type in [3, 4] else 15 + + # Selection Glow + if note in self.editor.selected_notes: + pygame.gfxdraw.filled_circle(screen, int(draw_x), int(center_y), radius+4, (255, 255, 255, 100)) + pygame.draw.circle(screen, (255, 255, 255), (int(draw_x), int(center_y)), radius+2, 2) + + # Note Body + pygame.gfxdraw.aacircle(screen, int(draw_x), int(center_y), radius, (255, 255, 255)) # White Outline + pygame.gfxdraw.filled_circle(screen, int(draw_x), int(center_y), radius, color) + # Shine + pygame.gfxdraw.filled_circle(screen, int(draw_x - radius*0.3), int(center_y - radius*0.3), int(radius*0.25), (255, 255, 255, 120)) + + # Playhead + ph_x = self.time_to_x(self.editor.current_time, rect.x, rect.w) + if rect.x <= ph_x <= rect.right: + pygame.draw.line(screen, COLOR_ACCENT, (ph_x, rect.top - 10), (ph_x, rect.bottom), 2) + # Cap + pts = [(ph_x-6, rect.top-10), (ph_x+6, rect.top-10), (ph_x, rect.top)] + pygame.draw.polygon(screen, COLOR_ACCENT, pts) + + screen.set_clip(prev_clip) + + # Coordinate Systems + # We define: Center of timeline view corresponds to current_time + # Playhead is FIXED at center of the track view area? + # Bcut style: Playhead usually moves, or timeline scrolls. + # Let's keep Fixed Playhead (Timeline Scrolls) for easier editing. + + def time_to_x(self, t, track_x, track_w): + center_x = track_x + track_w // 2 + return center_x + (t - self.editor.current_time) * self.editor.zoom_x + + def x_to_time(self, x, track_x, track_w): + center_x = track_x + track_w // 2 + return self.editor.current_time + (x - center_x) / self.editor.zoom_x + + def handle_event(self, event): + mx, my = pygame.mouse.get_pos() + + # 0. Global Key Handling (Input vs Recording) + if event.type == pygame.KEYDOWN: + if self.input_active: + if event.key == pygame.K_RETURN: + # Commit + try: + val = self.input_text + if self.input_active in ['BPM', 'OFFSET']: + val = float(val) + self.editor.tja.headers[self.input_active] = val + except: + pass + self.input_active = None + elif event.key == pygame.K_BACKSPACE: + self.input_text = self.input_text[:-1] + elif event.key == pygame.K_ESCAPE: + self.input_active = None + else: + self.input_text += event.unicode + return # Consume event + + # Recording Logic (F/J/D/K) + # Logic: On key down, store time. If partner key pressed within 50ms, trigger Big. + # Otherwise trigger small after timeout? + # Or simplified: if F pressed, check if J is already down? + # Better: When key pressed, add to buffer. On update loop, check buffer. + # But event loop is easiest for immediate reaction. + + # Mapping + k = event.key + now = pygame.time.get_ticks() + + is_don_key = (k == pygame.K_f or k == pygame.K_j) + is_ka_key = (k == pygame.K_d or k == pygame.K_k) + + if is_don_key or is_ka_key: + # Add to buffer + self.key_buffer[k] = now + + # Check for big note conditions + # Don (F+J) + f_down = (now - self.key_buffer.get(pygame.K_f, 0) < 50) + j_down = (now - self.key_buffer.get(pygame.K_j, 0) < 50) + + if f_down and j_down: + self.editor.record_note(3) # Big Don + # Clear buffer to prevent double trigger + self.key_buffer[pygame.K_f] = 0 + self.key_buffer[pygame.K_j] = 0 + return + + # Ka (D+K) + d_down = (now - self.key_buffer.get(pygame.K_d, 0) < 50) + k_down = (now - self.key_buffer.get(pygame.K_k, 0) < 50) + + if d_down and k_down: + self.editor.record_note(4) # Big Ka + self.key_buffer[pygame.K_d] = 0 + self.key_buffer[pygame.K_k] = 0 + return + + # Single Note (Small) + # We need to wait a bit to ensure it's not a big note? + # For real-time feedback, usually we trigger small immediately, + # and upgrade to big if simultaneous? (Hard to upgrade) + # Or just trigger small if the other key isn't down. + # Simplified: If pressed, and other key NOT pressed recently, add small. + + if is_don_key: + # Check if partner is pressed + partner = pygame.K_j if k == pygame.K_f else pygame.K_f + if now - self.key_buffer.get(partner, 0) > 50: + self.editor.record_note(1) # Small Don + + if is_ka_key: + partner = pygame.K_k if k == pygame.K_d else pygame.K_d + if now - self.key_buffer.get(partner, 0) > 50: + self.editor.record_note(2) # Small Ka + + # Check regions + if self.rect_header.collidepoint(mx, my): + if event.type == pygame.MOUSEBUTTONDOWN: + self.input_active = None # Clear input focus + # Use stored rects for precise hit testing + if hasattr(self, 'tab_rects'): + for d, rect in self.tab_rects.items(): + if rect.collidepoint(mx, my): + self.editor.current_course_name = d + break + + # Export + if hasattr(self, 'rect_exp') and self.rect_exp.collidepoint(mx, my): + self.editor.save() + print("Exported") + + elif self.rect_preview.collidepoint(mx, my): + if event.type == pygame.MOUSEBUTTONDOWN: + self.input_active = None + # Toggle play + self.editor.toggle_play() + + elif self.rect_prop.collidepoint(mx, my): + if event.type == pygame.MOUSEBUTTONDOWN: + # Check property clicks + clicked_prop = None + if hasattr(self, 'prop_rects'): + for k, rect in self.prop_rects.items(): + if rect.collidepoint(mx, my): + clicked_prop = k + break + + if clicked_prop: + self.input_active = clicked_prop + # Init text + val = self.editor.tja.headers.get(clicked_prop, '') + self.input_text = str(val) + else: + self.input_active = None + + elif self.rect_timeline.collidepoint(mx, my): + self.input_active = None + # Track Area + track_y = self.rect_timeline.y + 30 + track_rect = pygame.Rect(self.rect_timeline.x + 100, track_y, self.rect_timeline.w - 100, 80) + + if track_rect.collidepoint(mx, my): + clicked_note = self.get_note_at(mx, my, track_rect) + + if event.type == pygame.MOUSEBUTTONDOWN: + if clicked_note: + if event.button == 1: + self.editor.selected_notes = [clicked_note] + self.dragging_note = {'type': 'move', 'note': clicked_note} + elif event.button == 3: + course = self.editor.get_current_course() + if course and clicked_note in course.notes: + course.notes.remove(clicked_note) + else: + if event.button == 1: + self.is_dragging_timeline = True + self.last_mouse_x = mx + + if event.type == pygame.MOUSEBUTTONUP: + if self.dragging_note and self.dragging_note['type'] != 'move': + # Drop from library + time = self.x_to_time(mx, track_rect.x, track_rect.w) + # Snap + bpm = self.editor.tja.headers.get('BPM', 120) + snap = (60000/bpm) / 4 + time = round(time/snap) * snap + + beat_total = time / (60000/bpm) + measure = int(beat_total / 4) + beat = beat_total % 4 + + self.editor.add_note(self.dragging_note['type'], time, measure, beat) + + self.dragging_note = None + self.is_dragging_timeline = False + + if event.type == pygame.MOUSEMOTION: + if self.is_dragging_timeline: + dx = mx - self.last_mouse_x + self.editor.current_time -= dx / self.editor.zoom_x + self.last_mouse_x = mx + + if self.dragging_note and self.dragging_note.get('type') == 'move': + time = self.x_to_time(mx, track_rect.x, track_rect.w) + bpm = self.editor.tja.headers.get('BPM', 120) + snap = (60000/bpm) / 4 + time = round(time/snap) * snap + self.dragging_note['note'].time = time + + if event.type == pygame.MOUSEWHEEL: + if pygame.key.get_pressed()[pygame.K_LCTRL]: + self.editor.zoom_x *= (1.1 if event.y > 0 else 0.9) + else: + self.editor.current_time -= event.y * 100 + + def get_note_at(self, mx, my, track_rect): + course = self.editor.get_current_course() + if not course: return None + + cy = track_rect.centery + for note in course.notes: + nx = self.time_to_x(note.time, track_rect.x, track_rect.w) + dist = ((mx - nx)**2 + (my - cy)**2)**0.5 + r = 24 if note.type in [3, 4] else 16 + if dist < r: + return note + return None diff --git a/ui_utils.py b/ui_utils.py new file mode 100644 index 0000000..724f2e0 --- /dev/null +++ b/ui_utils.py @@ -0,0 +1,106 @@ +import pygame +import pygame.gfxdraw +import sys + +# --- Colors (Bcut / Dark Theme) --- +COLOR_BG_ROOT = (19, 19, 19) # #131313 +COLOR_BG_PANEL = (30, 30, 30) # #1E1E1E +COLOR_BORDER = (44, 44, 44) # #2C2C2C +COLOR_ACCENT = (255, 71, 87) # #FF4757 (Pinkish Red) +COLOR_ACCENT_HOVER = (255, 107, 129) +COLOR_TEXT_MAIN = (224, 224, 224) # #E0E0E0 +COLOR_TEXT_DIM = (128, 128, 128) # #808080 +COLOR_TIMELINE_BG = (25, 25, 25) +COLOR_TRACK_BG = (35, 35, 35) + +class FontManager: + def __init__(self): + self.fonts = {} + # Try to find a good Chinese font + self.font_names = ['Microsoft YaHei', 'SimHei', 'PingFang SC', 'Segoe UI', 'Arial'] + + def get_font(self, size, bold=False): + key = (size, bold) + if key in self.fonts: + return self.fonts[key] + + font = pygame.font.SysFont(self.font_names, size, bold=bold) + self.fonts[key] = font + return font + +def draw_rounded_rect(surface, rect, color, radius=0.4, border_width=0): + """ + Draw a rectangle with rounded corners. + rect: tuple (x, y, w, h) + radius: float (0.0 to 1.0) or int (pixels) + """ + rect = pygame.Rect(rect) + color = pygame.Color(*color) + alpha = color.a + color.a = 0 + pos = rect.topleft + rect.topleft = 0,0 + rectangle = pygame.Surface(rect.size,pygame.SRCALPHA) + + circle = pygame.Surface([min(rect.size)*3]*2,pygame.SRCALPHA) + pygame.draw.ellipse(circle,(0,0,0),circle.get_rect(),0) + circle = pygame.transform.smoothscale(circle,[int(min(rect.size)*radius)]*2) + + radius = rectangle.blit(circle,(0,0)) + radius.bottomright = rect.bottomright + rectangle.blit(circle,radius) + radius.topright = rect.topright + rectangle.blit(circle,radius) + radius.bottomleft = rect.bottomleft + rectangle.blit(circle,radius) + + rectangle.fill((0,0,0),rect.inflate(-radius.w,0)) + rectangle.fill((0,0,0),rect.inflate(0,-radius.h)) + + rectangle.fill(color, special_flags=pygame.BLEND_RGBA_MAX) + rectangle.fill((255, 255, 255, alpha), special_flags=pygame.BLEND_RGBA_MIN) + + if border_width > 0: + # Simple border support (not perfect for rounded) + pygame.draw.rect(surface, color, pos, width=border_width) # Fallback + + return surface.blit(rectangle, pos) + +def draw_pill_btn(surface, text, rect, is_active, font_mgr): + color = COLOR_ACCENT if is_active else (60, 60, 60) + text_color = (255, 255, 255) if is_active else COLOR_TEXT_DIM + + # Draw pill background + r = rect[3] // 2 + + # Left circle + pygame.gfxdraw.aacircle(surface, rect[0] + r, rect[1] + r, r, color) + pygame.gfxdraw.filled_circle(surface, rect[0] + r, rect[1] + r, r, color) + # Right circle + pygame.gfxdraw.aacircle(surface, rect[0] + rect[2] - r, rect[1] + r, r, color) + pygame.gfxdraw.filled_circle(surface, rect[0] + rect[2] - r, rect[1] + r, r, color) + # Center rect + pygame.draw.rect(surface, color, (rect[0] + r, rect[1], rect[2] - 2*r, rect[3])) + + # Text + font = font_mgr.get_font(12, bold=True) + txt_surf = font.render(text, True, text_color) + text_rect = txt_surf.get_rect(center=(rect[0] + rect[2]//2, rect[1] + rect[3]//2)) + surface.blit(txt_surf, text_rect) + +def draw_icon_play(surface, center, size, color): + x, y = center + points = [ + (x - size//3, y - size//2), + (x - size//3, y + size//2), + (x + size//2, y) + ] + pygame.draw.polygon(surface, color, points) + +def draw_icon_pause(surface, center, size, color): + x, y = center + w = size // 3 + h = size + pygame.draw.rect(surface, color, (x - w - 2, y - h//2, w, h)) + pygame.draw.rect(surface, color, (x + 2, y - h//2, w, h)) +