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`.
This commit is contained in:
Javier Bautista Fernández
2026-04-27 14:02:33 +02:00
parent 4a1abd0be0
commit a8d5b0f002
14 changed files with 1779 additions and 421 deletions

View File

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