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 + + + + + + +
+
+

🍌 BANANA COLLECTOR

+
+ Collection : 0 / 100 + 🪙 Pièces : 0 + + +
+
+ + + +
+
+ +
+
+ +
+
+ + + +
+ +
+

Ma collection

+
+ Collection : 0 / 100 +
+
+
+ +
+

🕵️ Bananes secrètes 0 / 10

+
+
+
+ + + + +
+ +
+
+ + + +
+ +
+

Boutique

+

Dépense tes pièces pour améliorer tes chances d'obtenir des bananes rares !

+
+
+ + + + +
+ +
+
+ + +
+ +
+

⚔️ 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.

+ +
+ Ta championne : +
+
+ +
+
+
⚔️
+
+
+ + + + +

Progression dans l'arène :

+
+
+ + +
+ +
+
+ + +
+ +
+

🏆 Classement

+

Comparaison entre joueurs ayant un compte cloud (bouton "👤 Compte" en haut de la page).

+ +
+ + + +
+ +
+
+ + +
+
+
+ + + + + + + +
+ + + + + + + + + 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 + + + + + + + + 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 ` +
+ ${banana.name} +
+ `; + } + + 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}
+
✨✨✨
+
+ + `; + 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}
+
+ +
+ `; + }).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 ` + + `; + }).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" ? `` : ""} + ${mode === "sell" && listing.status === "active" ? `` : ""} +
+ `; + } + + 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}
+ + `; + 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 ` + + `; + }).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 ` + + `; + }).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 ``; + }).join(""); + return ` +
+ + +
+ `; + }).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 = ` + + + + + + ${cfg.columns.map((c) => ``).join("")} + + + + ${sorted.map((r, i) => ` + + + + ${cfg.columns.map((c) => ``).join("")} + + `).join("")} + +
#Joueur${c.label}
${i + 1}${r.username}${c.value(r)}
+ `; + } + + /* ---------------- 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 = ` +
+

👤 Compte

+

Le service de compte est momentanément indisponible (connexion réseau). Le jeu solo continue de fonctionner normalement — réessaie plus tard pour le Marché et l'Arène PVP.

+
+ `; + return; + } + + if (CLOUD.isLinked()) { + els.accountModalContent.innerHTML = ` +
+
👤 ${CLOUD.currentUsername()}
+
Connecté — le Marché et l'Arène PVP sont disponibles.
+ +
+ `; + els.accountModalContent.querySelector("#account-signout-btn").addEventListener("click", async () => { + await CLOUD.signOut(); + updateAccountBtn(); + renderAccountModal(); + renderMarketTab(); + renderPvpTab(); + }); + return; + } + + const isSignup = accountMode === "signup"; + els.accountModalContent.innerHTML = ` +
+

${isSignup ? "Créer un compte" : "Connexion"}

+ ${isSignup ? `

⚠️ Pas d'email associé à ce compte : si tu oublies ton mot de passe, il ne pourra pas être récupéré. Note-le bien quelque part !

` : ""} +
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + 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é. + }); +});