Gamificación

This commit is contained in:
2026-05-09 17:24:46 +02:00
parent dcecee805b
commit e2cebafdbb
29 changed files with 877 additions and 58 deletions

View File

@@ -145,13 +145,17 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
// 3. Iniciar host en Nearby
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
final nombreSala = '${nombre.trim()} - Farolero';
final ok = await nearby.iniciarHost(
nombreSala,
nombre.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (!ok) {

View File

@@ -3,8 +3,11 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../modelos/gamificacion_usuario.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart';
@@ -18,6 +21,7 @@ class PantallaFinPartida extends StatefulWidget {
class _PantallaFinPartidaState extends State<PantallaFinPartida> {
bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override
Widget build(BuildContext context) {
@@ -31,9 +35,15 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
partida.jugadores.where((j) => j.esImpostor).toList();
if (!_guardada && partida.ganador != null) {
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
context.read<ServicioHistorialPartidas>().guardarPartida(partida);
final historial = context.read<ServicioHistorialPartidas>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarPartida(partida);
final progreso = await perfil.registrarPartidaCompletada(
victoria: ganaronJugadores,
);
if (mounted) setState(() => _progreso = progreso);
}
});
}
@@ -91,6 +101,10 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
),
),
const SizedBox(height: 24),
if (_progreso != null) ...[
_TarjetaProgresoGamificacion(progreso: _progreso!),
const SizedBox(height: 16),
],
// Palabra secreta
Card(
@@ -260,3 +274,59 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
);
}
}
class _TarjetaProgresoGamificacion extends StatelessWidget {
final ProgresoGamificacionUsuario progreso;
const _TarjetaProgresoGamificacion({required this.progreso});
@override
Widget build(BuildContext context) {
final nuevas = progreso.nuevasMedallas;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
const Text('🔥', style: TextStyle(fontSize: 24)),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progreso.despues.fuego / 100,
minHeight: 8,
color: TemaApp.colorNaranja,
backgroundColor: Colors.black.withValues(alpha: 0.35),
),
),
),
const SizedBox(width: 10),
Text('${progreso.despues.fuego}%'),
],
),
const SizedBox(height: 8),
Text(
progreso.incrementoFuego >= 0
? '+${progreso.incrementoFuego}% de fuego'
: '${progreso.incrementoFuego}% de fuego',
style: Theme.of(context).textTheme.bodySmall,
),
if (nuevas.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Nuevas medallas',
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 6),
MedallasCompactasFarolero(ids: nuevas, max: nuevas.length),
],
],
),
),
);
}
}

View File

@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/gamificacion_usuario.dart';
import '../modelos/palabra.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart';
import 'pantalla_principal.dart';
@@ -30,6 +33,7 @@ class PantallaFinPartidaOnline extends StatefulWidget {
class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override
Widget build(BuildContext context) {
@@ -41,11 +45,18 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
if (!_guardada && snapshot.ganador != null) {
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
context
.read<ServicioHistorialPartidas>()
.guardarSnapshotOnline(snapshot);
final historial = context.read<ServicioHistorialPartidas>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarSnapshotOnline(snapshot);
final progreso = await perfil.registrarPartidaCompletada(
victoria: _perfilLocalGano(snapshot, jugadoresControlados),
comoImpostor: jugadoresControlados.any((j) => j.esImpostor),
victoriaComoImpostor: snapshot.ganador == 'impostores' &&
jugadoresControlados.any((j) => j.esImpostor),
);
if (mounted) setState(() => _progreso = progreso);
}
});
}
@@ -132,6 +143,10 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
),
),
const SizedBox(height: 24),
if (_progreso != null) ...[
_TarjetaProgresoGamificacion(progreso: _progreso!),
const SizedBox(height: 16),
],
Card(
child: Padding(
padding: const EdgeInsets.all(20),
@@ -254,4 +269,71 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
),
);
}
bool _perfilLocalGano(
SnapshotPartidaOnline snapshot,
List<JugadorInicioPartida> jugadoresControlados,
) {
if (jugadoresControlados.isEmpty) return snapshot.ganador == 'jugadores';
final ganaronImpostores = snapshot.ganador == 'impostores';
return jugadoresControlados.any(
(jugador) => jugador.esImpostor ? ganaronImpostores : !ganaronImpostores,
);
}
}
class _TarjetaProgresoGamificacion extends StatelessWidget {
final ProgresoGamificacionUsuario progreso;
const _TarjetaProgresoGamificacion({required this.progreso});
@override
Widget build(BuildContext context) {
final nuevas = progreso.nuevasMedallas;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
const Text('🔥', style: TextStyle(fontSize: 24)),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progreso.despues.fuego / 100,
minHeight: 8,
color: TemaApp.colorNaranja,
backgroundColor: Colors.black.withValues(alpha: 0.35),
),
),
),
const SizedBox(width: 10),
Text('${progreso.despues.fuego}%'),
],
),
const SizedBox(height: 8),
Text(
progreso.incrementoFuego >= 0
? '+${progreso.incrementoFuego}% de fuego'
: '${progreso.incrementoFuego}% de fuego',
style: Theme.of(context).textTheme.bodySmall,
),
if (nuevas.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Nuevas medallas',
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 6),
MedallasCompactasFarolero(ids: nuevas, max: nuevas.length),
],
],
),
),
);
}
}

View File

@@ -4,12 +4,14 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/gamificacion_usuario.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart';
@@ -31,6 +33,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
int _segundosRestantes = 0;
bool _hostListo = false;
bool _partidaOnlineGuardada = false;
ProgresoGamificacionUsuario? _progresoGamificacion;
String? _primerTurnoId;
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {};
@@ -695,7 +698,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
label: Text(
_hostYaVoto(context)
? 'Votos del host registrados'
: 'Votar por los jugadores de este m?vil',
: 'Votar por los jugadores de este móvil',
),
),
),
@@ -934,12 +937,57 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
partida == null ? '' : l10n.theWordWas(partida.palabraSecreta),
textAlign: TextAlign.center,
),
if (_progresoGamificacion != null) ...[
const SizedBox(height: 16),
_buildProgresoGamificacion(context, _progresoGamificacion!),
],
],
),
),
);
}
Widget _buildProgresoGamificacion(
BuildContext context,
ProgresoGamificacionUsuario progreso,
) {
final nuevas = progreso.nuevasMedallas;
return PanelFarolero(
padding: const EdgeInsets.all(14),
color: TemaApp.colorSuperficie.withValues(alpha: 0.82),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
Row(
children: [
const Text('🔥', style: TextStyle(fontSize: 22)),
const SizedBox(width: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progreso.despues.fuego / 100,
minHeight: 8,
color: TemaApp.colorNaranja,
backgroundColor: Colors.black.withValues(alpha: 0.35),
),
),
),
const SizedBox(width: 8),
Text('${progreso.despues.fuego}%'),
],
),
if (nuevas.isNotEmpty) ...[
const SizedBox(height: 10),
MedallasCompactasFarolero(ids: nuevas, max: nuevas.length),
],
],
),
);
}
bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
@@ -1182,7 +1230,28 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
final partida = context.read<EstadoJuego>().partida;
if (partida?.ganador == null) return;
_partidaOnlineGuardada = true;
await context.read<ServicioHistorialPartidas>().guardarPartida(partida!);
final historial = context.read<ServicioHistorialPartidas>();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarPartida(partida!);
final jugadoresHost = _jugadoresHostControlados(
partida,
nearby,
);
final ganaronImpostores = partida.ganador == 'impostores';
final victoria = jugadoresHost.isEmpty
? partida.ganador == 'jugadores'
: jugadoresHost.any(
(jugador) =>
jugador.esImpostor ? ganaronImpostores : !ganaronImpostores,
);
final progreso = await perfil.registrarPartidaCompletada(
victoria: victoria,
comoImpostor: jugadoresHost.any((j) => j.esImpostor),
victoriaComoImpostor:
ganaronImpostores && jugadoresHost.any((j) => j.esImpostor),
);
if (mounted) setState(() => _progresoGamificacion = progreso);
}
Future<void> _iniciarAdivinanzaOnline(BuildContext context) async {

View File

@@ -217,15 +217,26 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
? TemaApp.colorNaranja
: TemaApp.colorAzul,
size: 38,
fuego: usuario.fuego,
medallas: usuario.medallas,
),
),
title: Text(usuario.nombre),
subtitle: Text(
seleccionadoPorMi
? 'Seleccionado por este móvil'
: seleccionadoPorOtro
? 'Seleccionado por otro cliente'
: 'Disponible',
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
seleccionadoPorMi
? 'Seleccionado por este móvil'
: seleccionadoPorOtro
? 'Seleccionado por otro cliente'
: 'Disponible',
),
if (usuario.medallas.isNotEmpty) ...[
const SizedBox(height: 4),
MedallasCompactasFarolero(ids: usuario.medallas),
],
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -300,11 +311,15 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
);
}
}

View File

@@ -16,7 +16,9 @@ class PantallaPrincipal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.watch<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
return Scaffold(
body: FondoFarolero(
@@ -35,6 +37,8 @@ class PantallaPrincipal extends StatelessWidget {
texto: perfil.nombre.substring(0, 1).toUpperCase(),
assetPath: perfil.avatarAsset,
size: 48,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
),
const SizedBox(width: 10),
Expanded(
@@ -53,9 +57,9 @@ class PantallaPrincipal extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: 0.68,
value: gamificacion.fuego / 100,
minHeight: 4,
color: TemaApp.colorPurpura,
color: TemaApp.colorNaranja,
backgroundColor: Colors.black.withValues(alpha: 0.45),
),
),

View File

@@ -314,11 +314,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
final ok = await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (ok) {
@@ -342,7 +346,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
});
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
// Parar discovery antes de conectar
await nearby.pararBusqueda();
final ok = await nearby.conectarAHost(
@@ -350,6 +356,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (!ok && mounted) {
@@ -389,11 +397,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// Iniciar búsqueda para que Nearby encuentre al host
final nearby = context.read<ServicioNearby>();
if (!nearby.buscando) {
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
}
return;
@@ -835,11 +847,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
);
}
}
@@ -861,17 +877,29 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
return ListTile(
leading: Text(
usuario.avatar ?? '??',
style: const TextStyle(fontSize: 24),
leading: AvatarFarolero(
texto: usuario.nombre.isEmpty ? '?' : usuario.nombre[0],
assetPath: usuario.avatar,
size: 38,
fuego: usuario.fuego,
medallas: usuario.medallas,
),
title: Text(usuario.nombre),
subtitle: Text(
seleccionadoPorMi
? 'Seleccionado por este m?vil'
: seleccionadoPorOtro
? 'No disponible'
: 'Disponible',
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
seleccionadoPorMi
? 'Seleccionado por este móvil'
: seleccionadoPorOtro
? 'No disponible'
: 'Disponible',
),
if (usuario.medallas.isNotEmpty) ...[
const SizedBox(height: 4),
MedallasCompactasFarolero(ids: usuario.medallas),
],
],
),
trailing: seleccionadoPorMi
? IconButton(