Lettres Magiques — jeu de prénoms HTML5 (mobile+ordi)
This commit is contained in:
41
README.md
Normal file
41
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
608
game.js
Normal file
608
game.js
Normal 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(() => {}));
|
||||||
|
}
|
||||||
BIN
icons/icon-192.png
Normal file
BIN
icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
icons/icon-512.png
Normal file
BIN
icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
75
index.html
Normal file
75
index.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#6a5cff" />
|
||||||
|
<meta name="description" content="Lettres Magiques — un jeu pour apprendre les lettres en s'amusant, sur téléphone et ordinateur." />
|
||||||
|
<title>✨ Lettres Magiques ✨</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon-192.png" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Fond animé -->
|
||||||
|
<canvas id="fx"></canvas>
|
||||||
|
|
||||||
|
<!-- Écran d'accueil -->
|
||||||
|
<div id="start" class="screen">
|
||||||
|
<div class="start-card">
|
||||||
|
<h1 class="title">✨ Lettres Magiques ✨</h1>
|
||||||
|
<p class="subtitle">Écris les prénoms de la famille !</p>
|
||||||
|
<button id="btn-play" class="big-btn">🎮 Jouer</button>
|
||||||
|
<p class="hint-line">🔊 Le son aide les petits — laisse-le activé.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interface de jeu -->
|
||||||
|
<div id="hud" class="hud hidden">
|
||||||
|
<div class="hud-left">
|
||||||
|
<div class="badge">⭐ <span id="score">0</span></div>
|
||||||
|
<div class="badge">🚀 Niv. <span id="level">1</span></div>
|
||||||
|
<div class="badge">🏅 <span id="stickers-count">0</span>/15</div>
|
||||||
|
</div>
|
||||||
|
<div class="hud-right">
|
||||||
|
<button id="btn-sound" class="icon-btn" title="Son">🔊</button>
|
||||||
|
<button id="btn-stickers" class="icon-btn" title="Mes autocollants">🏅</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mot en cours -->
|
||||||
|
<div id="word-zone" class="word-zone hidden"></div>
|
||||||
|
|
||||||
|
<!-- Liste des mots à trouver (aide permanente) -->
|
||||||
|
<div id="word-list" class="word-list hidden"></div>
|
||||||
|
|
||||||
|
<!-- Clavier tactile -->
|
||||||
|
<div id="keyboard" class="keyboard hidden"></div>
|
||||||
|
|
||||||
|
<!-- Mascotte + bulle -->
|
||||||
|
<div id="mascot" class="mascot hidden">
|
||||||
|
<div id="speech" class="speech"></div>
|
||||||
|
<div id="blob" class="blob">
|
||||||
|
<div class="eye left"><span></span></div>
|
||||||
|
<div class="eye right"><span></span></div>
|
||||||
|
<div class="mouth"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay mini-jeu -->
|
||||||
|
<div id="minigame" class="minigame hidden">
|
||||||
|
<div class="mg-banner"><span id="mg-title">Mini-jeu !</span> — <span id="mg-time">10</span>s</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tiroir des autocollants -->
|
||||||
|
<div id="sticker-drawer" class="drawer hidden">
|
||||||
|
<div class="drawer-card">
|
||||||
|
<h2>🏅 Mes autocollants</h2>
|
||||||
|
<div id="sticker-grid" class="sticker-grid"></div>
|
||||||
|
<button id="btn-close-stickers" class="big-btn small">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
manifest.webmanifest
Normal file
15
manifest.webmanifest
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
304
style.css
Normal file
304
style.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
36
sw.js
Normal file
36
sw.js
Normal file
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user