#!/usr/bin/env python3 # -*- coding: utf-8 -*- 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()