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