Compare commits

..

16 Commits

Author SHA1 Message Date
ShanaiaBot
ab0d4dc2ba chore: bump version to 1.1.9+14 [ci skip] 2026-04-27 16:04:31 +02:00
Javier Bautista Fernández
50b050e678 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m57s
2026-04-27 16:04:10 +02:00
Javier Bautista Fernández
5d3b3ef271 feat: Add eliminarUsuario message type and handle user removal in ServicioNearby 2026-04-27 16:04:03 +02:00
c8e5cf25c5 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
Build & Deploy Farolero / Análisis de código (push) Failing after 13s
2026-04-27 14:43:52 +02:00
d850b66089 Actualizar .gitea/workflows/build.yml 2026-04-27 14:43:36 +02:00
Javier Bautista Fernández
166b89a661 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:41:01 +02:00
Javier Bautista Fernández
1cb2260298 chore: Remove PATH from environment variables and add Flutter version check steps 2026-04-27 14:40:43 +02:00
da9bd0cd4a Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:37:50 +02:00
d600835105 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 6s
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
2026-04-27 14:36:44 +02:00
Javier Bautista Fernández
a8d5b0f002 feat: Implement multiplayer game session management
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Has been cancelled
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
- Add models for managing player assignments and game session initialization in `inicio_partida_multijugador.dart`.
- Create a multiplayer room state management system in `sala_multijugador.dart`, including user registration, selection, and session validation.
- Develop a UI screen for displaying player words sequentially in `pantalla_palabras_cliente.dart`.
- Implement unit tests for the multiplayer session management and player assignment logic in `inicio_partida_multijugador_test.dart` and `sala_multijugador_test.dart`.
2026-04-27 14:02:33 +02:00
ShanaiaBot
4a1abd0be0 chore: bump version to 1.1.8+13 [ci skip] 2026-04-24 21:38:01 +02:00
f3dcb99de1 Merge pull request 'fix: boton ver palabra del host ahora funciona' (#3) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m18s
Reviewed-on: #3
2026-04-24 21:37:42 +02:00
FreeTLab
f41fbc7dd9 fix: boton ver palabra del host ahora funciona 2026-04-24 21:34:40 +02:00
ShanaiaBot
e3c502c7df chore: bump version to 1.1.7+12 [ci skip] 2026-04-24 20:04:17 +02:00
3f4ec2d20f Merge pull request 'feat: host como jugador' (#2) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m51s
Reviewed-on: #2
2026-04-24 20:03:59 +02:00
FreeTLab
1231b32c3c feat: host como jugador 2026-04-24 20:01:54 +02:00
19 changed files with 1981 additions and 423 deletions

View File

@@ -5,7 +5,6 @@ on:
branches: [main] branches: [main]
env: env:
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
ANDROID_HOME: /Users/freetlab/Library/Android/sdk ANDROID_HOME: /Users/freetlab/Library/Android/sdk
jobs: jobs:
@@ -14,6 +13,10 @@ jobs:
runs-on: [self-hosted, macos, arm64, flutter] runs-on: [self-hosted, macos, arm64, flutter]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Verificar Flutter
run: |
which flutter
flutter --version
- name: Obtener dependencias - name: Obtener dependencias
run: flutter pub get run: flutter pub get
- name: Generar l10n - name: Generar l10n
@@ -28,6 +31,10 @@ jobs:
if: ${{ gitea.ref == 'refs/heads/main' }} if: ${{ gitea.ref == 'refs/heads/main' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Verificar Flutter
run: |
which flutter
flutter --version
- name: Fetch completo + Bump versión patch + commit - name: Fetch completo + Bump versión patch + commit
run: | run: |

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ build/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.packages .packages
.atl/

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/sala_multijugador.dart';
import '../servicios/servicio_notas.dart'; import '../servicios/servicio_notas.dart';
/// Estado global del juego gestionado con Provider /// Estado global del juego gestionado con Provider
@@ -89,6 +90,61 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners(); 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 = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
}
for (final jugador in jugadores) {
if (!jugador.esImpostor) {
jugador.palabra = palabra;
}
}
_partida = Partida(
config: config,
jugadores: jugadores,
palabraSecreta: palabra,
categoriaReal: categoriaReal,
);
_votos.clear();
ServicioNotas.limpiarNotas();
notifyListeners();
}
/// Avanza a la fase de debate /// Avanza a la fase de debate
void iniciarDebate() { void iniciarDebate() {
if (_partida == null) return; if (_partida == null) return;

View File

@@ -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<String, dynamic> toJson() => {
'jugadorId': jugadorId,
'nombre': nombre,
'esImpostor': esImpostor,
if (palabra != null) 'palabra': palabra,
};
factory JugadorInicioPartida.fromJson(Map<String, dynamic> 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<JugadorInicioPartida> jugadores;
const InicioPartidaCliente({
required this.clientId,
required this.endpointId,
required this.categoria,
required this.jugadores,
});
Map<String, dynamic> toJson() => {
'clientId': clientId,
if (endpointId != null) 'endpointId': endpointId,
'categoria': categoria,
'jugadores': jugadores.map((jugador) => jugador.toJson()).toList(),
};
factory InicioPartidaCliente.fromJson(Map<String, dynamic> json) {
return InicioPartidaCliente(
clientId: json['clientId'] as String,
endpointId: json['endpointId'] as String?,
categoria: json['categoria'] as String,
jugadores: (json['jugadores'] as List<dynamic>? ?? [])
.map((jugadorJson) => JugadorInicioPartida.fromJson(
jugadorJson as Map<String, dynamic>,
))
.toList(),
);
}
}
class InicioPartidaMultijugador {
static Map<String, InicioPartidaCliente> crearPayloadsPorCliente({
required List<AsignacionJugador> asignaciones,
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostoresPorJugadorId,
}) {
final payloads = <String, InicioPartidaCliente>{};
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;
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'clientId': clientId,
if (endpointId != null) 'endpointId': endpointId,
'nombre': nombre,
'esHost': esHost,
'conectado': conectado,
};
factory ClienteSala.fromJson(Map<String, dynamic> 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<String, ClienteSala> clientes;
final Map<String, Usuario> usuarios;
EstadoSalaMultijugador({
required this.roomId,
required this.nombreSala,
required this.hostClientId,
this.fase = FaseSalaMultijugador.lobby,
Map<String, ClienteSala>? clientes,
Map<String, Usuario>? 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<Usuario> get usuariosSeleccionados =>
usuarios.values.where((usuario) => usuario.estaSeleccionado).toList();
List<Usuario> get usuariosDisponibles =>
usuarios.values.where((usuario) => usuario.estaDisponible).toList();
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
List<Usuario> 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<String, dynamic> 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<String, dynamic> json) {
final clientes = <String, ClienteSala>{};
for (final clienteJson in json['clientes'] as List<dynamic>? ?? []) {
final cliente = ClienteSala.fromJson(
clienteJson as Map<String, dynamic>,
);
clientes[cliente.clientId] = cliente;
}
final usuarios = <String, Usuario>{};
for (final usuarioJson in json['usuarios'] as List<dynamic>? ?? []) {
final usuario = Usuario.fromJson(usuarioJson as Map<String, dynamic>);
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,
);
}
}

View File

@@ -3,18 +3,53 @@ class Usuario {
final String id; final String id;
final String nombre; final String nombre;
final String? avatar; 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<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'nombre': nombre, 'nombre': nombre,
if (avatar != null) 'avatar': avatar, if (avatar != null) 'avatar': avatar,
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
if (clienteIdSeleccionado != null)
'clienteIdSeleccionado': clienteIdSeleccionado,
}; };
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario( factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
id: json['id'] as String, id: json['id'] as String,
nombre: json['nombre'] as String, nombre: json['nombre'] as String,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
creadoPorClienteId: json['creadoPorClienteId'] as String?,
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
@@ -153,15 +154,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
onIniciar: () { onIniciar: () {
// Cuando el host toca "Iniciar" con suficientes jugadores // Cuando el host toca "Iniciar" con suficientes jugadores
final estado = context.read<EstadoJuego>(); final estado = context.read<EstadoJuego>();
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.crearPartidaDesdeSala(
estado.setHostJugador(nombre.trim());
final jugadoresMulti = [
nombre.trim(),
...nearby.jugadores.map((j) => j.nombre),
];
estado.crearPartida(
config: ConfigPartida( config: ConfigPartida(
modoMultimovil: true, modoMultimovil: true,
categoria: _categoria, categoria: _categoria,
@@ -169,24 +176,41 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
pistaImpostor: _pistaImpostor, pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate, tiempoDebateSegundos: _tiempoDebate,
), ),
nombresJugadores: jugadoresMulti, sala: sala,
); );
// Enviar palabras a cada jugador via Nearby
final partida = estado.partida!; final partida = estado.partida!;
final impostores = <String, bool>{}; final asignaciones = partida.jugadores.map((jugador) {
for (int i = 0; i < nearby.jugadores.length; i++) { final usuarioSala = sala.usuarios[jugador.id];
final jugadorNearby = nearby.jugadores[i]; final clientId = usuarioSala?.clienteIdSeleccionado;
// El jugador [0] es el host, los de nearby son [1..n] final cliente = clientId == null ? null : sala.clientes[clientId];
final jugadorPartida = partida.jugadores[i + 1]; return AsignacionJugador(
impostores[jugadorNearby.endpointId] = jugadorId: jugador.id,
jugadorPartida.esImpostor; 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, palabraSecreta: partida.palabraSecreta,
categoria: _categoria, categoria: _categoria,
impostores: impostores, impostoresPorJugadorId: impostores,
jugadoresTodos: jugadoresTodos,
); );
Navigator.pushReplacement( Navigator.pushReplacement(
@@ -230,7 +254,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: seleccionado, initialValue: seleccionado,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.person), prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile, hintText: l10n.selectProfile,

View File

@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_votacion_cliente.dart';
import 'pantalla_palabras_cliente.dart';
class PantallaGestorHost extends StatefulWidget { class PantallaGestorHost extends StatefulWidget {
final VoidCallback onPartidaFin; final VoidCallback onPartidaFin;
@@ -52,8 +54,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
setState(() => _clientesListos[endpointId] = true); setState(() => _clientesListos[endpointId] = true);
} else if (mensaje.tipo == TipoMensaje.voto) { } else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?; final votanteId = mensaje.datos['votanteId'] as String?;
final votoId = mensaje.datos['votoporId'] as String?; final votoId =
mensaje.datos['votadoId'] as String? ??
mensaje.datos['votoporId'] as String?;
if (votanteId != null && votoId != null) { if (votanteId != null && votoId != null) {
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId); setState(() => _votosRecibidos[votanteId] = votoId);
} }
} }
@@ -117,9 +122,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
final numJugadores = partida.jugadores.length + 1; final todosListos = _clientesListos.length >= nearby.jugadores.length;
final todosListos = _clientesListos.length >= numJugadores - 1; final todosVotaron = estado.todosHanVotado();
final todosVotaron = _votosRecibidos.length >= numJugadores - 1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -253,6 +257,21 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
), ),
const Spacer(), const Spacer(),
// Botón para que el host vea su palabra
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _mostrarPalabraHost(context),
icon: const Icon(Icons.visibility),
label: Text(l10n.seeYourWord),
style: ElevatedButton.styleFrom(
backgroundColor: TemaApp.colorNaranja,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(height: 12),
if (todosListos) if (todosListos)
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -278,6 +297,62 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
void _mostrarPalabraHost(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
final partida = estado.partida;
if (partida == null || sala == null) return;
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,
MaterialPageRoute(
builder: (_) => _PantallaRevelarPalabraHost(
nombre: hostLocal.nombre,
esImpostor: hostLocal.esImpostor,
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal,
),
),
);
}
Widget _buildFaseDebate( Widget _buildFaseDebate(
BuildContext context, BuildContext context,
AppLocalizations l10n, AppLocalizations l10n,
@@ -354,6 +429,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
bool todosVotaron, bool todosVotaron,
ServicioNearby nearby, ServicioNearby nearby,
) { ) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida!;
final totalVotos = partida.jugadoresActivos.length;
final votosEmitidos = estado.votos.length;
final progreso = totalVotos == 0 ? 0.0 : votosEmitidos / totalVotos;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -371,19 +452,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
child: Column( child: Column(
children: [ children: [
Text( Text(l10n.votesProgress(votosEmitidos, totalVotos)),
l10n.votesProgress(
_votosRecibidos.length,
nearby.jugadores.length + 1,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: value: progreso.clamp(0.0, 1.0).toDouble(),
_votosRecibidos.length /
(nearby.jugadores.length + 1),
backgroundColor: TemaApp.colorSuperficie, backgroundColor: TemaApp.colorSuperficie,
valueColor: const AlwaysStoppedAnimation( valueColor: const AlwaysStoppedAnimation(
TemaApp.colorAcento, TemaApp.colorAcento,
@@ -394,6 +468,19 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
], ],
), ),
), ),
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), const SizedBox(height: 16),
Text( Text(
l10n.playersVoted, l10n.playersVoted,
@@ -402,15 +489,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: nearby.jugadores.length + 1, itemCount: partida.jugadoresActivos.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final esHost = index == 0; final jugador = partida.jugadoresActivos[index];
final nombre = esHost final haVotado = estado.votos.containsKey(jugador.id);
? (nearby.miNombre ?? 'Host') return _buildJugadorTile(jugador.nombre, false, haVotado);
: nearby.jugadores[index - 1].nombre;
final haVotado =
esHost || _votosRecibidos.containsKey(nombre);
return _buildJugadorTile(nombre, esHost, haVotado);
}, },
), ),
), ),
@@ -439,6 +522,51 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().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<EstadoJuego>();
final sala = context.read<ServicioNearby>().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) { Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -451,7 +579,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
child: Row( child: Row(
children: [ children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 18)), Text(
esHost ? 'Host' : 'Cliente',
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(nombre)), Expanded(child: Text(nombre)),
if (listo) if (listo)
@@ -512,3 +643,154 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
} }
} }
} }
class _PantallaRevelarPalabraHost extends StatefulWidget {
final String nombre;
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String categoria;
const _PantallaRevelarPalabraHost({
required this.nombre,
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.categoria,
});
@override
State<_PantallaRevelarPalabraHost> createState() =>
_PantallaRevelarPalabraHostState();
}
class _PantallaRevelarPalabraHostState
extends State<_PantallaRevelarPalabraHost> {
bool _manteniendo = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.nombre,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorVerde.withValues(alpha: 0.3))
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde)
: Colors.transparent,
width: 2,
),
),
child: _manteniendo
? Column(
children: [
Text(
widget.esImpostor ? 'Impostor' : 'Ciudadano',
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
Text(
widget.esImpostor
? l10n.youAreImpostor
: l10n.yourWordIs,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
color: widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde,
),
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
Text(
widget.palabra,
style: Theme.of(context).textTheme.headlineLarge
?.copyWith(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
'Categoria: ${widget.categoria}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja),
),
],
],
)
: Column(
children: [
const Text('Candado', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
l10n.holdToSeeWord,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.makeSureNoOneLooks,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 24),
GestureDetector(
onLongPressStart: (_) => setState(() => _manteniendo = true),
onLongPressEnd: (_) => setState(() => _manteniendo = false),
child: Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _manteniendo
? [TemaApp.colorNaranja, TemaApp.colorAcento]
: [TemaApp.colorAcento, TemaApp.colorAcento],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
_manteniendo ? l10n.showingWord : l10n.holdToSee,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
@@ -6,7 +6,7 @@ import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.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 { class PantallaLobbyHost extends StatefulWidget {
final String nombreSala; final String nombreSala;
final VoidCallback onIniciar; final VoidCallback onIniciar;
@@ -23,14 +23,16 @@ class PantallaLobbyHost extends StatefulWidget {
class _PantallaLobbyHostState extends State<PantallaLobbyHost> { class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
bool _iniciando = false; bool _iniciando = false;
String? _perfilSeleccionado;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>(); final nearby = context.watch<ServicioNearby>();
final jugadores = nearby.jugadores; final sala = nearby.estadoSala;
final totalJugadores = jugadores.length + 1; // +1 host final usuarios = nearby.usuarios;
final seleccionados = usuarios.where((u) => u.estaSeleccionado).length;
final validacionInicio = sala?.validarInicio();
final puedeIniciar = validacionInicio?.exitoso ?? false;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -47,7 +49,6 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
children: [ children: [
// QR Code
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -57,174 +58,66 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
child: QrImageView( child: QrImageView(
data: nearby.generarDatosQR(widget.nombreSala), data: nearby.generarDatosQR(widget.nombreSala),
version: QrVersions.auto, version: QrVersions.auto,
size: 180, size: 160,
backgroundColor: Colors.white, backgroundColor: Colors.white,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(l10n.scanToJoin),
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<String>(
value: _perfilSeleccionado,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile,
border: const OutlineInputBorder(),
),
items: [
// Opción para crear nuevo usuario
DropdownMenuItem<String>(
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<String>(
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);
}
},
),
],
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildResumenSala(context, seleccionados, nearby.jugadores.length),
// Lista de jugadores const SizedBox(height: 12),
Expanded( Expanded(
child: Column( child: Card(
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.all(12),
Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
l10n.connectedPlayers, children: [
style: Theme.of(context).textTheme.titleLarge, Expanded(
), child: Text(
const Spacer(), 'Usuarios de la partida',
Container( style: Theme.of(context).textTheme.titleLarge,
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,
), ),
), 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), const SizedBox(height: 12),
if (_perfilSeleccionado == null) if (!puedeIniciar)
Text( Text(
l10n.selectProfile, _mensajeValidacion(validacionInicio?.codigo),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja), .bodyMedium
?.copyWith(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onPressed: puedeIniciar && !_iniciando
totalJugadores >= 3 &&
_perfilSeleccionado != null &&
!_iniciando
? () { ? () {
setState(() => _iniciando = true); setState(() => _iniciando = true);
widget.onIniciar(); widget.onIniciar();
@@ -240,45 +133,129 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
); );
} }
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( return Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: TemaApp.colorTarjeta, color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: esHost
? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5))
: null,
), ),
child: Row( child: Row(
children: [ children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)), Icon(icon, color: color),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(nombre, style: Theme.of(context).textTheme.titleMedium), child: Text(label, style: Theme.of(context).textTheme.bodySmall),
), ),
if (esHost) Text(
Container( value,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), style: TextStyle(color: color, fontWeight: FontWeight.bold),
decoration: BoxDecoration( ),
color: TemaApp.colorAcento.withValues(alpha: 0.2), ],
borderRadius: BorderRadius.circular(8), ),
), );
child: const Text( }
'HOST',
style: TextStyle( Widget _buildUsuarioTile(BuildContext context, Usuario usuario) {
color: TemaApp.colorAcento, final nearby = context.read<ServicioNearby>();
fontSize: 10, final miClientId = nearby.miClientId;
fontWeight: FontWeight.bold, 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<void> _crearNuevoUsuario(BuildContext context) async { Future<void> _crearNuevoUsuario(BuildContext context) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController(); final controller = TextEditingController();
@@ -312,12 +289,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario( await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
);
nearby.agregarUsuario(nuevoUsuario);
setState(() => _perfilSeleccionado = nombre.trim());
} }
} }
} }

View File

@@ -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<JugadorInicioPartida> jugadores;
final String? pistaCategoria;
final VoidCallback onTodosVistos;
const PantallaPalabrasCliente({
super.key,
required this.jugadores,
this.pistaCategoria,
required this.onTodosVistos,
});
@override
State<PantallaPalabrasCliente> createState() => _PantallaPalabrasClienteState();
}
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
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'),
),
),
],
),
),
),
);
}
}

View File

@@ -3,11 +3,13 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart'; import '../servicios/servicio_permisos.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_palabra_cliente.dart'; import 'pantalla_palabra_cliente.dart';
import 'pantalla_palabras_cliente.dart';
import 'pantalla_debate_cliente.dart'; import 'pantalla_debate_cliente.dart';
import 'pantalla_votacion_cliente.dart'; import 'pantalla_votacion_cliente.dart';
@@ -36,6 +38,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
bool _esImpostor = false; bool _esImpostor = false;
String? _pistaCategoria; String? _pistaCategoria;
final List<Jugador> _jugadores = []; final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
@override @override
void initState() { void initState() {
@@ -51,13 +54,38 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
nearby.onMensaje((endpointId, mensaje) { nearby.onMensaje((endpointId, mensaje) {
if (mensaje.tipo == TipoMensaje.partidaInicio) { if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra // El host ha iniciado la partida — nos ha enviado nuestra palabra
final jugadoresData = mensaje.datos['jugadores'] as List<dynamic>?;
final jugadoresTodosData =
mensaje.datos['jugadoresTodos'] as List<dynamic>?;
setState(() { setState(() {
_palabraRecibida = mensaje.datos['palabra'] as String?; _jugadoresControlados
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false; ..clear()
..addAll(
(jugadoresData ?? []).map(
(json) => JugadorInicioPartida.fromJson(
json as Map<String, dynamic>,
),
),
);
_jugadores
..clear()
..addAll(
(jugadoresTodosData ?? []).map(
(json) => Jugador.fromJson(json as Map<String, dynamic>),
),
);
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?; _pistaCategoria = mensaje.datos['categoria'] as String?;
}); });
// Navegar a pantalla de palabra del cliente // Navegar a pantalla de palabra del cliente
if (mounted && _palabraRecibida != null) { if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra(); _navegarAPalabra();
} }
} else if (mensaje.tipo == TipoMensaje.fase) { } else if (mensaje.tipo == TipoMensaje.fase) {
@@ -71,6 +99,28 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
} }
void _navegarAPalabra() { void _navegarAPalabra() {
if (_jugadoresControlados.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
onTodosVistos: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
);
}
Navigator.of(context).pop();
},
),
),
);
return;
}
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaPalabraCliente( builder: (_) => PantallaPalabraCliente(
@@ -121,16 +171,23 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaVotacionCliente( builder: (_) => PantallaVotacionCliente(
jugadores: _jugadores, jugadores: _jugadores,
onVoto: (votoporId) { jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onVotos: (votos) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { if (nearby.hostEndpointId != null) {
nearby.enviarMensaje( for (final entry in votos.entries) {
nearby.hostEndpointId!, nearby.enviarMensaje(
MensajeP2P( nearby.hostEndpointId!,
tipo: TipoMensaje.voto, MensajeP2P(
datos: {'votoporId': votoporId}, tipo: TipoMensaje.voto,
), datos: {
); 'votanteId': entry.key,
'votadoId': entry.value,
'votoporId': entry.value,
},
),
);
}
} }
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@@ -605,19 +662,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
), ),
const Divider(), const Divider(),
// Usuarios existentes // Usuarios existentes
...usuarios.map( ...usuarios.map(_buildUsuarioSalaTile),
(usuario) => ListTile(
leading: Text(
usuario.avatar ?? '👤',
style: const TextStyle(fontSize: 24),
),
title: Text(usuario.nombre),
onTap: () {
// Seleccionar usuario - enviar al host
_enviarUsuarioAlHost(usuario);
},
),
),
], ],
), ),
), ),
@@ -671,34 +716,57 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario( await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
);
// Agregar localmente
nearby.agregarUsuario(nuevoUsuario);
// Enviar al host
_enviarUsuarioAlHost(nuevoUsuario);
} }
} }
/// Envía el usuario seleccionado/creado al host /// Env?a el usuario seleccionado/creado al host
void _enviarUsuarioAlHost(Usuario usuario) { void _enviarUsuarioAlHost(Usuario usuario) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { nearby.seleccionarUsuarioSala(usuario.id);
nearby.enviarMensaje( ScaffoldMessenger.of(
nearby.hostEndpointId!, context,
MensajeP2P( ).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuario.toJson()},
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
}
} }
Widget _buildUsuarioSalaTile(Usuario usuario) {
final nearby = context.read<ServicioNearby>();
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 ==================== // ==================== HELPERS ====================
Widget _buildError(String msg) { Widget _buildError(String msg) {

View File

@@ -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/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart'; import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/tema/tema_app.dart'; import 'package:farolero/tema/tema_app.dart';
/// Pantalla de votación para el cliente (multidispositivo). /// Pantalla de votación para cliente multidispositivo.
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar. /// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
/// por cada jugador controlado activo.
class PantallaVotacionCliente extends StatefulWidget { class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores; final List<Jugador> jugadores;
final Function(String votoporId) onVoto; final List<JugadorInicioPartida> jugadoresControlados;
final Function(Map<String, String> votos) onVotos;
const PantallaVotacionCliente({ const PantallaVotacionCliente({
super.key, super.key,
required this.jugadores, required this.jugadores,
required this.onVoto, this.jugadoresControlados = const [],
required this.onVotos,
}); });
@override @override
@@ -20,7 +24,14 @@ class PantallaVotacionCliente extends StatefulWidget {
} }
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> { class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
String? _votoSeleccionado; final Map<String, String> _votosPorVotante = {};
List<JugadorInicioPartida> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -45,56 +56,31 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
l10n.selectOnePlayer, _modoMultiVotante
? 'Emití un voto por cada jugador que manejás.'
: l10n.selectOnePlayer,
style: TextStyle(color: TemaApp.colorTextoSecundario), style: TextStyle(color: TemaApp.colorTextoSecundario),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: ListView.builder( child: _votantes.isEmpty
itemCount: widget.jugadores.length, ? _buildSelectorLegacy()
itemBuilder: (context, index) { : ListView.builder(
final jugador = widget.jugadores[index]; itemCount: _votantes.length,
final selected = _votoSeleccionado == jugador.id; itemBuilder: (context, index) {
return Card( final votante = _votantes[index];
color: selected return _buildSelectorParaVotante(context, votante);
? 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);
}, },
), ),
);
},
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: 56,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _votoSeleccionado == null onPressed: _votacionCompleta
? null ? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
: () => widget.onVoto(_votoSeleccionado!), : null,
icon: const Icon(Icons.how_to_vote), icon: const Icon(Icons.how_to_vote),
label: Text(l10n.votar), label: Text(l10n.votar),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -109,4 +95,87 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
), ),
); );
} }
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,
),
);
}
} }

View File

@@ -1,9 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart'; import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
/// Tipos de mensajes en el protocolo P2P /// Tipos de mensajes en el protocolo P2P.
enum TipoMensaje { enum TipoMensaje {
salaInfo, salaInfo,
partidaInicio, partidaInicio,
@@ -15,12 +17,20 @@ enum TipoMensaje {
listo, listo,
ping, ping,
jugadorDesconectado, jugadorDesconectado,
clienteRegistrado,
estadoSala,
crearUsuario,
seleccionarUsuario,
liberarUsuario,
eliminarUsuario,
errorOperacion,
usuarioNuevo, usuarioNuevo,
// Compatibilidad con versiones previas del protocolo.
usuarioEliminado, usuarioEliminado,
usuariosActualizados, usuariosActualizados,
} }
/// Mensaje del protocolo P2P entre dispositivos /// Mensaje del protocolo P2P entre dispositivos.
class MensajeP2P { class MensajeP2P {
final TipoMensaje tipo; final TipoMensaje tipo;
final Map<String, dynamic> datos; final Map<String, dynamic> datos;
@@ -40,7 +50,8 @@ class MensajeP2P {
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson())); 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 { class JugadorConectado {
final String endpointId; final String endpointId;
final String nombre; final String nombre;
@@ -53,13 +64,13 @@ class JugadorConectado {
}); });
} }
/// Callback para mensajes recibidos
typedef OnMensajeCallback = typedef OnMensajeCallback =
void Function(String endpointId, MensajeP2P mensaje); void Function(String endpointId, MensajeP2P mensaje);
/// Servicio para conexiones P2P usando Google Nearby Connections API. /// Servicio para conexiones P2P usando Google Nearby Connections API.
class ServicioNearby extends ChangeNotifier { class ServicioNearby extends ChangeNotifier {
static const _serviceId = 'es.freetimelab.farolero'; static const _serviceId = 'es.freetimelab.farolero';
static const _hostClientId = 'host';
bool _esHost = false; bool _esHost = false;
bool _conectado = false; bool _conectado = false;
@@ -67,23 +78,21 @@ class ServicioNearby extends ChangeNotifier {
bool _anunciando = false; bool _anunciando = false;
String? _miEndpointId; String? _miEndpointId;
String? _hostEndpointId; String? _hostEndpointId;
String? _roomId;
String? _miClientId;
String? _nombreSala; String? _nombreSala;
String? _miNombre; String? _miNombre;
final Map<String, JugadorConectado> _jugadores = {}; final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = []; final List<OnMensajeCallback> _listeners = [];
final Map<String, String> _hostsEncontrados = {};
final Map<String, Usuario> _usuariosPool = {};
// Hosts descubiertos (para discovery automático)
final Map<String, String> _hostsEncontrados = {}; // endpointId -> nombre
// Estado para clientes
String? _palabraRecibida; String? _palabraRecibida;
bool? _soyImpostor; bool? _soyImpostor;
String? _faseActual; String? _faseActual;
Map<String, dynamic>? _datosPartida; Map<String, dynamic>? _datosPartida;
EstadoSalaMultijugador? _estadoSala;
// Pool de usuarios para modo multi-dispositivo
final Map<String, Usuario> _usuariosPool = {};
bool get esHost => _esHost; bool get esHost => _esHost;
bool get conectado => _conectado; bool get conectado => _conectado;
@@ -91,27 +100,35 @@ class ServicioNearby extends ChangeNotifier {
bool get anunciando => _anunciando; bool get anunciando => _anunciando;
String? get miEndpointId => _miEndpointId; String? get miEndpointId => _miEndpointId;
String? get hostEndpointId => _hostEndpointId; String? get hostEndpointId => _hostEndpointId;
String? get roomId => _roomId;
String? get miClientId => _miClientId;
String? get nombreSala => _nombreSala; String? get nombreSala => _nombreSala;
String? get miNombre => _miNombre; String? get miNombre => _miNombre;
String? get palabraRecibida => _palabraRecibida; String? get palabraRecibida => _palabraRecibida;
bool? get soyImpostor => _soyImpostor; bool? get soyImpostor => _soyImpostor;
String? get faseActual => _faseActual; String? get faseActual => _faseActual;
Map<String, dynamic>? get datosPartida => _datosPartida; Map<String, dynamic>? get datosPartida => _datosPartida;
EstadoSalaMultijugador? get estadoSala => _estadoSala;
List<JugadorConectado> get jugadores => _jugadores.values.toList(); List<JugadorConectado> get jugadores => _jugadores.values.toList();
int get numJugadoresConectados => _jugadores.length; int get numJugadoresConectados => _jugadores.length;
Map<String, String> get hostsEncontrados => Map<String, String> get hostsEncontrados =>
Map.unmodifiable(_hostsEncontrados); Map.unmodifiable(_hostsEncontrados);
/// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo List<Usuario> get usuarios =>
List<Usuario> get usuarios => _usuariosPool.values.toList(); (_estadoSala?.usuarios.values ?? _usuariosPool.values).toList();
List<Usuario> 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) { void onMensaje(OnMensajeCallback callback) {
_listeners.add(callback); _listeners.add(callback);
} }
/// Elimina un listener
void removeMensajeListener(OnMensajeCallback callback) { void removeMensajeListener(OnMensajeCallback callback) {
_listeners.remove(callback); _listeners.remove(callback);
} }
@@ -122,26 +139,24 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
// ==================== USER POOL ==================== // ==================== USER POOL / SALA ====================
/// Agrega un usuario al pool de usuarios
void agregarUsuario(Usuario usuario) { void agregarUsuario(Usuario usuario) {
_usuariosPool[usuario.id] = usuario; _usuariosPool[usuario.id] = usuario;
_estadoSala?.usuarios[usuario.id] = usuario;
notifyListeners(); notifyListeners();
} }
/// Elimina un usuario del pool
void eliminarUsuario(String usuarioId) { void eliminarUsuario(String usuarioId) {
_usuariosPool.remove(usuarioId); _usuariosPool.remove(usuarioId);
_estadoSala?.usuarios.remove(usuarioId);
notifyListeners(); notifyListeners();
} }
/// Obtiene un usuario por su ID
Usuario? getUsuario(String usuarioId) { Usuario? getUsuario(String usuarioId) {
return _usuariosPool[usuarioId]; return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId];
} }
/// Sincroniza el pool de usuarios con una lista
void sincronizarUsuarios(List<Usuario> usuarios) { void sincronizarUsuarios(List<Usuario> usuarios) {
_usuariosPool.clear(); _usuariosPool.clear();
for (final usuario in usuarios) { for (final usuario in usuarios) {
@@ -150,12 +165,38 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Obtiene el jugador local del host (él mismo como participante) void _sincronizarSala(EstadoSalaMultijugador sala) {
/// Retorna un JugadorConectado con endpointId null porque es local _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<void> _broadcastEstadoSala() async {
final sala = _estadoSala;
if (sala == null) return;
_sincronizarPoolDesdeSala();
if (_esHost) {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.estadoSala, datos: sala.toJson()),
);
}
notifyListeners();
}
JugadorConectado? getJugadorLocal() { JugadorConectado? getJugadorLocal() {
if (_miNombre == null) return null; if (_miNombre == null) return null;
return JugadorConectado( return JugadorConectado(
endpointId: _miEndpointId ?? '', // vacío indica que es el host local endpointId: _miEndpointId ?? '',
nombre: _miNombre!, nombre: _miNombre!,
listo: true, listo: true,
); );
@@ -163,10 +204,29 @@ class ServicioNearby extends ChangeNotifier {
// ==================== HOST ==================== // ==================== HOST ====================
/// Inicia como host (anunciando el endpoint)
Future<bool> iniciarHost(String nombreSala, String miNombre) async { Future<bool> iniciarHost(String nombreSala, String miNombre) async {
_nombreSala = nombreSala; _nombreSala = nombreSala;
_miNombre = miNombre; _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 { try {
final resultado = await Nearby().startAdvertising( final resultado = await Nearby().startAdvertising(
@@ -194,7 +254,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CLIENTE ==================== // ==================== CLIENTE ====================
/// Busca hosts disponibles
Future<bool> buscarHosts(String miNombre) async { Future<bool> buscarHosts(String miNombre) async {
_miNombre = miNombre; _miNombre = miNombre;
@@ -219,7 +278,6 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// Conecta a un host específico
Future<bool> conectarAHost(String endpointId, String miNombre) async { Future<bool> conectarAHost(String endpointId, String miNombre) async {
try { try {
await Nearby().requestConnection( await Nearby().requestConnection(
@@ -239,8 +297,7 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CALLBACKS NEARBY ==================== // ==================== CALLBACKS NEARBY ====================
void _onConexionIniciada(String endpointId, ConnectionInfo info) { void _onConexionIniciada(String endpointId, ConnectionInfo info) {
debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}'); debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}');
// Auto-aceptar conexiones
Nearby().acceptConnection( Nearby().acceptConnection(
endpointId, endpointId,
onPayLoadRecieved: _onPayloadRecibido, onPayLoadRecieved: _onPayloadRecibido,
@@ -249,16 +306,13 @@ class ServicioNearby extends ChangeNotifier {
} }
void _onResultadoConexion(String endpointId, Status status) { void _onResultadoConexion(String endpointId, Status status) {
debugPrint('Resultado conexión $endpointId: $status'); debugPrint('Resultado conexion $endpointId: $status');
if (status == Status.CONNECTED) { if (status == Status.CONNECTED) {
if (_esHost) { if (_esHost) {
// Host: esperar mensaje 'unirse' del cliente
debugPrint('Cliente conectado: $endpointId'); debugPrint('Cliente conectado: $endpointId');
} else { } else {
// Cliente: conectado al host
_hostEndpointId = endpointId; _hostEndpointId = endpointId;
_conectado = true; _conectado = true;
// Enviar mensaje de unirse
enviarMensaje( enviarMensaje(
endpointId, endpointId,
MensajeP2P( MensajeP2P(
@@ -269,16 +323,20 @@ class ServicioNearby extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
} else { } else {
debugPrint('Conexión fallida con $endpointId'); debugPrint('Conexion fallida con $endpointId');
} }
} }
void _onDesconexion(String endpointId) { void _onDesconexion(String endpointId) {
debugPrint('Desconexión: $endpointId'); debugPrint('Desconexion: $endpointId');
if (_esHost) { if (_esHost) {
final jugador = _jugadores.remove(endpointId); final jugador = _jugadores.remove(endpointId);
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
if (cliente != null) {
_estadoSala?.desconectarCliente(cliente.clientId);
_broadcastEstadoSala();
}
if (jugador != null) { if (jugador != null) {
// Notificar a todos que se desconectó
enviarATodos( enviarATodos(
MensajeP2P( MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado, tipo: TipoMensaje.jugadorDesconectado,
@@ -287,7 +345,6 @@ class ServicioNearby extends ChangeNotifier {
); );
} }
} else { } else {
// Cliente perdió conexión con host
_conectado = false; _conectado = false;
_hostEndpointId = null; _hostEndpointId = null;
} }
@@ -312,7 +369,6 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// Para el discovery sin desconectar
Future<void> pararBusqueda() async { Future<void> pararBusqueda() async {
try { try {
await Nearby().stopDiscovery(); await Nearby().stopDiscovery();
@@ -334,9 +390,7 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) { void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {}
// No necesitamos trackear progreso para bytes pequeños
}
// ==================== PROCESAMIENTO DE MENSAJES ==================== // ==================== PROCESAMIENTO DE MENSAJES ====================
@@ -355,33 +409,11 @@ class ServicioNearby extends ChangeNotifier {
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) { switch (mensaje.tipo) {
case TipoMensaje.unirse: case TipoMensaje.unirse:
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; _registrarClienteRemoto(endpointId, mensaje);
_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; break;
case TipoMensaje.voto: case TipoMensaje.voto:
// Propagar al flujo de juego
_notificarMensaje(endpointId, mensaje); _notificarMensaje(endpointId, mensaje);
break; break;
case TipoMensaje.listo: case TipoMensaje.listo:
final jugador = _jugadores[endpointId]; final jugador = _jugadores[endpointId];
if (jugador != null) { if (jugador != null) {
@@ -389,33 +421,69 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
break; 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:
case TipoMensaje.usuarioEliminado:
_handleEliminarUsuario(endpointId, mensaje);
break;
case TipoMensaje.usuarioNuevo: case TipoMensaje.usuarioNuevo:
_handleUsuarioNuevo(mensaje); _handleUsuarioNuevo(mensaje);
break; break;
case TipoMensaje.usuariosActualizados: case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje); _handleUsuariosActualizados(mensaje);
break; break;
default: default:
break; 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) { void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?; final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) { if (usuarioJson != null) {
final nuevoUsuario = Usuario.fromJson(usuarioJson); final nuevoUsuario = Usuario.fromJson(usuarioJson);
_usuariosPool[nuevoUsuario.id] = nuevoUsuario; _usuariosPool[nuevoUsuario.id] = nuevoUsuario;
// Propagar a todos los clientes _estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario;
if (_esHost) { if (_esHost) {
enviarATodos( _broadcastEstadoSala();
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuarioJson},
),
);
} }
notifyListeners(); notifyListeners();
} }
@@ -433,11 +501,87 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
String _clientIdParaEndpoint(String endpointId, MensajeP2P mensaje) {
return mensaje.datos['clientId'] as String? ??
_estadoSala?.clientePorEndpoint(endpointId)?.clientId ??
endpointId;
}
Future<void> _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<String, dynamic>?;
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) { void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) { switch (mensaje.tipo) {
case TipoMensaje.salaInfo: case TipoMensaje.salaInfo:
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
// Sincronizar pool de usuarios si viene en el mensaje
final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?; final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosData != null) { if (usuariosData != null) {
_usuariosPool.clear(); _usuariosPool.clear();
@@ -448,42 +592,54 @@ class ServicioNearby extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.clienteRegistrado:
_miClientId = mensaje.datos['clientId'] as String?;
_datosPartida = mensaje.datos;
final estadoSalaJson =
mensaje.datos['estadoSala'] as Map<String, dynamic>?;
if (estadoSalaJson != null) {
_sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson));
}
notifyListeners();
break;
case TipoMensaje.estadoSala:
_sincronizarSala(EstadoSalaMultijugador.fromJson(mensaje.datos));
notifyListeners();
break;
case TipoMensaje.partidaInicio: case TipoMensaje.partidaInicio:
_palabraRecibida = mensaje.datos['palabra'] as String?; final jugadoresInicio = mensaje.datos['jugadores'] as List<dynamic>?;
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false; if (jugadoresInicio != null && jugadoresInicio.isNotEmpty) {
final primerJugador = jugadoresInicio.first as Map<String, dynamic>;
_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; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.fase: case TipoMensaje.fase:
_faseActual = mensaje.datos['fase'] as String?; _faseActual = mensaje.datos['fase'] as String?;
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.votacionResultado: case TipoMensaje.votacionResultado:
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.partidaFin: case TipoMensaje.partidaFin:
case TipoMensaje.errorOperacion:
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.jugadorDesconectado: case TipoMensaje.jugadorDesconectado:
notifyListeners(); notifyListeners();
break; break;
default: default:
break; break;
} }
} }
// ==================== ENVÍO ==================== // ==================== ENVIO ====================
/// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async { Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
try { try {
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
@@ -492,20 +648,113 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// Envía un mensaje a todos los dispositivos conectados (solo host)
Future<void> enviarATodos(MensajeP2P mensaje) async { Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _jugadores.keys) { for (final id in _jugadores.keys) {
await enviarMensaje(id, mensaje); await enviarMensaje(id, mensaje);
} }
} }
Future<void> 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<void> 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<void> 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<void> 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: ACCIONES DE JUEGO ====================
/// Host envía inicio de partida con la palabra de cada jugador
Future<void> enviarInicioPartida({ Future<void> enviarInicioPartida({
required String palabraSecreta, required String palabraSecreta,
required String categoria, required String categoria,
required Map<String, bool> impostores, // endpointId -> esImpostor required Map<String, bool> impostores,
}) async { }) async {
for (final entry in _jugadores.entries) { for (final entry in _jugadores.entries) {
final esImpostor = impostores[entry.key] ?? false; final esImpostor = impostores[entry.key] ?? false;
@@ -517,14 +766,39 @@ class ServicioNearby extends ChangeNotifier {
'palabra': esImpostor ? null : palabraSecreta, 'palabra': esImpostor ? null : palabraSecreta,
'esImpostor': esImpostor, 'esImpostor': esImpostor,
'categoria': categoria, 'categoria': categoria,
'numJugadores': _jugadores.length + 1, // +1 por el host 'numJugadores': _jugadores.length + 1,
}, },
), ),
); );
} }
} }
/// Host envía cambio de fase Future<void> enviarInicioPartidaMulti({
required List<AsignacionJugador> asignaciones,
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostoresPorJugadorId,
required List<Map<String, dynamic>> 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<void> enviarCambioFase( Future<void> enviarCambioFase(
String fase, [ String fase, [
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
@@ -533,14 +807,12 @@ class ServicioNearby extends ChangeNotifier {
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
} }
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async { Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos( await enviarATodos(
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado), MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
); );
} }
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async { Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos( await enviarATodos(
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado), MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
@@ -549,7 +821,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ==================== // ==================== LIMPIEZA ====================
/// Desconecta y limpia todo
Future<void> desconectar() async { Future<void> desconectar() async {
try { try {
await Nearby().stopAllEndpoints(); await Nearby().stopAllEndpoints();
@@ -565,27 +836,30 @@ class ServicioNearby extends ChangeNotifier {
_anunciando = false; _anunciando = false;
_miEndpointId = null; _miEndpointId = null;
_hostEndpointId = null; _hostEndpointId = null;
_roomId = null;
_miClientId = null;
_nombreSala = null; _nombreSala = null;
_miNombre = null; _miNombre = null;
_palabraRecibida = null; _palabraRecibida = null;
_soyImpostor = null; _soyImpostor = null;
_faseActual = null; _faseActual = null;
_datosPartida = null; _datosPartida = null;
_estadoSala = null;
_jugadores.clear(); _jugadores.clear();
_hostsEncontrados.clear(); _hostsEncontrados.clear();
_usuariosPool.clear();
notifyListeners(); notifyListeners();
} }
/// Genera los datos para el código QR de conexión
String generarDatosQR(String nombreSala) { String generarDatosQR(String nombreSala) {
return json.encode({ return json.encode({
'app': 'farolero', 'app': 'farolero',
'sala': nombreSala, 'sala': nombreSala,
'host': _miNombre, 'host': _miNombre,
'roomId': _roomId,
}); });
} }
/// Parsea datos de QR escaneado
static Map<String, dynamic>? parsearQR(String datos) { static Map<String, dynamic>? parsearQR(String datos) {
try { try {
final mapa = json.decode(datos) as Map<String, dynamic>; final mapa = json.decode(datos) as Map<String, dynamic>;

View File

@@ -1,7 +1,7 @@
name: farolero name: farolero
description: "Farolero — Juego de deducción social. ¿Quién finge saber?" description: "Farolero — Juego de deducción social. ¿Quién finge saber?"
publish_to: 'none' publish_to: 'none'
version: 1.1.6+11 version: 1.1.9+14
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1

View File

@@ -1,6 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/estado/estado_juego.dart'; import 'package:farolero/estado/estado_juego.dart';
import 'package:farolero/modelos/palabra.dart';
import 'package:farolero/modelos/partida.dart'; import 'package:farolero/modelos/partida.dart';
void main() { void main() {

View File

@@ -1,4 +1,3 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/estado/estado_juego.dart'; import 'package:farolero/estado/estado_juego.dart';

View File

@@ -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);
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/modelos/usuario.dart'; import 'package:farolero/modelos/usuario.dart';
import 'package:farolero/servicios/servicio_nearby.dart'; import 'package:farolero/servicios/servicio_nearby.dart';