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