Files
taiko-editor/ui.py

618 lines
25 KiB
Python

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