feat(multidispositivo): reconnection, impostor awareness and protocol fixes

Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
  over. Every phase change was broadcasting the impostor roles in clear to
  all clients.
- The impostor clue travels in its own field and only when the game enables
  it. It used to be sent as the category key ("todas" when no category was
  picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
  screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
  twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
  impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
  silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.

Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
  nullable: null means nothing to show, an empty list means "you are the
  only impostor".

Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
  clientId so a phone that drops and returns is the same client. Falls back
  to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
  disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
  phase plus that client's own players, and the client jumps straight to the
  running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
  client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
  belonged to, and is cancelled correctly while shutting down.

Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
  new one carrying a clue per word, falling back to the category clue.

Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
This commit is contained in:
2026-07-25 20:36:32 +02:00
parent bad641653c
commit 863690168c
23 changed files with 1284 additions and 235 deletions
+50 -32
View File
@@ -42,6 +42,36 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Máximo de impostores admitido para un número de jugadores dado.
/// Es la misma regla en modo un solo móvil y en multidispositivo.
static int maxImpostoresPara(int numJugadores) =>
(numJugadores ~/ 3).clamp(1, 4);
/// Asigna impostores con un generador seguro y reparte la palabra al resto.
void _repartirRoles(
List<Jugador> jugadores,
ConfigPartida config,
String palabra,
) {
final rng = Random.secure();
final numImpostores = config.numImpostores.clamp(
1,
maxImpostoresPara(jugadores.length),
);
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
}
for (final jugador in jugadores) {
if (!jugador.esImpostor) {
jugador.palabra = palabra;
}
}
}
/// Crea una nueva partida con la configuración dada y lista de jugadores /// Crea una nueva partida con la configuración dada y lista de jugadores
void crearPartida({ void crearPartida({
required ConfigPartida config, required ConfigPartida config,
@@ -60,29 +90,14 @@ class EstadoJuego extends ChangeNotifier {
return Jugador(id: 'j${e.key}', nombre: e.value); return Jugador(id: 'j${e.key}', nombre: e.value);
}).toList(); }).toList();
// Asignar impostores usando Random seguro (no predecible) _repartirRoles(jugadores, config, palabra);
final rng = Random.secure();
final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
}
// Asignar palabras
for (final j in jugadores) {
if (!j.esImpostor) {
j.palabra = palabra;
}
}
_partida = Partida( _partida = Partida(
config: config, config: config,
jugadores: jugadores, jugadores: jugadores,
palabraSecreta: palabra, palabraSecreta: palabra,
categoriaReal: categoriaReal, categoriaReal: categoriaReal,
pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal),
); );
_votos.clear(); _votos.clear();
@@ -117,27 +132,14 @@ class EstadoJuego extends ChangeNotifier {
); );
}).toList(); }).toList();
final rng = Random.secure(); _repartirRoles(jugadores, config, palabra);
final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
}
for (final jugador in jugadores) {
if (!jugador.esImpostor) {
jugador.palabra = palabra;
}
}
_partida = Partida( _partida = Partida(
config: config, config: config,
jugadores: jugadores, jugadores: jugadores,
palabraSecreta: palabra, palabraSecreta: palabra,
categoriaReal: categoriaReal, categoriaReal: categoriaReal,
pistaImpostor: _banco!.pistaDePalabra(palabra, categoria: categoriaReal),
); );
_votos.clear(); _votos.clear();
@@ -145,6 +147,22 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Nombres del resto de impostores para un jugador dado.
///
/// Devuelve `null` cuando no hay nada que mostrar (el jugador no es impostor
/// o la partida no permite que se conozcan) y una lista —posiblemente vacía,
/// si es el único impostor— cuando sí procede mostrarlo.
List<String>? companerosImpostoresDe(String jugadorId) {
final partida = _partida;
if (partida == null || !partida.config.impostoresSeConocen) return null;
final indice = partida.jugadores.indexWhere((j) => j.id == jugadorId);
if (indice < 0 || !partida.jugadores[indice].esImpostor) return null;
return partida.jugadores
.where((j) => j.esImpostor && j.id != jugadorId)
.map((j) => j.nombre)
.toList();
}
/// Avanza a la fase de debate /// Avanza a la fase de debate
void iniciarDebate() { void iniciarDebate() {
if (_partida == null) return; if (_partida == null) return;
@@ -18,11 +18,19 @@ class JugadorInicioPartida {
final bool esImpostor; final bool esImpostor;
final String? palabra; final String? palabra;
/// Nombres del resto de impostores.
///
/// `null` significa que no hay nada que mostrar (el jugador no es impostor o
/// la partida no permite que se conozcan). Una lista vacía significa que sí
/// procede mostrarlo y que es el único impostor.
final List<String>? companerosImpostores;
const JugadorInicioPartida({ const JugadorInicioPartida({
required this.jugadorId, required this.jugadorId,
required this.nombre, required this.nombre,
required this.esImpostor, required this.esImpostor,
required this.palabra, required this.palabra,
this.companerosImpostores,
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -30,14 +38,20 @@ class JugadorInicioPartida {
'nombre': nombre, 'nombre': nombre,
'esImpostor': esImpostor, 'esImpostor': esImpostor,
if (palabra != null) 'palabra': palabra, if (palabra != null) 'palabra': palabra,
if (companerosImpostores != null)
'companerosImpostores': companerosImpostores,
}; };
factory JugadorInicioPartida.fromJson(Map<String, dynamic> json) { factory JugadorInicioPartida.fromJson(Map<String, dynamic> json) {
final companeros = json['companerosImpostores'] as List<dynamic>?;
return JugadorInicioPartida( return JugadorInicioPartida(
jugadorId: json['jugadorId'] as String, jugadorId: json['jugadorId'] as String,
nombre: json['nombre'] as String, nombre: json['nombre'] as String,
esImpostor: json['esImpostor'] as bool? ?? false, esImpostor: json['esImpostor'] as bool? ?? false,
palabra: json['palabra'] as String?, palabra: json['palabra'] as String?,
companerosImpostores: companeros
?.map((nombre) => nombre.toString())
.toList(),
); );
} }
} }
@@ -82,9 +96,16 @@ class InicioPartidaMultijugador {
required String palabraSecreta, required String palabraSecreta,
required String categoria, required String categoria,
required Map<String, bool> impostoresPorJugadorId, required Map<String, bool> impostoresPorJugadorId,
bool impostoresSeConocen = false,
}) { }) {
final payloads = <String, InicioPartidaCliente>{}; final payloads = <String, InicioPartidaCliente>{};
final nombresImpostores = <String, String>{
for (final asignacion in asignaciones)
if (impostoresPorJugadorId[asignacion.jugadorId] ?? false)
asignacion.jugadorId: asignacion.nombre,
};
for (final asignacion in asignaciones) { for (final asignacion in asignaciones) {
final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false; final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false;
final payloadActual = payloads[asignacion.clientId]; final payloadActual = payloads[asignacion.clientId];
@@ -93,6 +114,12 @@ class InicioPartidaMultijugador {
nombre: asignacion.nombre, nombre: asignacion.nombre,
esImpostor: esImpostor, esImpostor: esImpostor,
palabra: esImpostor ? null : palabraSecreta, palabra: esImpostor ? null : palabraSecreta,
companerosImpostores: esImpostor && impostoresSeConocen
? (nombresImpostores.entries
.where((entry) => entry.key != asignacion.jugadorId)
.map((entry) => entry.value)
.toList())
: null,
); );
if (payloadActual == null) { if (payloadActual == null) {
+67 -9
View File
@@ -3,13 +3,30 @@ import 'dart:math';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
/// Una palabra del banco junto con la pista que verá el impostor.
class EntradaPalabra {
final String palabra;
/// Pista específica de esta palabra. Si es null se usa la de la categoría.
final String? pista;
const EntradaPalabra({required this.palabra, this.pista});
}
/// Categorías disponibles en el banco de palabras. /// Categorías disponibles en el banco de palabras.
class BancoPalabras { class BancoPalabras {
final Map<String, List<String>> categorias; final Map<String, List<String>> categorias;
final Map<String, String> pistasPorCategoria; final Map<String, String> pistasPorCategoria;
BancoPalabras(this.categorias, {Map<String, String>? pistasPorCategoria}) /// Pista por palabra, cuando el banco la aporta.
: pistasPorCategoria = pistasPorCategoria ?? {}; final Map<String, String> pistasPorPalabra;
BancoPalabras(
this.categorias, {
Map<String, String>? pistasPorCategoria,
Map<String, String>? pistasPorPalabra,
}) : pistasPorCategoria = pistasPorCategoria ?? {},
pistasPorPalabra = pistasPorPalabra ?? {};
static final Map<String, BancoPalabras> _instancias = {}; static final Map<String, BancoPalabras> _instancias = {};
@@ -37,19 +54,42 @@ class BancoPalabras {
final cats = data['categorias'] as Map<String, dynamic>; final cats = data['categorias'] as Map<String, dynamic>;
final mapa = <String, List<String>>{}; final mapa = <String, List<String>>{};
final pistas = <String, String>{}; final pistas = <String, String>{};
final pistasPalabra = <String, String>{};
for (final entrada in cats.entries) { for (final entrada in cats.entries) {
final valor = entrada.value; final valor = entrada.value;
final listaCruda = valor is Map<String, dynamic>
? valor['palabras'] as List
: valor as List;
if (valor is Map<String, dynamic>) { if (valor is Map<String, dynamic>) {
mapa[entrada.key] = List<String>.from(valor['palabras'] as List);
final pista = valor['pista']; final pista = valor['pista'];
if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista; if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista;
} else {
mapa[entrada.key] = List<String>.from(valor as List);
} }
final palabras = <String>[];
for (final elemento in listaCruda) {
// Formato v2: "Perro". Formato v3: {"palabra": "Perro", "pista": "..."}
if (elemento is Map) {
final palabra = elemento['palabra'] as String?;
if (palabra == null || palabra.isEmpty) continue;
palabras.add(palabra);
final pistaPalabra = elemento['pista'];
if (pistaPalabra is String && pistaPalabra.isNotEmpty) {
pistasPalabra[palabra] = pistaPalabra;
}
} else {
palabras.add(elemento as String);
}
}
mapa[entrada.key] = palabras;
} }
_instancias[idioma] = BancoPalabras(mapa, pistasPorCategoria: pistas); _instancias[idioma] = BancoPalabras(
mapa,
pistasPorCategoria: pistas,
pistasPorPalabra: pistasPalabra,
);
return _instancias[idioma]!; return _instancias[idioma]!;
} }
@@ -57,7 +97,7 @@ class BancoPalabras {
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null). /// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null).
String palabraAleatoria(String? categoria) { String palabraAleatoria(String? categoria) {
final rng = Random(); final rng = Random.secure();
if (categoria == null || categoria == 'todas') { if (categoria == null || categoria == 'todas') {
final todasPalabras = categorias.values.expand((l) => l).toList(); final todasPalabras = categorias.values.expand((l) => l).toList();
return todasPalabras[rng.nextInt(todasPalabras.length)]; return todasPalabras[rng.nextInt(todasPalabras.length)];
@@ -77,6 +117,16 @@ class BancoPalabras {
/// Devuelve la pista localizada de una categoría si el banco la trae. /// Devuelve la pista localizada de una categoría si el banco la trae.
String? pistaDeCategoria(String categoria) => pistasPorCategoria[categoria]; String? pistaDeCategoria(String categoria) => pistasPorCategoria[categoria];
/// Pista que verá el impostor para una palabra concreta. Prioriza la pista
/// específica de la palabra y cae a la de su categoría si no existe.
String? pistaDePalabra(String palabra, {String? categoria}) {
final especifica = pistasPorPalabra[palabra];
if (especifica != null && especifica.isNotEmpty) return especifica;
final clave = categoria ?? categoriaDepalabra(palabra);
if (clave == null) return null;
return pistasPorCategoria[clave];
}
/// Devuelve el nombre localizado de la categoría usando AppLocalizations. /// Devuelve el nombre localizado de la categoría usando AppLocalizations.
static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) { static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) {
if (l10n != null) { if (l10n != null) {
@@ -132,9 +182,17 @@ class BancoPalabrasTraducidas {
final banco = await BancoPalabras.cargar(idioma: idioma); final banco = await BancoPalabras.cargar(idioma: idioma);
final mapa = <String, List<EntradaPalabraTraducida>>{}; final mapa = <String, List<EntradaPalabraTraducida>>{};
for (final categoria in banco.categorias.entries) { for (final categoria in banco.categorias.entries) {
final pista = banco.pistaDeCategoria(categoria.key) ?? categoria.key; final pistaImpostor =
banco.pistaDeCategoria(categoria.key) ?? categoria.key;
mapa[categoria.key] = categoria.value mapa[categoria.key] = categoria.value
.map((palabra) => EntradaPalabraTraducida(palabra: palabra, pista: pista)) .map(
(palabra) => EntradaPalabraTraducida(
palabra: palabra,
pista:
banco.pistaDePalabra(palabra, categoria: categoria.key) ??
pistaImpostor,
),
)
.toList(); .toList();
} }
+16 -1
View File
@@ -8,12 +8,16 @@ class ConfigPartida {
final bool pistaImpostor; final bool pistaImpostor;
final int? tiempoDebateSegundos; // null = sin límite final int? tiempoDebateSegundos; // null = sin límite
/// Cuando hay más de un impostor, cada impostor ve los nombres del resto.
final bool impostoresSeConocen;
const ConfigPartida({ const ConfigPartida({
this.modoMultimovil = false, this.modoMultimovil = false,
this.categoria = 'todas', this.categoria = 'todas',
this.numImpostores = 1, this.numImpostores = 1,
this.pistaImpostor = false, this.pistaImpostor = false,
this.tiempoDebateSegundos, this.tiempoDebateSegundos,
this.impostoresSeConocen = true,
}); });
} }
@@ -49,6 +53,10 @@ class Partida {
final List<Jugador> jugadores; final List<Jugador> jugadores;
final String palabraSecreta; final String palabraSecreta;
final String categoriaReal; final String categoriaReal;
/// Pista que ve el impostor. Es específica de la palabra cuando el banco la
/// aporta; si no, cae al nombre de la categoría.
final String pistaImpostor;
FaseJuego fase; FaseJuego fase;
int rondaActual; int rondaActual;
final List<ResultadoVotacion> historialVotaciones; final List<ResultadoVotacion> historialVotaciones;
@@ -59,11 +67,18 @@ class Partida {
required this.jugadores, required this.jugadores,
required this.palabraSecreta, required this.palabraSecreta,
required this.categoriaReal, required this.categoriaReal,
String? pistaImpostor,
this.fase = FaseJuego.verPalabra, this.fase = FaseJuego.verPalabra,
this.rondaActual = 1, this.rondaActual = 1,
List<ResultadoVotacion>? historialVotaciones, List<ResultadoVotacion>? historialVotaciones,
this.ganador, this.ganador,
}) : historialVotaciones = historialVotaciones ?? []; }) : pistaImpostor = pistaImpostor ?? categoriaReal,
historialVotaciones = historialVotaciones ?? [];
/// Nombres de los impostores, para que cada impostor sepa quiénes son sus
/// compañeros cuando la partida lo permite.
List<String> get nombresImpostores =>
jugadores.where((j) => j.esImpostor).map((j) => j.nombre).toList();
List<Jugador> get jugadoresActivos => List<Jugador> get jugadoresActivos =>
jugadores.where((j) => !j.eliminado).toList(); jugadores.where((j) => !j.eliminado).toList();
+37
View File
@@ -154,10 +154,24 @@ class EstadoSalaMultijugador {
} }
ResultadoOperacionSala registrarCliente(ClienteSala cliente) { ResultadoOperacionSala registrarCliente(ClienteSala cliente) {
final existente = clientes[cliente.clientId];
if (existente != null) {
// Reconexión: el clientId es estable, el endpointId no. Conservamos lo
// que ya sabíamos del cliente y solo refrescamos por dónde se le habla.
clientes[cliente.clientId] = existente.copiar(
endpointId: cliente.endpointId,
nombre: cliente.nombre,
conectado: true,
);
return const ResultadoOperacionSala.ok();
}
clientes[cliente.clientId] = cliente; clientes[cliente.clientId] = cliente;
return const ResultadoOperacionSala.ok(); return const ResultadoOperacionSala.ok();
} }
/// True si ese cliente ya estuvo en la sala y vuelve tras una caída.
bool esReconexion(String clientId) => clientes.containsKey(clientId);
ResultadoOperacionSala crearUsuario(Usuario usuario) { ResultadoOperacionSala crearUsuario(Usuario usuario) {
if (fase != FaseSalaMultijugador.lobby) { if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada'); return const ResultadoOperacionSala.error('sala_cerrada');
@@ -274,6 +288,8 @@ class EstadoSalaMultijugador {
if (entry.value.clienteIdSeleccionado == clientIdOrigen) { if (entry.value.clienteIdSeleccionado == clientIdOrigen) {
usuarios[entry.key] = entry.value.copiar( usuarios[entry.key] = entry.value.copiar(
clienteIdSeleccionado: clientIdDestino, clienteIdSeleccionado: clientIdDestino,
// Se recuerda de quién eran para poder devolvérselos si vuelve.
absorbidoDe: entry.value.absorbidoDe ?? clientIdOrigen,
); );
reasignados++; reasignados++;
} }
@@ -281,6 +297,27 @@ class EstadoSalaMultijugador {
return reasignados; return reasignados;
} }
/// Usuarios que el host absorbió de un cliente concreto.
List<Usuario> usuariosAbsorbidosDe(String clientId) => usuarios.values
.where((usuario) => usuario.absorbidoDe == clientId)
.toList();
/// Devuelve a su dueño original los usuarios que el host había absorbido.
/// Se usa cuando ese dispositivo se reconecta.
int devolverUsuariosAbsorbidos(String clientId) {
if (!clientes.containsKey(clientId)) return 0;
var devueltos = 0;
for (final entry in usuarios.entries.toList()) {
if (entry.value.absorbidoDe != clientId) continue;
usuarios[entry.key] = entry.value.copiar(
clienteIdSeleccionado: clientId,
limpiarAbsorbidoDe: true,
);
devueltos++;
}
return devueltos;
}
ResultadoOperacionSala validarInicio() { ResultadoOperacionSala validarInicio() {
if (fase != FaseSalaMultijugador.lobby) { if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada'); return const ResultadoOperacionSala.error('sala_cerrada');
+18 -3
View File
@@ -14,6 +14,11 @@ class SnapshotPartidaOnline {
final List<String> impostores; final List<String> impostores;
final String? mensaje; final String? mensaje;
/// Whether impostor roles may travel over the wire. While the game is running
/// the host must never broadcast who the impostors are: every client would be
/// able to read it straight from the payload.
final bool revelarImpostores;
const SnapshotPartidaOnline({ const SnapshotPartidaOnline({
required this.roomId, required this.roomId,
required this.fase, required this.fase,
@@ -26,6 +31,7 @@ class SnapshotPartidaOnline {
this.historialVotaciones = const [], this.historialVotaciones = const [],
this.impostores = const [], this.impostores = const [],
this.mensaje, this.mensaje,
this.revelarImpostores = false,
}); });
factory SnapshotPartidaOnline.desdePartida( factory SnapshotPartidaOnline.desdePartida(
@@ -57,6 +63,7 @@ class SnapshotPartidaOnline {
.toList() .toList()
: const [], : const [],
mensaje: mensaje, mensaje: mensaje,
revelarImpostores: revelarImpostores,
); );
} }
@@ -67,7 +74,9 @@ class SnapshotPartidaOnline {
'categoria': categoria, 'categoria': categoria,
if (palabraSecreta != null) 'palabraSecreta': palabraSecreta, if (palabraSecreta != null) 'palabraSecreta': palabraSecreta,
if (ganador != null) 'ganador': ganador, if (ganador != null) 'ganador': ganador,
'jugadoresTodos': jugadores.map(_jugadorToJson).toList(), 'jugadoresTodos': jugadores
.map((jugador) => _jugadorToJson(jugador, revelarImpostores))
.toList(),
if (resultadoActual != null) if (resultadoActual != null)
'resultadoActual': _resultadoToJson(resultadoActual!), 'resultadoActual': _resultadoToJson(resultadoActual!),
'historialVotaciones': 'historialVotaciones':
@@ -101,13 +110,19 @@ class SnapshotPartidaOnline {
.map((nombre) => nombre.toString()) .map((nombre) => nombre.toString())
.toList(), .toList(),
mensaje: json['mensaje'] as String?, mensaje: json['mensaje'] as String?,
revelarImpostores: jugadoresData.any(
(data) => (data as Map<String, dynamic>).containsKey('esImpostor'),
),
); );
} }
static Map<String, dynamic> _jugadorToJson(Jugador jugador) => { static Map<String, dynamic> _jugadorToJson(
Jugador jugador,
bool revelarImpostores,
) => {
'id': jugador.id, 'id': jugador.id,
'nombre': jugador.nombre, 'nombre': jugador.nombre,
'esImpostor': jugador.esImpostor, if (revelarImpostores) 'esImpostor': jugador.esImpostor,
'eliminado': jugador.eliminado, 'eliminado': jugador.eliminado,
}; };
+12
View File
@@ -7,6 +7,10 @@ class Usuario {
final String? foto; final String? foto;
final String? creadoPorClienteId; final String? creadoPorClienteId;
final String? clienteIdSeleccionado; final String? clienteIdSeleccionado;
/// Cliente que controlaba a este usuario antes de que el host lo absorbiera
/// por desconexión. Permite devolvérselo si ese dispositivo vuelve.
final String? absorbidoDe;
final int fuego; final int fuego;
final List<String> medallas; final List<String> medallas;
@@ -18,6 +22,7 @@ class Usuario {
this.foto, this.foto,
this.creadoPorClienteId, this.creadoPorClienteId,
this.clienteIdSeleccionado, this.clienteIdSeleccionado,
this.absorbidoDe,
this.fuego = 0, this.fuego = 0,
this.medallas = const [], this.medallas = const [],
}); });
@@ -33,9 +38,11 @@ class Usuario {
String? foto, String? foto,
String? creadoPorClienteId, String? creadoPorClienteId,
String? clienteIdSeleccionado, String? clienteIdSeleccionado,
String? absorbidoDe,
int? fuego, int? fuego,
List<String>? medallas, List<String>? medallas,
bool liberarSeleccion = false, bool liberarSeleccion = false,
bool limpiarAbsorbidoDe = false,
}) { }) {
return Usuario( return Usuario(
id: id ?? this.id, id: id ?? this.id,
@@ -47,6 +54,9 @@ class Usuario {
clienteIdSeleccionado: liberarSeleccion clienteIdSeleccionado: liberarSeleccion
? null ? null
: (clienteIdSeleccionado ?? this.clienteIdSeleccionado), : (clienteIdSeleccionado ?? this.clienteIdSeleccionado),
absorbidoDe: limpiarAbsorbidoDe
? null
: (absorbidoDe ?? this.absorbidoDe),
fuego: fuego ?? this.fuego, fuego: fuego ?? this.fuego,
medallas: medallas ?? this.medallas, medallas: medallas ?? this.medallas,
); );
@@ -61,6 +71,7 @@ class Usuario {
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId, if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
if (clienteIdSeleccionado != null) if (clienteIdSeleccionado != null)
'clienteIdSeleccionado': clienteIdSeleccionado, 'clienteIdSeleccionado': clienteIdSeleccionado,
if (absorbidoDe != null) 'absorbidoDe': absorbidoDe,
if (fuego > 0) 'fuego': fuego, if (fuego > 0) 'fuego': fuego,
if (medallas.isNotEmpty) 'medallas': medallas, if (medallas.isNotEmpty) 'medallas': medallas,
}; };
@@ -73,6 +84,7 @@ class Usuario {
foto: json['foto'] as String?, foto: json['foto'] as String?,
creadoPorClienteId: json['creadoPorClienteId'] as String?, creadoPorClienteId: json['creadoPorClienteId'] as String?,
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?, clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
absorbidoDe: json['absorbidoDe'] as String?,
fuego: (json['fuego'] as num?)?.toInt() ?? 0, fuego: (json['fuego'] as num?)?.toInt() ?? 0,
medallas: (json['medallas'] as List<dynamic>? ?? const []) medallas: (json['medallas'] as List<dynamic>? ?? const [])
.map((valor) => valor.toString()) .map((valor) => valor.toString())
+38 -3
View File
@@ -34,6 +34,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
String _categoria = 'todas'; String _categoria = 'todas';
int _numImpostores = 1; int _numImpostores = 1;
bool _pistaImpostor = false; bool _pistaImpostor = false;
bool _impostoresSeConocen = true;
int? _tiempoDebate; int? _tiempoDebate;
final List<String> _jugadores = []; final List<String> _jugadores = [];
final _controladorNombre = TextEditingController(); final _controladorNombre = TextEditingController();
@@ -65,8 +66,11 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
}); });
} }
int get _maxImpostores => /// En multidispositivo el número real de jugadores se conoce en el lobby, así
_modoMultimovil ? 4 : (_jugadores.length / 3).floor().clamp(1, 4); /// que aquí se permite el tope global; al iniciar se ajusta y se avisa.
int get _maxImpostores => _modoMultimovil
? 4
: EstadoJuego.maxImpostoresPara(_jugadores.length);
List<String> _etiquetasTiempo(AppLocalizations l10n) => [ List<String> _etiquetasTiempo(AppLocalizations l10n) => [
l10n.noLimit, l10n.noLimit,
@@ -139,6 +143,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
numImpostores: _numImpostores, numImpostores: _numImpostores,
pistaImpostor: _pistaImpostor, pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate, tiempoDebateSegundos: _tiempoDebate,
impostoresSeConocen: _impostoresSeConocen,
), ),
nombresJugadores: _jugadores, nombresJugadores: _jugadores,
); );
@@ -228,11 +233,23 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
numImpostores: _numImpostores, numImpostores: _numImpostores,
pistaImpostor: _pistaImpostor, pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate, tiempoDebateSegundos: _tiempoDebate,
impostoresSeConocen: _impostoresSeConocen,
), ),
sala: sala, sala: sala,
); );
final partida = estado.partida!; final partida = estado.partida!;
// El tope de impostores depende de cuántos jugadores hay, y eso
// solo se sabe aquí. Si se recorta, hay que decirlo.
if (partida.impostoresTotales < _numImpostores) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.impostorsAdjusted(partida.impostoresTotales),
),
),
);
}
final asignaciones = partida.jugadores.map((jugador) { final asignaciones = partida.jugadores.map((jugador) {
final usuarioSala = sala.usuarios[jugador.id]; final usuarioSala = sala.usuarios[jugador.id];
final clientId = usuarioSala?.clienteIdSeleccionado; final clientId = usuarioSala?.clienteIdSeleccionado;
@@ -261,9 +278,12 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
nearby.enviarInicioPartidaMulti( nearby.enviarInicioPartidaMulti(
asignaciones: asignaciones, asignaciones: asignaciones,
palabraSecreta: partida.palabraSecreta, palabraSecreta: partida.palabraSecreta,
categoria: _categoria, categoria: partida.categoriaReal,
impostoresPorJugadorId: impostores, impostoresPorJugadorId: impostores,
jugadoresTodos: jugadoresTodos, jugadoresTodos: jugadoresTodos,
impostoresSeConocen: _impostoresSeConocen,
// La pista solo sale del host si la partida la tiene activada.
pistaImpostor: _pistaImpostor ? partida.pistaImpostor : null,
); );
Navigator.pushReplacement( Navigator.pushReplacement(
@@ -596,6 +616,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
// Los impostores se reconocen entre ellos
SwitchListTile(
title: Text(l10n.impostorsKnowEachOther),
subtitle: Text(
_numImpostores > 1
? l10n.impostorsKnowEachOtherDescription
: l10n.impostorsKnowEachOtherNeedsTwo,
),
value: _impostoresSeConocen,
onChanged: _numImpostores > 1
? (v) => setState(() => _impostoresSeConocen = v)
: null,
contentPadding: EdgeInsets.zero,
),
// Temporizador // Temporizador
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
+4 -4
View File
@@ -15,7 +15,7 @@ class PantallaDebateCliente extends StatefulWidget {
final int? tiempoDebateSegundos; final int? tiempoDebateSegundos;
final String? primerTurnoNombre; final String? primerTurnoNombre;
final String? partidaId; final String? partidaId;
final String? pistaCategoria; final String? pistaImpostor;
final List<Jugador> jugadores; final List<Jugador> jugadores;
final List<JugadorInicioPartida> jugadoresControlados; final List<JugadorInicioPartida> jugadoresControlados;
final VoidCallback onSolicitarVotacion; final VoidCallback onSolicitarVotacion;
@@ -25,7 +25,7 @@ class PantallaDebateCliente extends StatefulWidget {
this.tiempoDebateSegundos, this.tiempoDebateSegundos,
this.primerTurnoNombre, this.primerTurnoNombre,
this.partidaId, this.partidaId,
this.pistaCategoria, this.pistaImpostor,
this.jugadores = const [], this.jugadores = const [],
this.jugadoresControlados = const [], this.jugadoresControlados = const [],
required this.onSolicitarVotacion, required this.onSolicitarVotacion,
@@ -55,7 +55,7 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
jugadores: widget.jugadores, jugadores: widget.jugadores,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
partidaId: widget.partidaId, partidaId: widget.partidaId,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
onVotos: _enviarVotos, onVotos: _enviarVotos,
), ),
), ),
@@ -133,7 +133,7 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
: () => mostrarRevisionPalabraOnline( : () => mostrarRevisionPalabraOnline(
context: context, context: context,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
), ),
), ),
IconButton( IconButton(
@@ -21,13 +21,13 @@ import 'pantalla_revision_palabra.dart';
class PantallaFinPartidaOnline extends StatefulWidget { class PantallaFinPartidaOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot; final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados; final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria; final String? pistaImpostor;
const PantallaFinPartidaOnline({ const PantallaFinPartidaOnline({
super.key, super.key,
required this.snapshot, required this.snapshot,
required this.jugadoresControlados, required this.jugadoresControlados,
this.pistaCategoria, this.pistaImpostor,
}); });
@override @override
@@ -202,7 +202,7 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
: () => mostrarRevisionPalabraOnline( : () => mostrarRevisionPalabraOnline(
context: context, context: context,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
), ),
), ),
IconButton( IconButton(
+142 -44
View File
@@ -9,6 +9,7 @@ import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
@@ -40,6 +41,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
String? _primerTurnoNombre; String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {}; final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {}; final Map<String, String> _votosRecibidos = {};
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@override @override
void initState() { void initState() {
@@ -65,28 +68,118 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
void _registrarListeners() { void _registrarListeners() {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) { _nearby = nearby;
_listenerMensajes = (endpointId, mensaje) {
if (!mounted) return;
if (mensaje.tipo == TipoMensaje.listo) { if (mensaje.tipo == TipoMensaje.listo) {
setState(() => _clientesListos[endpointId] = true); // Se indexa por clientId, no por endpointId: el endpoint cambia en
// cada reconexión y si no perderíamos el "ya la he visto".
final clientId = _clientIdDe(endpointId) ?? endpointId;
setState(() => _clientesListos[clientId] = true);
} else if (mensaje.tipo == TipoMensaje.unirse) {
// Con la partida ya en marcha, un `unirse` solo puede ser un móvil
// que vuelve tras caerse.
final nombre = mensaje.datos['nombre'] as String?;
if (nombre != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.playerRejoined(nombre)),
),
);
}
} else if (mensaje.tipo == TipoMensaje.solicitarResync) {
_responderResync(endpointId);
} else if (mensaje.tipo == TipoMensaje.voto) { } else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?; final votanteId = mensaje.datos['votanteId'] as String?;
final votoId = final votoId =
mensaje.datos['votadoId'] as String? ?? mensaje.datos['votadoId'] as String? ??
mensaje.datos['votoporId'] as String?; mensaje.datos['votoporId'] as String?;
if (votanteId != null && votoId != null) { if (votanteId == null || votoId == null) return;
context.read<EstadoJuego>().registrarVoto(votanteId, votoId); // Un jugador eliminado ya no vota, venga de donde venga el mensaje.
setState(() => _votosRecibidos[votanteId] = votoId); final partida = context.read<EstadoJuego>().partida;
} final sigueVivo =
partida?.jugadoresActivos.any((j) => j.id == votanteId) ?? false;
if (!sigueVivo) return;
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId);
} }
}); };
nearby.onMensaje(_listenerMensajes!);
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
super.dispose(); super.dispose();
} }
String? _clientIdDe(String endpointId) {
return context
.read<ServicioNearby>()
.estadoSala
?.clientePorEndpoint(endpointId)
?.clientId;
}
/// Jugadores de la partida que controla un cliente concreto, con su palabra,
/// su rol y sus compañeros. Es lo que necesita un móvil para volver a jugar.
List<JugadorInicioPartida> _jugadoresDeCliente(
Partida partida,
EstadoSalaMultijugador sala,
String clientId,
) {
final estado = context.read<EstadoJuego>();
return sala
.usuariosPorCliente(clientId)
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
.map((usuario) {
final jugador = partida.jugadores.firstWhere(
(j) => j.id == usuario.id,
);
return JugadorInicioPartida(
jugadorId: jugador.id,
nombre: jugador.nombre,
esImpostor: jugador.esImpostor,
palabra: jugador.esImpostor ? null : partida.palabraSecreta,
companerosImpostores: estado.companerosImpostoresDe(jugador.id),
);
})
.toList();
}
/// Responde a un móvil que vuelve tras una caída con el estado completo:
/// en qué fase va la partida y qué jugadores le tocan.
Future<void> _responderResync(String endpointId) async {
final nearby = context.read<ServicioNearby>();
final estado = context.read<EstadoJuego>();
final partida = estado.partida;
final sala = nearby.estadoSala;
if (partida == null || sala == null) return;
final clientId = _clientIdDe(endpointId);
if (clientId == null) return;
final datos = _snapshot(fase: partida.fase.name).toJson();
datos['fase'] = partida.fase.name;
datos['jugadores'] = _jugadoresDeCliente(partida, sala, clientId)
.map((jugador) => jugador.toJson())
.toList();
if (partida.config.pistaImpostor) {
datos['pistaImpostor'] = partida.pistaImpostor;
}
if (partida.config.tiempoDebateSegundos != null) {
datos['tiempoDebateSegundos'] = partida.config.tiempoDebateSegundos;
}
if (_primerTurnoNombre != null) {
datos['primerTurnoNombre'] = _primerTurnoNombre;
}
await nearby.enviarResync(endpointId, datos);
if (mounted) setState(() {});
}
String _formatearTiempo(int segundos) { String _formatearTiempo(int segundos) {
final min = segundos ~/ 60; final min = segundos ~/ 60;
final seg = segundos % 60; final seg = segundos % 60;
@@ -152,8 +245,16 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
// Solo bloquean los clientes que siguen conectados: un móvil caído no
// puede impedir que avance la partida.
final clientesPendientes =
nearby.estadoSala?.clientes.values
.where((cliente) => !cliente.esHost && cliente.conectado)
.map((cliente) => cliente.clientId) ??
const <String>[];
final todosListos = final todosListos =
_hostListo && _clientesListos.length >= nearby.jugadores.length; _hostListo &&
clientesPendientes.every((id) => _clientesListos[id] == true);
final todosVotaron = estado.todosHanVotado(); final todosVotaron = estado.todosHanVotado();
return Scaffold( return Scaffold(
@@ -172,8 +273,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
partida, partida,
nearby, nearby,
), ),
pistaCategoria: partida.config.pistaImpostor pistaImpostor: partida.config.pistaImpostor
? partida.categoriaReal ? partida.pistaImpostor
: null, : null,
), ),
), ),
@@ -287,7 +388,16 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
texto: AppLocalizations.of(context)!.assumeOnThisPhone, texto: AppLocalizations.of(context)!.assumeOnThisPhone,
icono: Icons.person_add_alt_1, icono: Icons.person_add_alt_1,
assetIconPath: 'assets/ui/generated/actions/action_add_player.webp', assetIconPath: 'assets/ui/generated/actions/action_add_player.webp',
onPressed: () => nearby.asumirUsuariosDesconectados(), onPressed: () async {
// Son jugadores nuevos para este móvil: sus palabras están
// sin ver y sus votos sin emitir.
final fase = context.read<EstadoJuego>().partida?.fase;
await nearby.asumirUsuariosDesconectados();
if (!mounted) return;
setState(() {
if (fase == FaseJuego.verPalabra) _hostListo = false;
});
},
), ),
), ),
), ),
@@ -422,7 +532,9 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
(jugador) => _buildJugadorTile( (jugador) => _buildJugadorTile(
jugador.nombre, jugador.nombre,
false, false,
_clientesListos[jugador.endpointId] ?? false, _clientesListos[_clientIdDe(jugador.endpointId) ??
jugador.endpointId] ??
false,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -451,22 +563,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
) { ) {
final sala = nearby.estadoSala; final sala = nearby.estadoSala;
if (sala == null) return const []; if (sala == null) return const [];
return _jugadoresDeCliente(partida, sala, sala.hostClientId);
return sala
.usuariosPorCliente(sala.hostClientId)
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
.map((usuario) {
final jugador = partida.jugadores.firstWhere(
(j) => j.id == usuario.id,
);
return JugadorInicioPartida(
jugadorId: jugador.id,
nombre: jugador.nombre,
esImpostor: jugador.esImpostor,
palabra: jugador.palabra ?? partida.palabraSecreta,
);
})
.toList();
} }
void _mostrarPalabraHost(BuildContext context) { void _mostrarPalabraHost(BuildContext context) {
@@ -483,8 +580,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente( builder: (_) => PantallaPalabrasCliente(
jugadores: jugadoresHost, jugadores: jugadoresHost,
pistaCategoria: partida.config.pistaImpostor pistaImpostor: partida.config.pistaImpostor
? partida.categoriaReal ? partida.pistaImpostor
: null, : null,
onTodosVistos: () { onTodosVistos: () {
setState(() => _hostListo = true); setState(() => _hostListo = true);
@@ -508,7 +605,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
esImpostor: hostLocal.esImpostor, esImpostor: hostLocal.esImpostor,
palabra: partida.palabraSecreta, palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor, pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal, pista: partida.pistaImpostor,
companerosImpostores: estado.companerosImpostoresDe(hostLocal.id),
onVisto: () => setState(() => _hostListo = true), onVisto: () => setState(() => _hostListo = true),
), ),
), ),
@@ -767,8 +865,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
jugadores: partida.jugadoresActivos, jugadores: partida.jugadoresActivos,
jugadoresControlados: jugadoresHost, jugadoresControlados: jugadoresHost,
partidaId: context.read<ServicioNearby>().roomId, partidaId: context.read<ServicioNearby>().roomId,
pistaCategoria: partida.config.pistaImpostor pistaImpostor: partida.config.pistaImpostor
? partida.categoriaReal ? partida.pistaImpostor
: null, : null,
onVotos: (votos) { onVotos: (votos) {
for (final entry in votos.entries) { for (final entry in votos.entries) {
@@ -1029,7 +1127,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
final bool esImpostor; final bool esImpostor;
final String palabra; final String palabra;
final bool pistaActiva; final bool pistaActiva;
final String categoria; final String pista;
final List<String>? companerosImpostores;
final VoidCallback onVisto; final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({ const _PantallaRevelarPalabraHost({
@@ -1037,7 +1136,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
required this.esImpostor, required this.esImpostor,
required this.palabra, required this.palabra,
required this.pistaActiva, required this.pistaActiva,
required this.categoria, required this.pista,
required this.companerosImpostores,
required this.onVisto, required this.onVisto,
}); });
@@ -1120,15 +1220,13 @@ class _PantallaRevelarPalabraHostState
], ],
if (widget.esImpostor && widget.pistaActiva) ...[ if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( PistaImpostorFarolero(pista: widget.pista),
l10n.clueCategory( ],
BancoPalabras.nombreBonitoCategoria( if (widget.esImpostor &&
widget.categoria, widget.companerosImpostores != null) ...[
l10n, const SizedBox(height: 12),
), CompanerosImpostorFarolero(
), nombres: widget.companerosImpostores!,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja),
), ),
], ],
], ],
+4 -4
View File
@@ -10,14 +10,14 @@ import 'package:farolero/tema/tema_app.dart';
class PantallaPalabraCliente extends StatefulWidget { class PantallaPalabraCliente extends StatefulWidget {
final String palabra; final String palabra;
final bool esImpostor; final bool esImpostor;
final String? pistaCategoria; final String? pistaImpostor;
final VoidCallback onVisto; final VoidCallback onVisto;
const PantallaPalabraCliente({ const PantallaPalabraCliente({
super.key, super.key,
required this.palabra, required this.palabra,
required this.esImpostor, required this.esImpostor,
this.pistaCategoria, this.pistaImpostor,
required this.onVisto, required this.onVisto,
}); });
@@ -124,7 +124,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Pista para impostores // Pista para impostores
if (widget.esImpostor && widget.pistaCategoria != null) ...[ if (widget.esImpostor && widget.pistaImpostor != null) ...[
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -138,7 +138,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
'\u{1F3AD} ${l10n.clueIs(widget.pistaCategoria!)}', '\u{1F3AD} ${l10n.clueIs(widget.pistaImpostor!)}',
style: const TextStyle(color: TemaApp.colorAcento), style: const TextStyle(color: TemaApp.colorAcento),
), ),
), ),
+11 -7
View File
@@ -7,13 +7,13 @@ import 'package:farolero/tema/tema_app.dart';
/// Reveal secuencial para clientes que manejan uno o varios jugadores. /// Reveal secuencial para clientes que manejan uno o varios jugadores.
class PantallaPalabrasCliente extends StatefulWidget { class PantallaPalabrasCliente extends StatefulWidget {
final List<JugadorInicioPartida> jugadores; final List<JugadorInicioPartida> jugadores;
final String? pistaCategoria; final String? pistaImpostor;
final VoidCallback onTodosVistos; final VoidCallback onTodosVistos;
const PantallaPalabrasCliente({ const PantallaPalabrasCliente({
super.key, super.key,
required this.jugadores, required this.jugadores,
this.pistaCategoria, this.pistaImpostor,
required this.onTodosVistos, required this.onTodosVistos,
}); });
@@ -113,12 +113,16 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
), ),
), ),
), ),
if (_visible && actual.esImpostor && widget.pistaCategoria != null) ...[ if (_visible && actual.esImpostor && widget.pistaImpostor != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( PistaImpostorFarolero(pista: widget.pistaImpostor!),
l10n.clueIs(widget.pistaCategoria!), ],
style: const TextStyle(color: TemaApp.colorNaranja), if (_visible &&
textAlign: TextAlign.center, actual.esImpostor &&
actual.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: actual.companerosImpostores!,
), ),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
+6 -6
View File
@@ -16,13 +16,13 @@ import 'package:provider/provider.dart';
class PantallaResultadoOnline extends StatefulWidget { class PantallaResultadoOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot; final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados; final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria; final String? pistaImpostor;
const PantallaResultadoOnline({ const PantallaResultadoOnline({
super.key, super.key,
required this.snapshot, required this.snapshot,
required this.jugadoresControlados, required this.jugadoresControlados,
this.pistaCategoria, this.pistaImpostor,
}); });
@override @override
@@ -86,7 +86,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?, tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?,
primerTurnoNombre: datos['primerTurnoNombre'] as String?, primerTurnoNombre: datos['primerTurnoNombre'] as String?,
partidaId: snapshot.roomId, partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
jugadores: snapshot.jugadores, jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
onSolicitarVotacion: _solicitarVotacion, onSolicitarVotacion: _solicitarVotacion,
@@ -103,7 +103,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
jugadores: snapshot.jugadores, jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
partidaId: snapshot.roomId, partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
onVotos: _enviarVotos, onVotos: _enviarVotos,
), ),
), ),
@@ -117,7 +117,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
builder: (_) => PantallaFinPartidaOnline( builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot, snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
), ),
), ),
); );
@@ -197,7 +197,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
: () => mostrarRevisionPalabraOnline( : () => mostrarRevisionPalabraOnline(
context: context, context: context,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
), ),
), ),
IconButton( IconButton(
+12 -9
View File
@@ -7,7 +7,7 @@ import 'package:farolero/tema/tema_app.dart';
Future<void> mostrarRevisionPalabraOnline({ Future<void> mostrarRevisionPalabraOnline({
required BuildContext context, required BuildContext context,
required List<JugadorInicioPartida> jugadoresControlados, required List<JugadorInicioPartida> jugadoresControlados,
String? pistaCategoria, String? pistaImpostor,
}) async { }) async {
if (jugadoresControlados.isEmpty) return; if (jugadoresControlados.isEmpty) return;
@@ -45,18 +45,18 @@ Future<void> mostrarRevisionPalabraOnline({
context: context, context: context,
builder: (dialogContext) => _DialogoRevisionPalabra( builder: (dialogContext) => _DialogoRevisionPalabra(
jugador: jugador, jugador: jugador,
pistaCategoria: pistaCategoria, pistaImpostor: pistaImpostor,
), ),
); );
} }
class _DialogoRevisionPalabra extends StatelessWidget { class _DialogoRevisionPalabra extends StatelessWidget {
final JugadorInicioPartida jugador; final JugadorInicioPartida jugador;
final String? pistaCategoria; final String? pistaImpostor;
const _DialogoRevisionPalabra({ const _DialogoRevisionPalabra({
required this.jugador, required this.jugador,
required this.pistaCategoria, required this.pistaImpostor,
}); });
@override @override
@@ -106,12 +106,15 @@ class _DialogoRevisionPalabra extends StatelessWidget {
) )
else else
TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''), TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''),
if (jugador.esImpostor && pistaCategoria != null) ...[ if (jugador.esImpostor && pistaImpostor != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( PistaImpostorFarolero(pista: pistaImpostor!),
l10n.clueIs(pistaCategoria!), ],
style: const TextStyle(color: TemaApp.colorNaranja), if (jugador.esImpostor &&
textAlign: TextAlign.center, jugador.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: jugador.companerosImpostores!,
), ),
], ],
], ],
+118 -10
View File
@@ -3,6 +3,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
@@ -41,10 +42,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// Estado del juego recibido del host // Estado del juego recibido del host
String? _palabraRecibida; String? _palabraRecibida;
bool _esImpostor = false; bool _esImpostor = false;
String? _pistaCategoria; String? _pistaImpostor;
String? _partidaId; String? _partidaId;
final List<Jugador> _jugadores = []; final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = []; final List<JugadorInicioPartida> _jugadoresControlados = [];
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@override @override
void initState() { void initState() {
@@ -61,7 +64,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
void _registrarListenerPartida() { void _registrarListenerPartida() {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) { _nearby = nearby;
_listenerMensajes = (endpointId, mensaje) {
if (!mounted) return; if (!mounted) return;
if (mensaje.tipo == TipoMensaje.partidaInicio) { if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra // El host ha iniciado la partida — nos ha enviado nuestra palabra
@@ -105,7 +109,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
); );
} }
} }
_pistaCategoria = mensaje.datos['categoria'] as String?; // La pista solo llega si la partida la tiene activada; la categoría
// no vale como pista porque puede ser 'todas'.
_pistaImpostor = mensaje.datos['pistaImpostor'] as String?;
_partidaId = (mensaje.datos['roomId'] as String?) ?? _partidaId = (mensaje.datos['roomId'] as String?) ??
nearby.roomId ?? nearby.roomId ??
(mensaje.datos['clientId'] as String?) ?? (mensaje.datos['clientId'] as String?) ??
@@ -115,6 +121,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) { if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra(); _navegarAPalabra();
} }
} else if (mensaje.tipo == TipoMensaje.resync) {
_aplicarResync(mensaje.datos);
} else if (mensaje.tipo == TipoMensaje.fase) { } else if (mensaje.tipo == TipoMensaje.fase) {
final fase = mensaje.datos['fase'] as String?; final fase = mensaje.datos['fase'] as String?;
_actualizarSnapshotSiExiste(mensaje.datos); _actualizarSnapshotSiExiste(mensaje.datos);
@@ -128,7 +136,43 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_actualizarSnapshotSiExiste(mensaje.datos); _actualizarSnapshotSiExiste(mensaje.datos);
if (mounted) _navegarFinPartida(mensaje.datos); if (mounted) _navegarFinPartida(mensaje.datos);
} }
};
nearby.onMensaje(_listenerMensajes!);
}
/// Reincorpora este móvil a una partida ya empezada tras una caída.
///
/// El host manda el estado completo, así que se reconstruye todo —jugadores
/// controlados, pista, censo— antes de saltar a la pantalla de la fase en
/// curso. Se descartan las rutas viejas: la pila de antes ya no vale.
void _aplicarResync(Map<String, dynamic> datos) {
final controlados = (datos['jugadores'] as List<dynamic>? ?? const [])
.map((json) => JugadorInicioPartida.fromJson(
json as Map<String, dynamic>,
))
.toList();
setState(() {
_jugadoresControlados
..clear()
..addAll(controlados);
_pistaImpostor = datos['pistaImpostor'] as String? ?? _pistaImpostor;
_partidaId = (datos['roomId'] as String?) ??
_partidaId ??
context.read<ServicioNearby>().roomId;
}); });
_actualizarSnapshotSiExiste(datos);
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
final fase = datos['fase'] as String?;
if (fase == null || fase == FaseJuego.verPalabra.name) {
// Aún no ha empezado el debate: que vuelva a ver su palabra.
if (_jugadoresControlados.isNotEmpty) _navegarAPalabra();
return;
}
_navegarSegunFase(fase, datos);
} }
void _navegarAPalabra() { void _navegarAPalabra() {
@@ -137,7 +181,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente( builder: (_) => PantallaPalabrasCliente(
jugadores: List.unmodifiable(_jugadoresControlados), jugadores: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
onTodosVistos: () { onTodosVistos: () {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { if (nearby.hostEndpointId != null) {
@@ -159,7 +203,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaPalabraCliente( builder: (_) => PantallaPalabraCliente(
palabra: _palabraRecibida ?? '', palabra: _palabraRecibida ?? '',
esImpostor: _esImpostor, esImpostor: _esImpostor,
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
onVisto: () { onVisto: () {
// Enviar "listo" al host y volver a la espera // Enviar "listo" al host y volver a la espera
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
@@ -190,7 +234,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_partidaId = (datos['roomId'] as String?) ?? _partidaId = (datos['roomId'] as String?) ??
_partidaId ?? _partidaId ??
context.read<ServicioNearby>().roomId; context.read<ServicioNearby>().roomId;
_pistaCategoria = (datos['categoria'] as String?) ?? _pistaCategoria;
}); });
} }
@@ -206,7 +250,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
primerTurnoNombre: primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?, datosFase?['primerTurnoNombre'] as String?,
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId, partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
jugadores: List.unmodifiable(_jugadores), jugadores: List.unmodifiable(_jugadores),
jugadoresControlados: List.unmodifiable(_jugadoresControlados), jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onSolicitarVotacion: () { onSolicitarVotacion: () {
@@ -232,7 +276,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
jugadores: _jugadores, jugadores: _jugadores,
jugadoresControlados: List.unmodifiable(_jugadoresControlados), jugadoresControlados: List.unmodifiable(_jugadoresControlados),
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId, partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
onVotos: (votos) { onVotos: (votos) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { if (nearby.hostEndpointId != null) {
@@ -273,7 +317,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaResultadoOnline( builder: (_) => PantallaResultadoOnline(
snapshot: snapshot, snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados), jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
), ),
), ),
); );
@@ -287,13 +331,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaFinPartidaOnline( builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot, snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados), jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria, pistaImpostor: _pistaImpostor,
), ),
), ),
); );
} }
@override @override
void dispose() { void dispose() {
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
_nombreController.dispose(); _nombreController.dispose();
super.dispose(); super.dispose();
} }
@@ -419,6 +465,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>(); final nearby = context.watch<ServicioNearby>();
if (nearby.reconectando && !nearby.conectado) {
return _buildReconectando(context, l10n, nearby);
}
// Si estamos conectados → pantalla de espera // Si estamos conectados → pantalla de espera
if (nearby.conectado && !nearby.esHost) { if (nearby.conectado && !nearby.esHost) {
return _buildPantallaEspera(context, l10n); return _buildPantallaEspera(context, l10n);
@@ -704,6 +754,64 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
); );
} }
// ==================== RECONEXIÓN ====================
Widget _buildReconectando(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
return Scaffold(
appBar: AppBar(
title: Text(_salaSeleccionada ?? l10n.joinGameTitle),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: l10n.leaveGame,
icon: IconoFarolero(Icons.close),
onPressed: () async {
await nearby.desconectar();
if (!context.mounted) return;
setState(() {
_buscando = false;
_conectando = false;
});
},
),
],
),
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _JoinLobbySignalArt(height: 180),
const SizedBox(height: 16),
EncabezadoFarolero(
icono: Icons.wifi_tethering_off,
titulo: l10n.reconnecting,
subtitulo: l10n.reconnectingHint,
color: TemaApp.colorNaranja,
trailing: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: TemaApp.colorNaranja,
),
),
),
],
),
),
),
),
);
}
// ==================== ESPERA ==================== // ==================== ESPERA ====================
Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) { Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) {
+13 -8
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../tema/componentes_farolero.dart'; import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_debate.dart'; import 'pantalla_debate.dart';
@@ -104,7 +103,8 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
esImpostor: jugador.esImpostor, esImpostor: jugador.esImpostor,
palabra: partida.palabraSecreta, palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor, pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal, pista: partida.pistaImpostor,
companerosImpostores: estado.companerosImpostoresDe(jugador.id),
onVisto: () { onVisto: () {
setState(() => _hanVisto.add(jugadorId)); setState(() => _hanVisto.add(jugadorId));
}, },
@@ -119,7 +119,8 @@ class _PantallaRevelarPalabra extends StatefulWidget {
final bool esImpostor; final bool esImpostor;
final String palabra; final String palabra;
final bool pistaActiva; final bool pistaActiva;
final String categoria; final String pista;
final List<String>? companerosImpostores;
final VoidCallback onVisto; final VoidCallback onVisto;
const _PantallaRevelarPalabra({ const _PantallaRevelarPalabra({
@@ -127,7 +128,8 @@ class _PantallaRevelarPalabra extends StatefulWidget {
required this.esImpostor, required this.esImpostor,
required this.palabra, required this.palabra,
required this.pistaActiva, required this.pistaActiva,
required this.categoria, required this.pista,
required this.companerosImpostores,
required this.onVisto, required this.onVisto,
}); });
@@ -191,10 +193,13 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
], ],
if (widget.esImpostor && widget.pistaActiva) ...[ if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( PistaImpostorFarolero(pista: widget.pista),
l10n.clueCategory(BancoPalabras.nombreBonitoCategoria(widget.categoria, l10n)), ],
textAlign: TextAlign.center, if (widget.esImpostor &&
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja), widget.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: widget.companerosImpostores!,
), ),
], ],
], ],
+40 -80
View File
@@ -2,15 +2,10 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart'; import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart'; import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/modelos/partida.dart';
import 'package:farolero/modelos/snapshot_partida_online.dart';
import 'package:farolero/pantallas/pantalla_notas_online.dart'; import 'package:farolero/pantallas/pantalla_notas_online.dart';
import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; import 'package:farolero/pantallas/pantalla_revision_palabra.dart';
import 'package:farolero/pantallas/pantalla_resultado_online.dart';
import 'package:farolero/servicios/servicio_nearby.dart';
import 'package:farolero/tema/componentes_farolero.dart'; import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart'; import 'package:farolero/tema/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla de votación para cliente multidispositivo. /// Pantalla de votación para cliente multidispositivo.
/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto /// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
@@ -19,7 +14,7 @@ class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores; final List<Jugador> jugadores;
final List<JugadorInicioPartida> jugadoresControlados; final List<JugadorInicioPartida> jugadoresControlados;
final String? partidaId; final String? partidaId;
final String? pistaCategoria; final String? pistaImpostor;
final Function(Map<String, String> votos) onVotos; final Function(Map<String, String> votos) onVotos;
const PantallaVotacionCliente({ const PantallaVotacionCliente({
@@ -27,7 +22,7 @@ class PantallaVotacionCliente extends StatefulWidget {
required this.jugadores, required this.jugadores,
this.jugadoresControlados = const [], this.jugadoresControlados = const [],
this.partidaId, this.partidaId,
this.pistaCategoria, this.pistaImpostor,
required this.onVotos, required this.onVotos,
}); });
@@ -37,75 +32,31 @@ class PantallaVotacionCliente extends StatefulWidget {
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> { class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
final Map<String, String> _votosPorVotante = {}; final Map<String, String> _votosPorVotante = {};
OnMensajeCallback? _listener;
ServicioNearby? _nearby;
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados; /// Solo los jugadores vivos pueden ser votados.
List<Jugador> get _votables =>
widget.jugadores.where((jugador) => !jugador.eliminado).toList();
/// Y solo los jugadores vivos que controla este dispositivo pueden votar.
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados
.where(
(controlado) =>
_votables.any((jugador) => jugador.id == controlado.jugadorId),
)
.toList();
/// Protocolo antiguo: el cliente no recibió jugadores controlados y emite un
/// único voto sin identificar al votante.
bool get _modoLegacy => widget.jugadoresControlados.isEmpty;
/// Todos los jugadores de este dispositivo están eliminados: no vota nadie.
bool get _sinVotantesVivos => !_modoLegacy && _votantes.isEmpty;
bool get _votacionCompleta { bool get _votacionCompleta {
if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy'); if (_sinVotantesVivos) return false;
return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null); if (_modoLegacy) return _votosPorVotante.containsKey('_legacy');
} return _votantes.every(
(votante) => _votosPorVotante[votante.jugadorId] != null,
@override );
void initState() {
super.initState();
_listener = (endpointId, mensaje) {
if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return;
if (mensaje.datos.containsKey('jugadoresTodos')) {
final snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
),
),
);
} else {
final votosRaw = mensaje.datos['votos'] as Map<dynamic, dynamic>? ?? {};
final snapshot = SnapshotPartidaOnline(
roomId: widget.partidaId,
fase: 'resultado',
ronda: 1,
categoria: '',
jugadores: widget.jugadores,
resultadoActual: ResultadoVotacion(
eliminadoId: mensaje.datos['eliminadoId'] as String? ?? '',
eliminadoNombre: mensaje.datos['eliminadoNombre'] as String? ?? '?',
eraImpostor: mensaje.datos['eraImpostor'] as bool? ?? false,
votos: votosRaw.map(
(key, value) => MapEntry(key.toString(), value.toString()),
),
),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
),
),
);
}
};
WidgetsBinding.instance.addPostFrameCallback((_) {
final listener = _listener;
if (listener != null && mounted) {
_nearby = context.read<ServicioNearby>();
_nearby!.onMensaje(listener);
}
});
}
@override
void dispose() {
final listener = _listener;
if (listener != null) {
_nearby?.removeMensajeListener(listener);
}
super.dispose();
} }
@override @override
@@ -128,7 +79,7 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
: () => mostrarRevisionPalabraOnline( : () => mostrarRevisionPalabraOnline(
context: context, context: context,
jugadoresControlados: widget.jugadoresControlados, jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria, pistaImpostor: widget.pistaImpostor,
), ),
), ),
IconButton( IconButton(
@@ -172,7 +123,15 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: _votantes.isEmpty child: _sinVotantesVivos
? Center(
child: EstadoVacioFarolero(
icono: Icons.hourglass_empty,
titulo: l10n.eliminatedCannotVote,
subtitulo: l10n.waitingVoting,
),
)
: _modoLegacy
? _buildSelectorLegacy() ? _buildSelectorLegacy()
: ListView.builder( : ListView.builder(
itemCount: _votantes.length, itemCount: _votantes.length,
@@ -200,15 +159,16 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
bool get _puedeAbrirNotas { bool get _puedeAbrirNotas {
return widget.partidaId != null && return widget.partidaId != null &&
widget.jugadores.isNotEmpty && _votables.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty; widget.jugadoresControlados.isNotEmpty;
} }
Widget _buildSelectorLegacy() { Widget _buildSelectorLegacy() {
final votables = _votables;
return ListView.builder( return ListView.builder(
itemCount: widget.jugadores.length, itemCount: votables.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final jugador = widget.jugadores[index]; final jugador = votables[index];
final selected = _votosPorVotante['_legacy'] == jugador.id; final selected = _votosPorVotante['_legacy'] == jugador.id;
return _buildJugadorVotable( return _buildJugadorVotable(
jugador: jugador, jugador: jugador,
@@ -236,7 +196,7 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...widget.jugadores.asMap().entries.map((entry) { ..._votables.asMap().entries.map((entry) {
final jugador = entry.value; final jugador = entry.value;
final selected = _votosPorVotante[votante.jugadorId] == jugador.id; final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
return _buildJugadorVotable( return _buildJugadorVotable(
+47
View File
@@ -0,0 +1,47 @@
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
/// Identificador estable de este dispositivo.
///
/// Nearby Connections asigna un `endpointId` nuevo en cada conexión, así que no
/// sirve para reconocer a un móvil que se reconecta. Este id se guarda en disco
/// y sobrevive a caídas de conexión, cierres de la app y reinicios.
class IdentidadDispositivo {
static const _clave = 'dispositivo.id';
static String? _cache;
/// Devuelve el id del dispositivo, creándolo la primera vez.
static Future<String> obtener() async {
final cacheado = _cache;
if (cacheado != null) return cacheado;
final prefs = await SharedPreferences.getInstance();
final guardado = prefs.getString(_clave);
if (guardado != null && guardado.isNotEmpty) {
_cache = guardado;
return guardado;
}
final nuevo = _generar();
await prefs.setString(_clave, nuevo);
_cache = nuevo;
return nuevo;
}
/// Id ya cargado en memoria, si existe. Útil donde no se puede esperar.
static String? get cacheado => _cache;
static String _generar() {
final rng = Random.secure();
final bytes = List<int>.generate(8, (_) => rng.nextInt(256));
final hex = bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return 'dev-$hex';
}
/// Solo para pruebas: fija el id en memoria sin tocar disco.
static void fijarParaPruebas(String? id) => _cache = id;
}
+184 -12
View File
@@ -5,6 +5,7 @@ import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/sala_multijugador.dart'; import '../modelos/sala_multijugador.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
import 'identidad_dispositivo.dart';
/// Tipos de mensajes en el protocolo P2P. /// Tipos de mensajes en el protocolo P2P.
enum TipoMensaje { enum TipoMensaje {
@@ -26,6 +27,10 @@ enum TipoMensaje {
eliminarUsuario, eliminarUsuario,
errorOperacion, errorOperacion,
usuarioNuevo, usuarioNuevo,
// Reconexión: el cliente pide el estado de la partida en curso y el host se
// lo devuelve completo.
solicitarResync,
resync,
// Compatibilidad con versiones previas del protocolo. // Compatibilidad con versiones previas del protocolo.
usuarioEliminado, usuarioEliminado,
usuariosActualizados, usuariosActualizados,
@@ -94,6 +99,17 @@ class ServicioNearby extends ChangeNotifier {
final Map<String, Usuario> _usuariosPool = {}; final Map<String, Usuario> _usuariosPool = {};
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
String? _miDeviceId;
bool _reconectando = false;
bool _partidaEnCursoAlEntrar = false;
bool _cerrando = false;
String? _nombreHostConectado;
Timer? _limiteReconexion;
/// Cuánto se insiste en volver antes de rendirse. Sin tope, un móvil cuyo
/// host se ha ido se quedaría escaneando y gastando batería para siempre.
static const _ventanaReconexion = Duration(minutes: 3);
String? _palabraRecibida; String? _palabraRecibida;
bool? _soyImpostor; bool? _soyImpostor;
String? _faseActual; String? _faseActual;
@@ -102,6 +118,13 @@ class ServicioNearby extends ChangeNotifier {
bool get esHost => _esHost; bool get esHost => _esHost;
bool get conectado => _conectado; bool get conectado => _conectado;
/// El cliente perdió la conexión y está intentando volver por su cuenta.
bool get reconectando => _reconectando;
/// Al registrarse, el host indicó que ya había una partida empezada.
bool get partidaEnCursoAlEntrar => _partidaEnCursoAlEntrar;
String? get miDeviceId => _miDeviceId;
bool get buscando => _buscando; bool get buscando => _buscando;
bool get anunciando => _anunciando; bool get anunciando => _anunciando;
String? get miEndpointId => _miEndpointId; String? get miEndpointId => _miEndpointId;
@@ -231,6 +254,7 @@ class ServicioNearby extends ChangeNotifier {
_miNombre = miNombre; _miNombre = miNombre;
_roomId = DateTime.now().microsecondsSinceEpoch.toString(); _roomId = DateTime.now().microsecondsSinceEpoch.toString();
_miClientId = _hostClientId; _miClientId = _hostClientId;
_miDeviceId = await IdentidadDispositivo.obtener();
_estadoSala = EstadoSalaMultijugador.crear( _estadoSala = EstadoSalaMultijugador.crear(
roomId: _roomId!, roomId: _roomId!,
nombreSala: nombreSala, nombreSala: nombreSala,
@@ -295,6 +319,7 @@ class ServicioNearby extends ChangeNotifier {
_miAvatar = miAvatar; _miAvatar = miAvatar;
_miFuego = miFuego; _miFuego = miFuego;
_miMedallas = miMedallas; _miMedallas = miMedallas;
_miDeviceId ??= await IdentidadDispositivo.obtener();
try { try {
final resultado = await Nearby().startDiscovery( final resultado = await Nearby().startDiscovery(
@@ -330,6 +355,8 @@ class ServicioNearby extends ChangeNotifier {
_miAvatar = miAvatar; _miAvatar = miAvatar;
_miFuego = miFuego; _miFuego = miFuego;
_miMedallas = miMedallas; _miMedallas = miMedallas;
_miDeviceId ??= await IdentidadDispositivo.obtener();
_nombreHostConectado = _hostsEncontrados[endpointId];
try { try {
await Nearby().requestConnection( await Nearby().requestConnection(
miNombre, miNombre,
@@ -364,6 +391,10 @@ class ServicioNearby extends ChangeNotifier {
} else { } else {
_hostEndpointId = endpointId; _hostEndpointId = endpointId;
_conectado = true; _conectado = true;
_reconectando = false;
_buscando = false;
_limiteReconexion?.cancel();
_limiteReconexion = null;
_iniciarHeartbeatCliente(); _iniciarHeartbeatCliente();
enviarMensaje( enviarMensaje(
endpointId, endpointId,
@@ -375,6 +406,7 @@ class ServicioNearby extends ChangeNotifier {
if (_miAvatar != null) 'avatar': _miAvatar, if (_miAvatar != null) 'avatar': _miAvatar,
'fuego': _miFuego, 'fuego': _miFuego,
'medallas': _miMedallas, 'medallas': _miMedallas,
if (_miDeviceId != null) 'deviceId': _miDeviceId,
}, },
), ),
); );
@@ -406,10 +438,49 @@ class ServicioNearby extends ChangeNotifier {
_conectado = false; _conectado = false;
_hostEndpointId = null; _hostEndpointId = null;
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
// Conservamos clientId y deviceId: son la llave para que el host nos
// reconozca cuando volvamos.
_iniciarReconexionCliente();
} }
notifyListeners(); notifyListeners();
} }
/// Relanza el descubrimiento tras una caída para volver a la misma sala.
Future<void> _iniciarReconexionCliente() async {
if (_esHost || _cerrando || _miNombre == null) return;
_reconectando = true;
_limiteReconexion?.cancel();
_limiteReconexion = Timer(_ventanaReconexion, cancelarReconexion);
notifyListeners();
try {
await Nearby().stopDiscovery();
} catch (_) {}
// Entre el await y aquí el usuario puede haber salido.
if (_cerrando || !_reconectando) return;
try {
_buscando = await Nearby().startDiscovery(
_miNombre!,
Strategy.P2P_STAR,
onEndpointFound: _onEndpointEncontrado,
onEndpointLost: _onEndpointPerdido,
serviceId: _serviceId,
);
} catch (e) {
debugPrint('Error reiniciando descubrimiento: $e');
}
notifyListeners();
}
/// Corta el reintento automático (por ejemplo si el usuario sale a menú).
Future<void> cancelarReconexion() async {
_limiteReconexion?.cancel();
_limiteReconexion = null;
if (!_reconectando) return;
_reconectando = false;
await pararBusqueda();
}
void _iniciarHeartbeatCliente() { void _iniciarHeartbeatCliente() {
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
@@ -436,9 +507,30 @@ class ServicioNearby extends ChangeNotifier {
) { ) {
debugPrint('Host encontrado: $endpointName ($endpointId)'); debugPrint('Host encontrado: $endpointName ($endpointId)');
_hostsEncontrados[endpointId] = endpointName; _hostsEncontrados[endpointId] = endpointName;
// Volvemos solos, pero solo a nuestro host: si hay otra partida cerca no
// queremos aterrizar en la sala equivocada.
final esNuestroHost =
_nombreHostConectado == null || _nombreHostConectado == endpointName;
if (_reconectando && !_conectado && esNuestroHost) {
_reconectarA(endpointId);
}
notifyListeners(); notifyListeners();
} }
Future<void> _reconectarA(String endpointId) async {
try {
await Nearby().requestConnection(
_miNombre ?? 'Jugador',
endpointId,
onConnectionInitiated: _onConexionIniciada,
onConnectionResult: _onResultadoConexion,
onDisconnected: _onDesconexion,
);
} catch (e) {
debugPrint('Error reconectando a $endpointId: $e');
}
}
void _onEndpointPerdido(String? endpointId) { void _onEndpointPerdido(String? endpointId) {
debugPrint('Endpoint perdido: $endpointId'); debugPrint('Endpoint perdido: $endpointId');
if (endpointId != null) { if (endpointId != null) {
@@ -494,7 +586,8 @@ class ServicioNearby extends ChangeNotifier {
_registrarClienteRemoto(endpointId, mensaje); _registrarClienteRemoto(endpointId, mensaje);
break; break;
case TipoMensaje.voto: case TipoMensaje.voto:
_notificarMensaje(endpointId, mensaje); // El reparto a los listeners lo hace _procesarMensaje al final; hacerlo
// aquí también entregaba cada voto dos veces.
break; break;
case TipoMensaje.listo: case TipoMensaje.listo:
final jugador = _jugadores[endpointId]; final jugador = _jugadores[endpointId];
@@ -522,6 +615,9 @@ class ServicioNearby extends ChangeNotifier {
case TipoMensaje.usuariosActualizados: case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje); _handleUsuariosActualizados(mensaje);
break; break;
case TipoMensaje.solicitarResync:
// Lo resuelve la pantalla gestora, que es quien tiene la partida.
break;
default: default:
break; break;
} }
@@ -535,22 +631,45 @@ class ServicioNearby extends ChangeNotifier {
final medallas = (mensaje.datos['medallas'] as List<dynamic>? ?? const []) final medallas = (mensaje.datos['medallas'] as List<dynamic>? ?? const [])
.map((valor) => valor.toString()) .map((valor) => valor.toString())
.toList(); .toList();
final clientId = endpointId;
// El clientId debe sobrevivir a la reconexión, y el endpointId no lo hace:
// Nearby asigna uno nuevo cada vez. Con clientes antiguos que no mandan
// deviceId se cae al comportamiento de siempre.
final deviceId = mensaje.datos['deviceId'] as String?;
final clientId = deviceId ?? endpointId;
final sala = _estadoSala;
final esReconexion = sala?.esReconexion(clientId) ?? false;
// Si el mismo dispositivo tenía otro endpoint abierto, se descarta.
_jugadores.removeWhere(
(id, _) =>
id != endpointId &&
sala?.clientePorEndpoint(id)?.clientId == clientId,
);
_jugadores[endpointId] = JugadorConectado( _jugadores[endpointId] = JugadorConectado(
endpointId: endpointId, endpointId: endpointId,
nombre: nombre, nombre: nombre,
); );
_estadoSala?.registrarCliente( sala?.registrarCliente(
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre), ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
); );
_crearUsuarioAutomaticoCliente(
clientId: clientId, if (esReconexion) {
nombre: nombre, // Vuelve un móvil que se había caído: recupera los jugadores que el host
nick: nick, // le había absorbido mientras tanto.
avatar: avatar, sala?.devolverUsuariosAbsorbidos(clientId);
fuego: fuego, } else {
medallas: medallas, _crearUsuarioAutomaticoCliente(
); clientId: clientId,
nombre: nombre,
nick: nick,
avatar: avatar,
fuego: fuego,
medallas: medallas,
);
}
final partidaEnCurso = sala?.fase == FaseSalaMultijugador.enPartida;
enviarMensaje( enviarMensaje(
endpointId, endpointId,
@@ -560,11 +679,13 @@ class ServicioNearby extends ChangeNotifier {
'clientId': clientId, 'clientId': clientId,
'sala': _nombreSala, 'sala': _nombreSala,
'roomId': _roomId, 'roomId': _roomId,
'reconexion': esReconexion,
'partidaEnCurso': partidaEnCurso,
'jugadores': _jugadores.values 'jugadores': _jugadores.values
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId}) .map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
.toList(), .toList(),
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(), 'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
if (_estadoSala != null) 'estadoSala': _estadoSala!.toJson(), if (sala != null) 'estadoSala': sala.toJson(),
}, },
), ),
); );
@@ -736,6 +857,20 @@ class ServicioNearby extends ChangeNotifier {
if (estadoSalaJson != null) { if (estadoSalaJson != null) {
_sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson)); _sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson));
} }
_partidaEnCursoAlEntrar =
mensaje.datos['partidaEnCurso'] as bool? ?? false;
if (_partidaEnCursoAlEntrar) {
// Entramos con la partida ya empezada: pedimos el estado completo en
// vez de quedarnos esperando en el lobby.
solicitarResync();
}
notifyListeners();
break;
case TipoMensaje.resync:
_faseActual = mensaje.datos['fase'] as String?;
_datosPartida = mensaje.datos;
final pista = mensaje.datos['pistaImpostor'] as String?;
if (pista != null) _datosPartida!['pistaImpostor'] = pista;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.estadoSala: case TipoMensaje.estadoSala:
@@ -944,12 +1079,15 @@ class ServicioNearby extends ChangeNotifier {
required String categoria, required String categoria,
required Map<String, bool> impostoresPorJugadorId, required Map<String, bool> impostoresPorJugadorId,
required List<Map<String, dynamic>> jugadoresTodos, required List<Map<String, dynamic>> jugadoresTodos,
bool impostoresSeConocen = false,
String? pistaImpostor,
}) async { }) async {
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones, asignaciones: asignaciones,
palabraSecreta: palabraSecreta, palabraSecreta: palabraSecreta,
categoria: categoria, categoria: categoria,
impostoresPorJugadorId: impostoresPorJugadorId, impostoresPorJugadorId: impostoresPorJugadorId,
impostoresSeConocen: impostoresSeConocen,
); );
for (final payload in payloads.values) { for (final payload in payloads.values) {
@@ -958,6 +1096,8 @@ class ServicioNearby extends ChangeNotifier {
final datos = payload.toJson(); final datos = payload.toJson();
datos['jugadoresTodos'] = jugadoresTodos; datos['jugadoresTodos'] = jugadoresTodos;
datos['roomId'] = _roomId; datos['roomId'] = _roomId;
// Solo viaja si la partida tiene la pista activada.
if (pistaImpostor != null) datos['pistaImpostor'] = pistaImpostor;
await enviarMensaje( await enviarMensaje(
endpointId, endpointId,
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos), MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
@@ -965,6 +1105,30 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// El cliente pide al host el estado completo de la partida en curso.
Future<void> solicitarResync() async {
final hostId = _hostEndpointId;
if (_esHost || hostId == null) return;
await enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.solicitarResync,
datos: {if (_miClientId != null) 'clientId': _miClientId},
),
);
}
/// El host responde a un cliente concreto con el estado completo.
Future<void> enviarResync(
String endpointId,
Map<String, dynamic> datos,
) async {
await enviarMensaje(
endpointId,
MensajeP2P(tipo: TipoMensaje.resync, datos: datos),
);
}
Future<void> enviarCambioFase( Future<void> enviarCambioFase(
String fase, [ String fase, [
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
@@ -988,7 +1152,10 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ==================== // ==================== LIMPIEZA ====================
Future<void> desconectar() async { Future<void> desconectar() async {
_cerrando = true;
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
_limiteReconexion?.cancel();
_limiteReconexion = null;
try { try {
await Nearby().stopAllEndpoints(); await Nearby().stopAllEndpoints();
if (_anunciando) await Nearby().stopAdvertising(); if (_anunciando) await Nearby().stopAdvertising();
@@ -1020,6 +1187,10 @@ class ServicioNearby extends ChangeNotifier {
_hostsEncontrados.clear(); _hostsEncontrados.clear();
_usuariosPool.clear(); _usuariosPool.clear();
_heartbeatTimer = null; _heartbeatTimer = null;
_reconectando = false;
_partidaEnCursoAlEntrar = false;
_nombreHostConectado = null;
_cerrando = false;
notifyListeners(); notifyListeners();
} }
@@ -1045,6 +1216,7 @@ class ServicioNearby extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
_limiteReconexion?.cancel();
desconectar(); desconectar();
super.dispose(); super.dispose();
} }
+81
View File
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../l10n/generated/app_localizations.dart';
import '../modelos/gamificacion_usuario.dart'; import '../modelos/gamificacion_usuario.dart';
import 'tema_app.dart'; import 'tema_app.dart';
@@ -1072,6 +1073,86 @@ class TarjetaPalabraFarolero extends StatelessWidget {
} }
} }
/// Pista que ve el impostor cuando la partida la tiene activada.
class PistaImpostorFarolero extends StatelessWidget {
final String pista;
const PistaImpostorFarolero({super.key, required this.pista});
@override
Widget build(BuildContext context) {
return Text(
AppLocalizations.of(context)!.clueIs(pista),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja),
);
}
}
/// Panel con el resto de impostores. Solo se muestra a un impostor y solo
/// cuando la partida permite que se conozcan entre ellos.
class CompanerosImpostorFarolero extends StatelessWidget {
final List<String> nombres;
const CompanerosImpostorFarolero({super.key, required this.nombres});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (nombres.isEmpty) {
return Text(
l10n.youAreTheOnlyImpostor,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorTextoSecundario),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorAcento.withValues(alpha: 0.16),
borderColor: TemaApp.colorAcento.withValues(alpha: 0.65),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconoFarolero(
Icons.groups,
color: TemaApp.colorAcento,
size: 20,
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.otherImpostorsTitle(nombres.length),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 6),
Text(
nombres.join(' · '),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorAcento,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class AvatarFarolero extends StatelessWidget { class AvatarFarolero extends StatelessWidget {
final String texto; final String texto;
final String? assetPath; final String? assetPath;
+156
View File
@@ -0,0 +1,156 @@
import 'package:farolero/modelos/sala_multijugador.dart';
import 'package:farolero/modelos/usuario.dart';
import 'package:flutter_test/flutter_test.dart';
EstadoSalaMultijugador _salaConCliente() {
final sala = EstadoSalaMultijugador.crear(
roomId: 'r1',
nombreSala: 'Sala',
hostClientId: 'host',
hostNombre: 'Ana',
);
sala.registrarCliente(
const ClienteSala(
clientId: 'dev-beto',
endpointId: 'ep-1',
nombre: 'Beto',
),
);
sala.usuarios['u-beto'] = Usuario(
id: 'u-beto',
nombre: 'Beto',
creadoPorClienteId: 'dev-beto',
clienteIdSeleccionado: 'dev-beto',
);
return sala;
}
void main() {
group('Reconexión de un cliente', () {
test('el mismo clientId con endpoint nuevo no crea un cliente duplicado', () {
final sala = _salaConCliente();
sala.registrarCliente(
const ClienteSala(
clientId: 'dev-beto',
endpointId: 'ep-2',
nombre: 'Beto',
),
);
expect(sala.clientes.length, 2, reason: 'host + Beto, sin duplicados');
expect(sala.clientes['dev-beto']!.endpointId, 'ep-2');
expect(sala.clientes['dev-beto']!.conectado, isTrue);
});
test('esReconexion distingue a quien vuelve de quien llega nuevo', () {
final sala = _salaConCliente();
expect(sala.esReconexion('dev-beto'), isTrue);
expect(sala.esReconexion('dev-cris'), isFalse);
});
test('en partida, desconectarse no libera a sus jugadores', () {
final sala = _salaConCliente();
sala.fase = FaseSalaMultijugador.enPartida;
sala.desconectarCliente('dev-beto');
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto');
expect(sala.clientes['dev-beto']!.conectado, isFalse);
});
test('en lobby, desconectarse sí libera a sus jugadores', () {
final sala = _salaConCliente();
sala.desconectarCliente('dev-beto');
expect(sala.usuarios['u-beto']!.estaDisponible, isTrue);
});
});
group('Absorción por el host y devolución', () {
test('el host absorbe y queda constancia de quién era el dueño', () {
final sala = _salaConCliente();
sala.fase = FaseSalaMultijugador.enPartida;
sala.desconectarCliente('dev-beto');
final reasignados = sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'dev-beto',
clientIdDestino: 'host',
);
expect(reasignados, 1);
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'host');
expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
expect(sala.usuariosAbsorbidosDe('dev-beto').single.id, 'u-beto');
});
test('al volver el móvil recupera a sus jugadores', () {
final sala = _salaConCliente();
sala.fase = FaseSalaMultijugador.enPartida;
sala.desconectarCliente('dev-beto');
sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'dev-beto',
clientIdDestino: 'host',
);
sala.registrarCliente(
const ClienteSala(
clientId: 'dev-beto',
endpointId: 'ep-2',
nombre: 'Beto',
),
);
final devueltos = sala.devolverUsuariosAbsorbidos('dev-beto');
expect(devueltos, 1);
expect(sala.usuarios['u-beto']!.clienteIdSeleccionado, 'dev-beto');
expect(sala.usuarios['u-beto']!.absorbidoDe, isNull);
expect(sala.usuariosPorCliente('host'), isEmpty);
});
test('una doble absorción no pierde al dueño original', () {
final sala = _salaConCliente();
sala.fase = FaseSalaMultijugador.enPartida;
sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'dev-beto',
clientIdDestino: 'host',
);
// El host vuelve a pasar por el mismo camino: no debe reescribir el
// origen a 'host' y dejar al usuario huérfano.
sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'host',
clientIdDestino: 'host',
);
expect(sala.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
});
test('no se devuelve nada a un cliente que no está en la sala', () {
final sala = _salaConCliente();
sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'dev-beto',
clientIdDestino: 'host',
);
expect(sala.devolverUsuariosAbsorbidos('dev-fantasma'), 0);
});
});
group('Serialización', () {
test('absorbidoDe sobrevive al viaje JSON', () {
final sala = _salaConCliente();
sala.fase = FaseSalaMultijugador.enPartida;
sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'dev-beto',
clientIdDestino: 'host',
);
final reparsed = EstadoSalaMultijugador.fromJson(sala.toJson());
expect(reparsed.usuarios['u-beto']!.absorbidoDe, 'dev-beto');
expect(reparsed.clientes['dev-beto']!.endpointId, 'ep-1');
});
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:farolero/estado/estado_juego.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/modelos/palabra.dart';
import 'package:farolero/modelos/partida.dart';
import 'package:farolero/modelos/snapshot_partida_online.dart';
import 'package:flutter_test/flutter_test.dart';
Partida _partidaConImpostores() => Partida(
config: const ConfigPartida(numImpostores: 2),
jugadores: [
Jugador(id: 'j1', nombre: 'Ana', esImpostor: true),
Jugador(id: 'j2', nombre: 'Beto'),
Jugador(id: 'j3', nombre: 'Cris', esImpostor: true),
Jugador(id: 'j4', nombre: 'Dani'),
],
palabraSecreta: 'Camión',
categoriaReal: 'objetos',
);
void main() {
group('SnapshotPartidaOnline no filtra los roles', () {
test('durante la partida no envía esImpostor de nadie', () {
final json = SnapshotPartidaOnline.desdePartida(
_partidaConImpostores(),
fase: 'debate',
).toJson();
final jugadores = json['jugadoresTodos'] as List<dynamic>;
for (final jugador in jugadores) {
expect(
(jugador as Map<String, dynamic>).containsKey('esImpostor'),
isFalse,
reason: 'el rol no puede viajar en el snapshot de fase',
);
}
expect(json.containsKey('impostores'), isFalse);
expect(json.containsKey('palabraSecreta'), isFalse);
});
test('al final de la partida sí revela roles y palabra', () {
final json = SnapshotPartidaOnline.desdePartida(
_partidaConImpostores(),
fase: 'finPartida',
revelarImpostores: true,
revelarPalabra: true,
).toJson();
final jugadores = (json['jugadoresTodos'] as List<dynamic>)
.cast<Map<String, dynamic>>();
expect(jugadores.every((j) => j.containsKey('esImpostor')), isTrue);
expect(json['impostores'], ['Ana', 'Cris']);
expect(json['palabraSecreta'], 'Camión');
});
test('el snapshot de fase se reparsea sin marcar impostores', () {
final snapshot = SnapshotPartidaOnline.fromJson(
SnapshotPartidaOnline.desdePartida(
_partidaConImpostores(),
fase: 'votacion',
).toJson(),
);
expect(snapshot.jugadores.any((j) => j.esImpostor), isFalse);
expect(snapshot.revelarImpostores, isFalse);
});
});
group('Los impostores se conocen entre ellos', () {
final asignaciones = const [
AsignacionJugador(
jugadorId: 'j1',
nombre: 'Ana',
clientId: 'host',
endpointId: null,
),
AsignacionJugador(
jugadorId: 'j2',
nombre: 'Beto',
clientId: 'c2',
endpointId: 'e2',
),
AsignacionJugador(
jugadorId: 'j3',
nombre: 'Cris',
clientId: 'c3',
endpointId: 'e3',
),
];
const impostores = {'j1': true, 'j2': false, 'j3': true};
test('cada impostor recibe los nombres del resto, nunca el suyo', () {
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones,
palabraSecreta: 'Camión',
categoria: 'objetos',
impostoresPorJugadorId: impostores,
impostoresSeConocen: true,
);
expect(payloads['host']!.jugadores.single.companerosImpostores, ['Cris']);
expect(payloads['c3']!.jugadores.single.companerosImpostores, ['Ana']);
});
test('un jugador normal nunca recibe la lista', () {
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones,
palabraSecreta: 'Camión',
categoria: 'objetos',
impostoresPorJugadorId: impostores,
impostoresSeConocen: true,
);
expect(payloads['c2']!.jugadores.single.companerosImpostores, isNull);
});
test('con la opción desactivada nadie recibe la lista', () {
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones,
palabraSecreta: 'Camión',
categoria: 'objetos',
impostoresPorJugadorId: impostores,
impostoresSeConocen: false,
);
for (final payload in payloads.values) {
for (final jugador in payload.jugadores) {
expect(jugador.companerosImpostores, isNull);
}
}
});
test('la lista vacía sobrevive al viaje JSON y no se vuelve null', () {
const jugador = JugadorInicioPartida(
jugadorId: 'j1',
nombre: 'Ana',
esImpostor: true,
palabra: null,
companerosImpostores: [],
);
final reparsed = JugadorInicioPartida.fromJson(jugador.toJson());
expect(reparsed.companerosImpostores, isEmpty);
expect(reparsed.companerosImpostores, isNotNull);
});
});
group('Pistas del banco de palabras', () {
test('prioriza la pista de la palabra sobre la de la categoría', () {
final banco = BancoPalabras(
{
'objetos': ['Camión', 'Silla'],
},
pistasPorCategoria: {'objetos': 'Objetos'},
pistasPorPalabra: {'Camión': 'Se conduce y transporta carga'},
);
expect(banco.pistaDePalabra('Camión'), 'Se conduce y transporta carga');
expect(banco.pistaDePalabra('Silla'), 'Objetos');
});
test('sin pista de palabra ni de categoría devuelve null', () {
final banco = BancoPalabras({
'objetos': ['Silla'],
});
expect(banco.pistaDePalabra('Silla'), isNull);
});
});
group('Tope de impostores', () {
test('es el mismo en ambos modos de juego', () {
expect(EstadoJuego.maxImpostoresPara(3), 1);
expect(EstadoJuego.maxImpostoresPara(5), 1);
expect(EstadoJuego.maxImpostoresPara(6), 2);
expect(EstadoJuego.maxImpostoresPara(12), 4);
expect(EstadoJuego.maxImpostoresPara(20), 4);
});
});
group('Partida', () {
test('la pista cae a la categoría cuando no se aporta una específica', () {
final partida = Partida(
config: const ConfigPartida(),
jugadores: [Jugador(id: 'j1', nombre: 'Ana')],
palabraSecreta: 'Camión',
categoriaReal: 'objetos',
);
expect(partida.pistaImpostor, 'objetos');
});
test('nombresImpostores lista solo a los impostores', () {
expect(_partidaConImpostores().nombresImpostores, ['Ana', 'Cris']);
});
});
}