diff --git a/.gitignore b/.gitignore index 16b994b..21adecc 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ build/ .flutter-plugins .flutter-plugins-dependencies .packages + +.atl/ diff --git a/lib/estado/estado_juego.dart b/lib/estado/estado_juego.dart index f766c8e..c3f0817 100644 --- a/lib/estado/estado_juego.dart +++ b/lib/estado/estado_juego.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../modelos/jugador.dart'; import '../modelos/partida.dart'; import '../modelos/palabra.dart'; +import '../modelos/sala_multijugador.dart'; import '../servicios/servicio_notas.dart'; /// Estado global del juego gestionado con Provider @@ -89,6 +90,61 @@ class EstadoJuego extends ChangeNotifier { notifyListeners(); } + /// Crea una partida multi-dispositivo usando los usuarios seleccionados de la + /// sala como jugadores reales. La identidad de jugador se conserva por id y + /// cada jugador queda asociado al endpoint del cliente que lo controla. + void crearPartidaDesdeSala({ + required ConfigPartida config, + required EstadoSalaMultijugador sala, + }) { + if (_banco == null) return; + final usuariosSeleccionados = sala.usuariosSeleccionados; + if (usuariosSeleccionados.length < 3) return; + + final palabra = _banco!.palabraAleatoria(config.categoria); + final categoriaReal = + _banco!.categoriaDepalabra(palabra) ?? config.categoria; + + final jugadores = usuariosSeleccionados.map((usuario) { + final clienteId = usuario.clienteIdSeleccionado; + final endpointId = clienteId == null + ? null + : sala.clientes[clienteId]?.endpointId; + return Jugador( + id: usuario.id, + nombre: usuario.nombre, + endpointId: endpointId, + ); + }).toList(); + + final rng = Random.secure(); + final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3); + final impostoresElegidos = {}; + 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( + config: config, + jugadores: jugadores, + palabraSecreta: palabra, + categoriaReal: categoriaReal, + ); + + _votos.clear(); + ServicioNotas.limpiarNotas(); + notifyListeners(); + } + /// Avanza a la fase de debate void iniciarDebate() { if (_partida == null) return; diff --git a/lib/modelos/inicio_partida_multijugador.dart b/lib/modelos/inicio_partida_multijugador.dart new file mode 100644 index 0000000..f2a2019 --- /dev/null +++ b/lib/modelos/inicio_partida_multijugador.dart @@ -0,0 +1,117 @@ +class AsignacionJugador { + final String jugadorId; + final String nombre; + final String clientId; + final String? endpointId; + + const AsignacionJugador({ + required this.jugadorId, + required this.nombre, + required this.clientId, + required this.endpointId, + }); +} + +class JugadorInicioPartida { + final String jugadorId; + final String nombre; + final bool esImpostor; + final String? palabra; + + const JugadorInicioPartida({ + required this.jugadorId, + required this.nombre, + required this.esImpostor, + required this.palabra, + }); + + Map toJson() => { + 'jugadorId': jugadorId, + 'nombre': nombre, + 'esImpostor': esImpostor, + if (palabra != null) 'palabra': palabra, + }; + + factory JugadorInicioPartida.fromJson(Map json) { + return JugadorInicioPartida( + jugadorId: json['jugadorId'] as String, + nombre: json['nombre'] as String, + esImpostor: json['esImpostor'] as bool? ?? false, + palabra: json['palabra'] as String?, + ); + } +} + +class InicioPartidaCliente { + final String clientId; + final String? endpointId; + final String categoria; + final List jugadores; + + const InicioPartidaCliente({ + required this.clientId, + required this.endpointId, + required this.categoria, + required this.jugadores, + }); + + Map toJson() => { + 'clientId': clientId, + if (endpointId != null) 'endpointId': endpointId, + 'categoria': categoria, + 'jugadores': jugadores.map((jugador) => jugador.toJson()).toList(), + }; + + factory InicioPartidaCliente.fromJson(Map json) { + return InicioPartidaCliente( + clientId: json['clientId'] as String, + endpointId: json['endpointId'] as String?, + categoria: json['categoria'] as String, + jugadores: (json['jugadores'] as List? ?? []) + .map((jugadorJson) => JugadorInicioPartida.fromJson( + jugadorJson as Map, + )) + .toList(), + ); + } +} + +class InicioPartidaMultijugador { + static Map crearPayloadsPorCliente({ + required List asignaciones, + required String palabraSecreta, + required String categoria, + required Map impostoresPorJugadorId, + }) { + final payloads = {}; + + for (final asignacion in asignaciones) { + final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false; + final payloadActual = payloads[asignacion.clientId]; + final jugador = JugadorInicioPartida( + jugadorId: asignacion.jugadorId, + nombre: asignacion.nombre, + esImpostor: esImpostor, + palabra: esImpostor ? null : palabraSecreta, + ); + + if (payloadActual == null) { + payloads[asignacion.clientId] = InicioPartidaCliente( + clientId: asignacion.clientId, + endpointId: asignacion.endpointId, + categoria: categoria, + jugadores: [jugador], + ); + } else { + payloads[asignacion.clientId] = InicioPartidaCliente( + clientId: payloadActual.clientId, + endpointId: payloadActual.endpointId, + categoria: payloadActual.categoria, + jugadores: [...payloadActual.jugadores, jugador], + ); + } + } + + return payloads; + } +} diff --git a/lib/modelos/sala_multijugador.dart b/lib/modelos/sala_multijugador.dart new file mode 100644 index 0000000..caa3bf5 --- /dev/null +++ b/lib/modelos/sala_multijugador.dart @@ -0,0 +1,294 @@ +import 'usuario.dart'; + +enum FaseSalaMultijugador { lobby, enPartida, finalizada } + +class ResultadoOperacionSala { + final bool exitoso; + final String? codigo; + final String? mensaje; + + const ResultadoOperacionSala._({ + required this.exitoso, + this.codigo, + this.mensaje, + }); + + const ResultadoOperacionSala.ok([String? mensaje]) + : this._(exitoso: true, mensaje: mensaje); + + const ResultadoOperacionSala.error(String codigo, [String? mensaje]) + : this._(exitoso: false, codigo: codigo, mensaje: mensaje); + + Map toJson() => { + 'exitoso': exitoso, + if (codigo != null) 'codigo': codigo, + if (mensaje != null) 'mensaje': mensaje, + }; +} + +class ClienteSala { + final String clientId; + final String? endpointId; + final String nombre; + final bool esHost; + final bool conectado; + + const ClienteSala({ + required this.clientId, + this.endpointId, + required this.nombre, + this.esHost = false, + this.conectado = true, + }); + + ClienteSala copiar({ + String? clientId, + String? endpointId, + String? nombre, + bool? esHost, + bool? conectado, + }) { + return ClienteSala( + clientId: clientId ?? this.clientId, + endpointId: endpointId ?? this.endpointId, + nombre: nombre ?? this.nombre, + esHost: esHost ?? this.esHost, + conectado: conectado ?? this.conectado, + ); + } + + Map toJson() => { + 'clientId': clientId, + if (endpointId != null) 'endpointId': endpointId, + 'nombre': nombre, + 'esHost': esHost, + 'conectado': conectado, + }; + + factory ClienteSala.fromJson(Map json) => ClienteSala( + clientId: json['clientId'] as String, + endpointId: json['endpointId'] as String?, + nombre: json['nombre'] as String, + esHost: json['esHost'] as bool? ?? false, + conectado: json['conectado'] as bool? ?? true, + ); +} + +class EstadoSalaMultijugador { + final String roomId; + final String nombreSala; + final String hostClientId; + FaseSalaMultijugador fase; + final Map clientes; + final Map usuarios; + + EstadoSalaMultijugador({ + required this.roomId, + required this.nombreSala, + required this.hostClientId, + this.fase = FaseSalaMultijugador.lobby, + Map? clientes, + Map? usuarios, + }) : clientes = clientes ?? {}, + usuarios = usuarios ?? {}; + + factory EstadoSalaMultijugador.crear({ + required String roomId, + required String nombreSala, + required String hostClientId, + required String hostNombre, + }) { + final sala = EstadoSalaMultijugador( + roomId: roomId, + nombreSala: nombreSala, + hostClientId: hostClientId, + ); + sala.registrarCliente( + ClienteSala( + clientId: hostClientId, + nombre: hostNombre, + esHost: true, + ), + ); + return sala; + } + + List get usuariosSeleccionados => + usuarios.values.where((usuario) => usuario.estaSeleccionado).toList(); + + List get usuariosDisponibles => + usuarios.values.where((usuario) => usuario.estaDisponible).toList(); + + int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length; + + List usuariosPorCliente(String clientId) { + return usuarios.values + .where((usuario) => usuario.clienteIdSeleccionado == clientId) + .toList(); + } + + ClienteSala? clientePorEndpoint(String endpointId) { + for (final cliente in clientes.values) { + if (cliente.endpointId == endpointId) return cliente; + } + return null; + } + + ResultadoOperacionSala registrarCliente(ClienteSala cliente) { + clientes[cliente.clientId] = cliente; + return const ResultadoOperacionSala.ok(); + } + + ResultadoOperacionSala crearUsuario(Usuario usuario) { + if (fase != FaseSalaMultijugador.lobby) { + return const ResultadoOperacionSala.error('sala_cerrada'); + } + if (usuarios.containsKey(usuario.id)) { + return const ResultadoOperacionSala.error('usuario_duplicado'); + } + final nombreNormalizado = usuario.nombre.trim().toLowerCase(); + final nombreExiste = usuarios.values.any( + (u) => u.nombre.trim().toLowerCase() == nombreNormalizado, + ); + if (nombreExiste) { + return const ResultadoOperacionSala.error('nombre_duplicado'); + } + usuarios[usuario.id] = usuario; + return const ResultadoOperacionSala.ok(); + } + + ResultadoOperacionSala seleccionarUsuario({ + required String usuarioId, + required String clienteId, + }) { + if (fase != FaseSalaMultijugador.lobby) { + return const ResultadoOperacionSala.error('sala_cerrada'); + } + final cliente = clientes[clienteId]; + if (cliente == null || !cliente.conectado) { + return const ResultadoOperacionSala.error('cliente_no_disponible'); + } + final usuario = usuarios[usuarioId]; + if (usuario == null) { + return const ResultadoOperacionSala.error('usuario_no_existe'); + } + if (usuario.clienteIdSeleccionado == clienteId) { + return const ResultadoOperacionSala.ok(); + } + if (usuario.clienteIdSeleccionado != null) { + return const ResultadoOperacionSala.error('usuario_ya_seleccionado'); + } + usuarios[usuarioId] = usuario.copiar(clienteIdSeleccionado: clienteId); + return const ResultadoOperacionSala.ok(); + } + + ResultadoOperacionSala liberarUsuario({ + required String usuarioId, + required String solicitanteClientId, + }) { + if (fase != FaseSalaMultijugador.lobby) { + return const ResultadoOperacionSala.error('sala_cerrada'); + } + final usuario = usuarios[usuarioId]; + if (usuario == null) { + return const ResultadoOperacionSala.error('usuario_no_existe'); + } + final solicitante = clientes[solicitanteClientId]; + final puedeLiberar = + usuario.clienteIdSeleccionado == solicitanteClientId || + (solicitante?.esHost ?? false); + if (!puedeLiberar) { + return const ResultadoOperacionSala.error('usuario_de_otro_cliente'); + } + usuarios[usuarioId] = usuario.copiar(liberarSeleccion: true); + return const ResultadoOperacionSala.ok(); + } + + ResultadoOperacionSala eliminarUsuario({ + required String usuarioId, + required String solicitanteClientId, + }) { + final solicitante = clientes[solicitanteClientId]; + if (!(solicitante?.esHost ?? false)) { + return const ResultadoOperacionSala.error('solo_host'); + } + final usuario = usuarios[usuarioId]; + if (usuario == null) { + return const ResultadoOperacionSala.error('usuario_no_existe'); + } + if (usuario.estaSeleccionado) { + return const ResultadoOperacionSala.error('usuario_seleccionado'); + } + usuarios.remove(usuarioId); + return const ResultadoOperacionSala.ok(); + } + + void desconectarCliente(String clientId) { + final cliente = clientes[clientId]; + if (cliente == null) return; + clientes[clientId] = cliente.copiar(conectado: false); + if (fase != FaseSalaMultijugador.lobby) return; + for (final entry in usuarios.entries.toList()) { + if (entry.value.clienteIdSeleccionado == clientId) { + usuarios[entry.key] = entry.value.copiar(liberarSeleccion: true); + } + } + } + + ResultadoOperacionSala validarInicio() { + if (fase != FaseSalaMultijugador.lobby) { + return const ResultadoOperacionSala.error('sala_cerrada'); + } + if (cantidadUsuariosSeleccionados < 3) { + return const ResultadoOperacionSala.error('faltan_jugadores'); + } + if (usuariosPorCliente(hostClientId).isEmpty) { + return const ResultadoOperacionSala.error('host_sin_usuario'); + } + return const ResultadoOperacionSala.ok(); + } + + ResultadoOperacionSala iniciarPartida() { + final validacion = validarInicio(); + if (!validacion.exitoso) return validacion; + fase = FaseSalaMultijugador.enPartida; + return const ResultadoOperacionSala.ok(); + } + + Map toJson() => { + 'roomId': roomId, + 'nombreSala': nombreSala, + 'hostClientId': hostClientId, + 'fase': fase.name, + 'clientes': clientes.values.map((cliente) => cliente.toJson()).toList(), + 'usuarios': usuarios.values.map((usuario) => usuario.toJson()).toList(), + }; + + factory EstadoSalaMultijugador.fromJson(Map json) { + final clientes = {}; + for (final clienteJson in json['clientes'] as List? ?? []) { + final cliente = ClienteSala.fromJson( + clienteJson as Map, + ); + clientes[cliente.clientId] = cliente; + } + + final usuarios = {}; + for (final usuarioJson in json['usuarios'] as List? ?? []) { + final usuario = Usuario.fromJson(usuarioJson as Map); + usuarios[usuario.id] = usuario; + } + + return EstadoSalaMultijugador( + roomId: json['roomId'] as String, + nombreSala: json['nombreSala'] as String, + hostClientId: json['hostClientId'] as String, + fase: FaseSalaMultijugador.values.firstWhere( + (fase) => fase.name == json['fase'], + orElse: () => FaseSalaMultijugador.lobby, + ), + clientes: clientes, + usuarios: usuarios, + ); + } +} diff --git a/lib/modelos/usuario.dart b/lib/modelos/usuario.dart index d0a6768..38e1b37 100644 --- a/lib/modelos/usuario.dart +++ b/lib/modelos/usuario.dart @@ -3,18 +3,53 @@ class Usuario { final String id; final String nombre; final String? avatar; + final String? creadoPorClienteId; + final String? clienteIdSeleccionado; - Usuario({required this.id, required this.nombre, this.avatar}); + Usuario({ + required this.id, + required this.nombre, + this.avatar, + this.creadoPorClienteId, + this.clienteIdSeleccionado, + }); + + bool get estaSeleccionado => clienteIdSeleccionado != null; + bool get estaDisponible => clienteIdSeleccionado == null; + + Usuario copiar({ + String? id, + String? nombre, + String? avatar, + String? creadoPorClienteId, + String? clienteIdSeleccionado, + bool liberarSeleccion = false, + }) { + return Usuario( + id: id ?? this.id, + nombre: nombre ?? this.nombre, + avatar: avatar ?? this.avatar, + creadoPorClienteId: creadoPorClienteId ?? this.creadoPorClienteId, + clienteIdSeleccionado: liberarSeleccion + ? null + : (clienteIdSeleccionado ?? this.clienteIdSeleccionado), + ); + } Map toJson() => { 'id': id, 'nombre': nombre, if (avatar != null) 'avatar': avatar, + if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId, + if (clienteIdSeleccionado != null) + 'clienteIdSeleccionado': clienteIdSeleccionado, }; factory Usuario.fromJson(Map json) => Usuario( id: json['id'] as String, nombre: json['nombre'] as String, avatar: json['avatar'] as String?, + creadoPorClienteId: json['creadoPorClienteId'] as String?, + clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?, ); } diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart index 37e4d61..bb6bd20 100644 --- a/lib/pantallas/pantalla_crear_partida.dart +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -2,6 +2,7 @@ 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/inicio_partida_multijugador.dart'; import '../modelos/palabra.dart'; import '../modelos/partida.dart'; import '../modelos/usuario.dart'; @@ -153,15 +154,21 @@ class _PantallaCrearPartidaState extends State { onIniciar: () { // Cuando el host toca "Iniciar" con suficientes jugadores final estado = context.read(); + final sala = nearby.estadoSala; + if (sala == null) return; + final validacion = sala.iniciarPartida(); + if (!validacion.exitoso) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'No se puede iniciar: ${validacion.codigo ?? "sala inválida"}', + ), + ), + ); + return; + } - // Set host local player first (required for host-included game) - estado.setHostJugador(nombre.trim()); - - final jugadoresMulti = [ - nombre.trim(), - ...nearby.jugadores.map((j) => j.nombre), - ]; - estado.crearPartida( + estado.crearPartidaDesdeSala( config: ConfigPartida( modoMultimovil: true, categoria: _categoria, @@ -169,24 +176,41 @@ class _PantallaCrearPartidaState extends State { pistaImpostor: _pistaImpostor, tiempoDebateSegundos: _tiempoDebate, ), - nombresJugadores: jugadoresMulti, + sala: sala, ); - // Enviar palabras a cada jugador via Nearby final partida = estado.partida!; - final impostores = {}; - for (int i = 0; i < nearby.jugadores.length; i++) { - final jugadorNearby = nearby.jugadores[i]; - // El jugador [0] es el host, los de nearby son [1..n] - final jugadorPartida = partida.jugadores[i + 1]; - impostores[jugadorNearby.endpointId] = - jugadorPartida.esImpostor; - } + final asignaciones = partida.jugadores.map((jugador) { + final usuarioSala = sala.usuarios[jugador.id]; + final clientId = usuarioSala?.clienteIdSeleccionado; + final cliente = clientId == null ? null : sala.clientes[clientId]; + return AsignacionJugador( + jugadorId: jugador.id, + nombre: jugador.nombre, + clientId: clientId ?? sala.hostClientId, + endpointId: cliente?.endpointId, + ); + }).toList(); + final impostores = { + for (final jugador in partida.jugadores) + jugador.id: jugador.esImpostor, + }; + final jugadoresTodos = partida.jugadores + .map( + (jugador) => { + 'id': jugador.id, + 'nombre': jugador.nombre, + 'eliminado': jugador.eliminado, + }, + ) + .toList(); - nearby.enviarInicioPartida( + nearby.enviarInicioPartidaMulti( + asignaciones: asignaciones, palabraSecreta: partida.palabraSecreta, categoria: _categoria, - impostores: impostores, + impostoresPorJugadorId: impostores, + jugadoresTodos: jugadoresTodos, ); Navigator.pushReplacement( diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index 282ac98..bba6d7b 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -3,9 +3,12 @@ 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/inicio_partida_multijugador.dart'; import '../modelos/partida.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/tema_app.dart'; +import 'pantalla_votacion_cliente.dart'; +import 'pantalla_palabras_cliente.dart'; class PantallaGestorHost extends StatefulWidget { final VoidCallback onPartidaFin; @@ -51,8 +54,11 @@ class _PantallaGestorHostState extends State { setState(() => _clientesListos[endpointId] = true); } else if (mensaje.tipo == TipoMensaje.voto) { final votanteId = mensaje.datos['votanteId'] as String?; - final votoId = mensaje.datos['votoporId'] as String?; + final votoId = + mensaje.datos['votadoId'] as String? ?? + mensaje.datos['votoporId'] as String?; if (votanteId != null && votoId != null) { + context.read().registrarVoto(votanteId, votoId); setState(() => _votosRecibidos[votanteId] = votoId); } } @@ -116,9 +122,8 @@ class _PantallaGestorHostState extends State { ); } - final numJugadores = partida.jugadores.length + 1; - final todosListos = _clientesListos.length >= numJugadores - 1; - final todosVotaron = _votosRecibidos.length >= numJugadores - 1; + final todosListos = _clientesListos.length >= nearby.jugadores.length; + final todosVotaron = estado.todosHanVotado(); return Scaffold( appBar: AppBar( @@ -294,14 +299,45 @@ class _PantallaGestorHostState extends State { void _mostrarPalabraHost(BuildContext context) { final estado = context.read(); + final sala = context.read().estadoSala; final partida = estado.partida; - if (partida == null) return; + if (partida == null || sala == null) return; - // Buscar el jugador host local - final hostLocal = partida.jugadores.firstWhere( - (j) => j.nombre == context.read().miNombre, - orElse: () => partida.jugadores.first, - ); + final jugadoresHost = 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, + ); + }) + .toList(); + + if (jugadoresHost.length > 1) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaPalabrasCliente( + jugadores: jugadoresHost, + pistaCategoria: partida.config.pistaImpostor + ? partida.categoriaReal + : null, + onTodosVistos: () => Navigator.of(context).pop(), + ), + ), + ); + return; + } + + final hostLocal = jugadoresHost.isNotEmpty + ? partida.jugadores.firstWhere((j) => j.id == jugadoresHost.first.jugadorId) + : partida.jugadores.first; Navigator.push( context, @@ -393,6 +429,12 @@ class _PantallaGestorHostState extends State { bool todosVotaron, ServicioNearby nearby, ) { + final estado = context.watch(); + final partida = estado.partida!; + final totalVotos = partida.jugadoresActivos.length; + final votosEmitidos = estado.votos.length; + final progreso = totalVotos == 0 ? 0.0 : votosEmitidos / totalVotos; + return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -410,19 +452,12 @@ class _PantallaGestorHostState extends State { ), child: Column( children: [ - Text( - l10n.votesProgress( - _votosRecibidos.length, - nearby.jugadores.length + 1, - ), - ), + Text(l10n.votesProgress(votosEmitidos, totalVotos)), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: - _votosRecibidos.length / - (nearby.jugadores.length + 1), + value: progreso.clamp(0.0, 1.0).toDouble(), backgroundColor: TemaApp.colorSuperficie, valueColor: const AlwaysStoppedAnimation( TemaApp.colorAcento, @@ -433,6 +468,19 @@ class _PantallaGestorHostState extends State { ], ), ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _hostYaVoto(context) ? null : () => _abrirVotacionHost(context), + icon: const Icon(Icons.how_to_vote), + label: Text( + _hostYaVoto(context) + ? 'Votos del host registrados' + : 'Votar por los jugadores de este m?vil', + ), + ), + ), const SizedBox(height: 16), Text( l10n.playersVoted, @@ -441,15 +489,11 @@ class _PantallaGestorHostState extends State { const SizedBox(height: 8), Expanded( child: ListView.builder( - itemCount: nearby.jugadores.length + 1, + itemCount: partida.jugadoresActivos.length, itemBuilder: (context, index) { - final esHost = index == 0; - final nombre = esHost - ? (nearby.miNombre ?? 'Host') - : nearby.jugadores[index - 1].nombre; - final haVotado = - esHost || _votosRecibidos.containsKey(nombre); - return _buildJugadorTile(nombre, esHost, haVotado); + final jugador = partida.jugadoresActivos[index]; + final haVotado = estado.votos.containsKey(jugador.id); + return _buildJugadorTile(jugador.nombre, false, haVotado); }, ), ), @@ -478,6 +522,51 @@ class _PantallaGestorHostState extends State { ); } + bool _hostYaVoto(BuildContext context) { + final estado = context.read(); + final sala = context.read().estadoSala; + if (sala == null || estado.partida == null) return false; + final hostIds = sala.usuariosPorCliente(sala.hostClientId).map((u) => u.id); + return hostIds.every((id) => estado.votos.containsKey(id)); + } + + void _abrirVotacionHost(BuildContext context) { + final estado = context.read(); + final sala = context.read().estadoSala; + final partida = estado.partida; + if (sala == null || partida == null) return; + + final jugadoresHost = sala.usuariosPorCliente(sala.hostClientId) + .where((usuario) => partida.jugadoresActivos.any((j) => j.id == usuario.id)) + .map( + (usuario) => JugadorInicioPartida( + jugadorId: usuario.id, + nombre: usuario.nombre, + esImpostor: partida.jugadores.firstWhere((j) => j.id == usuario.id).esImpostor, + palabra: partida.jugadores.firstWhere((j) => j.id == usuario.id).palabra, + ), + ) + .toList(); + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaVotacionCliente( + jugadores: partida.jugadoresActivos, + jugadoresControlados: jugadoresHost, + onVotos: (votos) { + for (final entry in votos.entries) { + estado.registrarVoto(entry.key, entry.value); + _votosRecibidos[entry.key] = entry.value; + } + if (mounted) setState(() {}); + Navigator.of(context).pop(); + }, + ), + ), + ); + } + Widget _buildJugadorTile(String nombre, bool esHost, bool listo) { return Container( margin: const EdgeInsets.only(bottom: 8), diff --git a/lib/pantallas/pantalla_lobby_host.dart b/lib/pantallas/pantalla_lobby_host.dart index 3e716d8..cc5cbfb 100644 --- a/lib/pantallas/pantalla_lobby_host.dart +++ b/lib/pantallas/pantalla_lobby_host.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; @@ -6,7 +6,7 @@ import '../modelos/usuario.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/tema_app.dart'; -/// Pantalla de lobby del host: muestra QR y lista de jugadores conectados +/// Lobby del host. El host es autoridad de sala y también cliente local. class PantallaLobbyHost extends StatefulWidget { final String nombreSala; final VoidCallback onIniciar; @@ -23,14 +23,16 @@ class PantallaLobbyHost extends StatefulWidget { class _PantallaLobbyHostState extends State { bool _iniciando = false; - String? _perfilSeleccionado; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final nearby = context.watch(); - final jugadores = nearby.jugadores; - final totalJugadores = jugadores.length + 1; // +1 host + final sala = nearby.estadoSala; + final usuarios = nearby.usuarios; + final seleccionados = usuarios.where((u) => u.estaSeleccionado).length; + final validacionInicio = sala?.validarInicio(); + final puedeIniciar = validacionInicio?.exitoso ?? false; return Scaffold( appBar: AppBar( @@ -47,7 +49,6 @@ class _PantallaLobbyHostState extends State { padding: const EdgeInsets.all(24), child: Column( children: [ - // QR Code Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -57,174 +58,66 @@ class _PantallaLobbyHostState extends State { child: QrImageView( data: nearby.generarDatosQR(widget.nombreSala), version: QrVersions.auto, - size: 180, + size: 160, backgroundColor: Colors.white, ), ), const SizedBox(height: 12), - Text( - l10n.scanToJoin, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 24), - - // Selección de perfil - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.selectYourProfile, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _perfilSeleccionado, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.person), - hintText: l10n.selectProfile, - border: const OutlineInputBorder(), - ), - items: [ - // Opción para crear nuevo usuario - DropdownMenuItem( - value: '_new_', - child: Row( - children: [ - const Icon(Icons.add, size: 18), - const SizedBox(width: 8), - Text(l10n.createNewUser), - ], - ), - ), - // Usuarios existentes - ...nearby.usuarios.map((usuario) { - return DropdownMenuItem( - value: usuario.nombre, - child: Row( - children: [ - Text(usuario.avatar ?? '👤'), - const SizedBox(width: 8), - Text(usuario.nombre), - ], - ), - ); - }), - ], - onChanged: (valor) { - if (valor == '_new_') { - _crearNuevoUsuario(context); - } else { - setState(() => _perfilSeleccionado = valor); - } - }, - ), - ], - ), - ), - ), + Text(l10n.scanToJoin), const SizedBox(height: 16), - - // Lista de jugadores + _buildResumenSala(context, seleccionados, nearby.jugadores.length), + const SizedBox(height: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.connectedPlayers, - style: Theme.of(context).textTheme.titleLarge, - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: totalJugadores >= 3 - ? TemaApp.colorVerde.withValues(alpha: 0.2) - : TemaApp.colorNaranja.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$totalJugadores', - style: TextStyle( - color: totalJugadores >= 3 - ? TemaApp.colorVerde - : TemaApp.colorNaranja, - fontWeight: FontWeight.bold, + Row( + children: [ + Expanded( + child: Text( + 'Usuarios de la partida', + style: Theme.of(context).textTheme.titleLarge, + ), ), - ), + IconButton.filledTonal( + onPressed: () => _crearNuevoUsuario(context), + icon: const Icon(Icons.person_add), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: usuarios.isEmpty + ? Center(child: Text(l10n.waitingForPlayers)) + : ListView.builder( + itemCount: usuarios.length, + itemBuilder: (context, index) => + _buildUsuarioTile(context, usuarios[index]), + ), ), ], ), - const SizedBox(height: 12), - - // Host (yo) - _buildJugadorTile( - nombre: nearby.miNombre ?? 'Host', - esHost: true, - ), - - // Jugadores conectados - Expanded( - child: jugadores.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '📱', - style: TextStyle(fontSize: 48), - ), - const SizedBox(height: 12), - Text( - l10n.waitingForPlayers, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ) - : ListView.builder( - itemCount: jugadores.length, - itemBuilder: (context, index) { - final j = jugadores[index]; - return _buildJugadorTile(nombre: j.nombre); - }, - ), - ), - ], + ), ), ), - - // Botón iniciar - if (totalJugadores < 3) - Text( - l10n.needMorePlayers(3 - totalJugadores), - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja), - ), const SizedBox(height: 12), - if (_perfilSeleccionado == null) + if (!puedeIniciar) Text( - l10n.selectProfile, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja), + _mensajeValidacion(validacionInicio?.codigo), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: TemaApp.colorNaranja), + textAlign: TextAlign.center, ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: - totalJugadores >= 3 && - _perfilSeleccionado != null && - !_iniciando + onPressed: puedeIniciar && !_iniciando ? () { setState(() => _iniciando = true); widget.onIniciar(); @@ -240,45 +133,129 @@ class _PantallaLobbyHostState extends State { ); } - Widget _buildJugadorTile({required String nombre, bool esHost = false}) { + Widget _buildResumenSala( + BuildContext context, + int seleccionados, + int clientesRemotos, + ) { + return Row( + children: [ + Expanded( + child: _buildStat( + context, + icon: Icons.groups, + label: 'Jugadores seleccionados', + value: '$seleccionados', + ok: seleccionados >= 3, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildStat( + context, + icon: Icons.devices, + label: 'Móviles conectados', + value: '${clientesRemotos + 1}', + ok: true, + ), + ), + ], + ); + } + + Widget _buildStat( + BuildContext context, { + required IconData icon, + required String label, + required String value, + required bool ok, + }) { + final color = ok ? TemaApp.colorVerde : TemaApp.colorNaranja; return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: TemaApp.colorTarjeta, + color: color.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(12), - border: esHost - ? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5)) - : null, ), child: Row( children: [ - Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)), - const SizedBox(width: 12), + Icon(icon, color: color), + const SizedBox(width: 8), Expanded( - child: Text(nombre, style: Theme.of(context).textTheme.titleMedium), + child: Text(label, style: Theme.of(context).textTheme.bodySmall), ), - if (esHost) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: TemaApp.colorAcento.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'HOST', - style: TextStyle( - color: TemaApp.colorAcento, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), + Text( + value, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + Widget _buildUsuarioTile(BuildContext context, Usuario usuario) { + final nearby = context.read(); + final miClientId = nearby.miClientId; + final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId; + final seleccionadoPorOtro = + usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId; + + return ListTile( + leading: CircleAvatar( + backgroundColor: seleccionadoPorMi + ? TemaApp.colorVerde + : seleccionadoPorOtro + ? TemaApp.colorNaranja + : TemaApp.colorTarjeta, + child: Text(usuario.avatar ?? '👤'), + ), + title: Text(usuario.nombre), + subtitle: Text( + seleccionadoPorMi + ? 'Seleccionado por este móvil' + : seleccionadoPorOtro + ? 'Seleccionado por otro cliente' + : 'Disponible', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (seleccionadoPorMi) + IconButton( + tooltip: 'Liberar', + icon: const Icon(Icons.close), + onPressed: () => nearby.liberarUsuarioSala(usuario.id), + ) + else if (!seleccionadoPorOtro) + IconButton( + tooltip: 'Seleccionar', + icon: const Icon(Icons.check_circle_outline), + onPressed: () => nearby.seleccionarUsuarioSala(usuario.id), + ), + if (!usuario.estaSeleccionado) + IconButton( + tooltip: 'Eliminar', + icon: const Icon(Icons.delete_outline, color: TemaApp.colorAcento), + onPressed: () => nearby.eliminarUsuarioSala(usuario.id), ), ], ), ); } + String _mensajeValidacion(String? codigo) { + switch (codigo) { + case 'faltan_jugadores': + return 'Seleccioná al menos 3 usuarios para iniciar.'; + case 'host_sin_usuario': + return 'El móvil servidor debe seleccionar al menos un usuario.'; + case 'sala_cerrada': + return 'La sala ya no está en lobby.'; + default: + return 'Completá la selección de usuarios para iniciar.'; + } + } + Future _crearNuevoUsuario(BuildContext context) async { final l10n = AppLocalizations.of(context)!; final controller = TextEditingController(); @@ -312,12 +289,7 @@ class _PantallaLobbyHostState extends State { ); if (nombre != null && nombre.trim().isNotEmpty) { - final nuevoUsuario = Usuario( - id: DateTime.now().millisecondsSinceEpoch.toString(), - nombre: nombre.trim(), - ); - nearby.agregarUsuario(nuevoUsuario); - setState(() => _perfilSeleccionado = nombre.trim()); + await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true); } } } diff --git a/lib/pantallas/pantalla_palabras_cliente.dart b/lib/pantallas/pantalla_palabras_cliente.dart new file mode 100644 index 0000000..fff1b35 --- /dev/null +++ b/lib/pantallas/pantalla_palabras_cliente.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; +import 'package:farolero/tema/tema_app.dart'; + +/// Reveal secuencial para clientes que manejan uno o varios jugadores. +class PantallaPalabrasCliente extends StatefulWidget { + final List jugadores; + final String? pistaCategoria; + final VoidCallback onTodosVistos; + + const PantallaPalabrasCliente({ + super.key, + required this.jugadores, + this.pistaCategoria, + required this.onTodosVistos, + }); + + @override + State createState() => _PantallaPalabrasClienteState(); +} + +class _PantallaPalabrasClienteState extends State { + int _indice = 0; + bool _visible = false; + + JugadorInicioPartida get _actual => widget.jugadores[_indice]; + bool get _esUltimo => _indice == widget.jugadores.length - 1; + + void _continuar() { + if (_esUltimo) { + widget.onTodosVistos(); + return; + } + setState(() { + _indice++; + _visible = false; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final actual = _actual; + + return Scaffold( + backgroundColor: TemaApp.colorFondo, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text( + 'Jugador ${_indice + 1} de ${widget.jugadores.length}', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + Text( + actual.nombre, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: () => setState(() => _visible = !_visible), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 48, + horizontal: 24, + ), + decoration: BoxDecoration( + color: _visible ? TemaApp.colorAcento : TemaApp.colorTarjeta, + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + Icon( + _visible ? Icons.visibility : Icons.visibility_off, + color: _visible ? Colors.white : TemaApp.colorTextoSecundario, + size: 32, + ), + const SizedBox(height: 16), + Text( + _visible + ? (actual.esImpostor + ? l10n.youAreImpostor + : actual.palabra ?? '') + : '???', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: _visible + ? Colors.white + : TemaApp.colorTextoSecundario, + ), + ), + ], + ), + ), + ), + if (_visible && actual.esImpostor && widget.pistaCategoria != null) ...[ + const SizedBox(height: 12), + Text( + l10n.clueIs(widget.pistaCategoria!), + style: const TextStyle(color: TemaApp.colorNaranja), + textAlign: TextAlign.center, + ), + ], + const Spacer(), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: _continuar, + icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward), + label: Text(_esUltimo ? l10n.iveSeenIt : 'Siguiente jugador'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index 7a575a2..815d470 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -3,11 +3,13 @@ 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/inicio_partida_multijugador.dart'; import '../modelos/usuario.dart'; import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_permisos.dart'; import '../tema/tema_app.dart'; import 'pantalla_palabra_cliente.dart'; +import 'pantalla_palabras_cliente.dart'; import 'pantalla_debate_cliente.dart'; import 'pantalla_votacion_cliente.dart'; @@ -36,6 +38,7 @@ class _PantallaUnirseState extends State { bool _esImpostor = false; String? _pistaCategoria; final List _jugadores = []; + final List _jugadoresControlados = []; @override void initState() { @@ -51,13 +54,38 @@ class _PantallaUnirseState extends State { nearby.onMensaje((endpointId, mensaje) { if (mensaje.tipo == TipoMensaje.partidaInicio) { // El host ha iniciado la partida — nos ha enviado nuestra palabra + final jugadoresData = mensaje.datos['jugadores'] as List?; + final jugadoresTodosData = + mensaje.datos['jugadoresTodos'] as List?; setState(() { - _palabraRecibida = mensaje.datos['palabra'] as String?; - _esImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + _jugadoresControlados + ..clear() + ..addAll( + (jugadoresData ?? []).map( + (json) => JugadorInicioPartida.fromJson( + json as Map, + ), + ), + ); + _jugadores + ..clear() + ..addAll( + (jugadoresTodosData ?? []).map( + (json) => Jugador.fromJson(json as Map), + ), + ); + if (_jugadoresControlados.isNotEmpty) { + final primero = _jugadoresControlados.first; + _palabraRecibida = primero.palabra; + _esImpostor = primero.esImpostor; + } else { + _palabraRecibida = mensaje.datos['palabra'] as String?; + _esImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + } _pistaCategoria = mensaje.datos['categoria'] as String?; }); // Navegar a pantalla de palabra del cliente - if (mounted && _palabraRecibida != null) { + if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) { _navegarAPalabra(); } } else if (mensaje.tipo == TipoMensaje.fase) { @@ -71,6 +99,28 @@ class _PantallaUnirseState extends State { } void _navegarAPalabra() { + if (_jugadoresControlados.isNotEmpty) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PantallaPalabrasCliente( + jugadores: List.unmodifiable(_jugadoresControlados), + pistaCategoria: _pistaCategoria, + onTodosVistos: () { + final nearby = context.read(); + if (nearby.hostEndpointId != null) { + nearby.enviarMensaje( + nearby.hostEndpointId!, + MensajeP2P(tipo: TipoMensaje.listo, datos: {}), + ); + } + Navigator.of(context).pop(); + }, + ), + ), + ); + return; + } + Navigator.of(context).push( MaterialPageRoute( builder: (_) => PantallaPalabraCliente( @@ -121,16 +171,23 @@ class _PantallaUnirseState extends State { MaterialPageRoute( builder: (_) => PantallaVotacionCliente( jugadores: _jugadores, - onVoto: (votoporId) { + jugadoresControlados: List.unmodifiable(_jugadoresControlados), + onVotos: (votos) { final nearby = context.read(); if (nearby.hostEndpointId != null) { - nearby.enviarMensaje( - nearby.hostEndpointId!, - MensajeP2P( - tipo: TipoMensaje.voto, - datos: {'votoporId': votoporId}, - ), - ); + for (final entry in votos.entries) { + nearby.enviarMensaje( + nearby.hostEndpointId!, + MensajeP2P( + tipo: TipoMensaje.voto, + datos: { + 'votanteId': entry.key, + 'votadoId': entry.value, + 'votoporId': entry.value, + }, + ), + ); + } } Navigator.of(context).pop(); }, @@ -605,19 +662,7 @@ class _PantallaUnirseState extends State { ), const Divider(), // Usuarios existentes - ...usuarios.map( - (usuario) => ListTile( - leading: Text( - usuario.avatar ?? '👤', - style: const TextStyle(fontSize: 24), - ), - title: Text(usuario.nombre), - onTap: () { - // Seleccionar usuario - enviar al host - _enviarUsuarioAlHost(usuario); - }, - ), - ), + ...usuarios.map(_buildUsuarioSalaTile), ], ), ), @@ -671,34 +716,57 @@ class _PantallaUnirseState extends State { ); if (nombre != null && nombre.trim().isNotEmpty) { - final nuevoUsuario = Usuario( - id: DateTime.now().millisecondsSinceEpoch.toString(), - nombre: nombre.trim(), - ); - // Agregar localmente - nearby.agregarUsuario(nuevoUsuario); - // Enviar al host - _enviarUsuarioAlHost(nuevoUsuario); + await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true); } } - /// Envía el usuario seleccionado/creado al host + /// Env?a el usuario seleccionado/creado al host void _enviarUsuarioAlHost(Usuario usuario) { final nearby = context.read(); - if (nearby.hostEndpointId != null) { - nearby.enviarMensaje( - nearby.hostEndpointId!, - MensajeP2P( - tipo: TipoMensaje.usuarioNuevo, - datos: {'usuario': usuario.toJson()}, - ), - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado'))); - } + nearby.seleccionarUsuarioSala(usuario.id); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado'))); } + Widget _buildUsuarioSalaTile(Usuario usuario) { + final nearby = context.read(); + final miClientId = nearby.miClientId; + final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId; + final seleccionadoPorOtro = + usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId; + + return ListTile( + leading: Text( + usuario.avatar ?? '??', + style: const TextStyle(fontSize: 24), + ), + title: Text(usuario.nombre), + subtitle: Text( + seleccionadoPorMi + ? 'Seleccionado por este m?vil' + : seleccionadoPorOtro + ? 'No disponible' + : 'Disponible', + ), + trailing: seleccionadoPorMi + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () => nearby.liberarUsuarioSala(usuario.id), + ) + : null, + enabled: !seleccionadoPorOtro, + onTap: seleccionadoPorOtro + ? null + : () { + if (seleccionadoPorMi) { + nearby.liberarUsuarioSala(usuario.id); + } else { + _enviarUsuarioAlHost(usuario); + } + }, + ); + } // ==================== HELPERS ==================== Widget _buildError(String msg) { diff --git a/lib/pantallas/pantalla_votacion_cliente.dart b/lib/pantallas/pantalla_votacion_cliente.dart index c541425..1e938ef 100644 --- a/lib/pantallas/pantalla_votacion_cliente.dart +++ b/lib/pantallas/pantalla_votacion_cliente.dart @@ -1,18 +1,22 @@ -import 'package:flutter/material.dart'; +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/tema/tema_app.dart'; -/// Pantalla de votación para el cliente (multidispositivo). -/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar. +/// Pantalla de votación para cliente multidispositivo. +/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto +/// por cada jugador controlado activo. class PantallaVotacionCliente extends StatefulWidget { final List jugadores; - final Function(String votoporId) onVoto; + final List jugadoresControlados; + final Function(Map votos) onVotos; const PantallaVotacionCliente({ super.key, required this.jugadores, - required this.onVoto, + this.jugadoresControlados = const [], + required this.onVotos, }); @override @@ -20,7 +24,14 @@ class PantallaVotacionCliente extends StatefulWidget { } class _PantallaVotacionClienteState extends State { - String? _votoSeleccionado; + final Map _votosPorVotante = {}; + + List get _votantes => widget.jugadoresControlados; + bool get _modoMultiVotante => _votantes.length > 1; + bool get _votacionCompleta { + if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy'); + return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null); + } @override Widget build(BuildContext context) { @@ -45,56 +56,31 @@ class _PantallaVotacionClienteState extends State { ), const SizedBox(height: 8), Text( - l10n.selectOnePlayer, + _modoMultiVotante + ? 'Emití un voto por cada jugador que manejás.' + : l10n.selectOnePlayer, style: TextStyle(color: TemaApp.colorTextoSecundario), ), const SizedBox(height: 16), Expanded( - child: ListView.builder( - itemCount: widget.jugadores.length, - itemBuilder: (context, index) { - final jugador = widget.jugadores[index]; - final selected = _votoSeleccionado == jugador.id; - return Card( - color: selected - ? TemaApp.colorAcento.withValues(alpha: 0.3) - : TemaApp.colorTarjeta, - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar( - backgroundColor: selected - ? TemaApp.colorAcento - : TemaApp.colorAcento.withValues(alpha: 0.3), - child: Text( - '${index + 1}', - style: TextStyle( - color: selected - ? Colors.white - : TemaApp.colorTexto, - ), - ), - ), - title: Text(jugador.nombre), - trailing: selected - ? const Icon(Icons.check_circle, - color: TemaApp.colorAcento) - : null, - onTap: () { - setState(() => _votoSeleccionado = jugador.id); + child: _votantes.isEmpty + ? _buildSelectorLegacy() + : ListView.builder( + itemCount: _votantes.length, + itemBuilder: (context, index) { + final votante = _votantes[index]; + return _buildSelectorParaVotante(context, votante); }, ), - ); - }, - ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, height: 56, child: ElevatedButton.icon( - onPressed: _votoSeleccionado == null - ? null - : () => widget.onVoto(_votoSeleccionado!), + onPressed: _votacionCompleta + ? () => widget.onVotos(Map.unmodifiable(_votosPorVotante)) + : null, icon: const Icon(Icons.how_to_vote), label: Text(l10n.votar), style: ElevatedButton.styleFrom( @@ -109,4 +95,87 @@ class _PantallaVotacionClienteState extends State { ), ); } + + Widget _buildSelectorLegacy() { + return ListView.builder( + itemCount: widget.jugadores.length, + itemBuilder: (context, index) { + final jugador = widget.jugadores[index]; + final selected = _votosPorVotante['_legacy'] == jugador.id; + return _buildJugadorVotable( + jugador: jugador, + index: index, + selected: selected, + onTap: () => setState(() => _votosPorVotante['_legacy'] = jugador.id), + ); + }, + ); + } + + Widget _buildSelectorParaVotante( + BuildContext context, + JugadorInicioPartida votante, + ) { + return Card( + color: TemaApp.colorSuperficie, + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Voto de ${votante.nombre}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ...widget.jugadores.asMap().entries.map((entry) { + final jugador = entry.value; + final selected = _votosPorVotante[votante.jugadorId] == jugador.id; + return _buildJugadorVotable( + jugador: jugador, + index: entry.key, + selected: selected, + onTap: () => setState( + () => _votosPorVotante[votante.jugadorId] = jugador.id, + ), + ); + }), + ], + ), + ), + ); + } + + Widget _buildJugadorVotable({ + required Jugador jugador, + required int index, + required bool selected, + required VoidCallback onTap, + }) { + return Card( + color: selected + ? TemaApp.colorAcento.withValues(alpha: 0.3) + : TemaApp.colorTarjeta, + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: selected + ? TemaApp.colorAcento + : TemaApp.colorAcento.withValues(alpha: 0.3), + child: Text( + '${index + 1}', + style: TextStyle( + color: selected ? Colors.white : TemaApp.colorTexto, + ), + ), + ), + title: Text(jugador.nombre), + trailing: selected + ? const Icon(Icons.check_circle, color: TemaApp.colorAcento) + : null, + onTap: onTap, + ), + ); + } } diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index a07e578..b929257 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -1,9 +1,11 @@ -import 'dart:convert'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nearby_connections/nearby_connections.dart'; +import '../modelos/inicio_partida_multijugador.dart'; +import '../modelos/sala_multijugador.dart'; import '../modelos/usuario.dart'; -/// Tipos de mensajes en el protocolo P2P +/// Tipos de mensajes en el protocolo P2P. enum TipoMensaje { salaInfo, partidaInicio, @@ -15,12 +17,18 @@ enum TipoMensaje { listo, ping, jugadorDesconectado, + clienteRegistrado, + estadoSala, + crearUsuario, + seleccionarUsuario, + liberarUsuario, + errorOperacion, usuarioNuevo, usuarioEliminado, usuariosActualizados, } -/// Mensaje del protocolo P2P entre dispositivos +/// Mensaje del protocolo P2P entre dispositivos. class MensajeP2P { final TipoMensaje tipo; final Map datos; @@ -40,7 +48,8 @@ class MensajeP2P { Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson())); } -/// Info de un jugador conectado +/// Info de un dispositivo conectado. El nombre identifica al cliente/dispositivo, +/// no necesariamente a un jugador de la partida. class JugadorConectado { final String endpointId; final String nombre; @@ -53,13 +62,13 @@ class JugadorConectado { }); } -/// Callback para mensajes recibidos typedef OnMensajeCallback = void Function(String endpointId, MensajeP2P mensaje); /// Servicio para conexiones P2P usando Google Nearby Connections API. class ServicioNearby extends ChangeNotifier { static const _serviceId = 'es.freetimelab.farolero'; + static const _hostClientId = 'host'; bool _esHost = false; bool _conectado = false; @@ -67,23 +76,21 @@ class ServicioNearby extends ChangeNotifier { bool _anunciando = false; String? _miEndpointId; String? _hostEndpointId; + String? _roomId; + String? _miClientId; String? _nombreSala; String? _miNombre; final Map _jugadores = {}; final List _listeners = []; + final Map _hostsEncontrados = {}; + final Map _usuariosPool = {}; - // Hosts descubiertos (para discovery automático) - final Map _hostsEncontrados = {}; // endpointId -> nombre - - // Estado para clientes String? _palabraRecibida; bool? _soyImpostor; String? _faseActual; Map? _datosPartida; - - // Pool de usuarios para modo multi-dispositivo - final Map _usuariosPool = {}; + EstadoSalaMultijugador? _estadoSala; bool get esHost => _esHost; bool get conectado => _conectado; @@ -91,27 +98,35 @@ class ServicioNearby extends ChangeNotifier { bool get anunciando => _anunciando; String? get miEndpointId => _miEndpointId; String? get hostEndpointId => _hostEndpointId; + String? get roomId => _roomId; + String? get miClientId => _miClientId; String? get nombreSala => _nombreSala; String? get miNombre => _miNombre; String? get palabraRecibida => _palabraRecibida; bool? get soyImpostor => _soyImpostor; String? get faseActual => _faseActual; Map? get datosPartida => _datosPartida; + EstadoSalaMultijugador? get estadoSala => _estadoSala; List get jugadores => _jugadores.values.toList(); int get numJugadoresConectados => _jugadores.length; Map get hostsEncontrados => Map.unmodifiable(_hostsEncontrados); - /// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo - List get usuarios => _usuariosPool.values.toList(); + List get usuarios => + (_estadoSala?.usuarios.values ?? _usuariosPool.values).toList(); + + List get misUsuariosSeleccionados { + final clientId = _miClientId; + final sala = _estadoSala; + if (clientId == null || sala == null) return []; + return sala.usuariosPorCliente(clientId); + } - /// Registra un listener de mensajes void onMensaje(OnMensajeCallback callback) { _listeners.add(callback); } - /// Elimina un listener void removeMensajeListener(OnMensajeCallback callback) { _listeners.remove(callback); } @@ -122,26 +137,24 @@ class ServicioNearby extends ChangeNotifier { } } - // ==================== USER POOL ==================== + // ==================== USER POOL / SALA ==================== - /// Agrega un usuario al pool de usuarios void agregarUsuario(Usuario usuario) { _usuariosPool[usuario.id] = usuario; + _estadoSala?.usuarios[usuario.id] = usuario; notifyListeners(); } - /// Elimina un usuario del pool void eliminarUsuario(String usuarioId) { _usuariosPool.remove(usuarioId); + _estadoSala?.usuarios.remove(usuarioId); notifyListeners(); } - /// Obtiene un usuario por su ID Usuario? getUsuario(String usuarioId) { - return _usuariosPool[usuarioId]; + return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId]; } - /// Sincroniza el pool de usuarios con una lista void sincronizarUsuarios(List usuarios) { _usuariosPool.clear(); for (final usuario in usuarios) { @@ -150,12 +163,38 @@ class ServicioNearby extends ChangeNotifier { notifyListeners(); } - /// Obtiene el jugador local del host (él mismo como participante) - /// Retorna un JugadorConectado con endpointId null porque es local + void _sincronizarSala(EstadoSalaMultijugador sala) { + _estadoSala = sala; + _roomId = sala.roomId; + _usuariosPool + ..clear() + ..addEntries(sala.usuarios.entries); + } + + void _sincronizarPoolDesdeSala() { + final sala = _estadoSala; + if (sala == null) return; + _usuariosPool + ..clear() + ..addEntries(sala.usuarios.entries); + } + + Future _broadcastEstadoSala() async { + final sala = _estadoSala; + if (sala == null) return; + _sincronizarPoolDesdeSala(); + if (_esHost) { + await enviarATodos( + MensajeP2P(tipo: TipoMensaje.estadoSala, datos: sala.toJson()), + ); + } + notifyListeners(); + } + JugadorConectado? getJugadorLocal() { if (_miNombre == null) return null; return JugadorConectado( - endpointId: _miEndpointId ?? '', // vacío indica que es el host local + endpointId: _miEndpointId ?? '', nombre: _miNombre!, listo: true, ); @@ -163,10 +202,29 @@ class ServicioNearby extends ChangeNotifier { // ==================== HOST ==================== - /// Inicia como host (anunciando el endpoint) Future iniciarHost(String nombreSala, String miNombre) async { _nombreSala = nombreSala; _miNombre = miNombre; + _roomId = DateTime.now().microsecondsSinceEpoch.toString(); + _miClientId = _hostClientId; + _estadoSala = EstadoSalaMultijugador.crear( + roomId: _roomId!, + nombreSala: nombreSala, + hostClientId: _hostClientId, + hostNombre: miNombre, + ); + + // Compatibilidad con el flujo actual: el nombre con el que se crea la sala + // arranca como usuario seleccionado por el host. Luego puede crear/seleccionar + // más usuarios en el lobby. + final usuarioHost = Usuario( + id: 'u-${_roomId!}-host', + nombre: miNombre, + creadoPorClienteId: _hostClientId, + clienteIdSeleccionado: _hostClientId, + ); + _estadoSala!.crearUsuario(usuarioHost); + _sincronizarPoolDesdeSala(); try { final resultado = await Nearby().startAdvertising( @@ -194,7 +252,6 @@ class ServicioNearby extends ChangeNotifier { // ==================== CLIENTE ==================== - /// Busca hosts disponibles Future buscarHosts(String miNombre) async { _miNombre = miNombre; @@ -219,7 +276,6 @@ class ServicioNearby extends ChangeNotifier { } } - /// Conecta a un host específico Future conectarAHost(String endpointId, String miNombre) async { try { await Nearby().requestConnection( @@ -239,8 +295,7 @@ class ServicioNearby extends ChangeNotifier { // ==================== CALLBACKS NEARBY ==================== void _onConexionIniciada(String endpointId, ConnectionInfo info) { - debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}'); - // Auto-aceptar conexiones + debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}'); Nearby().acceptConnection( endpointId, onPayLoadRecieved: _onPayloadRecibido, @@ -249,16 +304,13 @@ class ServicioNearby extends ChangeNotifier { } void _onResultadoConexion(String endpointId, Status status) { - debugPrint('Resultado conexión $endpointId: $status'); + debugPrint('Resultado conexion $endpointId: $status'); if (status == Status.CONNECTED) { if (_esHost) { - // Host: esperar mensaje 'unirse' del cliente debugPrint('Cliente conectado: $endpointId'); } else { - // Cliente: conectado al host _hostEndpointId = endpointId; _conectado = true; - // Enviar mensaje de unirse enviarMensaje( endpointId, MensajeP2P( @@ -269,16 +321,20 @@ class ServicioNearby extends ChangeNotifier { } notifyListeners(); } else { - debugPrint('Conexión fallida con $endpointId'); + debugPrint('Conexion fallida con $endpointId'); } } void _onDesconexion(String endpointId) { - debugPrint('Desconexión: $endpointId'); + debugPrint('Desconexion: $endpointId'); if (_esHost) { final jugador = _jugadores.remove(endpointId); + final cliente = _estadoSala?.clientePorEndpoint(endpointId); + if (cliente != null) { + _estadoSala?.desconectarCliente(cliente.clientId); + _broadcastEstadoSala(); + } if (jugador != null) { - // Notificar a todos que se desconectó enviarATodos( MensajeP2P( tipo: TipoMensaje.jugadorDesconectado, @@ -287,7 +343,6 @@ class ServicioNearby extends ChangeNotifier { ); } } else { - // Cliente perdió conexión con host _conectado = false; _hostEndpointId = null; } @@ -312,7 +367,6 @@ class ServicioNearby extends ChangeNotifier { } } - /// Para el discovery sin desconectar Future pararBusqueda() async { try { await Nearby().stopDiscovery(); @@ -334,9 +388,7 @@ class ServicioNearby extends ChangeNotifier { } } - void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) { - // No necesitamos trackear progreso para bytes pequeños - } + void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {} // ==================== PROCESAMIENTO DE MENSAJES ==================== @@ -355,33 +407,11 @@ class ServicioNearby extends ChangeNotifier { void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { switch (mensaje.tipo) { case TipoMensaje.unirse: - final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; - _jugadores[endpointId] = JugadorConectado( - endpointId: endpointId, - nombre: nombre, - ); - // Enviar info de sala al nuevo jugador (incluye pool de usuarios) - enviarMensaje( - endpointId, - MensajeP2P( - tipo: TipoMensaje.salaInfo, - datos: { - 'sala': _nombreSala, - 'jugadores': _jugadores.values - .map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId}) - .toList(), - 'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(), - }, - ), - ); - notifyListeners(); + _registrarClienteRemoto(endpointId, mensaje); break; - case TipoMensaje.voto: - // Propagar al flujo de juego _notificarMensaje(endpointId, mensaje); break; - case TipoMensaje.listo: final jugador = _jugadores[endpointId]; if (jugador != null) { @@ -389,33 +419,68 @@ class ServicioNearby extends ChangeNotifier { notifyListeners(); } break; - + case TipoMensaje.crearUsuario: + _handleCrearUsuario(endpointId, mensaje); + break; + case TipoMensaje.seleccionarUsuario: + _handleSeleccionarUsuario(endpointId, mensaje); + break; + case TipoMensaje.liberarUsuario: + _handleLiberarUsuario(endpointId, mensaje); + break; + case TipoMensaje.eliminarUsuario: + _handleEliminarUsuario(endpointId, mensaje); + break; case TipoMensaje.usuarioNuevo: _handleUsuarioNuevo(mensaje); break; - case TipoMensaje.usuariosActualizados: _handleUsuariosActualizados(mensaje); break; - default: break; } } + void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) { + final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; + final clientId = endpointId; + _jugadores[endpointId] = JugadorConectado( + endpointId: endpointId, + nombre: nombre, + ); + _estadoSala?.registrarCliente( + ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre), + ); + + enviarMensaje( + endpointId, + MensajeP2P( + tipo: TipoMensaje.clienteRegistrado, + datos: { + 'clientId': clientId, + 'sala': _nombreSala, + 'roomId': _roomId, + 'jugadores': _jugadores.values + .map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId}) + .toList(), + 'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(), + if (_estadoSala != null) 'estadoSala': _estadoSala!.toJson(), + }, + ), + ); + _broadcastEstadoSala(); + notifyListeners(); + } + void _handleUsuarioNuevo(MensajeP2P mensaje) { final usuarioJson = mensaje.datos['usuario'] as Map?; if (usuarioJson != null) { final nuevoUsuario = Usuario.fromJson(usuarioJson); _usuariosPool[nuevoUsuario.id] = nuevoUsuario; - // Propagar a todos los clientes + _estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario; if (_esHost) { - enviarATodos( - MensajeP2P( - tipo: TipoMensaje.usuarioNuevo, - datos: {'usuario': usuarioJson}, - ), - ); + _broadcastEstadoSala(); } notifyListeners(); } @@ -433,11 +498,87 @@ class ServicioNearby extends ChangeNotifier { } } + String _clientIdParaEndpoint(String endpointId, MensajeP2P mensaje) { + return mensaje.datos['clientId'] as String? ?? + _estadoSala?.clientePorEndpoint(endpointId)?.clientId ?? + endpointId; + } + + Future _enviarErrorOperacion( + String endpointId, + ResultadoOperacionSala resultado, + ) async { + if (resultado.exitoso) return; + await enviarMensaje( + endpointId, + MensajeP2P(tipo: TipoMensaje.errorOperacion, datos: resultado.toJson()), + ); + } + + void _handleCrearUsuario(String endpointId, MensajeP2P mensaje) { + final sala = _estadoSala; + final usuarioJson = mensaje.datos['usuario'] as Map?; + if (sala == null || usuarioJson == null) return; + final clientId = _clientIdParaEndpoint(endpointId, mensaje); + final usuario = Usuario.fromJson(usuarioJson).copiar( + creadoPorClienteId: clientId, + liberarSeleccion: true, + ); + final resultadoCrear = sala.crearUsuario(usuario); + if (!resultadoCrear.exitoso) { + _enviarErrorOperacion(endpointId, resultadoCrear); + return; + } + if (mensaje.datos['seleccionar'] == true) { + final resultadoSeleccion = sala.seleccionarUsuario( + usuarioId: usuario.id, + clienteId: clientId, + ); + _enviarErrorOperacion(endpointId, resultadoSeleccion); + } + _broadcastEstadoSala(); + } + + void _handleSeleccionarUsuario(String endpointId, MensajeP2P mensaje) { + final sala = _estadoSala; + final usuarioId = mensaje.datos['usuarioId'] as String?; + if (sala == null || usuarioId == null) return; + final resultado = sala.seleccionarUsuario( + usuarioId: usuarioId, + clienteId: _clientIdParaEndpoint(endpointId, mensaje), + ); + _enviarErrorOperacion(endpointId, resultado); + _broadcastEstadoSala(); + } + + void _handleLiberarUsuario(String endpointId, MensajeP2P mensaje) { + final sala = _estadoSala; + final usuarioId = mensaje.datos['usuarioId'] as String?; + if (sala == null || usuarioId == null) return; + final resultado = sala.liberarUsuario( + usuarioId: usuarioId, + solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje), + ); + _enviarErrorOperacion(endpointId, resultado); + _broadcastEstadoSala(); + } + + void _handleEliminarUsuario(String endpointId, MensajeP2P mensaje) { + final sala = _estadoSala; + final usuarioId = mensaje.datos['usuarioId'] as String?; + if (sala == null || usuarioId == null) return; + final resultado = sala.eliminarUsuario( + usuarioId: usuarioId, + solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje), + ); + _enviarErrorOperacion(endpointId, resultado); + _broadcastEstadoSala(); + } + void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) { switch (mensaje.tipo) { case TipoMensaje.salaInfo: _datosPartida = mensaje.datos; - // Sincronizar pool de usuarios si viene en el mensaje final usuariosData = mensaje.datos['usuarios'] as List?; if (usuariosData != null) { _usuariosPool.clear(); @@ -448,42 +589,54 @@ class ServicioNearby extends ChangeNotifier { } notifyListeners(); break; - + case TipoMensaje.clienteRegistrado: + _miClientId = mensaje.datos['clientId'] as String?; + _datosPartida = mensaje.datos; + final estadoSalaJson = + mensaje.datos['estadoSala'] as Map?; + if (estadoSalaJson != null) { + _sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson)); + } + notifyListeners(); + break; + case TipoMensaje.estadoSala: + _sincronizarSala(EstadoSalaMultijugador.fromJson(mensaje.datos)); + notifyListeners(); + break; case TipoMensaje.partidaInicio: - _palabraRecibida = mensaje.datos['palabra'] as String?; - _soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + final jugadoresInicio = mensaje.datos['jugadores'] as List?; + if (jugadoresInicio != null && jugadoresInicio.isNotEmpty) { + final primerJugador = jugadoresInicio.first as Map; + _palabraRecibida = primerJugador['palabra'] as String?; + _soyImpostor = primerJugador['esImpostor'] as bool? ?? false; + } else { + _palabraRecibida = mensaje.datos['palabra'] as String?; + _soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + } _datosPartida = mensaje.datos; notifyListeners(); break; - case TipoMensaje.fase: _faseActual = mensaje.datos['fase'] as String?; _datosPartida = mensaje.datos; notifyListeners(); break; - case TipoMensaje.votacionResultado: - _datosPartida = mensaje.datos; - notifyListeners(); - break; - case TipoMensaje.partidaFin: + case TipoMensaje.errorOperacion: _datosPartida = mensaje.datos; notifyListeners(); break; - case TipoMensaje.jugadorDesconectado: notifyListeners(); break; - default: break; } } - // ==================== ENVÍO ==================== + // ==================== ENVIO ==================== - /// Envía un mensaje a un dispositivo específico Future enviarMensaje(String endpointId, MensajeP2P mensaje) async { try { await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); @@ -492,20 +645,113 @@ class ServicioNearby extends ChangeNotifier { } } - /// Envía un mensaje a todos los dispositivos conectados (solo host) Future enviarATodos(MensajeP2P mensaje) async { for (final id in _jugadores.keys) { await enviarMensaje(id, mensaje); } } + Future crearUsuarioSala(String nombre, {bool seleccionar = true}) async { + final nombreLimpio = nombre.trim(); + if (nombreLimpio.isEmpty) return; + final clientId = _miClientId; + final usuario = Usuario( + id: 'u-${DateTime.now().microsecondsSinceEpoch}', + nombre: nombreLimpio, + creadoPorClienteId: clientId, + ); + if (_esHost && _estadoSala != null && clientId != null) { + final resultado = _estadoSala!.crearUsuario(usuario); + if (resultado.exitoso && seleccionar) { + _estadoSala!.seleccionarUsuario( + usuarioId: usuario.id, + clienteId: clientId, + ); + } + await _broadcastEstadoSala(); + return; + } + final hostId = _hostEndpointId; + if (hostId == null) return; + await enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.crearUsuario, + datos: { + 'clientId': clientId, + 'seleccionar': seleccionar, + 'usuario': usuario.toJson(), + }, + ), + ); + } + + Future seleccionarUsuarioSala(String usuarioId) async { + final clientId = _miClientId; + if (_esHost && _estadoSala != null && clientId != null) { + _estadoSala!.seleccionarUsuario(usuarioId: usuarioId, clienteId: clientId); + await _broadcastEstadoSala(); + return; + } + final hostId = _hostEndpointId; + if (hostId == null) return; + await enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.seleccionarUsuario, + datos: {'clientId': clientId, 'usuarioId': usuarioId}, + ), + ); + } + + Future liberarUsuarioSala(String usuarioId) async { + final clientId = _miClientId; + if (_esHost && _estadoSala != null && clientId != null) { + _estadoSala!.liberarUsuario( + usuarioId: usuarioId, + solicitanteClientId: clientId, + ); + await _broadcastEstadoSala(); + return; + } + final hostId = _hostEndpointId; + if (hostId == null) return; + await enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.liberarUsuario, + datos: {'clientId': clientId, 'usuarioId': usuarioId}, + ), + ); + } + + Future eliminarUsuarioSala(String usuarioId) async { + final clientId = _miClientId; + if (_esHost && _estadoSala != null && clientId != null) { + _estadoSala!.eliminarUsuario( + usuarioId: usuarioId, + solicitanteClientId: clientId, + ); + await _broadcastEstadoSala(); + return; + } + final hostId = _hostEndpointId; + if (hostId == null) return; + await enviarMensaje( + hostId, + MensajeP2P( + tipo: TipoMensaje.eliminarUsuario, + datos: {'clientId': clientId, 'usuarioId': usuarioId}, + ), + ); + } + // ==================== HOST: ACCIONES DE JUEGO ==================== - /// Host envía inicio de partida con la palabra de cada jugador Future enviarInicioPartida({ required String palabraSecreta, required String categoria, - required Map impostores, // endpointId -> esImpostor + required Map impostores, }) async { for (final entry in _jugadores.entries) { final esImpostor = impostores[entry.key] ?? false; @@ -517,14 +763,39 @@ class ServicioNearby extends ChangeNotifier { 'palabra': esImpostor ? null : palabraSecreta, 'esImpostor': esImpostor, 'categoria': categoria, - 'numJugadores': _jugadores.length + 1, // +1 por el host + 'numJugadores': _jugadores.length + 1, }, ), ); } } - /// Host envía cambio de fase + Future enviarInicioPartidaMulti({ + required List asignaciones, + required String palabraSecreta, + required String categoria, + required Map impostoresPorJugadorId, + required List> jugadoresTodos, + }) async { + final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( + asignaciones: asignaciones, + palabraSecreta: palabraSecreta, + categoria: categoria, + impostoresPorJugadorId: impostoresPorJugadorId, + ); + + for (final payload in payloads.values) { + final endpointId = payload.endpointId; + if (endpointId == null) continue; + final datos = payload.toJson(); + datos['jugadoresTodos'] = jugadoresTodos; + await enviarMensaje( + endpointId, + MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos), + ); + } + } + Future enviarCambioFase( String fase, [ Map? extra, @@ -533,14 +804,12 @@ class ServicioNearby extends ChangeNotifier { await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); } - /// Host envía resultado de votación Future enviarResultadoVotacion(Map resultado) async { await enviarATodos( MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado), ); } - /// Host envía fin de partida Future enviarFinPartida(Map resultado) async { await enviarATodos( MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado), @@ -549,7 +818,6 @@ class ServicioNearby extends ChangeNotifier { // ==================== LIMPIEZA ==================== - /// Desconecta y limpia todo Future desconectar() async { try { await Nearby().stopAllEndpoints(); @@ -565,27 +833,30 @@ class ServicioNearby extends ChangeNotifier { _anunciando = false; _miEndpointId = null; _hostEndpointId = null; + _roomId = null; + _miClientId = null; _nombreSala = null; _miNombre = null; _palabraRecibida = null; _soyImpostor = null; _faseActual = null; _datosPartida = null; + _estadoSala = null; _jugadores.clear(); _hostsEncontrados.clear(); + _usuariosPool.clear(); notifyListeners(); } - /// Genera los datos para el código QR de conexión String generarDatosQR(String nombreSala) { return json.encode({ 'app': 'farolero', 'sala': nombreSala, 'host': _miNombre, + 'roomId': _roomId, }); } - /// Parsea datos de QR escaneado static Map? parsearQR(String datos) { try { final mapa = json.decode(datos) as Map; diff --git a/test/modelos/inicio_partida_multijugador_test.dart b/test/modelos/inicio_partida_multijugador_test.dart new file mode 100644 index 0000000..c43110f --- /dev/null +++ b/test/modelos/inicio_partida_multijugador_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; + +void main() { + group('InicioPartidaMultijugador', () { + test('agrupa varios jugadores controlados por el mismo cliente', () { + final asignaciones = [ + const AsignacionJugador( + jugadorId: 'ana', + nombre: 'Ana', + clientId: 'host', + endpointId: null, + ), + const AsignacionJugador( + jugadorId: 'juan', + nombre: 'Juan', + clientId: 'host', + endpointId: null, + ), + const AsignacionJugador( + jugadorId: 'sofia', + nombre: 'Sofía', + clientId: 'cliente-sofia', + endpointId: 'endpoint-2', + ), + ]; + final impostores = {'juan': true}; + + final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente( + asignaciones: asignaciones, + palabraSecreta: 'mate', + categoria: 'objetos', + impostoresPorJugadorId: impostores, + ); + + expect(payloads['host']?.jugadores.map((j) => j.jugadorId), [ + 'ana', + 'juan', + ]); + expect(payloads['host']?.jugadores.first.palabra, 'mate'); + expect(payloads['host']?.jugadores.last.esImpostor, isTrue); + expect(payloads['host']?.jugadores.last.palabra, isNull); + expect(payloads['cliente-sofia']?.endpointId, 'endpoint-2'); + expect(payloads['cliente-sofia']?.jugadores.single.nombre, 'Sofía'); + }); + + test('restaura payload completo desde json', () { + final payload = InicioPartidaCliente( + clientId: 'cliente-sofia', + endpointId: 'endpoint-2', + categoria: 'todas', + jugadores: const [ + JugadorInicioPartida( + jugadorId: 'sofia', + nombre: 'Sofía', + esImpostor: false, + palabra: 'luna', + ), + JugadorInicioPartida( + jugadorId: 'helena', + nombre: 'Helena', + esImpostor: true, + palabra: null, + ), + ], + ); + + final restaurado = InicioPartidaCliente.fromJson(payload.toJson()); + + expect(restaurado.clientId, 'cliente-sofia'); + expect(restaurado.endpointId, 'endpoint-2'); + expect(restaurado.jugadores.length, 2); + expect(restaurado.jugadores.first.palabra, 'luna'); + expect(restaurado.jugadores.last.esImpostor, isTrue); + }); + }); +} diff --git a/test/modelos/sala_multijugador_test.dart b/test/modelos/sala_multijugador_test.dart new file mode 100644 index 0000000..38d0bb4 --- /dev/null +++ b/test/modelos/sala_multijugador_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/sala_multijugador.dart'; +import 'package:farolero/modelos/usuario.dart'; + +void main() { + group('EstadoSalaMultijugador', () { + late EstadoSalaMultijugador sala; + + setUp(() { + sala = EstadoSalaMultijugador.crear( + roomId: 'room-1', + nombreSala: 'Ana - Farolero', + hostClientId: 'host', + hostNombre: 'Ana', + ); + }); + + test('registra al host como cliente conectado de la sala', () { + expect(sala.hostClientId, 'host'); + expect(sala.clientes['host']?.esHost, isTrue); + expect(sala.clientes['host']?.conectado, isTrue); + }); + + test('permite seleccionar varios usuarios para un mismo cliente', () { + sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana')); + sala.crearUsuario(Usuario(id: 'juan', nombre: 'Juan')); + + final seleccionAna = sala.seleccionarUsuario( + usuarioId: 'ana', + clienteId: 'host', + ); + final seleccionJuan = sala.seleccionarUsuario( + usuarioId: 'juan', + clienteId: 'host', + ); + + expect(seleccionAna.exitoso, isTrue); + expect(seleccionJuan.exitoso, isTrue); + expect(sala.usuariosPorCliente('host').map((u) => u.nombre), [ + 'Ana', + 'Juan', + ]); + }); + + test('impide que dos clientes seleccionen el mismo usuario', () { + sala.registrarCliente( + const ClienteSala( + clientId: 'cliente-jorge', + endpointId: 'endpoint-1', + nombre: 'Jorge', + ), + ); + sala.crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía')); + + final primeraSeleccion = sala.seleccionarUsuario( + usuarioId: 'sofia', + clienteId: 'host', + ); + final segundaSeleccion = sala.seleccionarUsuario( + usuarioId: 'sofia', + clienteId: 'cliente-jorge', + ); + + expect(primeraSeleccion.exitoso, isTrue); + expect(segundaSeleccion.exitoso, isFalse); + expect(segundaSeleccion.codigo, 'usuario_ya_seleccionado'); + expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'host'); + }); + + test('solo permite eliminar usuarios no seleccionados y por el host', () { + sala.registrarCliente( + const ClienteSala( + clientId: 'cliente-jorge', + endpointId: 'endpoint-1', + nombre: 'Jorge', + ), + ); + sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana')); + sala.crearUsuario(Usuario(id: 'javier', nombre: 'Javier')); + sala.seleccionarUsuario(usuarioId: 'ana', clienteId: 'host'); + + final clienteElimina = sala.eliminarUsuario( + usuarioId: 'javier', + solicitanteClientId: 'cliente-jorge', + ); + final hostEliminaSeleccionado = sala.eliminarUsuario( + usuarioId: 'ana', + solicitanteClientId: 'host', + ); + final hostEliminaLibre = sala.eliminarUsuario( + usuarioId: 'javier', + solicitanteClientId: 'host', + ); + + expect(clienteElimina.exitoso, isFalse); + expect(clienteElimina.codigo, 'solo_host'); + expect(hostEliminaSeleccionado.exitoso, isFalse); + expect(hostEliminaSeleccionado.codigo, 'usuario_seleccionado'); + expect(hostEliminaLibre.exitoso, isTrue); + expect(sala.usuarios.containsKey('javier'), isFalse); + }); + + test('valida inicio con minimo tres usuarios y host seleccionado', () { + sala + ..crearUsuario(Usuario(id: 'ana', nombre: 'Ana')) + ..crearUsuario(Usuario(id: 'juan', nombre: 'Juan')) + ..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía')); + + expect(sala.validarInicio().exitoso, isFalse); + expect(sala.validarInicio().codigo, 'faltan_jugadores'); + + sala + ..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host') + ..seleccionarUsuario(usuarioId: 'juan', clienteId: 'host') + ..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'host'); + + expect(sala.validarInicio().exitoso, isTrue); + expect(sala.iniciarPartida().exitoso, isTrue); + expect(sala.fase, FaseSalaMultijugador.enPartida); + }); + + test('libera usuarios de un cliente desconectado durante lobby', () { + sala.registrarCliente( + const ClienteSala( + clientId: 'cliente-sofia', + endpointId: 'endpoint-2', + nombre: 'Sofía', + ), + ); + sala + ..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía')) + ..crearUsuario(Usuario(id: 'helena', nombre: 'Helena')) + ..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'cliente-sofia') + ..seleccionarUsuario(usuarioId: 'helena', clienteId: 'cliente-sofia'); + + sala.desconectarCliente('cliente-sofia'); + + expect(sala.clientes['cliente-sofia']?.conectado, isFalse); + expect(sala.usuarios['sofia']?.estaDisponible, isTrue); + expect(sala.usuarios['helena']?.estaDisponible, isTrue); + }); + + test('serializa y restaura clientes y usuarios seleccionados', () { + sala + ..crearUsuario(Usuario(id: 'ana', nombre: 'Ana')) + ..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host'); + + final restaurada = EstadoSalaMultijugador.fromJson(sala.toJson()); + + expect(restaurada.roomId, 'room-1'); + expect(restaurada.clientes['host']?.esHost, isTrue); + expect(restaurada.usuarios['ana']?.clienteIdSeleccionado, 'host'); + expect(restaurada.usuariosSeleccionados.single.nombre, 'Ana'); + }); + }); +}