Initial commit: Bcut-style Taiko Editor

This commit is contained in:
2026-01-01 14:35:14 +08:00
commit 930fa1f4c6
8 changed files with 1297 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.idea/
.vscode/
*.log

129
editor_core.py Normal file
View File

@@ -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 = []

81
main.py Normal file
View File

@@ -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()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
pygame

64
test_ui_mock.py Normal file
View File

@@ -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()

293
tja_parser.py Normal file
View File

@@ -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")

617
ui.py Normal file
View File

@@ -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

106
ui_utils.py Normal file
View File

@@ -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))