Files
farolero/lib/servicios/servicio_nearby.dart
ShanaiaBot d3fc3386f9 feat(multi-device): host puede participar como jugador
- Añadido modelo Usuario con pool de usuarios sincronizado
- El host ahora recibe palabra y rol como cualquier jugador
- UI de selección de perfil en pantallas de lobby
- Los clientes pueden ver usuarios del servidor o crear nuevos
- El juego no inicia hasta que el host selecciona perfil
2026-04-24 18:47:56 +02:00

605 lines
16 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/usuario.dart';
/// Tipos de mensajes en el protocolo P2P
enum TipoMensaje {
salaInfo,
partidaInicio,
fase,
votacionResultado,
partidaFin,
unirse,
voto,
listo,
ping,
jugadorDesconectado,
usuarioNuevo,
usuarioEliminado,
usuariosActualizados,
}
/// Mensaje del protocolo P2P entre dispositivos
class MensajeP2P {
final TipoMensaje tipo;
final Map<String, dynamic> 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<String, dynamic>;
return MensajeP2P(
tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']),
datos: mapa['datos'] as Map<String, dynamic>,
);
}
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson()));
}
/// Info de un jugador conectado
class JugadorConectado {
final String endpointId;
final String nombre;
bool listo;
JugadorConectado({
required this.endpointId,
required this.nombre,
this.listo = false,
});
}
/// 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';
bool _esHost = false;
bool _conectado = false;
bool _buscando = false;
bool _anunciando = false;
String? _miEndpointId;
String? _hostEndpointId;
String? _nombreSala;
String? _miNombre;
final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = [];
// Hosts descubiertos (para discovery automático)
final Map<String, String> _hostsEncontrados = {}; // endpointId -> nombre
// Estado para clientes
String? _palabraRecibida;
bool? _soyImpostor;
String? _faseActual;
Map<String, dynamic>? _datosPartida;
// Pool de usuarios para modo multi-dispositivo
final Map<String, Usuario> _usuariosPool = {};
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 nombreSala => _nombreSala;
String? get miNombre => _miNombre;
String? get palabraRecibida => _palabraRecibida;
bool? get soyImpostor => _soyImpostor;
String? get faseActual => _faseActual;
Map<String, dynamic>? get datosPartida => _datosPartida;
List<JugadorConectado> get jugadores => _jugadores.values.toList();
int get numJugadoresConectados => _jugadores.length;
Map<String, String> get hostsEncontrados =>
Map.unmodifiable(_hostsEncontrados);
/// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo
List<Usuario> get usuarios => _usuariosPool.values.toList();
/// Registra un listener de mensajes
void onMensaje(OnMensajeCallback callback) {
_listeners.add(callback);
}
/// Elimina un listener
void removeMensajeListener(OnMensajeCallback callback) {
_listeners.remove(callback);
}
void _notificarMensaje(String endpointId, MensajeP2P mensaje) {
for (final listener in _listeners) {
listener(endpointId, mensaje);
}
}
// ==================== USER POOL ====================
/// Agrega un usuario al pool de usuarios
void agregarUsuario(Usuario usuario) {
_usuariosPool[usuario.id] = usuario;
notifyListeners();
}
/// Elimina un usuario del pool
void eliminarUsuario(String usuarioId) {
_usuariosPool.remove(usuarioId);
notifyListeners();
}
/// Obtiene un usuario por su ID
Usuario? getUsuario(String usuarioId) {
return _usuariosPool[usuarioId];
}
/// Sincroniza el pool de usuarios con una lista
void sincronizarUsuarios(List<Usuario> usuarios) {
_usuariosPool.clear();
for (final usuario in usuarios) {
_usuariosPool[usuario.id] = usuario;
}
notifyListeners();
}
/// Obtiene el jugador local del host (él mismo como participante)
/// Retorna un JugadorConectado con endpointId null porque es local
JugadorConectado? getJugadorLocal() {
if (_miNombre == null) return null;
return JugadorConectado(
endpointId: _miEndpointId ?? '', // vacío indica que es el host local
nombre: _miNombre!,
listo: true,
);
}
// ==================== HOST ====================
/// Inicia como host (anunciando el endpoint)
Future<bool> iniciarHost(String nombreSala, String miNombre) async {
_nombreSala = nombreSala;
_miNombre = miNombre;
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 ====================
/// Busca hosts disponibles
Future<bool> 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;
}
}
/// Conecta a un host específico
Future<bool> 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('Conexión iniciada con $endpointId: ${info.endpointName}');
// Auto-aceptar conexiones
Nearby().acceptConnection(
endpointId,
onPayLoadRecieved: _onPayloadRecibido,
onPayloadTransferUpdate: _onPayloadUpdate,
);
}
void _onResultadoConexion(String endpointId, Status status) {
debugPrint('Resultado conexión $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(
tipo: TipoMensaje.unirse,
datos: {'nombre': _miNombre ?? 'Jugador'},
),
);
}
notifyListeners();
} else {
debugPrint('Conexión fallida con $endpointId');
}
}
void _onDesconexion(String endpointId) {
debugPrint('Desconexión: $endpointId');
if (_esHost) {
final jugador = _jugadores.remove(endpointId);
if (jugador != null) {
// Notificar a todos que se desconectó
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado,
datos: {'nombre': jugador.nombre, 'endpointId': endpointId},
),
);
}
} else {
// Cliente perdió conexión con host
_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();
}
}
/// Para el discovery sin desconectar
Future<void> 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) {
// No necesitamos trackear progreso para bytes pequeños
}
// ==================== 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:
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();
break;
case TipoMensaje.voto:
// Propagar al flujo de juego
_notificarMensaje(endpointId, mensaje);
break;
case TipoMensaje.listo:
final jugador = _jugadores[endpointId];
if (jugador != null) {
jugador.listo = true;
notifyListeners();
}
break;
case TipoMensaje.usuarioNuevo:
_handleUsuarioNuevo(mensaje);
break;
case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje);
break;
default:
break;
}
}
void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) {
final nuevoUsuario = Usuario.fromJson(usuarioJson);
_usuariosPool[nuevoUsuario.id] = nuevoUsuario;
// Propagar a todos los clientes
if (_esHost) {
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuarioJson},
),
);
}
notifyListeners();
}
}
void _handleUsuariosActualizados(MensajeP2P mensaje) {
final usuariosJson = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosJson != null) {
_usuariosPool.clear();
for (final u in usuariosJson) {
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
_usuariosPool[usuario.id] = usuario;
}
notifyListeners();
}
}
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<dynamic>?;
if (usuariosData != null) {
_usuariosPool.clear();
for (final u in usuariosData) {
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
_usuariosPool[usuario.id] = usuario;
}
}
notifyListeners();
break;
case TipoMensaje.partidaInicio:
_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:
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.jugadorDesconectado:
notifyListeners();
break;
default:
break;
}
}
// ==================== ENVÍO ====================
/// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
try {
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
} catch (e) {
debugPrint('Error enviando a $endpointId: $e');
}
}
/// Envía un mensaje a todos los dispositivos conectados (solo host)
Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _jugadores.keys) {
await enviarMensaje(id, mensaje);
}
}
// ==================== HOST: ACCIONES DE JUEGO ====================
/// Host envía inicio de partida con la palabra de cada jugador
Future<void> enviarInicioPartida({
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostores, // endpointId -> esImpostor
}) 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, // +1 por el host
},
),
);
}
}
/// Host envía cambio de fase
Future<void> enviarCambioFase(
String fase, [
Map<String, dynamic>? extra,
]) async {
final datos = {'fase': fase, ...?extra};
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
}
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
);
}
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
);
}
// ==================== LIMPIEZA ====================
/// Desconecta y limpia todo
Future<void> 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;
_nombreSala = null;
_miNombre = null;
_palabraRecibida = null;
_soyImpostor = null;
_faseActual = null;
_datosPartida = null;
_jugadores.clear();
_hostsEncontrados.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,
});
}
/// Parsea datos de QR escaneado
static Map<String, dynamic>? parsearQR(String datos) {
try {
final mapa = json.decode(datos) as Map<String, dynamic>;
if (mapa['app'] == 'farolero') return mapa;
return null;
} catch (_) {
return null;
}
}
@override
void dispose() {
desconectar();
super.dispose();
}
}