- (() => {
- // ===== i18n (ES por defecto / EN opcional) =====
- const T =
- window.APP_LOCALE === "en"
- ? {
- scoreLabel: "Score:",
- livesLabel: "Lives:",
- gameOver: "Game Over",
- gameOverText: "Press R or Restart",
- pause: "Pause",
- pauseText: "Press P to continue",
- ready: "Ready",
- readyTouch: "Move with the d-pad and shoot with 🔥",
- readyKeys: "Space to shoot · P pause · R restart",
- }
- : {
- scoreLabel: "Puntos:",
- livesLabel: "Vidas:",
- gameOver: "Game Over",
- gameOverText: "Pulsa R o Reiniciar",
- pause: "Pausa",
- pauseText: "Pulsa P para continuar",
- ready: "Listo",
- readyTouch: "Mueve con la cruceta y dispara con 🔥",
- readyKeys: "Espacio para disparar · P pausa · R reiniciar",
- };
- // ===== DOM =====
- const canvas = document.getElementById("game");
- const ctx = canvas?.getContext("2d");
- const hudScore = document.getElementById("hudScore");
- const hudLives = document.getElementById("hudLives");
- const overlay = document.getElementById("overlay");
- const overlayTitle = document.getElementById("overlayTitle");
- const overlayText = document.getElementById("overlayText");
- const btnResume = document.getElementById("btnResume");
- const btnRestart = document.getElementById("btnRestart");
- // Controles táctiles
- const btnStartTouch = document.getElementById("btnStartTouch");
- const btnUp = document.getElementById("btnUp");
- const btnDown = document.getElementById("btnDown");
- const btnLeft = document.getElementById("btnLeft");
- const btnRight = document.getElementById("btnRight");
- const touchFire = document.getElementById("touchFire");
- if (!canvas || !ctx) {
- return;
- }
- // ===== Configuración =====
- const SPRITES_BASE = "/sources/img/space/";
- const SPRITES = {
- player: SPRITES_BASE + "player.png",
- enemy1: SPRITES_BASE + "enemy1.png",
- enemy2: SPRITES_BASE + "enemy2.png",
- };
- // ===== Utilidades =====
- const clamp = (value, min, max) => {
- return Math.max(min, Math.min(max, value));
- };
- const rand = (min, max) => {
- return min + Math.random() * (max - min);
- };
- function rectsOverlap(a, b) {
- return (
- a.x < b.x + b.w &&
- a.x + a.w > b.x &&
- a.y < b.y + b.h &&
- a.y + a.h > b.y
- );
- }
- function roundRectFill(x, y, width, height, radius) {
- const finalRadius = Math.min(
- radius,
- width / 2,
- height / 2
- );
- ctx.beginPath();
- ctx.moveTo(x + finalRadius, y);
- ctx.arcTo(
- x + width,
- y,
- x + width,
- y + height,
- finalRadius
- );
- ctx.arcTo(
- x + width,
- y + height,
- x,
- y + height,
- finalRadius
- );
- ctx.arcTo(
- x,
- y + height,
- x,
- y,
- finalRadius
- );
- ctx.arcTo(
- x,
- y,
- x + width,
- y,
- finalRadius
- );
- ctx.closePath();
- ctx.fill();
- }
- // ===== Carga de sprites =====
- const images = {};
- function loadImage(key, src) {
- return new Promise((resolve) => {
- const image = new Image();
- image.onload = () => {
- images[key] = image;
- resolve(true);
- };
- image.onerror = () => {
- resolve(false);
- };
- image.src = src;
- });
- }
- // ===== Input de teclado =====
- const keys = new Set();
- /*
- * Estas teclas pertenecen al juego.
- * Se bloquea su comportamiento dentro del navegador.
- */
- const gameKeys = new Set([
- "ArrowUp",
- "ArrowDown",
- "ArrowLeft",
- "ArrowRight",
- "Space",
- "KeyW",
- "KeyA",
- "KeyS",
- "KeyD",
- "KeyP",
- "KeyR",
- ]);
- window.addEventListener(
- "keydown",
- (event) => {
- if (gameKeys.has(event.code)) {
- event.preventDefault();
- }
- const key = event.key.toLowerCase();
- keys.add(key);
- /*
- * event.repeat evita que al mantener P pulsada
- * el juego pause y continúe repetidamente.
- */
- if (!event.repeat && key === "p") {
- togglePause();
- }
- if (!event.repeat && key === "r") {
- reset();
- }
- },
- {
- passive: false,
- }
- );
- window.addEventListener(
- "keyup",
- (event) => {
- if (gameKeys.has(event.code)) {
- event.preventDefault();
- }
- keys.delete(event.key.toLowerCase());
- },
- {
- passive: false,
- }
- );
- /*
- * Evita que un botón conserve el foco.
- * Así ESPACIO no vuelve a pulsar accidentalmente
- * el último botón utilizado.
- */
- document.addEventListener("click", (event) => {
- if (event.target instanceof HTMLButtonElement) {
- event.target.blur();
- }
- });
- /*
- * Si el navegador pierde el foco, se limpian
- * las teclas para evitar movimientos bloqueados.
- */
- window.addEventListener("blur", () => {
- keys.clear();
- });
- // ===== Input móvil =====
- const isTouchMode = () => {
- return (
- window.matchMedia("(max-width: 900px)").matches &&
- window.matchMedia("(hover: none)").matches &&
- window.matchMedia("(pointer: coarse)").matches
- );
- };
- const touchDir = {
- up: false,
- down: false,
- left: false,
- right: false,
- };
- let touchFiring = false;
- function bindHold(button, directionKey) {
- if (!button) {
- return;
- }
- const press = (event) => {
- if (!isTouchMode()) {
- return;
- }
- event.preventDefault();
- touchDir[directionKey] = true;
- };
- const release = (event) => {
- event.preventDefault();
- touchDir[directionKey] = false;
- };
- // iOS y Safari
- button.addEventListener(
- "touchstart",
- press,
- {
- passive: false,
- }
- );
- button.addEventListener(
- "touchend",
- release,
- {
- passive: false,
- }
- );
- button.addEventListener(
- "touchcancel",
- release,
- {
- passive: false,
- }
- );
- // Android y Chrome
- button.addEventListener("pointerdown", press);
- button.addEventListener("pointerup", release);
- button.addEventListener("pointercancel", release);
- button.addEventListener("pointerleave", release);
- }
- bindHold(btnUp, "up");
- bindHold(btnDown, "down");
- bindHold(btnLeft, "left");
- bindHold(btnRight, "right");
- if (touchFire) {
- const fireOn = (event) => {
- if (!isTouchMode()) {
- return;
- }
- event.preventDefault();
- touchFiring = true;
- };
- const fireOff = (event) => {
- event.preventDefault();
- touchFiring = false;
- };
- touchFire.addEventListener(
- "touchstart",
- fireOn,
- {
- passive: false,
- }
- );
- touchFire.addEventListener(
- "touchend",
- fireOff,
- {
- passive: false,
- }
- );
- touchFire.addEventListener(
- "touchcancel",
- fireOff,
- {
- passive: false,
- }
- );
- touchFire.addEventListener("pointerdown", fireOn);
- touchFire.addEventListener("pointerup", fireOff);
- touchFire.addEventListener("pointercancel", fireOff);
- touchFire.addEventListener("pointerleave", fireOff);
- }
- // START móvil: pausa o continuación
- btnStartTouch?.addEventListener("click", (event) => {
- if (!isTouchMode()) {
- return;
- }
- event.preventDefault();
- togglePause();
- });
- // ===== Overlay =====
- function showOverlay(title, text) {
- if (overlayTitle) {
- overlayTitle.textContent = title;
- }
- if (overlayText) {
- overlayText.textContent = text;
- }
- overlay?.classList.remove("hidden");
- }
- function hideOverlay() {
- overlay?.classList.add("hidden");
- }
- btnResume?.addEventListener("click", () => {
- if (!running) {
- return;
- }
- paused = false;
- hideOverlay();
- });
- btnRestart?.addEventListener("click", () => {
- reset();
- });
- // ===== HUD =====
- function updateHUD() {
- if (hudScore) {
- hudScore.textContent = String(score);
- }
- if (hudLives) {
- hudLives.textContent = String(lives);
- }
- }
- // ===== Estado del juego =====
- let running = true;
- let paused = false;
- let lastTime = performance.now();
- let score = 0;
- let lives = 3;
- const player = {
- x: canvas.width * 0.5 - 18,
- y: canvas.height - 64,
- w: 40,
- h: 40,
- speed: 360,
- cooldown: 0,
- fireRate: 0.2,
- invuln: 0,
- };
- let bullets = [];
- let enemies = [];
- let particles = [];
- let spawnTimer = 0;
- let spawnEvery = 1.2;
- let difficultyTimer = 0;
- const stars = Array.from(
- {
- length: 140,
- },
- () => ({
- x: Math.random() * canvas.width,
- y: Math.random() * canvas.height,
- s: rand(0.6, 2.2),
- v: rand(10, 30),
- })
- );
- // ===== Fondo de estrellas =====
- function drawStars(deltaTime) {
- ctx.fillStyle = "rgba(255,255,255,.65)";
- const step = deltaTime || 1 / 60;
- for (const star of stars) {
- star.y += star.v * step;
- if (star.y > canvas.height) {
- star.y = -4;
- star.x = Math.random() * canvas.width;
- star.v = rand(10, 30);
- star.s = rand(0.6, 2.2);
- }
- ctx.fillRect(
- star.x,
- star.y,
- star.s,
- star.s
- );
- }
- }
- // ===== Disparo =====
- function fireBullet() {
- bullets.push({
- x: player.x + player.w / 2 - 3,
- y: player.y - 10,
- w: 6,
- h: 14,
- vy: -700,
- });
- for (let index = 0; index < 5; index++) {
- particles.push({
- x: player.x + player.w / 2,
- y: player.y,
- vx: rand(-90, 90),
- vy: rand(-150, 30),
- life: rand(0.18, 0.28),
- r: rand(1, 2.2),
- });
- }
- }
- // ===== Enemigos =====
- function spawnEnemy() {
- const size = rand(28, 46);
- const x = rand(
- 8,
- canvas.width - size - 8
- );
- const speed = rand(90, 160);
- enemies.push({
- x,
- y: -size - 6,
- w: size,
- h: size,
- vy: speed,
- hp: size > 40 ? 2 : 1,
- });
- }
- // ===== Partículas =====
- function explode(x, y, quantity = 16) {
- for (let index = 0; index < quantity; index++) {
- particles.push({
- x,
- y,
- vx: rand(-220, 220),
- vy: rand(-220, 220),
- life: rand(0.22, 0.5),
- r: rand(1.2, 3),
- });
- }
- }
- // ===== Daño =====
- function takeHit() {
- if (player.invuln > 0) {
- return;
- }
- lives -= 1;
- player.invuln = 1.2;
- updateHUD();
- explode(
- player.x + player.w / 2,
- player.y + player.h / 2,
- 24
- );
- if (lives <= 0) {
- running = false;
- paused = false;
- showOverlay(
- T.gameOver,
- T.gameOverText
- );
- if (window.GameScores) {
- GameScores.submit(
- "space",
- score
- );
- }
- }
- }
- // ===== Pausa =====
- function togglePause() {
- if (!running) {
- return;
- }
- paused = !paused;
- if (paused) {
- showOverlay(
- T.pause,
- T.pauseText
- );
- } else {
- hideOverlay();
- }
- }
- // ===== Reinicio =====
- function reset() {
- score = 0;
- lives = 3;
- bullets = [];
- enemies = [];
- particles = [];
- spawnTimer = 0;
- spawnEvery = 1.2;
- difficultyTimer = 0;
- player.x =
- canvas.width * 0.5 -
- player.w / 2;
- player.y =
- canvas.height - 64;
- player.cooldown = 0;
- player.invuln = 0;
- running = true;
- paused = false;
- touchDir.up = false;
- touchDir.down = false;
- touchDir.left = false;
- touchDir.right = false;
- touchFiring = false;
- keys.clear();
- updateHUD();
- hideOverlay();
- showOverlay(
- T.ready,
- isTouchMode()
- ? T.readyTouch
- : T.readyKeys
- );
- setTimeout(() => {
- if (!paused && running) {
- hideOverlay();
- }
- }, 900);
- }
- // ===== Bucle principal =====
- function tick(time) {
- const deltaTime = Math.min(
- 0.033,
- (time - lastTime) / 1000
- );
- lastTime = time;
- if (!paused) {
- update(deltaTime);
- render(deltaTime);
- }
- requestAnimationFrame(tick);
- }
- // ===== Actualización =====
- function update(deltaTime) {
- if (!running) {
- return;
- }
- difficultyTimer += deltaTime;
- if (difficultyTimer > 10) {
- difficultyTimer = 0;
- spawnEvery = Math.max(
- 0.75,
- spawnEvery - 0.05
- );
- }
- let directionX = 0;
- let directionY = 0;
- if (
- keys.has("a") ||
- keys.has("arrowleft")
- ) {
- directionX -= 1;
- }
- if (
- keys.has("d") ||
- keys.has("arrowright")
- ) {
- directionX += 1;
- }
- if (
- keys.has("w") ||
- keys.has("arrowup")
- ) {
- directionY -= 1;
- }
- if (
- keys.has("s") ||
- keys.has("arrowdown")
- ) {
- directionY += 1;
- }
- // Controles táctiles
- if (isTouchMode()) {
- directionX =
- (touchDir.right ? 1 : 0) -
- (touchDir.left ? 1 : 0);
- directionY =
- (touchDir.down ? 1 : 0) -
- (touchDir.up ? 1 : 0);
- }
- // Normalizar movimiento diagonal
- if (
- directionX !== 0 &&
- directionY !== 0
- ) {
- const inverse = 1 / Math.sqrt(2);
- directionX *= inverse;
- directionY *= inverse;
- }
- player.x +=
- directionX *
- player.speed *
- deltaTime;
- player.y +=
- directionY *
- player.speed *
- deltaTime;
- player.x = clamp(
- player.x,
- 8,
- canvas.width -
- player.w -
- 8
- );
- player.y = clamp(
- player.y,
- 8,
- canvas.height -
- player.h -
- 8
- );
- player.cooldown -= deltaTime;
- const firing =
- keys.has(" ") ||
- keys.has("space") ||
- (isTouchMode() && touchFiring);
- if (
- firing &&
- player.cooldown <= 0
- ) {
- player.cooldown =
- player.fireRate;
- fireBullet();
- }
- if (player.invuln > 0) {
- player.invuln -= deltaTime;
- }
- spawnTimer += deltaTime;
- while (
- spawnTimer >= spawnEvery
- ) {
- spawnTimer -= spawnEvery;
- spawnEnemy();
- }
- for (const bullet of bullets) {
- bullet.y +=
- bullet.vy *
- deltaTime;
- }
- bullets = bullets.filter(
- (bullet) =>
- bullet.y +
- bullet.h >
- -40
- );
- for (const enemy of enemies) {
- enemy.y +=
- enemy.vy *
- deltaTime;
- }
- // Colisiones entre balas y enemigos
- for (
- let enemyIndex =
- enemies.length - 1;
- enemyIndex >= 0;
- enemyIndex--
- ) {
- const enemy =
- enemies[enemyIndex];
- for (
- let bulletIndex =
- bullets.length - 1;
- bulletIndex >= 0;
- bulletIndex--
- ) {
- const bullet =
- bullets[bulletIndex];
- if (
- rectsOverlap(
- enemy,
- bullet
- )
- ) {
- bullets.splice(
- bulletIndex,
- 1
- );
- enemy.hp -= 1;
- explode(
- bullet.x +
- bullet.w / 2,
- bullet.y,
- 10
- );
- if (enemy.hp <= 0) {
- enemies.splice(
- enemyIndex,
- 1
- );
- score += 10;
- updateHUD();
- explode(
- enemy.x +
- enemy.w / 2,
- enemy.y +
- enemy.h / 2,
- 18
- );
- }
- break;
- }
- }
- }
- // Colisiones entre enemigos y jugador
- for (
- let enemyIndex =
- enemies.length - 1;
- enemyIndex >= 0;
- enemyIndex--
- ) {
- const enemy =
- enemies[enemyIndex];
- if (
- enemy.y >
- canvas.height + 40
- ) {
- enemies.splice(
- enemyIndex,
- 1
- );
- takeHit();
- continue;
- }
- if (
- rectsOverlap(
- enemy,
- player
- )
- ) {
- enemies.splice(
- enemyIndex,
- 1
- );
- takeHit();
- }
- }
- for (const particle of particles) {
- particle.life -= deltaTime;
- particle.x +=
- particle.vx *
- deltaTime;
- particle.y +=
- particle.vy *
- deltaTime;
- particle.vx *=
- 1 - 2.6 * deltaTime;
- particle.vy *=
- 1 - 2.6 * deltaTime;
- }
- particles = particles.filter(
- (particle) =>
- particle.life > 0
- );
- }
- // ===== Renderizado =====
- function render(deltaTime) {
- ctx.clearRect(
- 0,
- 0,
- canvas.width,
- canvas.height
- );
- drawStars(deltaTime);
- // Balas
- ctx.fillStyle =
- "rgba(170,220,255,.95)";
- for (const bullet of bullets) {
- ctx.fillRect(
- bullet.x,
- bullet.y,
- bullet.w,
- bullet.h
- );
- }
- // Enemigos
- for (const enemy of enemies) {
- const enemyImage =
- enemy.w > 40
- ? images.enemy2
- : images.enemy1;
- if (enemyImage) {
- ctx.imageSmoothingEnabled =
- false;
- ctx.drawImage(
- enemyImage,
- enemy.x,
- enemy.y,
- enemy.w,
- enemy.h
- );
- } else {
- ctx.fillStyle =
- enemy.w > 40
- ? "rgba(255,120,120,.95)"
- : "rgba(255,200,120,.95)";
- roundRectFill(
- enemy.x,
- enemy.y,
- enemy.w,
- enemy.h,
- 10
- );
- }
- }
- // Jugador
- const blink =
- player.invuln > 0 &&
- Math.floor(
- performance.now() / 80
- ) %
- 2 ===
- 0;
- if (!blink) {
- const playerImage =
- images.player;
- if (playerImage) {
- ctx.imageSmoothingEnabled =
- false;
- ctx.drawImage(
- playerImage,
- player.x,
- player.y,
- player.w,
- player.h
- );
- } else {
- ctx.fillStyle =
- "rgba(200,220,255,.95)";
- roundRectFill(
- player.x,
- player.y,
- player.w,
- player.h,
- 10
- );
- }
- }
- // Partículas
- ctx.fillStyle =
- "rgba(255,255,255,.9)";
- for (const particle of particles) {
- ctx.beginPath();
- ctx.arc(
- particle.x,
- particle.y,
- particle.r,
- 0,
- Math.PI * 2
- );
- ctx.fill();
- }
- // Borde interior
- ctx.strokeStyle =
- "rgba(255,255,255,.07)";
- ctx.lineWidth = 2;
- ctx.strokeRect(
- 1,
- 1,
- canvas.width - 2,
- canvas.height - 2
- );
- }
- // ===== Inicio =====
- Promise.all([
- loadImage(
- "player",
- SPRITES.player
- ),
- loadImage(
- "enemy1",
- SPRITES.enemy1
- ),
- loadImage(
- "enemy2",
- SPRITES.enemy2
- ),
- ]).finally(() => {
- updateHUD();
- reset();
- requestAnimationFrame(tick);
- });
- })();