# 8. Récords, guardado y sonido

> Guarda la tabla de récords y los ajustes de un juego en Python con Pyzed: un archivo JSON en la carpeta correcta de Windows, macOS y Linux, escritura atómica, nombre con ui.input, una tabla de récords, efectos de sonido sintetizados con pyzed.audio y empaquetar el juego.

Source: https://albakore.github.io/pyzed/es/games/high-scores/

Una puntuación vale más si mañana sigue ahí. Este último paso de la ruta construye *Target Rush*, un juego de clics
de 20 segundos, y le da lo que tienen los juegos terminados: una **tabla de récords guardada en disco**, un
**nombre** cuando batas un récord, **efectos de sonido** y una forma de **compartirlo** con tus amigos.

| Objetivo | Tiempo | Nivel | Necesitas |
| --- | --- | --- | --- |
| Un juego con sonido que guarda una tabla de los 10 mejores | 35 minutos | Intermedio | [7. Menús, HUD y ajustes](https://albakore.github.io/pyzed/es/games/menus-hud/) |

## Qué aprenderás

- Encontrar la **carpeta correcta** para las partidas guardadas en Windows, macOS y Linux.
- **Guardar con seguridad**: escribir un archivo temporal y cambiarlo de golpe, y sobrevivir a un archivo dañado.
- Pedir un **nombre** con `ui.input` (Enter confirma) y una **tabla de récords** hecha de filas.
- **Efectos de sonido**: un método al que se llama donde va un sonido, que toca percusión sintetizada con `pyzed.audio`.
- **Empaquetar** el juego como app.

1. **Dónde van las partidas guardadas.** Cada sistema tiene una carpeta para los datos de las apps, y los juegos no
   deberían escribir junto a su código (esa carpeta puede ser de solo lectura una vez instalado):

```python
def data_dir(app_name):
    if sys.platform == "win32":
        base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:
        base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
    return base / app_name
```

   | Sistema | Carpeta de "Target Rush" |
   | --- | --- |
   | Windows | `C:\Users\tú\AppData\Roaming\Target Rush` |
   | macOS | `~/Library/Application Support/Target Rush` |
   | Linux | `~/.local/share/Target Rush` |

2. **Cargar sin miedo.** La primera vez no hay archivo, y un archivo se puede dañar (un disco lleno, alguien que lo
   edita a mano). Las dos cosas deberían significar "aún no hay récords", nunca un error:

```python
def load(self):
    try:
        data = json.loads(self.path.read_text(encoding="utf-8"))
        entries = [e for e in data.get("scores", []) if isinstance(e.get("score"), int)]
    except (OSError, ValueError, AttributeError):
        return []
    return sorted(entries, key=lambda e: -e["score"])[:KEEP]
```

3. **Guardar con seguridad.** Si el juego falla mientras escribe, un archivo a medias perdería todos los récords. Así
   que escribe un archivo temporal en la misma carpeta y sustituye el real en un solo paso; `os.replace` es atómico en
   todos los sistemas:

```python
def save(self):
    self.path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".scores-", suffix=".json")
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump({"version": 1, "scores": self.entries}, f, indent=2)
    os.replace(tmp, self.path)
```

   El `"version": 1` no cuesta nada y permite que una versión futura del juego lea de otra forma las partidas antiguas.

4. **¿Entra en la tabla?** Una puntuación entra si la tabla aún no está llena o supera a la última. `add` la inserta,
   se queda con las 10 mejores, guarda y devuelve el puesto:

```python
def qualifies(self, score):
    return score > 0 and (len(self.entries) < KEEP or score > self.entries[-1]["score"])

def add(self, name, score):
    entry = {"name": name.strip()[:16] or "Player", "score": score, "date": time.strftime("%Y-%m-%d")}
    self.entries.append(entry)
    self.entries.sort(key=lambda e: -e["score"])
    del self.entries[KEEP:]
    self.save()
    return self.entries.index(entry) + 1 if entry in self.entries else None
```

5. **Pedir el nombre.** Cuando una ronda termina con una puntuación que entra, la escena pasa a `"name"`. `ui.input`
   mantiene el texto en tu estado con `on_input`, y `on_submit` se ejecuta con <kbd>Enter</kbd>, así que basta con
   escribir un nombre y pulsar Enter:

```python
ui.input(self.name, self.set_name, placeholder="Your name", icon="user",
         on_submit=self.submit_name, id="name", max_length=16)
```

   La tabla es una columna de filas; la fila recién añadida tiene un fondo teñido, y las tres primeras tienen números
   de oro, plata y bronce.

6. **Ganchos de sonido.** Decide *dónde* van los sonidos antes de decidir *cómo* suenan. Target Rush llama a
   `self.sound("hit")`, `"miss"`, `"combo"`, `"start"`, `"end"` y `"record"` en el momento justo, y solo este método
   sabe qué hacer con ellos:

```python
def sound(self, name):
    """Called wherever a sound belongs: "start", "hit", "miss", "combo", "end", "record"."""
    self.last_sound, self.sound_age = name, 0.0
    if self.sounds is None and self.sound_backend:
        self.sounds = Sounds(self.sound_backend)    # the first sound starts the engine
    if self.sounds:
        self.sounds.play(name)
```

   Además muestra el nombre en el HUD un momento, algo útil para comprobar el ritmo de tus sonidos.

7. **Crea los sonidos.** `Sounds` monta un pequeño patch con [`pyzed.audio`](https://albakore.github.io/pyzed/es/audio/): cuatro voces
   `drum` que entran en un `mixer` y van a los altavoces. Cada efecto es una voz con su propia afinación, en una
   tabla al principio del archivo, así que no hay archivos de sonido que distribuir:

```python
SOUNDS = {                # effect: (drum voice, tune in semitones, decay in seconds)
    "hit": ("tom", 7, 0.12),
    "miss": ("kick", -6, 0.25),
    ...
}

def play(self, name):
    kind, tune, decay = SOUNDS[name]
    voice = self.voices[kind]
    voice.set(tune=tune, decay=decay)
    voice.trigger()       # returns at once: the sound plays on the audio thread
```

## Efectos de sonido

`pyzed.audio` calcula el sonido en Rust, en su propio hilo de tiempo real, así que `trigger()` nunca frena el bucle
de fotogramas, y sin tarjeta de sonido el motor suena en silencio en vez de fallar. Algunas formas de ir más lejos:

- **Sonidos grabados**: un módulo `sampler` reproduce un archivo WAV. Llama a `node.load("coin.wav")` una vez al
  empezar y a `node.trigger()` en `sound()`.
- **Ajustes de volumen**: el módulo `audio_out` tiene un mando `volume`, y el mezclador un `level_1`…`level_4` por
  canal. Cámbialos desde los sliders de [Orb Rush](https://albakore.github.io/pyzed/es/games/menus-hud/): `self.sounds.out["volume"] = valor`.
- **Música**: un `clock` y un `sequencer` tocan un bucle solos; mira el [motor de audio](https://albakore.github.io/pyzed/es/audio/engine/getting-started/).
- **Pruebas**: `sound_backend="offline"` nunca abre un dispositivo; las pruebas llaman a `engine.render(segundos)`
  para comprobar que cada efecto suena, y `None` silencia el juego.

**Tip:**

Que los sonidos sean cortos y un poco distintos cada vez (varía `tune` un semitono o dos), para que un sonido que se
oye cien veces en una partida no canse.

Guarda los ajustes igual que los récords: un `settings.json` junto a `scores.json`, escrito con el mismo `save()`
al cerrar la pantalla de ajustes, y cargado al empezar con valores por defecto para las claves que falten.

## Comparte tu juego

Un juego está terminado cuando otra persona puede jugarlo. Pyzed empaqueta una app como un programa que se abre con
doble clic en Windows, macOS y Linux, con su icono y su nombre:

- [Empaqueta tu app](https://albakore.github.io/pyzed/es/tutorials/advanced/packaging/): Un tutorial paso a paso: compilar, icono y compilaciones para los tres sistemas desde CI.
- [Distribuir](https://albakore.github.io/pyzed/es/packaging/): Todo sobre compilar, firmar y actualizar apps en cada sistema.

**Tip:**

Una vez empaquetado, la carpeta del juego puede ser de solo lectura: un motivo más para guardar las partidas en
`data_dir`.

## Prueba esto

- Guarda los ajustes de [Orb Rush](https://albakore.github.io/pyzed/es/games/menus-hud/) con el mismo patrón.
- Lleva una tabla de récords por dificultad: una clave por dificultad en el JSON.
- Añade un botón "Borrar récords" detrás de un diálogo `ui.confirm`.

## Código completo

`examples/games/high_scores.py`:

```py
"""Target Rush: a 20-second clicking game that keeps a top-10 table on disk.

    python examples/games/high_scores.py

Click the targets before they shrink away: the sooner, the more points, and hits in a row
build a combo. A good score asks for your name and goes into the leaderboard, which is
saved as JSON in your user data folder and is still there next time. Shows:

- **Saving data** the safe way: a JSON file in the right folder for each system, written
  to a temporary file first and then swapped in, so a crash never leaves half a file.
  A missing or damaged file just means an empty table.
- **Name entry** with ``ui.input`` (Enter submits) and a leaderboard made of rows.
- **Sound effects** from one ``sound(name)`` method called wherever a sound belongs. The
  sounds are synthesized live by ``pyzed.audio`` (drum voices, no files); with no sound card
  the game simply stays silent.

Tutorial: https://albakore.github.io/pyzed/games/high-scores/
"""

import json
import math
import os
import random
import sys
import tempfile
import time
from pathlib import Path

import pyzed as pz
from pyzed import audio, draw, game, ui

W, H = 640, 420
ROUND = 20.0
TARGET_LIFE = 1.4         # seconds a target lives (it shrinks meanwhile)
TARGET_R = 34
KEEP = 10                 # entries in the leaderboard

SOUNDS = {                # effect: (drum voice, tune in semitones, decay in seconds)
    "start": ("tom", 0, 0.3),
    "hit": ("tom", 7, 0.12),
    "combo": ("clap", 0, 0.25),
    "miss": ("kick", -6, 0.25),
    "end": ("tom", -7, 0.6),
    "record": ("hihat", 5, 0.5),
}

# -- sound ----------------------------------------------------------------------------------------
class Sounds:
    """The game's sound effects, synthesized live by ``pyzed.audio``: there are no files to load.

    Four drum voices go through a mixer to the speakers, and each effect strikes one voice with
    its own tuning. Without a sound card the engine plays silently, so the game runs anywhere.
    """

    def __init__(self, backend="auto", volume=0.8):
        self.engine = audio.Engine(backend)
        mixer = self.engine.add("mixer")
        self.out = self.engine.add("audio_out", volume=volume)
        self.engine.connect(mixer, "left", self.out, "left")
        self.engine.connect(mixer, "right", self.out, "right")
        self.voices = {}
        for i, kind in enumerate(("tom", "clap", "kick", "hihat"), start=1):
            self.voices[kind] = self.engine.add("drum", sound=kind)
            self.engine.connect(self.voices[kind], "out", mixer, f"in_{i}")
        if backend != "offline":         # "offline" never plays: tests render() it instead
            self.engine.start()

    def play(self, name):
        kind, tune, decay = SOUNDS[name]
        voice = self.voices[kind]
        voice.set(tune=tune, decay=decay)
        voice.trigger()                  # returns at once: the sound plays on the audio thread

# -- saving --------------------------------------------------------------------------------------
def data_dir(app_name):
    """The folder where a game keeps its saves, following each system's convention."""
    if sys.platform == "win32":
        base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:
        base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
    return base / app_name

class HighScores:
    """The best ``KEEP`` scores, as a list of ``{"name", "score", "date"}`` saved as JSON."""

    def __init__(self, path):
        self.path = Path(path)
        self.entries = self.load()

    def load(self):
        try:
            data = json.loads(self.path.read_text(encoding="utf-8"))
            entries = [e for e in data.get("scores", []) if isinstance(e.get("score"), int)]
        except (OSError, ValueError, AttributeError):
            return []                      # no file yet, or a damaged one: start empty
        return sorted(entries, key=lambda e: -e["score"])[:KEEP]

    def qualifies(self, score):
        return score > 0 and (len(self.entries) < KEEP or score > self.entries[-1]["score"])

    def add(self, name, score):
        """Adds a score, saves, and returns its place (1 is the best)."""
        entry = {"name": name.strip()[:16] or "Player", "score": score, "date": time.strftime("%Y-%m-%d")}
        self.entries.append(entry)
        self.entries.sort(key=lambda e: -e["score"])   # a stable sort: older equal scores stay first
        del self.entries[KEEP:]
        self.save()
        return self.entries.index(entry) + 1 if entry in self.entries else None

    def save(self):
        self.path.parent.mkdir(parents=True, exist_ok=True)
        # Write next to the real file, then replace it in one step (atomic on every system).
        fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".scores-", suffix=".json")
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump({"version": 1, "scores": self.entries}, f, indent=2)
        os.replace(tmp, self.path)

    @property
    def best(self):
        return self.entries[0]["score"] if self.entries else 0

# -- the game ------------------------------------------------------------------------------------
class TargetRush(pz.App):
    title = "Target Rush"
    size = (W + 32, H + 92)
    theme = "One Dark"

    def __init__(self, seed=None, path=None, sound_backend="auto"):
        self.random = random.Random(seed)
        self.scores = HighScores(path or data_dir("Target Rush") / "scores.json")
        self.sparks = game.Particles(gravity=500, seed=seed)
        self.scene = "ready"             # ready -> play -> name (if it made the table) -> board
        self.name = ""
        self.rank = None
        self.last_sound, self.sound_age = None, 9.0
        self.sound_backend = sound_backend   # "auto", "offline" (tests) or None (muted)
        self.sounds = None                   # made on the first sound, so a menu costs nothing
        self.reset()

    def reset(self):
        self.targets = []                # [x, y, age]
        self.score, self.combo, self.hits, self.misses = 0, 0, 0, 0
        self.time_left = ROUND
        self.spawn = 0.0
        self.clock = 0.0

    def start(self):
        self.reset()
        self.scene = "play"
        self.sound("start")

    # -- sound hooks --------------------------------------------------------------------------
    def sound(self, name):
        """Called wherever a sound belongs: "start", "hit", "miss", "combo", "end", "record".

        Plays the effect and shows its name in the HUD for a moment.
        """
        self.last_sound, self.sound_age = name, 0.0
        if self.sounds is None and self.sound_backend:
            self.sounds = Sounds(self.sound_backend)
        if self.sounds:
            self.sounds.play(name)

    # -- update -------------------------------------------------------------------------------
    def subscriptions(self):
        return [pz.frames(self.tick)] if self.scene == "play" or len(self.sparks) or self.sound_age < 1 else []

    def tick(self, dt):
        dt = min(dt, 1 / 30)
        self.sparks.update(dt)
        self.sound_age += dt
        if self.scene != "play":
            return
        self.clock += dt
        self.time_left = max(0.0, self.time_left - dt)
        self.spawn -= dt
        if self.spawn <= 0:
            self.spawn = max(0.35, 0.8 - self.clock * 0.02)
            self.targets.append([self.random.uniform(50, W - 50), self.random.uniform(50, H - 50), 0.0])
        for t in self.targets:
            t[2] += dt
        expired = [t for t in self.targets if t[2] >= TARGET_LIFE]
        if expired:
            self.combo = 0
            self.misses += len(expired)
        self.targets = [t for t in self.targets if t[2] < TARGET_LIFE]
        if self.time_left <= 0:
            self.finish()

    def finish(self):
        self.targets = []
        if self.scores.qualifies(self.score):
            self.scene = "name"
            self.sound("record")
        else:
            self.scene = "board"
            self.rank = None
            self.sound("end")

    # -- input --------------------------------------------------------------------------------
    def click(self, x, y):
        if self.scene != "play":
            return
        for t in reversed(self.targets):             # the newest (on top) first
            r = self.radius(t)
            if math.hypot(x - t[0], y - t[1]) <= r:
                self.targets.remove(t)
                self.combo += 1
                self.hits += 1
                freshness = 1 - t[2] / TARGET_LIFE    # earlier clicks are worth more
                self.score += int(10 + 40 * freshness) * min(self.combo, 5)
                self.sparks.burst(t[0], t[1], count=18, color=["#f472b6", "#fbbf24", "#ffffff"], speed=(60, 260))
                self.sound("combo" if self.combo in (5, 10, 15) else "hit")
                return
        self.combo = 0
        self.misses += 1
        self.sound("miss")

    def set_name(self, value):
        self.name = value

    def submit_name(self, *_):
        self.rank = self.scores.add(self.name, self.score)
        self.scene = "board"

    def on_key(self, combo):
        if combo in ("enter", "space") and self.scene in ("ready", "board"):
            self.start()

    # -- draw ---------------------------------------------------------------------------------
    @staticmethod
    def radius(t):
        return TARGET_R * (1 - t[2] / TARGET_LIFE * 0.75)

    def board(self):
        shapes = [draw.background("#12101c")]
        for i in range(0, W, 32):
            shapes.append(draw.line(i, 0, i, H, stroke="#ffffff06"))
        for t in self.targets:
            r = self.radius(t)
            appear = min(1.0, t[2] / 0.12)              # a quick grow-in
            r *= appear
            for k, color in enumerate(("#f472b6", "#ffffff", "#f472b6")):
                shapes.append(draw.circle(t[0], t[1], r * (1 - k * 0.3), fill=color))
            # the remaining time as an arc around the target
            left = 1 - t[2] / TARGET_LIFE
            shapes.append(draw.arc(t[0], t[1], TARGET_R + 6, -90, -90 + 360 * left, stroke="#fbbf24", width=3,
                                   cap="round"))
        shapes += self.sparks.shapes(glow=True)
        return pz.canvas(shapes, width=W, height=H, on_press=self.click, cursor="crosshair")

    def leaderboard(self):
        rows = []
        for i, e in enumerate(self.scores.entries):
            mine = self.rank == i + 1
            medal = {0: "#fbbf24", 1: "#cbd5e1", 2: "#d97706"}.get(i)
            rows.append(pz.container(pz.row([
                pz.container(pz.text(f"{i + 1}", weight="bold", color=medal) if medal else ui.muted(f"{i + 1}"),
                             width=28),
                pz.text(e["name"], weight="semibold" if mine else None),
                pz.hspace(),
                ui.muted(e["date"], size="sm"),
                pz.container(pz.text(f"{e['score']:,}", font="mono"), width=70, align_x="end"),
            ], spacing=10, align="center"), padding=(4, 10)).style(background="#fbbf2430" if mine else None,
                                                                  radius=6))
        if not rows:
            rows = [ui.muted("No scores yet. Be the first!")]
        return pz.column(rows, spacing=2, width=340)

    def card(self, *children):
        card = pz.container(pz.column(list(children), spacing=10, align="center"), padding=(20, 28)).style(
            glass=True, radius=14).animate("fade_in_up", duration=260)
        return pz.container(card, width="fill", height="fill", align_x="center", align_y="center")

    def overlay(self):
        if self.scene == "ready":
            return self.card(ui.heading("Target Rush", level=2), ui.muted("Click the targets before they vanish."),
                             self.leaderboard(), ui.button("Start", self.start, variant="solid", icon="play"))
        if self.scene == "name":
            return self.card(
                ui.heading("New high score!", level=2), pz.text(f"{self.score:,}", size=28, weight="bold"),
                pz.container(ui.input(self.name, self.set_name, placeholder="Your name", icon="user",
                                      on_submit=self.submit_name, id="name", max_length=16), width=240),
                ui.button("Save", self.submit_name, variant="solid", icon="save"))
        if self.scene == "board":
            place = f"You placed #{self.rank}!" if self.rank else f"Score {self.score:,}"
            return self.card(ui.heading("Leaderboard", level=2), ui.muted(place), self.leaderboard(),
                             ui.button("Play again", self.start, variant="solid", icon="rotate-ccw"))
        return None

    def view(self):
        t = self.resolved_theme()
        sound = (ui.badge(f"♪ {self.last_sound}", icon="volume-2", tint="hint")
                 if self.sound_age < 0.8 and self.last_sound else None)
        hud = pz.row([
            ui.label("Target Rush", weight="semibold"), pz.hspace(), sound,
            ui.badge(f"x{self.combo}", icon="zap", tint="warning") if self.combo > 1 else None,
            ui.badge(f"{self.score:,}", icon="target", tint="accent"),
            ui.badge(f"{math.ceil(self.time_left)} s", icon="timer", tint="error" if self.time_left < 5 else "text_muted"),
            ui.badge(f"Best {self.scores.best:,}", icon="trophy", tint="text_muted"),
        ], spacing=8, align="center")
        play = pz.stack([self.board(), self.overlay()], width=W, height=H)
        return pz.container(pz.column([hud, pz.container(play, radius=10, clip=True),
                                       ui.muted(f"Scores are saved in {self.scores.path}", size="sm")], spacing=10),
                            padding=16, width="fill", height="fill", bg=t.background)

if __name__ == "__main__":
    pz.run(TargetRush)
```

## Siguiente

- [Consejos para crear juegos](https://albakore.github.io/pyzed/es/games/tips/): Rendimiento, pasos de tiempo fijos, buffer de entrada, juice, organizar el estado y probar sin ventana.
