Initial commit: Bcut-style Taiko Editor
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
129
editor_core.py
Normal file
129
editor_core.py
Normal 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
81
main.py
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pygame
|
||||
64
test_ui_mock.py
Normal file
64
test_ui_mock.py
Normal 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
293
tja_parser.py
Normal 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
617
ui.py
Normal 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
106
ui_utils.py
Normal 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))
|
||||
|
||||
Reference in New Issue
Block a user