Editors Choice

3/recent/post-list

Verificador de cartones de bingo de 75 números

 

Cómo Crear un Verificador de Bingo Interactivo con Google Apps Script

¡Bienvenido! En este tutorial, construiremos una aplicación web completa, paso a paso, que te permitirá verificar cientos de cartones de bingo en tiempo real.

1. Introducción

El Problema

¿Alguna vez has jugado al bingo con muchos cartones a la vez? Es casi imposible revisar todos los cartones al mismo tiempo sin que se te pase un número.

La Solución

Crearemos una aplicación web donde puedes pegar todos tus cartones de bingo. Luego, a medida que el locutor "canta" los números, los ingresarás en nuestra app, y esta marcará automáticamente cada número en todos tus cartones. Nos avisará con una alerta y resaltará los cartones ganadores tan pronto como completen un patrón (¡o varios!).

¿Para Quién es Este Tutorial?

Este tutorial es para cualquiera que quiera aprender a construir una aplicación web real y útil. Está diseñado para principiantes; no necesitas ser un experto, ¡solo tener ganas de aprender!

¿Qué Aprenderás?

  • Google Apps Script (GAS): Cómo usar esta poderosa (y gratuita) herramienta de Google para crear y publicar aplicaciones web.

  • HTML: Cómo estructurar la página web (los "huesos" de la aplicación).

  • CSS (con Tailwind): Cómo darle estilos modernos a la aplicación para que se vea increíble (la "ropa").

  • JavaScript: Cómo agregar toda la interactividad y la lógica del juego (el "cerebro").

2. Requisitos Previos

Solo necesitas una cosa:

  • Una Cuenta de Google (como Gmail).

¡Eso es todo! No necesitas comprar servidores, ni dominios, ni instalar nada.

3. Paso 1: Configurar el Proyecto en Google Apps Script

Primero, le diremos a Google que queremos crear un nuevo proyecto.

  1. Abre tu navegador y ve a script.google.com.

  2. Haz clic en el botón "+ Nuevo proyecto" en la esquina superior izquierda.

  3. Tu proyecto está creado. ¡Fue fácil!

  4. Haz clic en "Proyecto sin título" en la parte superior y cámbiale el nombre. Un excelente nombre es "Verificador de Bingo".

4. Paso 2: El "Cerebro" - El Código del Servidor (Code.gs)

Verás un archivo llamado Code.gs. Este es el "servidor" o el "cerebro" de tu aplicación. Es el primer punto de contacto cuando alguien visita tu enlace.

  1. Borra cualquier código que esté en ese archivo (function myFunction...).

  2. Copia y pega el siguiente código.

/**
 * @OnlyCurrentDoc
 * Sirve la aplicación web principal.
 */
function doGet(e) {
  return HtmlService.createTemplateFromFile("Index")
    .evaluate()
    .setTitle("Verificador de Bingo")
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.DEFAULT);
}

Explicación del Código (Code.gs)

  • function doGet(e): Este es un nombre especial. Google Apps Script lo ejecuta automáticamente cada vez que alguien "visita" (hace un "GET") tu aplicación web.

  • HtmlService.createTemplateFromFile("Index"): Esto le dice a Google: "Busca un archivo llamado Index (que crearemos a continuación) y prepáralo".

  • .evaluate(): Este comando procesa el archivo HTML.

  • .setTitle(...): Pone el título en la pestaña del navegador.

  • .setXFrameOptionsMode(...): Esta es una configuración de seguridad importante que permite que tu app se ejecute correctamente en el entorno de Google.

5. Paso 3: La "Cara" - La Interfaz de Usuario (Index.html)

Ahora crearemos la parte visual de la aplicación.

  1. En el editor de Apps Script, haz clic en el ícono "+" al lado de "Archivos".

  2. Selecciona "HTML".

  3. En el cuadro de diálogo, escribe el nombre Index (¡Debe ser exacto, con la 'I' mayúscula!) y presiona Enter.

  4. Se creará un nuevo archivo Index.html. Borra el contenido que trae por defecto.

  5. Copia y pega todo el siguiente código dentro de tu archivo Index.html.


<!DOCTYPE html>
<html>

<head>
<base target="_top">
<!-- Carga de Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Fuente Inter */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');

body {
font-family: 'Inter', sans-serif;
background-color: #f4f7f6; /* Un gris muy claro */
}

/* Estilos para el cartón de bingo */
.bingo-card {
display: grid;
grid-template-columns: repeat(5, 1fr);
border: 2px solid #4a5568; /* a darker gray */
border-radius: 8px;
overflow: hidden;
width: 280px; /* Ancho fijo para consistencia */
margin: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}

.bingo-card.is-winner {
box-shadow: 0 0 20px 5px #48bb78; /* Resplandor verde */
border-color: #48bb78;
transform: scale(1.03);
}

.bingo-header {
background-color: #2c5282; /* Azul oscuro */
color: white;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
padding: 8px 0;
letter-spacing: 0.5em; /* Espaciado B I N G O */
}

.bingo-cell {
height: 50px;
width: 56px;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.25rem;
font-weight: 600;
color: #2d3748; /* gris oscuro */
background-color: #ffffff;
border: 1px solid #e2e8f0; /* gris claro */
transition: all 0.2s ease-in-out;
position: relative;
}

.bingo-cell.is-free {
background-color: #edf2f7; /* gris un poco más oscuro */
color: #4a5568;
font-size: 0.9rem;
}

.bingo-cell.is-marked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
height: 80%;
background-color: #fbd38d; /* Naranja/amarillo */
border-radius: 50%;
opacity: 0.7;
z-index: 1;
}

.bingo-cell span {
position: relative;
z-index: 2; /* Asegura que el número esté sobre el marcador */
}

/* Estilos para el modal de patrones */
.pattern-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
border: 2px solid #cbd5e0;
border-radius: 8px;
padding: 8px;
background-color: #f7fafc;
}

.pattern-cell {
width: 40px;
height: 40px;
background-color: #e2e8f0;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}

.pattern-cell.is-selected {
background-color: #4299e1; /* Azul */
}

/* Pequeña animación para los números cantados */
.called-number-item {
animation: fadeIn 0.3s ease-out;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>

<body class="p-4 md:p-8">

<!-- ! NUEVO: Vista de Configuración -->
<div id="setup-view" class="max-w-3xl mx-auto bg-white p-8 rounded-lg shadow-xl">
<h1 class="text-3xl font-bold text-gray-900 mb-4">Configuración del Juego de Bingo</h1>
<p class="text-gray-700 mb-6">Pega aquí los datos de tus cartones. Cada cartón debe estar en una nueva línea con el formato: <br>
<code class="text-sm bg-gray-100 p-1 rounded">#ID: 1,2,3,4,5, 6,7,8,9,10, 11,12,Free,14,15, 16,17,18,19,20, 21,22,23,24,25</code>
</p>
<label for="card-data-input" class="block text-sm font-medium text-gray-700 mb-2">Datos de los Cartones</label>
<textarea id="card-data-input" rows="10"
class="w-full p-3 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
placeholder="#0336409: 14,7,15,3,10, 20,25,28,27,22, 40,42,Free,32,37, 47,51,54,57,59, 72,70,71,63,64
#0336410: 4,6,5,1,15, 19,17,27,25,29, 45,42,Free,31,41, 58,53,59,56,54, 71,62,68,73,63"></textarea>
<button id="start-game-btn"
class="w-full mt-6 px-6 py-3 bg-blue-600 text-white font-semibold text-lg rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Cargar Cartones e Iniciar Juego
</button>
</div>

<!-- ! MODIFICADO: Vista del Juego (oculta por defecto) -->
<div id="game-view" class="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-4 gap-6 hidden">

<!-- Columna de Controles (Izquierda) -->
<div class="lg:col-span-1 bg-white p-5 rounded-lg shadow-lg h-full lg:sticky lg:top-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Controles del Juego</h2>

<!-- Ingreso de Números -->
<div class="mb-6">
<label for="number-input" class="block text-sm font-medium text-gray-700 mb-1">Número Cantado</label>
<div class="flex space-x-2">
<input type="number" id="number-input"
class="flex-grow p-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
placeholder="Ej: 14">
<button id="add-number-btn"
class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Agregar
</button>
</div>
</div>

<!-- Números Cantados -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-semibold text-gray-800">Números Cantados</h3>
<button id="reset-game-btn" class="text-sm text-red-500 hover:text-red-700 font-medium">Reiniciar</button>
</div>
<div id="called-numbers-list"
class="h-48 bg-gray-50 rounded-md border border-gray-200 p-3 overflow-y-auto flex flex-wrap gap-2 content-start">
<!-- Los números se agregarán aquí -->
</div>
</div>

<!-- Patrones a Jugar (Checkboxes) -->
<div>
<h3 class="text-lg font-semibold text-gray-800 mb-3">Patrones a Jugar</h3>
<div id="pattern-checkboxes" class="space-y-2">
<div>
<input type="checkbox" id="cb-carton-lleno" value="carton-lleno" class="mr-2 pattern-cb h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="cb-carton-lleno" class="text-gray-700">Cartón Lleno</label>
</div>
<div>
<input type="checkbox" id="cb-letra-x" value="letra-x" class="mr-2 pattern-cb h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="cb-letra-x" class="text-gray-700">Letra X</label>
</div>
<div>
<input type="checkbox" id="cb-letra-l" value="letra-l" class="mr-2 pattern-cb h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="cb-letra-l" class="text-gray-700">Letra L</label>
</div>
<div>
<input type="checkbox" id="cb-esquinas" value="esquinas" class="mr-2 pattern-cb h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="cb-esquinas" class="text-gray-700">4 Esquinas</label>
</div>
<div>
<input type="checkbox" id="cb-custom" value="custom" class="mr-2 pattern-cb h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="cb-custom" class="text-gray-700">Personalizado...</label>
</div>
</div>
<button id="custom-pattern-btn"
class="w-full p-2 bg-gray-200 text-gray-700 font-semibold rounded-md hover:bg-gray-300 mt-3">
Definir Patrón Personalizado
</button>
</div>
</div>

<!-- Área de Cartones (Derecha) -->
<div class="lg:col-span-3">
<h1 class="text-3xl font-bold text-gray-900 mb-5">Mis Cartones de Bingo</h1>
<div id="cards-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<!-- Los cartones se generarán aquí -->
</div>
</div>
</div>

<!-- Modal para Patrón Personalizado -->
<div id="pattern-modal" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex items-center justify-center p-4 hidden z-50">
<div class="bg-white rounded-lg shadow-2xl p-6 w-full max-w-sm">
<h3 class="text-xl font-semibold mb-4">Define tu Patrón</h3>
<p class="text-sm text-gray-600 mb-4">Haz clic en las casillas para crear el patrón ganador.</p>
<div class="pattern-grid mx-auto mb-5" id="modal-pattern-grid">
<!-- 25 celdas se generarán aquí -->
</div>
<div class="flex justify-end space-x-3">
<button id="modal-cancel-btn"
class="px-4 py-2 bg-gray-200 text-gray-700 font-medium rounded-md hover:bg-gray-300">Cancelar</button>
<button id="modal-save-btn"
class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md shadow-md hover:bg-blue-700">Guardar</button>
</div>
</div>
</div>

<!-- Modal de Ganador -->
<div id="winner-modal" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex items-center justify-center p-4 hidden z-50">
<div class="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md text-center">
<div class="text-6xl mb-4">🎉</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2">¡BINGO!</h2>
<p class="text-lg text-gray-700 mb-6">El cartón <strong id="winner-card-id" class="text-blue-600"></strong> ha ganado
con el patrón <strong id="winner-pattern-name" class="text-blue-600"></strong>.</p>
<button id="winner-close-btn"
class="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-md shadow-md hover:bg-blue-700 text-lg">
Cerrar
</button>
</div>
</div>

<!-- Modal de Alerta -->
<div id="alert-modal" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex items-center justify-center p-4 hidden z-50">
<div class="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md text-center">
<h3 class="text-xl font-semibold mb-4" id="alert-title">Aviso</h3>
<p class="text-gray-700 mb-6" id="alert-message">Este es un mensaje de alerta.</p>
<button id="alert-close-btn"
class="w-full px-6 py-2 bg-blue-600 text-white font-semibold rounded-md shadow-md hover:bg-blue-700">
Entendido
</button>
</div>
</div>

<!-- Modal de Confirmación -->
<div id="confirm-modal" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex items-center justify-center p-4 hidden z-50">
<div class="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md text-center">
<h3 class="text-xl font-semibold mb-4" id="confirm-title">Confirmación</h3>
<p class="text-gray-700 mb-6" id="confirm-message">¿Estás seguro?</p>
<div class="flex justify-center space-x-4">
<button id="confirm-cancel-btn"
class="w-1/2 px-6 py-2 bg-gray-200 text-gray-700 font-medium rounded-md hover:bg-gray-300">
Cancelar
</button>
<button id="confirm-ok-btn"
class="w-1/2 px-6 py-2 bg-red-600 text-white font-semibold rounded-md shadow-md hover:bg-red-700">
Aceptar
</button>
</div>
</div>
</div>


<script>
// --- 1. DATOS INICIALES ---
// ¡Eliminados! Ahora se cargarán desde el textarea.
// --- 2. PATRONES GANADORES ---
const patterns = {
"carton-lleno": {
id: "carton-lleno",
name: "Cartón Lleno",
grid: Array(5).fill(Array(5).fill(true))
},
"letra-x": {
id: "letra-x",
name: "Letra X",
grid: [
[true, false, false, false, true],
[false, true, false, true, false],
[false, false, true, false, false],
[false, true, false, true, false],
[true, false, false, false, true]
]
},
"letra-l": {
id: "letra-l",
name: "Letra L",
grid: [
[true, false, false, false, false],
[true, false, false, false, false],
[true, false, false, false, false],
[true, false, false, false, false],
[true, true, true, true, true]
]
},
"esquinas": {
id: "esquinas",
name: "4 Esquinas",
grid: [
[true, false, false, false, true],
[false, false, false, false, false],
[false, false, false, false, false],
[false, false, false, false, false],
[true, false, false, false, true]
]
},
"custom": {
id: "custom",
name: "Personalizado",
grid: Array(5).fill(Array(5).fill(false)) // Se llenará desde el modal
}
};

// --- 3. ESTADO DE LA APLICACIÓN ---
let calledNumbers = new Set();
let bingoCards = [];
let activePatterns = [];

// --- 4. ELEMENTOS DEL DOM ---
const setupView = document.getElementById('setup-view');
const gameView = document.getElementById('game-view');
const cardDataInput = document.getElementById('card-data-input');
const startGameBtn = document.getElementById('start-game-btn');
const numberInput = document.getElementById('number-input');
const addNumberBtn = document.getElementById('add-number-btn');
const calledNumbersList = document.getElementById('called-numbers-list');
const cardsContainer = document.getElementById('cards-container');
const patternCheckboxes = document.getElementById('pattern-checkboxes');
const customPatternBtn = document.getElementById('custom-pattern-btn');
const resetGameBtn = document.getElementById('reset-game-btn');
const patternModal = document.getElementById('pattern-modal');
const modalPatternGrid = document.getElementById('modal-pattern-grid');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const modalSaveBtn = document.getElementById('modal-save-btn');
const winnerModal = document.getElementById('winner-modal');
const winnerCardId = document.getElementById('winner-card-id');
const winnerPatternName = document.getElementById('winner-pattern-name');
const winnerCloseBtn = document.getElementById('winner-close-btn');
const alertModal = document.getElementById('alert-modal');
const alertMessage = document.getElementById('alert-message');
const alertCloseBtn = document.getElementById('alert-close-btn');
const confirmModal = document.getElementById('confirm-modal');
const confirmMessage = document.getElementById('confirm-message');
const confirmCancelBtn = document.getElementById('confirm-cancel-btn');
const confirmOkBtn = document.getElementById('confirm-ok-btn');
let confirmCallback = null;


// --- 5. LÓGICA DEL JUEGO ---

/**
* Parsea el texto del textarea y lo convierte en datos de cartones.
*/
function parseCardData(text) {
const lines = text.split('\n').filter(line => line.trim().length > 0);
const newCardsData = [];
for (const line of lines) {
try {
const parts = line.split(':');
if (parts.length !== 2) {
throw new Error(`Formato inválido (falta ':'). Formato esperado: #ID: num,num,...`);
}
const id = parts[0].trim();
if (!id.startsWith('#')) {
throw new Error(`ID debe empezar con '#'.`);
}

const numbers = parts[1].split(',').map(n => n.trim());
if (numbers.length !== 25) {
throw new Error(`No tiene 25 números. Tiene ${numbers.length}.`);
}
const rows = [];
for (let i = 0; i < 5; i++) {
const row = [];
for (let j = 0; j < 5; j++) {
const numStr = numbers[i * 5 + j];
if (numStr.toLowerCase() === 'free') {
row.push('Free');
} else {
const num = parseInt(numStr, 10);
if (isNaN(num)) throw new Error(`Valor no numérico encontrado: '${numStr}'`);
row.push(num);
}
}
rows.push(row);
}
if (rows[2][2] !== 'Free') {
showAlert(`Advertencia: Cartón ${id} no tiene "Free" en el centro. Se marcará como "Free" automáticamente.`);
rows[2][2] = 'Free';
}

newCardsData.push({ id, rows });

} catch (error) {
showAlert(`Línea inválida (ignorada): ${line.substring(0, 30)}... | Error: ${error.message}`);
}
}
return newCardsData;
}

/**
* Inicia el juego desde la pantalla de setup.
*/
function startGame() {
const textData = cardDataInput.value;
const parsedData = parseCardData(textData);

if (parsedData.length === 0) {
showAlert("No se cargaron cartones válidos. Por favor revisa el formato e inténtalo de nuevo.");
return;
}

initGame(parsedData);
setupView.classList.add('hidden');
gameView.classList.remove('hidden');
}

/**
* Inicializa el juego: resetea el estado y dibuja los cartones.
*/
function initGame(cardData) {
calledNumbers.clear();
bingoCards = [];

cardData.forEach(cardData => {
const markedGrid = Array(5).fill(null).map(() => Array(5).fill(false));
markedGrid[2][2] = true; // Marcar "Free"

bingoCards.push({
id: cardData.id,
rows: cardData.rows,
marked: markedGrid,
wonPatterns: new Set() // Para rastrear patrones ganados
});
});

renderAllCards();
renderCalledNumbers();
updateActivePatterns();
winnerModal.classList.add('hidden');
document.querySelectorAll('.bingo-card.is-winner').forEach(c => c.classList.remove('is-winner'));
}

/**
* Dibuja todos los cartones en el contenedor.
*/
function renderAllCards() {
cardsContainer.innerHTML = '';
bingoCards.forEach(card => {
cardsContainer.appendChild(createCardElement(card));
});
}

/**
* Crea el elemento HTML para un solo cartón.
* ! MODIFICADO: Añade la lista de patrones ganados.
*/
function createCardElement(card) {
const cardEl = document.createElement('div');
// Contenedor principal del cartón
cardEl.className = 'bg-white rounded-lg shadow-md overflow-hidden';
cardEl.dataset.cardId = card.id;

// Cabecera B-I-N-G-O
let headerHtml = '<div class="grid grid-cols-5 text-center bg-blue-800 text-white font-bold text-xl py-2">';
['B', 'I', 'N', 'G', 'O'].forEach(letter => headerHtml += `<div>${letter}</div>`);
headerHtml += '</div>';

// Grilla de números
let gridHtml = `<div class="p-2 bingo-card" id="card-${card.id}">`;
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
const number = card.rows[r][c];
const isFree = (number === 'Free');
const isMarked = card.marked[r][c];
let cellClasses = 'bingo-cell';
if (isFree) cellClasses += ' is-free';
if (isMarked) cellClasses += ' is-marked';

gridHtml += `<div class="${cellClasses}"><span>${number}</span></div>`;
}
}
gridHtml += '</div>';

// ! --- INICIO DE LA MODIFICACIÓN ---
// Sección de Patrones Ganados (solo si hay alguno)
let wonPatternsHtml = '';
if (card.wonPatterns.size > 0) {
wonPatternsHtml = `
<div class="p-2 bg-gray-50 border-t border-gray-200">
<h5 class="text-xs font-semibold text-gray-600 mb-1">Patrones Ganados:</h5>
<div class="flex flex-wrap gap-1">
`;
for (const patternKey of card.wonPatterns) {
const patternName = patterns[patternKey]?.name || patternKey;
// Añade una "insignia" (badge) por cada patrón ganado
wonPatternsHtml += `
<span class="text-xs font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
${patternName}
</span>
`;
}
wonPatternsHtml += '</div></div>'; // Cierra los divs
}
// ! --- FIN DE LA MODIFICACIÓN ---


// Ensambla el cartón completo
cardEl.innerHTML = `
<h4 class="text-lg font-semibold text-center py-2 bg-gray-100 text-gray-700">${card.id}</h4>
${headerHtml}
${gridHtml}
${wonPatternsHtml} <!-- ! AÑADIDO: Muestra los patrones ganados -->
`;
return cardEl;
}

/**
* Dibuja la lista de números cantados.
*/
function renderCalledNumbers() {
calledNumbersList.innerHTML = '';
const sortedNumbers = Array.from(calledNumbers).sort((a, b) => a - b);
sortedNumbers.forEach(num => {
const numEl = document.createElement('div');
numEl.className = 'called-number-item w-10 h-10 flex items-center justify-center bg-white border-2 border-blue-500 text-blue-700 font-bold rounded-full shadow-sm';
numEl.textContent = num;
calledNumbersList.appendChild(numEl);
});
}

/**
* Maneja el ingreso de un nuevo número.
*/
function handleAddNumber() {
const num = parseInt(numberInput.value, 10);
if (isNaN(num) || num < 1 || num > 75) {
showAlert("Por favor ingresa un número válido entre 1 y 75.");
return;
}
if (calledNumbers.has(num)) {
showAlert("Ese número ya fue cantado.");
return;
}

calledNumbers.add(num);
numberInput.value = '';
renderCalledNumbers();
updateCardsWithNumber(num);
checkAllCardsForWin();
}

/**
* Actualiza el estado 'marked' de todos los cartones con el nuevo número.
*/
function updateCardsWithNumber(num) {
bingoCards.forEach(card => {
let
numberFound = false;
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
if (card.rows[r][c] === num) {
card.marked[r][c] = true;
numberFound = true;
break;
}
}
if (numberFound) break;
}

if (numberFound) {
const cardEl = document.getElementById(`card-${card.id}`);
if (cardEl) {
// Reemplaza el contenedor 'div.bg-white...' (el padre del grid)
const parent = cardEl.parentElement;
if (parent) {
// Vuelve a crear el cartón. La nueva función 'createCardElement'
// incluirá la lista de patrones ganados actualizada.
parent.replaceWith(createCardElement(card));
}
}
}
});
}

/**
* Revisa todos los cartones para ver si alguno ganó.
*/
function checkAllCardsForWin() {
for (const card of bingoCards) {
for (const patternKey of activePatterns) {
const pattern = patterns[patternKey];
// Revisa si este patrón AÚN NO HA SIDO GANADO por este cartón
if (!card.wonPatterns.has(patternKey) && isWinner(card.marked, pattern.grid)) {
card.wonPatterns.add(patternKey); // Marcar este patrón como ganado
announceWinner(card, pattern);
// ! IMPORTANTE: Vuelve a renderizar el cartón para mostrar la nueva insignia
const cardEl = document.getElementById(`card-${card.id}`);
if (cardEl) {
const parent = cardEl.parentElement;
if (parent) {
parent.replaceWith(createCardElement(card));
}
}
}
}
}
}

/**
* Compara el grid marcado de un cartón con un patrón ganador.
*/
function isWinner(markedGrid, patternGrid) {
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
if (patternGrid[r][c] && !markedGrid[r][c]) {
return false;
}
}
}
return true;
}

/**
* Muestra el modal de ganador.
*/
function announceWinner(card, pattern) {
winnerCardId.textContent = card.id;
winnerPatternName.textContent = pattern.name;
winnerModal.classList.remove('hidden');

const cardEl = document.getElementById(`card-${card.id}`);
if (cardEl) {
cardEl.classList.add('is-winner'); // El resaltado persiste
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}

/**
* Actualiza el array 'activePatterns' basado en los checkboxes.
*/
function updateActivePatterns() {
activePatterns = [];
const checkboxes = document.querySelectorAll('.pattern-cb');
checkboxes.forEach(cb => {
if (cb.checked) {
activePatterns.push(cb.value);
}
});
// Re-revisa por si un nuevo patrón seleccionado ya es un ganador
checkAllCardsForWin();
}

// --- 6. LÓGICA DEL MODAL DE PATRONES ---

/**
* Crea el grid de 5x5 para el modal.
*/
function createModalGrid() {
modalPatternGrid.innerHTML = '';
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
const cell = document.createElement('div');
cell.className = 'pattern-cell';
cell.dataset.row = r;
cell.dataset.col = c;
if (patterns.custom.grid[r][c]) {
cell.classList.add('is-selected');
}
cell.addEventListener('click', () => {
cell.classList.toggle('is-selected');
});
modalPatternGrid.appendChild(cell);
}
}
modalPatternGrid.children[12].classList.add('is-selected', 'opacity-50', 'cursor-not-allowed');
modalPatternGrid.children[12].style.pointerEvents = 'none';
}

/**
* Guarda el patrón personalizado del modal.
*/
function saveCustomPattern() {
const newPatternGrid = Array(5).fill(null).map(() => Array(5).fill(false));
const cells = modalPatternGrid.children;
for (let i = 0; i < cells.length; i++) {
const r = cells[i].dataset.row;
const c = cells[i].dataset.col;
if (cells[i].classList.contains('is-selected')) {
newPatternGrid[r][c] = true;
}
}
patterns.custom.grid = newPatternGrid;
patternModal.classList.add('hidden');
document.getElementById('cb-custom').checked = true;
updateActivePatterns();
}


// --- Funciones para mostrar modales ---
function showAlert(message) {
alertMessage.textContent = message;
alertModal.classList.remove('hidden');
}

function showConfirm(message, onOk) {
confirmMessage.textContent = message;
confirmCallback = onOk;
confirmModal.classList.remove('hidden');
}


// --- 7. EVENT LISTENERS ---
startGameBtn.addEventListener('click', startGame);

addNumberBtn.addEventListener('click', handleAddNumber);
numberInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleAddNumber();
}
});

resetGameBtn.addEventListener('click', () => {
showConfirm('¿Estás seguro de que quieres reiniciar? Volverás a la pantalla de carga de cartones.', () => {
gameView.classList.add('hidden');
setupView.classList.remove('hidden');
cardDataInput.value = ''; // Limpiar cartones antiguos
document.querySelectorAll('.pattern-cb').forEach(cb => cb.checked = false);
initGame([]); // Limpiar estado del juego
});
});

document.querySelectorAll('.pattern-cb').forEach(cb => {
cb.addEventListener('change', updateActivePatterns);
});

customPatternBtn.addEventListener('click', () => {
createModalGrid();
patternModal.classList.remove('hidden');
});
modalCancelBtn.addEventListener('click', () => {
patternModal.classList.add('hidden');
document.getElementById('cb-custom').checked = false;
updateActivePatterns();
});
modalSaveBtn.addEventListener('click', saveCustomPattern);

winnerCloseBtn.addEventListener('click', () => {
winnerModal.classList.add('hidden');
});

alertCloseBtn.addEventListener('click', () => {
alertModal.classList.add('hidden');
});
confirmCancelBtn.addEventListener('click', () => {
confirmModal.classList.add('hidden');
confirmCallback = null;
});
confirmOkBtn.addEventListener('click', () => {
confirmModal.classList.add('hidden');
if (confirmCallback) {
confirmCallback();
}
confirmCallback = null;
});

</script>
</body>

</html>

6. Paso 4: Desglosando el Index.html (Explicación)

Este archivo es 3 cosas en 1:

  1. HTML (Los Huesos): Todo lo que empieza con <div, <button>, <input>, etc. Define la estructura. Creamos dos "vistas" principales: #setup-view (para pegar los cartones) y #game-view (el juego en sí).

  2. CSS (La Ropa): Todo dentro de la etiqueta <style>...</style>. Aquí definimos cómo se ven los cartones.

    • .bingo-cell.is-marked::after: Este es el selector clave. Crea el círculo amarillo semitransparente que "marca" el número.

    • .bingo-card.is-winner: Este añade el brillo verde al cartón ganador.

    • Cargamos Tailwind CSS desde una CDN (<script src="...tailwindcss.com">). Esto nos da clases útiles como p-4 (padding), font-bold (negrita), grid (cuadrícula), etc., para no tener que escribir todo el CSS a mano.


  3. JavaScript (El Cerebro):
    Todo dentro de la etiqueta <script>...</script>. Es la parte más importante. La hemos comentado línea por línea en el código de arriba para que entiendas qué hace cada función.

7. Paso 5: ¡Implementar y Probar la Aplicación!

Ya tenemos el código, ahora vamos a "publicarlo" en la web.

  1. En el editor de Apps Script, haz clic en el botón azul "Implementar" en la esquina superior derecha.

  2. Selecciona "Nueva implementación".

  3. Haz clic en el ícono de engranaje (rueda dentada) al lado de "Seleccionar tipo" y elige "Aplicación web".

  4. En el formulario:

    • Descripción: Escribe algo como "Versión 1 del verificador de bingo".

    • Ejecutar como: Déjalo en "Yo (tu@email.com)".

    • Quién tiene acceso: ¡Este es el paso más importante! Cámbialo de "Solo yo" a "Cualquier persona". Esto hace que sea una página web pública.

  5. Haz clic en "Implementar".

  6. Google te pedirá que "Autorices el acceso". Haz clic, selecciona tu cuenta de Google.

  7. Verás una pantalla de "Google no ha verificado esta aplicación". ¡No te preocupes! Es porque tú eres el desarrollador. Haz clic en "Configuración avanzada" y luego en "Ir a Verificador de Bingo (no seguro)".

  8. Haz clic en "Permitir".

  9. ¡Listo! Te aparecerá una ventana con la URL de tu aplicación web. Copia ese enlace, pégalo en una nueva pestaña del navegador y... ¡tu aplicación estará funcionando!

8. Conclusión y próximos pasos

¡Felicidades! Has construido una aplicación web completa, interactiva y muy útil desde cero. Has aprendido cómo Google Apps Script sirve una página HTML, cómo usar JavaScript para manejar la lógica de un juego y cómo usar CSS para que se vea genial.


Publicar un comentario

0 Comentarios