first commit

This commit is contained in:
qjorge 2025-07-16 17:57:35 +02:00
commit c3223d211e
12 changed files with 570 additions and 0 deletions

16
.dockerignore Normal file
View file

@ -0,0 +1,16 @@
run.sh
venv/
env/
__pycache__/
*.pyc
.git
.gitignore
ranking.db
.vscode/
.idea/
*.swp

34
Dockerfile Normal file
View file

@ -0,0 +1,34 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV FLASK_RUN_HOST=0.0.0.0
# ---------------------------------------------
# 9) Antes de arrancar, creamos la tabla si no existe
# (llamamos a la función crear_tabla() definida en app.py).
# Esto hará que, cuando se construya la imagen, se garantice
# que la BD ya tiene la estructura mínima.
# ---------------------------------------------
RUN python - << 'EOF'
from app import crear_tabla
crear_tabla()
EOF
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# MULTI-GAME
Tienes 1 minuto para resolver todas las multiplicaciones de 2 cifras que seas capaz!

149
app.py Normal file
View file

@ -0,0 +1,149 @@
import sqlite3
from flask import Flask, render_template, request, jsonify, g
DATABASE = "ranking.db"
app = Flask(__name__)
def get_db():
"""
Abre una conexión a la base de datos SQLite y la asocia a 'g'
para reutilizarla en cada petición.
"""
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
return db
@app.teardown_appcontext
def close_connection(exception):
"""
Al terminar el contexto de la petición, cierra la conexión si existe.
"""
db = getattr(g, "_database", None)
if db is not None:
db.close()
def crear_tabla():
"""
Crea la tabla ranking si no existe.
"""
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS ranking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
puntuacion INTEGER NOT NULL
)
""")
conn.commit()
conn.close()
def obtener_top10():
"""
Devuelve una lista de tuplas [(nombre, puntuacion), ...] con el top 10 ordenado descendente.
"""
conn = get_db()
c = conn.cursor()
c.execute("SELECT nombre, puntuacion FROM ranking ORDER BY puntuacion DESC, id ASC LIMIT 10")
resultados = c.fetchall()
return resultados
def puntuacion_entra_en_top(puntos):
"""
Comprueba si 'puntos' supera la última posición del top 10.
Si hay menos de 10 filas, siempre devuelve True para guardar.
"""
top = obtener_top10()
if len(top) < 10:
return True
# La décima posición es top[-1][1] (puntuacion más baja en top10)
return puntos > top[-1][1]
def guardar_puntuacion(nombre, puntos):
"""
Inserta la nueva puntuación en la tabla ranking.
"""
conn = get_db()
c = conn.cursor()
c.execute("INSERT INTO ranking (nombre, puntuacion) VALUES (?, ?)", (nombre, puntos))
conn.commit()
@app.route("/")
def index():
"""
Página principal con instrucciones y botón para iniciar el juego.
"""
return render_template("index.html")
@app.route("/juego")
def juego():
"""
Página donde se juega: se carga el HTML y el JS se encarga de todo lo demás.
"""
return render_template("game.html")
@app.route("/ranking")
def ranking():
"""
Página que muestra el top 10.
"""
top10 = obtener_top10()
return render_template("ranking.html", top10=top10)
@app.route("/api/score", methods=["POST"])
def api_score():
"""
Recibe JSON: { "puntuacion": <int> }
Comprueba si entra en top10 y devuelve JSON: { "entra_en_top": true/false, "top10": [...] }
"""
data = request.get_json()
if not data or "puntuacion" not in data:
return jsonify({"error": "No se recibió puntuación"}), 400
puntos = int(data["puntuacion"])
entra = puntuacion_entra_en_top(puntos)
top10 = obtener_top10()
# Devolvemos el top10 actual (ANTES de guardar la nueva puntuación)
return jsonify({
"entra_en_top": entra,
"top10": top10
})
@app.route("/api/score/save", methods=["POST"])
def api_score_save():
"""
Recibe JSON: { "nombre": <string>, "puntuacion": <int> }
Guarda en la BD y devuelve el ranking actualizado.
"""
data = request.get_json()
if not data or "nombre" not in data or "puntuacion" not in data:
return jsonify({"error": "Faltan parámetros"}), 400
nombre = data["nombre"].strip()
puntos = int(data["puntuacion"])
# Sólo guardamos si efectivamente entra en top10 (por seguridad)
if puntuacion_entra_en_top(puntos):
guardar_puntuacion(nombre, puntos)
top10 = obtener_top10()
return jsonify({
"guardado": True,
"top10": top10
})
if __name__ == "__main__":
crear_tabla()
app.run(debug=True)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Flask==2.3.2
gunicorn==20.1.0

3
run.sh Executable file
View file

@ -0,0 +1,3 @@
docker rm -f multiplicat-contenedor
docker build -t multiplicat-app .
docker run -d --name multiplicat-contenedor -p 5000:5000 multiplicat-app

10
static/css/styles.css Normal file
View file

@ -0,0 +1,10 @@
body {
background-color: #f8f9fa; /* gris claro */
}
.card {
border-radius: 1rem;
}
#enunciado {
font-weight: 500;
}

186
static/js/game.js Normal file
View file

@ -0,0 +1,186 @@
// static/js/game.js
document.addEventListener("DOMContentLoaded", () => {
const TIEMPO_TOTAL = 60; // segundos
let segundosRestantes = TIEMPO_TOTAL;
let puntuacion = 0;
const barraTiempo = document.getElementById("barra-tiempo");
const tiempoTexto = document.getElementById("tiempo-texto");
const contadorAciertos = document.getElementById("contador-aciertos");
const enunciado = document.getElementById("enunciado");
const inputRespuesta = document.getElementById("input-respuesta");
const feedback = document.getElementById("feedback");
const botonFinal = document.getElementById("boton-final");
const verRankingBtn = document.getElementById("ver-ranking");
const jugarOtraBtn = document.getElementById("jugar-otra");
const formNombre = document.getElementById("form-nombre");
const inputNombre = document.getElementById("input-nombre");
const guardarNombreBtn = document.getElementById("guardar-nombre");
const mensajeGuardar = document.getElementById("mensaje-guardar");
let numeroA = 0;
let numeroB = 0;
let temporizadorInterval = null;
// Genera un par aleatorio (dos números entre 10 y 99)
function generarPregunta() {
numeroA = Math.floor(Math.random() * 90) + 10; // 10…99
numeroB = Math.floor(Math.random() * 90) + 10;
enunciado.textContent = `¿Cuánto es ${numeroA} × ${numeroB}?`;
inputRespuesta.value = "";
inputRespuesta.focus();
}
// Inicia el cronómetro y actualiza cada segundo
function iniciarTemporizador() {
barraTiempo.style.width = "100%";
tiempoTexto.textContent = `${segundosRestantes} segundos restantes`;
temporizadorInterval = setInterval(() => {
segundosRestantes--;
if (segundosRestantes < 0) {
clearInterval(temporizadorInterval);
terminarJuego();
return;
}
// Actualiza texto y barra de progreso
tiempoTexto.textContent = `${segundosRestantes} segundos restantes`;
const porcentaje = (segundosRestantes / TIEMPO_TOTAL) * 100;
barraTiempo.style.width = `${porcentaje}%`;
// Cambiar color de barra si queda poco tiempo
if (segundosRestantes <= 10) {
barraTiempo.classList.remove("bg-success");
barraTiempo.classList.add("bg-danger");
} else if (segundosRestantes <= 30) {
barraTiempo.classList.remove("bg-success");
barraTiempo.classList.add("bg-warning");
}
}, 1000);
}
// Función que maneja el envío de la respuesta al presionar Enter
inputRespuesta.addEventListener("keydown", (e) => {
if (e.key === "Enter" && segundosRestantes > 0) {
e.preventDefault();
validarRespuesta();
}
});
function validarRespuesta() {
const valor = parseInt(inputRespuesta.value);
const correcta = numeroA * numeroB;
if (!isNaN(valor)) {
if (valor === correcta) {
puntuacion++;
contadorAciertos.textContent = puntuacion;
feedback.innerHTML = `<span class="text-success">Correcto!</span>`;
} else {
feedback.innerHTML = `<span class="text-danger">Incorrecto. Era ${correcta}.</span>`;
}
// Mostrar feedback por 800ms y luego limpiarlo
setTimeout(() => {
feedback.textContent = "";
}, 800);
} else {
feedback.innerHTML = `<span class="text-warning">Ingresa un número válido.</span>`;
setTimeout(() => {
feedback.textContent = "";
}, 800);
}
generarPregunta();
}
function terminarJuego() {
// Deshabilitar input
inputRespuesta.disabled = true;
feedback.innerHTML = `
<div class="alert alert-info p-2">
¡Tiempo terminado! Tu puntuación es: <strong>${puntuacion}</strong>
</div>
`;
// Enviar puntuación al servidor
fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ puntuacion })
})
.then(res => res.json())
.then(data => {
if (data.error) {
feedback.innerHTML += `<div class="text-danger">Error: ${data.error}</div>`;
botonFinal.classList.remove("d-none");
return;
}
if (data.entra_en_top) {
// Muestra formulario para nombre
formNombre.classList.remove("d-none");
} else {
// No entra en top10: mostrar botón para ver ranking
botonFinal.classList.remove("d-none");
}
// Guarda el top10 actual en data.top10 para mostrar más tarde si hace falta
window.top10_actual = data.top10;
})
.catch(err => {
feedback.innerHTML += `<div class="text-danger">Error conectando al servidor.</div>`;
botonFinal.classList.remove("d-none");
});
}
// Botón “Ver Ranking” (si no entró en top 10)
verRankingBtn.addEventListener("click", () => {
// Redirigir a la página /ranking
window.location.href = "/ranking";
});
// Botón “Jugar otra vez”
jugarOtraBtn.addEventListener("click", () => {
window.location.href = "/juego";
});
// Al hacer clic en “Guardar” nombre (si entró en top 10)
guardarNombreBtn.addEventListener("click", () => {
const nombre = inputNombre.value.trim();
if (nombre.length === 0) {
mensajeGuardar.innerHTML = `<small class="text-danger">El nombre no puede estar vacío.</small>`;
return;
}
// Enviar nombre + puntuación al servidor
fetch('/api/score/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre, puntuacion })
})
.then(res => res.json())
.then(data => {
if (data.error) {
mensajeGuardar.innerHTML = `<small class="text-danger">Error: ${data.error}</small>`;
return;
}
mensajeGuardar.innerHTML = `<div class="alert alert-success p-2">
¡Puntuación guardada! Puedes ver el ranking actualizado.</div>`;
// Opcional: mostrar tabla de top10 recibida en data.top10
setTimeout(() => {
window.location.href = "/ranking";
}, 1500);
})
.catch(err => {
mensajeGuardar.innerHTML = `<small class="text-danger">Error guardando el nombre.</small>`;
});
});
// Iniciar todo
function iniciarJuego() {
generarPregunta();
iniciarTemporizador();
inputRespuesta.disabled = false;
inputRespuesta.focus();
}
// Esperar 300ms para que el usuario vea la primera pregunta
setTimeout(iniciarJuego, 300);
});

52
templates/base.html Normal file
View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% block head %}
<title>{% block title %}Juego de Multiplicaciones{% endblock %}</title>
<!-- Bootstrap 5 desde CDN -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-ENjdO4Dr2bkBIFxQpeo4Gaw416pGxYhbr2VdF+4E7RjzvRQZAZo+N91UZ7Jb8M+"
crossorigin="anonymous">
<!-- (Opcional) tu CSS personalizado -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% endblock %}
</head>
<body class="bg-light">
<!-- Barra de navegación sencilla -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">Multiplic-a-t</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('juego') }}">Jugar</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('ranking') }}">Top 10</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container">
{% block body %}{% endblock %}
</main>
<!-- Scripts de Bootstrap 5 (Popper + JS) -->
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-q2gy50CFdE7mU2MDoKvxo6oREjY4FKRDvKoiL+KD8l1zsR4OrFvCDZXGuXtn0pjg"
crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
</body>
</html>

60
templates/game.html Normal file
View file

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Jugar - Multiplic-a-t{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<h3 class="card-title mb-4 text-center">¡Pon a prueba tus multiplicaciones!</h3>
<!-- Temporizador: texto + barra de progreso -->
<div class="mb-3">
<span id="tiempo-texto" class="fs-5">60 segundos restantes</span>
<div class="progress mt-1">
<div id="barra-tiempo" class="progress-bar bg-success" role="progressbar"
style="width: 100%" aria-valuenow="60" aria-valuemin="0" aria-valuemax="60">
</div>
</div>
</div>
<!-- Contador de aciertos -->
<div class="mb-3">
<span class="fs-6">Aciertos: <span id="contador-aciertos">0</span></span>
</div>
<!-- Enunciado de la multiplicación -->
<div class="mb-3">
<label for="input-respuesta" class="form-label fs-5" id="enunciado">¿Cuánto es 0 × 0?</label>
<input type="number" id="input-respuesta" class="form-control" autocomplete="off" />
</div>
<!-- Mensaje de feedback (correcto/incorrecto) -->
<div id="feedback" class="mb-3" style="min-height: 1.5em;"></div>
<!-- Botón oculto inicialmente, para reiniciar o ver ranking -->
<div id="boton-final" class="text-center mt-4 d-none">
<button id="ver-ranking" class="btn btn-primary">Ver Ranking</button>
<button id="jugar-otra" class="btn btn-secondary ms-2">Jugar otra vez</button>
</div>
<!-- Formulario para que el usuario escriba nombre si entra en top 10 -->
<div id="form-nombre" class="mt-4 d-none">
<p class="mb-2">¡Felicidades! Entraste en el Top 10. Escribe tu nombre:</p>
<div class="input-group">
<input type="text" id="input-nombre" class="form-control" maxlength="20" />
<button id="guardar-nombre" class="btn btn-success">Guardar</button>
</div>
<div id="mensaje-guardar" class="mt-2"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- Importar el archivo JS que contiene la lógica del juego -->
<script src="{{ url_for('static', filename='js/game.js') }}"></script>
{% endblock %}

16
templates/index.html Normal file
View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Inicio - Multiplic-a-t{% endblock %}
{% block body %}
<div class="text-center py-5">
<h1 class="mb-3">Bienvenido a Multiplic-a-t</h1>
<p class="lead">
Tienes 1 minuto para resolver tantas multiplicaciones de dos cifras como puedas.
Escribe la respuesta en el campo de texto y presiona Enter.
Al terminar el minuto, podrás ver tu puntuación y, si entras en el top 10, registrar tu nombre.
</p>
<a href="{{ url_for('juego') }}" class="btn btn-lg btn-primary mt-4">Comenzar a jugar</a>
</div>
{% endblock %}

38
templates/ranking.html Normal file
View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Top 10 - Multiplic-a-t{% endblock %}
{% block body %}
<div class="text-center mb-4">
<h2>Top 10 de Puntuaciones</h2>
<a href="{{ url_for('juego') }}" class="btn btn-sm btn-primary mt-2">Jugar de nuevo</a>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
{% if top10 %}
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Nombre</th>
<th>Puntuación</th>
</tr>
</thead>
<tbody>
{# Recorremos top10 sin enumerate; loop.index comienza en 1 #}
{% for par in top10 %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ par[0] }}</td> {# par[0] = nombre #}
<td>{{ par[1] }}</td> {# par[1] = puntuación #}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-center">Aún no hay puntuaciones.</p>
{% endif %}
</div>
</div>
{% endblock %}