Files
usefulscripts/stepmania-ddr/stepmania_simplifier.py

805 lines
38 KiB
Python

#!/usr/bin/env python3
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import os
import re
from typing import List, Dict, Tuple
import copy
import random
class StepManiaSimplifier:
def __init__(self, root):
self.root = root
self.root.title("StepMania Chart Simplifier v2")
self.root.geometry("800x850") # Aumentado un poco el alto para las nuevas opciones
self.current_file = None
self.chart_data = {}
# Configuración para las opciones de notas
self.note_options_config = [
{"color": "🟣", "text": "Eliminar notas púrpura (24th+) - Muy rápidas", "var_name": "24th", "default_remove": True},
{"color": "🟢", "text": "Eliminar notas verdes (16th) - Semicorcheas", "var_name": "16th", "default_remove": True},
{"color": "🟡", "text": "Eliminar notas amarillas (12th) - Tripletes", "var_name": "12th", "default_remove": False},
{"color": "🔵", "text": "Eliminar notas azules (8th) - Corcheas", "var_name": "8th", "default_remove": False},
]
self.keep_percentage_values = ["0%", "10%", "25%", "50%", "75%"]
self.setup_ui()
def create_note_option_frame(self, parent, row, config):
frame = ttk.Frame(parent)
frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=(10, 0), pady=2)
# Variable para "Eliminar X notas"
remove_var = tk.BooleanVar(value=config["default_remove"])
setattr(self, f"remove_{config['var_name']}", remove_var)
# Variable para el porcentaje de "Dejar algunas"
keep_percentage_var = tk.StringVar(value=self.keep_percentage_values[0]) # Default a 0%
setattr(self, f"keep_percentage_{config['var_name']}", keep_percentage_var)
ttk.Checkbutton(frame, text=f"{config['color']} {config['text']}",
variable=remove_var,
command=lambda vn=config['var_name']: self.toggle_keep_percentage_option(vn)).pack(side=tk.LEFT)
ttk.Label(frame, text="Dejar algunas:").pack(side=tk.LEFT, padx=(10, 2))
keep_combo = ttk.Combobox(frame, textvariable=keep_percentage_var,
values=self.keep_percentage_values,
width=5, state="disabled" if not config["default_remove"] else "readonly")
keep_combo.pack(side=tk.LEFT, padx=(0, 0))
setattr(self, f"combo_keep_percentage_{config['var_name']}", keep_combo)
# Inicializar estado del combobox
self.toggle_keep_percentage_option(config['var_name'])
return frame
def create_jump_option_frame(self, parent, row):
frame = ttk.Frame(parent)
frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=(10, 0))
self.remove_jumps = tk.BooleanVar(value=False)
ttk.Checkbutton(frame, text="Eliminar saltos (notas simultáneas)",
variable=self.remove_jumps,
command=self.toggle_jump_options).pack(side=tk.LEFT)
self.keep_some_jumps = tk.BooleanVar(value=False) # Este controla si se aplica el porcentaje
self.jump_percentage_val = tk.IntVar(value=50) # Este es el valor del slider
self.check_jumps_some = ttk.Checkbutton(frame, text="Dejar algunos:",
variable=self.keep_some_jumps,
state="normal" if self.remove_jumps.get() else "disabled",
command=self.toggle_jump_slider_visibility) # Comando para mostrar/ocultar slider
self.check_jumps_some.pack(side=tk.LEFT, padx=(10, 0))
self.jump_scale = ttk.Scale(frame, from_=0, to=100, variable=self.jump_percentage_val,
orient=tk.HORIZONTAL, length=100, state="disabled") # Inicia deshabilitado
self.jump_scale.pack(side=tk.LEFT, padx=(5, 0))
self.jump_label = ttk.Label(frame, text="50%", state="disabled") # Inicia deshabilitado
self.jump_label.pack(side=tk.LEFT, padx=(5, 0))
self.jump_percentage_val.trace_add('write', self.update_jump_label)
self.toggle_jump_options() # Para setear estado inicial correcto
self.toggle_jump_slider_visibility() # Para setear estado inicial correcto del slider/label
return frame
def toggle_keep_percentage_option(self, var_name):
remove_var = getattr(self, f"remove_{var_name}")
combo_widget = getattr(self, f"combo_keep_percentage_{var_name}")
keep_percentage_var = getattr(self, f"keep_percentage_{var_name}")
if remove_var.get():
combo_widget.config(state="readonly")
else:
combo_widget.config(state="disabled")
keep_percentage_var.set("0%") # Si no se eliminan, no se dejan algunas
def toggle_jump_options(self):
# Habilita/deshabilita el checkbox "Dejar algunos" para saltos
if self.remove_jumps.get():
self.check_jumps_some.config(state="normal")
else:
self.check_jumps_some.config(state="disabled")
self.keep_some_jumps.set(False) # Si no se eliminan saltos, no se "dejan algunos"
self.toggle_jump_slider_visibility() # Actualiza visibilidad del slider y label
def toggle_jump_slider_visibility(self):
# Muestra/oculta el slider y label de porcentaje de saltos
# Solo se muestran si "Eliminar saltos" Y "Dejar algunos" están activos
if self.remove_jumps.get() and self.keep_some_jumps.get():
self.jump_scale.config(state="normal")
self.jump_label.config(state="normal")
else:
self.jump_scale.config(state="disabled")
self.jump_label.config(state="disabled")
def update_jump_label(self, *args):
self.jump_label.config(text=f"{self.jump_percentage_val.get()}%")
def setup_ui(self):
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
self.setup_file_selection(main_frame, 0)
self.setup_chart_selection(main_frame, 1)
self.setup_options(main_frame, 2)
self.setup_buttons(main_frame, 3)
self.setup_info_area(main_frame, 4)
self.setup_debug_area(main_frame, 5)
def setup_file_selection(self, parent, row):
ttk.Label(parent, text="Archivo .sm:").grid(row=row, column=0, sticky=tk.W, pady=5)
file_frame = ttk.Frame(parent)
file_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5)
file_frame.columnconfigure(0, weight=1)
self.file_var = tk.StringVar()
self.file_entry = ttk.Entry(file_frame, textvariable=self.file_var, state="readonly")
self.file_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
ttk.Button(file_frame, text="Examinar", command=self.browse_file).grid(row=0, column=1)
def setup_chart_selection(self, parent, row):
base_frame = ttk.LabelFrame(parent, text="Chart Base", padding="10")
base_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
base_frame.columnconfigure(1, weight=1)
ttk.Label(base_frame, text="Seleccionar chart base:").grid(row=0, column=0, sticky=tk.W)
self.base_chart_var = tk.StringVar()
self.base_chart_combo = ttk.Combobox(base_frame, textvariable=self.base_chart_var, state="readonly")
self.base_chart_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5)
def setup_options(self, parent, row):
options_frame = ttk.LabelFrame(parent, text="Opciones de Simplificación", padding="10")
options_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
options_frame.columnconfigure(1, weight=1)
current_row = 0
ttk.Label(options_frame, text="Eliminar notas rápidas:", font=('TkDefaultFont', 9, 'bold')).grid(
row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5))
current_row += 1
for config in self.note_options_config:
self.create_note_option_frame(options_frame, current_row, config)
current_row +=1
ttk.Separator(options_frame, orient='horizontal').grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
current_row += 1
ttk.Label(options_frame, text="Simplificar patrones:", font=('TkDefaultFont', 9, 'bold')).grid(
row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5))
current_row += 1
self.create_jump_option_frame(options_frame, current_row)
current_row += 1
self.simplify_holds = tk.BooleanVar(value=False)
ttk.Checkbutton(options_frame, text="Convertir holds largos en notas normales",
variable=self.simplify_holds).grid(row=current_row, column=0, columnspan=2, sticky=tk.W, padx=(10, 0))
current_row += 1
ttk.Separator(options_frame, orient='horizontal').grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
current_row += 1
ttk.Label(options_frame, text="Nuevo chart:", font=('TkDefaultFont', 9, 'bold')).grid(
row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5))
current_row += 1
ttk.Label(options_frame, text="Nombre nueva dificultad:").grid(row=current_row, column=0, sticky=tk.W, padx=(10, 0))
self.new_difficulty_name = tk.StringVar(value="Easy")
ttk.Entry(options_frame, textvariable=self.new_difficulty_name, width=15).grid(row=current_row, column=1, sticky=tk.W, padx=5)
def setup_buttons(self, parent, row):
button_frame = ttk.Frame(parent)
button_frame.grid(row=row, column=0, columnspan=2, pady=10)
# El botón de Analizar ya no es necesario aquí, se hace al cargar
# ttk.Button(button_frame, text="Analizar Archivo", command=self.analyze_file).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Generar Versión Simplificada", command=self.generate_simplified).pack(side=tk.LEFT, padx=5)
def setup_info_area(self, parent, row):
info_frame = ttk.LabelFrame(parent, text="Información del Archivo", padding="10")
info_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=10)
info_frame.columnconfigure(0, weight=1)
info_frame.rowconfigure(0, weight=1)
parent.rowconfigure(row, weight=1)
self.info_text = scrolledtext.ScrolledText(info_frame, height=10, width=70, wrap=tk.WORD) # wrap=tk.WORD
self.info_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
def setup_debug_area(self, parent, row):
debug_frame = ttk.LabelFrame(parent, text="Debug Output", padding="10")
debug_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
debug_frame.columnconfigure(0, weight=1)
self.debug_text = scrolledtext.ScrolledText(debug_frame, height=5, width=70, wrap=tk.WORD) # wrap=tk.WORD
self.debug_text.grid(row=0, column=0, sticky=(tk.W, tk.E))
def browse_file(self):
filename = filedialog.askopenfilename(
title="Seleccionar archivo .sm",
filetypes=[("StepMania files", "*.sm"), ("All files", "*.*")]
)
if filename:
self.file_var.set(filename)
self.current_file = filename
self.analyze_file() # <--- ANÁLISIS AUTOMÁTICO
def parse_sm_file(self, filepath: str) -> Dict:
# Intenta con 'utf-8' primero, que es lo más común y robusto
encodings_to_try = ['utf-8', 'cp1252', 'iso-8859-1', 'latin1']
content = None
for encoding in encodings_to_try:
try:
with open(filepath, 'r', encoding=encoding) as f:
content = f.read()
self.debug_print(f"Archivo leído exitosamente con encoding: {encoding}")
break
except UnicodeDecodeError:
self.debug_print(f"Fallo al leer con encoding: {encoding}")
continue
except Exception as e:
self.debug_print(f"Error inesperado al abrir el archivo con {encoding}: {e}")
continue
if content is None:
# Si todos fallan, intenta con errors='ignore' como último recurso
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
self.debug_print("Archivo leído con encoding utf-8 e ignorando errores.")
except Exception as e:
messagebox.showerror("Error de Lectura", f"No se pudo leer el archivo '{os.path.basename(filepath)}' con los encodings probados.\n{e}")
return {}
data = {}
patterns = {
'title': r'#TITLE:([^;]+);',
'artist': r'#ARTIST:([^;]+);',
'bpms': r'#BPMS:([^;]+);',
'offset': r'#OFFSET:([^;]+);'
}
for key, pattern in patterns.items():
match = re.search(pattern, content, re.IGNORECASE)
data[key] = match.group(1).strip() if match else 'Unknown'
charts = []
# Patrón mejorado para ser más tolerante con espacios y saltos de línea
chart_pattern = r'#NOTES:\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^;]+);'
for match in re.finditer(chart_pattern, content, re.DOTALL | re.IGNORECASE):
chart = {
'type': match.group(1).strip(),
'description': match.group(2).strip(), # Author/Description
'difficulty': match.group(3).strip(), # Difficulty Name
'level': match.group(4).strip(), # Difficulty Meter
'radar': match.group(5).strip().replace('\n','').replace('\r','').replace(' ',''), # Radar values
'notes': match.group(6).strip() # Note data
}
charts.append(chart)
data['charts'] = charts
data['original_content'] = content
return data
def analyze_file(self):
if not self.current_file:
# Esto no debería ocurrir si se llama desde browse_file, pero por si acaso
messagebox.showerror("Error", "Por favor selecciona un archivo .sm primero")
return
self.info_text.delete(1.0, tk.END) # Limpiar info anterior
self.debug_text.delete(1.0, tk.END) # Limpiar debug anterior
self.base_chart_combo['values'] = [] # Limpiar combobox de charts
self.base_chart_var.set("")
try:
self.debug_print(f"Analizando archivo: {self.current_file}")
self.chart_data = self.parse_sm_file(self.current_file)
if not self.chart_data: # Si parse_sm_file devolvió vacío por error
return
self.display_file_info()
self.debug_print("Análisis completado.")
except Exception as e:
messagebox.showerror("Error", f"Error al analizar el archivo: {str(e)}")
self.debug_print(f"Excepción en analyze_file: {str(e)}")
def display_file_info(self):
info = f"=== INFORMACIÓN DEL ARCHIVO ===\n"
info += f"Título: {self.chart_data.get('title', 'N/A')}\n"
info += f"Artista: {self.chart_data.get('artist', 'N/A')}\n"
info += f"BPMs: {self.chart_data.get('bpms', 'N/A')}\n"
info += f"Offset: {self.chart_data.get('offset', 'N/A')}\n\n"
info += "=== CHARTS DISPONIBLES ===\n"
chart_options = []
if not self.chart_data.get('charts'):
info += "No se encontraron charts válidos en el archivo.\n"
self.debug_print("No se encontraron charts en los datos parseados.")
else:
for i, chart in enumerate(self.chart_data.get('charts', [])):
chart_name = f"{chart.get('difficulty','UnknownDif')} (Lv.{chart.get('level','?')}) - {chart.get('description','NoDesc')}"
chart_options.append(chart_name)
info += f"{i+1}. {chart_name}\n"
info += f" Tipo: {chart.get('type','N/A')}\n"
notes_analysis = self.analyze_notes_summary(chart['notes'])
info += f" Notas totales: {notes_analysis['total_notes']}\n"
info += f" Saltos: {notes_analysis['jumps']}\n"
info += f" Holds: {notes_analysis['holds']}\n"
info += f" Minas: {notes_analysis['mines']}\n"
info += f" Compases estimados: {notes_analysis['measures']}\n\n"
self.base_chart_combo['values'] = chart_options
if chart_options:
self.base_chart_combo.current(0)
else:
self.base_chart_var.set("No hay charts disponibles")
self.info_text.delete(1.0, tk.END)
self.info_text.insert(1.0, info)
def analyze_notes_summary(self, notes_data: str) -> Dict:
lines = [line.strip() for line in notes_data.split('\n') if line.strip()]
total_notes = 0
jumps = 0
holds_start = 0 # '2'
mines = 0
measures = 0
for line in lines:
if line.startswith(','):
measures += 1
continue
# Solo procesar líneas que parecen ser de notas (longitud y caracteres)
if len(line) >= 4 and all(c in '01234MFLK' for c in line[:4].upper()): # Simplificado
note_count_in_line = 0
for char_idx, char_val in enumerate(line[:4]):
if char_val == '1': # Tap note
total_notes += 1
note_count_in_line +=1
elif char_val == '2': # Hold start
total_notes += 1
holds_start += 1
note_count_in_line +=1
elif char_val == '4': # Roll start (contar como hold)
total_notes += 1
holds_start += 1
note_count_in_line +=1
elif char_val.upper() == 'M': # Mine
mines +=1
if note_count_in_line > 1:
jumps +=1
return {
'total_notes': total_notes,
'jumps': jumps,
'holds': holds_start,
'mines': mines,
'measures': measures
}
def calculate_difficulty_level(self, original_level: int, notes_removed_percentage: float) -> int:
if not isinstance(original_level, int) or original_level < 1:
original_level = 1 # Default a 1 si el original no es válido
# Reducción más pronunciada si se quitan muchas notas, menos si se quitan pocas.
# Por ejemplo, quitar 50% de notas podría reducir el nivel a la mitad.
# Quitar 10% de notas podría reducir el nivel un 10-20%.
difficulty_reduction_factor = notes_removed_percentage * 0.7 # Ajustar este factor según se vea
new_level = original_level * (1 - difficulty_reduction_factor)
# Asegurar que el nivel no baje de 1 y redondear
new_level = max(1, round(new_level))
return new_level
def simplify_chart(self, chart: Dict) -> Dict:
simplified_chart = copy.deepcopy(chart)
notes_lines = chart['notes'].split('\n')
simplified_lines = []
current_measure_lines = []
notes_removed_count = 0
original_tap_and_hold_notes_count = 0 # Contar solo '1' y '2' para el % de reducción
# Primera pasada para contar notas originales relevantes
for line_idx, line_content in enumerate(notes_lines):
stripped_line = line_content.strip()
if stripped_line == ',':
continue
if len(stripped_line) >= 4 and self.is_valid_note_line(stripped_line):
for char_note in stripped_line[:4]:
if char_note in '124': # Tap, Hold start, Roll start
original_tap_and_hold_notes_count += 1
self.debug_print(f"Notas originales (tap/hold/roll): {original_tap_and_hold_notes_count}")
for line in notes_lines:
stripped_line = line.strip()
if not stripped_line: # Línea vacía
if current_measure_lines: # Procesar compás acumulado si existe
processed_measure, removed_in_measure = self.process_measure(current_measure_lines)
simplified_lines.extend(processed_measure)
notes_removed_count += removed_in_measure
current_measure_lines = []
simplified_lines.append(line) # Añadir la línea vacía
continue
if stripped_line == ',':
if current_measure_lines:
processed_measure, removed_in_measure = self.process_measure(current_measure_lines)
simplified_lines.extend(processed_measure)
notes_removed_count += removed_in_measure
current_measure_lines = []
simplified_lines.append(line) # Añadir la coma
continue
if self.is_valid_note_line(stripped_line):
current_measure_lines.append(line) # Usar línea original con sus espacios
else: # Líneas que no son de notas (comentarios, etc.)
if current_measure_lines: # Procesar compás acumulado si lo hubiera antes de esta línea no-nota
processed_measure, removed_in_measure = self.process_measure(current_measure_lines)
simplified_lines.extend(processed_measure)
notes_removed_count += removed_in_measure
current_measure_lines = []
simplified_lines.append(line)
if current_measure_lines: # Procesar el último compás si queda algo
processed_measure, removed_in_measure = self.process_measure(current_measure_lines)
simplified_lines.extend(processed_measure)
notes_removed_count += removed_in_measure
simplified_chart['notes'] = '\n'.join(simplified_lines)
try:
original_level = int(chart['level'])
except ValueError:
self.debug_print(f"Nivel original '{chart['level']}' no es un número. Usando 5 por defecto.")
original_level = 5 # Default si el nivel no es un número
notes_removed_percentage = 0
if original_tap_and_hold_notes_count > 0:
notes_removed_percentage = notes_removed_count / original_tap_and_hold_notes_count
self.debug_print(f"Notas eliminadas: {notes_removed_count}")
self.debug_print(f"Porcentaje de notas eliminadas: {notes_removed_percentage:.2%}")
new_level = self.calculate_difficulty_level(original_level, notes_removed_percentage)
simplified_chart['difficulty'] = self.new_difficulty_name.get()
simplified_chart['level'] = str(new_level)
base_desc = chart['description']
if "(Simplified)" not in base_desc and "(Easy)" not in base_desc: # Evitar duplicados
simplified_chart['description'] = f"{base_desc} ({self.new_difficulty_name.get()})"
else: # Si ya tiene un tag de simplificación, lo reemplazamos o usamos el nuevo
parts = re.split(r'\s*\(.*\)', base_desc) # Quitar el (tag) viejo
simplified_chart['description'] = f"{parts[0].strip()} ({self.new_difficulty_name.get()})"
return simplified_chart
def is_valid_note_line(self, line: str) -> bool:
# Una línea de nota válida tiene al menos 4 caracteres y esos son 0-4, M, F, L, K
# (considerando mayúsculas para MFLK)
if len(line) < 4:
return False
return all(c in '01234MFLK' for c in line[:4].upper())
def process_measure(self, measure_lines: List[str]) -> Tuple[List[str], int]:
if not measure_lines:
return [], 0
processed_lines = []
notes_removed_in_measure = 0
num_lines_in_measure = len(measure_lines)
for i, original_line_text in enumerate(measure_lines):
line_text = original_line_text.strip() # Trabajar con la línea sin espacios extra al inicio/fin
# Mantener los espacios originales del inicio para el formato
leading_whitespace = ""
match_whitespace = re.match(r"(\s*)", original_line_text)
if match_whitespace:
leading_whitespace = match_whitespace.group(1)
modified_line_chars = list(line_text[:4]) # Solo los primeros 4 caracteres para notas
rest_of_line = line_text[4:] # Comentarios, etc.
# 1. Detección de subdivisión y eliminación de notas rápidas
subdivision = self.detect_note_subdivision_in_measure(i, num_lines_in_measure)
option_var_name = None
if subdivision == "24th+": option_var_name = "24th"
elif subdivision == "16th": option_var_name = "16th"
elif subdivision == "12th": option_var_name = "12th"
elif subdivision == "8th": option_var_name = "8th"
line_had_notes = any(c in '124' for c in modified_line_chars)
if option_var_name:
remove_this_subdivision = getattr(self, f"remove_{option_var_name}").get()
if remove_this_subdivision:
keep_percentage_str = getattr(self, f"keep_percentage_{option_var_name}").get()
keep_percentage = float(keep_percentage_str.replace('%','')) / 100.0
if random.random() >= keep_percentage: # Si random es MAYOR o IGUAL, se elimina
for k_idx, k_char in enumerate(modified_line_chars):
if k_char in '124': # Tap, Hold, Roll
modified_line_chars[k_idx] = '0'
notes_removed_in_measure += 1
# self.debug_print(f"L{i} ({subdivision}) eliminada (o parte), %keep: {keep_percentage_str}")
# else:
# self.debug_print(f"L{i} ({subdivision}) MANTENIDA por % ({keep_percentage_str})")
# 2. Simplificación de saltos (después de posible eliminación por subdivisión)
current_notes_in_line = sum(1 for char_note in modified_line_chars if char_note in '124')
if current_notes_in_line > 1 and self.remove_jumps.get():
# self.debug_print(f"Salto detectado en L{i}: {''.join(modified_line_chars)}")
notes_indices_in_jump = [idx for idx, char_note in enumerate(modified_line_chars) if char_note in '124']
notes_to_keep_count = 1 # Por defecto, dejar 1 nota de un salto
if self.keep_some_jumps.get(): # Si el checkbox "Dejar algunos [saltos]" está activo
percentage_to_keep_jumps = self.jump_percentage_val.get() / 100.0
# self.debug_print(f" Intentando mantener {percentage_to_keep_jumps*100}% de {current_notes_in_line} notas del salto")
notes_to_keep_count = max(1, int(round(len(notes_indices_in_jump) * percentage_to_keep_jumps)))
# self.debug_print(f" Original: {notes_indices_in_jump}, a mantener: {notes_to_keep_count}")
# Mantener las primeras 'notes_to_keep_count' notas, eliminar el resto
random.shuffle(notes_indices_in_jump) # Aleatorizar cuáles se quedan
notes_to_remove_from_jump = notes_indices_in_jump[notes_to_keep_count:]
for idx_to_remove in notes_to_remove_from_jump:
if modified_line_chars[idx_to_remove] in '124':
modified_line_chars[idx_to_remove] = '0'
notes_removed_in_measure += 1
# self.debug_print(f" Salto simplificado L{i}: {''.join(modified_line_chars)}")
# 3. Simplificación de holds (después de todo lo anterior)
if self.simplify_holds.get():
for k_idx, k_char in enumerate(modified_line_chars):
if k_char == '2': # Inicio de Hold
modified_line_chars[k_idx] = '1' # Convertir a Tap
# No contamos esto como nota eliminada, es una conversión
elif k_char == '3': # Fin de Hold
modified_line_chars[k_idx] = '0' # Eliminar marcador de fin
elif k_char == '4': # Inicio de Roll
modified_line_chars[k_idx] = '1' # Convertir a Tap
# No se tocan 'L' (fin de roll) ya que no tienen valor numérico en StepMania y son más raros
processed_lines.append(leading_whitespace + "".join(modified_line_chars) + rest_of_line)
return processed_lines, notes_removed_in_measure
def detect_note_subdivision_in_measure(self, line_index: int, total_lines_in_measure: int) -> str:
"""
Detecta la subdivisión de una nota basada en su índice dentro del compás y el total de líneas.
Esta es una heurística y puede no ser perfecta para todos los casos de BPM changes o time signatures.
Asume que las líneas de notas están distribuidas uniformemente dentro del compás.
"""
if total_lines_in_measure == 0: return "Unknown"
# Casos comunes para 4/4 time signature
if total_lines_in_measure % 48 == 0: # Probablemente 48ths (o 24ths si son pares)
beat_division = 48
elif total_lines_in_measure % 32 == 0: # Probablemente 32nds
beat_division = 32
elif total_lines_in_measure % 24 == 0: # Probablemente 24ths
beat_division = 24
elif total_lines_in_measure % 16 == 0: # Probablemente 16ths
beat_division = 16
elif total_lines_in_measure % 12 == 0: # Probablemente 12ths (triplets over 4th)
beat_division = 12
elif total_lines_in_measure % 8 == 0: # Probablemente 8ths
beat_division = 8
elif total_lines_in_measure % 6 == 0: # Probablemente 6ths (triplets over 8th, raro pero posible)
beat_division = 6
elif total_lines_in_measure % 4 == 0: # Probablemente 4ths
beat_division = 4
elif total_lines_in_measure % 3 == 0 and total_lines_in_measure <= 12 : # Podría ser un compás de 3/4 en 4ths, o 4/4 en 3 notas por alguna razón
beat_division = total_lines_in_measure # ej. 3 notas: 3rd, 6 notas: 6th
elif total_lines_in_measure % 2 == 0 and total_lines_in_measure <= 8:
beat_division = total_lines_in_measure
else: # Casos menos comunes o compases con pocas notas
if total_lines_in_measure > 16 : return "24th+" # Si hay muchas, default a muy rápidas
if total_lines_in_measure > 12 : return "16th"
if total_lines_in_measure > 8 : return "12th"
if total_lines_in_measure > 4 : return "8th"
return "4th"
# Simplificación de la lógica de subdivisión
# El `line_index` nos dice en qué "slot" de la subdivisión más fina cae esta línea.
# Si `total_lines_in_measure` es 16 (16ths), y `line_index` es 0, 4, 8, 12, es un 4th.
# Si es 2, 6, 10, 14, es un 8th (pero no 4th).
# El resto son 16ths.
if line_index % (beat_division / 4) == 0: return "4th"
if beat_division >= 8 and line_index % (beat_division / 8) == 0: return "8th"
if beat_division >= 12 and line_index % (beat_division / 12) == 0: return "12th"
if beat_division >= 16 and line_index % (beat_division / 16) == 0: return "16th"
if beat_division >= 24 : return "24th+" # Cubre 24th, 32nd, 48th, etc.
return "Unknown" # Default por si acaso
def debug_print(self, message):
if hasattr(self, 'debug_text') and self.debug_text:
try:
self.debug_text.insert(tk.END, f"{message}\n")
self.debug_text.see(tk.END)
self.root.update_idletasks() # Forzar actualización de la UI
except tk.TclError: # En caso de que el widget ya no exista (ej. al cerrar)
pass
print(f"DEBUG: {message}") # También imprimir a consola
def remove_jump_notes(self, line: str) -> str:
# Esta función ya no se usa directamente, su lógica está en process_measure
# Se deja aquí por si se quiere reusar o como referencia, pero no es llamada.
if len(line) < 4:
return line
chars = list(line)
jump_notes_indices = [i for i, char_note in enumerate(chars[:4]) if char_note in '124']
if len(jump_notes_indices) > 1: # Es un salto
if self.remove_jumps.get(): # Si la opción general de eliminar saltos está activa
notes_to_keep_count = 1 # Por defecto, dejar 1 nota
if self.keep_some_jumps.get(): # Si el checkbox "Dejar algunos [saltos]" está activo
percentage_to_keep = self.jump_percentage_val.get() / 100.0
notes_to_keep_count = max(1, int(round(len(jump_notes_indices) * percentage_to_keep)))
# Aleatorizar y seleccionar las notas a mantener
random.shuffle(jump_notes_indices)
notes_to_remove_indices = jump_notes_indices[notes_to_keep_count:]
for idx_to_remove in notes_to_remove_indices:
chars[idx_to_remove] = '0'
return "".join(chars)
def simplify_hold_notes(self, line: str) -> str:
# Esta función ya no se usa directamente, su lógica está en process_measure
modified_line = list(line)
for i, char_val in enumerate(modified_line[:4]):
if char_val == '2': # Hold Start
modified_line[i] = '1'
elif char_val == '3': # Hold End
modified_line[i] = '0'
elif char_val == '4': # Roll Start
modified_line[i] = '1'
return "".join(modified_line)
def generate_simplified(self):
if not self.chart_data or not self.chart_data.get('charts'):
messagebox.showerror("Error", "Por favor carga y analiza un archivo primero, o el archivo no contiene charts.")
return
if not self.base_chart_var.get() or "No hay charts" in self.base_chart_var.get():
messagebox.showerror("Error", "Por favor selecciona un chart base válido.")
return
try:
selected_index = self.base_chart_combo.current()
if selected_index < 0 or selected_index >= len(self.chart_data['charts']):
messagebox.showerror("Error", "Chart seleccionado no válido. Intenta recargar el archivo.")
return
base_chart = self.chart_data['charts'][selected_index]
self.debug_print(f"Generando versión simplificada para: {base_chart.get('difficulty')} (Lv.{base_chart.get('level')})")
simplified_chart = self.simplify_chart(base_chart)
# Contar notas '1' (tap) en el chart simplificado para una verificación rápida
simplified_tap_notes_count = 0
for line_s in simplified_chart['notes'].split('\n'):
if self.is_valid_note_line(line_s.strip()):
for char_s in line_s.strip()[:4]:
if char_s == '1':
simplified_tap_notes_count +=1
self.debug_print(f"Chart simplificado: {simplified_tap_notes_count} notas '1' (tap) finales.")
self.debug_print(f"Nivel original: {base_chart['level']}, Nivel calculado: {simplified_chart['level']}")
if simplified_tap_notes_count == 0: # Chequeo más específico
if not messagebox.askyesno("Advertencia",
"El chart simplificado no tiene notas '1' (tap notes).\n"
"Esto puede resultar en un chart vacío o no jugable.\n"
"¿Deseas generarlo de todas formas?"):
self.debug_print("Generación cancelada por el usuario debido a 0 notas tap.")
return
new_content = self.chart_data['original_content']
# Asegurarse de que el nuevo bloque de notas se añade al final del archivo
if not new_content.endswith('\n\n'):
if new_content.endswith('\n'):
new_content += '\n'
else:
new_content += '\n\n'
# Formato del bloque de notas
# //---------------dance-single - [Simplificado (Easy)]----------------
comment_desc = simplified_chart['description'].replace(':','-') # Evitar problemas con ':' en comentarios
comment = f"//---------------{simplified_chart['type']} - {comment_desc}----------------\n"
new_notes_block = comment
new_notes_block += "#NOTES:\n"
new_notes_block += f" {simplified_chart['type']}:\n"
new_notes_block += f" {simplified_chart['description']}:\n" # Ya incluye el (Simplified) o (Easy)
new_notes_block += f" {simplified_chart['difficulty']}:\n"
new_notes_block += f" {simplified_chart['level']}:\n"
new_notes_block += f" {simplified_chart['radar']}:\n" # Usar el radar original
new_notes_block += f"{simplified_chart['notes']};\n\n"
new_content += new_notes_block
original_path = self.current_file
base_name = os.path.splitext(original_path)[0]
# Construir nombre de archivo con tag de dificultad si es posible
difficulty_tag = self.new_difficulty_name.get().replace(" ", "_")
new_path = f"{base_name}_{difficulty_tag}_simplified.sm"
# Intentar guardar con el encoding original si es conocido, sino utf-8
source_encoding = 'utf-8' # Default
# (No tenemos una forma fácil de saber el encoding original exacto después de leerlo)
with open(new_path, 'w', encoding=source_encoding, errors='ignore') as f:
f.write(new_content)
messagebox.showinfo("Éxito", f"Archivo simplificado guardado como:\n{new_path}")
self.debug_print(f"Archivo simplificado guardado en: {new_path}")
except Exception as e:
messagebox.showerror("Error", f"Error al generar archivo simplificado: {str(e)}")
self.debug_print(f"Excepción en generate_simplified: {str(e)}")
import traceback
self.debug_print(traceback.format_exc())
def main():
root = tk.Tk()
app = StepManiaSimplifier(root)
root.mainloop()
if __name__ == "__main__":
main()