juegos

Juegos de terminal de Pancho: Catan (TUI, GUI, web, servidor, PicoCalc), ajedrez, calculadora y minijuegos
git clone https://git.lu3dhn.xyz/juegos.git
Log | Files | Refs

ui.sh (34547B)


      1 #!/usr/bin/env bash
      2 # ============================================================================
      3 #  lib/ui.sh  --  La INTERFAZ: dibujar el tablero y elegir jugadas.
      4 # ============================================================================
      5 #
      6 # Todo lo que ve y toca la persona vive aca: el tablero a color en la terminal, el
      7 # historial al costado, los relojes, los menus, y el selector de jugadas (que usa
      8 # fzf, un programa de menus). IMPORTANTE: los textos que ve la persona quedan en
      9 # INGLES a proposito (es la interfaz del juego); solo los COMENTARIOS estan en
     10 # castellano.
     11 #
     12 # Sobre los colores en la terminal: se controlan con "codigos de escape ANSI",
     13 # unos textos raros como "\e[48;5;180m". No te asustes: son instrucciones para la
     14 # terminal del estilo "pinta el fondo de este color". "\e[0m" significa "volve a
     15 # lo normal". Usa board.sh, movegen.sh y rules.sh.
     16 
     17 # --- Temas de color del tablero ---------------------------------------------
     18 # Cada tema define 6 colores de fondo (codigos ANSI "48;5;N"):
     19 #   THEME_LIGHT   casilla clara
     20 #   THEME_DARK    casilla oscura
     21 #   THEME_FROM    casilla de origen seleccionada
     22 #   THEME_CAPTURE destino de captura
     23 #   THEME_TARGET  destino de jugada tranquila
     24 #   THEME_PATH    camino de la pieza
     25 
     26 # theme_set: aplica uno de los temas predefinidos. Se llama al arrancar y desde el
     27 # menu de temas. El nombre del tema activo se guarda en CHESS_THEME para mostrarlo.
     28 # Archivo donde se persiste el tema elegido. Al estar en /tmp con el UID, cada
     29 # usuario tiene el suyo. Esto permite que los subprocesos (la vista previa de fzf
     30 # y el proceso padre que dibuja el tablero real) compartan el mismo tema aunque
     31 # corran en subshells separados y no puedan pasarse variables entre si.
     32 _THEME_FILE="${TMPDIR:-/tmp}/chess_theme_${UID:-$(id -u)}"
     33 
     34 CHESS_THEME="classic"
     35 theme_set() {
     36     # Ignoramos cualquier argumento que no sea un tema conocido: asi un archivo
     37     # vacio o corrupto no borra los colores que ya estaban cargados.
     38     case "$1" in
     39         classic|ocean|night|contrast|colorblind|sunset) : ;;
     40         *) return ;;
     41     esac
     42     CHESS_THEME="$1"
     43     printf '%s' "$1" > "$_THEME_FILE"   # persiste para que el padre y los subprocesos lo lean
     44     case "$1" in
     45         classic)
     46             # Arena y verde oliva, los colores de madera clasicos.
     47             THEME_LIGHT="48;5;180" THEME_DARK="48;5;101"
     48             THEME_FROM="48;5;33"   THEME_CAPTURE="48;5;160"
     49             THEME_TARGET="48;5;71" THEME_PATH="48;5;109" ;;
     50         ocean)
     51             # Azules: celeste claro y azul marino.
     52             THEME_LIGHT="48;5;153" THEME_DARK="48;5;25"
     53             THEME_FROM="48;5;220"  THEME_CAPTURE="48;5;196"
     54             THEME_TARGET="48;5;48" THEME_PATH="48;5;87" ;;
     55         night)
     56             # Grises oscuros para terminales con fondo negro.
     57             THEME_LIGHT="48;5;240" THEME_DARK="48;5;235"
     58             THEME_FROM="48;5;24"   THEME_CAPTURE="48;5;88"
     59             THEME_TARGET="48;5;22" THEME_PATH="48;5;23" ;;
     60         contrast)
     61             # Alto contraste: blanco y negro puros, resaltados muy vividos.
     62             THEME_LIGHT="48;5;231" THEME_DARK="48;5;16"
     63             THEME_FROM="48;5;21"   THEME_CAPTURE="48;5;196"
     64             THEME_TARGET="48;5;46" THEME_PATH="48;5;51" ;;
     65         colorblind)
     66             # Azul y naranja: distinguibles para deuteranopia/protanopia.
     67             THEME_LIGHT="48;5;215" THEME_DARK="48;5;25"
     68             THEME_FROM="48;5;226"  THEME_CAPTURE="48;5;202"
     69             THEME_TARGET="48;5;75" THEME_PATH="48;5;117" ;;
     70         sunset)
     71             # Naranja y violeta: durazno claro contra violeta medio. Los
     72             # resaltados van en azul/verde para no confundirse con el tablero.
     73             THEME_LIGHT="48;5;216" THEME_DARK="48;5;97"
     74             THEME_FROM="48;5;33"   THEME_CAPTURE="48;5;160"
     75             THEME_TARGET="48;5;35" THEME_PATH="48;5;109" ;;
     76     esac
     77 }
     78 # Al arrancar: leemos el archivo persistente si existe; si no, el tema por defecto.
     79 if [[ -f "$_THEME_FILE" ]]; then
     80     theme_set "$(< "$_THEME_FILE")"
     81 else
     82     theme_set "classic"
     83 fi
     84 
     85 # --- Configuracion de ayudas visuales ---------------------------------------
     86 # Estas variables controlan si se muestran las "ayudas" opcionales durante la
     87 # partida. Se guardan en un archivo para recordarlas entre sesiones.
     88 
     89 _SETTINGS_FILE="${SAVE_DIR:-saves}/.settings"
     90 
     91 SHOW_EVAL_BAR=1   # 1 = mostrar barra de evaluacion de Stockfish
     92 SHOW_OPENING=1    # 1 = mostrar el nombre de la apertura en curso
     93 
     94 # settings_load: lee el archivo de configuracion si existe y aplica los valores.
     95 settings_load() {
     96     [[ -f "$_SETTINGS_FILE" ]] || return
     97     local key val
     98     while IFS='=' read -r key val; do
     99         case "$key" in
    100             SHOW_EVAL_BAR) SHOW_EVAL_BAR="$val" ;;
    101             SHOW_OPENING)  SHOW_OPENING="$val"  ;;
    102         esac
    103     done < "$_SETTINGS_FILE"
    104 }
    105 
    106 # settings_save: escribe los valores actuales al archivo.
    107 settings_save() {
    108     mkdir -p "$(dirname "$_SETTINGS_FILE")"
    109     printf 'SHOW_EVAL_BAR=%s\nSHOW_OPENING=%s\n' \
    110         "$SHOW_EVAL_BAR" "$SHOW_OPENING" > "$_SETTINGS_FILE"
    111 }
    112 
    113 # ui_settings_menu: menu de toggles. Cada seleccion prende/apaga una ayuda;
    114 # se cierra con Escape o eligiendo "back".
    115 ui_settings_menu() {
    116     while true; do
    117         local ev_lbl op_lbl
    118         (( SHOW_EVAL_BAR )) && ev_lbl="[ON ]" || ev_lbl="[OFF]"
    119         (( SHOW_OPENING  )) && op_lbl="[ON ]" || op_lbl="[OFF]"
    120         local pick
    121         pick=$(printf '%s\n' \
    122             "evalbar  ${ev_lbl}  Evaluation bar — Stockfish score shown while playing" \
    123             "opening  ${op_lbl}  Opening name — identify the opening being played" \
    124             "back     Done" \
    125           | fzf --reverse --border --height=35% --with-nth=1.. \
    126                 --prompt="Aids: " --header="  Visual aids — select to toggle")
    127         case "${pick%% *}" in
    128             evalbar) (( SHOW_EVAL_BAR )) && SHOW_EVAL_BAR=0 || SHOW_EVAL_BAR=1
    129                      settings_save; export SHOW_EVAL_BAR ;;
    130             opening) (( SHOW_OPENING  )) && SHOW_OPENING=0  || SHOW_OPENING=1
    131                      settings_save; export SHOW_OPENING ;;
    132             *)       return ;;
    133         esac
    134     done
    135 }
    136 
    137 # ui_pick_theme: abre un selector de tema y aplica el elegido. Se llama desde el
    138 # menu de pausa y desde el menu principal.
    139 ui_pick_theme() {
    140     local pick
    141     pick=$(printf '%s\n' \
    142         "classic     Wood tones — sand & olive (default)" \
    143         "ocean       Cool blues — sky & navy" \
    144         "night       Dark greys — low-light terminals" \
    145         "contrast    High contrast — pure black & white" \
    146         "colorblind  Blue & orange — deuteranopia / protanopia friendly" \
    147         "sunset      Orange & violet — peach & purple squares" \
    148       | fzf --reverse --border --height=45% --with-nth=1.. \
    149             --prompt="Theme: " \
    150             --header="  Current theme: ${CHESS_THEME}  (Esc to cancel)")
    151     [[ -z "$pick" ]] && return
    152     theme_set "${pick%% *}"
    153     export CHESS_THEME   # los subprocesos de la vista previa tambien lo necesitan
    154 }
    155 
    156 declare -a HISTORY   # historial de jugadas (un elemento por jugada completa)
    157 
    158 # _glyph: devuelve el dibujito (simbolo Unicode) de una pieza. Usamos siempre las
    159 # figuras "rellenas" porque las "huecas" se ven mal sobre casillas claras; el
    160 # color de cada bando lo da el color de letra (ver _piece_fg), no la figura.
    161 _glyph() {
    162     # "case" segun la letra. "[Pp]" coincide con P mayuscula o p minuscula (las dos
    163     # son peones, de distinto color). Asi un solo caso cubre los dos colores.
    164     case "$1" in
    165         [Pp]) echo "♙";;
    166         [Kk]) echo "♚";; [Qq]) echo "♛";; [Rr]) echo "♜";;
    167         [Bb]) echo "♝";; [Nn]) echo "♞";;
    168         *) echo " ";;   # casilla vacia: un espacio
    169     esac
    170 }
    171 
    172 # _piece_fg: el color de LETRA de una pieza: blanco brillante para las blancas,
    173 # casi negro para las negras. Asi distinguimos los bandos aunque la figura sea igual.
    174 _piece_fg() {
    175     if is_white "$1"; then echo "\e[38;5;231m"      # blanco brillante
    176     elif is_black "$1"; then echo "\e[38;5;232m"     # casi negro
    177     else echo ""; fi
    178 }
    179 
    180 # _captured_glyphs: muestra las piezas capturadas de un color. Lo calcula
    181 # comparando cuantas piezas DEBERIA haber al inicio contra cuantas hay ahora; la
    182 # diferencia son las capturadas. $1 = "white" o "black" (el bando capturado).
    183 _captured_glyphs() {
    184     local color=$1
    185     local -A want   # arreglo asociativo: cuantas piezas de cada tipo hay al inicio
    186     if [[ "$color" == white ]]; then want=( [P]=8 [N]=2 [B]=2 [R]=2 [Q]=1 )
    187     else want=( [p]=8 [n]=2 [b]=2 [r]=2 [q]=1 ); fi
    188     local -A have=()
    189     local i p
    190     # Contamos cuantas piezas de cada tipo hay AHORA en el tablero.
    191     for (( i=0; i<64; i++ )); do p="${BOARD[$i]}"; [[ "$p" != "." ]] && (( have["$p"]++ )); done
    192     local out="" k n
    193     # "${!want[@]}" recorre las CLAVES del arreglo (los tipos de pieza).
    194     for k in "${!want[@]}"; do
    195         n=$(( want[$k] - ${have[$k]:-0} ))     # cuantas faltan = capturadas
    196         while (( n-- > 0 )); do out+="$(_glyph "$k")"; done   # un dibujito por cada una
    197     done
    198     echo "$out"
    199 }
    200 
    201 # --- Barra de evaluacion (ayuda opcional, ver SHOW_EVAL_BAR) ----------------
    202 
    203 # _draw_eval_bar: dibuja una barra horizontal que indica quien esta ganando segun
    204 # Stockfish. Los cuadros claros (░) representan la ventaja de blancas y los oscuros
    205 # (▓) la de negras. La barra se satura en ±500 centipawns (5 piezas de valor).
    206 _draw_eval_bar() {
    207     local score=${EVAL_SCORE:-0}
    208     # Saturamos en ±500 cp para que la barra no quede siempre en un extremo.
    209     local clamped=$score
    210     (( clamped >  500 )) && clamped=500
    211     (( clamped < -500 )) && clamped=-500
    212     # Convertimos a [0..20]: 10 = igualdad. Cada unidad = 50 cp.
    213     local white=$(( 10 + clamped / 50 ))
    214     (( white <  0 )) && white=0
    215     (( white > 20 )) && white=20
    216     local black=$(( 20 - white ))
    217 
    218     local bar="" i
    219     for (( i=0; i<black; i++ )); do bar+="\e[48;5;232m \e[0m"; done
    220     for (( i=0; i<white; i++ )); do bar+="\e[48;5;231m \e[0m"; done
    221 
    222     printf '  Eval  %b  %s\n' "$bar" "$(_fmt_cp "$score")"
    223 }
    224 
    225 # --- Ayudantes del reloj (solo para mostrarlo) ------------------------------
    226 # El estado del reloj vive en transport.sh; estas funciones solo lo formatean.
    227 
    228 # _fmt_clock: pasa segundos a "MM:SS" (minutos:segundos), nunca negativo.
    229 # "%02d" significa "numero con al menos 2 digitos, rellenando con cero".
    230 _fmt_clock() {
    231     local s=$1
    232     (( s < 0 )) && s=0
    233     printf '%02d:%02d' $(( s / 60 )) $(( s % 60 ))
    234 }
    235 
    236 # _clock_str: una linea con los dos relojes; al lado que mueve (3er argumento) le
    237 # pone una flechita. Se usa tanto para la linea fija del tablero como para el
    238 # encabezado del selector que va contando en vivo (ver clock_ticker en ches.sh).
    239 _clock_str() {
    240     local w=$1 b=$2 active=$3
    241     local wlab="White $(_fmt_clock "$w")" blab="Black $(_fmt_clock "$b")"
    242     [[ "$active" == white ]] && wlab="▶ $wlab"
    243     [[ "$active" == black ]] && blab="▶ $blab"
    244     printf '⏱  %s     %s' "$wlab" "$blab"
    245 }
    246 
    247 # _status_line: el texto de estado de la posicion (de quien es el turno, si hay
    248 # jaque, o como termino la partida). Pregunta el estado a game_status (rules.sh).
    249 _status_line() {
    250     local st; st=$(game_status)
    251     case "$st" in
    252         checkmate)        echo "CHECKMATE — $(other_color "$TURN") wins";;
    253         stalemate)        echo "STALEMATE — draw";;
    254         draw-fivefold)    echo "DRAW — fivefold repetition";;
    255         draw-75move)      echo "DRAW — 75-move rule";;
    256         draw-material)    echo "DRAW — insufficient material";;
    257         *) if in_check "$TURN"; then echo "${TURN^^} to move — CHECK"; else echo "${TURN^^} to move"; fi;;
    258     esac
    259 }
    260 
    261 # --- Resaltados del tablero (para la vista previa del selector) -------------
    262 # Cuando elegis una pieza, el selector muestra una vista previa del tablero con
    263 # las jugadas posibles pintadas. Estas variables guardan QUE pintar. Las casillas
    264 # se guardan por su indice, en una lista con espacios alrededor para poder
    265 # preguntar facil si un indice esta adentro.
    266 HL_FROM=-1        # casilla de origen elegida (indice) o -1 si ninguna
    267 HL_TARGETS=" "    # destinos de jugadas tranquilas (sin captura)
    268 HL_CAPTURES=" "   # destinos de capturas
    269 HL_PATH=" "       # casillas por las que pasa una pieza que se desliza
    270 HL_TITLE=""       # titulo de arriba; si esta vacio, usa la linea de estado
    271 
    272 _hl_reset() { HL_FROM=-1; HL_TARGETS=" "; HL_CAPTURES=" "; HL_PATH=" "; HL_TITLE=""; }
    273 # _hl_has: el indice $2 esta en la lista $1? (busca " indice " con comodines *).
    274 _hl_has()   { [[ "$1" == *" $2 "* ]]; }
    275 
    276 # _piece_name: el nombre en ingles de una pieza, segun su letra.
    277 _piece_name() {
    278     case "${1,,}" in
    279         p) echo Pawn;; n) echo Knight;; b) echo Bishop;;
    280         r) echo Rook;; q) echo Queen;; k) echo King;; *) echo "";;
    281     esac
    282 }
    283 
    284 # _is_capture_move: la jugada $1 es una captura (incluyendo al paso) en la
    285 # posicion actual? (Es como _move_is_capture de notation.sh, repetida aca para que
    286 # la interfaz no dependa de ese archivo.)
    287 _is_capture_move() {
    288     local m=$1; local frm=${m:0:2} to=${m:2:2}
    289     local ti; ti=$(alg_to_idx "$to")
    290     [[ "${BOARD[$ti]}" != "." ]] && return 0
    291     local fi; fi=$(alg_to_idx "$frm")
    292     [[ "${BOARD[$fi],,}" == p && "${frm:0:1}" != "${to:0:1}" && "$to" == "$EP" ]]
    293 }
    294 
    295 # --- Nombre de apertura (ayuda opcional, ver SHOW_OPENING) ------------------
    296 
    297 # _opening_name: busca la coincidencia mas larga entre las jugadas jugadas hasta
    298 # ahora (PGN_UCI) y el archivo de aperturas. Imprime el nombre, o nada si no hay
    299 # coincidencia. PGN_UCI es el arreglo de jugadas en UCI que mantiene ches.sh.
    300 _OPENINGS_FILE="${SCRIPT_DIR:-/home/pancho/ajedrez}/lib/openings.tsv"
    301 _opening_name() {
    302     [[ -f "$_OPENINGS_FILE" ]] || return
    303     (( ${#PGN_UCI[@]} == 0 )) && return
    304 
    305     # Armamos el prefijo de jugadas jugadas como texto (ej: "e2e4 c7c5 g1f3").
    306     local played="${PGN_UCI[*]}"   # ${arreglo[*]} pega los elementos con espacios
    307 
    308     local best_name="" best_len=0
    309     local code name moves
    310     while IFS=$'\t' read -r code name moves; do
    311         [[ "$code" == \#* || -z "$code" ]] && continue   # saltamos comentarios y vacios
    312         local mlen=${#moves}
    313         # La secuencia de aperturas tiene que ser un prefijo de lo que jugamos.
    314         # Comparamos con "${played:0:mlen}" (los primeros mlen caracteres).
    315         if [[ "${played:0:$mlen}" == "$moves" && $mlen -gt $best_len ]]; then
    316             # Ademas, el caracter siguiente tiene que ser un espacio o fin: evitamos
    317             # que "e2e4" coincida con "e2e4c" (que no existe, pero por las dudas).
    318             local next="${played:$mlen:1}"
    319             [[ -z "$next" || "$next" == " " ]] && best_name="$name" && best_len=$mlen
    320         fi
    321     done < "$_OPENINGS_FILE"
    322 
    323     [[ -n "$best_name" ]] && printf '%s' "$best_name"
    324 }
    325 
    326 # --- _draw_board: DIBUJAR el tablero ----------------------------------------
    327 # Esta funcion arma el tablero a color con todos los datos. No limpia la pantalla
    328 # (de eso se encarga render_board). La vista previa del selector la usa directo.
    329 _draw_board() {
    330     local title="${HL_TITLE:-$(_status_line)}"   # titulo, o la linea de estado si no hay
    331     # Si la ayuda de apertura esta prendida, la agregamos al titulo.
    332     if (( ${SHOW_OPENING:-1} )) && [[ -z "$HL_TITLE" ]]; then
    333         local op; op=$(_opening_name)
    334         [[ -n "$op" ]] && title+="  \e[38;5;245m— ${op}\e[0m"
    335     fi
    336     echo -e "  ${title}\t\t MOVE HISTORY"
    337     echo "  +------------------------+ ------------------"
    338 
    339     # El historial muestra las ultimas 8 jugadas como mucho.
    340     local total=${#HISTORY[@]} hstart=0
    341     (( total > 8 )) && hstart=$(( total - 8 ))
    342 
    343     local r c
    344     for (( r=0; r<8; r++ )); do                   # cada fila del tablero
    345         local row_str="$((8-r)) |"                # el numero de fila (8 arriba, 1 abajo) y el borde
    346         for (( c=0; c<8; c++ )); do               # cada columna
    347             local idx=$(( r * 8 + c )) bg piece p fg
    348             piece="${BOARD[$idx]}"
    349             # Color de fondo del casillero (alternado, como un tablero real).
    350             if (( (r + c) % 2 == 0 )); then bg="\e[${THEME_LIGHT}m"; else bg="\e[${THEME_DARK}m"; fi
    351             p=$(_glyph "$piece"); fg=$(_piece_fg "$piece")
    352 
    353             # Si esta casilla esta resaltada, le cambiamos el color de fondo.
    354             if (( idx == HL_FROM )); then
    355                 bg="\e[${THEME_FROM}m"
    356             elif _hl_has "$HL_CAPTURES" "$idx"; then
    357                 bg="\e[${THEME_CAPTURE}m"
    358             elif _hl_has "$HL_TARGETS" "$idx"; then
    359                 bg="\e[${THEME_TARGET}m"
    360                 [[ "$piece" == "." ]] && { p="•"; fg="\e[38;5;231m"; }   # punto si esta vacia
    361             elif _hl_has "$HL_PATH" "$idx"; then
    362                 bg="\e[${THEME_PATH}m"
    363                 [[ "$piece" == "." ]] && { p="·"; fg="\e[38;5;231m"; }
    364             fi
    365             # Pegamos la celda: fondo + negrita (\e[1m) + color de letra + la figura + reset.
    366             row_str+="${bg}\e[1m${fg} ${p} \e[0m"
    367         done
    368         row_str+="|"
    369 
    370         # A la derecha de cada fila mostramos una linea del historial de jugadas.
    371         local move_line="" hidx=$(( hstart + r ))
    372         [[ -n "${HISTORY[$hidx]}" ]] && move_line="  $((hidx + 1)). ${HISTORY[$hidx]}"
    373         echo -e "${row_str}${move_line}"
    374     done
    375 
    376     echo "  +------------------------+ ------------------"
    377     echo "    a  b  c  d  e  f  g  h "
    378     echo -e "  Captured: white $(_captured_glyphs white)   black $(_captured_glyphs black)"
    379 
    380     # Contador de la regla de 50 movimientos (jugadas completas sin peon ni captura).
    381     local hm50=$(( HALFMOVE / 2 ))
    382     local c50="  50-move rule: ${hm50}/50"
    383     if (( HALFMOVE >= 100 )); then c50+="   \e[1;38;5;231m\e[48;5;71m DRAW CLAIMABLE \e[0m"; fi
    384     echo -e "$c50"
    385 
    386     # Aviso de que se pueden pedir tablas por triple repeticion. (En la pantalla
    387     # real, POSCOUNT esta cargado; en la vista previa del selector no, asi que ahi
    388     # no aparece. No pasa nada, es solo un aviso.)
    389     if can_claim_threefold; then
    390         echo -e "  Repetition:    \e[1;38;5;231m\e[48;5;71m THREEFOLD — DRAW CLAIMABLE \e[0m"
    391     fi
    392 
    393     # Barra de evaluacion (solo si la ayuda esta prendida y hay un puntaje calculado).
    394     # En la vista previa del selector EVAL_SCORE no esta disponible, asi que no aparece.
    395     if (( ${SHOW_EVAL_BAR:-1} )) && [[ -n "${AI_PID:-}" ]]; then
    396         _draw_eval_bar
    397     fi
    398 
    399     # El reloj. Solo la pantalla real (render_board) tiene TC_ENABLED prendido; la
    400     # vista previa del selector corre en otro proceso con TC_ENABLED=0, asi que ahi
    401     # no se dibuja (el reloj que va contando aparece en el encabezado del selector).
    402     if (( ${TC_ENABLED:-0} )); then
    403         echo -e "  $(_clock_str "$CLOCK_white" "$CLOCK_black" "$TURN")"
    404     fi
    405 }
    406 
    407 # render_board: la version para la pantalla real: limpia la terminal, resetea los
    408 # resaltados, dibuja, y deja una linea en blanco.
    409 render_board() {
    410     # Leemos el tema del archivo por si cambio en un subshell (menu de pausa).
    411     # "-s" chequea que el archivo exista Y no este vacio.
    412     [[ -s "$_THEME_FILE" ]] && theme_set "$(< "$_THEME_FILE")"
    413     clear
    414     _hl_reset
    415     _draw_board
    416     echo ""
    417 }
    418 
    419 # --- Vistas previas del selector (las llama ches.sh con subcomandos ocultos) ----
    420 # El selector fzf muestra una "vista previa" al costado. Esa vista la genera otro
    421 # proceso que reconstruye la posicion desde la variable CHESS_FEN.
    422 
    423 # ui_preview_piece: vista previa al elegir una PIEZA: resalta todos sus destinos
    424 # (capturas en rojo, jugadas tranquilas en verde).
    425 ui_preview_piece() {
    426     local sq=$1 fi m ti
    427     # Si lo elegido no es una casilla (ej: la opcion de reclamar tablas), mostramos
    428     # el tablero comun con un titulo explicativo, sin resaltar nada raro.
    429     if [[ ! "$sq" =~ ^[a-h][1-8]$ ]]; then
    430         _hl_reset
    431         HL_TITLE="\e[48;5;71m ♻ \e[0m Claim a draw — threefold repetition or the 50-move rule"
    432         _draw_board
    433         return
    434     fi
    435     fi=$(alg_to_idx "$sq")
    436     _hl_reset
    437     HL_FROM=$fi
    438     # Recorremos las jugadas legales que SALEN de esa casilla y las clasificamos.
    439     while IFS= read -r m; do
    440         [[ "${m:0:2}" == "$sq" ]] || continue
    441         ti=$(alg_to_idx "${m:2:2}")
    442         if _is_capture_move "$m"; then HL_CAPTURES+="$ti "; else HL_TARGETS+="$ti "; fi
    443     done < <(legal_moves)
    444     HL_TITLE="$(_piece_name "${BOARD[$fi]}") $sq    \e[48;5;71m • \e[0m move   \e[48;5;160m ✗ \e[0m capture"
    445     _draw_board
    446 }
    447 
    448 # ui_preview_move: vista previa de UNA jugada concreta: resalta el origen, el
    449 # camino que recorre, y el destino (rojo si es captura).
    450 ui_preview_move() {
    451     local mv=$1; local frm=${mv:0:2} to=${mv:2:2}
    452     local fi ti fr fc tr tc
    453     fi=$(alg_to_idx "$frm"); ti=$(alg_to_idx "$to")
    454     _hl_reset
    455     HL_FROM=$fi
    456     fr=$(( fi/8 )); fc=$(( fi%8 )); tr=$(( ti/8 )); tc=$(( ti%8 ))
    457 
    458     # Calculamos el camino (solo para piezas que se deslizan en linea). dr/dc es la
    459     # direccion (paso a paso) desde el origen hacia el destino.
    460     local dr=0 dc=0
    461     (( tr > fr )) && dr=1; (( tr < fr )) && dr=-1
    462     (( tc > fc )) && dc=1; (( tc < fc )) && dc=-1
    463     # Solo hay "camino" si la jugada es en linea recta o en diagonal perfecta.
    464     if (( dr == 0 || dc == 0 )) || (( (tr-fr)*(tr-fr) == (tc-fc)*(tc-fc) )); then
    465         local nr=$(( fr+dr )) nc=$(( fc+dc ))
    466         while (( nr != tr || nc != tc )); do      # marcamos las casillas intermedias
    467             HL_PATH+="$(( nr*8+nc )) "
    468             nr=$(( nr+dr )); nc=$(( nc+dc ))
    469         done
    470     fi
    471 
    472     if _is_capture_move "$mv"; then HL_CAPTURES+="$ti "; else HL_TARGETS+="$ti "; fi
    473     HL_TITLE="$(_piece_name "${BOARD[$fi]}") $frm → $to"
    474     _draw_board
    475 }
    476 
    477 # --- Guardar / exportar desde el menu de pausa ------------------------------
    478 # Estas funciones piden un nombre de archivo por teclado y guardan/exportan. Leen
    479 # de /dev/tty (el teclado real) porque fzf ya solto la pantalla cuando llegamos aca.
    480 
    481 LAST_SAVE=""
    482 _save_prompt() {
    483     printf '  Save name [game]: ' >/dev/tty
    484     local name; read -r name </dev/tty
    485     name=${name:-game}; name=${name// /_}          # nombre por defecto "game"; espacios -> "_"
    486     local file="${SAVE_DIR:-saves}/${name}.save"
    487     save_game "$file"
    488     LAST_SAVE="$file"
    489 }
    490 
    491 # Exportar la partida hasta ahora a un PGN estandar (para analizar/compartir).
    492 LAST_EXPORT=""
    493 _export_pgn_prompt() {
    494     printf '  PGN file name [game]: ' >/dev/tty
    495     local name; read -r name </dev/tty
    496     name=${name:-game}; name=${name// /_}
    497     local file="${SAVE_DIR:-saves}/${name}.pgn"
    498     export_pgn "$file" "*"
    499     LAST_EXPORT="$file"
    500 }
    501 
    502 # Exportar la posicion actual como un FEN pelado (a un archivo, y se muestra para
    503 # poder copiarla).
    504 LAST_FEN=""
    505 _export_fen_prompt() {
    506     printf '  FEN file name [position]: ' >/dev/tty
    507     local name; read -r name </dev/tty
    508     name=${name:-position}; name=${name// /_}
    509     local file="${SAVE_DIR:-saves}/${name}.fen"
    510     LAST_FEN="$(export_fen "$file")"
    511 }
    512 
    513 # ui_confirm_draw: menu chico de si/no cuando el otro ofrece tablas. Devuelve 0
    514 # (acepta) o 1 (rechaza). Es solo interfaz; la red la maneja quien la llama.
    515 ui_confirm_draw() {
    516     local ans
    517     ans=$(printf '%s\n' "no   ✗ Decline, keep playing" "yes  🤝 Accept the draw" \
    518         | fzf --reverse --border --height=30% --with-nth=1.. \
    519               --prompt="Draw offer: " --header="  Your opponent offers a draw")
    520     [[ "${ans%% *}" == yes ]]
    521 }
    522 
    523 # ui_wait_menu: se abre al apretar una tecla mientras esperamos al otro. Devuelve
    524 # una palabra de control: "" (seguir esperando), RECONNECT (declarar que el otro
    525 # se cayo), SAVE, QUIT o RESIGN.
    526 ui_wait_menu() {
    527     local act
    528     act=$(printf '%s\n' \
    529         "wait    ▶ Keep waiting" \
    530         "drop    ⇄ Opponent disconnected — wait for reconnect" \
    531         "save    💾 Save game" \
    532         "quit    ⏹ Quit (no resign)" \
    533         "resign  🏳 Resign" \
    534       | fzf --reverse --border --height=45% --with-nth=1.. \
    535             --prompt="Waiting: " --header="  Opponent hasn't moved — pick an action")
    536     case "${act%% *}" in
    537         drop)   echo "RECONNECT" ;;
    538         save)   echo "SAVE" ;;
    539         quit)   echo "QUIT" ;;
    540         resign) echo "RESIGN" ;;
    541         *)      echo "" ;;
    542     esac
    543 }
    544 
    545 # _wait_repaint: repinta EN EL LUGAR la unica linea de espera (el reloj del otro
    546 # que va contando, o un relojito si no hay tiempo, mas la ayuda del teclado).
    547 # "\r" lleva el cursor al inicio de la linea, asi se reescribe encima sin bajar.
    548 _wait_repaint() {
    549     local active=$1 deadline=$2 line
    550     if [[ -n "$deadline" ]]; then
    551         local now rem w=$CLOCK_white b=$CLOCK_black
    552         now=$(date +%s); rem=$(( deadline - now )); (( rem < 0 )) && rem=0
    553         [[ "$active" == white ]] && w=$rem || b=$rem
    554         line="$(_clock_str "$w" "$b" "$active")"
    555     else
    556         line="⏳  Waiting for ${active^^}'s move"
    557     fi
    558     printf '\r  %s    (press any key for options)   ' "$line" >/dev/tty
    559 }
    560 
    561 # ui_confirm_takeback: menu de si/no cuando el otro pide retroceder su jugada.
    562 # Devuelve 0 (acepta) o 1 (rechaza). Solo interfaz.
    563 ui_confirm_takeback() {
    564     local ans
    565     ans=$(printf '%s\n' "no   ✗ Refuse" "yes  ↩ Allow the takeback" \
    566         | fzf --reverse --border --height=30% --with-nth=1.. \
    567               --prompt="Takeback: " --header="  Opponent wants to take back their last move")
    568     [[ "${ans%% *}" == yes ]]
    569 }
    570 
    571 # --- _escape_menu: el menu de PAUSA (al apretar Escape en el selector) -------
    572 # Deja seguir jugando, ofrecer tablas, pedir un retroceso (si esta activado),
    573 # guardar, exportar, salir sin rendirse, o rendirse. Devuelve la palabra de
    574 # control que tiene que actuar quien llama ("" = seguir, DRAWOFFER, TAKEBACK,
    575 # QUIT o RESIGN). La opcion de retroceder solo aparece si esta permitido y hay una
    576 # jugada en la pila de deshacer.
    577 _escape_menu() {
    578     local status="  Game paused — choose an action"
    579     while true; do
    580         # Armamos las opciones en un arreglo, agregando algunas solo si corresponde.
    581         local -a items=( "play    ▶ Keep playing" )
    582         # Ofrecer tablas es una negociacion entre dos; no tiene sentido contra la maquina.
    583         [[ "${TRANSPORT_MODE:-}" != ai ]] && items+=( "draw    🤝 Offer draw" )
    584         if (( ${ALLOW_TAKEBACK:-0} )) && (( ${#UNDO_STACK[@]} > 0 )); then
    585             items+=( "undo    ↩ Request takeback" )
    586         fi
    587         items+=( "save    💾 Save game" \
    588                  "epgn    ⬇ Export PGN (full game)" \
    589                  "efen    ⬇ Export FEN (this position)" \
    590                  "theme   🎨 Change board theme (${CHESS_THEME})" \
    591                  "aids    ☰  Toggle visual aids (eval bar, opening name)" \
    592                  "quit    ⏹ Quit (no resign)" "resign  🏳 Resign" )
    593         local act
    594         act=$(printf '%s\n' "${items[@]}" \
    595           | fzf --reverse --border --height=60% --with-nth=1.. \
    596                 --prompt="Paused: " --header="$status")
    597         case "${act%% *}" in
    598             play|"") return ;;
    599             draw)    echo "DRAWOFFER"; return ;;
    600             undo)    echo "TAKEBACK";  return ;;
    601             save)    _save_prompt;       status="  Saved to ${LAST_SAVE}" ;;
    602             epgn)    _export_pgn_prompt; status="  Exported PGN to ${LAST_EXPORT}" ;;
    603             efen)    _export_fen_prompt; status="  Exported FEN: ${LAST_FEN}" ;;
    604             theme)   ui_pick_theme;      status="  Theme: ${CHESS_THEME}" ;;
    605             aids)    ui_settings_menu;  status="  Aids updated" ;;
    606             quit)    echo "QUIT";   return ;;
    607             resign)  echo "RESIGN"; return ;;
    608         esac
    609     done
    610 }
    611 
    612 # --- ui_pick_move: ELEGIR una jugada, en dos pasos --------------------------
    613 #   1) elegir una pieza (la vista previa resalta sus jugadas y capturas)
    614 #   2) elegir el destino (la vista previa resalta el camino origen->destino)
    615 # Devuelve la jugada elegida (con la coronacion ya resuelta), o nada si la persona
    616 # cancela en el paso 1 (quien llama lo toma como salir/rendirse). Cancelar en el
    617 # paso 2 vuelve al paso 1.
    618 ui_pick_move() {
    619     local -a moves
    620     # "mapfile -t moves < <(...)" mete cada linea que imprime legal_moves como un
    621     # elemento del arreglo "moves".
    622     mapfile -t moves < <(legal_moves)
    623     (( ${#moves[@]} == 0 )) && return            # sin jugadas: no hay nada que elegir
    624 
    625     # Exportamos la posicion y el tema para que el proceso de la vista previa los use.
    626     export CHESS_FEN; CHESS_FEN="$(get_fen)"
    627     export CHESS_THEME
    628     local bin="${CHESS_BIN:-$PWD/ches.sh}"
    629 
    630     # --- Cableado del reloj en vivo (solo si hay control de tiempo) ---
    631     # Truco lindo: cada etapa de fzf abre un pequenio "servidor de control"
    632     # (--listen). Al arrancar, lanza en segundo plano "ches.sh __clocktick", que cada
    633     # un segundo repinta el encabezado con el tiempo restante y, si se acaba, deja
    634     # una senial (un archivo "sentinel") y cierra el selector. Despues de cada
    635     # etapa miramos ese archivo para distinguir "se acabo el tiempo" de "cancele a
    636     # mano". Si no hay reloj, todo este bloque no hace nada.
    637     local -a topts=()
    638     local sentinel=""
    639     if (( ${TC_ENABLED:-0} )); then
    640         sentinel="${TMPDIR:-/tmp}/chess_to.$$.$RANDOM"   # nombre de archivo unico
    641         rm -f "$sentinel"
    642         local now myrem
    643         now=$(date +%s)
    644         [[ "$TURN" == white ]] && myrem=$CLOCK_white || myrem=$CLOCK_black
    645         # Exportamos datos para que el proceso del reloj sepa cuanto falta.
    646         export CHESS_DEADLINE=$(( now + myrem ))
    647         export CHESS_ACTIVE="$TURN"
    648         export CHESS_CLK_W="$CLOCK_white" CHESS_CLK_B="$CLOCK_black"
    649         export CHESS_SENTINEL="$sentinel"
    650         topts=( --listen --header-first
    651                 --header="$(_clock_str "$CLOCK_white" "$CLOCK_black" "$TURN")"
    652                 --bind "start:execute-silent(bash '$bin' __clocktick \$FZF_PORT &)" )
    653     fi
    654     # _timed_out: verdadero si la ultima etapa se cerro porque se acabo el tiempo.
    655     _timed_out() { [[ -n "$sentinel" && -e "$sentinel" ]]; }
    656 
    657     # Agrupamos las jugadas por casilla de ORIGEN (para el menu del paso 1).
    658     local -A by_src seen
    659     local -a srcs=()
    660     local m base frm
    661     for m in "${moves[@]}"; do
    662         base="${m:0:4}"                          # solo origen+destino (sin la letra de coronacion)
    663         [[ -n "${seen[$base]}" ]] && continue    # evitamos repetir la misma jugada base
    664         seen[$base]=1
    665         frm="${base:0:2}"
    666         [[ -z "${by_src[$frm]}" ]] && srcs+=("$frm")
    667         by_src[$frm]+="$base "
    668     done
    669 
    670     while true; do
    671         # --- Paso 1: elegir una pieza ---
    672         local -a plist=()
    673         for frm in "${srcs[@]}"; do
    674             # Contamos cuantas jugadas y cuantas capturas tiene esa pieza, para el texto.
    675             local cnt=0 caps=0 b
    676             for b in ${by_src[$frm]}; do
    677                 ((cnt++)); _is_capture_move "$b" && ((caps++))
    678             done
    679             local extra=""; (( caps > 0 )) && extra=", ${caps} capture$([[ $caps -gt 1 ]] && echo s)"
    680             plist+=("$(printf '%s  %-6s %d move%s%s' "$frm" \
    681                 "$(_piece_name "${BOARD[$(alg_to_idx "$frm")]}")" \
    682                 "$cnt" "$([[ $cnt -gt 1 ]] && echo s)" "$extra")")
    683         done
    684 
    685         # Si se pueden pedir tablas, agregamos una opcion arriba de todo. (La triple
    686         # repeticion tiene prioridad en el texto; play() decide que regla aplica.)
    687         local claim_row=""
    688         if   can_claim_threefold; then claim_row="CLAIM  ♻ Claim draw (threefold repetition)"
    689         elif can_claim_draw50;    then claim_row="CLAIM  ♻ Claim draw (50-move rule)"; fi
    690 
    691         local pick
    692         # Mostramos el menu: la opcion de tablas (si hay) arriba, despues las piezas
    693         # ordenadas. "--preview" hace que fzf muestre el tablero al costado llamando
    694         # al subcomando oculto __ppiece con la casilla resaltada ({1}).
    695         pick=$( { [[ -n "$claim_row" ]] && printf '%s\n' "$claim_row"
    696                   printf '%s\n' "${plist[@]}" | sort; } | fzf --reverse --border \
    697             --prompt="Select piece (${TURN}): " --with-nth=1.. "${topts[@]}" \
    698             --preview="bash '$bin' __ppiece {1}" --preview-window=right:62%:border-left)
    699         _timed_out && { rm -f "$sentinel"; echo "TIMEOUT"; return; }
    700         if [[ -z "$pick" ]]; then             # Escape en el paso 1 -> menu de pausa
    701             local em; em=$(_escape_menu)
    702             case "$em" in
    703                 QUIT)      echo "QUIT";      return ;;
    704                 RESIGN)    echo "RESIGN";    return ;;
    705                 DRAWOFFER) echo "DRAWOFFER"; return ;;
    706                 TAKEBACK)  echo "TAKEBACK";  return ;;
    707                 *)         continue ;;        # seguir jugando
    708             esac
    709         fi
    710         local src="${pick%% *}"
    711         [[ "$src" == CLAIM ]] && { echo "CLAIM50"; return; }   # eligio reclamar tablas
    712 
    713         # --- Paso 2: elegir el destino ---
    714         local -a dlist=()
    715         for base in ${by_src[$src]}; do
    716             local to="${base:2:2}" mark="move"
    717             _is_capture_move "$base" && mark="✗ capture"
    718             dlist+=("$(printf '%s  %s' "$to" "$mark")")
    719         done
    720 
    721         local dpick
    722         dpick=$(printf '%s\n' "${dlist[@]}" | sort | fzf --reverse --border \
    723             --prompt="Move ${src} to: " --with-nth=1.. "${topts[@]}" \
    724             --preview="bash '$bin' __pmove ${src}{1}" --preview-window=right:62%:border-left)
    725         _timed_out && { rm -f "$sentinel"; echo "TIMEOUT"; return; }
    726         [[ -z "$dpick" ]] && continue         # cancelar el paso 2: volver al paso 1
    727         local dst="${dpick%% *}"
    728         local chosen="${src}${dst}"
    729 
    730         # --- Coronacion (si esta jugada tiene variantes de coronar) ---
    731         local promos=""
    732         for m in "${moves[@]}"; do
    733             # Una jugada de 5 letras cuya base coincide es una coronacion; juntamos
    734             # las piezas posibles (q/r/b/n).
    735             [[ ${#m} == 5 && "${m:0:4}" == "$chosen" ]] && promos+="${m:4:1}"
    736         done
    737         if [[ -n "$promos" ]]; then
    738             local choices="" i ch
    739             for (( i=0; i<${#promos}; i++ )); do
    740                 ch="${promos:$i:1}"
    741                 case "$ch" in q) choices+="q (Queen)"$'\n';; r) choices+="r (Rook)"$'\n';;
    742                               b) choices+="b (Bishop)"$'\n';; n) choices+="n (Knight)"$'\n';; esac
    743             done
    744             local sel
    745             sel=$(printf '%s' "$choices" | fzf --height=20% --reverse \
    746                 --prompt="Promote to: " --border "${topts[@]}")
    747             _timed_out && { rm -f "$sentinel"; echo "TIMEOUT"; return; }
    748             [[ -z "$sel" ]] && continue
    749             chosen+="${sel:0:1}"              # le pegamos la letra de la pieza elegida
    750         fi
    751 
    752         echo "$chosen"
    753         return
    754     done
    755 }