๐ŸŽฎArcadeLab

Spanish Word Fall - Learn Spanish | Arcabelab.ai

by HyperScout89
613 lines20.1 KB
โ–ถ Play
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
  <title>Spanish Word Fall - Learn Spanish | Arcabelab.ai</title>
  <style>
    * {
      user-select: none;
      -webkit-tap-highlight-color: transparent;
      box-sizing: border-box;
    }

    body {
      background: linear-gradient(145deg, #1a2a3a 0%, #0f1a24 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      font-family: system-ui, 'Segoe UI', 'Comic Neue', 'Roboto', sans-serif;
      margin: 0;
      padding: 16px;
    }

    .game-container {
      background: rgba(0, 0, 0, 0.45);
      border-radius: 56px;
      padding: 18px;
      box-shadow: 0 20px 35px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
      backdrop-filter: blur(2px);
      width: 100%;
      max-width: 1000px;
    }

    canvas {
      display: block;
      margin: 0 auto;
      border-radius: 32px;
      box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
      cursor: pointer;
      background: #fef9e8;
      width: 100%;
      height: auto;
      touch-action: none;
    }

    .info-panel {
      display: flex;
      justify-content: space-between;
      align-items: center;
      background: #2c3e2fcc;
      backdrop-filter: blur(4px);
      padding: 12px 24px;
      border-radius: 80px;
      margin-bottom: 20px;
      color: #f5e7d9;
      text-shadow: 0 1px 2px rgba(0,0,0,0.4);
      gap: 12px;
      flex-wrap: wrap;
    }

    .score-box, .target-box, .timer-box {
      background: #1e2a1c;
      padding: 6px 18px;
      border-radius: 60px;
      font-weight: bold;
      font-size: 1.2rem;
      backdrop-filter: blur(2px);
      box-shadow: inset 0 1px 3px rgba(0,0,0,0.3), 0 2px 3px rgba(255,255,255,0.1);
      font-family: monospace;
      letter-spacing: 0.5px;
    }

    .target-box {
      background: #b4422b;
      color: #ffefc0;
      font-size: 1.5rem;
      font-weight: 800;
      text-transform: capitalize;
    }

    .feedback {
      background: #000000aa;
      backdrop-filter: blur(8px);
      padding: 10px 20px;
      border-radius: 60px;
      font-size: 1.1rem;
      font-weight: bold;
      text-align: center;
      transition: all 0.2s ease;
      margin-top: 12px;
      color: #f9eec1;
    }

    button {
      background: #ff914d;
      border: none;
      font-size: 1rem;
      font-weight: bold;
      padding: 8px 24px;
      border-radius: 80px;
      color: #2d2b1f;
      cursor: pointer;
      transition: transform 0.1s, background 0.2s;
      box-shadow: 0 4px 0 #a23b0a;
      font-family: inherit;
    }

    button:active {
      transform: translateY(2px);
      box-shadow: 0 1px 0 #a23b0a;
    }

    .status {
      font-size: 0.85rem;
      background: #00000088;
      padding: 6px 14px;
      border-radius: 40px;
      text-align: center;
      margin-top: 12px;
      color: #e9f5db;
      backdrop-filter: blur(4px);
    }

    @media (max-width: 650px) {
      .info-panel {
        justify-content: center;
      }
      .score-box, .target-box, .timer-box {
        font-size: 1rem;
        padding: 4px 14px;
      }
      .target-box {
        font-size: 1.2rem;
      }
      .feedback {
        font-size: 0.9rem;
      }
    }
  </style>
</head>
<body>
<div>
  <div class="game-container">
    <div class="info-panel">
      <div class="score-box">๐ŸŽฏ SCORE: <span id="scoreValue">0</span></div>
      <div class="target-box">๐Ÿ” MATCH: <span id="targetWord">hello</span></div>
      <div class="timer-box">โฑ๏ธ TIME: <span id="timerValue">60</span>s</div>
      <button id="restartButton">๐Ÿ”„ RESTART</button>
    </div>
    <canvas id="gameCanvas" width="900" height="500" style="width:100%; height:auto; max-width:900px; aspect-ratio:900/500"></canvas>
    <div id="feedbackMessage" class="feedback">โœจ Tap the correct Spanish translation! โœจ</div>
    <div class="status">๐Ÿ’ก TIP: Click / Tap the FALLING word that matches the English target โ†’ +10 | wrong = -5</div>
  </div>
</div>

<script>
  (function(){
    // ========== SPANISH -> ENGLISH DICTIONARY (rich set) ==========
    const wordPairs = [
      { spanish: "Hola", english: "hello" },
      { spanish: "Amigo", english: "friend" },
      { spanish: "Uno", english: "one" },
      { spanish: "Dos", english: "two" },
      { spanish: "Tres", english: "three" },
      { spanish: "Cuatro", english: "four" },
      { spanish: "Cinco", english: "five" },
      { spanish: "Adiรณs", english: "bye" },
      { spanish: "Gracias", english: "thanks" },
      { spanish: "Por favor", english: "please" },
      { spanish: "Rojo", english: "red" },
      { spanish: "Azul", english: "blue" },
      { spanish: "Verde", english: "green" },
      { spanish: "Perro", english: "dog" },
      { spanish: "Gato", english: "cat" },
      { spanish: "Casa", english: "house" },
      { spanish: "Feliz", english: "happy" },
      { spanish: "Triste", english: "sad" }
    ];

    // ---------- DOM elements ----------
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const scoreSpan = document.getElementById('scoreValue');
    const targetSpan = document.getElementById('targetWord');
    const timerSpan = document.getElementById('timerValue');
    const feedbackDiv = document.getElementById('feedbackMessage');
    const restartBtn = document.getElementById('restartButton');

    // ---------- GAME VARIABLES ----------
    let score = 0;
    let currentTargetEnglish = "hello";
    let gameActive = true;
    let timeLeft = 60;
    let timerInterval = null;
    let wordGeneratorInterval = null;
    let activeWords = [];      // each word obj: {text, x, y, speed, correct, width, height}
    let animationId = null;
    
    // Dynamic difficulty base values
    let baseSpeed = 2.2;           // pixels per frame (60fps target)
    
    // Helper: measure text width reliably
    function measureTextWidth(text, fontSize = 26) {
      ctx.font = `bold ${fontSize}px "Segoe UI", system-ui, "Comic Neue", sans-serif`;
      return ctx.measureText(text).width + 18; // generous click padding
    }
    
    // --- Update UI and difficulty dynamically based on score ---
    function refreshDifficultySettings() {
      // speed increases with score: floor(score/80) * 0.5
      let extraSpeed = Math.min(4.0, Math.floor(score / 80) * 0.55);
      let currentGameSpeed = baseSpeed + extraSpeed;
      currentGameSpeed = Math.min(7.2, currentGameSpeed);
      // speed is applied per word on spawn, but we also store base for updates.
      // we'll handle in spawnWord individually.
      return currentGameSpeed;
    }
    
    // adjust spawn interval based on score (more points = faster spawning)
    function getDynamicSpawnInterval() {
      let interval = Math.max(700, 2100 - Math.floor(score / 28) * 45);
      return Math.min(interval, 2100);
    }
    
    function restartSpawnGenerator() {
      if (wordGeneratorInterval) clearInterval(wordGeneratorInterval);
      const newInterval = getDynamicSpawnInterval();
      wordGeneratorInterval = setInterval(() => {
        if (gameActive) spawnWord();
      }, newInterval);
    }
    
    // play subtle beep (Web Audio - works in arcabelab.ai if user interaction exists)
    function playSound(type) {
      try {
        const AudioContextClass = window.AudioContext || window.webkitAudioContext;
        if (!AudioContextClass) return;
        const audioCtx = new AudioContextClass();
        const oscillator = audioCtx.createOscillator();
        const gainNode = audioCtx.createGain();
        oscillator.connect(gainNode);
        gainNode.connect(audioCtx.destination);
        oscillator.type = 'sine';
        oscillator.frequency.value = type === 'correct' ? 880 : 480;
        gainNode.gain.value = 0.18;
        oscillator.start();
        gainNode.gain.exponentialRampToValueAtTime(0.00001, audioCtx.currentTime + 0.28);
        oscillator.stop(audioCtx.currentTime + 0.25);
        // resume if suspended (user gesture already happened)
        if (audioCtx.state === 'suspended') audioCtx.resume();
      } catch(e) { /* silent fallback */ }
    }
    
    function showFeedback(message, isCorrect = true) {
      feedbackDiv.style.color = isCorrect ? '#d4ffb0' : '#ffc2a1';
      feedbackDiv.style.textShadow = '0 0 2px black';
      feedbackDiv.innerHTML = message;
      setTimeout(() => {
        if (feedbackDiv.innerHTML === message && gameActive) {
          feedbackDiv.style.color = '#f9eec1';
          feedbackDiv.innerHTML = 'โœจ Tap the correct falling word! โœจ';
        } else if (!gameActive && feedbackDiv.innerHTML === message) {
          // keep game over message style
        }
      }, 1300);
    }
    
    function updateScoreUI() {
      scoreSpan.textContent = score;
    }
    
    // pick fresh random target (English word)
    function pickNewTarget() {
      let newIndex;
      do {
        newIndex = Math.floor(Math.random() * wordPairs.length);
      } while (wordPairs.length > 1 && wordPairs[newIndex].english === currentTargetEnglish);
      currentTargetEnglish = wordPairs[newIndex].english;
      targetSpan.textContent = currentTargetEnglish;
    }
    
    // spawn a new falling Spanish word (with dynamic speed)
    function spawnWord() {
      if (!gameActive) return;
      const randomPair = wordPairs[Math.floor(Math.random() * wordPairs.length)];
      const spanishWord = randomPair.spanish;
      const isCorrectWord = (randomPair.english === currentTargetEnglish);
      
      // dynamic speed based on score (global difficulty)
      let calculatedSpeed = baseSpeed + Math.min(4.2, Math.floor(score / 70) * 0.5);
      calculatedSpeed = Math.min(7.0, calculatedSpeed);
      const individualSpeed = calculatedSpeed + (Math.random() * 1.1 - 0.3);
      const finalSpeed = Math.max(1.9, individualSpeed);
      
      const fontSize = 26;
      ctx.font = `bold ${fontSize}px "Segoe UI", system-ui, "Comic Neue", sans-serif`;
      const textWidth = ctx.measureText(spanishWord).width + 18;
      const maxX = canvas.width - textWidth - 12;
      const minX = 12;
      const xPos = minX + Math.random() * Math.max(0, (maxX - minX));
      
      activeWords.push({
        text: spanishWord,
        x: xPos,
        y: -28,
        speed: finalSpeed,
        correct: isCorrectWord,
        width: textWidth,
        height: 38
      });
    }
    
    // initial warm spawns
    function initialWordWave() {
      for(let i=0;i<3;i++) {
        setTimeout(() => { if(gameActive) spawnWord(); }, i * 220);
      }
    }
    
    // DRAW everything (canvas updated) with polished UI
    function draw() {
      if (!ctx) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // light paper texture bg lines
      ctx.strokeStyle = "#e2d5b6";
      ctx.lineWidth = 0.6;
      for (let i = 0; i < canvas.width; i += 45) {
        ctx.beginPath();
        ctx.moveTo(i, 0);
        ctx.lineTo(i, canvas.height);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(0, i % canvas.height);
        ctx.lineTo(canvas.width, i % canvas.height);
        ctx.stroke();
      }
      
      for (let w of activeWords) {
        const isTarget = w.correct;
        const wordX = w.x;
        const wordY = w.y;
        const wWidth = w.width;
        const wHeight = 36;
        
        ctx.shadowBlur = 5;
        ctx.shadowColor = "rgba(0,0,0,0.3)";
        // card-like rounded background
        ctx.beginPath();
        ctx.roundRect(wordX - 6, wordY - 5, wWidth + 12, wHeight + 6, 24);
        ctx.fillStyle = isTarget ? "#fdebb3dd" : "#fffaecd9";
        ctx.fill();
        ctx.shadowBlur = 2;
        ctx.font = `bold 26px "Segoe UI", system-ui, "Comic Neue", sans-serif`;
        ctx.fillStyle = isTarget ? "#b64926" : "#2f4f2f";
        ctx.fillText(w.text, wordX, wordY + 24);
        
        if (isTarget) {
          // subtle glow as indicator for correct match
          ctx.shadowBlur = 10;
          ctx.fillStyle = "#e67e22";
          ctx.fillText(w.text, wordX-1, wordY+23);
          ctx.fillStyle = "#ffb347";
          ctx.fillText(w.text, wordX+1, wordY+25);
          ctx.fillStyle = "#b64926";
          ctx.fillText(w.text, wordX, wordY+24);
        }
      }
      ctx.shadowBlur = 0;
      
      // if gameActive is false, show semi transparent overlay & end text inside draw as fallback
      if (!gameActive) {
        ctx.font = "bold 30px system-ui, 'Segoe UI'";
        ctx.fillStyle = "#2b2b1fcc";
        ctx.shadowBlur = 0;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = "#fdebb3";
        ctx.font = "bold 32px system-ui";
        ctx.shadowColor = "black";
        ctx.fillText("โฐ GAME OVER", canvas.width/2-110, canvas.height/2-30);
        ctx.font = "26px system-ui";
        ctx.fillStyle = "#fff0c0";
        ctx.fillText(`โญ FINAL SCORE: ${score} โญ`, canvas.width/2-140, canvas.height/2+30);
        ctx.font = "20px system-ui";
        ctx.fillStyle = "#ffd58c";
        ctx.fillText("๐Ÿ” Press RESTART to play again", canvas.width/2-135, canvas.height/2+90);
      }
    }
    
    function updatePositions() {
      if (!gameActive) return;
      for (let i = 0; i < activeWords.length; i++) {
        const w = activeWords[i];
        w.y += w.speed;
        if (w.y > canvas.height + 100 || w.y + 50 < -50) {
          activeWords.splice(i,1);
          i--;
        }
      }
    }
    
    function animate() {
      if (!gameActive) {
        draw();
        return;
      }
      updatePositions();
      draw();
      animationId = requestAnimationFrame(animate);
    }
    
    // ========== CORE GAMEPLAY: HANDLE CLICK/TAP ON FALLING WORDS ==========
    function handleCanvasInteraction(e) {
      if (!gameActive) return false;
      
      let clientX, clientY;
      if (e.touches) {
        e.preventDefault();
        clientX = e.touches[0].clientX;
        clientY = e.touches[0].clientY;
      } else {
        clientX = e.clientX;
        clientY = e.clientY;
      }
      
      const rect = canvas.getBoundingClientRect();
      const scaleX = canvas.width / rect.width;
      const scaleY = canvas.height / rect.height;
      const canvasX = (clientX - rect.left) * scaleX;
      const canvasY = (clientY - rect.top) * scaleY;
      
      // check from topmost (last in array) to first for natural feel
      for (let i = activeWords.length-1; i >= 0; i--) {
        const word = activeWords[i];
        const leftBound = word.x - 12;
        const rightBound = word.x + word.width + 10;
        const topBound = word.y - 8;
        const bottomBound = word.y + 38;
        
        if (canvasX >= leftBound && canvasX <= rightBound && canvasY >= topBound && canvasY <= bottomBound) {
          if (word.correct) {
            // CORRECT MATCH!
            score += 10;
            updateScoreUI();
            playSound('correct');
            showFeedback(`๐ŸŽ‰ CORRECT! +10 pts  (${word.text} = ${currentTargetEnglish})`, true);
            // remove matched word
            activeWords.splice(i,1);
            // change target to new random Spanish-English word
            pickNewTarget();
            // update difficulty: speed & spawn interval adapter
            restartSpawnGenerator();
          } else {
            // wrong click penalty
            score = Math.max(0, score - 5);
            updateScoreUI();
            playSound('wrong');
            showFeedback(`โŒ WRONG! -5 pts. โ€œ${word.text}โ€ โ‰  โ€œ${currentTargetEnglish}โ€`, false);
            activeWords.splice(i,1);
            // target remains same, but difficulty may still adapt to lower score
            restartSpawnGenerator();
          }
          return true;
        }
      }
      return false;
    }
    
    // ----- TIMER MANAGEMENT -----
    function startTimer() {
      if (timerInterval) clearInterval(timerInterval);
      timerInterval = setInterval(() => {
        if (!gameActive) return;
        if (timeLeft <= 1) {
          timeLeft = 0;
          timerSpan.textContent = "0";
          endGame();
        } else {
          timeLeft--;
          timerSpan.textContent = timeLeft;
        }
      }, 1000);
    }
    
    function endGame() {
      if (!gameActive) return;
      gameActive = false;
      if (wordGeneratorInterval) clearInterval(wordGeneratorInterval);
      if (timerInterval) clearInterval(timerInterval);
      if (animationId) cancelAnimationFrame(animationId);
      
      draw(); // show final game over overlay
      showFeedback(`๐Ÿ GAME COMPLETE! Final Score: ${score} ๐Ÿ`, true);
      feedbackDiv.style.color = "#ffd966";
    }
    
    // ------ RESTART FULL GAME with clean state ------
    function restartGame() {
      // kill existing loops
      if (wordGeneratorInterval) clearInterval(wordGeneratorInterval);
      if (timerInterval) clearInterval(timerInterval);
      if (animationId) cancelAnimationFrame(animationId);
      
      // reset state
      gameActive = true;
      score = 0;
      timeLeft = 60;
      activeWords = [];
      baseSpeed = 2.2;
      
      // UI reset
      updateScoreUI();
      timerSpan.textContent = "60";
      
      // pick random initial target (avoid always hello)
      const initPair = wordPairs[Math.floor(Math.random() * wordPairs.length)];
      currentTargetEnglish = initPair.english;
      targetSpan.textContent = currentTargetEnglish;
      
      feedbackDiv.style.color = "#f9eec1";
      feedbackDiv.innerHTML = "โœจ New round! Find the matching Spanish word โœจ";
      
      // restart dynamic generator
      restartSpawnGenerator();
      startTimer();
      
      // start animation again
      animationId = requestAnimationFrame(animate);
      
      // create initial falling words to populate canvas
      setTimeout(() => {
        if (gameActive) {
          for(let s=0; s<3; s++) spawnWord();
        }
      }, 80);
    }
    
    // ----- canvas round rect helper (smooth) -----
    if (!CanvasRenderingContext2D.prototype.roundRect) {
      CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
        if (w < 2 * r) r = w / 2;
        if (h < 2 * r) r = h / 2;
        this.moveTo(x+r, y);
        this.lineTo(x+w-r, y);
        this.quadraticCurveTo(x+w, y, x+w, y+r);
        this.lineTo(x+w, y+h-r);
        this.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
        this.lineTo(x+r, y+h);
        this.quadraticCurveTo(x, y+h, x, y+h-r);
        this.lineTo(x, y+r);
        this.quadraticCurveTo(x, y, x+r, y);
        return this;
      };
    }
    
    // ---- set up canvas event listeners with full compatibility for arcabelab.ai (both mouse + touch) ----
    function attachCanvasEvents() {
      canvas.removeEventListener('click', onClickHandler);
      canvas.removeEventListener('touchstart', onTouchHandler);
      canvas.addEventListener('click', onClickHandler);
      canvas.addEventListener('touchstart', onTouchHandler, { passive: false });
    }
    
    function onClickHandler(e) {
      if (!gameActive) return;
      e.preventDefault();
      handleCanvasInteraction(e);
    }
    
    function onTouchHandler(e) {
      if (!gameActive) return;
      e.preventDefault();
      handleCanvasInteraction(e);
    }
    
    // additional restart button behavior
    restartBtn.addEventListener('click', (e) => {
      e.preventDefault();
      restartGame();
    });
    
    // window resize handling โ€“ maintain canvas ratio / redraw on resize ? just call draw refresh
    window.addEventListener('resize', () => {
      if (gameActive) draw();
      else draw();
    });
    
    // optional: preload AudioContext on first user interaction (improves sound)
    function primeAudioOnFirstTap() {
      const resumeAudio = () => {
        try {
          const actx = new (window.AudioContext || window.webkitAudioContext)();
          if (actx.state === 'suspended') actx.resume();
        } catch(e) {}
        document.removeEventListener('click', resumeAudio);
        canvas.removeEventListener('touchstart', resumeAudio);
      };
      document.addEventListener('click', resumeAudio);
      canvas.addEventListener('touchstart', resumeAudio);
    }
    primeAudioOnFirstTap();
    
    // ---- final initialization ---- 
    function initGame() {
      attachCanvasEvents();
      restartGame();  // kickstarts everything
    }
    
    initGame();
  })();
</script>
</body>
</html>

Game Source: Spanish Word Fall - Learn Spanish | Arcabelab.ai

Creator: HyperScout89

Libraries: none

Complexity: complex (613 lines, 20.1 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: spanish-word-fall-learn-spanish-hyperscout89" to link back to the original. Then publish at arcadelab.ai/publish.