Un videojuego combina reglas, gráficos, sonido, entradas del usuario y actualización constante del estado. Las funciones matemáticas ayudan a organizar esos cambios y a convertir decisiones en movimiento, colisiones, animaciones y resultados.
En este contexto, una función puede calcular la nueva posición de un personaje, verificar si dos objetos chocan, reducir la vida de un enemigo o decidir hacia dónde se mueve una entidad.
El estado del juego reúne la información necesaria para continuar la partida: posición, velocidad, vida, puntaje, tiempo, nivel y cualquier otra variable relevante.
const estado = {
tiempo: 0,
puntaje: 0,
jugador: { x: 40, y: 80, vida: 100 },
enemigo: { x: 120, y: 80, vida: 50 }
};
console.log("Jugador:", estado.jugador);
console.log("Enemigo:", estado.enemigo);
console.log("Puntaje:", estado.puntaje);
Cuanto más claro sea el estado, más fácil será escribir funciones que lo actualicen sin confusión.
La mayoría de los videojuegos tienen un ciclo que se repite muchas veces por segundo. Ese ciclo procesa entrada, actualiza el mundo y dibuja el resultado.
function actualizar(estado) {
return {
tiempo: estado.tiempo + 1,
puntaje: estado.puntaje + 10
};
}
let estado = { tiempo: 0, puntaje: 0 };
for (let frame = 1; frame <= 5; frame++) {
estado = actualizar(estado);
console.log("Frame", frame, estado);
}
Cada vuelta del ciclo suele llamarse frame o fotograma.
El movimiento puede modelarse con una función que recibe la posición, la dirección y la velocidad.
function moverJugador(jugador, entrada, velocidad) {
return {
x: jugador.x + entrada.x * velocidad,
y: jugador.y + entrada.y * velocidad
};
}
let jugador = { x: 10, y: 20 };
const entrada = { x: 1, y: -1 };
jugador = moverJugador(jugador, entrada, 5);
console.log(jugador);
La entrada puede venir del teclado, mouse, pantalla táctil o un control.
Usar dt permite que el movimiento dependa del tiempo real y no solamente de la cantidad de frames.
function mover(posicion, velocidad, dt) {
return posicion + velocidad * dt;
}
let x = 0;
const velocidad = 120;
const dt = 1 / 60;
for (let frame = 1; frame <= 5; frame++) {
x = mover(x, velocidad, dt);
console.log("Frame", frame, "x:", x.toFixed(2));
}
Esto evita que el juego se vuelva más rápido o más lento según el rendimiento de la computadora.
Si un jugador se mueve en diagonal sumando x e y, puede avanzar más rápido que en línea recta. Para evitarlo se normaliza el vector de dirección.
function normalizar(v) {
const longitud = Math.sqrt(v.x * v.x + v.y * v.y);
if (longitud === 0) {
return { x: 0, y: 0 };
}
return { x: v.x / longitud, y: v.y / longitud };
}
const direccion = normalizar({ x: 1, y: 1 });
console.log(direccion);
Una función de límite impide que un personaje salga del área permitida.
function limitar(valor, minimo, maximo) {
return Math.max(minimo, Math.min(maximo, valor));
}
function limitarJugador(jugador, ancho, alto) {
return {
x: limitar(jugador.x, 0, ancho),
y: limitar(jugador.y, 0, alto)
};
}
const jugador = { x: 450, y: -20 };
console.log(limitarJugador(jugador, 400, 300));
Un salto se puede simular con velocidad vertical y gravedad. La gravedad modifica la velocidad, y la velocidad modifica la posición.
function actualizarSalto(jugador, dt) {
const gravedad = 980;
let vy = jugador.vy + gravedad * dt;
let y = jugador.y + vy * dt;
let enSuelo = false;
if (y > 0) {
y = 0;
vy = 0;
enSuelo = true;
}
return { y, vy, enSuelo };
}
let jugador = { y: 0, vy: -420, enSuelo: false };
for (let i = 1; i <= 5; i++) {
jugador = actualizarSalto(jugador, 1 / 60);
console.log(i, jugador);
}
Un proyectil se actualiza con una función de movimiento. También se suele verificar si salió del escenario para eliminarlo.
function actualizarProyectil(proyectil, dt) {
return {
x: proyectil.x + proyectil.vx * dt,
y: proyectil.y + proyectil.vy * dt,
vx: proyectil.vx,
vy: proyectil.vy
};
}
function estaFuera(proyectil, ancho, alto) {
return proyectil.x < 0 || proyectil.x > ancho || proyectil.y < 0 || proyectil.y > alto;
}
let proyectil = { x: 20, y: 50, vx: 180, vy: 0 };
proyectil = actualizarProyectil(proyectil, 0.5);
console.log(proyectil);
console.log("Fuera:", estaFuera(proyectil, 100, 100));
La distancia entre dos entidades permite decidir si están cerca, si un enemigo puede atacar o si un objeto puede recogerse.
function distancia(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
const jugador = { x: 30, y: 40 };
const llave = { x: 42, y: 49 };
console.log("Distancia:", distancia(jugador, llave).toFixed(2));
console.log("Puede recoger:", distancia(jugador, llave) <= 15);
Una colisión común en juegos 2D es la intersección entre rectángulos alineados a los ejes.
function colisionRectangular(a, b) {
return a.x < b.x + b.ancho &&
a.x + a.ancho > b.x &&
a.y < b.y + b.alto &&
a.y + a.alto > b.y;
}
const jugador = { x: 20, y: 20, ancho: 30, alto: 40 };
const enemigo = { x: 45, y: 30, ancho: 30, alto: 30 };
console.log("Colisionan:", colisionRectangular(jugador, enemigo));
Las colisiones circulares se usan para objetos redondos o para aproximar entidades con una zona de contacto simple.
function colisionCircular(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const sumaRadios = a.radio + b.radio;
return dx * dx + dy * dy <= sumaRadios * sumaRadios;
}
const pelota = { x: 10, y: 10, radio: 8 };
const moneda = { x: 23, y: 12, radio: 5 };
console.log("Colisionan:", colisionCircular(pelota, moneda));
Las funciones también expresan reglas del juego. Por ejemplo, recibir daño reduce la vida, pero no debería bajarla por debajo de cero.
function aplicarDanio(entidad, danio) {
return {
...entidad,
vida: Math.max(0, entidad.vida - danio)
};
}
let enemigo = { nombre: "Robot", vida: 35 };
enemigo = aplicarDanio(enemigo, 12);
enemigo = aplicarDanio(enemigo, 30);
console.log(enemigo);
El puntaje suele depender de acciones: recoger objetos, derrotar enemigos, completar objetivos o terminar un nivel rápido.
function calcularPuntos(evento) {
const valores = {
moneda: 10,
enemigo: 100,
bonus: 250
};
return valores[evento] || 0;
}
let puntaje = 0;
puntaje += calcularPuntos("moneda");
puntaje += calcularPuntos("enemigo");
console.log("Puntaje:", puntaje);
Una IA simple puede mover un enemigo hacia el jugador calculando la dirección entre ambos.
function moverHacia(origen, destino, velocidad) {
const dx = destino.x - origen.x;
const dy = destino.y - origen.y;
const distancia = Math.sqrt(dx * dx + dy * dy);
if (distancia === 0) {
return origen;
}
return {
x: origen.x + dx / distancia * velocidad,
y: origen.y + dy / distancia * velocidad
};
}
const enemigo = { x: 10, y: 10 };
const jugador = { x: 40, y: 50 };
console.log(moverHacia(enemigo, jugador, 5));
Un enemigo puede patrullar entre dos puntos usando una función que invierte la dirección al llegar a los límites.
function patrullar(enemigo, minimo, maximo) {
let x = enemigo.x + enemigo.direccion * enemigo.velocidad;
let direccion = enemigo.direccion;
if (x >= maximo || x <= minimo) {
direccion *= -1;
x = Math.max(minimo, Math.min(maximo, x));
}
return { x, direccion, velocidad: enemigo.velocidad };
}
let enemigo = { x: 48, direccion: 1, velocidad: 4 };
for (let i = 1; i <= 4; i++) {
enemigo = patrullar(enemigo, 20, 50);
console.log(enemigo);
}
La aleatoriedad permite decidir si aparece un enemigo, un objeto o una recompensa. Conviene encapsular esa regla en una función.
function ocurre(probabilidad) {
return Math.random() < probabilidad;
}
let apariciones = 0;
for (let intento = 1; intento <= 10; intento++) {
if (ocurre(0.3)) {
apariciones++;
}
}
console.log("Apariciones:", apariciones);
La dificultad puede expresarse mediante funciones que escalan valores según el nivel actual.
function vidaEnemigo(nivel) {
return 50 + nivel * 15;
}
function velocidadEnemigo(nivel) {
return 2 + nivel * 0.4;
}
for (let nivel = 1; nivel <= 5; nivel++) {
console.log(
"Nivel", nivel,
"vida:", vidaEnemigo(nivel),
"velocidad:", velocidadEnemigo(nivel).toFixed(1)
);
}
Un enfriamiento evita que una acción se repita sin límite. Se usa para disparos, habilidades, ataques y recolecciones.
function puedeUsarHabilidad(tiempoActual, ultimoUso, cooldown) {
return tiempoActual - ultimoUso >= cooldown;
}
const cooldown = 3;
let ultimoUso = 5;
for (let tiempo = 6; tiempo <= 9; tiempo++) {
console.log("t =", tiempo, puedeUsarHabilidad(tiempo, ultimoUso, cooldown));
}
Una cámara puede seguir al jugador de manera suave interpolando entre su posición actual y el objetivo.
function acercar(actual, objetivo, factor) {
return actual + (objetivo - actual) * factor;
}
let camara = { x: 0, y: 0 };
const jugador = { x: 100, y: 60 };
for (let frame = 1; frame <= 5; frame++) {
camara = {
x: acercar(camara.x, jugador.x, 0.2),
y: acercar(camara.y, jugador.y, 0.2)
};
console.log("Cámara:", camara);
}
Las animaciones pueden elegir un cuadro según el tiempo transcurrido. Una función convierte tiempo en índice de imagen.
function cuadroAnimacion(tiempo, cuadros, duracionCuadro) {
return Math.floor(tiempo / duracionCuadro) % cuadros;
}
for (let i = 0; i <= 8; i++) {
const tiempo = i * 0.1;
console.log("t:", tiempo.toFixed(1), "cuadro:", cuadroAnimacion(tiempo, 4, 0.2));
}
Las funciones que calculan reglas del juego no deberían depender directamente de cómo se dibuja la pantalla. Esto facilita cambiar gráficos sin romper la lógica.
function estaVivo(entidad) {
return entidad.vida > 0;
}
function colorSegunVida(entidad) {
if (entidad.vida > 60) return "verde";
if (entidad.vida > 25) return "amarillo";
return "rojo";
}
const jugador = { vida: 40 };
console.log("Regla:", estaVivo(jugador));
console.log("Presentación:", colorSegunVida(jugador));
Para observar todas estas funciones trabajando de manera conjunta en tiempo real, hemos desarrollado el simulador MathQuest: Ecuaciones en Acción. Controla al personaje en este entorno neo-futurista usando tu teclado (A, D / Flechas para moverte, W / Flecha Arriba / Espacio para saltar y F / Clic para disparar). El panel Math Inspector a la derecha extrae directamente las variables internas del estado de juego y muestra qué fórmulas matemáticas exactas se están calculando en cada fotograma.
Limita la posición del personaje para que no pueda caer al vacío ni salir del nivel horizontal (1200px).
Al usar funciones en videojuegos conviene evitar estos errores:
Los videojuegos usan funciones para transformar entradas en acciones, reglas en consecuencias y tiempo en movimiento. Al escribir funciones pequeñas y claras, el comportamiento del juego se vuelve más fácil de probar, ajustar y ampliar.
La matemática no aparece como teoría aislada: aparece en cada movimiento, colisión, animación, cámara, recompensa y decisión del sistema.
/**
* js/tema68-juego.js - Motor del Videojuego Educativo "MathQuest: Ecuaciones en Acción"
* Implementación de los conceptos 68.1 a 68.22 para demostrar funciones matemáticas en vivo.
*/
(function () {
"use strict";
// ==========================================
// 68.22 Separar reglas y presentación
// ==========================================
// 68.6 Normalizar dirección
function normalizar(v) {
const longitud = Math.sqrt(v.x * v.x + v.y * v.y);
if (longitud === 0) return { x: 0, y: 0 };
return { x: v.x / longitud, y: v.y / longitud };
}
// 68.7 Mantener objetos dentro del escenario (Clamping)
function limitar(valor, minimo, maximo) {
return Math.max(minimo, Math.min(maximo, valor));
}
function limitarJugador(jugador, ancho, alto) {
return {
...jugador,
x: limitar(jugador.x, 0, ancho - jugador.ancho),
y: limitar(jugador.y, 0, alto - jugador.alto)
};
}
// 68.8 Saltos y gravedad
function actualizarSalto(jugador, dt, gravedad, limiteSuelo) {
let vy = jugador.vy + gravedad * dt;
let y = jugador.y + vy * dt;
let enSuelo = false;
if (y >= limiteSuelo) {
y = limiteSuelo;
vy = 0;
enSuelo = true;
}
return { y, vy, enSuelo };
}
// 68.9 Proyectiles
function actualizarProyectil(proyectil, dt) {
return {
...proyectil,
x: proyectil.x + proyectil.vx * dt,
y: proyectil.y + proyectil.vy * dt
};
}
function estaFuera(proyectil, ancho, alto) {
return proyectil.x < 0 || proyectil.x > ancho || proyectil.y < 0 || proyectil.y > alto;
}
// 68.10 Distancia entre entidades
function distancia(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
// 68.11 Colisiones rectangulares (AABB)
function colisionRectangular(a, b) {
return a.x < b.x + b.ancho &&
a.x + a.ancho > b.x &&
a.y < b.y + b.alto &&
a.y + a.alto > b.y;
}
// 68.12 Colisiones circulares
function colisionCircular(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const sumaRadios = a.radio + b.radio;
return dx * dx + dy * dy <= sumaRadios * sumaRadios;
}
// 68.13 Daño y vida
function aplicarDanio(entidad, danio) {
return {
...entidad,
vida: Math.max(0, entidad.vida - danio)
};
}
// 68.14 Puntuación
function calcularPuntos(evento) {
const valores = { moneda: 10, enemigo: 100, bonus: 250 };
return valores[evento] || 0;
}
// 68.15 Enemigos que persiguen (IA)
function moverHacia(origen, destino, velocidad) {
const dx = destino.x - origen.x;
const dy = destino.y - origen.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist === 0) return origen;
return {
x: origen.x + (dx / dist) * velocidad,
y: origen.y + (dy / dist) * velocidad
};
}
// 68.16 Patrullaje (IA)
function patrullar(enemigo, minimo, maximo) {
let x = enemigo.x + enemigo.direccion * enemigo.velocidad;
let direccion = enemigo.direccion;
if (x >= maximo || x <= minimo) {
direccion *= -1;
x = Math.max(minimo, Math.min(maximo, x));
}
return { x, direccion, velocidad: enemigo.velocidad };
}
// 68.17 Probabilidad de aparición
function ocurre(probabilidad) {
return Math.random() < probabilidad;
}
// 68.18 Niveles de dificultad
function vidaEnemigo(nivel) {
return 30 + nivel * 15;
}
// 68.18 Velocidad de dificultad
function velocidadEnemigo(nivel) {
return 1.2 + nivel * 0.3;
}
// 68.19 Enfriamiento de acciones
function puedeUsarHabilidad(tiempoActual, ultimoUso, cooldown) {
return tiempoActual - ultimoUso >= cooldown;
}
// 68.20 Interpolación en cámaras (Lerp)
function acercar(actual, objetivo, factor) {
return actual + (objetivo - actual) * factor;
}
// 68.21 Animaciones por tiempo
function cuadroAnimacion(tiempo, cuadros, duracionCuadro) {
return Math.floor(tiempo / duracionCuadro) % cuadros;
}
// ==========================================
// CONFIGURACIÓN DE ESCENARIO Y MOTOR DE PRESENTACIÓN
// ==========================================
const ANCHO_MAPA = 1200;
const ALTO_MAPA = 400;
const GRAVEDAD = 1100;
const LIMITE_SUELO = 320;
// 68.2 Estado del juego
let estado = {
tiempo: 0,
puntaje: 0,
nivel: 1,
jugador: {
x: 80, y: LIMITE_SUELO, vx: 0, vy: 0, ancho: 26, alto: 38,
vida: 100, vidaMax: 100, enSuelo: true, ultimoDisparo: -1,
cooldownDisparo: 0.3, direccion: 1
},
llave: { x: 550, y: 180, ancho: 18, alto: 18, recogida: false, radioAtraccion: 60 },
puerta: { x: 1100, y: LIMITE_SUELO - 22, ancho: 40, alto: 60, abierta: false },
plataformas: [
{ x: 0, y: LIMITE_SUELO + 38, ancho: ANCHO_MAPA, alto: 50 },
{ x: 200, y: 250, ancho: 160, alto: 16 },
{ x: 480, y: 220, ancho: 160, alto: 16 },
{ x: 750, y: 240, ancho: 180, alto: 16 }
],
enemigos: [], proyectiles: [], monedas: [], particulas: [], camara: { x: 0, y: 0 }
};
const teclas = {};
const depuracion = { hitboxes: true, vectores: true, distancias: true };
let canvas, ctx, ultimoTiempo = 0, juegoCorriendo = false;
function inicializarNivel(nivel) {
estado.nivel = nivel;
estado.jugador.x = 80; estado.jugador.y = LIMITE_SUELO;
estado.jugador.vx = 0; estado.jugador.vy = 0;
estado.jugador.enSuelo = true; estado.llave.recogida = false;
if (nivel % 2 === 0) {
estado.llave.x = 840; estado.llave.y = 200;
} else {
estado.llave.x = 560; estado.llave.y = 180;
}
estado.puerta.abierta = false;
estado.monedas = [];
const posicionesMonedas = [
{ x: 240, y: 210 }, { x: 280, y: 210 }, { x: 320, y: 210 },
{ x: 520, y: 180 }, { x: 600, y: 180 },
{ x: 790, y: 200 }, { x: 840, y: 200 }, { x: 890, y: 200 },
{ x: 420, y: 300 }, { x: 980, y: 300 }
];
posicionesMonedas.forEach(pos => {
if (ocurre(0.85)) {
estado.monedas.push({ x: pos.x, y: pos.y, radio: 8, recolectada: false });
}
});
estado.enemigos = [
{ tipo: "patrulla", x: 300, y: LIMITE_SUELO + 10, ancho: 30, alto: 28, limiteMin: 180, limiteMax: 440, velocidad: velocidadEnemigo(nivel), direccion: 1, vida: vidaEnemigo(nivel), vidaMax: vidaEnemigo(nivel) },
{ tipo: "patrulla", x: 750, y: LIMITE_SUELO + 10, ancho: 30, alto: 28, limiteMin: 650, limiteMax: 950, velocidad: velocidadEnemigo(nivel)*1.2, direccion: -1, vida: vidaEnemigo(nivel), vidaMax: vidaEnemigo(nivel) },
{ tipo: "chase", x: 600, y: 80, ancho: 24, alto: 24, radioDeteccion: 280, velocidad: velocidadEnemigo(nivel)*1.5, vida: vidaEnemigo(nivel)*0.7, vidaMax: vidaEnemigo(nivel)*0.7 }
];
estado.proyectiles = []; estado.particulas = []; estado.camara.x = 0;
crearParticulaFlotante("¡NIVEL " + nivel + "!", estado.jugador.x, estado.jugador.y - 40, "#00f2fe");
}
// 68.3 Bucle de juego
function frame(timestamp) {
if (!juegoCorriendo) return;
if (!ultimoTiempo) ultimoTiempo = timestamp;
let dt = (timestamp - ultimoTiempo) / 1000;
ultimoTiempo = timestamp;
if (dt > 0.1) dt = 0.1;
actualizarJuego(dt);
dibujarJuego();
actualizarInspector();
requestAnimationFrame(frame);
}
function actualizarJuego(dt) {
estado.tiempo += dt;
// 68.4 Entrada y 68.6 Normalizar
const entrada = { x: 0, y: 0 };
if (teclas["ArrowLeft"] || teclas["a"] || teclas["A"]) entrada.x = -1;
if (teclas["ArrowRight"] || teclas["d"] || teclas["D"]) entrada.x = 1;
const dirNormalizada = normalizar(entrada);
estado.jugador.vx = dirNormalizada.x * 220;
if (dirNormalizada.x !== 0) estado.jugador.direccion = Math.sign(dirNormalizada.x);
estado.jugador.x += estado.jugador.vx * dt;
// 68.8 Salto y gravedad
if ((teclas["ArrowUp"] || teclas["w"] || teclas["W"] || teclas[" "]) && estado.jugador.enSuelo) {
estado.jugador.vy = -490;
estado.jugador.enSuelo = false;
}
const fisicasSalto = actualizarSalto(estado.jugador, dt, GRAVEDAD, LIMITE_SUELO);
estado.jugador.y = fisicasSalto.y;
estado.jugador.vy = fisicasSalto.vy;
estado.jugador.enSuelo = fisicasSalto.enSuelo;
// Colisiones Plataformas AABB (68.11)
estado.plataformas.forEach(plat => {
if (plat.y < LIMITE_SUELO + 10) {
if (colisionRectangular(estado.jugador, plat)) {
if (estado.jugador.vy > 0 && estado.jugador.y + estado.jugador.alto - estado.jugador.vy * dt <= plat.y + 4) {
estado.jugador.y = plat.y - estado.jugador.alto;
estado.jugador.vy = 0;
estado.jugador.enSuelo = true;
}
}
}
});
// 68.7 Limitar escenario
estado.jugador = limitarJugador(estado.jugador, ANCHO_MAPA, ALTO_MAPA);
// 68.19 Cooldown e Disparo de proyectiles
if (teclas["f"] || teclas["F"] || teclas["x"] || teclas["X"]) {
if (puedeUsarHabilidad(estado.tiempo, estado.jugador.ultimoDisparo, estado.jugador.cooldownDisparo)) {
estado.jugador.ultimoDisparo = estado.tiempo;
estado.proyectiles.push({
x: estado.jugador.x + (estado.jugador.direccion > 0 ? estado.jugador.ancho : -6),
y: estado.jugador.y + 16, vx: estado.jugador.direccion * 450, vy: 0,
ancho: 10, alto: 6, delJugador: true
});
}
}
estado.proyectiles = estado.proyectiles.map(proj => actualizarProyectil(proj, dt));
estado.proyectiles = estado.proyectiles.filter(proj => !estaFuera(proj, ANCHO_MAPA, ALTO_MAPA));
// 68.12 Recolectar Monedas
estado.monedas.forEach(moneda => {
if (moneda.recolectada) return;
const jCentro = { x: estado.jugador.x + estado.jugador.ancho / 2, y: estado.jugador.y + estado.jugador.alto / 2, radio: 12 };
if (colisionCircular(jCentro, moneda)) {
moneda.recolectada = true;
estado.puntaje += calcularPuntos("moneda");
crearParticulaFlotante("+10 PTS", moneda.x, moneda.y - 10, "#00f2fe");
}
});
// 68.10 Recolección Llave
if (!estado.llave.recogida) {
const distJL = distancia(
{ x: estado.jugador.x + estado.jugador.ancho / 2, y: estado.jugador.y + estado.jugador.alto / 2 },
{ x: estado.llave.x + estado.llave.ancho / 2, y: estado.llave.y + estado.llave.alto / 2 }
);
if (distJL <= 22) {
estado.llave.recogida = true;
estado.puntaje += calcularPuntos("moneda") * 2;
crearParticulaFlotante("🔑 LLAVE!", estado.llave.x, estado.llave.y - 20, "#fffb00");
} else if (distJL <= estado.llave.radioAtraccion) {
// 68.20 Lerp atracción llave
estado.llave.x = acercar(estado.llave.x, estado.jugador.x + estado.jugador.ancho/2 - 9, 0.15);
estado.llave.y = acercar(estado.llave.y, estado.jugador.y + estado.jugador.alto/2 - 9, 0.15);
}
}
// AABB colisión Puerta
if (estado.llave.recogida && !estado.puerta.abierta) {
if (colisionRectangular(estado.jugador, estado.puerta)) {
estado.puerta.abierta = true;
estado.puntaje += calcularPuntos("bonus");
crearParticulaFlotante("¡COMPLETO! +250 PTS", estado.puerta.x, estado.puerta.y - 20, "#39ff14");
setTimeout(() => inicializarNivel(estado.nivel + 1), 1200);
}
}
// Enemigos IA y colisiones
estado.enemigos.forEach(enemigo => {
if (enemigo.vida <= 0) return;
if (enemigo.tipo === "patrulla") {
const sgte = patrullar(enemigo, enemigo.limiteMin, enemigo.limiteMax);
enemigo.x = sgte.x; enemigo.direccion = sgte.direccion;
if (colisionRectangular(estado.jugador, enemigo)) dañarJugador(15 * dt);
} else if (enemigo.tipo === "chase") {
const cE = { x: enemigo.x + enemigo.ancho/2, y: enemigo.y + enemigo.alto/2 };
const cJ = { x: estado.jugador.x + estado.jugador.ancho/2, y: estado.jugador.y + estado.jugador.alto/2 };
if (distancia(cE, cJ) <= enemigo.radioDeteccion) {
const sgte = moverHacia(enemigo, estado.jugador, enemigo.velocidad);
enemigo.x = sgte.x; enemigo.y = sgte.y;
}
if (colisionRectangular(estado.jugador, enemigo)) dañarJugador(20 * dt);
}
estado.proyectiles.forEach(proj => {
if (proj.delJugador && colisionRectangular(proj, enemigo)) {
proj.x = -9999;
enemigo.vida = aplicarDanio(enemigo, 25).vida;
crearParticulaFlotante("-25", enemigo.x + enemigo.ancho/2, enemigo.y, "#ff007f");
if (enemigo.vida <= 0) {
estado.puntaje += calcularPuntos("enemigo");
crearParticulaFlotante("¡DESTRUIDO! +100", enemigo.x, enemigo.y - 15, "#39ff14");
}
}
});
});
estado.enemigos = estado.enemigos.filter(e => e.vida > 0);
estado.particulas.forEach(p => { p.y -= 30 * dt; p.vida -= dt; });
estado.particulas = estado.particulas.filter(p => p.vida > 0);
// 68.20 Lerp Cámara
const targetCamX = estado.jugador.x - canvas.width / 2 + estado.jugador.ancho / 2;
const camLimX = limitar(targetCamX, 0, ANCHO_MAPA - canvas.width);
estado.camara.x = acercar(estado.camara.x, camLimX, 0.1);
}
function dañarJugador(danio) {
estado.jugador.vida = aplicarDanio(estado.jugador, danio).vida;
if (estado.jugador.vida <= 0) {
crearParticulaFlotante("¡DERROTADO!", estado.jugador.x, estado.jugador.y - 20, "#ff007f");
juegoCorriendo = false;
setTimeout(() => {
estado.jugador.vida = estado.jugador.vidaMax;
inicializarNivel(1); estado.puntaje = 0;
juegoCorriendo = true; ultimoTiempo = 0;
requestAnimationFrame(frame);
}, 1500);
}
}
function dibujarJuego() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save(); ctx.translate(-Math.floor(estado.camara.x), 0);
// Rejilla de Fondo
ctx.strokeStyle = "rgba(0, 242, 254, 0.05)"; ctx.lineWidth = 1;
for (let x = 0; x < ANCHO_MAPA; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, ALTO_MAPA); ctx.stroke(); }
for (let y = 0; y < ALTO_MAPA; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(ANCHO_MAPA, y); ctx.stroke(); }
// Plataformas
estado.plataformas.forEach(plat => {
ctx.fillStyle = "rgba(11, 36, 71, 0.9)"; ctx.strokeStyle = "#00f2fe"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(plat.x, plat.y, plat.ancho, plat.alto, [4]); ctx.fill(); ctx.stroke();
});
// Puerta
ctx.lineWidth = 3;
ctx.fillStyle = estado.puerta.abierta ? "rgba(57, 255, 20, 0.25)" : "rgba(255, 0, 127, 0.15)";
ctx.strokeStyle = estado.puerta.abierta ? "#39ff14" : "#ff007f";
ctx.beginPath(); ctx.roundRect(estado.puerta.x, estado.puerta.y, estado.puerta.ancho, estado.puerta.alto, [8, 8, 0, 0]); ctx.fill(); ctx.stroke();
// Llave
if (!estado.llave.recogida) {
ctx.save(); ctx.translate(estado.llave.x, estado.llave.y + Math.sin(estado.tiempo * 5) * 6);
ctx.fillStyle = "#fffb00"; ctx.beginPath(); ctx.arc(6, 6, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(10, 4, 12, 4); ctx.fillRect(18, 8, 4, 4); ctx.restore();
}
// Monedas
estado.monedas.forEach(moneda => {
if (moneda.recolectada) return;
ctx.save(); ctx.translate(moneda.x, moneda.y); ctx.scale(Math.abs(Math.sin(estado.tiempo * 4 + moneda.x)), 1);
ctx.fillStyle = "rgba(0, 242, 254, 0.7)"; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, moneda.radio, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore();
});
// Proyectiles
ctx.fillStyle = "#00f2fe";
estado.proyectiles.forEach(proj => ctx.fillRect(proj.x, proj.y, proj.ancho, proj.alto));
// Enemigos
estado.enemigos.forEach(enemigo => {
ctx.save(); ctx.translate(enemigo.x, enemigo.y);
if (enemigo.tipo === "patrulla") {
ctx.fillStyle = "rgba(255, 0, 127, 0.75)"; ctx.strokeStyle = "#ffffff";
ctx.beginPath(); ctx.ellipse(enemigo.ancho/2, enemigo.alto/2, enemigo.ancho/2, enemigo.alto/2, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
} else if (enemigo.tipo === "chase") {
const wing = cuadroAnimacion(estado.tiempo, 4, 0.12) < 2 ? -6 : 6;
ctx.fillStyle = "rgba(168, 85, 247, 0.85)"; ctx.strokeStyle = "#ffffff";
ctx.beginPath(); ctx.arc(enemigo.ancho/2, enemigo.alto/2, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(enemigo.ancho/2 - 6, enemigo.alto/2); ctx.lineTo(enemigo.ancho/2 - 18, enemigo.alto/2 + wing); ctx.lineTo(enemigo.ancho/2 - 10, enemigo.alto/2 + 4); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(enemigo.ancho/2 + 6, enemigo.alto/2); ctx.lineTo(enemigo.ancho/2 + 18, enemigo.alto/2 + wing); ctx.lineTo(enemigo.ancho/2 + 10, enemigo.alto/2 + 4); ctx.closePath(); ctx.fill(); ctx.stroke();
}
if (enemigo.vida < enemigo.vidaMax) {
ctx.fillStyle = "#1e293b"; ctx.fillRect(0, -10, enemigo.ancho, 4);
ctx.fillStyle = "#ff007f"; ctx.fillRect(0, -10, enemigo.ancho * (enemigo.vida / enemigo.vidaMax), 4);
}
ctx.restore();
});
// Jugador
ctx.save(); ctx.translate(estado.jugador.x, estado.jugador.y);
ctx.fillStyle = "rgba(0, 242, 254, 0.8)"; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(0, 0, estado.jugador.ancho, estado.jugador.alto, [8]); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#ffffff"; const visX = estado.jugador.ancho/2 + estado.jugador.direccion*4;
ctx.fillRect(visX - (estado.jugador.direccion > 0 ? 3 : 11), 8, 14, 6); ctx.restore();
// Debugging overlays
if (depuracion.hitboxes) {
ctx.strokeStyle = "#39ff14"; ctx.strokeRect(estado.jugador.x, estado.jugador.y, estado.jugador.ancho, estado.jugador.alto);
estado.enemigos.forEach(enemigo => {
ctx.strokeStyle = "#ff007f"; ctx.strokeRect(enemigo.x, enemigo.y, enemigo.ancho, enemigo.alto);
});
}
if (depuracion.vectores && (Math.abs(estado.jugador.vx) > 0 || Math.abs(estado.jugador.vy) > 0.1)) {
ctx.strokeStyle = "#fffb00"; ctx.lineWidth = 2.5;
const oX = estado.jugador.x + estado.jugador.ancho/2, oY = estado.jugador.y - 12;
ctx.beginPath(); ctx.moveTo(oX, oY); ctx.lineTo(oX + estado.jugador.vx*0.15, oY + estado.jugador.vy*0.15); ctx.stroke();
}
if (depuracion.distancias && !estado.llave.recogida) {
const cJ = { x: estado.jugador.x + estado.jugador.ancho/2, y: estado.jugador.y + estado.jugador.alto/2 };
const cL = { x: estado.llave.x + estado.llave.ancho/2, y: estado.llave.y + estado.llave.alto/2 };
const d = distancia(cJ, cL);
ctx.strokeStyle = "rgba(0, 242, 254, 0.8)"; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(cJ.x, cJ.y); ctx.lineTo(cL.x, cL.y); ctx.stroke(); ctx.setLineDash([]);
}
ctx.restore();
dibujarHUDHTML();
}
function dibujarHUDHTML() {
document.getElementById("hud-life-fill").style.width = Math.round(estado.jugador.vida) + "%";
document.getElementById("hud-score-val").textContent = String(estado.puntaje);
document.getElementById("mathquest-level").textContent = String(estado.nivel);
const cdNode = document.getElementById("hud-cooldown-indicator");
if (cdNode) {
const cdLeft = estado.jugador.cooldownDisparo - (estado.tiempo - estado.jugador.ultimoDisparo);
if (cdLeft > 0) {
cdNode.innerHTML = ` CD`;
cdNode.style.color = "#ff007f";
} else {
cdNode.textContent = "LISTO ⚡";
cdNode.style.color = "#39ff14";
}
}
}
function actualizarInspector() {
setVal("ins-tiempo", estado.tiempo.toFixed(2) + "s");
setVal("ins-puntaje", String(estado.puntaje));
setVal("ins-x", Math.round(estado.jugador.x) + "px"); setVal("ins-y", Math.round(estado.jugador.y) + "px");
setVal("ins-vx", Math.round(estado.jugador.vx) + "px/s"); setVal("ins-vy", Math.round(estado.jugador.vy) + "px/s");
setVal("ins-enSuelo", String(estado.jugador.enSuelo));
const entX = teclas["ArrowLeft"] || teclas["a"] ? -1 : (teclas["ArrowRight"] || teclas["d"] ? 1 : 0);
setVal("ins-dir-bruta", `(${entX}, 0)`);
const n = normalizar({ x: entX, y: 0 });
setVal("ins-dir-norm", `(${n.x.toFixed(2)}, ${n.y.toFixed(2)})`);
setVal("ins-salto-y", "y = " + Math.round(estado.jugador.y) + ", vy = " + Math.round(estado.jugador.vy));
const distL = distancia(
{ x: estado.jugador.x + estado.jugador.ancho/2, y: estado.jugador.y + estado.jugador.alto/2 },
{ x: estado.llave.x + estado.llave.ancho/2, y: estado.llave.y + estado.llave.alto/2 }
);
setVal("ins-dist-llave", distL.toFixed(2) + " px " + (estado.llave.recogida ? "(Recogida)" : ""));
setVal("ins-cerca-llave", distL <= estado.llave.radioAtraccion ? "SÍ" : "NO");
setVal("ins-dif-nivel", "Nivel: " + estado.nivel);
setVal("ins-dif-vida", "Vida: " + vidaEnemigo(estado.nivel) + " HP");
setVal("ins-dif-vel", "Vel: " + velocidadEnemigo(estado.nivel).toFixed(1) + " px/s");
setVal("ins-anim-time", "t = " + estado.tiempo.toFixed(2) + "s");
setVal("ins-anim-frame", "Frame: " + cuadroAnimacion(estado.tiempo, 4, 0.1));
}
function setVal(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function crearParticulaFlotante(texto, x, y, colorHex) {
estado.particulas.push({ texto, x, y, color: colorHex, vida: 1.0 });
const viewport = document.getElementById("mathquest-viewport");
if (!viewport) return;
const div = document.createElement("div");
div.className = "particle-effect"; div.textContent = texto; div.style.color = colorHex;
div.style.textShadow = `0 0 4px #000, 0 0 8px ${colorHex}`;
const canvasEl = document.getElementById("mathquest-canvas");
const cX = x - estado.camara.x, cY = y;
if (canvasEl) {
const rect = canvasEl.getBoundingClientRect();
div.style.left = (cX * (rect.width / canvasEl.width)) + "px";
div.style.top = (cY * (rect.height / canvasEl.height)) + "px";
} else {
div.style.left = cX + "px"; div.style.top = cY + "px";
}
viewport.appendChild(div);
setTimeout(() => div.remove(), 900);
}
function inicializar() {
canvas = document.getElementById("mathquest-canvas");
if (!canvas) return;
ctx = canvas.getContext("2d");
canvas.width = 800; canvas.height = ALTO_MAPA;
window.addEventListener("keydown", e => {
teclas[e.key] = true;
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(e.key)) e.preventDefault();
});
window.addEventListener("keyup", e => teclas[e.key] = false);
canvas.addEventListener("mousedown", e => {
const rect = canvas.getBoundingClientRect();
const clickX = (e.clientX - rect.left) * (canvas.width / rect.width);
const dirClick = clickX > (estado.jugador.x + estado.jugador.ancho/2 - estado.camara.x) ? 1 : -1;
estado.jugador.direccion = dirClick;
if (puedeUsarHabilidad(estado.tiempo, estado.jugador.ultimoDisparo, estado.jugador.cooldownDisparo)) {
estado.jugador.ultimoDisparo = estado.tiempo;
estado.proyectiles.push({
x: estado.jugador.x + (dirClick > 0 ? estado.jugador.ancho : -6), y: estado.jugador.y + 16,
vx: dirClick * 450, vy: 0, ancho: 10, alto: 6, delJugador: true
});
}
});
document.querySelectorAll(".mathquest-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".mathquest-tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".mathquest-tab-pane").forEach(pane => {
pane.classList.remove("active");
if (pane.id === "tab-" + btn.getAttribute("data-tab")) pane.classList.add("active");
});
});
});
document.getElementById("chk-hitboxes").addEventListener("change", e => depuracion.hitboxes = e.target.checked);
document.getElementById("chk-vectores").addEventListener("change", e => depuracion.vectores = e.target.checked);
document.getElementById("chk-distancias").addEventListener("change", e => depuracion.distancias = e.target.checked);
document.getElementById("btn-reset-game").addEventListener("click", () => { estado.puntaje = 0; inicializarNivel(1); });
const linkSource = document.getElementById("btn-source-code"), modalSource = document.getElementById("modal-source-code");
linkSource.addEventListener("click", e => { e.preventDefault(); modalSource.classList.add("open"); document.body.style.overflow = "hidden"; });
const close = () => { modalSource.classList.remove("open"); document.body.style.overflow = ""; };
document.getElementById("btn-close-source-modal").addEventListener("click", close);
document.getElementById("modal-source-backdrop").addEventListener("click", close);
document.getElementById("btn-copy-source").addEventListener("click", () => {
const code = document.querySelector("#modal-source-code code").textContent;
navigator.clipboard.writeText(code).then(() => {
const btn = document.getElementById("btn-copy-source");
btn.classList.add("success"); btn.innerHTML = "✓ ¡Copiado!";
setTimeout(() => { btn.classList.remove("success"); btn.innerHTML = "📋 Copiar Código"; }, 2000);
});
});
// Iniciar Nivel 1 y dibujar el escenario estático (detrás del overlay)
inicializarNivel(1);
dibujarJuego();
// Vincular botón de inicio con logs
const btnStart = document.getElementById("btn-start-game");
const startOverlay = document.getElementById("mathquest-start-overlay");
console.log("MathQuest: Buscando elementos de inicio", btnStart, startOverlay);
if (btnStart && startOverlay) {
btnStart.addEventListener("click", (e) => {
console.log("MathQuest: Botón iniciar partida clickeado");
e.preventDefault();
startOverlay.classList.add("hidden");
juegoCorriendo = true;
ultimoTiempo = performance.now();
requestAnimationFrame(frame);
console.log("MathQuest: Juego corriendo:", juegoCorriendo);
});
} else {
console.warn("MathQuest: Elementos del overlay no encontrados. Iniciando automáticamente.");
juegoCorriendo = true;
requestAnimationFrame(frame);
}
}
// Registrar inicio robusto con window load
if (document.readyState === "complete") {
inicializar();
} else {
window.addEventListener("load", inicializar);
}
})();