commit 379aaac74d669f3183234545c297362a54bc2757 Author: Poulpe Date: Wed Jun 17 08:43:53 2026 +0000 Lettres Magiques — jeu de prénoms HTML5 (mobile+ordi) diff --git a/README.md b/README.md new file mode 100644 index 0000000..62250c1 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# ✨ Lettres Magiques + +Jeu web pour enfants (4+) pour apprendre les lettres en s'amusant. +Réécriture HTML5 du jeu pygame « Super Lettres Magiques » — fonctionne **au clavier sur ordinateur** et **au toucher sur téléphone/tablette**. + +## Jouer + +Ouvre `index.html` dans un navigateur, ou va sur l'URL publique : +**https://laboratoire.freeboxos.fr/lettres-magiques/** + +## Comment ça marche + +- L'enfant tape/touche les lettres pour écrire les **prénoms de la famille** affichés (PEPE, JULIE, LILWENN, BAPTISTE, TRUFFE le chien…), chacun avec sa petite phrase magique. La liste se personnalise en haut de `game.js` (`NAMES` / `NAME_PHRASES`). +- Mascotte « Blob » qui parle et encourage (voix française via Web Speech API). +- Confettis, bulles, étoiles, arc-en-ciel à chaque réussite. +- Autocollants à collectionner (1 tous les 3 mots). +- Mini-jeux bonus (éclate les ballons / attrape les étoiles) toutes les ~30 s. +- Aucune pression temporelle, aide permanente (liste des mots + indice après quelques secondes). + +## Technique + +- 100 % statique : HTML + CSS + JS, aucune dépendance, aucun build. +- PWA : installable sur l'écran d'accueil et jouable hors-ligne (`manifest.webmanifest` + `sw.js`). +- Sons synthétisés en WebAudio (pas de fichiers audio lourds). + +## Fichiers + +| Fichier | Rôle | +|---|---| +| `index.html` | Structure de la page | +| `style.css` | Style responsive (mobile + desktop) | +| `game.js` | Logique du jeu, mascotte, effets, mini-jeux | +| `manifest.webmanifest`, `sw.js` | PWA | +| `icons/` | Icônes de l'app | +| `make_icons.py` | (dev) régénère les icônes | + +## Régénérer les icônes + +```bash +python make_icons.py +``` diff --git a/game.js b/game.js new file mode 100644 index 0000000..5dcca18 --- /dev/null +++ b/game.js @@ -0,0 +1,608 @@ +/* ✨ 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(() => {})); +} diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000..d22ea3f Binary files /dev/null and b/icons/icon-192.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..d52aaed Binary files /dev/null and b/icons/icon-512.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..b8f99f0 --- /dev/null +++ b/index.html @@ -0,0 +1,75 @@ + + + + + + + + ✨ Lettres Magiques ✨ + + + + + + + + + +
+
+

✨ Lettres Magiques ✨

+

Écris les prénoms de la famille !

+ +

🔊 Le son aide les petits — laisse-le activé.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..009e6b3 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Lettres Magiques", + "short_name": "Lettres", + "description": "Un jeu pour apprendre les lettres en s'amusant.", + "start_url": ".", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#5b6cff", + "theme_color": "#6a5cff", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..7dbe2bb --- /dev/null +++ b/style.css @@ -0,0 +1,304 @@ +:root { + --pink: #ff6bcb; + --purple: #6a5cff; + --cyan: #2ee6e6; + --yellow: #ffd23f; + --green: #3ddc84; + --ink: #2a2350; + --radius: 22px; + --safe-b: env(safe-area-inset-bottom, 0px); +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +html, body { + margin: 0; + height: 100%; + overflow: hidden; + font-family: "Baloo 2", "Comic Sans MS", "Segoe UI", system-ui, sans-serif; + color: var(--ink); + background: #1b1640; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; +} + +#fx { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + display: block; +} + +.hidden { display: none !important; } + +/* ---------- Écran d'accueil ---------- */ +.screen { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.start-card { + background: rgba(255,255,255,0.92); + border-radius: var(--radius); + padding: 32px 28px 28px; + text-align: center; + box-shadow: 0 18px 60px rgba(0,0,0,0.35); + max-width: 420px; + width: 100%; + animation: pop 0.5s ease; +} +.title { + font-size: clamp(28px, 8vw, 44px); + margin: 0 0 8px; + background: linear-gradient(90deg, var(--pink), var(--purple), var(--cyan)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 800; +} +.subtitle { font-size: clamp(15px, 4.5vw, 20px); margin: 0 0 22px; color: #5b5480; } +.hint-line { font-size: 13px; color: #8a84a8; margin: 16px 0 0; } + +.big-btn { + font: inherit; + font-weight: 800; + font-size: clamp(20px, 6vw, 26px); + color: #fff; + border: none; + border-radius: 999px; + padding: 16px 38px; + cursor: pointer; + background: linear-gradient(135deg, var(--pink), var(--purple)); + box-shadow: 0 10px 0 rgba(0,0,0,0.18), 0 14px 24px rgba(106,92,255,0.45); + transition: transform .08s ease, box-shadow .08s ease; +} +.big-btn:active { transform: translateY(6px); box-shadow: 0 4px 0 rgba(0,0,0,0.18); } +.big-btn.small { font-size: 18px; padding: 12px 26px; } + +/* ---------- HUD ---------- */ +.hud { + position: fixed; + top: 0; left: 0; right: 0; + z-index: 20; + display: flex; + justify-content: space-between; + align-items: center; + padding: max(10px, env(safe-area-inset-top)) 12px 10px; + gap: 8px; + pointer-events: none; +} +.hud-left, .hud-right { display: flex; gap: 8px; pointer-events: auto; } +.badge { + background: rgba(255,255,255,0.92); + border-radius: 999px; + padding: 7px 14px; + font-weight: 800; + font-size: clamp(13px, 3.6vw, 17px); + box-shadow: 0 6px 16px rgba(0,0,0,0.2); + white-space: nowrap; +} +.icon-btn { + background: rgba(255,255,255,0.92); + border: none; + border-radius: 50%; + width: 44px; height: 44px; + font-size: 22px; + cursor: pointer; + box-shadow: 0 6px 16px rgba(0,0,0,0.2); + display: grid; place-items: center; +} +.icon-btn:active { transform: scale(0.92); } + +/* ---------- Mot en cours ---------- */ +.word-zone { + position: fixed; + top: 16vh; + left: 0; right: 0; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + gap: clamp(6px, 2vw, 14px); + min-height: 22vh; + flex-wrap: wrap; + padding: 0 12px; +} +.tile { + font-weight: 900; + font-size: clamp(46px, 16vw, 110px); + line-height: 1; + color: #fff; + text-shadow: 0 6px 0 rgba(0,0,0,0.18); + animation: bounceIn .45s cubic-bezier(.2,1.4,.5,1); + filter: drop-shadow(0 8px 14px rgba(0,0,0,0.25)); +} +.tile.pop { animation: tilePop .4s ease; } + +/* ---------- Liste de mots ---------- */ +.word-list { + position: fixed; + z-index: 10; + left: 0; right: 0; + bottom: calc(var(--kb-h, 230px) + 10px + var(--safe-b)); + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + padding: 0 12px; +} +.chip { + background: rgba(255,255,255,0.85); + border-radius: 999px; + padding: 6px 14px; + font-weight: 800; + font-size: clamp(13px, 3.8vw, 18px); + letter-spacing: 2px; + box-shadow: 0 4px 10px rgba(0,0,0,0.15); + transition: transform .2s ease, background .2s ease, opacity .2s ease; +} +.chip.found { background: var(--green); color: #fff; opacity: .6; text-decoration: line-through; } +.chip.hint { animation: chipHint .6s ease infinite; background: var(--yellow); } + +/* ---------- Clavier tactile ---------- */ +.keyboard { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 15; + padding: 8px 6px calc(8px + var(--safe-b)); + background: linear-gradient(180deg, rgba(27,22,64,0) 0%, rgba(27,22,64,0.55) 30%); + display: flex; + flex-direction: column; + gap: 6px; +} +.kb-row { display: flex; gap: 6px; justify-content: center; } +.key { + flex: 1 1 0; + max-width: 64px; + aspect-ratio: 1 / 1.15; + border: none; + border-radius: 14px; + font: inherit; + font-weight: 900; + font-size: clamp(16px, 4.6vw, 24px); + color: #fff; + cursor: pointer; + box-shadow: 0 5px 0 rgba(0,0,0,0.22); + transition: transform .06s ease, box-shadow .06s ease, filter .1s ease; + display: grid; place-items: center; +} +.key:active, .key.down { transform: translateY(4px); box-shadow: 0 1px 0 rgba(0,0,0,0.22); filter: brightness(1.15); } +.key.wide { flex: 1.6 1 0; max-width: 110px; aspect-ratio: auto; padding: 0 8px; font-size: 18px; } +.key.erase { background: linear-gradient(135deg, #ff7a7a, #ff5c8a); } + +/* ---------- Mascotte ---------- */ +.mascot { + position: fixed; + right: max(10px, env(safe-area-inset-right)); + bottom: calc(var(--kb-h, 230px) + 8px + var(--safe-b)); + z-index: 16; + width: 116px; height: 116px; + pointer-events: none; +} +.blob { + position: absolute; + inset: 0; + border-radius: 48% 52% 47% 53% / 55% 50% 50% 45%; + background: radial-gradient(circle at 35% 30%, #7af5f5, var(--cyan) 60%, #1bb8c9); + box-shadow: 0 12px 24px rgba(0,0,0,0.3), inset 0 -10px 16px rgba(0,0,0,0.12); + animation: blobFloat 3s ease-in-out infinite; +} +.blob .eye { + position: absolute; top: 34%; + width: 26px; height: 26px; + background: #fff; border-radius: 50%; + display: grid; place-items: center; + box-shadow: inset 0 -2px 3px rgba(0,0,0,0.1); +} +.blob .eye.left { left: 24%; } +.blob .eye.right { right: 24%; } +.blob .eye span { width: 11px; height: 11px; background: var(--ink); border-radius: 50%; transition: transform .2s ease; } +.blob.blink .eye { height: 4px; } +.blob.blink .eye span { opacity: 0; } +.blob .mouth { + position: absolute; left: 50%; bottom: 24%; + width: 34px; height: 18px; + transform: translateX(-50%); + border-bottom: 5px solid var(--ink); + border-radius: 0 0 40px 40px; + transition: all .2s ease; +} +.blob.happy .mouth { height: 26px; border-bottom-width: 6px; } +.blob.sad .mouth { border-bottom: none; border-top: 5px solid var(--ink); border-radius: 40px 40px 0 0; bottom: 20%; } +.blob.sleep .mouth { width: 16px; height: 10px; } +.blob.dance { animation: blobDance .5s ease-in-out infinite; } +.speech { + position: absolute; + right: 60%; + bottom: 70%; + min-width: 130px; + max-width: 60vw; + background: #fff; + border-radius: 16px; + padding: 10px 14px; + font-weight: 700; + font-size: 15px; + box-shadow: 0 8px 20px rgba(0,0,0,0.25); + opacity: 0; + transform: translateY(8px) scale(.9); + transition: opacity .2s ease, transform .2s ease; + pointer-events: none; +} +.speech.show { opacity: 1; transform: translateY(0) scale(1); } +.speech::after { + content: ""; position: absolute; right: 18px; bottom: -9px; + border: 10px solid transparent; border-top-color: #fff; border-bottom: 0; +} + +/* ---------- Mini-jeu ---------- */ +.minigame { position: fixed; inset: 0; z-index: 18; pointer-events: none; } +.mg-banner { + position: absolute; top: 12vh; left: 50%; transform: translateX(-50%); + background: rgba(255,255,255,0.94); + border-radius: 999px; + padding: 10px 22px; + font-weight: 800; + font-size: clamp(16px, 5vw, 22px); + box-shadow: 0 8px 20px rgba(0,0,0,0.25); + white-space: nowrap; +} + +/* ---------- Tiroir autocollants ---------- */ +.drawer { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; background: rgba(20,16,50,0.6); padding: 20px; } +.drawer-card { + background: #fff; border-radius: var(--radius); padding: 22px; + max-width: 460px; width: 100%; text-align: center; + box-shadow: 0 18px 60px rgba(0,0,0,0.4); +} +.drawer-card h2 { margin: 0 0 16px; } +.sticker-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 18px; } +.sticker { + aspect-ratio: 1; border-radius: 16px; display: grid; place-items: center; + font-size: 28px; background: #eee9ff; +} +.sticker.locked { filter: grayscale(1); opacity: .35; } +.sticker.unlocked { background: linear-gradient(135deg, var(--yellow), var(--pink)); animation: pop .4s ease; } + +/* ---------- Animations ---------- */ +@keyframes pop { from { transform: scale(.8); opacity: 0; } to { transform: scale(1); opacity: 1; } } +@keyframes bounceIn { 0% { transform: scale(0) rotate(-20deg); opacity: 0; } 60% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } } +@keyframes tilePop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.3); } } +@keyframes blobFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } } +@keyframes blobDance { 0%,100% { transform: translateY(0) rotate(-8deg); } 50% { transform: translateY(-14px) rotate(8deg); } } +@keyframes chipHint { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } } + +@media (min-width: 820px) { + .key { max-width: 56px; } + .speech { font-size: 16px; } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..da07562 --- /dev/null +++ b/sw.js @@ -0,0 +1,36 @@ +/* Service worker — network-first (frais en ligne, cache en secours hors-ligne) */ +const CACHE = "lettres-magiques-v2"; +const ASSETS = [ + ".", + "index.html", + "style.css", + "game.js", + "manifest.webmanifest", + "icons/icon-192.png", + "icons/icon-512.png", +]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (e) => { + if (e.request.method !== "GET") return; + // Network-first : on tente le réseau, on met à jour le cache, et on retombe + // sur le cache si on est hors-ligne. Garantit des mises à jour immédiates. + e.respondWith( + fetch(e.request).then((resp) => { + const copy = resp.clone(); + caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {}); + return resp; + }).catch(() => caches.match(e.request)) + ); +});