/* ✨ Lettres Magiques — jeu web pour enfants Réécriture HTML5 du jeu pygame "Super Lettres Magiques". Fonctionne au clavier (ordi) et au toucher (téléphone/tablette). */ "use strict"; /* ------------------------------------------------------------------ */ /* Données du jeu */ /* ------------------------------------------------------------------ */ // Prénoms de la famille (repris du jeu d'origine) + leur petite phrase magique. // 👉 Personnalisable : ajoute/retire des prénoms ici, c'est tout. const NAMES = ["PEPE", "GEGE", "NICO", "JULIE", "LILWENN", "COME", "GWEN", "ISABELLE", "BAPTISTE", "TRUFFE"]; const NAME_PHRASES = { PEPE: "Pépé, la reine des bisous magiques !", GEGE: "Gégé, le champion des chatouilles !", NICO: "Nico, le super héros rigolo !", JULIE: "Julie, la fée des câlins tout doux !", LILWENN: "Lilwenn, la princesse des étoiles filantes !", COME: "Côme, le pirate des doudous perdus !", GWEN: "Gwen, la magicienne des sourires !", ISABELLE: "Isabelle, la reine des gâteaux rigolos !", BAPTISTE: "Baptiste, le roi des voitures qui font vroum !", TRUFFE: "Truffe, le chien qui fait des bisous baveux !", }; const WORD_POOL = NAMES; const BATCH_SIZE = NAMES.length; // on affiche toute la famille const STICKERS = ["🦄","⭐","🌈","🐱","🚀","🍭","🎈","🐠","🦋","🌸","🐢","🍩","🎸","🐧","🌟"]; const ENCOURAGEMENTS = [ "Super ! 🌟", "Génial ! 🎉", "Bravo ! 👏", "Magnifique ! ✨", "Tu es fort ! 💪", "Youpi ! 🎈", "Fantastique ! 🌈", "Trop bien ! 🚀", ]; const ERREURS = [ "Oups ! Essaie encore ! 🤗", "Pas grave ! Recommence ! 😊", "Presque ! Tu vas y arriver ! 💪", "C'est pas facile ! Essaie ! 🌟", ]; /* ------------------------------------------------------------------ */ /* État */ /* ------------------------------------------------------------------ */ const state = { running: false, score: 0, level: 1, current: "", // mot en cours de frappe targets: [], // mots à trouver (batch courant) found: new Set(), // mots du batch déjà trouvés totalFound: 0, stickers: 0, soundOn: true, lastAction: 0, hintShown: false, miniGame: null, }; /* ------------------------------------------------------------------ */ /* Raccourcis DOM */ /* ------------------------------------------------------------------ */ const $ = (id) => document.getElementById(id); const elScore = $("score"), elLevel = $("level"), elStickersCount = $("stickers-count"); const elWordZone = $("word-zone"), elWordList = $("word-list"); const elKeyboard = $("keyboard"), elMascot = $("mascot"), elBlob = $("blob"), elSpeech = $("speech"); const elMini = $("minigame"), elMgTitle = $("mg-title"), elMgTime = $("mg-time"); /* ------------------------------------------------------------------ */ /* Son (synthèse WebAudio + voix) */ /* ------------------------------------------------------------------ */ let audioCtx = null; function ac() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); return audioCtx; } function beep(freq, dur = 0.12, type = "sine", vol = 0.18, delay = 0) { if (!state.soundOn) return; try { const ctx = ac(); const o = ctx.createOscillator(), g = ctx.createGain(); o.type = type; o.frequency.value = freq; const t0 = ctx.currentTime + delay; g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(vol, t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); o.connect(g).connect(ctx.destination); o.start(t0); o.stop(t0 + dur + 0.02); } catch (e) { /* ignore */ } } const sndType = () => beep(420 + Math.random() * 120, 0.08, "triangle", 0.12); const sndPop = () => beep(700, 0.10, "square", 0.14); const sndError = () => { beep(300, 0.14, "sawtooth", 0.10); beep(220, 0.18, "sawtooth", 0.10, 0.08); }; const sndSuccess = () => { [523, 659, 784, 1047].forEach((f, i) => beep(f, 0.22, "triangle", 0.16, i * 0.09)); }; const sndSticker = () => { [784, 988, 1175].forEach((f, i) => beep(f, 0.25, "sine", 0.16, i * 0.10)); }; let frVoice = null; function pickVoice() { const voices = speechSynthesis.getVoices(); frVoice = voices.find(v => /fr(-|_)?/i.test(v.lang)) || voices[0] || null; } if ("speechSynthesis" in window) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; } function say(text) { if (!state.soundOn || !("speechSynthesis" in window)) return; // Retirer les emojis / garder lettres, chiffres, ponctuation simple const clean = text.replace(/[^\p{L}\p{N} !?.,']/gu, "").trim(); if (!clean) return; try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(clean); if (frVoice) u.voice = frVoice; u.lang = "fr-FR"; u.rate = 0.92; u.pitch = 1.15; speechSynthesis.speak(u); } catch (e) { /* ignore */ } } /* ------------------------------------------------------------------ */ /* Mascotte « Blob » */ /* ------------------------------------------------------------------ */ let speechTimer = null, blinkTimer = null; function mascotSet(stateName) { elBlob.classList.remove("happy", "sad", "sleep", "dance"); if (stateName) elBlob.classList.add(stateName); } function mascotSay(text, mood = "happy") { mascotSet(mood); elSpeech.textContent = text; elSpeech.classList.add("show"); clearTimeout(speechTimer); speechTimer = setTimeout(() => elSpeech.classList.remove("show"), 3200); say(text); if (mood === "dance") setTimeout(() => mascotSet("happy"), 2500); } function mascotEncourage() { mascotSay(rand(ENCOURAGEMENTS), "dance"); } function mascotError() { mascotSay(rand(ERREURS), "sad"); } function blinkLoop() { clearTimeout(blinkTimer); const next = 1500 + Math.random() * 3000; blinkTimer = setTimeout(() => { elBlob.classList.add("blink"); setTimeout(() => elBlob.classList.remove("blink"), 150); blinkLoop(); }, next); } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const rand = (arr) => arr[Math.floor(Math.random() * arr.length)]; function shuffle(a) { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /* ------------------------------------------------------------------ */ /* Affichage : mot en cours, liste, HUD */ /* ------------------------------------------------------------------ */ const LETTER_COLORS = ["#ff6bcb", "#6a5cff", "#2ee6e6", "#ffd23f", "#3ddc84", "#ff8c42"]; function renderWord(popLast = false) { elWordZone.innerHTML = ""; [...state.current].forEach((ch, i) => { const t = document.createElement("span"); t.className = "tile" + (popLast && i === state.current.length - 1 ? " pop" : ""); t.textContent = ch; t.style.color = LETTER_COLORS[i % LETTER_COLORS.length]; elWordZone.appendChild(t); }); } function renderList() { elWordList.innerHTML = ""; state.targets.forEach((w) => { const c = document.createElement("span"); c.className = "chip" + (state.found.has(w) ? " found" : ""); c.dataset.word = w; c.textContent = w; elWordList.appendChild(c); }); } function updateHud() { elScore.textContent = state.score; elLevel.textContent = state.level; elStickersCount.textContent = state.stickers; } function newBatch() { state.targets = shuffle(WORD_POOL).slice(0, BATCH_SIZE); state.found.clear(); renderList(); } /* ------------------------------------------------------------------ */ /* Saisie d'une lettre */ /* ------------------------------------------------------------------ */ function eraseLetter() { state.current = ""; renderWord(); bump(); } function inputLetter(ch) { ch = ch.toUpperCase(); if (!/^[A-Z]$/.test(ch)) return; state.lastAction = now(); state.hintShown = false; clearHint(); mascotSet("happy"); state.current += ch; renderWord(true); sndType(); spawnBubbles(window.innerWidth / 2, window.innerHeight * 0.28, 6); checkWord(); } function checkWord() { const w = state.current; if (state.targets.includes(w) && !state.found.has(w)) { wordFound(w); return; } // Erreur : ces lettres ne peuvent plus mener à aucun prénom restant const possible = state.targets.some(t => !state.found.has(t) && t.startsWith(w)); if (!possible) { mascotError(); sndError(); setTimeout(() => { state.current = ""; renderWord(); }, 350); } } function wordFound(w) { state.found.add(w); state.totalFound++; state.score += 100 * w.length; updateHud(); renderList(); celebrate(); sndSuccess(); mascotSay("Bravo ! " + (NAME_PHRASES[w] || (w + " !")) + " 🎉", "dance"); // Sticker tous les 3 mots if (state.totalFound % 3 === 0) earnSticker(); // Niveau suivant tous les 3 mots if (state.totalFound % 3 === 0) { state.level++; updateHud(); confettiBurst(150); } setTimeout(() => { state.current = ""; renderWord(); }, 700); // Toute la famille trouvée → grande fête puis on recommence if (state.found.size >= state.targets.length) { confettiBurst(200); setTimeout(() => mascotSay("Tu connais toute la famille ! 🥳", "dance"), 800); setTimeout(newBatch, 1600); } } /* ------------------------------------------------------------------ */ /* Stickers */ /* ------------------------------------------------------------------ */ function earnSticker() { if (state.stickers >= STICKERS.length) return; state.stickers++; updateHud(); sndSticker(); const emoji = STICKERS[state.stickers - 1]; setTimeout(() => mascotSay("Nouvel autocollant ! " + emoji, "dance"), 900); renderStickerGrid(); } function renderStickerGrid() { const grid = $("sticker-grid"); grid.innerHTML = ""; STICKERS.forEach((emoji, i) => { const d = document.createElement("div"); const unlocked = i < state.stickers; d.className = "sticker " + (unlocked ? "unlocked" : "locked"); d.textContent = unlocked ? emoji : "❔"; grid.appendChild(d); }); } /* ------------------------------------------------------------------ */ /* Inactivité : indice puis dodo */ /* ------------------------------------------------------------------ */ function clearHint() { document.querySelectorAll(".chip.hint").forEach(c => c.classList.remove("hint")); } function checkIdle() { if (!state.running || state.miniGame) return; const idle = (now() - state.lastAction) / 1000; if (idle > 5 && !state.hintShown) { state.hintShown = true; const remaining = state.targets.filter(w => !state.found.has(w)); if (remaining.length) { const w = rand(remaining); const chip = elWordList.querySelector(`.chip[data-word="${w}"]`); if (chip) chip.classList.add("hint"); mascotSay("Essaie d'écrire " + w + " ! 💡", "happy"); } } if (idle > 12) { mascotSet("sleep"); elSpeech.textContent = "Zzz… joue avec moi ! 😴"; elSpeech.classList.add("show"); } } /* ------------------------------------------------------------------ */ /* Mini-jeux (ballons / étoiles) — taper pour éclater */ /* ------------------------------------------------------------------ */ let nextMiniGame = 0; function maybeStartMiniGame() { if (state.miniGame || !state.running) return; if (now() < nextMiniGame) return; startMiniGame(); } function startMiniGame() { const kind = Math.random() < 0.5 ? "ballons" : "etoiles"; state.miniGame = { kind, items: [], end: now() + 10000, spawn: 0, earned: 0, }; elMgTitle.textContent = kind === "ballons" ? "🎈 Éclate les ballons !" : "⭐ Attrape les étoiles !"; elMini.classList.remove("hidden"); mascotSay("Mini-jeu bonus ! 🎮", "dance"); } function endMiniGame() { if (!state.miniGame) return; const earned = state.miniGame.earned; state.miniGame = null; elMini.classList.add("hidden"); nextMiniGame = now() + 30000; if (earned > 0) { state.score += earned; updateHud(); mascotEncourage(); } } function updateMiniGame(dt) { const mg = state.miniGame; if (!mg) return; if (now() > mg.end) { endMiniGame(); return; } elMgTime.textContent = Math.ceil((mg.end - now()) / 1000); mg.spawn -= dt; if (mg.spawn <= 0) { mg.spawn = 0.55; mg.items.push({ x: 40 + Math.random() * (window.innerWidth - 80), y: window.innerHeight + 30, r: 26 + Math.random() * 14, vy: 90 + Math.random() * 70, hue: Math.floor(Math.random() * 360), kind: mg.kind, }); } for (const it of mg.items) it.y -= it.vy * dt; mg.items = mg.items.filter(it => it.y > -60); } function hitMiniGame(x, y) { const mg = state.miniGame; if (!mg) return false; for (let i = mg.items.length - 1; i >= 0; i--) { const it = mg.items[i]; if (Math.hypot(it.x - x, it.y - y) < it.r + 14) { mg.items.splice(i, 1); mg.earned += 20; sndPop(); spawnBubbles(x, y, 10); confettiAt(x, y, 8); return true; } } return false; } /* ------------------------------------------------------------------ */ /* Canvas : fond, étoiles, bulles, confettis, arc-en-ciel */ /* ------------------------------------------------------------------ */ const cv = $("fx"), ctx2d = cv.getContext("2d"); let W = 0, H = 0, DPR = 1; let stars = [], bubbles = [], confetti = [], rainbow = 0; function resize() { DPR = Math.min(window.devicePixelRatio || 1, 2); W = window.innerWidth; H = window.innerHeight; cv.width = W * DPR; cv.height = H * DPR; cv.style.width = W + "px"; cv.style.height = H + "px"; ctx2d.setTransform(DPR, 0, 0, DPR, 0, 0); layoutSafeAreas(); } function initStars() { stars = []; const n = Math.min(160, Math.floor((W * H) / 9000)); for (let i = 0; i < n; i++) { stars.push({ x: Math.random() * W, y: Math.random() * H, r: 0.6 + Math.random() * 2.2, phase: Math.random() * Math.PI * 2, sp: 0.01 + Math.random() * 0.04, col: rand(["#ffffff", "#ffd23f", "#2ee6e6", "#ff6bcb"]), }); } } function spawnBubbles(x, y, n) { for (let i = 0; i < n; i++) { bubbles.push({ x: x + (Math.random() - 0.5) * 80, y, r: 6 + Math.random() * 16, vy: 0.6 + Math.random() * 1.8, life: 1, drift: (Math.random() - 0.5) * 1.5, col: rand(["#2ee6e6", "#6a5cff", "#7af5f5"]), }); } } function confettiBurst(n) { for (let i = 0; i < n; i++) confetti.push(mkConfetti(Math.random() * W, -20)); rainbow = 1; } function confettiAt(x, y, n) { for (let i = 0; i < n; i++) confetti.push(mkConfetti(x, y)); } function mkConfetti(x, y) { return { x, y, vx: (Math.random() - 0.5) * 6, vy: 2 + Math.random() * 4, rot: Math.random() * 360, vr: (Math.random() - 0.5) * 20, size: 8 + Math.random() * 10, col: rand(["#ff6bcb", "#6a5cff", "#2ee6e6", "#ffd23f", "#3ddc84", "#ff8c42"]), }; } function celebrate() { confettiBurst(90); spawnBubbles(W / 2, H / 2, 24); } function drawBackground() { const g = ctx2d.createLinearGradient(0, 0, 0, H); g.addColorStop(0, "#5b6cff"); g.addColorStop(0.5, "#8a63ff"); g.addColorStop(1, "#ff8fd0"); ctx2d.fillStyle = g; ctx2d.fillRect(0, 0, W, H); } function loop(ts) { if (!loop.last) loop.last = ts; const dt = Math.min(0.05, (ts - loop.last) / 1000); loop.last = ts; drawBackground(); // Étoiles scintillantes for (const s of stars) { s.phase += s.sp; const lum = (Math.sin(s.phase) + 1) / 2; ctx2d.globalAlpha = 0.2 + lum * 0.8; ctx2d.fillStyle = s.col; ctx2d.beginPath(); ctx2d.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx2d.fill(); } ctx2d.globalAlpha = 1; // Arc-en-ciel lors d'un succès if (rainbow > 0) { rainbow -= dt * 0.5; const cols = ["#ff5c5c", "#ff8c42", "#ffd23f", "#3ddc84", "#2ee6e6", "#6a5cff", "#ff6bcb"]; ctx2d.globalAlpha = Math.max(0, rainbow) * 0.5; ctx2d.lineWidth = 16; cols.forEach((c, i) => { ctx2d.strokeStyle = c; ctx2d.beginPath(); ctx2d.arc(W / 2, H * 1.1, W * 0.5 - i * 18, Math.PI, Math.PI * 2); ctx2d.stroke(); }); ctx2d.globalAlpha = 1; } // Bulles for (const b of bubbles) { b.y -= b.vy; b.x += Math.sin(b.y * 0.02) * b.drift; b.life -= 0.01; ctx2d.globalAlpha = Math.max(0, b.life) * 0.6; ctx2d.strokeStyle = b.col; ctx2d.lineWidth = 2; ctx2d.beginPath(); ctx2d.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx2d.stroke(); } ctx2d.globalAlpha = 1; bubbles = bubbles.filter(b => b.life > 0 && b.y > -60); // Confettis for (const c of confetti) { c.x += c.vx; c.y += c.vy; c.vy += 0.12; c.rot += c.vr; ctx2d.save(); ctx2d.translate(c.x, c.y); ctx2d.rotate(c.rot * Math.PI / 180); ctx2d.fillStyle = c.col; ctx2d.fillRect(-c.size / 2, -c.size / 2, c.size, c.size * 0.6); ctx2d.restore(); } confetti = confetti.filter(c => c.y < H + 40); // Mini-jeu : ballons / étoiles updateMiniGame(dt); if (state.miniGame) { for (const it of state.miniGame.items) { if (it.kind === "ballons") { ctx2d.fillStyle = `hsl(${it.hue} 85% 60%)`; ctx2d.beginPath(); ctx2d.ellipse(it.x, it.y, it.r * 0.85, it.r, 0, 0, Math.PI * 2); ctx2d.fill(); ctx2d.strokeStyle = "rgba(255,255,255,0.5)"; ctx2d.lineWidth = 1; ctx2d.beginPath(); ctx2d.moveTo(it.x, it.y + it.r); ctx2d.lineTo(it.x, it.y + it.r + 22); ctx2d.stroke(); } else { drawStar(it.x, it.y, it.r, `hsl(${it.hue} 90% 60%)`); } } } requestAnimationFrame(loop); } function drawStar(cx, cy, r, color) { ctx2d.fillStyle = color; ctx2d.beginPath(); for (let i = 0; i < 10; i++) { const ang = (Math.PI / 5) * i - Math.PI / 2; const rad = i % 2 === 0 ? r : r * 0.45; const x = cx + Math.cos(ang) * rad, y = cy + Math.sin(ang) * rad; i === 0 ? ctx2d.moveTo(x, y) : ctx2d.lineTo(x, y); } ctx2d.closePath(); ctx2d.fill(); } /* ------------------------------------------------------------------ */ /* Clavier tactile (alphabétique, gros boutons) */ /* ------------------------------------------------------------------ */ const KB_ROWS = ["ABCDEFG", "HIJKLMN", "OPQRSTU", "VWXYZ"]; function buildKeyboard() { elKeyboard.innerHTML = ""; KB_ROWS.forEach((row, ri) => { const r = document.createElement("div"); r.className = "kb-row"; [...row].forEach((ch, i) => { const b = document.createElement("button"); b.className = "key"; b.textContent = ch; b.style.background = `linear-gradient(135deg, hsl(${(ri * 40 + i * 22) % 360} 80% 62%), hsl(${(ri * 40 + i * 22 + 30) % 360} 80% 52%))`; b.addEventListener("pointerdown", (e) => { e.preventDefault(); inputLetter(ch); }, { passive: false }); r.appendChild(b); }); if (ri === KB_ROWS.length - 1) { const e = document.createElement("button"); e.className = "key wide erase"; e.textContent = "⌫ Effacer"; e.addEventListener("pointerdown", (ev) => { ev.preventDefault(); eraseLetter(); }, { passive: false }); r.appendChild(e); } elKeyboard.appendChild(r); }); layoutSafeAreas(); } function layoutSafeAreas() { // Hauteur réelle du clavier → positionnement de la liste de mots / mascotte const h = elKeyboard.classList.contains("hidden") ? 0 : elKeyboard.offsetHeight; document.documentElement.style.setProperty("--kb-h", h + "px"); } /* ------------------------------------------------------------------ */ /* Entrées globales */ /* ------------------------------------------------------------------ */ function now() { return performance.now(); } function bump() { state.lastAction = now(); state.hintShown = false; clearHint(); } window.addEventListener("keydown", (e) => { if (!state.running) return; if (e.key === "Backspace" || e.key === " ") { e.preventDefault(); eraseLetter(); return; } if (/^[a-zA-Z]$/.test(e.key)) inputLetter(e.key); }); // Tap dans la zone de jeu = éclater ballon/étoile pendant le mini-jeu window.addEventListener("pointerdown", (e) => { if (state.miniGame) hitMiniGame(e.clientX, e.clientY); }); /* ------------------------------------------------------------------ */ /* Démarrage / boucle de jeu */ /* ------------------------------------------------------------------ */ function startGame() { $("start").classList.add("hidden"); ["hud", "word-zone", "word-list", "keyboard", "mascot"].forEach(id => $(id).classList.remove("hidden")); state.running = true; state.lastAction = now(); nextMiniGame = now() + 30000; newBatch(); updateHud(); buildKeyboard(); blinkLoop(); try { ac().resume(); } catch (e) {} mascotSay("Coucou ! Écris les prénoms de la famille ! 😊", "happy"); setInterval(checkIdle, 1000); setInterval(maybeStartMiniGame, 1000); } /* ------------------------------------------------------------------ */ /* Boutons UI */ /* ------------------------------------------------------------------ */ $("btn-play").addEventListener("click", startGame); $("btn-sound").addEventListener("click", () => { state.soundOn = !state.soundOn; $("btn-sound").textContent = state.soundOn ? "🔊" : "🔇"; if (!state.soundOn && "speechSynthesis" in window) speechSynthesis.cancel(); }); $("btn-stickers").addEventListener("click", () => { renderStickerGrid(); $("sticker-drawer").classList.remove("hidden"); }); $("btn-close-stickers").addEventListener("click", () => $("sticker-drawer").classList.add("hidden")); /* ------------------------------------------------------------------ */ /* Init */ /* ------------------------------------------------------------------ */ window.addEventListener("resize", () => { resize(); initStars(); }); resize(); initStars(); requestAnimationFrame(loop); // PWA : service worker (installable, jouable hors-ligne) if ("serviceWorker" in navigator) { window.addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {})); }