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
+38 -3
View File
@@ -34,6 +34,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
String _categoria = 'todas';
int _numImpostores = 1;
bool _pistaImpostor = false;
bool _impostoresSeConocen = true;
int? _tiempoDebate;
final List<String> _jugadores = [];
final _controladorNombre = TextEditingController();
@@ -65,8 +66,11 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
});
}
int get _maxImpostores =>
_modoMultimovil ? 4 : (_jugadores.length / 3).floor().clamp(1, 4);
/// En multidispositivo el número real de jugadores se conoce en el lobby, así
/// 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) => [
l10n.noLimit,
@@ -139,6 +143,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
numImpostores: _numImpostores,
pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate,
impostoresSeConocen: _impostoresSeConocen,
),
nombresJugadores: _jugadores,
);
@@ -228,11 +233,23 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
numImpostores: _numImpostores,
pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate,
impostoresSeConocen: _impostoresSeConocen,
),
sala: sala,
);
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 usuarioSala = sala.usuarios[jugador.id];
final clientId = usuarioSala?.clienteIdSeleccionado;
@@ -261,9 +278,12 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
nearby.enviarInicioPartidaMulti(
asignaciones: asignaciones,
palabraSecreta: partida.palabraSecreta,
categoria: _categoria,
categoria: partida.categoriaReal,
impostoresPorJugadorId: impostores,
jugadoresTodos: jugadoresTodos,
impostoresSeConocen: _impostoresSeConocen,
// La pista solo sale del host si la partida la tiene activada.
pistaImpostor: _pistaImpostor ? partida.pistaImpostor : null,
);
Navigator.pushReplacement(
@@ -596,6 +616,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
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
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+4 -4
View File
@@ -15,7 +15,7 @@ class PantallaDebateCliente extends StatefulWidget {
final int? tiempoDebateSegundos;
final String? primerTurnoNombre;
final String? partidaId;
final String? pistaCategoria;
final String? pistaImpostor;
final List<Jugador> jugadores;
final List<JugadorInicioPartida> jugadoresControlados;
final VoidCallback onSolicitarVotacion;
@@ -25,7 +25,7 @@ class PantallaDebateCliente extends StatefulWidget {
this.tiempoDebateSegundos,
this.primerTurnoNombre,
this.partidaId,
this.pistaCategoria,
this.pistaImpostor,
this.jugadores = const [],
this.jugadoresControlados = const [],
required this.onSolicitarVotacion,
@@ -55,7 +55,7 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
jugadores: widget.jugadores,
jugadoresControlados: widget.jugadoresControlados,
partidaId: widget.partidaId,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
onVotos: _enviarVotos,
),
),
@@ -133,7 +133,7 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
@@ -21,13 +21,13 @@ import 'pantalla_revision_palabra.dart';
class PantallaFinPartidaOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria;
final String? pistaImpostor;
const PantallaFinPartidaOnline({
super.key,
required this.snapshot,
required this.jugadoresControlados,
this.pistaCategoria,
this.pistaImpostor,
});
@override
@@ -202,7 +202,7 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
+142 -44
View File
@@ -9,6 +9,7 @@ import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/palabra.dart';
import '../modelos/partida.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
@@ -40,6 +41,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {};
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@override
void initState() {
@@ -65,28 +68,118 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
void _registrarListeners() {
final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) {
_nearby = nearby;
_listenerMensajes = (endpointId, mensaje) {
if (!mounted) return;
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) {
final votanteId = mensaje.datos['votanteId'] as String?;
final votoId =
mensaje.datos['votadoId'] as String? ??
mensaje.datos['votoporId'] as String?;
if (votanteId != null && votoId != null) {
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId);
}
if (votanteId == null || votoId == null) return;
// Un jugador eliminado ya no vota, venga de donde venga el mensaje.
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
void dispose() {
_timer?.cancel();
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
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) {
final min = 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 =
_hostListo && _clientesListos.length >= nearby.jugadores.length;
_hostListo &&
clientesPendientes.every((id) => _clientesListos[id] == true);
final todosVotaron = estado.todosHanVotado();
return Scaffold(
@@ -172,8 +273,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
partida,
nearby,
),
pistaCategoria: partida.config.pistaImpostor
? partida.categoriaReal
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
),
),
@@ -287,7 +388,16 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
texto: AppLocalizations.of(context)!.assumeOnThisPhone,
icono: Icons.person_add_alt_1,
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.nombre,
false,
_clientesListos[jugador.endpointId] ?? false,
_clientesListos[_clientIdDe(jugador.endpointId) ??
jugador.endpointId] ??
false,
),
),
const SizedBox(height: 12),
@@ -451,22 +563,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
) {
final sala = nearby.estadoSala;
if (sala == null) return const [];
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();
return _jugadoresDeCliente(partida, sala, sala.hostClientId);
}
void _mostrarPalabraHost(BuildContext context) {
@@ -483,8 +580,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: jugadoresHost,
pistaCategoria: partida.config.pistaImpostor
? partida.categoriaReal
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
onTodosVistos: () {
setState(() => _hostListo = true);
@@ -508,7 +605,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
esImpostor: hostLocal.esImpostor,
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal,
pista: partida.pistaImpostor,
companerosImpostores: estado.companerosImpostoresDe(hostLocal.id),
onVisto: () => setState(() => _hostListo = true),
),
),
@@ -767,8 +865,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
jugadores: partida.jugadoresActivos,
jugadoresControlados: jugadoresHost,
partidaId: context.read<ServicioNearby>().roomId,
pistaCategoria: partida.config.pistaImpostor
? partida.categoriaReal
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
onVotos: (votos) {
for (final entry in votos.entries) {
@@ -1029,7 +1127,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String categoria;
final String pista;
final List<String>? companerosImpostores;
final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({
@@ -1037,7 +1136,8 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.categoria,
required this.pista,
required this.companerosImpostores,
required this.onVisto,
});
@@ -1120,15 +1220,13 @@ class _PantallaRevelarPalabraHostState
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
l10n.clueCategory(
BancoPalabras.nombreBonitoCategoria(
widget.categoria,
l10n,
),
),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja),
PistaImpostorFarolero(pista: widget.pista),
],
if (widget.esImpostor &&
widget.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: widget.companerosImpostores!,
),
],
],
+4 -4
View File
@@ -10,14 +10,14 @@ import 'package:farolero/tema/tema_app.dart';
class PantallaPalabraCliente extends StatefulWidget {
final String palabra;
final bool esImpostor;
final String? pistaCategoria;
final String? pistaImpostor;
final VoidCallback onVisto;
const PantallaPalabraCliente({
super.key,
required this.palabra,
required this.esImpostor,
this.pistaCategoria,
this.pistaImpostor,
required this.onVisto,
});
@@ -124,7 +124,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
const SizedBox(height: 16),
// Pista para impostores
if (widget.esImpostor && widget.pistaCategoria != null) ...[
if (widget.esImpostor && widget.pistaImpostor != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -138,7 +138,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
const SizedBox(width: 8),
Flexible(
child: Text(
'\u{1F3AD} ${l10n.clueIs(widget.pistaCategoria!)}',
'\u{1F3AD} ${l10n.clueIs(widget.pistaImpostor!)}',
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.
class PantallaPalabrasCliente extends StatefulWidget {
final List<JugadorInicioPartida> jugadores;
final String? pistaCategoria;
final String? pistaImpostor;
final VoidCallback onTodosVistos;
const PantallaPalabrasCliente({
super.key,
required this.jugadores,
this.pistaCategoria,
this.pistaImpostor,
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),
Text(
l10n.clueIs(widget.pistaCategoria!),
style: const TextStyle(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
PistaImpostorFarolero(pista: widget.pistaImpostor!),
],
if (_visible &&
actual.esImpostor &&
actual.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: actual.companerosImpostores!,
),
],
const SizedBox(height: 12),
+6 -6
View File
@@ -16,13 +16,13 @@ import 'package:provider/provider.dart';
class PantallaResultadoOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria;
final String? pistaImpostor;
const PantallaResultadoOnline({
super.key,
required this.snapshot,
required this.jugadoresControlados,
this.pistaCategoria,
this.pistaImpostor,
});
@override
@@ -86,7 +86,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?,
primerTurnoNombre: datos['primerTurnoNombre'] as String?,
partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados,
onSolicitarVotacion: _solicitarVotacion,
@@ -103,7 +103,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados,
partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
onVotos: _enviarVotos,
),
),
@@ -117,7 +117,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
),
),
);
@@ -197,7 +197,7 @@ class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
+12 -9
View File
@@ -7,7 +7,7 @@ import 'package:farolero/tema/tema_app.dart';
Future<void> mostrarRevisionPalabraOnline({
required BuildContext context,
required List<JugadorInicioPartida> jugadoresControlados,
String? pistaCategoria,
String? pistaImpostor,
}) async {
if (jugadoresControlados.isEmpty) return;
@@ -45,18 +45,18 @@ Future<void> mostrarRevisionPalabraOnline({
context: context,
builder: (dialogContext) => _DialogoRevisionPalabra(
jugador: jugador,
pistaCategoria: pistaCategoria,
pistaImpostor: pistaImpostor,
),
);
}
class _DialogoRevisionPalabra extends StatelessWidget {
final JugadorInicioPartida jugador;
final String? pistaCategoria;
final String? pistaImpostor;
const _DialogoRevisionPalabra({
required this.jugador,
required this.pistaCategoria,
required this.pistaImpostor,
});
@override
@@ -106,12 +106,15 @@ class _DialogoRevisionPalabra extends StatelessWidget {
)
else
TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''),
if (jugador.esImpostor && pistaCategoria != null) ...[
if (jugador.esImpostor && pistaImpostor != null) ...[
const SizedBox(height: 12),
Text(
l10n.clueIs(pistaCategoria!),
style: const TextStyle(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
PistaImpostorFarolero(pista: pistaImpostor!),
],
if (jugador.esImpostor &&
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:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/snapshot_partida_online.dart';
import '../modelos/usuario.dart';
@@ -41,10 +42,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// Estado del juego recibido del host
String? _palabraRecibida;
bool _esImpostor = false;
String? _pistaCategoria;
String? _pistaImpostor;
String? _partidaId;
final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@override
void initState() {
@@ -61,7 +64,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
void _registrarListenerPartida() {
final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) {
_nearby = nearby;
_listenerMensajes = (endpointId, mensaje) {
if (!mounted) return;
if (mensaje.tipo == TipoMensaje.partidaInicio) {
// 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?) ??
nearby.roomId ??
(mensaje.datos['clientId'] as String?) ??
@@ -115,6 +121,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra();
}
} else if (mensaje.tipo == TipoMensaje.resync) {
_aplicarResync(mensaje.datos);
} else if (mensaje.tipo == TipoMensaje.fase) {
final fase = mensaje.datos['fase'] as String?;
_actualizarSnapshotSiExiste(mensaje.datos);
@@ -128,7 +136,43 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_actualizarSnapshotSiExiste(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() {
@@ -137,7 +181,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
onTodosVistos: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
@@ -159,7 +203,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaPalabraCliente(
palabra: _palabraRecibida ?? '',
esImpostor: _esImpostor,
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
onVisto: () {
// Enviar "listo" al host y volver a la espera
final nearby = context.read<ServicioNearby>();
@@ -190,7 +234,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_partidaId = (datos['roomId'] as String?) ??
_partidaId ??
context.read<ServicioNearby>().roomId;
_pistaCategoria = (datos['categoria'] as String?) ?? _pistaCategoria;
});
}
@@ -206,7 +250,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?,
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
jugadores: List.unmodifiable(_jugadores),
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onSolicitarVotacion: () {
@@ -232,7 +276,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
jugadores: _jugadores,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
onVotos: (votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
@@ -273,7 +317,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
),
),
);
@@ -287,13 +331,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
pistaImpostor: _pistaImpostor,
),
),
);
}
@override
void dispose() {
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
_nombreController.dispose();
super.dispose();
}
@@ -419,6 +465,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>();
if (nearby.reconectando && !nearby.conectado) {
return _buildReconectando(context, l10n, nearby);
}
// Si estamos conectados → pantalla de espera
if (nearby.conectado && !nearby.esHost) {
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 ====================
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:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_debate.dart';
@@ -104,7 +103,8 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
esImpostor: jugador.esImpostor,
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal,
pista: partida.pistaImpostor,
companerosImpostores: estado.companerosImpostoresDe(jugador.id),
onVisto: () {
setState(() => _hanVisto.add(jugadorId));
},
@@ -119,7 +119,8 @@ class _PantallaRevelarPalabra extends StatefulWidget {
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String categoria;
final String pista;
final List<String>? companerosImpostores;
final VoidCallback onVisto;
const _PantallaRevelarPalabra({
@@ -127,7 +128,8 @@ class _PantallaRevelarPalabra extends StatefulWidget {
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.categoria,
required this.pista,
required this.companerosImpostores,
required this.onVisto,
});
@@ -191,10 +193,13 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
l10n.clueCategory(BancoPalabras.nombreBonitoCategoria(widget.categoria, l10n)),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja),
PistaImpostorFarolero(pista: widget.pista),
],
if (widget.esImpostor &&
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/modelos/inicio_partida_multijugador.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_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/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla de votación para cliente multidispositivo.
/// 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<JugadorInicioPartida> jugadoresControlados;
final String? partidaId;
final String? pistaCategoria;
final String? pistaImpostor;
final Function(Map<String, String> votos) onVotos;
const PantallaVotacionCliente({
@@ -27,7 +22,7 @@ class PantallaVotacionCliente extends StatefulWidget {
required this.jugadores,
this.jugadoresControlados = const [],
this.partidaId,
this.pistaCategoria,
this.pistaImpostor,
required this.onVotos,
});
@@ -37,75 +32,31 @@ class PantallaVotacionCliente extends StatefulWidget {
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
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 {
if (_votantes.isEmpty) 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();
if (_sinVotantesVivos) return false;
if (_modoLegacy) return _votosPorVotante.containsKey('_legacy');
return _votantes.every(
(votante) => _votosPorVotante[votante.jugadorId] != null,
);
}
@override
@@ -128,7 +79,7 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
@@ -172,7 +123,15 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
),
const SizedBox(height: 16),
Expanded(
child: _votantes.isEmpty
child: _sinVotantesVivos
? Center(
child: EstadoVacioFarolero(
icono: Icons.hourglass_empty,
titulo: l10n.eliminatedCannotVote,
subtitulo: l10n.waitingVoting,
),
)
: _modoLegacy
? _buildSelectorLegacy()
: ListView.builder(
itemCount: _votantes.length,
@@ -200,15 +159,16 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
bool get _puedeAbrirNotas {
return widget.partidaId != null &&
widget.jugadores.isNotEmpty &&
_votables.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty;
}
Widget _buildSelectorLegacy() {
final votables = _votables;
return ListView.builder(
itemCount: widget.jugadores.length,
itemCount: votables.length,
itemBuilder: (context, index) {
final jugador = widget.jugadores[index];
final jugador = votables[index];
final selected = _votosPorVotante['_legacy'] == jugador.id;
return _buildJugadorVotable(
jugador: jugador,
@@ -236,7 +196,7 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...widget.jugadores.asMap().entries.map((entry) {
..._votables.asMap().entries.map((entry) {
final jugador = entry.value;
final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
return _buildJugadorVotable(