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. enum TipoMensaje { salaInfo, partidaInicio, fase, votacionResultado, partidaFin, unirse, voto, listo, ping, jugadorDesconectado, clienteRegistrado, estadoSala, crearUsuario, seleccionarUsuario, liberarUsuario, errorOperacion, usuarioNuevo, usuarioEliminado, usuariosActualizados, } /// Mensaje del protocolo P2P entre dispositivos. class MensajeP2P { final TipoMensaje tipo; final Map datos; MensajeP2P({required this.tipo, required this.datos}); String toJson() => json.encode({'tipo': tipo.name, 'datos': datos}); factory MensajeP2P.fromJson(String jsonStr) { final mapa = json.decode(jsonStr) as Map; return MensajeP2P( tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']), datos: mapa['datos'] as Map, ); } Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson())); } /// 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; bool listo; JugadorConectado({ required this.endpointId, required this.nombre, this.listo = false, }); } 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; bool _buscando = false; 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 = {}; String? _palabraRecibida; bool? _soyImpostor; String? _faseActual; Map? _datosPartida; EstadoSalaMultijugador? _estadoSala; bool get esHost => _esHost; bool get conectado => _conectado; bool get buscando => _buscando; 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); 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); } void onMensaje(OnMensajeCallback callback) { _listeners.add(callback); } void removeMensajeListener(OnMensajeCallback callback) { _listeners.remove(callback); } void _notificarMensaje(String endpointId, MensajeP2P mensaje) { for (final listener in _listeners) { listener(endpointId, mensaje); } } // ==================== USER POOL / SALA ==================== void agregarUsuario(Usuario usuario) { _usuariosPool[usuario.id] = usuario; _estadoSala?.usuarios[usuario.id] = usuario; notifyListeners(); } void eliminarUsuario(String usuarioId) { _usuariosPool.remove(usuarioId); _estadoSala?.usuarios.remove(usuarioId); notifyListeners(); } Usuario? getUsuario(String usuarioId) { return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId]; } void sincronizarUsuarios(List usuarios) { _usuariosPool.clear(); for (final usuario in usuarios) { _usuariosPool[usuario.id] = usuario; } notifyListeners(); } 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 ?? '', nombre: _miNombre!, listo: true, ); } // ==================== HOST ==================== 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( miNombre, Strategy.P2P_STAR, onConnectionInitiated: _onConexionIniciada, onConnectionResult: _onResultadoConexion, onDisconnected: _onDesconexion, serviceId: _serviceId, ); if (resultado) { _esHost = true; _anunciando = true; _conectado = true; notifyListeners(); return true; } return false; } catch (e) { debugPrint('Error iniciando host: $e'); return false; } } // ==================== CLIENTE ==================== Future buscarHosts(String miNombre) async { _miNombre = miNombre; try { final resultado = await Nearby().startDiscovery( miNombre, Strategy.P2P_STAR, onEndpointFound: _onEndpointEncontrado, onEndpointLost: _onEndpointPerdido, serviceId: _serviceId, ); if (resultado) { _buscando = true; notifyListeners(); return true; } return false; } catch (e) { debugPrint('Error buscando hosts: $e'); return false; } } Future conectarAHost(String endpointId, String miNombre) async { try { await Nearby().requestConnection( miNombre, endpointId, onConnectionInitiated: _onConexionIniciada, onConnectionResult: _onResultadoConexion, onDisconnected: _onDesconexion, ); return true; } catch (e) { debugPrint('Error conectando a host: $e'); return false; } } // ==================== CALLBACKS NEARBY ==================== void _onConexionIniciada(String endpointId, ConnectionInfo info) { debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}'); Nearby().acceptConnection( endpointId, onPayLoadRecieved: _onPayloadRecibido, onPayloadTransferUpdate: _onPayloadUpdate, ); } void _onResultadoConexion(String endpointId, Status status) { debugPrint('Resultado conexion $endpointId: $status'); if (status == Status.CONNECTED) { if (_esHost) { debugPrint('Cliente conectado: $endpointId'); } else { _hostEndpointId = endpointId; _conectado = true; enviarMensaje( endpointId, MensajeP2P( tipo: TipoMensaje.unirse, datos: {'nombre': _miNombre ?? 'Jugador'}, ), ); } notifyListeners(); } else { debugPrint('Conexion fallida con $endpointId'); } } void _onDesconexion(String 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) { enviarATodos( MensajeP2P( tipo: TipoMensaje.jugadorDesconectado, datos: {'nombre': jugador.nombre, 'endpointId': endpointId}, ), ); } } else { _conectado = false; _hostEndpointId = null; } notifyListeners(); } void _onEndpointEncontrado( String endpointId, String endpointName, String serviceId, ) { debugPrint('Host encontrado: $endpointName ($endpointId)'); _hostsEncontrados[endpointId] = endpointName; notifyListeners(); } void _onEndpointPerdido(String? endpointId) { debugPrint('Endpoint perdido: $endpointId'); if (endpointId != null) { _hostsEncontrados.remove(endpointId); notifyListeners(); } } Future pararBusqueda() async { try { await Nearby().stopDiscovery(); } catch (_) {} _buscando = false; _hostsEncontrados.clear(); notifyListeners(); } void _onPayloadRecibido(String endpointId, Payload payload) { if (payload.type == PayloadType.BYTES && payload.bytes != null) { try { final jsonStr = utf8.decode(payload.bytes!); final mensaje = MensajeP2P.fromJson(jsonStr); _procesarMensaje(endpointId, mensaje); } catch (e) { debugPrint('Error procesando payload: $e'); } } } void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {} // ==================== PROCESAMIENTO DE MENSAJES ==================== void _procesarMensaje(String endpointId, MensajeP2P mensaje) { debugPrint('Mensaje de $endpointId: ${mensaje.tipo.name}'); if (_esHost) { _procesarMensajeHost(endpointId, mensaje); } else { _procesarMensajeCliente(endpointId, mensaje); } _notificarMensaje(endpointId, mensaje); } void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { switch (mensaje.tipo) { case TipoMensaje.unirse: _registrarClienteRemoto(endpointId, mensaje); break; case TipoMensaje.voto: _notificarMensaje(endpointId, mensaje); break; case TipoMensaje.listo: final jugador = _jugadores[endpointId]; if (jugador != null) { jugador.listo = true; 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; _estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario; if (_esHost) { _broadcastEstadoSala(); } notifyListeners(); } } void _handleUsuariosActualizados(MensajeP2P mensaje) { final usuariosJson = mensaje.datos['usuarios'] as List?; if (usuariosJson != null) { _usuariosPool.clear(); for (final u in usuariosJson) { final usuario = Usuario.fromJson(u as Map); _usuariosPool[usuario.id] = usuario; } notifyListeners(); } } 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; final usuariosData = mensaje.datos['usuarios'] as List?; if (usuariosData != null) { _usuariosPool.clear(); for (final u in usuariosData) { final usuario = Usuario.fromJson(u as Map); _usuariosPool[usuario.id] = usuario; } } 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: 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: case TipoMensaje.partidaFin: case TipoMensaje.errorOperacion: _datosPartida = mensaje.datos; notifyListeners(); break; case TipoMensaje.jugadorDesconectado: notifyListeners(); break; default: break; } } // ==================== ENVIO ==================== Future enviarMensaje(String endpointId, MensajeP2P mensaje) async { try { await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); } catch (e) { debugPrint('Error enviando a $endpointId: $e'); } } 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 ==================== Future enviarInicioPartida({ required String palabraSecreta, required String categoria, required Map impostores, }) async { for (final entry in _jugadores.entries) { final esImpostor = impostores[entry.key] ?? false; await enviarMensaje( entry.key, MensajeP2P( tipo: TipoMensaje.partidaInicio, datos: { 'palabra': esImpostor ? null : palabraSecreta, 'esImpostor': esImpostor, 'categoria': categoria, 'numJugadores': _jugadores.length + 1, }, ), ); } } 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, ]) async { final datos = {'fase': fase, ...?extra}; await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); } Future enviarResultadoVotacion(Map resultado) async { await enviarATodos( MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado), ); } Future enviarFinPartida(Map resultado) async { await enviarATodos( MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado), ); } // ==================== LIMPIEZA ==================== Future desconectar() async { try { await Nearby().stopAllEndpoints(); if (_anunciando) await Nearby().stopAdvertising(); if (_buscando) await Nearby().stopDiscovery(); } catch (e) { debugPrint('Error desconectando: $e'); } _esHost = false; _conectado = false; _buscando = false; _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(); } String generarDatosQR(String nombreSala) { return json.encode({ 'app': 'farolero', 'sala': nombreSala, 'host': _miNombre, 'roomId': _roomId, }); } static Map? parsearQR(String datos) { try { final mapa = json.decode(datos) as Map; if (mapa['app'] == 'farolero') return mapa; return null; } catch (_) { return null; } } @override void dispose() { desconectar(); super.dispose(); } }