Lettres Magiques — jeu de prénoms HTML5 (mobile+ordi)

This commit is contained in:
Poulpe
2026-06-17 08:43:53 +00:00
commit 379aaac74d
8 changed files with 1079 additions and 0 deletions

608
game.js Normal file
View File

@@ -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(() => {}));
}