diff --git a/.github/workflows/deploy-banana-collector.yml b/.github/workflows/deploy-banana-collector.yml
new file mode 100644
index 000000000..898d7c398
--- /dev/null
+++ b/.github/workflows/deploy-banana-collector.yml
@@ -0,0 +1,49 @@
+name: Deploy Banana Collector to GitHub Pages
+
+# Déploie uniquement le dossier banana-collector/ sur GitHub Pages.
+# N'interfère pas avec le pipeline de documentation existant (Doc-preview).
+#
+# Étape manuelle à faire UNE FOIS dans GitHub, avant que ce workflow
+# puisse publier quoi que ce soit :
+# Settings > Pages > Build and deployment > Source = "GitHub Actions"
+
+on:
+ push:
+ branches: ["main"]
+ paths:
+ - "banana-collector/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: "banana-collector-pages"
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+
+ - name: Stamp cache-busting version into index.html
+ run: sed -i "s/__CACHEBUST__/${{ github.sha }}/g" banana-collector/index.html
+
+ - name: Upload banana-collector/ as Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: banana-collector
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/banana-collector-app/.gitignore b/banana-collector-app/.gitignore
new file mode 100644
index 000000000..a0887d151
--- /dev/null
+++ b/banana-collector-app/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+android/
+ios/
diff --git a/banana-collector-app/README.md b/banana-collector-app/README.md
new file mode 100644
index 000000000..dd085b06f
--- /dev/null
+++ b/banana-collector-app/README.md
@@ -0,0 +1,96 @@
+# Banana Collector — app mobile (Capacitor)
+
+Ce dossier enveloppe le jeu web (`../banana-collector`) dans une vraie app
+Android/iOS grâce à [Capacitor](https://capacitorjs.com/). Le code du jeu
+n'est pas dupliqué : Capacitor embarque directement les fichiers de
+`../banana-collector` dans l'app.
+
+Impossible de terminer cette étape depuis cet environnement (pas de Xcode,
+pas forcément de SDK Android) — les commandes ci-dessous sont à lancer
+**sur ta machine**.
+
+## 0. Avant de commencer
+
+Change `appId` dans `capacitor.config.json` — `com.bananacollector.app` est
+un identifiant provisoire. Il doit être **unique** et sera l'identifiant de
+bundle enregistré auprès d'Apple/Google (impossible à changer facilement
+après publication). Choisis quelque chose lié à un domaine que tu possèdes,
+par ex. `com.tonpseudo.bananacollector`.
+
+## 1. Installer les dépendances
+
+```bash
+cd banana-collector-app
+npm install
+```
+
+## 2. Ajouter les plateformes
+
+Android nécessite [Android Studio](https://developer.android.com/studio)
+installé (avec le SDK). iOS nécessite un **Mac** avec
+[Xcode](https://developer.apple.com/xcode/).
+
+```bash
+npx cap add android # nécessite Android Studio / SDK
+npx cap add ios # nécessite macOS + Xcode
+```
+
+Cela crée les dossiers `android/` et `ios/` (projets natifs générés,
+à ne pas éditer à la main sauf besoin spécifique).
+
+## 3. Synchroniser le code web dans les apps natives
+
+À refaire à chaque fois que tu modifies `../banana-collector` :
+
+```bash
+npx cap sync
+```
+
+## 4. Ouvrir et builder
+
+```bash
+npx cap open android # ouvre Android Studio
+npx cap open ios # ouvre Xcode
+```
+
+Depuis Android Studio / Xcode : lance l'app sur un émulateur/appareil pour
+tester, puis utilise leurs outils de build/signature pour générer un
+`.aab` (Android) ou archiver l'app (iOS).
+
+## 5. Comptes développeur (obligatoires pour publier)
+
+- **Apple Developer Program** : 99 $/an — https://developer.apple.com/programme/
+- **Google Play Console** : 25 $ (paiement unique) — https://play.google.com/console/
+
+Chaque store demande en plus, au minimum : icônes/captures d'écran, une
+fiche descriptive, et une **politique de confidentialité** (obligatoire dès
+qu'il y a des pubs ou de la collecte de données — prévois une page dédiée).
+
+## 6. Brancher les vraies pubs sur mobile
+
+L'onglet "📺 Pub" du jeu simule déjà la récompense (voir
+`../banana-collector/app.js`, fonction `grantAdReward`). Pour de vraies
+pubs qui rapportent de l'argent sur mobile :
+
+1. Crée un compte [Google AdMob](https://admob.google.com/) et une app
+ AdMob (tu obtiens un App ID + un Ad Unit ID).
+2. Installe le plugin communautaire :
+ ```bash
+ npm install @capacitor-community/admob
+ npx cap sync
+ ```
+3. Dans `../banana-collector/app.js`, remplace le corps de la fonction
+ `watchAd`/le `setTimeout` de simulation dans `ui.js` par un appel
+ `AdMob.prepareRewardVideoAd()` puis `.showRewardVideoAd()`, et
+ n'appelle `grantAdReward()` que dans l'écouteur `rewardVideoAdReward`
+ du SDK (sinon la récompense n'est plus liée au visionnage réel).
+
+## 7. Icône et écran de démarrage
+
+Capacitor fournit `@capacitor/assets` pour générer icônes et splash
+screens à partir d'une image source :
+
+```bash
+npm install @capacitor/assets --save-dev
+npx capacitor-assets generate
+```
diff --git a/banana-collector-app/capacitor.config.json b/banana-collector-app/capacitor.config.json
new file mode 100644
index 000000000..dba587241
--- /dev/null
+++ b/banana-collector-app/capacitor.config.json
@@ -0,0 +1,9 @@
+{
+ "appId": "com.bananacollector.app",
+ "appName": "Banana Collector",
+ "webDir": "../banana-collector",
+ "bundledWebRuntime": false,
+ "server": {
+ "androidScheme": "https"
+ }
+}
diff --git a/banana-collector-app/package.json b/banana-collector-app/package.json
new file mode 100644
index 000000000..2fe3d6bec
--- /dev/null
+++ b/banana-collector-app/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "banana-collector-app",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Enveloppe Capacitor pour publier Banana Collector sur iOS et Android à partir du même code web (../banana-collector).",
+ "scripts": {
+ "cap:add:android": "cap add android",
+ "cap:add:ios": "cap add ios",
+ "cap:sync": "cap sync",
+ "cap:open:android": "cap open android",
+ "cap:open:ios": "cap open ios",
+ "cap:run:android": "cap run android"
+ },
+ "dependencies": {
+ "@capacitor/core": "^6.1.2"
+ },
+ "devDependencies": {
+ "@capacitor/android": "^6.1.2",
+ "@capacitor/cli": "^6.1.2",
+ "@capacitor/ios": "^6.1.2"
+ }
+}
diff --git a/banana-collector/CNAME b/banana-collector/CNAME
new file mode 100644
index 000000000..4cdf53aca
--- /dev/null
+++ b/banana-collector/CNAME
@@ -0,0 +1 @@
+bananacollector.fr
diff --git a/banana-collector/app.js b/banana-collector/app.js
new file mode 100644
index 000000000..77c2aa651
--- /dev/null
+++ b/banana-collector/app.js
@@ -0,0 +1,657 @@
+/* ============================================================
+ Banana Collector — Logique du jeu
+ ============================================================ */
+
+const SAVE_KEY = "banana-collector-save-v1";
+
+const UPGRADES = [
+ {
+ id: "panier",
+ name: "🍌 Panier amélioré",
+ desc: "+5% de chance d'obtenir une banane peu commune",
+ targets: ["peu_commune"],
+ bonusPerLevel: 5,
+ basePrice: 150,
+ priceMult: 1.65,
+ maxLevel: 10,
+ },
+ {
+ id: "detecteur",
+ name: "🔍 Détecteur de bananes",
+ desc: "+5% de chance d'obtenir une banane rare",
+ targets: ["rare"],
+ bonusPerLevel: 5,
+ basePrice: 450,
+ priceMult: 1.75,
+ maxLevel: 10,
+ },
+ {
+ id: "dore",
+ name: "✨ Panier doré",
+ desc: "+5% de chance d'obtenir une banane épique",
+ targets: ["epique"],
+ bonusPerLevel: 5,
+ basePrice: 1200,
+ priceMult: 1.85,
+ maxLevel: 10,
+ },
+ {
+ id: "cosmique",
+ name: "🌌 Scanner cosmique",
+ desc: "+2% de chance d'obtenir une banane légendaire ou mythique",
+ targets: ["legendaire", "mythique"],
+ bonusPerLevel: 2,
+ basePrice: 3000,
+ priceMult: 2.1,
+ maxLevel: 10,
+ },
+ {
+ id: "auto",
+ name: "🔄 Récolteur automatique",
+ desc: "Récolte automatiquement une banane à intervalles réguliers, sans avoir à cliquer",
+ targets: [],
+ basePrice: 2000,
+ priceMult: 2.2,
+ maxLevel: 4,
+ intervalsMs: [60000, 45000, 30000, 20000],
+ },
+ {
+ id: "multiplicateur",
+ name: "💰 Multiplicateur de pièces",
+ desc: "+10% de pièces gagnées, toutes sources confondues (récolte, pub, roue, mini-jeux, combat)",
+ targets: [],
+ basePrice: 2500,
+ priceMult: 2,
+ maxLevel: 5,
+ },
+ {
+ id: "pubplus",
+ name: "📺 Pub boostée",
+ desc: "+1 pub disponible par jour",
+ targets: [],
+ basePrice: 1800,
+ priceMult: 1.9,
+ maxLevel: 3,
+ },
+ {
+ id: "strategie",
+ name: "🎯 Stratège de combat",
+ desc: "+4% de chance de victoire dans l'Arène par niveau",
+ targets: [],
+ basePrice: 2200,
+ priceMult: 1.8,
+ maxLevel: 5,
+ },
+ {
+ id: "questbonus",
+ name: "📜 Quête bonus",
+ desc: "+1 quête quotidienne disponible par niveau",
+ targets: [],
+ basePrice: 3500,
+ priceMult: 2.3,
+ maxLevel: 2,
+ },
+];
+
+function defaultState() {
+ return {
+ coins: 0,
+ clicks: 0,
+ totalRolls: 0,
+ counts: {}, // bananaId -> count
+ discovered: [], // bananaId[]
+ pityRare: 0,
+ pityLegendary: 0,
+ upgrades: { panier: 0, detecteur: 0, dore: 0, cosmique: 0, auto: 0, multiplicateur: 0, pubplus: 0, strategie: 0, questbonus: 0 },
+ lastBananaId: null,
+ mythicCount: 0,
+ rarestId: null,
+ ads: { watchedToday: 0, lastResetDate: null },
+ wheel: { lastSpinDate: null },
+ catchGame: { bestScore: 0, bestCoins: 0 },
+ streak: { count: 0, lastLoginDate: null },
+ achievements: { unlocked: [] },
+ pve: { stage: 0, wins: 0, losses: 0 },
+ quests: { date: null, assigned: [], progress: {}, completed: [] },
+ settings: { muted: false },
+ // Compte cloud (Marché / Arène PVP), opt-in — voir cloud.js. Le jeu solo
+ // n'y touche jamais et continue de fonctionner 100% hors ligne sans lui.
+ cloud: { linked: false, lastLedgerId: 0 },
+ };
+}
+
+let state = loadState();
+
+function loadState() {
+ try {
+ const raw = localStorage.getItem(SAVE_KEY);
+ if (!raw) return defaultState();
+ const parsed = JSON.parse(raw);
+ return sanitizeState(Object.assign(defaultState(), parsed));
+ } catch (e) {
+ console.warn("Sauvegarde illisible, réinitialisation.", e);
+ return defaultState();
+ }
+}
+
+// Retire toute référence à un id de banane retiré du jeu depuis la dernière
+// sauvegarde (BANANAS_BY_ID[id] serait undefined) — sans ça, la moindre
+// bannière/carte/tri qui lit banana.rarity ou banana.secret plante et casse
+// tout l'affichage pour les joueurs ayant déjà collecté cette banane.
+function sanitizeState(s) {
+ s.discovered = s.discovered.filter((id) => BANANAS_BY_ID[id]);
+ for (const id of Object.keys(s.counts)) {
+ if (!BANANAS_BY_ID[id]) delete s.counts[id];
+ }
+ if (s.lastBananaId != null && !BANANAS_BY_ID[s.lastBananaId]) s.lastBananaId = null;
+ if (s.rarestId != null && !BANANAS_BY_ID[s.rarestId]) s.rarestId = null;
+ return s;
+}
+
+function saveState() {
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify(state));
+ } catch (e) {
+ console.warn("Impossible de sauvegarder la partie.", e);
+ }
+}
+
+// Tous les gains de pièces (récolte, pub, roue, mini-jeux, combat, succès,
+// prime de connexion) passent par ici pour que le multiplicateur de boutique
+// s'applique partout de façon cohérente.
+function coinMultiplier() {
+ return 1 + (state.upgrades.multiplicateur || 0) * 0.1;
+}
+
+function grantCoins(amount) {
+ const final = Math.round(amount * coinMultiplier());
+ state.coins += final;
+ return final;
+}
+
+/* ---------------- Tirage pondéré avec système de pitié ---------------- */
+
+function upgradeLevelBonus(rarityKey) {
+ let bonus = 0;
+ for (const up of UPGRADES) {
+ if (up.targets.includes(rarityKey)) {
+ const level = state.upgrades[up.id] || 0;
+ bonus += level * up.bonusPerLevel;
+ }
+ }
+ return bonus;
+}
+
+function computeWeights() {
+ const weights = {};
+ for (const key of RARITY_ORDER) {
+ weights[key] = RARITIES[key].weight + upgradeLevelBonus(key);
+ }
+
+ // Pitié douce : après 10 tirages sans rare+, les chances remontent progressivement.
+ if (state.pityRare >= 10) {
+ const bonus = Math.min((state.pityRare - 9) * 4, 150);
+ const targets = ["rare", "epique", "legendaire", "mythique", "secrete"];
+ const base = targets.reduce((s, r) => s + weights[r], 0) || 1;
+ targets.forEach((r) => {
+ weights[r] += bonus * (weights[r] / base);
+ });
+ }
+ // Pitié forte : au-delà de 25 tirages sans rare+, on garantit quasiment un rare+.
+ if (state.pityRare >= 25) {
+ weights.commune = 0;
+ weights.peu_commune = 0;
+ }
+
+ // Pitié douce pour légendaire+ après 40 tirages sans en obtenir.
+ if (state.pityLegendary >= 40) {
+ const bonus = Math.min((state.pityLegendary - 39) * 3, 80);
+ const targets = ["legendaire", "mythique", "secrete"];
+ const base = targets.reduce((s, r) => s + weights[r], 0) || 1;
+ targets.forEach((r) => {
+ weights[r] += bonus * (weights[r] / base);
+ });
+ }
+ // Pitié forte pour légendaire+ après 80 tirages.
+ if (state.pityLegendary >= 80) {
+ ["commune", "peu_commune", "rare", "epique"].forEach((r) => {
+ weights[r] *= 0.05;
+ });
+ }
+
+ return weights;
+}
+
+function pickRarity(weights) {
+ const total = RARITY_ORDER.reduce((s, r) => s + Math.max(weights[r], 0), 0);
+ let roll = Math.random() * total;
+ for (const r of RARITY_ORDER) {
+ const w = Math.max(weights[r], 0);
+ if (roll < w) return r;
+ roll -= w;
+ }
+ return "commune";
+}
+
+function pickBananaOfRarity(rarityKey) {
+ const pool = BANANAS.filter((b) => b.rarity === rarityKey);
+ return pool[Math.floor(Math.random() * pool.length)];
+}
+
+function rollBanana() {
+ const weights = computeWeights();
+ const rarity = pickRarity(weights);
+ const banana = pickBananaOfRarity(rarity);
+
+ // Mise à jour des compteurs de pitié
+ if (isRareOrAbove(rarity)) {
+ state.pityRare = 0;
+ } else {
+ state.pityRare += 1;
+ }
+ if (isLegendaryOrAbove(rarity)) {
+ state.pityLegendary = 0;
+ } else {
+ state.pityLegendary += 1;
+ }
+
+ const isNew = !state.discovered.includes(banana.id);
+ if (isNew) state.discovered.push(banana.id);
+ state.counts[banana.id] = (state.counts[banana.id] || 0) + 1;
+
+ const coinsEarned = grantCoins(banana.value);
+ state.clicks += 1;
+ state.totalRolls += 1;
+ state.lastBananaId = banana.id;
+ if (rarity === "mythique") state.mythicCount += 1;
+
+ if (state.rarestId == null || rarityIndex(rarity) > rarityIndex(BANANAS_BY_ID[state.rarestId].rarity)) {
+ state.rarestId = banana.id;
+ }
+
+ bumpQuestProgress("rolls");
+ if (isRareOrAbove(rarity)) bumpQuestProgress("rarePlus");
+
+ saveState();
+ return { banana, isNew, rarity, coinsEarned };
+}
+
+/* ---------------- Boutique ---------------- */
+
+function upgradePrice(upgrade) {
+ const level = state.upgrades[upgrade.id] || 0;
+ return Math.round(upgrade.basePrice * Math.pow(upgrade.priceMult, level));
+}
+
+function buyUpgrade(id) {
+ const upgrade = UPGRADES.find((u) => u.id === id);
+ if (!upgrade) return { ok: false, reason: "inconnu" };
+ const level = state.upgrades[id] || 0;
+ if (level >= upgrade.maxLevel) return { ok: false, reason: "max" };
+ const price = upgradePrice(upgrade);
+ if (state.coins < price) return { ok: false, reason: "pauvre" };
+ state.coins -= price;
+ state.upgrades[id] = level + 1;
+ bumpQuestProgress("upgradesBought");
+ saveState();
+ return { ok: true };
+}
+
+/* ---------------- Publicité récompensée ----------------
+ Emplacement d'intégration pour une vraie régie publicitaire.
+ Aujourd'hui : aucune requête réseau, juste une simulation de
+ chargement + une récompense garantie, limitée par jour.
+
+ Pour brancher une vraie pub plus tard :
+ - Web : Google AdSense (bannière classique dans l'onglet, ou un
+ format "récompensé" via Ad Manager) — remplacer le corps
+ de watchAd() par le chargement/l'affichage du format choisi
+ et n'appeler grantAdReward() que dans son callback de succès.
+ - Mobile (Capacitor) : plugin @capacitor-community/admob,
+ RewardedAd.load() puis .show(), et grantAdReward() dans
+ l'écouteur "onUserEarnedReward" de ce SDK.
+ -------------------------------------------------------- */
+
+const AD_REWARD = 300;
+const MAX_ADS_PER_DAY_BASE = 5;
+
+function todayKey() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function maxAdsPerDay() {
+ return MAX_ADS_PER_DAY_BASE + (state.upgrades.pubplus || 0);
+}
+
+function refreshAdQuota() {
+ if (state.ads.lastResetDate !== todayKey()) {
+ state.ads.watchedToday = 0;
+ state.ads.lastResetDate = todayKey();
+ }
+}
+
+function adsRemainingToday() {
+ refreshAdQuota();
+ return Math.max(maxAdsPerDay() - state.ads.watchedToday, 0);
+}
+
+function grantAdReward() {
+ state.ads.watchedToday += 1;
+ const coinsEarned = grantCoins(AD_REWARD);
+ bumpQuestProgress("ads");
+ saveState();
+ return coinsEarned;
+}
+
+/* ---------------- Mini-jeu : Roue de la fortune quotidienne ---------------- */
+
+// Un tirage gratuit par jour. Chaque prix correspond à un secteur de 60°
+// sur la roue (6 secteurs), dans cet ordre, en partant du haut et dans le
+// sens horaire — voir WHEEL_PRIZES[i].angle dans ui.js pour l'alignement visuel.
+const WHEEL_PRIZES = [
+ { coins: 50, weight: 30 },
+ { coins: 100, weight: 25 },
+ { coins: 150, weight: 20 },
+ { coins: 300, weight: 15 },
+ { coins: 500, weight: 7 },
+ { coins: 1000, weight: 3 },
+];
+
+function canSpinWheelToday() {
+ return state.wheel.lastSpinDate !== todayKey();
+}
+
+function pickWheelPrizeIndex() {
+ const total = WHEEL_PRIZES.reduce((s, p) => s + p.weight, 0);
+ let roll = Math.random() * total;
+ for (let i = 0; i < WHEEL_PRIZES.length; i++) {
+ if (roll < WHEEL_PRIZES[i].weight) return i;
+ roll -= WHEEL_PRIZES[i].weight;
+ }
+ return 0;
+}
+
+function spinWheel() {
+ if (!canSpinWheelToday()) return { ok: false, reason: "deja_tourne" };
+ const index = pickWheelPrizeIndex();
+ const prize = WHEEL_PRIZES[index];
+ state.wheel.lastSpinDate = todayKey();
+ const coinsEarned = grantCoins(prize.coins);
+ bumpQuestProgress("wheel");
+ saveState();
+ return { ok: true, index, coins: coinsEarned };
+}
+
+/* ---------------- Mini-jeu : Attrape les bananes ---------------- */
+
+// 3 niveaux joués à la suite dans un même round, chacun plus rapide et plus
+// difficile que le précédent (chute plus rapide, bananes plus fréquentes,
+// plus de bananes pourries à éviter).
+const CATCH_LEVEL_DURATION_MS = 10000;
+const CATCH_LEVELS = [
+ { spawnDelay: 780, fallMin: 2.6, fallMax: 3.4, rottenChance: 0.15, label: "C'est parti !" },
+ { spawnDelay: 560, fallMin: 2.0, fallMax: 2.7, rottenChance: 0.22, label: "Ça accélère !" },
+ { spawnDelay: 380, fallMin: 1.5, fallMax: 2.1, rottenChance: 0.3, label: "Vitesse maximale !" },
+];
+const CATCH_GOOD_COINS = 4;
+const CATCH_ROTTEN_PENALTY = 6;
+
+function awardCatchGameResult(goodCaught, rottenCaught) {
+ const rawCoins = Math.max(0, goodCaught * CATCH_GOOD_COINS - rottenCaught * CATCH_ROTTEN_PENALTY);
+ const coinsEarned = grantCoins(rawCoins);
+ if (goodCaught > state.catchGame.bestScore) state.catchGame.bestScore = goodCaught;
+ if (coinsEarned > state.catchGame.bestCoins) state.catchGame.bestCoins = coinsEarned;
+ bumpQuestProgress("catchRounds");
+ saveState();
+ return coinsEarned;
+}
+
+/* ---------------- Prime de connexion quotidienne ---------------- */
+
+// Appelée une fois au démarrage. Retourne les infos de la prime si un
+// nouveau jour a été détecté (pour afficher un toast), sinon null.
+function processDailyStreak() {
+ const today = todayKey();
+ if (state.streak.lastLoginDate === today) return null;
+
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
+ state.streak.count = state.streak.lastLoginDate === yesterday ? state.streak.count + 1 : 1;
+ state.streak.lastLoginDate = today;
+
+ const bonus = Math.min(20 + (state.streak.count - 1) * 15, 150);
+ const coinsEarned = grantCoins(bonus);
+ saveState();
+ return { streak: state.streak.count, coinsEarned };
+}
+
+/* ---------------- Quêtes quotidiennes ---------------- */
+
+// Bassin de quêtes possibles. Chaque jour, un tirage sans répétition en
+// sélectionne quelques-unes (3 de base, plus avec l'amélioration "Quête
+// bonus"). La progression ("key") est comptée en continu dans
+// state.quests.progress et remise à zéro chaque nouveau jour.
+const QUEST_POOL = [
+ { id: "harvest5", desc: "Récolte 5 bananes", need: 5, reward: 80, key: "rolls" },
+ { id: "harvest15", desc: "Récolte 15 bananes", need: 15, reward: 200, key: "rolls" },
+ { id: "watchAd", desc: "Regarde 1 pub", need: 1, reward: 120, key: "ads" },
+ { id: "spinWheel", desc: "Tourne la roue quotidienne", need: 1, reward: 100, key: "wheel" },
+ { id: "win1Fight", desc: "Gagne 1 combat dans l'Arène", need: 1, reward: 150, key: "wins" },
+ { id: "win3Fight", desc: "Gagne 3 combats dans l'Arène", need: 3, reward: 350, key: "wins" },
+ { id: "catchRound", desc: "Termine un round d'Attrape les bananes", need: 1, reward: 130, key: "catchRounds" },
+ { id: "rarePlus", desc: "Obtiens une banane rare ou mieux", need: 1, reward: 180, key: "rarePlus" },
+ { id: "buyUpgrade", desc: "Achète une amélioration en boutique", need: 1, reward: 150, key: "upgradesBought" },
+];
+
+function questCountToday() {
+ return 3 + (state.upgrades.questbonus || 0);
+}
+
+// Vérifie si on a changé de jour depuis le dernier tirage de quêtes et, si
+// oui, en tire un nouveau lot au hasard sans répétition.
+function refreshQuestsIfNewDay() {
+ const today = todayKey();
+ if (state.quests.date === today) return;
+ state.quests.date = today;
+ state.quests.progress = {};
+ state.quests.completed = [];
+ const pool = QUEST_POOL.slice();
+ const assigned = [];
+ const count = Math.min(questCountToday(), pool.length);
+ for (let i = 0; i < count; i++) {
+ const idx = Math.floor(Math.random() * pool.length);
+ assigned.push(pool.splice(idx, 1)[0].id);
+ }
+ state.quests.assigned = assigned;
+}
+
+function bumpQuestProgress(key, amount = 1) {
+ refreshQuestsIfNewDay();
+ state.quests.progress[key] = (state.quests.progress[key] || 0) + amount;
+}
+
+function questsForToday() {
+ refreshQuestsIfNewDay();
+ return state.quests.assigned
+ .map((id) => QUEST_POOL.find((q) => q.id === id))
+ .filter(Boolean)
+ .map((quest) => ({
+ ...quest,
+ progress: Math.min(state.quests.progress[quest.key] || 0, quest.need),
+ done: state.quests.completed.includes(quest.id),
+ }));
+}
+
+// Évalue les quêtes du jour, crédite les récompenses des quêtes tout juste
+// terminées et retourne leur liste (pour affichage de toasts).
+function checkQuests() {
+ refreshQuestsIfNewDay();
+ const completedNow = [];
+ for (const qid of state.quests.assigned) {
+ if (state.quests.completed.includes(qid)) continue;
+ const quest = QUEST_POOL.find((q) => q.id === qid);
+ if (!quest) continue;
+ const progress = state.quests.progress[quest.key] || 0;
+ if (progress >= quest.need) {
+ state.quests.completed.push(qid);
+ grantCoins(quest.reward);
+ completedNow.push(quest);
+ }
+ }
+ if (completedNow.length > 0) saveState();
+ return completedNow;
+}
+
+/* ---------------- Succès ---------------- */
+
+const ACHIEVEMENTS = [
+ { id: "first_harvest", icon: "🍌", name: "Première récolte", desc: "Récolte ta toute première banane", reward: 30, check: (s) => s.totalRolls >= 1 },
+ { id: "rolls_100", icon: "🧺", name: "Cueilleur assidu", desc: "Récolte 100 bananes au total", reward: 100, check: (s) => s.totalRolls >= 100 },
+ { id: "rolls_500", icon: "🚜", name: "Récolte industrielle", desc: "Récolte 500 bananes au total", reward: 300, check: (s) => s.totalRolls >= 500 },
+ { id: "rolls_1000", icon: "🏭", name: "Empire de la banane", desc: "Récolte 1000 bananes au total", reward: 800, check: (s) => s.totalRolls >= 1000 },
+ { id: "set_commune", icon: "🟢", name: "Collection commune complète", desc: "Découvre les 12 bananes communes", reward: 80, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "commune").every((b) => s.discovered.includes(b.id)) },
+ { id: "set_peu_commune", icon: "🔵", name: "Collection peu commune complète", desc: "Découvre les 10 bananes peu communes", reward: 120, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "peu_commune").every((b) => s.discovered.includes(b.id)) },
+ { id: "set_rare", icon: "🟣", name: "Collection rare complète", desc: "Découvre les 10 bananes rares", reward: 250, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "rare").every((b) => s.discovered.includes(b.id)) },
+ { id: "set_epique", icon: "🟠", name: "Collection épique complète", desc: "Découvre les 8 bananes épiques", reward: 500, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "epique").every((b) => s.discovered.includes(b.id)) },
+ { id: "set_legendaire", icon: "🟡", name: "Collection légendaire complète", desc: "Découvre les 6 bananes légendaires", reward: 900, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "legendaire").every((b) => s.discovered.includes(b.id)) },
+ { id: "set_mythique", icon: "🌈", name: "Collection mythique complète", desc: "Découvre les 4 bananes mythiques", reward: 2000, check: (s) => NORMAL_BANANAS.filter((b) => b.rarity === "mythique").every((b) => s.discovered.includes(b.id)) },
+ { id: "first_secret", icon: "🕵️", name: "Secret dévoilé", desc: "Découvre ta première banane secrète", reward: 1500, check: (s) => SECRET_BANANAS.some((b) => s.discovered.includes(b.id)) },
+ { id: "set_secret", icon: "👑", name: "Maître des secrets", desc: "Découvre les 10 bananes secrètes", reward: 5000, check: (s) => SECRET_BANANAS.every((b) => s.discovered.includes(b.id)) },
+ { id: "catch_30", icon: "🎯", name: "Bon réflexe", desc: "Attrape au moins 30 bananes en un round", reward: 150, check: (s) => s.catchGame.bestScore >= 30 },
+ { id: "catch_60", icon: "⚡", name: "Réflexes de jungle", desc: "Attrape au moins 60 bananes en un round", reward: 400, check: (s) => s.catchGame.bestScore >= 60 },
+ { id: "streak_7", icon: "🔥", name: "Semaine parfaite", desc: "Connecte-toi 7 jours d'affilée", reward: 500, check: (s) => s.streak.count >= 7 },
+ { id: "shop_maxed", icon: "🛒", name: "Boutique dévalisée", desc: "Monte une amélioration à son niveau maximum", reward: 300, check: (s) => UPGRADES.some((u) => (s.upgrades[u.id] || 0) >= u.maxLevel) },
+ { id: "pve_first_win", icon: "⚔️", name: "Premier combat", desc: "Remporte ta première victoire contre un ananas", reward: 100, check: (s) => s.pve.wins >= 1 },
+ { id: "pve_ananas_king", icon: "🍍", name: "Vainqueur du Roi Ananas", desc: "Bats le Roi Ananas et ouvre la voie vers les autres familles de fruits", reward: 800, check: (s) => s.pve.stage >= 5 },
+ { id: "pve_king", icon: "🏆", name: "Empereur vaincu", desc: "Bats l'Empereur Fruit du Dragon, le boss final de l'arène à 60 niveaux", reward: 5000, check: (s) => s.pve.stage >= FRUIT_ENEMIES.length - 1 },
+];
+
+// Évalue tous les succès, débloque les nouveaux, crédite leur récompense.
+// Retourne la liste des succès nouvellement débloqués (pour affichage).
+function checkAchievements() {
+ const unlockedNow = [];
+ for (const ach of ACHIEVEMENTS) {
+ if (state.achievements.unlocked.includes(ach.id)) continue;
+ if (ach.check(state)) {
+ state.achievements.unlocked.push(ach.id);
+ grantCoins(ach.reward);
+ unlockedNow.push(ach);
+ }
+ }
+ if (unlockedNow.length > 0) saveState();
+ return unlockedNow;
+}
+
+/* ---------------- Combat : l'Arène contre les Ananas ---------------- */
+
+// Statistiques d'attaque/défense dérivées de la rareté (+ variation propre
+// à chaque banane via sa valeur en pièces), pour éviter un système de stats
+// séparé à gérer par le joueur — la banane la plus rare qu'il possède est
+// aussi la plus forte au combat.
+const BANANA_BASE_STATS = {
+ commune: { atk: 5, def: 4 },
+ peu_commune: { atk: 8, def: 6 },
+ rare: { atk: 13, def: 10 },
+ epique: { atk: 20, def: 16 },
+ legendaire: { atk: 30, def: 24 },
+ mythique: { atk: 45, def: 36 },
+ secrete: { atk: 60, def: 50 },
+};
+
+function bananaCombatStats(banana) {
+ const base = BANANA_BASE_STATS[banana.rarity];
+ return {
+ atk: base.atk + Math.floor(banana.value / 8),
+ def: base.def + Math.floor(banana.value / 10),
+ };
+}
+
+// L'arène compte 10 familles de fruits, 6 niveaux chacune (60 au total).
+// Les ananas (famille 0) gardent leurs stats historiques ; chaque famille
+// suivante est strictement plus forte que la précédente — la première Pomme
+// (stade 6) dépasse déjà le Roi Ananas (stade 5).
+const FRUIT_FAMILIES = [
+ { emoji: "🍍", label: "Ananas", names: ["Ananas basique", "Ananas piquant", "Ananas doré", "Ananas de fer", "Ananas légendaire", "Roi Ananas"] },
+ { emoji: "🍎", label: "Pomme", names: ["Pomme sauvage", "Pomme acide", "Pomme dorée", "Pomme de fer", "Pomme légendaire", "Reine Pomme"] },
+ { emoji: "🍊", label: "Clémentine", names: ["Clémentine sauvage", "Clémentine acide", "Clémentine dorée", "Clémentine de fer", "Clémentine légendaire", "Reine Clémentine"] },
+ { emoji: "🍐", label: "Poire", names: ["Poire sauvage", "Poire acide", "Poire dorée", "Poire de fer", "Poire légendaire", "Reine Poire"] },
+ { emoji: "🍓", label: "Fraise", names: ["Fraise sauvage", "Fraise acide", "Fraise dorée", "Fraise de fer", "Fraise légendaire", "Reine Fraise"] },
+ { emoji: "🍇", label: "Raisin", names: ["Raisin sauvage", "Raisin acide", "Raisin doré", "Raisin de fer", "Raisin légendaire", "Roi Raisin"] },
+ { emoji: "🍉", label: "Pastèque", names: ["Pastèque sauvage", "Pastèque acide", "Pastèque dorée", "Pastèque de fer", "Pastèque légendaire", "Reine Pastèque"] },
+ { emoji: "🥝", label: "Kiwi", names: ["Kiwi sauvage", "Kiwi acide", "Kiwi doré", "Kiwi de fer", "Kiwi légendaire", "Roi Kiwi"] },
+ { emoji: "🥭", label: "Mangue", names: ["Mangue sauvage", "Mangue acide", "Mangue dorée", "Mangue de fer", "Mangue légendaire", "Reine Mangue"] },
+ { emoji: "🍈", label: "Fruit du Dragon", names: ["Fruit du Dragon endormi", "Fruit du Dragon enragé", "Fruit du Dragon doré", "Fruit du Dragon de fer", "Fruit du Dragon légendaire", "Empereur Fruit du Dragon"] },
+];
+
+const PINEAPPLE_BASE_STATS = [
+ { atk: 6, def: 5, reward: 15 },
+ { atk: 12, def: 9, reward: 35 },
+ { atk: 22, def: 18, reward: 80 },
+ { atk: 35, def: 30, reward: 160 },
+ { atk: 55, def: 45, reward: 350 },
+ { atk: 80, def: 65, reward: 800 },
+];
+
+const FRUIT_ENEMIES = (() => {
+ const list = [];
+ FRUIT_FAMILIES.forEach((family, f) => {
+ family.names.forEach((name, l) => {
+ const stage = f * 6 + l;
+ let atk, def, reward;
+ if (stage < 6) {
+ ({ atk, def, reward } = PINEAPPLE_BASE_STATS[stage]);
+ } else {
+ const t = stage - 5; // 1..54, progression exponentielle jusqu'au boss final
+ atk = Math.round(80 * Math.pow(37.5, t / 54));
+ def = Math.round(65 * Math.pow(33.85, t / 54));
+ reward = Math.round(800 * Math.pow(150, t / 54));
+ }
+ list.push({ name, emoji: family.emoji, family: f, familyLabel: family.label, atk, def, reward });
+ });
+ });
+ return list;
+})();
+
+// Un ennemi déjà battu reste jouable (pour refarmer des pièces), mais on ne
+// peut pas défier un ennemi plus loin que celui juste après le dernier battu.
+function maxPlayablePveStage() {
+ return Math.min(state.pve.stage + 1, FRUIT_ENEMIES.length - 1);
+}
+
+// Résout un combat en un coup : la chance de victoire dépend du rapport
+// attaque-vs-défense dans les deux sens, avec toujours une petite marge de
+// hasard (jamais 100% garanti, jamais totalement impossible).
+function fightFruitEnemy(bananaId, stageIndex) {
+ const banana = BANANAS_BY_ID[bananaId];
+ if (!banana || !state.discovered.includes(bananaId)) return { ok: false, reason: "banane_inconnue" };
+ if (stageIndex < 0 || stageIndex > maxPlayablePveStage()) return { ok: false, reason: "stage_verrouille" };
+
+ const enemy = FRUIT_ENEMIES[stageIndex];
+ const playerStats = bananaCombatStats(banana);
+ const atkRatio = playerStats.atk / (playerStats.atk + enemy.atk);
+ const defRatio = playerStats.def / (playerStats.def + enemy.def);
+ const strategyBonus = (state.upgrades.strategie || 0) * 0.04;
+ const winChance = Math.min(0.95, Math.max(0.05, atkRatio * 0.5 + defRatio * 0.5 + strategyBonus));
+ const won = Math.random() < winChance;
+
+ let coinsEarned;
+ const stageAdvanced = won && stageIndex === state.pve.stage + 1;
+ const winReward = Math.round(enemy.reward * 0.75);
+ if (won) {
+ coinsEarned = grantCoins(winReward);
+ state.pve.wins += 1;
+ bumpQuestProgress("wins");
+ if (stageAdvanced) state.pve.stage = stageIndex;
+ } else {
+ coinsEarned = grantCoins(Math.round(winReward * 0.08));
+ state.pve.losses += 1;
+ }
+ saveState();
+ return { ok: true, won, coinsEarned, winChance, enemy, playerStats, stageAdvanced };
+}
+
+/* ---------------- Réinitialisation ---------------- */
+
+function resetSave() {
+ state = defaultState();
+ saveState();
+}
diff --git a/banana-collector/cloud.js b/banana-collector/cloud.js
new file mode 100644
index 000000000..b957bdbb5
--- /dev/null
+++ b/banana-collector/cloud.js
@@ -0,0 +1,443 @@
+/* ============================================================
+ Banana Collector — Connexion au cloud (Supabase)
+ Compte joueur (pseudo + mot de passe), synchronisation du solde
+ et de l'inventaire pour le Marché et l'Arène PVP. Le jeu solo
+ (récolte, boutique, mini-jeux, arène solo) ne dépend jamais de ce
+ fichier et continue de fonctionner 100% hors ligne sans compte.
+ ============================================================ */
+
+const SUPABASE_URL = "https://zmbjrhyfofnhsokdveap.supabase.co";
+const SUPABASE_PUBLISHABLE_KEY = "sb_publishable_KwdOnuDXh5Xcfo4kbRZV6g_OvQwJNyq";
+
+// Domaine réservé par la RFC 2606, garanti à ne jamais pouvoir recevoir de
+// vrai courrier : sert à simuler un email pour l'auth Supabase alors que le
+// joueur ne fournit qu'un pseudo + mot de passe, sans email réel.
+const SYNTH_EMAIL_DOMAIN = "banana-collector.invalid";
+const USERNAME_REGEX = /^[a-z0-9_]{3,20}$/;
+
+const CLOUD = (() => {
+ // Si la lib Supabase (CDN) n'a pas pu se charger (bloqueur de pub, réseau
+ // hors ligne au premier chargement...), le compte cloud est simplement
+ // indisponible — le jeu solo n'en dépend jamais, donc il continue de
+ // fonctionner normalement ; seuls Marché/PVP resteront inaccessibles.
+ const supabase = window.supabase ? window.supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY) : null;
+ const unavailable = { ok: false, reason: "supabase_indisponible" };
+
+ let cachedUsername = null;
+ let cachedUserId = null;
+ let pushTimer = null;
+ let lastPushedBananasSnapshot = null;
+
+ function ensureCloudState() {
+ if (!state.cloud) {
+ state.cloud = { linked: false, lastLedgerId: 0 };
+ }
+ return state.cloud;
+ }
+
+ function usernameToEmail(username) {
+ return `${username.toLowerCase()}@${SYNTH_EMAIL_DOMAIN}`;
+ }
+
+ function isValidUsername(username) {
+ return USERNAME_REGEX.test(username);
+ }
+
+ async function isUsernameAvailable(username) {
+ if (!supabase) return false;
+ if (!isValidUsername(username)) return false;
+ const { data, error } = await supabase.rpc("is_username_available", { p_username: username });
+ if (error) throw error;
+ return data === true;
+ }
+
+ async function signUp(username, password) {
+ if (!supabase) return unavailable;
+ if (!isValidUsername(username)) {
+ return { ok: false, reason: "pseudo_invalide" };
+ }
+ const { data, error } = await supabase.auth.signUp({
+ email: usernameToEmail(username),
+ password,
+ options: { data: { username: username.toLowerCase() } },
+ });
+ if (error) return { ok: false, reason: error.message };
+ if (!data.session) {
+ // Ne devrait pas arriver une fois "Confirm email" désactivé côté projet.
+ return { ok: false, reason: "confirmation_email_requise" };
+ }
+ cachedUsername = username.toLowerCase();
+ cachedUserId = data.session.user.id;
+ const cloud = ensureCloudState();
+ cloud.linked = true;
+ saveState();
+ await pullLedger();
+ // Pousse tout de suite (pas de débounce) : un compte fraîchement créé n'a
+ // encore rien poussé côté serveur, il faut que solde/inventaire soient à
+ // jour avant que le joueur tente d'acheter/vendre/attaquer juste après.
+ await pushAll();
+ return { ok: true };
+ }
+
+ async function signIn(username, password) {
+ if (!supabase) return unavailable;
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email: usernameToEmail(username),
+ password,
+ });
+ if (error) return { ok: false, reason: error.message };
+ cachedUsername = username.toLowerCase();
+ cachedUserId = data.session.user.id;
+ const cloud = ensureCloudState();
+ cloud.linked = true;
+ saveState();
+ await pullLedger();
+ // Voir signUp() : on pousse tout de suite pour ne jamais laisser un solde
+ // ou un inventaire périmé côté serveur juste après une connexion.
+ await pushAll();
+ return { ok: true };
+ }
+
+ async function signOut() {
+ if (!supabase) return;
+ await supabase.auth.signOut();
+ cachedUsername = null;
+ cachedUserId = null;
+ const cloud = ensureCloudState();
+ cloud.linked = false;
+ saveState();
+ }
+
+ function isLinked() {
+ return ensureCloudState().linked === true;
+ }
+
+ function currentUserId() {
+ return cachedUserId;
+ }
+
+ function currentUsername() {
+ return cachedUsername;
+ }
+
+ // Applique les événements du journal serveur (vols PVP subis, ventes
+ // conclues...) survenus depuis la dernière fois, en ADDITION du solde
+ // local courant — ne remplace jamais state.coins par la valeur serveur.
+ async function pullLedger() {
+ if (!isLinked()) return;
+ const cloud = ensureCloudState();
+ const { data, error } = await supabase
+ .from("wallet_ledger")
+ .select("id, delta, reason")
+ .gt("id", cloud.lastLedgerId || 0)
+ .order("id", { ascending: true });
+ if (error || !data || data.length === 0) return;
+
+ for (const row of data) {
+ state.coins += row.delta;
+ cloud.lastLedgerId = row.id;
+ }
+ saveState();
+ return data;
+ }
+
+ // Pousse le solde local courant. Si le serveur a des événements plus
+ // récents que ceux déjà vus par ce client (ex: attaque PVP reçue entre le
+ // dernier pull et maintenant), il les renvoie au lieu d'écraser — on les
+ // applique alors localement avant de réessayer, pour ne jamais effacer un
+ // événement serveur avec un solde local périmé.
+ async function pushBalance(attempt = 0) {
+ if (!isLinked() || attempt > 2) return;
+ const cloud = ensureCloudState();
+ const { data, error } = await supabase.rpc("sync_local_balance", {
+ client_coins: state.coins,
+ last_seen_ledger_id: cloud.lastLedgerId || 0,
+ });
+ if (error || !data || data.length === 0) return;
+
+ const { status, ledger_events } = data[0];
+ if (status === "stale" && ledger_events && ledger_events.length > 0) {
+ for (const row of ledger_events) {
+ state.coins += row.delta;
+ cloud.lastLedgerId = Math.max(cloud.lastLedgerId || 0, row.id);
+ }
+ saveState();
+ await pushBalance(attempt + 1);
+ return;
+ }
+ saveState();
+ }
+
+ // Pousse (upsert) l'inventaire local complet — uniquement les entrées
+ // ayant changé depuis le dernier envoi, pour garder les requêtes légères.
+ async function pushBananas() {
+ if (!isLinked()) return;
+ const rows = Object.keys(state.counts)
+ .map((id) => ({ banana_id: Number(id), count: state.counts[id] }))
+ .filter((row) => row.count > 0);
+
+ const snapshotKey = JSON.stringify(rows);
+ if (snapshotKey === lastPushedBananasSnapshot) return;
+
+ const { error } = await supabase.rpc("sync_local_bananas", { rows });
+ if (!error) lastPushedBananasSnapshot = snapshotKey;
+ }
+
+ // Pousse (écrase) la progression PVE locale — même logique que
+ // pushBananas : l'état local est la source de vérité, jamais additif.
+ let lastPushedPveSnapshot = null;
+ async function pushPve() {
+ if (!isLinked()) return;
+ const snapshotKey = JSON.stringify(state.pve);
+ if (snapshotKey === lastPushedPveSnapshot) return;
+
+ const { error } = await supabase.rpc("sync_local_pve", {
+ p_stage: state.pve.stage,
+ p_wins: state.pve.wins,
+ p_losses: state.pve.losses,
+ });
+ if (!error) lastPushedPveSnapshot = snapshotKey;
+ }
+
+ async function pushAll() {
+ await Promise.all([pushBalance(), pushBananas(), pushPve()]);
+ }
+
+ // Le bouton "Réinitialiser la sauvegarde" ne touchait que le local — un
+ // compte cloud lié gardait son ancien solde/inventaire/PVE en base, ce qui
+ // laissait le classement figé sur les anciennes stats après un reset.
+ async function resetCloudProgress() {
+ if (!isLinked()) return;
+ const { data, error } = await supabase.rpc("reset_cloud_progress");
+ if (error) return;
+ const cloud = ensureCloudState();
+ cloud.lastLedgerId = (data && data[0] && data[0].max_ledger_id) || 0;
+ saveState();
+ lastPushedBananasSnapshot = null;
+ lastPushedPveSnapshot = null;
+ }
+
+ /* ---------------- Classement ---------------- */
+
+ // Lecture publique (pas besoin de compte pour consulter) : agrège
+ // collection/PVP/PVE de tous les joueurs ayant un compte cloud.
+ async function fetchLeaderboard() {
+ if (!supabase) return [];
+ const { data, error } = await supabase.rpc("get_leaderboard");
+ return error || !data ? [] : data;
+ }
+
+ /* ---------------- Marché ---------------- */
+
+ // Annonces actives de tout le monde, avec le pseudo du vendeur récupéré
+ // séparément via la vue publique (pas de embedding PostgREST sur une vue).
+ async function fetchActiveListings() {
+ if (!supabase) return [];
+ const { data: listings, error } = await supabase
+ .from("listings")
+ .select("id, seller_id, banana_id, quantity, unit_price, created_at")
+ .eq("status", "active")
+ .order("created_at", { ascending: false })
+ .limit(200);
+ if (error || !listings) return [];
+
+ const sellerIds = [...new Set(listings.map((l) => l.seller_id))];
+ let usernames = {};
+ if (sellerIds.length > 0) {
+ const { data: profiles } = await supabase.from("public_profiles").select("id, username").in("id", sellerIds);
+ if (profiles) usernames = Object.fromEntries(profiles.map((p) => [p.id, p.username]));
+ }
+ return listings.map((l) => ({ ...l, sellerUsername: usernames[l.seller_id] || "?" }));
+ }
+
+ // Historique complet (actives/vendues/annulées) du joueur connecté.
+ async function fetchMyListings() {
+ if (!supabase || !isLinked() || !cachedUserId) return [];
+ const { data, error } = await supabase
+ .from("listings")
+ .select("id, banana_id, quantity, unit_price, status, created_at")
+ .eq("seller_id", cachedUserId)
+ .order("created_at", { ascending: false })
+ .limit(100);
+ return error || !data ? [] : data;
+ }
+
+ async function createListing(bananaId, quantity, unitPrice) {
+ if (!supabase) return unavailable;
+ const { data, error } = await supabase.rpc("create_listing", {
+ p_banana_id: bananaId,
+ p_quantity: quantity,
+ p_unit_price: unitPrice,
+ });
+ if (error) return { ok: false, reason: error.message };
+ return { ok: true, listingId: data };
+ }
+
+ async function cancelListing(listingId) {
+ if (!supabase) return unavailable;
+ const { error } = await supabase.rpc("cancel_listing", { p_listing_id: listingId });
+ if (error) return { ok: false, reason: error.message };
+ return { ok: true };
+ }
+
+ async function buyListing(listingId, quantityWanted) {
+ if (!supabase) return unavailable;
+ const { data, error } = await supabase.rpc("buy_listing", {
+ p_listing_id: listingId,
+ p_quantity_wanted: quantityWanted,
+ });
+ if (error) return { ok: false, reason: error.message };
+ return { ok: true, newCoins: data && data[0] ? Number(data[0].new_coins) : null };
+ }
+
+ /* ---------------- Arène PVP ---------------- */
+
+ async function setDefenseTeam(bananaIds) {
+ if (!supabase) return unavailable;
+ const { error } = await supabase.rpc("set_defense_team", { p_banana_ids: bananaIds });
+ if (error) return { ok: false, reason: error.message };
+ return { ok: true };
+ }
+
+ async function fetchMyDefenseTeam() {
+ if (!supabase || !isLinked() || !cachedUserId) return null;
+ const { data, error } = await supabase
+ .from("defense_teams")
+ .select("banana_ids")
+ .eq("player_id", cachedUserId)
+ .maybeSingle();
+ return error || !data ? null : data.banana_ids;
+ }
+
+ async function findOpponent() {
+ if (!supabase) return unavailable;
+ const { data, error } = await supabase.rpc("find_opponent");
+ if (error) return { ok: false, reason: error.message };
+ if (!data || data.length === 0) return { ok: false, reason: "aucun_adversaire" };
+ const row = data[0];
+ return { ok: true, defenderId: row.defender_id, username: row.username, power: row.power };
+ }
+
+ async function attackPlayer(defenderId) {
+ if (!supabase) return unavailable;
+ const { data, error } = await supabase.rpc("attack_player", { p_defender_id: defenderId });
+ if (error) return { ok: false, reason: error.message };
+ if (!data || data.length === 0) return { ok: false, reason: "erreur_inconnue" };
+ const row = data[0];
+ return {
+ ok: true,
+ won: row.won,
+ attackerDelta: Number(row.attacker_delta),
+ defenderDelta: Number(row.defender_delta),
+ attackerPower: row.attacker_power,
+ defenderPower: row.defender_power,
+ };
+ }
+
+ // Combats reçus (en tant que défenseur) pas encore consultés — flux
+ // "pendant ton absence" affiché à l'ouverture de l'onglet PVP.
+ async function fetchUnseenCombatReports() {
+ if (!supabase || !isLinked() || !cachedUserId) return [];
+ const { data, error } = await supabase
+ .from("combat_log")
+ .select("id, attacker_id, attacker_win, defender_delta, created_at")
+ .eq("defender_id", cachedUserId)
+ .eq("seen_by_defender", false)
+ .order("created_at", { ascending: true })
+ .limit(50);
+ if (error || !data || data.length === 0) return [];
+
+ const attackerIds = [...new Set(data.map((r) => r.attacker_id))];
+ let usernames = {};
+ if (attackerIds.length > 0) {
+ const { data: profiles } = await supabase.from("public_profiles").select("id, username").in("id", attackerIds);
+ if (profiles) usernames = Object.fromEntries(profiles.map((p) => [p.id, p.username]));
+ }
+ return data.map((r) => ({ ...r, attackerUsername: usernames[r.attacker_id] || "?" }));
+ }
+
+ async function markCombatLogSeen(ids) {
+ if (!supabase || ids.length === 0) return;
+ await supabase.rpc("mark_combat_log_seen", { p_ids: ids });
+ }
+
+ // Synchronisation débounced : appelée librement par le reste du jeu à
+ // chaque action pertinente (achat, vente, fin de combat...) sans jamais
+ // ralentir l'action elle-même — la requête réseau part quelques secondes
+ // plus tard, en arrière-plan.
+ function scheduleSync(delayMs = 4000) {
+ if (!isLinked()) return;
+ clearTimeout(pushTimer);
+ pushTimer = setTimeout(() => {
+ pushAll().catch(() => {
+ // Échec réseau : no-op silencieux, retentera au prochain déclencheur.
+ });
+ }, delayMs);
+ }
+
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden" && isLinked()) {
+ pushAll().catch(() => {});
+ }
+ });
+
+ async function init() {
+ if (!supabase) return;
+ const { data } = await supabase.auth.getSession();
+ if (data.session) {
+ const meta = data.session.user.user_metadata || {};
+ cachedUsername = (meta.username || "").toLowerCase() || null;
+ cachedUserId = data.session.user.id;
+ const cloud = ensureCloudState();
+ cloud.linked = true;
+ saveState();
+ try {
+ await pullLedger();
+ // Voir signUp() : un joueur qui revient a pu jouer en solo hors
+ // ligne depuis sa dernière visite — pousse tout de suite pour que
+ // Marché/PVP voient son vrai solde/inventaire sans attendre.
+ await pushAll();
+ } catch (e) {
+ // Hors ligne au démarrage : le jeu solo continue normalement,
+ // on retentera au prochain déclencheur réseau.
+ }
+ }
+ }
+
+ return {
+ available: supabase !== null,
+ supabase,
+ isValidUsername,
+ isUsernameAvailable,
+ signUp,
+ signIn,
+ signOut,
+ isLinked,
+ currentUsername,
+ currentUserId,
+ pullLedger,
+ pushBalance,
+ pushBananas,
+ pushPve,
+ pushAll,
+ resetCloudProgress,
+ scheduleSync,
+ fetchLeaderboard,
+ fetchActiveListings,
+ fetchMyListings,
+ createListing,
+ cancelListing,
+ buyListing,
+ setDefenseTeam,
+ fetchMyDefenseTeam,
+ findOpponent,
+ attackPlayer,
+ fetchUnseenCombatReports,
+ markCombatLogSeen,
+ init,
+ };
+})();
+
+// L'appel réel se fait depuis ui.js (attendu avant le premier rendu de l'en-tête
+// et du bouton compte), pour éviter une course entre ce chargement asynchrone
+// et le rendu initial synchrone du DOMContentLoaded de ui.js.
diff --git a/banana-collector/data.js b/banana-collector/data.js
new file mode 100644
index 000000000..bb8948df5
--- /dev/null
+++ b/banana-collector/data.js
@@ -0,0 +1,796 @@
+/* ============================================================
+ Banana Collector — Données du jeu
+ Raretés, table de bananes (100 normales + 6 secrètes)
+ ============================================================ */
+
+// Ordre du plus commun au plus rare (utilisé pour comparer les raretés)
+const RARITY_ORDER = [
+ "commune",
+ "peu_commune",
+ "rare",
+ "epique",
+ "legendaire",
+ "mythique",
+ "secrete",
+];
+
+const RARITIES = {
+ commune: { label: "Commune", color: "#9e9e9e", glow: "#c9c9c9", weight: 50 },
+ peu_commune: { label: "Peu commune", color: "#4caf50", glow: "#7be08a", weight: 27 },
+ rare: { label: "Rare", color: "#2196f3", glow: "#6fc3ff", weight: 14 },
+ epique: { label: "Épique", color: "#9c27b0", glow: "#e08bfb", weight: 6 },
+ legendaire: { label: "Légendaire", color: "#ff9800", glow: "#ffcf7a", weight: 2.5 },
+ mythique: { label: "Mythique", color: "#f43f8e", glow: "#ff9fd0", weight: 0.4 },
+ secrete: { label: "Secrète", color: "#111827", glow: "#ffffff", weight: 0.1 },
+};
+
+function rarityIndex(key) {
+ return RARITY_ORDER.indexOf(key);
+}
+
+function isRareOrAbove(key) {
+ return rarityIndex(key) >= rarityIndex("rare");
+}
+
+function isLegendaryOrAbove(key) {
+ return rarityIndex(key) >= rarityIndex("legendaire");
+}
+
+// Valeur en pièces générée de façon déterministe selon la rareté et la position dans la rareté
+function valueFor(rarity, indexInRarity) {
+ const table = {
+ commune: [3, 5, 3, 6, 5, 6, 3, 5, 6, 3, 5, 6, 4, 6, 3, 5, 4, 6, 3, 5, 4, 6, 3, 5, 5],
+ peu_commune: [10, 13, 14, 11, 16, 14, 13, 16, 13, 14, 11, 15, 12, 16, 10, 14, 12, 15, 11, 13],
+ rare: [24, 29, 32, 35, 27, 30, 34, 29, 27, 37, 26, 33, 28, 36, 25, 31, 29, 34, 27, 32],
+ epique: [64, 70, 77, 83, 67, 74, 80, 86, 68, 75, 82, 66, 72, 79, 85, 69, 76, 84, 71, 65],
+ legendaire: [190, 210, 230, 250, 205, 225, 200, 220, 240, 195],
+ mythique: [640, 770, 960, 1150, 700],
+ secrete: [3200, 3500, 3800, 4200, 4500, 4800],
+ };
+ return table[rarity][indexInRarity] || 10;
+}
+
+/* ------------------------------------------------------------
+ Décorations : au lieu de coller un second emoji à côté de la
+ banane (ex. 🍌🥷), chaque accessoire est dessiné en CSS et posé
+ DIRECTEMENT sur le glyphe 🍌, pour un visuel fusionné — une seule
+ banane qui porte un bandeau, un chapeau, une cape...
+
+ Chaque accessoire a un `type` qui pioche dans un petit catalogue
+ de formes déjà stylées (dégradé, ombre, bords adoucis) défini une
+ fois pour toutes dans style.css — jamais de rectangle plat ou de
+ trait brut posé tel quel :
+ - "band" bandeau/visière/cape/ruban (barre arrondie)
+ - "peak-up" / "peak-down" pointe vers le haut / le bas
+ - "peak-out-left" / "peak-out-right" pointe vers l'extérieur (aile, corne, croc...)
+ - "orb" perle/oeil/bouton (rond, effet verre)
+ - "ring" anneau (halo, lunettes, orbite)
+ - "bubble" bulle de dialogue
+ - "text" un petit emoji en badge (ex. ⚡, 🏆)
+ `color` (une teinte, dégradé auto clair→sombre) ou `colors: [c1,c2]`
+ (dégradé personnalisé) définissent la couleur ; `style` ne sert
+ plus qu'au positionnement (top/left/width/height/transform...).
+ Voir bananaIconHTML() dans ui.js pour le rendu.
+ ------------------------------------------------------------ */
+
+// Chaque entrée : { id, name, rarity, emoji, deco? }
+// `id` est figé pour toujours : le Marché et l'Arène PVP (base de données
+// externe Supabase) stockent des références à ces ids. Règle définitive à
+// partir de maintenant : on n'ajoute qu'à LA FIN avec un id = max actuel + 1,
+// on ne réordonne jamais, on ne supprime jamais et on ne réutilise jamais un
+// id existant — sinon les annonces du marché, les équipes de défense et
+// l'historique de combat des joueurs se retrouveraient désynchronisés.
+const BANANA_DEFS = [
+ // ================= Commune (24, + id 111 en fin de fichier) =================
+ { id: 1, name: "Banane verte", rarity: "commune", image: "images/banana_1.png", emoji: "🍌" },
+ {
+ id: 2, name: "Banane rouge", rarity: "commune", image: "images/banana_2.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(70deg) saturate(1.25) brightness(0.98)" },
+ },
+ { id: 3, name: "Banane bleue", rarity: "commune", image: "images/banana_3.png", emoji: "🍌", deco: { scale: 0.72 } },
+ {
+ id: 4, name: "Banane orange", rarity: "commune", image: "images/banana_4.png", emoji: "🍌",
+ deco: {
+ filter: "sepia(0.5) saturate(1.3) brightness(0.9)",
+ accessories: [
+ { type: "orb", color: "#5c3b1e", style: "left:30%; top:55%; width:10%; height:10%; opacity:.7;" },
+ { type: "orb", color: "#5c3b1e", style: "right:28%; top:38%; width:8%; height:8%; opacity:.6;" },
+ ],
+ },
+ },
+ {
+ id: 5, name: "Banane noire", rarity: "commune", image: "images/banana_5.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "☀️", style: "top:-14%; right:-10%; font-size:.5em;" }] },
+ },
+ { id: 6, name: "Petite banane", rarity: "commune", image: "images/banana_6.png", emoji: "🍌" },
+ {
+ id: 7, name: "Banane mûre", rarity: "commune", image: "images/banana_7.png", emoji: "🍌",
+ deco: { accessories: [{ type: "band", color: "#e8c88a", style: "left:70%; top:60%; width:26%; height:18%; transform:rotate(18deg);" }] },
+ },
+ {
+ id: 8, name: "Banane du petit-déjeuner", rarity: "commune", image: "images/banana_8.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.1)",
+ accessories: [{ type: "peak-out-left", colors: ["#7ee08a", "#4cc26b"], style: "left:44%; width:16%; top:-16%; height:14%;" }],
+ },
+ },
+ {
+ id: 9, name: "Banane du marché", rarity: "commune", image: "images/banana_9.png", emoji: "🍌",
+ deco: { scale: 0.8, containerStyle: "border:2px dashed #b98b3e; border-radius:14px; box-shadow: inset 0 0 6px rgba(185,139,62,.25);" },
+ },
+ {
+ id: 10, name: "Banane bio", rarity: "commune", image: "images/banana_10.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🕓", style: "bottom:-12%; left:-12%; font-size:.42em;" }] },
+ },
+ { id: 11, name: "Banane de poche", rarity: "commune", image: "images/banana_11.png", emoji: "🍌" },
+ {
+ id: 12, name: "Banane du goûter", rarity: "commune", image: "images/banana_12.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#c81d25", style: "left:44%; top:-14%; width:5%; height:14%;" },
+ { type: "peak-out-left", color: "#c81d25", style: "left:36%; top:-10%; width:9%; height:9%;" },
+ { type: "peak-out-right", color: "#c81d25", style: "right:36%; top:-10%; width:9%; height:9%;" },
+ ],
+ },
+ },
+ {
+ id: 13, name: "Banane qui dort", rarity: "commune", image: "images/banana_13.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "😴", style: "top:-12%; right:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 14, name: "Banane câline", rarity: "commune", image: "images/banana_14.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🧡", style: "top:-12%; left:-10%; font-size:.42em;" }] },
+ },
+ {
+ id: 15, name: "Banane voyageuse", rarity: "commune", image: "images/banana_15.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🧭", style: "bottom:-10%; right:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 16, name: "Banane écolière", rarity: "commune", image: "images/banana_16.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🎓", style: "top:-14%; left:-8%; font-size:.48em;" }] },
+ },
+ {
+ id: 17, name: "Banane sportive", rarity: "commune", image: "images/banana_17.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "⚽", style: "bottom:-10%; left:-10%; font-size:.44em;" }] },
+ },
+ {
+ id: 18, name: "Banane musicienne", rarity: "commune", image: "images/banana_18.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🎵", style: "top:-12%; right:-8%; font-size:.46em;" }] },
+ },
+ {
+ id: 19, name: "Banane artiste", rarity: "commune", image: "images/banana_19.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🎨", style: "bottom:-10%; right:-12%; font-size:.46em;" }] },
+ },
+ {
+ id: 20, name: "Banane pressée", rarity: "commune", image: "images/banana_20.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "💨", style: "left:-14%; top:40%; font-size:.5em;" }] },
+ },
+ {
+ id: 21, name: "Banane curieuse", rarity: "commune", image: "images/banana_21.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🔍", style: "top:-10%; right:-12%; font-size:.46em;" }] },
+ },
+ {
+ id: 22, name: "Banane bricoleuse", rarity: "commune", image: "images/banana_22.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🔧", style: "bottom:-8%; left:-12%; font-size:.44em;" }] },
+ },
+ {
+ id: 23, name: "Banane heureuse", rarity: "commune", image: "images/banana_23.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🍴", style: "bottom:-10%; right:-10%; font-size:.44em;" }] },
+ },
+ {
+ id: 24, name: "Banane gourmande", rarity: "commune", image: "images/banana_24.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "☁️", style: "top:-14%; left:-10%; font-size:.5em;" }] },
+ },
+
+ // ================= Peu commune (20) =================
+ {
+ id: 25, name: "Banane tachetée", rarity: "peu_commune", image: "images/banana_25.png", emoji: "🍌",
+ deco: {
+ filter: "sepia(0.15)",
+ accessories: [
+ { type: "orb", color: "#6b4a23", style: "left:32%; top:34%; width:9%; height:9%; opacity:.65;" },
+ { type: "orb", color: "#6b4a23", style: "left:55%; top:52%; width:7%; height:7%; opacity:.6;" },
+ { type: "orb", color: "#6b4a23", style: "left:42%; top:65%; width:8%; height:8%; opacity:.55;" },
+ ],
+ },
+ },
+ {
+ id: 26, name: "Banane pompier", rarity: "peu_commune", image: "images/banana_26.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(-48deg) saturate(1.6) brightness(0.95) drop-shadow(0 0 3px rgba(255,70,70,.35))" },
+ },
+ {
+ id: 27, name: "Banane plantain", rarity: "peu_commune", image: "images/banana_27.png", emoji: "🍌",
+ deco: { filter: "sepia(0.35) hue-rotate(25deg) saturate(0.9) brightness(0.92)" },
+ },
+ { id: 28, name: "Banane cycliste", rarity: "peu_commune", image: "images/banana_28.png", emoji: "🍌", deco: { transform: "rotate(22deg)" } },
+ {
+ id: 29, name: "Banane pelée", rarity: "peu_commune", image: "images/banana_29.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "orb", color: "#fff8e6", style: "left:30%; top:32%; width:8%; height:8%; border:1px solid #d7b23a;" },
+ { type: "orb", color: "#fff8e6", style: "left:58%; top:44%; width:7%; height:7%; border:1px solid #d7b23a;" },
+ { type: "orb", color: "#fff8e6", style: "left:40%; top:60%; width:7%; height:7%; border:1px solid #d7b23a;" },
+ ],
+ },
+ },
+ { id: 30, name: "Banane XXL", rarity: "peu_commune", image: "images/banana_30.png", emoji: "🍌", deco: { scale: 1.16 } },
+ {
+ id: 31, name: "Banane parfumée", rarity: "peu_commune", image: "images/banana_31.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🌸", style: "top:-14%; left:-12%; font-size:.48em;" }] },
+ },
+ {
+ id: 32, name: "Banane fondante", rarity: "peu_commune", image: "images/banana_32.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "💥", style: "top:-10%; right:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 33, name: "Banane zébrée", rarity: "peu_commune", image: "images/banana_33.png", emoji: "🍌",
+ deco: { filter: "brightness(1.05)", accessories: [{ type: "text", text: "✨", style: "top:-12%; right:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 34, name: "Banane givrée", rarity: "peu_commune", image: "images/banana_34.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#c9992f", style: "left:15%; right:35%; top:30%; height:8%; opacity:.6; transform:rotate(-25deg);" },
+ { type: "band", color: "#c9992f", style: "left:25%; right:25%; top:48%; height:8%; opacity:.6; transform:rotate(-25deg);" },
+ { type: "band", color: "#c9992f", style: "left:35%; right:15%; top:66%; height:8%; opacity:.6; transform:rotate(-25deg);" },
+ ],
+ },
+ },
+ {
+ id: 35, name: "Banane épicée", rarity: "peu_commune", image: "images/banana_35.png", emoji: "🍌",
+ deco: { filter: "sepia(0.55) saturate(1.6) brightness(0.95)" },
+ },
+ {
+ id: 36, name: "Banane fumée", rarity: "peu_commune", image: "images/banana_36.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(120deg) saturate(0.9) brightness(1.1)" },
+ },
+ {
+ id: 37, name: "Banane coussin", rarity: "peu_commune", image: "images/banana_37.png", emoji: "🍌",
+ deco: { filter: "saturate(0.25) brightness(0.85) contrast(1.05)" },
+ },
+ {
+ id: 38, name: "Banane veloutée", rarity: "peu_commune", image: "images/banana_38.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(-30deg) saturate(1.5)", accessories: [{ type: "text", text: "🌶️", style: "top:-10%; right:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 39, name: "Banane pailletée", rarity: "peu_commune", image: "images/banana_39.png", emoji: "🍌",
+ deco: { accessories: [{ type: "text", text: "🧂", style: "top:-12%; left:-10%; font-size:.46em;" }] },
+ },
+ {
+ id: 40, name: "Banane clown", rarity: "peu_commune", image: "images/banana_40.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(35deg) saturate(1.4)", accessories: [{ type: "text", text: "🍋", style: "bottom:-10%; right:-10%; font-size:.44em;" }] },
+ },
+ {
+ id: 41, name: "Banane policier", rarity: "peu_commune", image: "images/banana_41.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(260deg) saturate(0.7) brightness(1.05)" },
+ },
+ {
+ id: 42, name: "Banane plombier", rarity: "peu_commune", image: "images/banana_42.png", emoji: "🍌",
+ deco: { filter: "brightness(1.15) saturate(1.3) drop-shadow(0 0 3px #fff3c4)", accessories: [{ type: "text", text: "✨", style: "top:-12%; right:-8%; font-size:.46em;" }] },
+ },
+ {
+ id: 43, name: "Banane moustachue", rarity: "peu_commune", image: "images/banana_43.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "orb", color: "#e63946", style: "left:28%; top:30%; width:8%; height:8%;" },
+ { type: "orb", color: "#2196f3", style: "left:55%; top:42%; width:7%; height:7%;" },
+ { type: "orb", color: "#4caf50", style: "left:38%; top:58%; width:7%; height:7%;" },
+ { type: "orb", color: "#ffd23f", style: "left:60%; top:62%; width:6%; height:6%;" },
+ ],
+ },
+ },
+ {
+ id: 44, name: "Banane aveugle", rarity: "peu_commune", image: "images/banana_44.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#1a1a1a", style: "left:15%; right:35%; top:28%; height:9%; transform:rotate(-20deg);" },
+ { type: "band", color: "#1a1a1a", style: "left:25%; right:25%; top:48%; height:9%; transform:rotate(-20deg);" },
+ { type: "band", color: "#1a1a1a", style: "left:35%; right:15%; top:66%; height:9%; transform:rotate(-20deg);" },
+ ],
+ },
+ },
+
+ // ================= Rare (20) =================
+ {
+ id: 45, name: "Banane géante", rarity: "rare", image: "images/banana_45.png", emoji: "🍌",
+ deco: { filter: "drop-shadow(0 4px 2px rgba(0,0,0,.35))", scale: 1.22 },
+ },
+ {
+ id: 46, name: "Banane enflammée", rarity: "rare", image: "images/banana_46.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(150deg) saturate(1.3) brightness(1.05) drop-shadow(0 0 4px #8fd8ff)" },
+ },
+ {
+ id: 47, name: "Banane des enfers", rarity: "rare", image: "images/banana_47.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(-25deg) saturate(1.6) drop-shadow(0 0 5px #ff5a1f)" },
+ },
+ {
+ id: 48, name: "Banane ninja", rarity: "rare", image: "images/banana_48.png", emoji: "🍌",
+ deco: {
+ filter: "brightness(0.97)",
+ accessories: [
+ { type: "band", color: "#1a1a1a", style: "left:10%; right:10%; top:32%; height:16%; transform:rotate(-6deg);" },
+ { type: "peak-out-left", color: "#1a1a1a", style: "right:4%; top:32%; width:11%; height:12%; transform:rotate(-6deg);" },
+ ],
+ },
+ },
+ {
+ id: 49, name: "Banane robotique", rarity: "rare", image: "images/banana_49.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(0.7) brightness(1.05)",
+ accessories: [
+ { type: "band", color: "#6b7f8f", style: "left:12%; right:12%; top:34%; height:14%;" },
+ { type: "band", color: "#5b6b78", style: "left:48%; width:4%; top:0%; height:16%;" },
+ { type: "orb", color: "#ff5a5a", style: "left:44%; width:12%; height:12%; top:-8%;" },
+ ],
+ },
+ },
+ {
+ id: 50, name: "Banane cristal", rarity: "rare", image: "images/banana_50.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(180deg) saturate(1.4) brightness(1.15) drop-shadow(0 0 5px #c9a8ff)" },
+ },
+ {
+ id: 51, name: "Banane électrique", rarity: "rare", image: "images/banana_51.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.5) brightness(1.2) drop-shadow(0 0 5px #fff176)",
+ accessories: [{ type: "text", text: "⚡", style: "top:-8%; right:-10%; font-size:0.85em;" }],
+ },
+ },
+ {
+ id: 52, name: "Banane musclée", rarity: "rare", image: "images/banana_52.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.1) brightness(0.98)",
+ accessories: [
+ { type: "orb", color: "#d9a066", style: "left:-10%; top:38%; width:22%; height:22%;" },
+ { type: "orb", color: "#d9a066", style: "right:-10%; top:38%; width:22%; height:22%;" },
+ ],
+ },
+ },
+ {
+ id: 53, name: "Banane pirate", rarity: "rare", image: "images/banana_53.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "peak-up", color: "#2a2a2a", style: "left:20%; right:20%; top:-10%; height:26%;" },
+ { type: "orb", color: "#111", style: "left:30%; top:36%; width:22%; height:22%;" },
+ ],
+ },
+ },
+ {
+ id: 54, name: "Banane sorcière", rarity: "rare", image: "images/banana_54.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.1) brightness(0.9)",
+ accessories: [
+ { type: "band", color: "#7a0f1f", style: "left:5%; right:5%; bottom:-6%; height:16%;" },
+ { type: "peak-down", color: "#fff", style: "left:40%; bottom:20%; width:9%; height:14%;" },
+ { type: "peak-down", color: "#fff", style: "left:52%; bottom:20%; width:9%; height:14%;" },
+ ],
+ },
+ },
+ {
+ id: 55, name: "Banane vampire", rarity: "rare", image: "images/banana_55.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#8a95a5", style: "left:10%; right:10%; top:32%; height:16%;" },
+ { type: "peak-up", color: "#8a95a5", style: "left:42%; right:42%; top:-8%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 56, name: "Banane chevalier", rarity: "rare", image: "images/banana_56.png", emoji: "🍌",
+ deco: { accessories: [{ type: "peak-up", color: "#3a2a52", style: "left:26%; right:26%; top:-26%; height:36%;" }] },
+ },
+ {
+ id: 57, name: "Banane bûcheron", rarity: "rare", image: "images/banana_57.png", emoji: "🍌",
+ deco: { accessories: [{ type: "band", color: "#b3312c", style: "left:8%; right:8%; top:30%; height:16%;" }] },
+ },
+ {
+ id: 58, name: "Banane cow-boy", rarity: "rare", image: "images/banana_58.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "peak-up", color: "#8a5a2b", style: "left:16%; right:16%; top:-14%; height:22%;" },
+ { type: "band", color: "#5c3a17", style: "left:22%; right:22%; top:2%; height:8%;" },
+ ],
+ },
+ },
+ {
+ id: 59, name: "Banane astronaute", rarity: "rare", image: "images/banana_59.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "ring", color: "#dff3ff", style: "left:14%; right:14%; top:16%; height:60%;" },
+ { type: "band", color: "#c7cdd3", style: "left:20%; right:20%; bottom:-8%; height:10%;" },
+ ],
+ },
+ },
+ {
+ id: 60, name: "Banane zombie", rarity: "rare", image: "images/banana_60.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "orb", color: "#e63946", style: "left:40%; top:48%; width:18%; height:18%;" },
+ { type: "peak-up", color: "#ff9f1c", style: "left:6%; top:-6%; width:16%; height:16%; transform:rotate(-25deg);" },
+ { type: "peak-up", color: "#ff9f1c", style: "right:6%; top:-6%; width:16%; height:16%; transform:rotate(25deg);" },
+ ],
+ },
+ },
+ {
+ id: 61, name: "Banane momie", rarity: "rare", image: "images/banana_61.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#e9e2cf", style: "left:8%; right:8%; top:20%; height:10%; transform:rotate(-8deg);" },
+ { type: "band", color: "#e9e2cf", style: "left:12%; right:12%; top:42%; height:10%; transform:rotate(6deg);" },
+ { type: "band", color: "#e9e2cf", style: "left:10%; right:10%; top:64%; height:10%; transform:rotate(-5deg);" },
+ ],
+ },
+ },
+ {
+ id: 62, name: "Banane requin", rarity: "rare", image: "images/banana_62.png", emoji: "🍌",
+ deco: {
+ filter: "hue-rotate(70deg) saturate(1.3) brightness(0.85)",
+ accessories: [{ type: "band", color: "#c9c2a8", style: "left:20%; top:44%; width:26%; height:9%; transform:rotate(-15deg);" }],
+ },
+ },
+ {
+ id: 63, name: "Banane extraterrestre", rarity: "rare", image: "images/banana_63.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(0.6) brightness(1.05)",
+ accessories: [{ type: "peak-up", color: "#8a97a3", style: "left:38%; right:38%; top:-16%; height:22%;" }],
+ },
+ },
+ {
+ id: 64, name: "Banane magnat", rarity: "rare", image: "images/banana_64.png", emoji: "🍌",
+ deco: {
+ filter: "hue-rotate(100deg) saturate(1.2) brightness(1.05)",
+ accessories: [
+ { type: "orb", color: "#111", style: "left:26%; top:34%; width:18%; height:14%;" },
+ { type: "orb", color: "#111", style: "right:26%; top:34%; width:18%; height:14%;" },
+ ],
+ },
+ },
+
+ // ================= Épique (16, + ids 112-115 en fin de fichier) =================
+ {
+ id: 65, name: "Banane dorée", rarity: "epique", image: "images/banana_65.png", emoji: "🍌",
+ deco: { filter: "sepia(0.6) saturate(2) hue-rotate(-10deg) brightness(1.1) drop-shadow(0 0 5px #ffdb70)" },
+ },
+ {
+ id: 66, name: "Banane diamant", rarity: "epique", image: "images/banana_66.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(190deg) saturate(0.5) brightness(1.3) drop-shadow(0 0 6px #d8f3ff)" },
+ },
+ {
+ id: 67, name: "Banane saphir", rarity: "epique", image: "images/banana_67.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#ffd23f", style: "left:16%; right:16%; top:-8%; height:12%;" },
+ { type: "peak-up", color: "#ffd23f", style: "left:38%; right:38%; top:-18%; height:14%;" },
+ ],
+ },
+ },
+ {
+ id: 68, name: "Banane royale", rarity: "epique", image: "images/banana_68.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.1)",
+ accessories: [
+ { type: "peak-up", color: "#7a3fc4", style: "left:28%; right:28%; top:-24%; height:34%;" },
+ { type: "text", text: "✨", style: "top:-24%; left:56%; font-size:0.5em;" },
+ ],
+ },
+ },
+ {
+ id: 69, name: "Banane magique", rarity: "epique", image: "images/banana_69.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(220deg) saturate(1.3) brightness(0.9) drop-shadow(0 0 6px #8a6bff)" },
+ },
+ {
+ id: 70, name: "Banane chat", rarity: "epique", image: "images/banana_70.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.2) brightness(1.05)",
+ accessories: [{ type: "peak-up", colors: ["#ffd6f5", "#c9a8ff"], style: "left:42%; width:16%; top:-16%; height:20%;" }],
+ },
+ },
+ {
+ id: 71, name: "Banane galactique", rarity: "epique", image: "images/banana_71.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#f5f0e6", style: "left:8%; right:8%; top:30%; height:15%; transform:rotate(-6deg);" },
+ { type: "orb", color: "#c81d25", style: "left:46%; top:32%; width:12%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 72, name: "Banane licorne", rarity: "epique", image: "images/banana_72.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.3) drop-shadow(0 0 5px #ff9a4d)",
+ accessories: [
+ { type: "peak-out-left", colors: ["#ffb84d", "#ff7a1a"], style: "left:-16%; top:28%; width:20%; height:30%;" },
+ { type: "peak-out-right", colors: ["#ffb84d", "#ff7a1a"], style: "right:-16%; top:28%; width:20%; height:30%;" },
+ ],
+ },
+ },
+ {
+ id: 73, name: "Banane samouraï", rarity: "epique", image: "images/banana_73.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#c9a8ff", style: "left:16%; right:16%; top:-8%; height:12%;" },
+ { type: "peak-up", color: "#c9a8ff", style: "left:38%; right:38%; top:-18%; height:14%;" },
+ { type: "ring", color: "#ffd23f", style: "left:30%; top:36%; width:12%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 74, name: "Banane phénix", rarity: "epique", image: "images/banana_74.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "peak-out-left", colors: ["#ffb84d", "#c81d25"], style: "left:-16%; top:26%; width:22%; height:32%;" },
+ { type: "peak-out-right", colors: ["#ffb84d", "#c81d25"], style: "right:-16%; top:26%; width:22%; height:32%;" },
+ { type: "peak-up", color: "#c81d25", style: "left:40%; right:40%; top:-10%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 75, name: "Banane Cléopâtre", rarity: "epique", image: "images/banana_75.png", emoji: "🍌",
+ deco: {
+ filter: "hue-rotate(150deg) saturate(1.2)",
+ accessories: [
+ { type: "band", color: "#2fa88a", style: "left:14%; right:14%; top:30%; height:14%;" },
+ { type: "peak-down", colors: ["#7ee0c8", "#2fa88a"], style: "left:24%; right:24%; bottom:-14%; height:20%;" },
+ ],
+ },
+ },
+ {
+ id: 76, name: "Banane dragon", rarity: "epique", image: "images/banana_76.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#3a5a8a", style: "left:8%; right:8%; top:30%; height:15%; transform:rotate(-6deg);" },
+ { type: "orb", color: "#c9d6e6", style: "left:46%; top:32%; width:12%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 77, name: "Banane sirène", rarity: "epique", image: "images/banana_77.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "ring", color: "#ffe9a8", style: "left:22%; right:22%; top:-22%; height:18%;" },
+ { type: "peak-out-left", color: "#fff", style: "left:-14%; top:30%; width:18%; height:26%;" },
+ { type: "peak-out-right", color: "#fff", style: "right:-14%; top:30%; width:18%; height:26%;" },
+ ],
+ },
+ },
+ {
+ id: 78, name: "Banane pharaon", rarity: "epique", image: "images/banana_78.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#ffd23f", style: "left:14%; right:14%; top:-10%; height:14%;" },
+ { type: "band", color: "#2a5aa8", style: "left:10%; right:10%; top:2%; height:10%;" },
+ ],
+ },
+ },
+ {
+ id: 79, name: "Banane gardienne", rarity: "epique", image: "images/banana_79.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#9fb4c7", style: "left:10%; right:10%; top:28%; height:14%;" },
+ { type: "peak-up", color: "#9fb4c7", style: "left:40%; right:40%; top:-10%; height:12%;" },
+ ],
+ },
+ },
+ {
+ id: 80, name: "Banane samouraï d'or", rarity: "epique", image: "images/banana_80.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#ffd23f", style: "left:8%; right:8%; top:30%; height:15%; transform:rotate(-6deg);" },
+ { type: "orb", color: "#c81d25", style: "left:46%; top:32%; width:12%; height:12%;" },
+ ],
+ },
+ },
+
+ // ================= Légendaire (10) =================
+ {
+ id: 81, name: "Banane radioactive", rarity: "legendaire", image: "images/banana_81.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.4) brightness(1.05) drop-shadow(0 0 6px #9cff5a)",
+ accessories: [{ type: "text", text: "☢️", style: "bottom:-10%; right:-10%; font-size:0.75em;" }],
+ },
+ },
+ {
+ id: 82, name: "Banane du chaos", rarity: "legendaire", image: "images/banana_82.png", emoji: "🍌",
+ deco: { filter: "saturate(0.3) brightness(1.3) opacity(0.75) drop-shadow(0 0 6px #cfd8ff)" },
+ },
+ {
+ id: 83, name: "Banane céleste", rarity: "legendaire", image: "images/banana_83.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(300deg) saturate(1.6) contrast(1.2) drop-shadow(0 0 6px #ff4dd8)", transform: "skewX(-6deg) rotate(4deg)" },
+ },
+ {
+ id: 84, name: "Banane des dieux", rarity: "legendaire", image: "images/banana_84.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(0.7) brightness(1.3) drop-shadow(0 0 7px #fff3c4)",
+ accessories: [{ type: "ring", color: "#ffe9a8", style: "left:20%; right:20%; top:-20%; height:16%;" }],
+ },
+ },
+ {
+ id: 85, name: "Banane titan", rarity: "legendaire", image: "images/banana_85.png", emoji: "🍌",
+ deco: {
+ filter: "brightness(1.15) drop-shadow(0 0 6px #fff3c4)",
+ accessories: [
+ { type: "band", color: "#fff6d0", style: "left:16%; right:16%; top:-8%; height:12%;" },
+ { type: "peak-up", color: "#fff6d0", style: "left:38%; right:38%; top:-18%; height:14%;" },
+ ],
+ },
+ },
+ {
+ id: 86, name: "Banane phénix noir", rarity: "legendaire", image: "images/banana_86.png", emoji: "🍌",
+ deco: {
+ filter: "sepia(0.4) saturate(1.1) brightness(0.95) drop-shadow(0 0 5px #e0c98a)",
+ accessories: [{ type: "ring", color: "#e0c98a", style: "left:10%; right:10%; top:38%; height:20%;" }],
+ },
+ },
+ {
+ id: 87, name: "Banane kraken", rarity: "legendaire", image: "images/banana_87.png", emoji: "🍌",
+ deco: {
+ filter: "drop-shadow(0 5px 3px rgba(0,0,0,.4))",
+ scale: 1.3,
+ accessories: [{ type: "band", color: "#7a8a99", style: "left:10%; right:10%; top:40%; height:10%;" }],
+ },
+ },
+ {
+ id: 88, name: "Banane valkyrie", rarity: "legendaire", image: "images/banana_88.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.2) drop-shadow(0 0 6px #8a5ac8)",
+ accessories: [
+ { type: "peak-out-left", colors: ["#8a5ac8", "#1a0e2e"], style: "left:-16%; top:26%; width:22%; height:32%;" },
+ { type: "peak-out-right", colors: ["#8a5ac8", "#1a0e2e"], style: "right:-16%; top:26%; width:22%; height:32%;" },
+ ],
+ },
+ },
+ {
+ id: 89, name: "Banane maléfique", rarity: "legendaire", image: "images/banana_89.png", emoji: "🍌",
+ deco: { filter: "hue-rotate(200deg) saturate(1.4) brightness(0.85) drop-shadow(0 0 6px #4a2f8a)" },
+ },
+ {
+ id: 90, name: "Banane dinosaure", rarity: "legendaire", image: "images/banana_90.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "band", color: "#c7cdd3", style: "left:14%; right:14%; top:-8%; height:14%;" },
+ { type: "peak-out-left", color: "#e6ecf2", style: "left:-10%; top:-14%; width:16%; height:20%;" },
+ { type: "peak-out-right", color: "#e6ecf2", style: "right:-10%; top:-14%; width:16%; height:20%;" },
+ ],
+ },
+ },
+
+ // ================= Mythique (5) =================
+ {
+ id: 93, name: "Banane arc-en-ciel", rarity: "mythique", image: "images/banana_93.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.6) drop-shadow(0 0 8px #ff9fd0)",
+ glyphClass: "anim-rainbow",
+ },
+ },
+ {
+ id: 94, name: "Banane cosmique", rarity: "mythique", image: "images/banana_94.png", emoji: "🍌",
+ deco: {
+ filter: "hue-rotate(230deg) saturate(1.3) brightness(0.95) drop-shadow(0 0 8px #8a6bff)",
+ accessories: [
+ { type: "text", text: "✨", style: "left:2%; top:4%; font-size:.34em;" },
+ { type: "text", text: "✨", style: "right:6%; bottom:8%; font-size:.3em;" },
+ ],
+ },
+ },
+ {
+ id: 95, name: "Banane quantique", rarity: "mythique", image: "images/banana_95.png", emoji: "🍌",
+ deco: {
+ filter: "hue-rotate(160deg) saturate(1.2) drop-shadow(0 0 8px #6fe0ff)",
+ accessories: [{ type: "ring", color: "#6fe0ff", style: "inset:-10%;" }],
+ },
+ },
+ {
+ id: 96, name: "Banane multidimensionnelle", rarity: "mythique", image: "images/banana_96.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.1) brightness(0.98)",
+ accessories: [
+ { type: "band", color: "#2f9e58", style: "left:48%; width:4%; top:-12%; height:14%;" },
+ { type: "peak-out-left", colors: ["#7ee08a", "#2f9e58"], style: "left:44%; width:16%; top:-18%; height:14%;" },
+ ],
+ },
+ },
+ {
+ id: 97, name: "Banane gorille géant", rarity: "mythique", image: "images/banana_97.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.6) brightness(1.1) drop-shadow(0 0 9px #ffd23f)",
+ accessories: [
+ { type: "text", text: "✨", style: "top:-10%; left:-10%; font-size:.4em;" },
+ { type: "text", text: "✨", style: "bottom:-8%; right:-8%; font-size:.4em;" },
+ ],
+ },
+ },
+
+ // ================= Secrète (6) — variantes bonus, ultra rares =================
+ {
+ id: 101, name: "Banane agent secret", rarity: "secrete", image: "images/banana_101.png", emoji: "🍌",
+ deco: {
+ accessories: [{ type: "bubble", style: "right:-32%; top:-14%; width:48%; height:32%;" }],
+ },
+ },
+ {
+ id: 102, name: "Banane blanche", rarity: "secrete", image: "images/banana_102.png", emoji: "🍌",
+ deco: {
+ filter: "saturate(1.2) brightness(1.05)",
+ accessories: [
+ { type: "band", color: "#1ab8d6", style: "left:12%; right:12%; top:34%; height:14%;" },
+ { type: "band", color: "#1ab8d6", style: "left:48%; width:4%; top:0%; height:16%;" },
+ { type: "orb", color: "#5ff0ff", style: "left:44%; width:12%; height:12%; top:-8%;" },
+ ],
+ },
+ },
+ {
+ id: 103, name: "Banane spectrale", rarity: "secrete", image: "images/banana_103.png", emoji: "🍌",
+ deco: {
+ duplicates: [{ transform: "translate(18%,-10%) rotate(10deg)", opacity: 0.85 }],
+ },
+ },
+ {
+ id: 104, name: "Banane souris électrique", rarity: "secrete", image: "images/banana_104.png", emoji: "🍌",
+ deco: { transform: "rotate(180deg)" },
+ },
+ {
+ id: 105, name: "Banane fermier", rarity: "secrete", image: "images/banana_105.png", emoji: "🍌",
+ deco: {
+ accessories: [
+ { type: "ring", color: "#333", style: "left:14%; top:34%; width:20%; height:20%; background:rgba(255,255,255,.35);" },
+ { type: "ring", color: "#333", style: "right:14%; top:34%; width:20%; height:20%; background:rgba(255,255,255,.35);" },
+ { type: "band", color: "#333", style: "left:44%; width:12%; top:41%; height:3%;" },
+ { type: "band", color: "#4a3520", style: "left:36%; width:28%; top:54%; height:6%;" },
+ ],
+ },
+ },
+ {
+ id: 106, name: "Banane invisible", rarity: "secrete", image: "images/banana_106.png", emoji: "🍌",
+ },
+ {
+ id: 111, name: "Banane rêveuse", rarity: "commune", emoji: "🍌",
+ image: "images/banana_111.png",
+ },
+ {
+ id: 112, name: "Banane hors-la-loi", rarity: "epique", emoji: "🍌",
+ image: "images/banana_112.png",
+ },
+ {
+ id: 113, name: "Banane guerrière dorée", rarity: "epique", emoji: "🍌",
+ image: "images/banana_113.png",
+ },
+ {
+ id: 114, name: "Banane apprentie mage", rarity: "epique", emoji: "🍌",
+ image: "images/banana_114.png",
+ },
+ {
+ id: 115, name: "Banane bébé", rarity: "epique", emoji: "🍌",
+ image: "images/banana_115.png",
+ },
+];
+
+// Construction de la table finale avec id, valeur, index dans la rareté, etc.
+const BANANAS = (() => {
+ const countersByRarity = {};
+ const seenIds = new Set();
+ return BANANA_DEFS.map((def, i) => {
+ const idxInRarity = countersByRarity[def.rarity] || 0;
+ countersByRarity[def.rarity] = idxInRarity + 1;
+ // L'id explicite (figé, voir commentaire au-dessus de BANANA_DEFS) est la
+ // source de vérité ; le repli sur la position ne devrait plus jamais servir.
+ const id = def.id ?? i + 1;
+ if (seenIds.has(id)) console.warn(`Id de banane en doublon détecté : ${id}`);
+ seenIds.add(id);
+ return {
+ id,
+ name: def.name,
+ rarity: def.rarity,
+ emoji: def.emoji,
+ image: def.image || null,
+ deco: def.deco || null,
+ secret: def.rarity === "secrete",
+ value: valueFor(def.rarity, idxInRarity),
+ };
+ });
+})();
+
+const BANANAS_BY_ID = Object.fromEntries(BANANAS.map((b) => [b.id, b]));
+const NORMAL_BANANAS = BANANAS.filter((b) => !b.secret);
+const SECRET_BANANAS = BANANAS.filter((b) => b.secret);
+
+const TOTAL_NORMAL = NORMAL_BANANAS.length; // 100
+const TOTAL_SECRET = SECRET_BANANAS.length; // 6
diff --git a/banana-collector/images/banana_1.png b/banana-collector/images/banana_1.png
new file mode 100644
index 000000000..32fc00cc3
Binary files /dev/null and b/banana-collector/images/banana_1.png differ
diff --git a/banana-collector/images/banana_10.png b/banana-collector/images/banana_10.png
new file mode 100644
index 000000000..0fc75ee16
Binary files /dev/null and b/banana-collector/images/banana_10.png differ
diff --git a/banana-collector/images/banana_101.png b/banana-collector/images/banana_101.png
new file mode 100644
index 000000000..dba58e42d
Binary files /dev/null and b/banana-collector/images/banana_101.png differ
diff --git a/banana-collector/images/banana_102.png b/banana-collector/images/banana_102.png
new file mode 100644
index 000000000..6f43995e5
Binary files /dev/null and b/banana-collector/images/banana_102.png differ
diff --git a/banana-collector/images/banana_103.png b/banana-collector/images/banana_103.png
new file mode 100644
index 000000000..fd479faf6
Binary files /dev/null and b/banana-collector/images/banana_103.png differ
diff --git a/banana-collector/images/banana_104.png b/banana-collector/images/banana_104.png
new file mode 100644
index 000000000..c7a0c76d4
Binary files /dev/null and b/banana-collector/images/banana_104.png differ
diff --git a/banana-collector/images/banana_105.png b/banana-collector/images/banana_105.png
new file mode 100644
index 000000000..f49042b11
Binary files /dev/null and b/banana-collector/images/banana_105.png differ
diff --git a/banana-collector/images/banana_106.png b/banana-collector/images/banana_106.png
new file mode 100644
index 000000000..e84298b9e
Binary files /dev/null and b/banana-collector/images/banana_106.png differ
diff --git a/banana-collector/images/banana_11.png b/banana-collector/images/banana_11.png
new file mode 100644
index 000000000..4b51250a4
Binary files /dev/null and b/banana-collector/images/banana_11.png differ
diff --git a/banana-collector/images/banana_111.png b/banana-collector/images/banana_111.png
new file mode 100644
index 000000000..abeff7c24
Binary files /dev/null and b/banana-collector/images/banana_111.png differ
diff --git a/banana-collector/images/banana_112.png b/banana-collector/images/banana_112.png
new file mode 100644
index 000000000..300933532
Binary files /dev/null and b/banana-collector/images/banana_112.png differ
diff --git a/banana-collector/images/banana_113.png b/banana-collector/images/banana_113.png
new file mode 100644
index 000000000..b9b88084c
Binary files /dev/null and b/banana-collector/images/banana_113.png differ
diff --git a/banana-collector/images/banana_114.png b/banana-collector/images/banana_114.png
new file mode 100644
index 000000000..3bca92631
Binary files /dev/null and b/banana-collector/images/banana_114.png differ
diff --git a/banana-collector/images/banana_115.png b/banana-collector/images/banana_115.png
new file mode 100644
index 000000000..f6af39576
Binary files /dev/null and b/banana-collector/images/banana_115.png differ
diff --git a/banana-collector/images/banana_12.png b/banana-collector/images/banana_12.png
new file mode 100644
index 000000000..280bede65
Binary files /dev/null and b/banana-collector/images/banana_12.png differ
diff --git a/banana-collector/images/banana_13.png b/banana-collector/images/banana_13.png
new file mode 100644
index 000000000..f588f8414
Binary files /dev/null and b/banana-collector/images/banana_13.png differ
diff --git a/banana-collector/images/banana_14.png b/banana-collector/images/banana_14.png
new file mode 100644
index 000000000..bcba66db4
Binary files /dev/null and b/banana-collector/images/banana_14.png differ
diff --git a/banana-collector/images/banana_15.png b/banana-collector/images/banana_15.png
new file mode 100644
index 000000000..76db71986
Binary files /dev/null and b/banana-collector/images/banana_15.png differ
diff --git a/banana-collector/images/banana_16.png b/banana-collector/images/banana_16.png
new file mode 100644
index 000000000..2a625fc62
Binary files /dev/null and b/banana-collector/images/banana_16.png differ
diff --git a/banana-collector/images/banana_17.png b/banana-collector/images/banana_17.png
new file mode 100644
index 000000000..8301b2cc6
Binary files /dev/null and b/banana-collector/images/banana_17.png differ
diff --git a/banana-collector/images/banana_18.png b/banana-collector/images/banana_18.png
new file mode 100644
index 000000000..7966a780c
Binary files /dev/null and b/banana-collector/images/banana_18.png differ
diff --git a/banana-collector/images/banana_19.png b/banana-collector/images/banana_19.png
new file mode 100644
index 000000000..826272bac
Binary files /dev/null and b/banana-collector/images/banana_19.png differ
diff --git a/banana-collector/images/banana_2.png b/banana-collector/images/banana_2.png
new file mode 100644
index 000000000..b0747ae32
Binary files /dev/null and b/banana-collector/images/banana_2.png differ
diff --git a/banana-collector/images/banana_20.png b/banana-collector/images/banana_20.png
new file mode 100644
index 000000000..a51a0671e
Binary files /dev/null and b/banana-collector/images/banana_20.png differ
diff --git a/banana-collector/images/banana_21.png b/banana-collector/images/banana_21.png
new file mode 100644
index 000000000..90f2f423f
Binary files /dev/null and b/banana-collector/images/banana_21.png differ
diff --git a/banana-collector/images/banana_22.png b/banana-collector/images/banana_22.png
new file mode 100644
index 000000000..e8603a873
Binary files /dev/null and b/banana-collector/images/banana_22.png differ
diff --git a/banana-collector/images/banana_23.png b/banana-collector/images/banana_23.png
new file mode 100644
index 000000000..175e33f2a
Binary files /dev/null and b/banana-collector/images/banana_23.png differ
diff --git a/banana-collector/images/banana_24.png b/banana-collector/images/banana_24.png
new file mode 100644
index 000000000..f96f74a33
Binary files /dev/null and b/banana-collector/images/banana_24.png differ
diff --git a/banana-collector/images/banana_25.png b/banana-collector/images/banana_25.png
new file mode 100644
index 000000000..74e90f89b
Binary files /dev/null and b/banana-collector/images/banana_25.png differ
diff --git a/banana-collector/images/banana_26.png b/banana-collector/images/banana_26.png
new file mode 100644
index 000000000..a34bb697d
Binary files /dev/null and b/banana-collector/images/banana_26.png differ
diff --git a/banana-collector/images/banana_27.png b/banana-collector/images/banana_27.png
new file mode 100644
index 000000000..9ca9e2015
Binary files /dev/null and b/banana-collector/images/banana_27.png differ
diff --git a/banana-collector/images/banana_28.png b/banana-collector/images/banana_28.png
new file mode 100644
index 000000000..d90039c72
Binary files /dev/null and b/banana-collector/images/banana_28.png differ
diff --git a/banana-collector/images/banana_29.png b/banana-collector/images/banana_29.png
new file mode 100644
index 000000000..bfb30fe8c
Binary files /dev/null and b/banana-collector/images/banana_29.png differ
diff --git a/banana-collector/images/banana_3.png b/banana-collector/images/banana_3.png
new file mode 100644
index 000000000..de6ae08cd
Binary files /dev/null and b/banana-collector/images/banana_3.png differ
diff --git a/banana-collector/images/banana_30.png b/banana-collector/images/banana_30.png
new file mode 100644
index 000000000..3ac0755c1
Binary files /dev/null and b/banana-collector/images/banana_30.png differ
diff --git a/banana-collector/images/banana_31.png b/banana-collector/images/banana_31.png
new file mode 100644
index 000000000..892299031
Binary files /dev/null and b/banana-collector/images/banana_31.png differ
diff --git a/banana-collector/images/banana_32.png b/banana-collector/images/banana_32.png
new file mode 100644
index 000000000..9cebc9c33
Binary files /dev/null and b/banana-collector/images/banana_32.png differ
diff --git a/banana-collector/images/banana_33.png b/banana-collector/images/banana_33.png
new file mode 100644
index 000000000..56bd6d7b0
Binary files /dev/null and b/banana-collector/images/banana_33.png differ
diff --git a/banana-collector/images/banana_34.png b/banana-collector/images/banana_34.png
new file mode 100644
index 000000000..4595d3f5e
Binary files /dev/null and b/banana-collector/images/banana_34.png differ
diff --git a/banana-collector/images/banana_35.png b/banana-collector/images/banana_35.png
new file mode 100644
index 000000000..75d3881de
Binary files /dev/null and b/banana-collector/images/banana_35.png differ
diff --git a/banana-collector/images/banana_36.png b/banana-collector/images/banana_36.png
new file mode 100644
index 000000000..3ec6eda5d
Binary files /dev/null and b/banana-collector/images/banana_36.png differ
diff --git a/banana-collector/images/banana_37.png b/banana-collector/images/banana_37.png
new file mode 100644
index 000000000..0e1d0045e
Binary files /dev/null and b/banana-collector/images/banana_37.png differ
diff --git a/banana-collector/images/banana_38.png b/banana-collector/images/banana_38.png
new file mode 100644
index 000000000..d1de2d735
Binary files /dev/null and b/banana-collector/images/banana_38.png differ
diff --git a/banana-collector/images/banana_39.png b/banana-collector/images/banana_39.png
new file mode 100644
index 000000000..73d9c81be
Binary files /dev/null and b/banana-collector/images/banana_39.png differ
diff --git a/banana-collector/images/banana_4.png b/banana-collector/images/banana_4.png
new file mode 100644
index 000000000..1160cfb1d
Binary files /dev/null and b/banana-collector/images/banana_4.png differ
diff --git a/banana-collector/images/banana_40.png b/banana-collector/images/banana_40.png
new file mode 100644
index 000000000..2a3497c39
Binary files /dev/null and b/banana-collector/images/banana_40.png differ
diff --git a/banana-collector/images/banana_41.png b/banana-collector/images/banana_41.png
new file mode 100644
index 000000000..d70d6fb06
Binary files /dev/null and b/banana-collector/images/banana_41.png differ
diff --git a/banana-collector/images/banana_42.png b/banana-collector/images/banana_42.png
new file mode 100644
index 000000000..a43c3faca
Binary files /dev/null and b/banana-collector/images/banana_42.png differ
diff --git a/banana-collector/images/banana_43.png b/banana-collector/images/banana_43.png
new file mode 100644
index 000000000..7f0f70bec
Binary files /dev/null and b/banana-collector/images/banana_43.png differ
diff --git a/banana-collector/images/banana_44.png b/banana-collector/images/banana_44.png
new file mode 100644
index 000000000..bc6bcd5a6
Binary files /dev/null and b/banana-collector/images/banana_44.png differ
diff --git a/banana-collector/images/banana_45.png b/banana-collector/images/banana_45.png
new file mode 100644
index 000000000..6c7262b3f
Binary files /dev/null and b/banana-collector/images/banana_45.png differ
diff --git a/banana-collector/images/banana_46.png b/banana-collector/images/banana_46.png
new file mode 100644
index 000000000..cccbaa615
Binary files /dev/null and b/banana-collector/images/banana_46.png differ
diff --git a/banana-collector/images/banana_47.png b/banana-collector/images/banana_47.png
new file mode 100644
index 000000000..d0ee7060e
Binary files /dev/null and b/banana-collector/images/banana_47.png differ
diff --git a/banana-collector/images/banana_48.png b/banana-collector/images/banana_48.png
new file mode 100644
index 000000000..bdf43d449
Binary files /dev/null and b/banana-collector/images/banana_48.png differ
diff --git a/banana-collector/images/banana_49.png b/banana-collector/images/banana_49.png
new file mode 100644
index 000000000..e9d49b5c9
Binary files /dev/null and b/banana-collector/images/banana_49.png differ
diff --git a/banana-collector/images/banana_5.png b/banana-collector/images/banana_5.png
new file mode 100644
index 000000000..0e041e354
Binary files /dev/null and b/banana-collector/images/banana_5.png differ
diff --git a/banana-collector/images/banana_50.png b/banana-collector/images/banana_50.png
new file mode 100644
index 000000000..91b7c7f41
Binary files /dev/null and b/banana-collector/images/banana_50.png differ
diff --git a/banana-collector/images/banana_51.png b/banana-collector/images/banana_51.png
new file mode 100644
index 000000000..3d721a39f
Binary files /dev/null and b/banana-collector/images/banana_51.png differ
diff --git a/banana-collector/images/banana_52.png b/banana-collector/images/banana_52.png
new file mode 100644
index 000000000..b2866a606
Binary files /dev/null and b/banana-collector/images/banana_52.png differ
diff --git a/banana-collector/images/banana_53.png b/banana-collector/images/banana_53.png
new file mode 100644
index 000000000..34ef8f91b
Binary files /dev/null and b/banana-collector/images/banana_53.png differ
diff --git a/banana-collector/images/banana_54.png b/banana-collector/images/banana_54.png
new file mode 100644
index 000000000..bb8c05a25
Binary files /dev/null and b/banana-collector/images/banana_54.png differ
diff --git a/banana-collector/images/banana_55.png b/banana-collector/images/banana_55.png
new file mode 100644
index 000000000..e7994157f
Binary files /dev/null and b/banana-collector/images/banana_55.png differ
diff --git a/banana-collector/images/banana_56.png b/banana-collector/images/banana_56.png
new file mode 100644
index 000000000..8db58c443
Binary files /dev/null and b/banana-collector/images/banana_56.png differ
diff --git a/banana-collector/images/banana_57.png b/banana-collector/images/banana_57.png
new file mode 100644
index 000000000..cfdecf86d
Binary files /dev/null and b/banana-collector/images/banana_57.png differ
diff --git a/banana-collector/images/banana_58.png b/banana-collector/images/banana_58.png
new file mode 100644
index 000000000..5fa0b7232
Binary files /dev/null and b/banana-collector/images/banana_58.png differ
diff --git a/banana-collector/images/banana_59.png b/banana-collector/images/banana_59.png
new file mode 100644
index 000000000..b4ca4d832
Binary files /dev/null and b/banana-collector/images/banana_59.png differ
diff --git a/banana-collector/images/banana_6.png b/banana-collector/images/banana_6.png
new file mode 100644
index 000000000..838ebec3e
Binary files /dev/null and b/banana-collector/images/banana_6.png differ
diff --git a/banana-collector/images/banana_60.png b/banana-collector/images/banana_60.png
new file mode 100644
index 000000000..989e6c430
Binary files /dev/null and b/banana-collector/images/banana_60.png differ
diff --git a/banana-collector/images/banana_61.png b/banana-collector/images/banana_61.png
new file mode 100644
index 000000000..b3d25f552
Binary files /dev/null and b/banana-collector/images/banana_61.png differ
diff --git a/banana-collector/images/banana_62.png b/banana-collector/images/banana_62.png
new file mode 100644
index 000000000..1c37df41e
Binary files /dev/null and b/banana-collector/images/banana_62.png differ
diff --git a/banana-collector/images/banana_63.png b/banana-collector/images/banana_63.png
new file mode 100644
index 000000000..d07d0ca7d
Binary files /dev/null and b/banana-collector/images/banana_63.png differ
diff --git a/banana-collector/images/banana_64.png b/banana-collector/images/banana_64.png
new file mode 100644
index 000000000..73ddfc8b5
Binary files /dev/null and b/banana-collector/images/banana_64.png differ
diff --git a/banana-collector/images/banana_65.png b/banana-collector/images/banana_65.png
new file mode 100644
index 000000000..6ffbeee9a
Binary files /dev/null and b/banana-collector/images/banana_65.png differ
diff --git a/banana-collector/images/banana_66.png b/banana-collector/images/banana_66.png
new file mode 100644
index 000000000..0bae5a5d5
Binary files /dev/null and b/banana-collector/images/banana_66.png differ
diff --git a/banana-collector/images/banana_67.png b/banana-collector/images/banana_67.png
new file mode 100644
index 000000000..9ccf2f28f
Binary files /dev/null and b/banana-collector/images/banana_67.png differ
diff --git a/banana-collector/images/banana_68.png b/banana-collector/images/banana_68.png
new file mode 100644
index 000000000..9666e6ef0
Binary files /dev/null and b/banana-collector/images/banana_68.png differ
diff --git a/banana-collector/images/banana_69.png b/banana-collector/images/banana_69.png
new file mode 100644
index 000000000..0bbb8eb06
Binary files /dev/null and b/banana-collector/images/banana_69.png differ
diff --git a/banana-collector/images/banana_7.png b/banana-collector/images/banana_7.png
new file mode 100644
index 000000000..6340711e1
Binary files /dev/null and b/banana-collector/images/banana_7.png differ
diff --git a/banana-collector/images/banana_70.png b/banana-collector/images/banana_70.png
new file mode 100644
index 000000000..66c5200d0
Binary files /dev/null and b/banana-collector/images/banana_70.png differ
diff --git a/banana-collector/images/banana_71.png b/banana-collector/images/banana_71.png
new file mode 100644
index 000000000..eecf8f9ed
Binary files /dev/null and b/banana-collector/images/banana_71.png differ
diff --git a/banana-collector/images/banana_72.png b/banana-collector/images/banana_72.png
new file mode 100644
index 000000000..19994270a
Binary files /dev/null and b/banana-collector/images/banana_72.png differ
diff --git a/banana-collector/images/banana_73.png b/banana-collector/images/banana_73.png
new file mode 100644
index 000000000..cadad0558
Binary files /dev/null and b/banana-collector/images/banana_73.png differ
diff --git a/banana-collector/images/banana_74.png b/banana-collector/images/banana_74.png
new file mode 100644
index 000000000..c6f1e170d
Binary files /dev/null and b/banana-collector/images/banana_74.png differ
diff --git a/banana-collector/images/banana_75.png b/banana-collector/images/banana_75.png
new file mode 100644
index 000000000..31559ed9f
Binary files /dev/null and b/banana-collector/images/banana_75.png differ
diff --git a/banana-collector/images/banana_76.png b/banana-collector/images/banana_76.png
new file mode 100644
index 000000000..cb58c41a5
Binary files /dev/null and b/banana-collector/images/banana_76.png differ
diff --git a/banana-collector/images/banana_77.png b/banana-collector/images/banana_77.png
new file mode 100644
index 000000000..93440ff06
Binary files /dev/null and b/banana-collector/images/banana_77.png differ
diff --git a/banana-collector/images/banana_78.png b/banana-collector/images/banana_78.png
new file mode 100644
index 000000000..45729591d
Binary files /dev/null and b/banana-collector/images/banana_78.png differ
diff --git a/banana-collector/images/banana_79.png b/banana-collector/images/banana_79.png
new file mode 100644
index 000000000..0011020cb
Binary files /dev/null and b/banana-collector/images/banana_79.png differ
diff --git a/banana-collector/images/banana_8.png b/banana-collector/images/banana_8.png
new file mode 100644
index 000000000..6aeb2538c
Binary files /dev/null and b/banana-collector/images/banana_8.png differ
diff --git a/banana-collector/images/banana_80.png b/banana-collector/images/banana_80.png
new file mode 100644
index 000000000..5fcfc90b3
Binary files /dev/null and b/banana-collector/images/banana_80.png differ
diff --git a/banana-collector/images/banana_81.png b/banana-collector/images/banana_81.png
new file mode 100644
index 000000000..4aa4c1fa6
Binary files /dev/null and b/banana-collector/images/banana_81.png differ
diff --git a/banana-collector/images/banana_82.png b/banana-collector/images/banana_82.png
new file mode 100644
index 000000000..be14fbca5
Binary files /dev/null and b/banana-collector/images/banana_82.png differ
diff --git a/banana-collector/images/banana_83.png b/banana-collector/images/banana_83.png
new file mode 100644
index 000000000..6bde07999
Binary files /dev/null and b/banana-collector/images/banana_83.png differ
diff --git a/banana-collector/images/banana_84.png b/banana-collector/images/banana_84.png
new file mode 100644
index 000000000..9005bc688
Binary files /dev/null and b/banana-collector/images/banana_84.png differ
diff --git a/banana-collector/images/banana_85.png b/banana-collector/images/banana_85.png
new file mode 100644
index 000000000..1ffd18dbd
Binary files /dev/null and b/banana-collector/images/banana_85.png differ
diff --git a/banana-collector/images/banana_86.png b/banana-collector/images/banana_86.png
new file mode 100644
index 000000000..a3f8bc2a8
Binary files /dev/null and b/banana-collector/images/banana_86.png differ
diff --git a/banana-collector/images/banana_87.png b/banana-collector/images/banana_87.png
new file mode 100644
index 000000000..a9017fcd5
Binary files /dev/null and b/banana-collector/images/banana_87.png differ
diff --git a/banana-collector/images/banana_88.png b/banana-collector/images/banana_88.png
new file mode 100644
index 000000000..2ee1e80af
Binary files /dev/null and b/banana-collector/images/banana_88.png differ
diff --git a/banana-collector/images/banana_89.png b/banana-collector/images/banana_89.png
new file mode 100644
index 000000000..bd2b57698
Binary files /dev/null and b/banana-collector/images/banana_89.png differ
diff --git a/banana-collector/images/banana_9.png b/banana-collector/images/banana_9.png
new file mode 100644
index 000000000..be649014b
Binary files /dev/null and b/banana-collector/images/banana_9.png differ
diff --git a/banana-collector/images/banana_90.png b/banana-collector/images/banana_90.png
new file mode 100644
index 000000000..b23d6d3d6
Binary files /dev/null and b/banana-collector/images/banana_90.png differ
diff --git a/banana-collector/images/banana_93.png b/banana-collector/images/banana_93.png
new file mode 100644
index 000000000..bff214dda
Binary files /dev/null and b/banana-collector/images/banana_93.png differ
diff --git a/banana-collector/images/banana_94.png b/banana-collector/images/banana_94.png
new file mode 100644
index 000000000..547864372
Binary files /dev/null and b/banana-collector/images/banana_94.png differ
diff --git a/banana-collector/images/banana_95.png b/banana-collector/images/banana_95.png
new file mode 100644
index 000000000..da2915f93
Binary files /dev/null and b/banana-collector/images/banana_95.png differ
diff --git a/banana-collector/images/banana_96.png b/banana-collector/images/banana_96.png
new file mode 100644
index 000000000..14a69e8cc
Binary files /dev/null and b/banana-collector/images/banana_96.png differ
diff --git a/banana-collector/images/banana_97.png b/banana-collector/images/banana_97.png
new file mode 100644
index 000000000..dd37968ec
Binary files /dev/null and b/banana-collector/images/banana_97.png differ
diff --git a/banana-collector/index.html b/banana-collector/index.html
new file mode 100644
index 000000000..66914eba3
--- /dev/null
+++ b/banana-collector/index.html
@@ -0,0 +1,297 @@
+
+
+
+
+
+ 🍌 Banana Collector
+
+
+
+
+
+
+
+
+
+
+ 🎲 Tirage
+ 🗂️ Progression
+ 💰 Économie
+ ⚔️ Combat
+ 📋 Bilan
+
+
+
+
+ 🍌 RÉCOLTER UNE BANANE
+
+
+
+
+
+ 📖 Collection
+ 📜 Quêtes
+ 🎮 Mini-jeux
+
+
+
+
Ma collection
+
+
Collection : 0 / 100
+
+
+
+
+
+
🕵️ Bananes secrètes 0 / 10
+
+
+
+
+
+
📜 Quêtes du jour
+
Un nouveau lot de quêtes t'attend chaque jour. Termine-les pour gagner des pièces bonus !
+
+
+
+
+
+
+
+
← Retour
+
🍌 Attrape les bananes
+
+ ⏱️ Niveau 1 — 10s
+ ⭐ 0
+
+
+
+ 🌿
+ 🌿
+ 🍃
+ 🌿
+
+
+
3 niveaux, de plus en plus rapides. Clique sur les bananes 🍌 qui tombent, évite les pourries !
+
▶️ Démarrer
+
+
+
+
+
+
+
← Retour
+
🎡 Roue de la fortune
+
+
🎡 Tourner la roue
+
+
+
+
+
+
+
+ 🛒 Boutique
+ 🏪 Marché
+ 📺 Pub
+
+
+
+
Boutique
+
Dépense tes pièces pour améliorer tes chances d'obtenir des bananes rares !
+
+
+
+
+
🏪 Marché
+
Vends tes doublons contre des pièces, achète ceux des autres joueurs.
+
🔒 Connecte-toi (bouton "👤 Compte" en haut de la page) pour accéder au Marché.
+
+
+ 🛍️ Acheter
+ 🏷️ Mes annonces
+
+
+
+
+
+
+
+
📺 Publicité
+
Regarde une pub pour gagner des pièces gratuitement, en plus de la récolte.
+
+
+
+
Espace publicitaire
+
Bannière AdSense / AdMob — à activer avec un compte annonceur
+
+
+
🎬 Regarder une pub (+300 🪙)
+
+
Politique de confidentialité & publicités
+
+
+
+
+
+ 🍍 Arène solo
+ 🆚 PVP
+
+
+
+
⚔️ L'Arène des Fruits
+
Choisis ta banane championne et affronte 60 niveaux répartis en 10 familles de fruits, de plus en plus fortes. L'attaque et la défense dépendent de la rareté de ta banane.
+
+
+
+
+
+
⚔️ Attaquer
+
+
+
Progression dans l'arène :
+
+
+
+
+
🆚 Arène PVP
+
Choisis une équipe de 5 bananes qui te défendra même hors ligne. Cette même équipe sert aussi à attaquer les autres joueurs. Victoire : tu voles 15% des pièces de l'adversaire. Défaite : tu perds 20% de tes pièces (dont 8% récupérés par le défenseur).
+
🔒 Connecte-toi (bouton "👤 Compte" en haut de la page) pour accéder à l'Arène PVP.
+
+
+
+
+
Ton équipe (défense + attaque)
+
+
+
+ Sauvegarder l'équipe
+
+
+
+
Attaquer
+
+
🔍 Trouver un adversaire
+
+
⚔️ Attaquer
+
+
+
+
+
+
+
+
+ 🏆 Classement
+ 📊 Statistiques
+
+
+
+
🏆 Classement
+
Comparaison entre joueurs ayant un compte cloud (bouton "👤 Compte" en haut de la page).
+
+
+ 📖 Collection
+ 🆚 Arène PVP
+ 🍍 Combats IA
+
+
+
+
+
+
+
Statistiques
+
+
+
🗑️ Réinitialiser la sauvegarde
+
+
+
+
+
+
+
+
+
+
Es-tu sûr de vouloir réinitialiser toute ta progression ? Cette action est irréversible .
+
+ Oui, tout supprimer
+ Annuler
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/banana-collector/privacy.html b/banana-collector/privacy.html
new file mode 100644
index 000000000..a6a373e11
--- /dev/null
+++ b/banana-collector/privacy.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Confidentialité — Banana Collector
+
+
+
+
+
+
+
← Retour au jeu
+
🍌 Politique de confidentialité — Banana Collector
+
Dernière mise à jour : 27 août 2026
+
+
Ce que le jeu enregistre
+
+ Banana Collector n'a pas de serveur : le jeu s'exécute entièrement dans
+ ton navigateur. Ta progression (bananes collectées, pièces, niveaux de
+ boutique) est sauvegardée uniquement dans le stockage local de
+ ton navigateur (localStorage), sur cet appareil. Rien n'est
+ envoyé à un serveur, aucun compte n'est nécessaire, et personne
+ d'autre que toi n'a accès à cette sauvegarde.
+
+
+ Effacer les données de ton navigateur, changer d'appareil ou utiliser
+ la navigation privée efface aussi cette sauvegarde.
+
+
+
Publicités
+
+ Ce site peut afficher des publicités fournies par Google (AdSense /
+ Google Ad Placement API pour jeux HTML5). Pour proposer des annonces
+ pertinentes, Google peut utiliser des cookies ou identifiants
+ similaires sur cet appareil. Ni ce site ni son développeur ne
+ reçoivent ni ne stockent ces données — elles sont gérées directement
+ par Google selon sa propre politique de confidentialité :
+
+ policies.google.com/technologies/ads
+ .
+
+
+ Tu peux consulter et modifier tes préférences de publicité
+ personnalisée sur
+ adssettings.google.com .
+
+
+
Cookies
+
+ Le jeu lui-même ne dépose aucun cookie. Seules les publicités
+ éventuellement affichées (voir ci-dessus) peuvent en utiliser.
+
+
+
Contact
+
+ Une question sur cette politique ? Ouvre une issue sur le dépôt
+ GitHub du projet.
+
+
+
+
diff --git a/banana-collector/sounds.js b/banana-collector/sounds.js
new file mode 100644
index 000000000..74e2d6d96
--- /dev/null
+++ b/banana-collector/sounds.js
@@ -0,0 +1,71 @@
+/* ============================================================
+ Banana Collector — Effets sonores synthétisés (Web Audio API)
+ Aucun fichier audio externe : tous les sons sont générés à la
+ volée par oscillateurs, pour rester ultra léger. Respecte
+ state.settings.muted et la politique navigateur qui interdit de
+ jouer un son avant un premier geste utilisateur (clic).
+ ============================================================ */
+
+const SFX = (() => {
+ let ctx = null;
+
+ function getCtx() {
+ if (!ctx) {
+ const AudioCtor = window.AudioContext || window.webkitAudioContext;
+ if (!AudioCtor) return null;
+ ctx = new AudioCtor();
+ }
+ if (ctx.state === "suspended") ctx.resume();
+ return ctx;
+ }
+
+ function beep({ freq = 440, duration = 0.15, type = "sine", gain = 0.15, sweep = null, delay = 0 }) {
+ if (typeof state !== "undefined" && state.settings && state.settings.muted) return;
+ const audioCtx = getCtx();
+ if (!audioCtx) return;
+ try {
+ const t0 = audioCtx.currentTime + delay;
+ const osc = audioCtx.createOscillator();
+ const g = audioCtx.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, t0);
+ if (sweep) osc.frequency.exponentialRampToValueAtTime(sweep, t0 + duration);
+ g.gain.setValueAtTime(0, t0);
+ g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
+ osc.connect(g).connect(audioCtx.destination);
+ osc.start(t0);
+ osc.stop(t0 + duration + 0.02);
+ } catch (e) {
+ // Environnement sans audio disponible : on ignore silencieusement.
+ }
+ }
+
+ return {
+ click: () => beep({ freq: 520, duration: 0.08, type: "square", gain: 0.08 }),
+ coin: () => beep({ freq: 880, duration: 0.12, type: "triangle", gain: 0.12, sweep: 1320 }),
+ harvestCommon: () => beep({ freq: 400, duration: 0.12, type: "sine", gain: 0.1 }),
+ harvestRare: () => {
+ beep({ freq: 600, duration: 0.12, type: "sine", gain: 0.12 });
+ beep({ freq: 900, duration: 0.18, type: "sine", gain: 0.12, delay: 0.1 });
+ },
+ harvestEpic: () => {
+ [660, 880, 1100].forEach((f, i) => beep({ freq: f, duration: 0.15, type: "triangle", delay: i * 0.09, gain: 0.14 }));
+ },
+ harvestMythic: () => {
+ [660, 880, 1100, 1320].forEach((f, i) => beep({ freq: f, duration: 0.2, type: "triangle", delay: i * 0.1, gain: 0.16 }));
+ },
+ win: () => {
+ [523, 659, 784, 1046].forEach((f, i) => beep({ freq: f, duration: 0.16, type: "triangle", delay: i * 0.08, gain: 0.14 }));
+ },
+ lose: () => beep({ freq: 220, duration: 0.35, type: "sawtooth", gain: 0.12, sweep: 110 }),
+ achievement: () => {
+ [784, 988, 1174, 1568].forEach((f, i) => beep({ freq: f, duration: 0.18, type: "sine", delay: i * 0.08, gain: 0.15 }));
+ },
+ quest: () => {
+ [523, 784, 1046].forEach((f, i) => beep({ freq: f, duration: 0.15, type: "sine", delay: i * 0.07, gain: 0.13 }));
+ },
+ wheelTick: () => beep({ freq: 300, duration: 0.04, type: "square", gain: 0.06 }),
+ buy: () => beep({ freq: 700, duration: 0.1, type: "square", gain: 0.1, sweep: 950 }),
+ };
+})();
diff --git a/banana-collector/style.css b/banana-collector/style.css
new file mode 100644
index 000000000..9853d980f
--- /dev/null
+++ b/banana-collector/style.css
@@ -0,0 +1,1311 @@
+:root {
+ --bg-1: #fff6d5;
+ --bg-2: #ffe08a;
+ --text: #3d2b00;
+ --card-bg: #ffffff;
+ --accent: #ffb703;
+ --accent-dark: #e08e00;
+ --danger: #e63946;
+ --radius: 16px;
+ --shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: "Segoe UI", "Trebuchet MS", Verdana, sans-serif;
+ background: linear-gradient(160deg, var(--bg-1), var(--bg-2));
+ color: var(--text);
+ min-height: 100vh;
+}
+
+.app {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 12px 16px 60px;
+}
+
+.topbar {
+ text-align: center;
+ padding: 12px 0 6px;
+}
+
+.topbar h1 {
+ margin: 0 0 8px;
+ font-size: clamp(1.6rem, 5vw, 2.4rem);
+ text-shadow: 2px 2px 0 rgba(255, 255, 255, 0.6);
+}
+
+.stats-bar {
+ display: flex;
+ justify-content: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ font-weight: 700;
+ font-size: 1.05rem;
+}
+
+.stats-bar span {
+ background: var(--card-bg);
+ border-radius: 999px;
+ padding: 6px 16px;
+ box-shadow: var(--shadow);
+}
+
+.mute-btn {
+ border: none;
+ background: var(--card-bg);
+ border-radius: 999px;
+ width: 2.2rem;
+ height: 2.2rem;
+ font-size: 1.1rem;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ line-height: 1;
+}
+.mute-btn.muted { opacity: 0.55; }
+
+.account-btn {
+ border: none;
+ background: var(--card-bg);
+ border-radius: 999px;
+ padding: 6px 14px;
+ font-weight: 700;
+ font-size: 0.85rem;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+}
+.account-btn.linked { background: var(--accent); color: #2b1a00; }
+
+/* --- Onglets --- */
+
+.tabs {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin: 16px 0;
+}
+
+.tab-btn {
+ border: none;
+ background: rgba(255, 255, 255, 0.6);
+ color: var(--text);
+ font-weight: 700;
+ padding: 10px 16px;
+ border-radius: 999px;
+ cursor: pointer;
+ font-size: 0.95rem;
+ transition: transform 0.15s ease, background 0.15s ease;
+}
+
+.tab-btn:hover { transform: translateY(-2px); }
+
+.tab-btn.active {
+ background: var(--accent);
+ color: #2b1a00;
+ box-shadow: var(--shadow);
+}
+
+.tab-panel { display: none; }
+.tab-panel.active { display: block; animation: fadeIn 0.25s ease; }
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* --- Accueil / récolte --- */
+
+#tab-accueil { text-align: center; }
+
+.harvest-btn {
+ font-size: clamp(1.1rem, 4vw, 1.5rem);
+ font-weight: 800;
+ padding: 22px 30px;
+ border: none;
+ border-radius: 20px;
+ background: linear-gradient(135deg, #ffd23f, #ff9f1c);
+ color: #3d2200;
+ box-shadow: 0 8px 0 var(--accent-dark), var(--shadow);
+ cursor: pointer;
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
+ width: min(100%, 420px);
+}
+
+.harvest-btn:hover { transform: translateY(-2px); }
+.harvest-btn:active {
+ transform: translateY(4px);
+ box-shadow: 0 4px 0 var(--accent-dark), var(--shadow);
+}
+.harvest-btn:disabled { opacity: 0.75; cursor: default; }
+
+.last-banana-zone {
+ margin: 26px auto 0;
+ min-height: 320px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.empty-hint {
+ color: #7a6224;
+ font-style: italic;
+}
+
+/* --- Carte "héros" de la dernière banane récoltée --- */
+
+.harvest-reveal-card {
+ --rarity-color: #9e9e9e;
+ --rarity-glow: #c9c9c9;
+ position: relative;
+ width: min(100%, 320px);
+ margin: 0 auto;
+ padding: 34px 20px 26px;
+ border-radius: 26px;
+ border: 4px solid var(--rarity-color);
+ background: radial-gradient(circle at 50% 30%, #fffef6, var(--card-bg) 70%);
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16), 0 0 0 6px rgba(255, 255, 255, 0.5) inset;
+ overflow: hidden;
+ text-align: center;
+}
+
+.harvest-reveal-glow {
+ position: absolute;
+ top: -20%;
+ left: 50%;
+ width: 220px;
+ height: 220px;
+ transform: translateX(-50%);
+ background: radial-gradient(circle, var(--rarity-glow) 0%, transparent 70%);
+ opacity: 0.55;
+ pointer-events: none;
+}
+
+.harvest-reveal-card .banana-icon {
+ position: relative;
+ margin: 6px auto 14px;
+ animation: heroFloat 2.6s ease-in-out infinite;
+}
+
+@keyframes heroFloat {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-8px); }
+}
+
+.harvest-reveal-name {
+ position: relative;
+ font-family: inherit;
+ font-weight: 800;
+ font-size: 1.35rem;
+ margin-top: 4px;
+}
+
+.harvest-reveal-rarity-pill {
+ position: relative;
+ display: inline-block;
+ margin-top: 8px;
+ padding: 4px 16px;
+ border-radius: 999px;
+ background: var(--rarity-color);
+ color: #fff;
+ font-weight: 800;
+ font-size: 0.78rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.harvest-reveal-meta {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-top: 14px;
+ font-weight: 800;
+ font-size: 1.05rem;
+ color: #5c4a1a;
+}
+
+.harvest-reveal-card.rarity-secrete {
+ background: radial-gradient(circle at 50% 30%, #2a2145, #1c1c2b 70%);
+ color: white;
+}
+.harvest-reveal-card.rarity-secrete .harvest-reveal-name,
+.harvest-reveal-card.rarity-secrete .harvest-reveal-meta { color: #f0e8ff; }
+
+.harvest-reveal-card.rarity-mythique {
+ background: radial-gradient(circle at 50% 25%, #fff0f7, var(--card-bg) 75%);
+}
+
+.harvest-reveal-card.pop-in { animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }
+.harvest-reveal-card.glow-pulse {
+ animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), heroGlowPulse 1.6s ease-in-out infinite;
+}
+
+@keyframes heroGlowPulse {
+ 0%, 100% { box-shadow: 0 0 18px 4px var(--rarity-glow), 0 14px 32px rgba(0, 0, 0, 0.16); }
+ 50% { box-shadow: 0 0 36px 12px var(--rarity-glow), 0 14px 32px rgba(0, 0, 0, 0.16); }
+}
+
+/* --- Cartes banane --- */
+
+.banana-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.banana-card {
+ --rarity-color: #9e9e9e;
+ --rarity-glow: #c9c9c9;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ border: 3px solid var(--rarity-color);
+ box-shadow: 0 0 0 rgba(0,0,0,0), var(--shadow);
+ padding: 14px 10px;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.banana-card.locked {
+ border-color: #cfcfcf;
+ filter: grayscale(1);
+ opacity: 0.7;
+}
+
+.banana-emoji {
+ font-size: 2.4rem;
+ line-height: 1;
+ margin-bottom: 6px;
+}
+
+.banana-emoji.silhouette { filter: brightness(0.15); opacity: 0.5; }
+
+/* --- Icône banane fusionnée (glyphe + accessoires) --- */
+
+.banana-icon {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ --icon-size: 2.4rem;
+ width: var(--icon-size);
+ height: var(--icon-size);
+ margin: 0 auto 6px;
+}
+
+.banana-icon-glyph {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--icon-size);
+ line-height: 1;
+}
+
+.banana-icon-img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
+}
+
+.inline-banana-icon {
+ display: inline-block;
+ height: 1.1em;
+ width: 1.1em;
+ object-fit: contain;
+ vertical-align: -0.2em;
+}
+
+.deco { position: absolute; pointer-events: none; }
+
+.deco-text {
+ font-size: calc(var(--icon-size) * 0.4);
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* --- Catalogue de formes d'accessoires ---
+ Chaque forme a un dégradé + une ombre intégrés, pour éviter tout
+ effet de rectangle plat ou de trait brut : `--deco-color-a/-b`
+ (définies en inline par bananaIconHTML) pilotent la teinte. */
+
+.deco-band {
+ border-radius: 5px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(0, 0, 0, 0.18)),
+ linear-gradient(180deg, var(--deco-color-a), var(--deco-color-b));
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.3);
+}
+
+.deco-orb {
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.7), rgba(0, 0, 0, 0.15) 75%),
+ linear-gradient(180deg, var(--deco-color-a), var(--deco-color-b));
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+}
+
+.deco-ring {
+ border-radius: 50%;
+ border: 2px solid var(--deco-color-a);
+ background: rgba(255, 255, 255, 0.22);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 0 4px rgba(255, 255, 255, 0.4);
+}
+
+.deco-peak-up, .deco-peak-down, .deco-peak-out-left, .deco-peak-out-right {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(0, 0, 0, 0.15)),
+ linear-gradient(180deg, var(--deco-color-a), var(--deco-color-b));
+ filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35));
+}
+
+.deco-peak-up { clip-path: polygon(50% 0, 0 100%, 100% 100%); }
+.deco-peak-down { clip-path: polygon(0 0, 100% 0, 50% 100%); }
+.deco-peak-out-left { clip-path: polygon(100% 0, 0 50%, 100% 100%); }
+.deco-peak-out-right { clip-path: polygon(0 0, 100% 50%, 0 100%); }
+
+.deco-bubble {
+ border-radius: 10px;
+ background: linear-gradient(180deg, #fff, #f2f2f2);
+ border: 2px solid #333;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
+}
+
+@keyframes hueSpin {
+ from { filter: saturate(1.6) drop-shadow(0 0 8px #ff9fd0) hue-rotate(0deg); }
+ to { filter: saturate(1.6) drop-shadow(0 0 8px #ff9fd0) hue-rotate(360deg); }
+}
+
+.anim-rainbow { animation: hueSpin 3s linear infinite; }
+
+@media (prefers-reduced-motion: reduce) {
+ .anim-rainbow { animation: none; }
+}
+
+.banana-name {
+ font-weight: 700;
+ font-size: 0.92rem;
+ min-height: 2.4em;
+}
+
+.banana-rarity {
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--rarity-color);
+ text-transform: uppercase;
+ margin-top: 2px;
+}
+
+.banana-value, .banana-count {
+ font-size: 0.85rem;
+ margin-top: 2px;
+ color: #5c4a1a;
+}
+
+.banana-card.pop-in { animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }
+
+@keyframes popIn {
+ 0% { transform: scale(0.4) rotate(-8deg); opacity: 0; }
+ 100% { transform: scale(1) rotate(0); opacity: 1; }
+}
+
+.banana-card.glow-pulse {
+ animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), glowPulse 1.4s ease-in-out infinite;
+}
+
+@keyframes glowPulse {
+ 0%, 100% { box-shadow: 0 0 8px 2px var(--rarity-glow), var(--shadow); }
+ 50% { box-shadow: 0 0 24px 8px var(--rarity-glow), var(--shadow); }
+}
+
+.new-badge {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(90deg, #ff5da2, #ff9f1c);
+ color: white;
+ font-size: 0.7rem;
+ font-weight: 800;
+ padding: 3px 0;
+ letter-spacing: 0.03em;
+}
+
+/* --- Rareté couleurs de fond légères --- */
+.rarity-secrete { background: linear-gradient(135deg, #1c1c2b, #3a2d5c); color: white; }
+.rarity-secrete .banana-name, .rarity-secrete .banana-value, .rarity-secrete .banana-count { color: #f0e8ff; }
+.rarity-mythique { background: linear-gradient(135deg, #fff0f7, #ffe0f0); }
+
+/* --- Collection / progression --- */
+
+.progress-wrap { margin: 14px 0 20px; }
+
+#progress-label { font-weight: 700; }
+
+.progress-bar {
+ height: 16px;
+ border-radius: 999px;
+ background: #f0e2b6;
+ overflow: hidden;
+ margin-top: 6px;
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);
+}
+
+.progress-bar-fill {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, #ffd23f, #ff5da2);
+ transition: width 0.4s ease;
+}
+
+.secret-section { margin-top: 30px; }
+.secret-hint { font-style: italic; color: #6b5a30; }
+
+/* --- Marché --- */
+
+.market-subtabs { display: flex; gap: 8px; margin: 14px 0; }
+
+.market-subtab-btn {
+ border: none;
+ background: rgba(255, 255, 255, 0.6);
+ color: var(--text);
+ font-weight: 700;
+ padding: 8px 16px;
+ border-radius: 999px;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+.market-subtab-btn.active { background: var(--accent); color: #2b1a00; box-shadow: var(--shadow); }
+
+.market-view.hidden { display: none; }
+
+.market-listings {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.market-listing-card {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 12px 10px;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+
+.market-listing-seller { font-size: 0.75rem; color: #7a6224; margin-top: 4px; }
+.market-listing-qty { font-size: 0.8rem; color: #5c4a1a; margin-top: 2px; }
+.market-listing-price { font-weight: 800; color: var(--accent-dark); margin-top: 6px; }
+.market-listing-status { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; margin-top: 4px; }
+.market-listing-status.sold { color: #4caf50; }
+.market-listing-status.cancelled { color: #9c874f; }
+
+.market-buy-btn, .market-cancel-btn { margin-top: 8px; width: 100%; font-size: 0.82rem; padding: 8px 10px; }
+
+.market-sell-form {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 16px;
+ box-shadow: var(--shadow);
+ margin-bottom: 16px;
+}
+
+.market-sell-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+ max-height: 220px;
+ overflow-y: auto;
+}
+
+.market-sell-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ background: #fff6d5;
+ border: 2px solid #f0dfa8;
+ border-radius: 12px;
+ padding: 6px 8px;
+ cursor: pointer;
+ font-family: inherit;
+}
+.market-sell-option.selected { border-color: var(--accent-dark); box-shadow: 0 0 0 2px var(--accent); }
+.market-sell-option-count { font-size: 0.68rem; font-weight: 700; color: #5c4a1a; }
+
+.market-sell-inputs { display: flex; gap: 8px; flex-wrap: wrap; }
+.market-sell-inputs input {
+ flex: 1;
+ min-width: 100px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 2px solid #f0dfa8;
+ font-family: inherit;
+ font-size: 0.9rem;
+}
+.market-sell-inputs .btn { flex-basis: 100%; }
+
+/* --- Boutique --- */
+
+.shop-hint { color: #6b5a30; }
+
+.shop-list { display: flex; flex-direction: column; gap: 12px; }
+
+.shop-item {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ box-shadow: var(--shadow);
+ flex-wrap: wrap;
+}
+
+.shop-item-name { font-weight: 700; }
+.shop-item-level { font-weight: 400; font-size: 0.8rem; color: #7a6224; margin-left: 6px; }
+.shop-item-desc { font-size: 0.85rem; color: #5c4a1a; margin-top: 2px; }
+
+/* --- Quêtes quotidiennes --- */
+
+.quests-list { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
+
+.quest-item {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ box-shadow: var(--shadow);
+}
+
+.quest-item.done { opacity: 0.65; }
+
+.quest-item-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-weight: 700;
+}
+
+.quest-item-desc { flex: 1; }
+.quest-item-reward { font-weight: 800; color: var(--accent-dark); white-space: nowrap; }
+
+.quest-progress-bar {
+ height: 10px;
+ border-radius: 999px;
+ background: #f0e2b6;
+ overflow: hidden;
+ margin-top: 8px;
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);
+}
+
+.quest-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ffd23f, #ff5da2);
+ transition: width 0.4s ease;
+}
+
+.quest-item-count { font-size: 0.78rem; color: #7a6224; margin-top: 4px; }
+
+/* --- Boutons génériques --- */
+
+.btn {
+ border: none;
+ border-radius: 999px;
+ padding: 10px 18px;
+ font-weight: 700;
+ background: var(--accent);
+ color: #2b1a00;
+ cursor: pointer;
+ transition: transform 0.1s ease;
+}
+.btn:hover { transform: translateY(-1px); }
+.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
+.btn.danger { background: var(--danger); color: white; }
+
+.reset-btn { margin-top: 24px; }
+
+/* --- Publicité --- */
+
+.ad-slot {
+ border: 2px dashed #d8c383;
+ border-radius: var(--radius);
+ background: rgba(255, 255, 255, 0.5);
+ padding: 34px 16px;
+ text-align: center;
+ margin: 6px 0 20px;
+}
+
+.ad-slot-label {
+ font-weight: 800;
+ color: #7a6224;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ font-size: 0.85rem;
+}
+
+.ad-slot-sub {
+ font-size: 0.8rem;
+ color: #9c874f;
+ margin-top: 6px;
+}
+
+.watch-ad-btn {
+ display: block;
+ width: min(100%, 340px);
+ margin: 0 auto;
+ padding: 14px 20px;
+ font-size: 1rem;
+}
+
+.ad-quota {
+ text-align: center;
+ color: #7a6224;
+ font-size: 0.85rem;
+ margin-top: 10px;
+}
+
+.privacy-link {
+ text-align: center;
+ margin-top: 18px;
+}
+
+.privacy-link a {
+ color: #b5680b;
+ font-size: 0.8rem;
+}
+
+.hidden { display: none !important; }
+
+/* --- Mini-jeux : menu --- */
+
+.game-menu { display: flex; flex-direction: column; gap: 14px; }
+
+.game-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ width: 100%;
+ background: var(--card-bg);
+ border: 2px solid #f0dfa8;
+ border-radius: var(--radius);
+ padding: 16px 18px;
+ text-align: left;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ font-family: inherit;
+ transition: transform 0.15s ease;
+}
+.game-card:hover { transform: translateY(-2px); }
+
+.game-card-icon { font-size: 2.2rem; line-height: 1; flex-shrink: 0; }
+.game-card-body { display: flex; flex-direction: column; gap: 3px; }
+.game-card-title { font-weight: 800; font-size: 1.05rem; }
+.game-card-desc { font-size: 0.85rem; color: #5c4a1a; }
+.game-card-best { font-size: 0.8rem; font-weight: 700; color: var(--accent-dark); }
+
+.game-back-btn { margin-bottom: 14px; }
+
+/* --- Mini-jeu : Attrape les bananes --- */
+
+.catch-hud {
+ display: flex;
+ justify-content: space-between;
+ font-weight: 800;
+ font-size: 1.1rem;
+ margin-bottom: 10px;
+}
+
+.catch-area {
+ position: relative;
+ width: 100%;
+ height: 380px;
+ border-radius: var(--radius);
+ border: 3px solid var(--accent);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ transition: background 0.6s ease;
+}
+
+/* Fond de jungle : chaque niveau assombrit un peu plus le décor,
+ pour appuyer visuellement la montée en difficulté. */
+.catch-area.level-1 { background: linear-gradient(180deg, #dff2c8 0%, #a8d98a 35%, #6fb35a 70%, #4f9646 100%); }
+.catch-area.level-2 { background: linear-gradient(180deg, #a8d98a 0%, #5fa64c 40%, #3c7d38 75%, #2c5f2a 100%); }
+.catch-area.level-3 { background: linear-gradient(180deg, #3c7d38 0%, #245023 45%, #163116 80%, #0c1f0c 100%); }
+
+.catch-jungle-deco {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+.catch-jungle-deco span {
+ position: absolute;
+ opacity: 0.35;
+ filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.25));
+}
+
+
+.catch-start-overlay,
+.catch-result {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ background: rgba(255, 246, 213, 0.92);
+ text-align: center;
+ padding: 20px;
+}
+
+.catch-instructions { color: #5c4a1a; max-width: 240px; }
+
+.catch-result-title { font-size: 1.3rem; font-weight: 800; }
+.catch-result-line { font-size: 0.95rem; color: #5c4a1a; }
+.catch-result-coins { font-size: 1.4rem; font-weight: 800; color: var(--accent-dark); }
+
+.catch-item {
+ position: absolute;
+ top: -50px;
+ cursor: pointer;
+ transition: top linear;
+ user-select: none;
+}
+
+/* --- Mini-jeu : Roue de la fortune --- */
+
+.wheel-wrap {
+ position: relative;
+ width: min(240px, 70vw);
+ aspect-ratio: 1;
+ margin: 10px auto 20px;
+}
+
+.wheel-pointer {
+ position: absolute;
+ top: -14px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 1.6rem;
+ color: var(--accent-dark);
+ z-index: 2;
+}
+
+.wheel-disc {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ border: 6px solid #fff;
+ box-shadow: var(--shadow), 0 0 0 3px var(--accent-dark);
+ background: conic-gradient(
+ #ffd23f 0deg 60deg,
+ #ff9f1c 60deg 120deg,
+ #ff5da2 120deg 180deg,
+ #9c27b0 180deg 240deg,
+ #2196f3 240deg 300deg,
+ #4caf50 300deg 360deg
+ );
+ transition: transform 4s cubic-bezier(0.15, 0.7, 0.2, 1);
+ position: relative;
+}
+
+.wheel-label-pivot {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 0;
+ height: 38%;
+ transform-origin: top center;
+}
+
+.wheel-label {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ display: inline-block;
+ font-weight: 800;
+ font-size: 0.95rem;
+ color: #fff;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
+ white-space: nowrap;
+}
+
+/* --- Statistiques --- */
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+}
+
+.stat-box {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 16px;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+
+.stat-num { font-size: 1.3rem; font-weight: 800; word-break: break-word; }
+.stat-label { font-size: 0.85rem; color: #5c4a1a; margin-top: 4px; }
+
+/* --- Succès --- */
+
+.achievements-heading {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 28px;
+}
+
+.achievements-count {
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: #7a6224;
+}
+
+.achievements-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.achievement-badge {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 12px 16px;
+ box-shadow: var(--shadow);
+}
+
+.achievement-badge.locked { opacity: 0.55; }
+
+.achievement-icon { font-size: 1.7rem; flex-shrink: 0; width: 1.7rem; text-align: center; }
+.achievement-info { flex: 1; min-width: 0; }
+.achievement-name { font-weight: 800; font-size: 0.95rem; }
+.achievement-desc { font-size: 0.8rem; color: #5c4a1a; margin-top: 2px; }
+.achievement-reward { font-weight: 800; font-size: 0.85rem; color: var(--accent-dark); white-space: nowrap; }
+
+/* --- Combat : l'Arène des Ananas --- */
+
+.pve-picker { margin: 12px 0; }
+.pve-picker-label { font-weight: 700; display: block; margin-bottom: 8px; }
+
+.pve-banana-select {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pve-banana-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ background: var(--card-bg);
+ border: 2px solid #f0dfa8;
+ border-radius: 12px;
+ padding: 6px 8px;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.pve-banana-option.selected {
+ border-color: var(--accent-dark);
+ background: #fff6d5;
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.pve-banana-stats { font-size: 0.68rem; font-weight: 700; color: #5c4a1a; }
+
+.pve-vs {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ margin: 22px 0;
+}
+
+.pve-fighter {
+ flex: 1;
+ max-width: 160px;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 16px 10px;
+ text-align: center;
+ box-shadow: var(--shadow);
+ min-height: 120px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.pve-fighter-empty { color: #7a6224; font-style: italic; font-size: 0.85rem; }
+.pve-fighter-name { font-weight: 800; font-size: 0.85rem; margin-top: 6px; }
+.pve-fighter-stats { font-size: 0.78rem; color: #5c4a1a; margin-top: 2px; }
+.pve-enemy-icon { line-height: 1; }
+
+.pve-vs-mark {
+ font-size: 1.6rem;
+ flex-shrink: 0;
+ transition: transform 0.15s ease;
+}
+.pve-vs-mark.clash { animation: pveClash 0.5s ease; }
+
+@keyframes pveClash {
+ 0%, 100% { transform: scale(1) rotate(0deg); }
+ 40% { transform: scale(1.6) rotate(-15deg); }
+ 60% { transform: scale(1.6) rotate(15deg); }
+}
+
+.pve-result {
+ margin-top: 16px;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 16px;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+
+.pve-result-title { font-size: 1.2rem; font-weight: 800; }
+.pve-result-line { font-size: 0.9rem; color: #5c4a1a; margin-top: 4px; }
+.pve-result-coins { font-size: 1.2rem; font-weight: 800; color: var(--accent-dark); margin-top: 6px; }
+
+.pve-stage-label { font-weight: 700; margin: 22px 0 8px; }
+
+.pve-stage-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.pve-stage-group-label {
+ font-weight: 700;
+ font-size: 0.85rem;
+ color: #7a6224;
+ margin-bottom: 6px;
+}
+
+.pve-stage-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pve-stage-chip {
+ position: relative;
+ background: var(--card-bg);
+ border: 2px solid #f0dfa8;
+ border-radius: 12px;
+ padding: 8px 12px;
+ font-size: 1.3rem;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.pve-stage-chip.selected { border-color: var(--accent-dark); box-shadow: 0 0 0 2px var(--accent); }
+.pve-stage-chip.locked { opacity: 0.5; cursor: not-allowed; }
+.pve-stage-chip .pve-stage-check { position: absolute; bottom: -4px; right: -4px; font-size: 0.7rem; }
+
+/* --- Arène PVP --- */
+
+.pvp-team-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 10px 0;
+}
+.pvp-team-count { font-weight: 700; color: #5c4a1a; }
+
+.pve-banana-option.pvp-locked-slot { opacity: 0.4; cursor: not-allowed; }
+
+.pvp-team-slots {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pvp-slot {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.pvp-slot-label {
+ font-weight: 700;
+ color: #5c4a1a;
+ min-width: 110px;
+}
+
+.pvp-slot-select {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 2px solid #f0dfa8;
+ border-radius: 12px;
+ background: var(--card-bg);
+ font-family: inherit;
+ font-size: 0.9rem;
+ color: inherit;
+}
+
+.pvp-opponent-card {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 14px;
+ margin: 10px 0;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+.pvp-opponent-name { font-weight: 800; font-size: 1.05rem; }
+.pvp-opponent-power { font-size: 0.85rem; color: #5c4a1a; margin-top: 4px; }
+
+.pvp-report-card {
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ padding: 12px 16px;
+ margin-bottom: 10px;
+ box-shadow: var(--shadow);
+ border-left: 4px solid var(--accent);
+}
+.pvp-report-card.lost { border-left-color: var(--danger); }
+.pvp-report-title { font-weight: 800; }
+.pvp-report-line { font-size: 0.85rem; color: #5c4a1a; margin-top: 2px; }
+
+/* --- Classement --- */
+
+.leaderboard-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+}
+
+.leaderboard-table th,
+.leaderboard-table td {
+ padding: 10px 12px;
+ text-align: left;
+ font-size: 0.9rem;
+}
+
+.leaderboard-table thead th {
+ background: #f0dfa8;
+ font-weight: 800;
+ color: #5c4a1a;
+}
+
+.leaderboard-table tbody tr:nth-child(even) { background: rgba(0, 0, 0, 0.03); }
+
+.leaderboard-table tbody tr.leaderboard-me {
+ background: #fff6d5;
+ box-shadow: inset 0 0 0 2px var(--accent);
+ font-weight: 700;
+}
+
+/* --- Overlay épique / mythique --- */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(10, 5, 20, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+.overlay.show { opacity: 1; }
+.overlay.hidden { display: none; }
+
+.overlay-content {
+ text-align: center;
+ color: white;
+ transform: scale(0.7);
+ transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.overlay.show .overlay-content { transform: scale(1); }
+
+.epic-sparkles { font-size: 1.8rem; animation: spin 2s linear infinite; }
+@keyframes spin { from { filter: hue-rotate(0deg); } to { filter: hue-rotate(360deg); } }
+
+.epic-title {
+ font-size: clamp(1.6rem, 6vw, 2.6rem);
+ font-weight: 900;
+ margin: 10px 0;
+ background: linear-gradient(90deg, #ff5da2, #ffd23f, #6fc3ff, #ff5da2);
+ background-size: 300% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: rainbow 2s linear infinite;
+}
+@keyframes rainbow { from { background-position: 0% 0; } to { background-position: 300% 0; } }
+
+.epic-emoji { margin: 10px 0; display: flex; justify-content: center; }
+.epic-sub { font-weight: 700; max-width: 320px; margin: 0 auto 10px; }
+.epic-name { font-size: 1.1rem; font-style: italic; margin-bottom: 16px; }
+.epic-close { margin-top: 6px; }
+
+/* --- Bannière légendaire --- */
+
+.rare-banner {
+ position: fixed;
+ top: 16px;
+ left: 50%;
+ transform: translate(-50%, -140%);
+ background: linear-gradient(135deg, #ff9800, #ff5722);
+ color: white;
+ padding: 14px 22px;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ text-align: center;
+ z-index: 90;
+ transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.rare-banner.show { transform: translate(-50%, 0); }
+.rare-banner-title { display: block; font-weight: 900; font-size: 1.2rem; }
+.rare-banner-name { display: block; font-size: 0.95rem; margin-top: 4px; }
+
+/* --- Confettis --- */
+
+.toast-layer {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 80;
+ overflow: hidden;
+}
+
+.confetti-piece {
+ position: absolute;
+ top: -40px;
+ animation: fall linear forwards;
+}
+@keyframes fall {
+ to { transform: translateY(110vh) rotate(360deg); opacity: 0.9; }
+}
+
+/* --- Modal de confirmation --- */
+
+.modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 110;
+}
+.modal.hidden { display: none; }
+
+.modal-box {
+ background: white;
+ border-radius: var(--radius);
+ padding: 22px;
+ max-width: 340px;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+
+.modal-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ margin-top: 16px;
+}
+
+/* --- Modal compte --- */
+
+.account-modal-box { position: relative; max-width: 380px; text-align: left; }
+
+.modal-close-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ border: none;
+ background: none;
+ font-size: 1.1rem;
+ cursor: pointer;
+ color: #7a6224;
+ padding: 4px;
+ line-height: 1;
+}
+
+.account-form h3 { margin: 0 0 12px; text-align: center; }
+
+.account-field { margin-bottom: 12px; }
+.account-field label { display: block; font-weight: 700; font-size: 0.85rem; margin-bottom: 4px; }
+.account-field input {
+ width: 100%;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 2px solid #f0dfa8;
+ font-size: 0.95rem;
+ font-family: inherit;
+ box-sizing: border-box;
+}
+.account-field input:focus { outline: none; border-color: var(--accent-dark); }
+
+.account-warning {
+ font-size: 0.78rem;
+ color: #9c5b0b;
+ background: #fff3d6;
+ border-radius: 10px;
+ padding: 8px 10px;
+ margin-bottom: 14px;
+}
+
+.account-error {
+ font-size: 0.82rem;
+ color: var(--danger);
+ margin: -4px 0 12px;
+ min-height: 1em;
+}
+
+.account-form-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
+.account-switch-mode {
+ background: none;
+ border: none;
+ color: #7a6224;
+ font-size: 0.82rem;
+ text-decoration: underline;
+ cursor: pointer;
+ padding: 4px;
+}
+
+.account-logged-in { text-align: center; }
+.account-logged-in .account-username { font-size: 1.2rem; font-weight: 800; margin-bottom: 4px; }
+.account-logged-in .account-hint { font-size: 0.82rem; color: #5c4a1a; margin-bottom: 16px; }
+
+/* --- Responsive --- */
+
+@media (max-width: 480px) {
+ .banana-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); }
+ .stats-bar { font-size: 0.9rem; }
+ .tab-btn { padding: 8px 12px; font-size: 0.85rem; }
+}
diff --git a/banana-collector/ui.js b/banana-collector/ui.js
new file mode 100644
index 000000000..6b846bbfe
--- /dev/null
+++ b/banana-collector/ui.js
@@ -0,0 +1,1734 @@
+/* ============================================================
+ Banana Collector — Interface (rendu DOM, onglets, animations)
+ ============================================================ */
+
+document.addEventListener("DOMContentLoaded", () => {
+ const els = {
+ statCollection: document.getElementById("stat-collection"),
+ statCoins: document.getElementById("stat-coins"),
+ tabButtons: Array.from(document.querySelectorAll(".tab-btn")),
+ tabPanels: Array.from(document.querySelectorAll(".tab-panel")),
+ harvestBtn: document.getElementById("harvest-btn"),
+ lastBanana: document.getElementById("last-banana"),
+ collectionGrid: document.getElementById("collection-grid"),
+ secretGrid: document.getElementById("secret-grid"),
+ secretSection: document.getElementById("secret-section"),
+ progressBarFill: document.getElementById("progress-bar-fill"),
+ progressLabel: document.getElementById("progress-label"),
+ shopList: document.getElementById("shop-list"),
+ questsList: document.getElementById("quests-list"),
+ muteBtn: document.getElementById("mute-btn"),
+ watchAdBtn: document.getElementById("watch-ad-btn"),
+ adQuota: document.getElementById("ad-quota"),
+ statsPanel: document.getElementById("stats-content"),
+ overlay: document.getElementById("overlay"),
+ overlayContent: document.getElementById("overlay-content"),
+ resetBtn: document.getElementById("reset-btn"),
+ confirmModal: document.getElementById("confirm-modal"),
+ confirmYes: document.getElementById("confirm-yes"),
+ confirmNo: document.getElementById("confirm-no"),
+ toastLayer: document.getElementById("toast-layer"),
+ minigamesMenu: document.getElementById("minigames-menu"),
+ openCatchGame: document.getElementById("open-catch-game"),
+ openWheelGame: document.getElementById("open-wheel-game"),
+ catchBestLabel: document.getElementById("catch-best-label"),
+ wheelStatusLabel: document.getElementById("wheel-status-label"),
+ minigameCatch: document.getElementById("minigame-catch"),
+ catchTimer: document.getElementById("catch-timer"),
+ catchScore: document.getElementById("catch-score"),
+ catchArea: document.getElementById("catch-area"),
+ catchStartOverlay: document.getElementById("catch-start-overlay"),
+ catchStartBtn: document.getElementById("catch-start-btn"),
+ catchResult: document.getElementById("catch-result"),
+ minigameWheel: document.getElementById("minigame-wheel"),
+ wheelDisc: document.getElementById("wheel-disc"),
+ wheelSpinBtn: document.getElementById("wheel-spin-btn"),
+ wheelStatus: document.getElementById("wheel-status"),
+ achievementsPanel: document.getElementById("achievements-content"),
+ accountBtn: document.getElementById("account-btn"),
+ accountModal: document.getElementById("account-modal"),
+ accountModalContent: document.getElementById("account-modal-content"),
+ accountModalClose: document.getElementById("account-modal-close"),
+ marketLocked: document.getElementById("market-locked"),
+ marketContent: document.getElementById("market-content"),
+ marketTabBuy: document.getElementById("market-tab-buy"),
+ marketTabSell: document.getElementById("market-tab-sell"),
+ marketBuyView: document.getElementById("market-buy-view"),
+ marketSellView: document.getElementById("market-sell-view"),
+ marketListings: document.getElementById("market-listings"),
+ marketSellPicker: document.getElementById("market-sell-picker"),
+ marketSellQuantity: document.getElementById("market-sell-quantity"),
+ marketSellPrice: document.getElementById("market-sell-price"),
+ marketSellSubmitBtn: document.getElementById("market-sell-submit-btn"),
+ marketSellError: document.getElementById("market-sell-error"),
+ marketMyListings: document.getElementById("market-my-listings"),
+ combatTabSolo: document.getElementById("combat-tab-solo"),
+ combatTabPvp: document.getElementById("combat-tab-pvp"),
+ combatSoloView: document.getElementById("combat-solo-view"),
+ combatPvpView: document.getElementById("combat-pvp-view"),
+ pvpLocked: document.getElementById("pvp-locked"),
+ pvpContent: document.getElementById("pvp-content"),
+ pvpReports: document.getElementById("pvp-reports"),
+ pvpTeamPicker: document.getElementById("pvp-team-picker"),
+ pvpTeamCount: document.getElementById("pvp-team-count"),
+ pvpSaveTeamBtn: document.getElementById("pvp-save-team-btn"),
+ pvpTeamError: document.getElementById("pvp-team-error"),
+ pvpFindBtn: document.getElementById("pvp-find-btn"),
+ pvpOpponentCard: document.getElementById("pvp-opponent-card"),
+ pvpAttackBtn: document.getElementById("pvp-attack-btn"),
+ pvpAttackResult: document.getElementById("pvp-attack-result"),
+ pveBananaSelect: document.getElementById("pve-banana-select"),
+ pvePlayerFighter: document.getElementById("pve-player-fighter"),
+ pveEnemyFighter: document.getElementById("pve-enemy-fighter"),
+ pveVsMark: document.getElementById("pve-vs-mark"),
+ pveFightBtn: document.getElementById("pve-fight-btn"),
+ pveResult: document.getElementById("pve-result"),
+ pveStageList: document.getElementById("pve-stage-list"),
+ leaderboardTabCollection: document.getElementById("leaderboard-tab-collection"),
+ leaderboardTabPvp: document.getElementById("leaderboard-tab-pvp"),
+ leaderboardTabPve: document.getElementById("leaderboard-tab-pve"),
+ leaderboardContent: document.getElementById("leaderboard-content"),
+ progressionTabCollection: document.getElementById("progression-tab-collection"),
+ progressionTabQuetes: document.getElementById("progression-tab-quetes"),
+ progressionTabMinijeux: document.getElementById("progression-tab-minijeux"),
+ progressionCollectionView: document.getElementById("progression-collection-view"),
+ progressionQuetesView: document.getElementById("progression-quetes-view"),
+ progressionMinijeuxView: document.getElementById("progression-minijeux-view"),
+ economieTabBoutique: document.getElementById("economie-tab-boutique"),
+ economieTabMarche: document.getElementById("economie-tab-marche"),
+ economieTabPub: document.getElementById("economie-tab-pub"),
+ economieBoutiqueView: document.getElementById("economie-boutique-view"),
+ economieMarcheView: document.getElementById("economie-marche-view"),
+ economiePubView: document.getElementById("economie-pub-view"),
+ bilanTabClassement: document.getElementById("bilan-tab-classement"),
+ bilanTabStats: document.getElementById("bilan-tab-stats"),
+ bilanClassementView: document.getElementById("bilan-classement-view"),
+ bilanStatsView: document.getElementById("bilan-stats-view"),
+ };
+
+ /* ---------------- Onglets ---------------- */
+
+ let progressionView = "collection"; // "collection" | "quetes" | "minijeux"
+ let economieView = "boutique"; // "boutique" | "marche" | "pub"
+ let bilanView = "classement"; // "classement" | "stats"
+
+ function showProgressionView(view) {
+ progressionView = view;
+ els.progressionTabCollection.classList.toggle("active", view === "collection");
+ els.progressionTabQuetes.classList.toggle("active", view === "quetes");
+ els.progressionTabMinijeux.classList.toggle("active", view === "minijeux");
+ els.progressionCollectionView.classList.toggle("hidden", view !== "collection");
+ els.progressionQuetesView.classList.toggle("hidden", view !== "quetes");
+ els.progressionMinijeuxView.classList.toggle("hidden", view !== "minijeux");
+ if (view === "collection") renderCollection();
+ if (view === "quetes") renderQuests();
+ if (view === "minijeux") showMinigamesMenu();
+ }
+
+ els.progressionTabCollection.addEventListener("click", () => showProgressionView("collection"));
+ els.progressionTabQuetes.addEventListener("click", () => showProgressionView("quetes"));
+ els.progressionTabMinijeux.addEventListener("click", () => showProgressionView("minijeux"));
+
+ function showEconomieView(view) {
+ economieView = view;
+ els.economieTabBoutique.classList.toggle("active", view === "boutique");
+ els.economieTabMarche.classList.toggle("active", view === "marche");
+ els.economieTabPub.classList.toggle("active", view === "pub");
+ els.economieBoutiqueView.classList.toggle("hidden", view !== "boutique");
+ els.economieMarcheView.classList.toggle("hidden", view !== "marche");
+ els.economiePubView.classList.toggle("hidden", view !== "pub");
+ if (view === "boutique") renderShop();
+ if (view === "marche") renderMarketTab();
+ if (view === "pub") renderAdTab();
+ }
+
+ els.economieTabBoutique.addEventListener("click", () => showEconomieView("boutique"));
+ els.economieTabMarche.addEventListener("click", () => showEconomieView("marche"));
+ els.economieTabPub.addEventListener("click", () => showEconomieView("pub"));
+
+ function showBilanView(view) {
+ bilanView = view;
+ els.bilanTabClassement.classList.toggle("active", view === "classement");
+ els.bilanTabStats.classList.toggle("active", view === "stats");
+ els.bilanClassementView.classList.toggle("hidden", view !== "classement");
+ els.bilanStatsView.classList.toggle("hidden", view !== "stats");
+ if (view === "classement") { showLeaderboardView(leaderboardView); startLeaderboardPolling(); }
+ else { stopLeaderboardPolling(); renderStats(); renderAchievements(); }
+ }
+
+ els.bilanTabClassement.addEventListener("click", () => showBilanView("classement"));
+ els.bilanTabStats.addEventListener("click", () => showBilanView("stats"));
+
+ function showTab(name) {
+ els.tabButtons.forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
+ els.tabPanels.forEach((p) => p.classList.toggle("active", p.id === `tab-${name}`));
+ if (name === "progression") showProgressionView(progressionView);
+ if (name === "economie") showEconomieView(economieView);
+ if (name === "combat") showCombatView(combatView);
+ if (name === "bilan") showBilanView(bilanView);
+ else stopLeaderboardPolling();
+ }
+
+ els.tabButtons.forEach((btn) => {
+ btn.addEventListener("click", () => showTab(btn.dataset.tab));
+ });
+
+ /* ---------------- En-tête ---------------- */
+
+ function renderHeader() {
+ const discoveredNormal = state.discovered.filter((id) => !BANANAS_BY_ID[id].secret).length;
+ els.statCollection.textContent = `Collection : ${discoveredNormal} / ${TOTAL_NORMAL}`;
+ els.statCoins.textContent = `🪙 Pièces : ${state.coins}`;
+ }
+
+ /* ---------------- Icône banane (fusion glyphe + accessoires) ---------------- */
+
+ // Construit une seule banane visuellement cohérente : le glyphe 🍌 reçoit un
+ // filtre CSS (teinte/lueur) et, si besoin, de petits accessoires (bandeau,
+ // chapeau, cape...) posés directement dessus — jamais un second emoji à côté.
+ function bananaIconHTML(banana, sizeRem) {
+ const deco = banana.deco;
+ const sizeStyle = sizeRem ? `--icon-size:${sizeRem}rem;` : "";
+
+ let filter = "";
+ let transform = "";
+ let glyphClass = "";
+ let containerStyle = "";
+ let extraGlyphs = "";
+ let decoHTML = "";
+
+ if (deco) {
+ filter = deco.filter || "";
+ transform = deco.transform || "";
+ glyphClass = deco.glyphClass || "";
+ containerStyle = deco.containerStyle || "";
+ if (deco.scale) transform += ` scale(${deco.scale})`;
+
+ if (deco.duplicates) {
+ extraGlyphs = deco.duplicates.map((d) => `
+ ${banana.emoji}
+ `).join("");
+ }
+ if (deco.accessories) {
+ decoHTML = deco.accessories.map((a) => {
+ if (a.type === "text") {
+ return `${a.text || ""} `;
+ }
+ const colorVars = a.colors
+ ? `--deco-color-a:${a.colors[0]}; --deco-color-b:${a.colors[1]};`
+ : `--deco-color-a:${a.color || "#999"}; --deco-color-b:${a.color || "#999"};`;
+ return ` `;
+ }).join("");
+ }
+ }
+
+ if (banana.image) {
+ return `
+
+
+
+ `;
+ }
+
+ return `
+
+ ${banana.emoji}
+ ${extraGlyphs}
+ ${decoHTML}
+
+ `;
+ }
+
+ /* ---------------- Récolte ---------------- */
+
+ let busy = false;
+
+ // Carte "héros" utilisée uniquement pour la dernière banane récoltée —
+ // en grand, avec une lueur de fond, distincte des petites cartes compactes
+ // de la grille de collection.
+ function bananaCardHTML(banana, count, isNew, coinsEarned) {
+ const rarity = RARITIES[banana.rarity];
+ const displayCoins = coinsEarned != null ? coinsEarned : banana.value;
+ return `
+
+ ${isNew ? '
NOUVELLE BANANE !
' : ""}
+
+ ${bananaIconHTML(banana, 5.5)}
+
${banana.name}
+
${rarity.label}
+
+ 🪙 +${displayCoins}
+ x${count}
+
+
+ `;
+ }
+
+ function harvest() {
+ if (busy) return;
+ busy = true;
+ els.harvestBtn.disabled = true;
+
+ const result = rollBanana();
+ const { banana, isNew, rarity, coinsEarned } = result;
+
+ if (rarity === "mythique" || rarity === "secrete") SFX.harvestMythic();
+ else if (rarity === "legendaire" || rarity === "epique") SFX.harvestEpic();
+ else if (rarity === "rare") SFX.harvestRare();
+ else SFX.harvestCommon();
+
+ renderHeader();
+ CLOUD.scheduleSync();
+ els.lastBanana.innerHTML = bananaCardHTML(banana, state.counts[banana.id], isNew, coinsEarned);
+ const card = els.lastBanana.querySelector(".harvest-reveal-card");
+ card.classList.add("pop-in");
+
+ if (isRareOrAbove(rarity)) {
+ card.classList.add("glow-pulse");
+ spawnConfetti(rarity === "epique" || rarity === "rare" ? 14 : 28);
+ }
+
+ if (rarity === "legendaire") {
+ showBanner("⭐ LÉGENDAIRE ! ⭐", banana, 1800);
+ } else if (rarity === "mythique" || rarity === "secrete") {
+ showEpicOverlay(banana, rarity);
+ } else if (isNew) {
+ spawnConfetti(10);
+ }
+
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+
+ const cooldown = rarity === "mythique" || rarity === "secrete" ? 300 : 350;
+ setTimeout(() => {
+ busy = false;
+ els.harvestBtn.disabled = false;
+ }, cooldown);
+ }
+
+ els.harvestBtn.addEventListener("click", harvest);
+
+ /* ---------------- Animations ---------------- */
+
+ function spawnConfetti(count) {
+ const emojis = ["🍌", "✨", "🎉", "⭐"];
+ for (let i = 0; i < count; i++) {
+ const piece = document.createElement("div");
+ piece.className = "confetti-piece";
+ piece.textContent = emojis[Math.floor(Math.random() * emojis.length)];
+ piece.style.left = Math.random() * 100 + "vw";
+ piece.style.animationDuration = 1.2 + Math.random() * 1.2 + "s";
+ piece.style.fontSize = 14 + Math.random() * 16 + "px";
+ els.toastLayer.appendChild(piece);
+ piece.addEventListener("animationend", () => piece.remove());
+ }
+ }
+
+ function showBanner(title, banana, duration) {
+ const banner = document.createElement("div");
+ banner.className = "rare-banner";
+ const bannerGlyph = banana.image ? ` ` : banana.emoji;
+ banner.innerHTML = `${title} ${bannerGlyph} ${banana.name} `;
+ els.toastLayer.appendChild(banner);
+ requestAnimationFrame(() => banner.classList.add("show"));
+ setTimeout(() => {
+ banner.classList.remove("show");
+ setTimeout(() => banner.remove(), 400);
+ }, duration);
+ }
+
+ // playSound=false est utilisé au démarrage (succès déjà acquis détectés
+ // au chargement) : un son n'est jamais déclenché sans geste préalable de
+ // l'utilisateur, pour respecter la politique de lecture audio auto des
+ // navigateurs.
+ function showAchievementToasts(achievements, playSound = true) {
+ achievements.forEach((ach, i) => {
+ setTimeout(() => {
+ if (playSound) SFX.achievement();
+ showBanner("🏆 SUCCÈS DÉBLOQUÉ !", { emoji: ach.icon, name: `${ach.name} (+${ach.reward} 🪙)` }, 2200);
+ spawnConfetti(15);
+ }, i * 900);
+ });
+ }
+
+ function showQuestToasts(quests, playSound = true) {
+ quests.forEach((quest, i) => {
+ setTimeout(() => {
+ if (playSound) SFX.quest();
+ showBanner("📜 QUÊTE TERMINÉE !", { emoji: "📜", name: `${quest.desc} (+${quest.reward} 🪙)` }, 2200);
+ spawnConfetti(12);
+ }, i * 900);
+ });
+ }
+
+ function showEpicOverlay(banana, rarity) {
+ const label = rarity === "secrete" ? "BANANE SECRÈTE !" : "BANANE MYTHIQUE !";
+ els.overlayContent.innerHTML = `
+
+
✨✨✨
+
${label}
+
${bananaIconHTML(banana, 4)}
+
TU AS TROUVÉ UNE BANANE EXTRÊMEMENT RARE !
+
${banana.name}
+
✨✨✨
+
+ Encaisser 🪙
+ `;
+ els.overlay.classList.remove("hidden");
+ requestAnimationFrame(() => els.overlay.classList.add("show"));
+ spawnConfetti(40);
+
+ const close = () => {
+ els.overlay.classList.remove("show");
+ setTimeout(() => els.overlay.classList.add("hidden"), 350);
+ };
+ els.overlayContent.querySelector(".epic-close").addEventListener("click", close);
+ els.overlay.addEventListener("click", (e) => {
+ if (e.target === els.overlay) close();
+ }, { once: true });
+ setTimeout(close, 4500);
+ }
+
+ /* ---------------- Collection ---------------- */
+
+ function renderCollection() {
+ const discoveredNormal = state.discovered.filter((id) => !BANANAS_BY_ID[id].secret).length;
+ els.progressLabel.textContent = `Collection : ${discoveredNormal} / ${TOTAL_NORMAL}`;
+ els.progressBarFill.style.width = `${(discoveredNormal / TOTAL_NORMAL) * 100}%`;
+
+ els.collectionGrid.innerHTML = NORMAL_BANANAS.map((banana) => {
+ const count = state.counts[banana.id] || 0;
+ const discovered = state.discovered.includes(banana.id);
+ if (!discovered) {
+ return `
+
+
🍌
+
???
+
???
+
🪙 ?
+
x0
+
+ `;
+ }
+ const rarity = RARITIES[banana.rarity];
+ return `
+
+ ${bananaIconHTML(banana)}
+
${banana.name}
+
${rarity.label}
+
🪙 ${banana.value}
+
x${count}
+
+ `;
+ }).join("");
+
+ const discoveredSecrets = SECRET_BANANAS.filter((b) => state.discovered.includes(b.id));
+ els.secretSection.style.display = "block";
+ document.getElementById("secret-count").textContent = `${discoveredSecrets.length} / ${TOTAL_SECRET}`;
+ if (discoveredSecrets.length === 0) {
+ els.secretGrid.innerHTML = `🕵️ Des bananes secrètes se cachent quelque part... continue de récolter pour percer leur mystère !
`;
+ } else {
+ els.secretGrid.innerHTML = discoveredSecrets.map((banana) => {
+ const count = state.counts[banana.id] || 0;
+ const rarity = RARITIES[banana.rarity];
+ return `
+
+ ${bananaIconHTML(banana)}
+
${banana.name}
+
${rarity.label}
+
🪙 ${banana.value}
+
x${count}
+
+ `;
+ }).join("");
+ }
+ }
+
+ /* ---------------- Boutique ---------------- */
+
+ function renderShop() {
+ els.shopList.innerHTML = UPGRADES.map((upgrade) => {
+ const level = state.upgrades[upgrade.id] || 0;
+ const maxed = level >= upgrade.maxLevel;
+ const price = upgradePrice(upgrade);
+ const canBuy = !maxed && state.coins >= price;
+ return `
+
+
+
${upgrade.name} Niveau ${level}/${upgrade.maxLevel}
+
${upgrade.desc}
+
+
+ ${maxed ? "MAX" : `🪙 ${price}`}
+
+
+ `;
+ }).join("");
+
+ els.shopList.querySelectorAll(".buy-btn").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const res = buyUpgrade(btn.dataset.id);
+ if (res.ok) {
+ SFX.buy();
+ renderHeader();
+ renderShop();
+ updateAutoHarvestTimer();
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+ }
+ });
+ });
+ }
+
+ /* ---------------- Quêtes quotidiennes ---------------- */
+
+ function renderQuests() {
+ const quests = questsForToday();
+ els.questsList.innerHTML = quests.map((quest) => {
+ const pct = Math.round((quest.progress / quest.need) * 100);
+ return `
+
+
+ ${quest.done ? "✅ " : ""}${quest.desc}
+ 🪙 +${quest.reward}
+
+
+
${quest.progress} / ${quest.need}
+
+ `;
+ }).join("");
+ }
+
+ /* ---------------- Marché ---------------- */
+
+ let marketView = "buy"; // "buy" | "sell"
+ let marketSelectedBananaId = null;
+
+ function sellableBananas() {
+ return state.discovered
+ .map((id) => BANANAS_BY_ID[id])
+ .filter((b) => (state.counts[b.id] || 0) > 0)
+ .sort((a, b) => rarityIndex(b.rarity) - rarityIndex(a.rarity) || b.value - a.value);
+ }
+
+ function renderMarketSellPicker() {
+ const owned = sellableBananas();
+ if (owned.length === 0) {
+ els.marketSellPicker.innerHTML = `Récolte des bananes avant de pouvoir en vendre !
`;
+ marketSelectedBananaId = null;
+ return;
+ }
+ if (!marketSelectedBananaId || !owned.some((b) => b.id === marketSelectedBananaId)) {
+ marketSelectedBananaId = owned[0].id;
+ }
+ els.marketSellPicker.innerHTML = owned.map((b) => {
+ const selected = b.id === marketSelectedBananaId;
+ return `
+
+ ${bananaIconHTML(b, 2)}
+ x${state.counts[b.id] || 0}
+
+ `;
+ }).join("");
+ els.marketSellPicker.querySelectorAll(".market-sell-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ marketSelectedBananaId = Number(btn.dataset.id);
+ renderMarketSellPicker();
+ });
+ });
+ }
+
+ function marketListingCardHTML(listing, mode) {
+ const banana = BANANAS_BY_ID[listing.banana_id];
+ if (!banana) return "";
+ const rarity = RARITIES[banana.rarity];
+ const total = listing.quantity * listing.unit_price;
+ const statusLabel = listing.status === "active" ? "En vente" : listing.status === "sold" ? "Vendue" : "Annulée";
+ return `
+
+ ${bananaIconHTML(banana, 2.2)}
+
${banana.name}
+ ${mode === "buy" ? `
par ${listing.sellerUsername}
` : ""}
+
x${listing.quantity}
+
🪙 ${listing.unit_price} / unité
+ ${mode === "sell" ? `
${statusLabel}
` : ""}
+ ${mode === "buy" ? `
🪙 Acheter tout (${total}) ` : ""}
+ ${mode === "sell" && listing.status === "active" ? `
Annuler ` : ""}
+
+ `;
+ }
+
+ async function renderMarketBuyView() {
+ els.marketListings.innerHTML = `Chargement...
`;
+ const listings = await CLOUD.fetchActiveListings();
+ const others = listings.filter((l) => l.seller_id !== CLOUD.currentUserId());
+ if (others.length === 0) {
+ els.marketListings.innerHTML = `Aucune annonce pour le moment. Reviens plus tard !
`;
+ return;
+ }
+ els.marketListings.innerHTML = others.map((l) => marketListingCardHTML(l, "buy")).join("");
+ els.marketListings.querySelectorAll(".market-buy-btn").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ btn.disabled = true;
+ btn.textContent = "⏳...";
+ const listingId = btn.dataset.listing;
+ const qty = Number(btn.dataset.qty);
+ const bananaId = Number(btn.dataset.banana);
+ const result = await CLOUD.buyListing(listingId, qty);
+ if (!result.ok) {
+ showBanner("❌ Achat impossible", { emoji: "🚫", name: result.reason || "Erreur" }, 1800);
+ renderMarketBuyView();
+ return;
+ }
+ state.counts[bananaId] = (state.counts[bananaId] || 0) + qty;
+ if (!state.discovered.includes(bananaId)) state.discovered.push(bananaId);
+ if (result.newCoins != null) state.coins = result.newCoins;
+ saveState();
+ SFX.buy();
+ renderHeader();
+ spawnConfetti(10);
+ showBanner("🛍️ ACHAT RÉUSSI !", { emoji: "🪙", name: `${BANANAS_BY_ID[bananaId].name} x${qty}` }, 1800);
+ CLOUD.scheduleSync();
+ renderMarketBuyView();
+ });
+ });
+ }
+
+ async function renderMarketMyListings() {
+ els.marketMyListings.innerHTML = `Chargement...
`;
+ // Les annonces annulées sont retirées de l'affichage pour de bon (elles
+ // n'apportent rien une fois annulées et allongeraient la liste inutilement).
+ const listings = (await CLOUD.fetchMyListings()).filter((l) => l.status !== "cancelled");
+ if (listings.length === 0) {
+ els.marketMyListings.innerHTML = `Tu n'as pas encore d'annonce.
`;
+ return;
+ }
+ els.marketMyListings.innerHTML = listings.map((l) => marketListingCardHTML(l, "sell")).join("");
+ els.marketMyListings.querySelectorAll(".market-cancel-btn").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ btn.disabled = true;
+ const listingId = btn.dataset.listing;
+ const listing = listings.find((l) => l.id === listingId);
+ const result = await CLOUD.cancelListing(listingId);
+ if (!result.ok) {
+ showBanner("❌ Impossible d'annuler", { emoji: "🚫", name: result.reason || "Erreur" }, 1800);
+ btn.disabled = false;
+ return;
+ }
+ if (listing) {
+ state.counts[listing.banana_id] = (state.counts[listing.banana_id] || 0) + listing.quantity;
+ saveState();
+ }
+ renderMarketSellPicker();
+ renderMarketMyListings();
+ CLOUD.scheduleSync();
+ });
+ });
+ }
+
+ function showMarketView(view) {
+ marketView = view;
+ els.marketTabBuy.classList.toggle("active", view === "buy");
+ els.marketTabSell.classList.toggle("active", view === "sell");
+ els.marketBuyView.classList.toggle("hidden", view !== "buy");
+ els.marketSellView.classList.toggle("hidden", view !== "sell");
+ if (view === "buy") {
+ renderMarketBuyView();
+ } else {
+ renderMarketSellPicker();
+ renderMarketMyListings();
+ }
+ }
+
+ async function renderMarketTab() {
+ if (!CLOUD.available || !CLOUD.isLinked()) {
+ els.marketLocked.classList.remove("hidden");
+ els.marketContent.classList.add("hidden");
+ return;
+ }
+ els.marketLocked.classList.add("hidden");
+ els.marketContent.classList.remove("hidden");
+ // Pousse tout de suite avant d'agir : évite un faux "solde insuffisant"
+ // si une action locale récente n'a pas encore eu le temps d'être
+ // synchronisée avec le serveur.
+ await CLOUD.pushAll();
+ showMarketView(marketView);
+ }
+
+ els.marketTabBuy.addEventListener("click", () => showMarketView("buy"));
+ els.marketTabSell.addEventListener("click", () => showMarketView("sell"));
+
+ els.marketSellSubmitBtn.addEventListener("click", async () => {
+ els.marketSellError.textContent = "";
+ if (!marketSelectedBananaId) {
+ els.marketSellError.textContent = "Choisis une banane à vendre.";
+ return;
+ }
+ const quantity = Math.floor(Number(els.marketSellQuantity.value));
+ const price = Math.floor(Number(els.marketSellPrice.value));
+ const owned = state.counts[marketSelectedBananaId] || 0;
+ if (!quantity || quantity <= 0) {
+ els.marketSellError.textContent = "Quantité invalide.";
+ return;
+ }
+ if (quantity > owned) {
+ els.marketSellError.textContent = `Tu n'as que ${owned} exemplaire(s).`;
+ return;
+ }
+ if (!price || price <= 0) {
+ els.marketSellError.textContent = "Prix invalide.";
+ return;
+ }
+
+ els.marketSellSubmitBtn.disabled = true;
+ els.marketSellSubmitBtn.textContent = "⏳...";
+ const result = await CLOUD.createListing(marketSelectedBananaId, quantity, price);
+ els.marketSellSubmitBtn.disabled = false;
+ els.marketSellSubmitBtn.textContent = "Mettre en vente";
+
+ if (!result.ok) {
+ els.marketSellError.textContent = result.reason || "Impossible de créer l'annonce.";
+ return;
+ }
+
+ state.counts[marketSelectedBananaId] -= quantity;
+ saveState();
+ SFX.buy();
+ els.marketSellQuantity.value = "";
+ els.marketSellPrice.value = "";
+ renderMarketSellPicker();
+ renderMarketMyListings();
+ CLOUD.scheduleSync();
+ });
+
+ /* ---------------- Publicité récompensée ---------------- */
+
+ let adPlaying = false;
+
+ function renderAdTab() {
+ const remaining = adsRemainingToday();
+ els.adQuota.textContent = remaining > 0
+ ? `${remaining} / ${maxAdsPerDay()} pubs disponibles aujourd'hui`
+ : "Plus de pub disponible aujourd'hui — reviens demain !";
+ els.watchAdBtn.disabled = adPlaying || remaining <= 0;
+ els.watchAdBtn.textContent = `🎬 Regarder une pub (+${AD_REWARD} 🪙)`;
+ }
+
+ els.watchAdBtn.addEventListener("click", () => {
+ if (adPlaying || adsRemainingToday() <= 0) return;
+ adPlaying = true;
+ els.watchAdBtn.disabled = true;
+ els.watchAdBtn.textContent = "⏳ Chargement de la pub...";
+
+ // Simulation du délai de chargement/visionnage d'une pub réelle.
+ setTimeout(() => {
+ const coinsEarned = grantAdReward();
+ SFX.coin();
+ renderHeader();
+ adPlaying = false;
+ renderAdTab();
+ spawnConfetti(16);
+ showBanner("🎉 MERCI D'AVOIR REGARDÉ !", { emoji: "🪙", name: `+${coinsEarned} pièces` }, 1600);
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+ }, 1500);
+ });
+
+ /* ---------------- Mini-jeux : menu ---------------- */
+
+ function showMinigameView(view) {
+ els.minigamesMenu.classList.toggle("hidden", view !== "menu");
+ els.minigameCatch.classList.toggle("hidden", view !== "catch");
+ els.minigameWheel.classList.toggle("hidden", view !== "wheel");
+ }
+
+ function renderMinigamesMenu() {
+ els.catchBestLabel.textContent = state.catchGame.bestScore > 0
+ ? `🏆 Record : ${state.catchGame.bestScore} bananes`
+ : "Pas encore joué";
+ els.wheelStatusLabel.textContent = canSpinWheelToday() ? "🎁 Tour disponible !" : "✅ Déjà tourné aujourd'hui";
+ }
+
+ function showMinigamesMenu() {
+ stopCatchGame();
+ showMinigameView("menu");
+ renderMinigamesMenu();
+ }
+
+ els.openCatchGame.addEventListener("click", () => {
+ showMinigameView("catch");
+ resetCatchGameView();
+ });
+ els.openWheelGame.addEventListener("click", () => {
+ showMinigameView("wheel");
+ renderWheelView();
+ });
+ document.querySelectorAll("[data-back]").forEach((btn) => {
+ btn.addEventListener("click", showMinigamesMenu);
+ });
+
+ /* ---------------- Mini-jeu : Attrape les bananes ---------------- */
+
+ let catchState = null;
+
+ const ROTTEN_BANANA_VISUAL = {
+ emoji: "🍌",
+ deco: {
+ filter: "sepia(0.7) saturate(0.35) brightness(0.55) hue-rotate(-15deg)",
+ accessories: [{ cls: "text", text: "🪰", style: "top:-10%; right:-14%; font-size:.55em;" }],
+ },
+ };
+
+ function setCatchLevelBackground(levelIndex) {
+ els.catchArea.classList.remove("level-1", "level-2", "level-3");
+ els.catchArea.classList.add(`level-${levelIndex + 1}`);
+ }
+
+ function resetCatchGameView() {
+ els.catchStartOverlay.classList.remove("hidden");
+ els.catchResult.classList.add("hidden");
+ els.catchArea.querySelectorAll(".catch-item").forEach((el) => el.remove());
+ setCatchLevelBackground(0);
+ els.catchTimer.textContent = `⏱️ Niveau 1 — ${CATCH_LEVEL_DURATION_MS / 1000}s`;
+ els.catchScore.textContent = "⭐ 0";
+ }
+
+ function stopCatchGame() {
+ if (!catchState) return;
+ clearTimeout(catchState.spawnTimer);
+ clearInterval(catchState.tickTimer);
+ clearTimeout(catchState.endTimer);
+ els.catchArea.querySelectorAll(".catch-item").forEach((el) => el.remove());
+ catchState = null;
+ }
+
+ function spawnCatchItem(levelConfig) {
+ const isRotten = Math.random() < levelConfig.rottenChance;
+ const item = document.createElement("div");
+ item.className = "catch-item";
+ item.style.left = `${5 + Math.random() * 85}%`;
+ item.innerHTML = bananaIconHTML(isRotten ? ROTTEN_BANANA_VISUAL : { emoji: "🍌", deco: null });
+ els.catchArea.appendChild(item);
+
+ const areaHeight = els.catchArea.clientHeight;
+ const fallDuration = levelConfig.fallMin + Math.random() * (levelConfig.fallMax - levelConfig.fallMin);
+ requestAnimationFrame(() => {
+ item.style.transitionDuration = `${fallDuration}s`;
+ item.style.top = `${areaHeight + 20}px`;
+ });
+
+ const missTimer = setTimeout(() => item.remove(), fallDuration * 1000 + 50);
+
+ item.addEventListener("click", () => {
+ if (!catchState || !catchState.running) return;
+ clearTimeout(missTimer);
+ item.style.pointerEvents = "none";
+ item.style.transition = "transform .15s ease, opacity .15s ease";
+ if (isRotten) {
+ catchState.rotten += 1;
+ item.style.filter = "brightness(0.5) saturate(0)";
+ SFX.lose();
+ } else {
+ catchState.good += 1;
+ spawnConfetti(3);
+ SFX.click();
+ }
+ item.style.transform = "scale(1.4)";
+ item.style.opacity = "0";
+ els.catchScore.textContent = `⭐ ${catchState.good}`;
+ setTimeout(() => item.remove(), 160);
+ });
+ }
+
+ function startCatchLevel(levelIndex) {
+ if (!catchState) return;
+ catchState.level = levelIndex;
+ const levelConfig = CATCH_LEVELS[levelIndex];
+ setCatchLevelBackground(levelIndex);
+ showBanner(`🌴 NIVEAU ${levelIndex + 1} !`, { emoji: "🐒", name: levelConfig.label }, 1100);
+
+ const scheduleSpawn = () => {
+ catchState.spawnTimer = setTimeout(() => {
+ if (!catchState || !catchState.running) return;
+ spawnCatchItem(levelConfig);
+ scheduleSpawn();
+ }, levelConfig.spawnDelay);
+ };
+ scheduleSpawn();
+
+ const levelStart = Date.now();
+ catchState.tickTimer = setInterval(() => {
+ const remaining = Math.max(0, CATCH_LEVEL_DURATION_MS - (Date.now() - levelStart));
+ els.catchTimer.textContent = `⏱️ Niveau ${levelIndex + 1} — ${Math.ceil(remaining / 1000)}s`;
+ }, 200);
+
+ catchState.endTimer = setTimeout(() => {
+ clearTimeout(catchState.spawnTimer);
+ clearInterval(catchState.tickTimer);
+ els.catchArea.querySelectorAll(".catch-item").forEach((el) => el.remove());
+ if (levelIndex < CATCH_LEVELS.length - 1) {
+ startCatchLevel(levelIndex + 1);
+ } else {
+ endCatchGame();
+ }
+ }, CATCH_LEVEL_DURATION_MS);
+ }
+
+ els.catchStartBtn.addEventListener("click", () => {
+ stopCatchGame();
+ els.catchStartOverlay.classList.add("hidden");
+ els.catchResult.classList.add("hidden");
+ catchState = { good: 0, rotten: 0, running: true, level: 0, spawnTimer: null, tickTimer: null, endTimer: null };
+ startCatchLevel(0);
+ });
+
+ function endCatchGame() {
+ if (!catchState) return;
+ catchState.running = false;
+ clearTimeout(catchState.spawnTimer);
+ clearInterval(catchState.tickTimer);
+ els.catchArea.querySelectorAll(".catch-item").forEach((el) => el.remove());
+
+ const { good, rotten } = catchState;
+ const coinsEarned = awardCatchGameResult(good, rotten);
+ renderHeader();
+
+ els.catchResult.innerHTML = `
+ 🏁 Les 3 niveaux sont terminés !
+ ${good} bananes attrapées, ${rotten} pourries touchées
+ 🪙 +${coinsEarned}
+ 🔁 Rejouer
+ `;
+ els.catchResult.classList.remove("hidden");
+ els.catchResult.querySelector("#catch-replay-btn").addEventListener("click", () => {
+ els.catchStartBtn.click();
+ });
+
+ if (good >= 6) spawnConfetti(20);
+ catchState = null;
+ renderMinigamesMenu();
+
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+ }
+
+ /* ---------------- Mini-jeu : Roue de la fortune ---------------- */
+
+ const WHEEL_SEGMENT_CENTER_ANGLES = [30, 90, 150, 210, 270, 330];
+ let wheelSpinning = false;
+
+ function renderWheelLabels() {
+ els.wheelDisc.innerHTML = WHEEL_PRIZES.map((prize, i) => {
+ // Le pivot est ancré en haut et grandit vers le bas (top:50%; height:38%),
+ // donc à rotation nulle il pointe déjà vers 6h (180°) — d'où le -180
+ // pour que l'angle du secteur (0° = 12h, sens horaire) soit respecté.
+ const pivotAngle = WHEEL_SEGMENT_CENTER_ANGLES[i] - 180;
+ return `
+
+ ${prize.coins}
+
+ `;
+ }).join("");
+ }
+ renderWheelLabels();
+
+ function renderWheelView() {
+ const canSpin = canSpinWheelToday();
+ els.wheelSpinBtn.disabled = wheelSpinning || !canSpin;
+ els.wheelSpinBtn.textContent = canSpin ? "🎡 Tourner la roue" : "✅ Déjà tourné aujourd'hui";
+ els.wheelStatus.textContent = canSpin
+ ? "Un tour gratuit par jour."
+ : "Reviens demain pour un nouveau tour !";
+ }
+
+ els.wheelSpinBtn.addEventListener("click", () => {
+ if (wheelSpinning || !canSpinWheelToday()) return;
+ const result = spinWheel();
+ if (!result.ok) {
+ renderWheelView();
+ return;
+ }
+
+ wheelSpinning = true;
+ els.wheelSpinBtn.disabled = true;
+ const centerAngle = WHEEL_SEGMENT_CENTER_ANGLES[result.index];
+ const targetRotation = 360 * 5 + (360 - centerAngle);
+ els.wheelDisc.style.transform = `rotate(${targetRotation}deg)`;
+
+ setTimeout(() => {
+ wheelSpinning = false;
+ SFX.coin();
+ renderHeader();
+ renderWheelView();
+ renderMinigamesMenu();
+ spawnConfetti(result.coins >= 500 ? 30 : 14);
+ showBanner(
+ result.coins >= 500 ? "🎉 GROS LOT ! 🎉" : "🎁 BONUS DU JOUR !",
+ { emoji: "🪙", name: `+${result.coins} pièces` },
+ 2000
+ );
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+ }, 4100);
+ });
+
+ /* ---------------- Combat : l'Arène des Ananas ---------------- */
+
+ let pveSelectedBananaId = null;
+ let pveSelectedStage = 0;
+ let pveFighting = false;
+
+ function pveDiscoveredBananasSorted() {
+ return state.discovered
+ .map((id) => BANANAS_BY_ID[id])
+ .sort((a, b) => rarityIndex(b.rarity) - rarityIndex(a.rarity) || b.value - a.value);
+ }
+
+ function renderPveBananaSelect() {
+ const owned = pveDiscoveredBananasSorted();
+ if (owned.length === 0) {
+ els.pveBananaSelect.innerHTML = `Récolte au moins une banane avant de combattre !
`;
+ pveSelectedBananaId = null;
+ return;
+ }
+ if (!pveSelectedBananaId || !owned.some((b) => b.id === pveSelectedBananaId)) {
+ pveSelectedBananaId = owned[0].id;
+ }
+ els.pveBananaSelect.innerHTML = owned.map((b) => {
+ const stats = bananaCombatStats(b);
+ const selected = b.id === pveSelectedBananaId;
+ return `
+
+ ${bananaIconHTML(b, 2)}
+ ⚔️${stats.atk} 🛡️${stats.def}
+
+ `;
+ }).join("");
+ els.pveBananaSelect.querySelectorAll(".pve-banana-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ pveSelectedBananaId = Number(btn.dataset.id);
+ renderPveBananaSelect();
+ renderPveFighters();
+ });
+ });
+ }
+
+ // Lueur de l'ennemi : une teinte par famille de fruit (10 familles réparties
+ // sur le cercle chromatique), qui s'intensifie légèrement à mesure qu'on
+ // avance dans les 6 niveaux de la famille.
+ function pveStageGlow(stageIndex) {
+ const family = Math.floor(stageIndex / 6);
+ const levelInFamily = stageIndex % 6;
+ const hue = (family * 36) % 360;
+ const light = 58 - levelInFamily * 4;
+ return `hsl(${hue}, 70%, ${light}%)`;
+ }
+
+ function renderPveFighters() {
+ const enemy = FRUIT_ENEMIES[pveSelectedStage];
+ const family = Math.floor(pveSelectedStage / 6);
+ const levelInFamily = pveSelectedStage % 6;
+ const locked = pveSelectedStage > maxPlayablePveStage();
+ const playerBanana = pveSelectedBananaId ? BANANAS_BY_ID[pveSelectedBananaId] : null;
+ const playerStats = playerBanana ? bananaCombatStats(playerBanana) : null;
+ const enemySize = 2.6 + family * 0.15 + levelInFamily * 0.12;
+
+ els.pvePlayerFighter.innerHTML = playerBanana ? `
+ ${bananaIconHTML(playerBanana, 3.4)}
+ ${playerBanana.name}
+ ⚔️ ${playerStats.atk} · 🛡️ ${playerStats.def}
+ ` : `Choisis une banane
`;
+
+ els.pveEnemyFighter.innerHTML = `
+ ${enemy.emoji}
+ ${enemy.name}${locked ? " 🔒" : ""}
+ ⚔️ ${enemy.atk} · 🛡️ ${enemy.def} · 🪙 ${Math.round(enemy.reward * 0.75)}
+ Niveau ${pveSelectedStage + 1} / ${FRUIT_ENEMIES.length}
+ `;
+
+ els.pveFightBtn.disabled = pveFighting || !playerBanana || locked;
+ els.pveFightBtn.textContent = locked ? "🔒 Bats l'ennemi précédent d'abord" : "⚔️ Attaquer";
+ }
+
+ // Les 60 niveaux sont regroupés par famille de fruit (10 groupes de 6),
+ // avec un en-tête par famille, plutôt qu'une seule rangée de 60 puces.
+ function renderPveStageList() {
+ const groupsHTML = FRUIT_FAMILIES.map((family, f) => {
+ const chips = FRUIT_ENEMIES.slice(f * 6, f * 6 + 6).map((enemy, li) => {
+ const i = f * 6 + li;
+ const beaten = i <= state.pve.stage;
+ const playable = i <= maxPlayablePveStage();
+ const selected = i === pveSelectedStage;
+ return `
+
+ ${playable ? enemy.emoji : "🔒"}
+ ${beaten ? '✅ ' : ""}
+
+ `;
+ }).join("");
+ return `
+
+
${family.emoji} ${family.label}
+
${chips}
+
+ `;
+ }).join("");
+
+ els.pveStageList.innerHTML = `${groupsHTML}
`;
+ els.pveStageList.querySelectorAll(".pve-stage-chip").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ pveSelectedStage = Number(btn.dataset.stage);
+ renderPveStageList();
+ renderPveFighters();
+ els.pveResult.classList.add("hidden");
+ });
+ });
+ }
+
+ function renderPveTab() {
+ if (pveSelectedStage > maxPlayablePveStage()) pveSelectedStage = maxPlayablePveStage();
+ renderPveBananaSelect();
+ renderPveStageList();
+ renderPveFighters();
+ els.pveResult.classList.add("hidden");
+ }
+
+ els.pveFightBtn.addEventListener("click", () => {
+ if (pveFighting || !pveSelectedBananaId) return;
+ pveFighting = true;
+ els.pveFightBtn.disabled = true;
+ els.pveResult.classList.add("hidden");
+ els.pveVsMark.classList.add("clash");
+
+ setTimeout(() => {
+ els.pveVsMark.classList.remove("clash");
+ const result = fightFruitEnemy(pveSelectedBananaId, pveSelectedStage);
+ pveFighting = false;
+
+ if (!result.ok) {
+ renderPveFighters();
+ return;
+ }
+
+ SFX[result.won ? "win" : "lose"]();
+ renderHeader();
+ CLOUD.scheduleSync();
+ els.pveResult.innerHTML = `
+ ${result.won ? "🎉 Victoire !" : "💥 Défaite..."}
+ ${result.won ? "Ta banane triomphe de l'ennemi !" : "L'ennemi était trop coriace cette fois — courage vaincu quand même récompensé."}
+ 🪙 +${result.coinsEarned}
+ ${result.stageAdvanced ? '🔓 Ennemi suivant débloqué !
' : ""}
+ `;
+ els.pveResult.classList.remove("hidden");
+
+ if (result.won) spawnConfetti(result.stageAdvanced ? 25 : 12);
+
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ const questsDone = checkQuests();
+ if (questsDone.length > 0) {
+ renderHeader();
+ showQuestToasts(questsDone);
+ }
+
+ renderPveStageList();
+ renderPveFighters();
+ }, 650);
+ });
+
+ /* ---------------- Combat : sous-onglets Solo / PVP ---------------- */
+
+ let combatView = "solo"; // "solo" | "pvp"
+
+ function showCombatView(view) {
+ combatView = view;
+ els.combatTabSolo.classList.toggle("active", view === "solo");
+ els.combatTabPvp.classList.toggle("active", view === "pvp");
+ els.combatSoloView.classList.toggle("hidden", view !== "solo");
+ els.combatPvpView.classList.toggle("hidden", view !== "pvp");
+ if (view === "solo") {
+ renderPveTab();
+ } else {
+ renderPvpTab();
+ }
+ }
+
+ els.combatTabSolo.addEventListener("click", () => showCombatView("solo"));
+ els.combatTabPvp.addEventListener("click", () => showCombatView("pvp"));
+
+ /* ---------------- Arène PVP ---------------- */
+
+ let pvpSelectedTeam = Array(5).fill(null);
+ let pvpOpponent = null;
+
+ function pvpOwnedBananas() {
+ return state.discovered
+ .map((id) => BANANAS_BY_ID[id])
+ .filter((b) => (state.counts[b.id] || 0) > 0)
+ .sort((a, b) => rarityIndex(b.rarity) - rarityIndex(a.rarity) || b.value - a.value);
+ }
+
+ function renderPvpTeamPicker() {
+ const owned = pvpOwnedBananas();
+ if (owned.length === 0) {
+ els.pvpTeamPicker.innerHTML = `Récolte des bananes avant de composer une équipe !
`;
+ els.pvpTeamCount.textContent = "";
+ return;
+ }
+ // Retire de l'équipe les bananes qu'on ne possède plus.
+ pvpSelectedTeam = pvpSelectedTeam.map((id) => (id != null && owned.some((b) => b.id === id) ? id : null));
+
+ els.pvpTeamPicker.innerHTML = pvpSelectedTeam.map((currentId, slot) => {
+ const options = owned.map((b) => {
+ const usedElsewhere = pvpSelectedTeam.includes(b.id) && currentId !== b.id;
+ const stats = bananaCombatStats(b);
+ return `${b.name} — ⚔️${stats.atk} 🛡️${stats.def} `;
+ }).join("");
+ return `
+
+ Combattant ${slot + 1}
+
+ — Vide —
+ ${options}
+
+
+ `;
+ }).join("");
+ els.pvpTeamCount.textContent = `${pvpSelectedTeam.filter((id) => id != null).length} / 5 sélectionnées`;
+ els.pvpTeamPicker.querySelectorAll(".pvp-slot-select").forEach((select) => {
+ select.addEventListener("change", () => {
+ const slot = Number(select.dataset.slot);
+ pvpSelectedTeam[slot] = select.value ? Number(select.value) : null;
+ renderPvpTeamPicker();
+ });
+ });
+ }
+
+ els.pvpSaveTeamBtn.addEventListener("click", async () => {
+ els.pvpTeamError.textContent = "";
+ const team = pvpSelectedTeam.filter((id) => id != null);
+ if (team.length !== 5) {
+ els.pvpTeamError.textContent = "Choisis exactement 5 bananes.";
+ return;
+ }
+ els.pvpSaveTeamBtn.disabled = true;
+ els.pvpSaveTeamBtn.textContent = "⏳...";
+ const result = await CLOUD.setDefenseTeam(team);
+ els.pvpSaveTeamBtn.disabled = false;
+ els.pvpSaveTeamBtn.textContent = "Sauvegarder l'équipe";
+ if (!result.ok) {
+ els.pvpTeamError.textContent = result.reason || "Impossible de sauvegarder l'équipe.";
+ return;
+ }
+ SFX.buy();
+ showBanner("✅ ÉQUIPE SAUVEGARDÉE !", { emoji: "🛡️", name: "Elle te défend même hors ligne" }, 1800);
+ });
+
+ async function renderPvpReports() {
+ const reports = await CLOUD.fetchUnseenCombatReports();
+ if (reports.length === 0) {
+ els.pvpReports.innerHTML = "";
+ return;
+ }
+ const won = reports.filter((r) => r.defender_delta > 0);
+ const lost = reports.filter((r) => r.defender_delta <= 0);
+ els.pvpReports.innerHTML = `
+ 📜 Pendant ton absence
+ ${reports.map((r) => `
+
+
${r.defender_delta > 0 ? "🛡️ Défense réussie !" : "💥 Tu as été attaqué"}
+
${r.attackerUsername} — ${r.defender_delta > 0 ? `tu as récupéré ${r.defender_delta}` : `tu as perdu ${Math.abs(r.defender_delta)}`} 🪙
+
+ `).join("")}
+ `;
+ if (won.length > 0 || lost.length > 0) {
+ renderHeader();
+ }
+ CLOUD.markCombatLogSeen(reports.map((r) => r.id));
+ }
+
+ function renderPvpOpponent() {
+ if (!pvpOpponent) {
+ els.pvpOpponentCard.classList.add("hidden");
+ els.pvpAttackBtn.classList.add("hidden");
+ return;
+ }
+ els.pvpOpponentCard.classList.remove("hidden");
+ els.pvpAttackBtn.classList.remove("hidden");
+ els.pvpOpponentCard.innerHTML = `
+ 👤 ${pvpOpponent.username}
+ Puissance totale : ${pvpOpponent.power}
+ `;
+ }
+
+ els.pvpFindBtn.addEventListener("click", async () => {
+ els.pvpFindBtn.disabled = true;
+ els.pvpFindBtn.textContent = "⏳...";
+ els.pvpAttackResult.classList.add("hidden");
+ const result = await CLOUD.findOpponent();
+ els.pvpFindBtn.disabled = false;
+ els.pvpFindBtn.textContent = "🔍 Trouver un adversaire";
+ if (!result.ok) {
+ pvpOpponent = null;
+ renderPvpOpponent();
+ showBanner("😕 PAS D'ADVERSAIRE", { emoji: "🔍", name: result.reason === "pas_equipe" ? "Sauvegarde d'abord ton équipe" : "Réessaie plus tard" }, 1800);
+ return;
+ }
+ pvpOpponent = { defenderId: result.defenderId, username: result.username, power: result.power };
+ renderPvpOpponent();
+ });
+
+ els.pvpAttackBtn.addEventListener("click", async () => {
+ if (!pvpOpponent) return;
+ els.pvpAttackBtn.disabled = true;
+ els.pvpAttackBtn.textContent = "⏳...";
+ const result = await CLOUD.attackPlayer(pvpOpponent.defenderId);
+ els.pvpAttackBtn.disabled = false;
+ els.pvpAttackBtn.textContent = "⚔️ Attaquer";
+
+ if (!result.ok) {
+ showBanner("❌ Attaque impossible", { emoji: "🚫", name: result.reason || "Erreur" }, 1800);
+ return;
+ }
+
+ SFX[result.won ? "win" : "lose"]();
+ state.coins += result.attackerDelta;
+ saveState();
+ renderHeader();
+ els.pvpAttackResult.innerHTML = `
+ ${result.won ? "🎉 Victoire !" : "💥 Défaite..."}
+ ${result.won ? `Tu voles ${result.attackerDelta} 🪙 à ${pvpOpponent.username} !` : `Tu perds ${Math.abs(result.attackerDelta)} 🪙 face à ${pvpOpponent.username}.`}
+ `;
+ els.pvpAttackResult.classList.remove("hidden");
+ if (result.won) spawnConfetti(20);
+ pvpOpponent = null;
+ renderPvpOpponent();
+
+ const unlocked = checkAchievements();
+ if (unlocked.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlocked);
+ }
+ CLOUD.scheduleSync();
+ });
+
+ async function renderPvpTab() {
+ if (!CLOUD.available || !CLOUD.isLinked()) {
+ els.pvpLocked.classList.remove("hidden");
+ els.pvpContent.classList.add("hidden");
+ return;
+ }
+ els.pvpLocked.classList.add("hidden");
+ els.pvpContent.classList.remove("hidden");
+ els.pvpAttackResult.classList.add("hidden");
+ pvpOpponent = null;
+ renderPvpOpponent();
+
+ // Pousse tout de suite avant d'agir : évite qu'une équipe ne puisse pas
+ // être sauvegardée parce que l'inventaire local n'a pas encore été
+ // synchronisé côté serveur.
+ await CLOUD.pushAll();
+
+ await renderPvpReports();
+
+ const savedTeam = await CLOUD.fetchMyDefenseTeam();
+ pvpSelectedTeam = Array(5).fill(null);
+ (savedTeam || []).forEach((id, i) => { pvpSelectedTeam[i] = id; });
+ renderPvpTeamPicker();
+ }
+
+ /* ---------------- Statistiques ---------------- */
+
+ function renderAchievements() {
+ const unlockedCount = state.achievements.unlocked.length;
+ const badges = ACHIEVEMENTS.map((ach) => {
+ const unlocked = state.achievements.unlocked.includes(ach.id);
+ return `
+
+
${unlocked ? ach.icon : "🔒"}
+
+
${unlocked ? ach.name : "???"}
+
${unlocked ? ach.desc : "Succès verrouillé"}
+
+ ${unlocked ? `
+${ach.reward}🪙
` : ""}
+
+ `;
+ }).join("");
+
+ els.achievementsPanel.innerHTML = `
+ 🏆 Succès ${unlockedCount} / ${ACHIEVEMENTS.length}
+ ${badges}
+ `;
+ }
+
+ function renderStats() {
+ const discoveredNormal = state.discovered.filter((id) => !BANANAS_BY_ID[id].secret).length;
+ const discoveredSecret = state.discovered.filter((id) => BANANAS_BY_ID[id].secret).length;
+ const rarest = state.rarestId ? BANANAS_BY_ID[state.rarestId] : null;
+ const pct = Math.round((discoveredNormal / TOTAL_NORMAL) * 1000) / 10;
+
+ els.statsPanel.innerHTML = `
+
+
${state.totalRolls}
Bananes récoltées
+
${discoveredNormal + discoveredSecret}
Bananes différentes découvertes
+
${rarest ? `${rarest.image ? `
` : rarest.emoji} ${rarest.name}` : "—"}
Banane la plus rare obtenue
+
${state.mythicCount}
Bananes mythiques obtenues
+
${state.clicks}
Nombre de clics
+
${pct}%
Collection complétée
+
+ `;
+ }
+
+ /* ---------------- Récolteur automatique ---------------- */
+
+ let autoHarvestTimer = null;
+
+ function updateAutoHarvestTimer() {
+ clearInterval(autoHarvestTimer);
+ const level = state.upgrades.auto || 0;
+ if (level <= 0) return;
+ const upgrade = UPGRADES.find((u) => u.id === "auto");
+ const interval = upgrade.intervalsMs[level - 1];
+ autoHarvestTimer = setInterval(() => {
+ if (!busy) harvest();
+ }, interval);
+ }
+
+ /* ---------------- Classement ---------------- */
+
+ let leaderboardView = "collection"; // "collection" | "pvp" | "pve"
+ let leaderboardPollTimer = null;
+ const LEADERBOARD_POLL_MS = 15000;
+
+ const LEADERBOARD_CONFIGS = {
+ collection: {
+ sort: (a, b) => (b.collection_count + b.secret_count) - (a.collection_count + a.secret_count),
+ columns: [
+ { label: "Bananes", value: (r) => `${r.collection_count} / ${TOTAL_NORMAL}` },
+ { label: "Secrètes", value: (r) => `${r.secret_count} / ${TOTAL_SECRET}` },
+ ],
+ },
+ pvp: {
+ sort: (a, b) => (b.pvp_wins - b.pvp_losses) - (a.pvp_wins - a.pvp_losses) || b.pvp_wins - a.pvp_wins,
+ columns: [
+ { label: "Victoires", value: (r) => r.pvp_wins },
+ { label: "Défaites", value: (r) => r.pvp_losses },
+ ],
+ },
+ pve: {
+ sort: (a, b) => b.pve_stage - a.pve_stage || b.pve_wins - a.pve_wins,
+ columns: [
+ { label: "Niveau", value: (r) => `${r.pve_stage + 1} / ${FRUIT_ENEMIES.length}` },
+ { label: "Victoires", value: (r) => r.pve_wins },
+ { label: "Défaites", value: (r) => r.pve_losses },
+ ],
+ },
+ };
+
+ function showLeaderboardView(view) {
+ leaderboardView = view;
+ els.leaderboardTabCollection.classList.toggle("active", view === "collection");
+ els.leaderboardTabPvp.classList.toggle("active", view === "pvp");
+ els.leaderboardTabPve.classList.toggle("active", view === "pve");
+ renderLeaderboard();
+ }
+
+ els.leaderboardTabCollection.addEventListener("click", () => showLeaderboardView("collection"));
+ els.leaderboardTabPvp.addEventListener("click", () => showLeaderboardView("pvp"));
+ els.leaderboardTabPve.addEventListener("click", () => showLeaderboardView("pve"));
+
+ function startLeaderboardPolling() {
+ stopLeaderboardPolling();
+ leaderboardPollTimer = setInterval(renderLeaderboard, LEADERBOARD_POLL_MS);
+ }
+
+ function stopLeaderboardPolling() {
+ clearInterval(leaderboardPollTimer);
+ leaderboardPollTimer = null;
+ }
+
+ async function renderLeaderboard() {
+ // Pas de flash "Chargement..." sur les rafraîchissements auto : seulement
+ // au tout premier affichage, quand il n'y a encore aucun tableau.
+ if (!els.leaderboardContent.querySelector("table")) {
+ els.leaderboardContent.innerHTML = `Chargement du classement...
`;
+ }
+ const rows = await CLOUD.fetchLeaderboard();
+ if (rows.length === 0) {
+ els.leaderboardContent.innerHTML = `Aucun joueur avec un compte cloud pour l'instant.
`;
+ return;
+ }
+
+ const cfg = LEADERBOARD_CONFIGS[leaderboardView];
+ const sorted = rows.slice().sort(cfg.sort);
+ const myUsername = CLOUD.currentUsername();
+
+ els.leaderboardContent.innerHTML = `
+
+
+
+ #
+ Joueur
+ ${cfg.columns.map((c) => `${c.label} `).join("")}
+
+
+
+ ${sorted.map((r, i) => `
+
+ ${i + 1}
+ ${r.username}
+ ${cfg.columns.map((c) => `${c.value(r)} `).join("")}
+
+ `).join("")}
+
+
+ `;
+ }
+
+ /* ---------------- Compte cloud (Marché / Arène PVP) ---------------- */
+
+ let accountMode = "login"; // "login" | "signup"
+
+ function updateAccountBtn() {
+ if (CLOUD.available && CLOUD.isLinked()) {
+ els.accountBtn.textContent = `👤 ${CLOUD.currentUsername()}`;
+ els.accountBtn.classList.add("linked");
+ } else {
+ els.accountBtn.textContent = "👤 Compte";
+ els.accountBtn.classList.remove("linked");
+ }
+ }
+
+ function closeAccountModal() {
+ els.accountModal.classList.add("hidden");
+ }
+
+ function renderAccountModal() {
+ if (!CLOUD.available) {
+ els.accountModalContent.innerHTML = `
+
+ `;
+ return;
+ }
+
+ if (CLOUD.isLinked()) {
+ els.accountModalContent.innerHTML = `
+
+
👤 ${CLOUD.currentUsername()}
+
Connecté — le Marché et l'Arène PVP sont disponibles.
+
Déconnexion
+
+ `;
+ els.accountModalContent.querySelector("#account-signout-btn").addEventListener("click", async () => {
+ await CLOUD.signOut();
+ updateAccountBtn();
+ renderAccountModal();
+ renderMarketTab();
+ renderPvpTab();
+ });
+ return;
+ }
+
+ const isSignup = accountMode === "signup";
+ els.accountModalContent.innerHTML = `
+
+ `;
+
+ const errorEl = els.accountModalContent.querySelector("#account-error");
+ els.accountModalContent.querySelector("#account-switch-mode-btn").addEventListener("click", () => {
+ accountMode = isSignup ? "login" : "signup";
+ renderAccountModal();
+ });
+
+ els.accountModalContent.querySelector("#account-submit-btn").addEventListener("click", async () => {
+ const username = els.accountModalContent.querySelector("#account-username-input").value.trim().toLowerCase();
+ const password = els.accountModalContent.querySelector("#account-password-input").value;
+ errorEl.textContent = "";
+
+ if (!CLOUD.isValidUsername(username)) {
+ errorEl.textContent = "Pseudo invalide (3 à 20 caractères : lettres minuscules, chiffres, _).";
+ return;
+ }
+ if (password.length < 6) {
+ errorEl.textContent = "Mot de passe trop court (6 caractères minimum).";
+ return;
+ }
+
+ const submitBtn = els.accountModalContent.querySelector("#account-submit-btn");
+ submitBtn.disabled = true;
+ submitBtn.textContent = "⏳ ...";
+
+ const result = isSignup ? await CLOUD.signUp(username, password) : await CLOUD.signIn(username, password);
+
+ if (!result.ok) {
+ errorEl.textContent = result.reason || "Une erreur est survenue.";
+ submitBtn.disabled = false;
+ submitBtn.textContent = isSignup ? "Créer mon compte" : "Se connecter";
+ return;
+ }
+
+ SFX.buy();
+ updateAccountBtn();
+ renderAccountModal();
+ renderHeader();
+ renderMarketTab();
+ renderPvpTab();
+ });
+ }
+
+ els.accountBtn.addEventListener("click", () => {
+ accountMode = "login";
+ renderAccountModal();
+ els.accountModal.classList.remove("hidden");
+ });
+ els.accountModalClose.addEventListener("click", closeAccountModal);
+ els.accountModal.addEventListener("click", (e) => {
+ if (e.target === els.accountModal) closeAccountModal();
+ });
+
+ /* ---------------- Son ---------------- */
+
+ function renderMuteBtn() {
+ const muted = !!(state.settings && state.settings.muted);
+ els.muteBtn.textContent = muted ? "🔇" : "🔊";
+ els.muteBtn.classList.toggle("muted", muted);
+ }
+
+ els.muteBtn.addEventListener("click", () => {
+ state.settings.muted = !state.settings.muted;
+ saveState();
+ renderMuteBtn();
+ if (!state.settings.muted) SFX.click();
+ });
+
+ /* ---------------- Réinitialisation ---------------- */
+
+ els.resetBtn.addEventListener("click", () => {
+ els.confirmModal.classList.remove("hidden");
+ });
+ els.confirmNo.addEventListener("click", () => {
+ els.confirmModal.classList.add("hidden");
+ });
+ els.confirmYes.addEventListener("click", () => {
+ if (CLOUD.isLinked()) CLOUD.resetCloudProgress().catch(() => {});
+ resetSave();
+ els.confirmModal.classList.add("hidden");
+ els.lastBanana.innerHTML = `Clique sur le bouton pour récolter ta première banane !
`;
+ updateAutoHarvestTimer();
+ pveSelectedBananaId = null;
+ pveSelectedStage = 0;
+ renderHeader();
+ renderCollection();
+ renderShop();
+ renderQuests();
+ renderMuteBtn();
+ renderMinigamesMenu();
+ renderStats();
+ renderAchievements();
+ });
+
+ /* ---------------- Démarrage ---------------- */
+
+ renderHeader();
+ renderMuteBtn();
+ if (state.lastBananaId) {
+ const banana = BANANAS_BY_ID[state.lastBananaId];
+ els.lastBanana.innerHTML = bananaCardHTML(banana, state.counts[banana.id], false);
+ } else {
+ els.lastBanana.innerHTML = `Clique sur le bouton pour récolter ta première banane !
`;
+ }
+ renderCollection();
+ updateAutoHarvestTimer();
+ refreshQuestsIfNewDay();
+ saveState();
+
+ // Aucun son n'est joué pour les toasts affichés ici : ils apparaissent au
+ // chargement de la page, avant tout geste de l'utilisateur, ce que les
+ // navigateurs interdisent pour la lecture audio.
+ const streakResult = processDailyStreak();
+ if (streakResult) {
+ renderHeader();
+ setTimeout(() => {
+ showBanner(`🔥 JOUR ${streakResult.streak} !`, { emoji: "🪙", name: `+${streakResult.coinsEarned} pièces de connexion` }, 2200);
+ spawnConfetti(12);
+ }, 500);
+ }
+
+ const unlockedAtStart = checkAchievements();
+ if (unlockedAtStart.length > 0) {
+ renderHeader();
+ showAchievementToasts(unlockedAtStart, false);
+ }
+
+ updateAccountBtn();
+ // Vérifie une session cloud existante (déjà connecté précédemment) en
+ // arrière-plan, sans jamais bloquer le rendu initial du jeu solo.
+ CLOUD.init().then(() => {
+ updateAccountBtn();
+ renderHeader();
+ }).catch(() => {
+ // Hors ligne / service indisponible au démarrage : jeu solo inchangé.
+ });
+});