import { tryBuyEgg, tryPlaceEgg, tryUpgradePlot, tryUpgradeWorldMap, tryBuyLabEgg, getNurseryCellKeysOrdered, tryBuyBaby, tryBuyAnimal, placeMatureBabyOnCell, placeReceptionAnimalOnCell, addPendingBaby, addReceptionAnimal } from "./zoo.js"; import { tryUpgrade as _tryUpgradeConveyor, refreshOffers, pickSaleTargetZoo, getSkillLevel, tryUpgradeSchool, tryUpgradeTruck } from "./conveyor.js"; import { sellAnimalToNpc, addMatureBabyToSale, addReceptionAnimalToSale } from "./trade.js"; import { getPlotUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost, getWorldMapUpgradeResearchCost, getNurseryBuildCost, getSouvenirShopBuildCost, getNurseryUpgradeCost, getSouvenirShopUpgradeCost, getResearchBuildCost, getResearchUpgradeCost, getBilleterieBuildCost, getBilleterieUpgradeCost, getFoodBuildCost, getFoodUpgradeCost, getReceptionBuildCost, getReceptionUpgradeCost, getBiomeChangeColorBuildCost, getBiomeChangeColorUpgradeCost, getBiomeChangeTempBuildCost, getBiomeChangeTempUpgradeCost } from "./economy.js"; import { moveCell, tryBuildNursery, tryBuildSouvenirShop, tryUpgradeNursery, tryUpgradeSouvenirShop, tryBuildResearch, tryUpgradeResearch, tryBuildBilleterie, tryUpgradeBilleterie, tryBuildFood, tryUpgradeFood, tryBuildReception, tryUpgradeReception, tryBuildBiomeChangeColor, tryUpgradeBiomeChangeColor, tryBuildBiomeChangeTemp, tryUpgradeBiomeChangeTemp } from "./placement.js"; import { getCellBiome, getDisplayBiome, getDisplayTemperature, getTemperatureBand } from "./biome-rules.js"; import { getVisitorCount } from "./income.js"; import { getTimePhase } from "./time-weather.js"; import { canPrestige, doPrestige } from "./prestige.js"; import { playSound, setMusicEnabled, isMusicEnabled } from "./audio.js"; import { t, eggTypeLabel, animalLabel, errorMessage, questDescription, timePhaseLabel, weatherLabel, prestigeLabel, prestigeButton as _prestigeButton, prestigeHint, visitorsLabel, musicLabel, sellZoneTitle, sellZoneShortLabel, restartButton, helpRestart, questsTitle, salesPanelAriaLabel, salesPanelMySales, salesPanelToRecover, salesPanelAuctions, salesBtnAccept, salesBtnReject, salesBtnDeliver, salesBtnBid, salesPendingValidation, salesValidationInMinutes, salesBidInputAriaLabel, noFreeNursery, noFreeReception, autoProfileFamilyLabel, autoProfileSpecialisationLabel, autoProfilePickerTitle, autoProfilePickerFamilyStep, autoProfilePickerSpecialisationStep, autoProfileCancel, } from "./texts-fr.js"; import { getProfilesByFamily, AUTO_MODE_FAMILY_IDS } from "./auto-mode-profiles.js"; import { GameConfig } from "./config.js"; import { defaultAnimalWeights } from "./state.js"; import { getApiBase, createSale, getSales, acceptSale, rejectSale, placeBid, deliverSale, mapServerListingToClient } from "./api-client.js"; const EGG_EMOJI = "🥚"; const EMOJI_BY_COLOR = ["🐰", "🦌", "🐸", "🦎", "🐢", "🐬", "🦭", "🐟", "🦈", "🐳", "🦅", "🐺", "🐻", "🦊", "🐗"]; const animalEmoji = {}; for (let c = 0; c < 15; c++) { for (let r = 0; r < 5; r++) { animalEmoji[`c${c}_r${r}`] = EMOJI_BY_COLOR[c] ?? "🐾"; } } /** * @param {string} labelContent * @param {string} tooltipText * @returns {HTMLElement} */ function makeHelpWrap(labelContent, tooltipText) { const wrap = document.createElement("div"); wrap.className = "help-wrap"; const label = document.createElement("span"); label.textContent = labelContent; const icon = document.createElement("span"); icon.className = "help-icon"; icon.setAttribute("aria-label", "Aide"); icon.textContent = "?"; const bubble = document.createElement("div"); bubble.className = "tooltip-bubble"; bubble.textContent = tooltipText; wrap.append(label, icon, bubble); return wrap; } /** * @param {HTMLElement} parent * @param {string} titleText * @param {string} helpText */ function _addSectionTitle(parent, titleText, helpText) { const section = document.createElement("div"); section.className = "section-with-help"; const h2 = document.createElement("h2"); h2.textContent = titleText; section.appendChild(h2); section.appendChild(makeHelpWrap("", helpText)); parent.appendChild(section); } /** * @param {HTMLElement} root * @param {{ state: import("./types.js").GameState, setState: () => void, getLastHatched: () => Array<{ x: number, y: number }>, onRestart?: () => void, updateState?: (partial: Partial) => void }} opts * @returns {void} */ export function render(root, opts) { const { state, setState, getLastHatched, onRestart, updateState } = opts; const getHatched = getLastHatched ?? (() => []); const phase = getTimePhase(state.timeOfDay ?? 6); const weather = state.weather || "sun"; document.body.classList.remove("bg-phase-dawn", "bg-phase-day", "bg-phase-dusk", "bg-phase-night", "bg-weather-sun", "bg-weather-cloudy", "bg-weather-rain"); document.body.classList.add(`bg-phase-${phase.phase}`, `bg-weather-${weather}`); root.innerHTML = ""; const selected = { x: 1, y: 1 }; const pendingTokenByEggType = {}; let selectedTokenId = null; let emptyCellChoice = null; const errorMsg = { current: "" }; let lastActionWasDrop = false; let sellZoneJustDropped = false; const clampSelection = () => { selected.x = Math.max(1, Math.min(state.grid.width, selected.x)); selected.y = Math.max(1, Math.min(state.grid.height, selected.y)); }; const setError = (msg) => { errorMsg.current = msg; if (errEl) { errEl.textContent = msg; errEl.hidden = !msg; } }; const gameBar = document.createElement("div"); gameBar.className = "game-bar"; gameBar.setAttribute("aria-label", "Barre du jeu"); const gameBarTitleWrap = document.createElement("div"); gameBarTitleWrap.className = "game-bar-title-wrap"; const gameBarTitle = document.createElement("h1"); gameBarTitle.className = "game-bar-title"; gameBarTitle.textContent = t.title; const titleHelp = makeHelpWrap("", t.helpStatus); titleHelp.querySelector(".tooltip-bubble").classList.add("below"); gameBarTitleWrap.append(gameBarTitle, titleHelp); gameBar.appendChild(gameBarTitleWrap); const statusBar = document.createElement("div"); statusBar.className = "status-bar"; statusBar.setAttribute("aria-label", "Indicateurs"); function addStatusItem(iconEmoji, tooltipText, initialValue) { const item = document.createElement("span"); item.className = "status-bar-item"; const icon = document.createElement("span"); icon.className = "status-bar-icon"; icon.setAttribute("aria-hidden", "true"); icon.title = tooltipText; icon.textContent = iconEmoji; const value = document.createElement("span"); value.className = "status-bar-value"; value.textContent = initialValue; item.append(icon, value); return { item, valueEl: value }; } const statusBarCoins = addStatusItem("🪙", "Pièces", "0"); const statusBarPlot = addStatusItem("📐", "Parcelle", "1"); const statusBarCell = addStatusItem("📍", "Case sélectionnée", "1 1"); const statusBarSkill = addStatusItem("🎓", "Compétences", "1"); const statusBarVisitors = addStatusItem("👤", visitorsLabel, "0"); const statusBarOffers = addStatusItem("🥚", "Œufs à vendre", "0"); const statusBarTimeWeather = addStatusItem("🌤️", "Météo et heure", "—"); statusBar.append( statusBarCoins.item, statusBarPlot.item, statusBarCell.item, statusBarSkill.item, statusBarVisitors.item, statusBarOffers.item, statusBarTimeWeather.item ); gameBar.appendChild(statusBar); const gameBarActions = document.createElement("div"); gameBarActions.className = "game-bar-actions"; const viewSwitcherWrap = document.createElement("div"); viewSwitcherWrap.className = "game-bar-view-switcher"; viewSwitcherWrap.setAttribute("aria-label", "Zoo ou carte du monde"); const viewToggleBtn = document.createElement("button"); viewToggleBtn.className = "game-bar-btn game-bar-view-btn"; viewToggleBtn.type = "button"; viewToggleBtn.id = "view-toggle"; viewToggleBtn.setAttribute("aria-label", "Afficher la carte du monde"); viewToggleBtn.title = "Carte du monde (cliquer pour afficher)"; viewToggleBtn.textContent = "🗺️"; viewSwitcherWrap.appendChild(viewToggleBtn); function setViewToggleIcon(isZooActive) { viewToggleBtn.textContent = isZooActive ? "🦒" : "🗺️"; viewToggleBtn.setAttribute("aria-label", isZooActive ? "Afficher la carte du monde" : "Afficher la carte du zoo"); viewToggleBtn.title = isZooActive ? "Carte du monde (cliquer pour afficher)" : "Carte du zoo (cliquer pour afficher)"; } viewToggleBtn.addEventListener("click", () => { const showZoo = !panelZoo.classList.contains("active"); if (showZoo) { panelZoo.classList.add("active"); panelWorld.classList.remove("active"); setViewToggleIcon(true); } else { panelWorld.classList.add("active"); panelZoo.classList.remove("active"); setViewToggleIcon(false); if (getApiBase()) { getSales().then((data) => { state.salesFromApi = data; setState(); }).catch(() => {}); } } }); setViewToggleIcon(true); const musicBtn = document.createElement("button"); musicBtn.className = "game-bar-btn game-bar-btn-music" + (isMusicEnabled() ? "" : " muted"); musicBtn.type = "button"; musicBtn.setAttribute("aria-label", musicLabel); musicBtn.title = musicLabel; musicBtn.textContent = "🎵"; musicBtn.addEventListener("click", () => { const next = !isMusicEnabled(); setMusicEnabled(next); try { localStorage.setItem("builazoo_music", next ? "1" : "0"); } catch (_) { // ignore localStorage } musicBtn.classList.toggle("muted", !next); }); gameBarActions.appendChild(musicBtn); const autoModeBtn = document.createElement("button"); autoModeBtn.className = "game-bar-btn game-bar-btn-auto-mode"; autoModeBtn.type = "button"; autoModeBtn.id = "auto-mode-btn"; autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false"); autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)"; autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique"); autoModeBtn.textContent = state.autoMode ? "🤖" : "✋"; autoModeBtn.addEventListener("click", () => { if (state.autoMode) { updateState({ autoMode: false }); } else { updateState({ autoProfilePickerOpen: true, autoProfilePickerFamily: undefined }); } }); gameBarActions.appendChild(autoModeBtn); if (state.autoProfilePickerOpen) { const pickerWrap = document.createElement("div"); pickerWrap.className = "auto-profile-picker-wrap"; pickerWrap.setAttribute("role", "dialog"); pickerWrap.setAttribute("aria-label", autoProfilePickerTitle); const pickerTitle = document.createElement("div"); pickerTitle.className = "auto-profile-picker-title"; pickerTitle.textContent = autoProfilePickerTitle; pickerWrap.appendChild(pickerTitle); const familyId = state.autoProfilePickerFamily; if (familyId === null || familyId === undefined) { const stepLabel = document.createElement("div"); stepLabel.className = "auto-profile-picker-step"; stepLabel.textContent = autoProfilePickerFamilyStep; pickerWrap.appendChild(stepLabel); const familyBtns = document.createElement("div"); familyBtns.className = "auto-profile-picker-families"; for (const fid of AUTO_MODE_FAMILY_IDS) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "auto-profile-picker-family-btn"; btn.textContent = autoProfileFamilyLabel[fid] ?? `Famille ${fid}`; btn.addEventListener("click", () => updateState({ autoProfilePickerFamily: fid })); familyBtns.appendChild(btn); } pickerWrap.appendChild(familyBtns); } else { const stepLabel = document.createElement("div"); stepLabel.className = "auto-profile-picker-step"; stepLabel.textContent = autoProfilePickerSpecialisationStep; pickerWrap.appendChild(stepLabel); const profiles = getProfilesByFamily(familyId); const specWrap = document.createElement("div"); specWrap.className = "auto-profile-picker-specialisations"; for (const prof of profiles) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "auto-profile-picker-spec-btn"; btn.textContent = autoProfileSpecialisationLabel[String(prof.id)] ?? `Profil ${prof.id}`; btn.addEventListener("click", () => { updateState({ autoModeProfileId: prof.id, autoMode: true, autoProfilePickerOpen: false, autoProfilePickerFamily: undefined, }); }); specWrap.appendChild(btn); } pickerWrap.appendChild(specWrap); } const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "auto-profile-picker-cancel"; cancelBtn.textContent = autoProfileCancel; cancelBtn.addEventListener("click", () => updateState({ autoProfilePickerOpen: false, autoProfilePickerFamily: undefined })); pickerWrap.appendChild(cancelBtn); gameBarActions.appendChild(pickerWrap); } gameBarActions.insertBefore(viewSwitcherWrap, gameBarActions.firstChild); const prestigeBtn = document.createElement("button"); prestigeBtn.className = "game-bar-btn game-bar-btn-prestige"; prestigeBtn.type = "button"; prestigeBtn.setAttribute("aria-label", prestigeLabel); prestigeBtn.title = prestigeHint; prestigeBtn.textContent = "⭐"; gameBarActions.appendChild(prestigeBtn); const restartBtn = document.createElement("button"); restartBtn.className = "game-bar-btn game-bar-btn-restart"; restartBtn.type = "button"; restartBtn.setAttribute("aria-label", restartButton); restartBtn.title = helpRestart; restartBtn.textContent = "🔄"; if (onRestart) { restartBtn.addEventListener("click", () => onRestart()); } else { restartBtn.disabled = true; } gameBarActions.appendChild(restartBtn); const questWrap = document.createElement("div"); questWrap.className = "game-bar-quest-wrap"; const questBtn = document.createElement("button"); questBtn.className = "game-bar-btn game-bar-btn-quest"; questBtn.type = "button"; questBtn.setAttribute("aria-label", questsTitle); questBtn.setAttribute("aria-expanded", "false"); questBtn.title = questsTitle; questBtn.textContent = "📋"; const questDropdown = document.createElement("div"); questDropdown.className = "quest-dropdown"; questDropdown.setAttribute("role", "dialog"); questDropdown.setAttribute("aria-label", questsTitle); const questDropdownTitle = document.createElement("div"); questDropdownTitle.className = "quest-dropdown-title"; questDropdownTitle.textContent = questsTitle; questDropdown.appendChild(questDropdownTitle); const questListEl = document.createElement("div"); questListEl.className = "quest-list"; questDropdown.appendChild(questListEl); questWrap.appendChild(questBtn); questWrap.appendChild(questDropdown); questBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = questWrap.classList.toggle("open"); questBtn.setAttribute("aria-expanded", String(open)); }); document.addEventListener("click", () => { questWrap.classList.remove("open"); questBtn.setAttribute("aria-expanded", "false"); }); questDropdown.addEventListener("click", (e) => e.stopPropagation()); gameBarActions.appendChild(questWrap); gameBar.appendChild(gameBarActions); const errEl = document.createElement("div"); errEl.className = "error-msg"; errEl.hidden = true; const tabsWrap = document.createElement("div"); tabsWrap.className = "tabs-wrap"; tabsWrap.setAttribute("aria-label", "Carte du zoo et carte du monde"); const tabContent = document.createElement("div"); tabContent.className = "tabs-content"; const panelZoo = document.createElement("div"); panelZoo.className = "tab-panel active"; panelZoo.id = "tab-panel-zoo"; panelZoo.setAttribute("role", "tabpanel"); panelZoo.setAttribute("aria-labelledby", "view-toggle"); const panelWorld = document.createElement("div"); panelWorld.className = "tab-panel"; panelWorld.id = "tab-panel-world"; panelWorld.setAttribute("role", "tabpanel"); panelWorld.setAttribute("aria-labelledby", "view-toggle"); const worldMapWrap = document.createElement("div"); worldMapWrap.className = "world-map-wrap world-map-wrap-square"; const worldMapEl = document.createElement("div"); worldMapEl.className = "world-map world-map-biomes"; const mapLevel = state.worldMapLevel ?? 1; const zoom = Math.min(0.65 + (mapLevel - 1) * 0.2, 1.45); worldMapEl.style.transformOrigin = "50% 50%"; worldMapEl.style.transform = `scale(${zoom})`; worldMapWrap.appendChild(worldMapEl); const worldMapTruckEl = document.createElement("div"); worldMapTruckEl.className = "world-map-truck"; worldMapTruckEl.setAttribute("aria-hidden", "true"); worldMapWrap.appendChild(worldMapTruckEl); const worldMapNpcTrucksEl = document.createElement("div"); worldMapNpcTrucksEl.className = "world-map-trucks"; worldMapNpcTrucksEl.setAttribute("aria-hidden", "true"); worldMapWrap.appendChild(worldMapNpcTrucksEl); panelWorld.appendChild(worldMapWrap); const worldMapActions = document.createElement("div"); worldMapActions.className = "world-map-actions"; const worldMapUpgradeZone = document.createElement("div"); worldMapUpgradeZone.className = "world-map-upgrade-zone"; worldMapUpgradeZone.setAttribute("aria-label", "Agrandir la carte"); worldMapUpgradeZone.title = "Agrandir la carte"; worldMapUpgradeZone.innerHTML = "🗺️Agrandir carte"; worldMapUpgradeZone.setAttribute("role", "button"); worldMapUpgradeZone.setAttribute("tabindex", "0"); worldMapUpgradeZone.addEventListener("click", () => { const [ok, reason] = tryUpgradeWorldMap(state); if (!ok) { setError(String(t.upgradeWorldMapFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } else { setError(""); playSound("worldMapUpgrade"); } setState(); }); worldMapUpgradeZone.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); worldMapUpgradeZone.click(); } }); worldMapActions.appendChild(worldMapUpgradeZone); const worldMapCounters = document.createElement("div"); worldMapCounters.className = "world-map-counters"; worldMapCounters.setAttribute("aria-label", "Compteurs carte du monde"); worldMapActions.appendChild(worldMapCounters); const worldMapTruckDropZone = document.createElement("div"); worldMapTruckDropZone.className = "world-map-truck-drop-zone"; worldMapTruckDropZone.setAttribute("aria-label", "Camion pour acheter un œuf"); worldMapTruckDropZone.title = "Glissez un œuf ici pour l'acheter"; worldMapTruckDropZone.innerHTML = "🚚Acheter œuf"; worldMapTruckDropZone.addEventListener("dragover", (e) => { e.preventDefault(); const hasOffer = e.dataTransfer.types.includes("application/x-builazoo-eggtype") || e.dataTransfer.types.includes("application/x-builazoo-baby-offer") || e.dataTransfer.types.includes("application/x-builazoo-animal-offer"); e.dataTransfer.dropEffect = hasOffer ? "copy" : "none"; if (hasOffer) worldMapTruckDropZone.classList.add("dragover"); }); worldMapTruckDropZone.addEventListener("dragleave", () => worldMapTruckDropZone.classList.remove("dragover")); worldMapTruckDropZone.addEventListener("drop", (e) => { e.preventDefault(); worldMapTruckDropZone.classList.remove("dragover"); const babyOffer = e.dataTransfer.getData("application/x-builazoo-baby-offer"); if (babyOffer) { const [animalId, priceStr] = babyOffer.split(":"); const price = Number(priceStr) || 80; const [ok, result] = tryBuyBaby(state, animalId, price); if (!ok) setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); else setError(""); playSound(ok ? "buy" : "error"); setState(); return; } const animalOffer = e.dataTransfer.getData("application/x-builazoo-animal-offer"); if (animalOffer) { const [animalId, priceStr] = animalOffer.split(":"); const price = Number(priceStr) || 120; const [ok, result] = tryBuyAnimal(state, animalId, price); if (!ok) setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); else setError(""); playSound(ok ? "buy" : "error"); setState(); return; } const eggType = e.dataTransfer.getData("application/x-builazoo-eggtype"); const toZooId = e.dataTransfer.getData("application/x-builazoo-offer-zooid") || "player"; if (!eggType) return; const [ok, result] = tryBuyEgg(state, eggType); if (!ok) { setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); playSound("error"); } else { setError(""); playSound("buy"); state.eggPurchaseTruck = { eggType, fromZooId: "player", toZooId, startAt: Date.now() }; } setState(); }); worldMapActions.appendChild(worldMapTruckDropZone); panelWorld.appendChild(worldMapActions); const gridWrap = document.createElement("div"); gridWrap.className = "grid-wrap"; const gridEl = document.createElement("div"); gridEl.className = "grid"; gridWrap.appendChild(gridEl); const plotUpgradeZone = document.createElement("div"); plotUpgradeZone.className = "plot-upgrade-zone"; plotUpgradeZone.setAttribute("aria-label", t.upgradePlot); plotUpgradeZone.title = t.upgradePlot; plotUpgradeZone.innerHTML = "📐Agrandir zoo"; plotUpgradeZone.setAttribute("role", "button"); plotUpgradeZone.setAttribute("tabindex", "0"); plotUpgradeZone.addEventListener("click", () => { const [ok, reason] = tryUpgradePlot(state); if (!ok) { setError(String(t.upgradePlotFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } else { setError(""); playSound("plotUpgrade"); } setState(); }); plotUpgradeZone.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); plotUpgradeZone.click(); } }); gridWrap.appendChild(plotUpgradeZone); const sellZone = document.createElement("div"); sellZone.className = "sell-zone"; sellZone.setAttribute("aria-label", sellZoneTitle); sellZone.title = sellZoneTitle; sellZone.innerHTML = "🚚" + sellZoneShortLabel + ""; sellZone.addEventListener("dragover", (e) => { e.preventDefault(); const hasCell = e.dataTransfer.types.includes("text/plain"); e.dataTransfer.dropEffect = hasCell ? "move" : "none"; if (hasCell) sellZone.classList.add("dragover"); }); sellZone.addEventListener("dragleave", () => sellZone.classList.remove("dragover")); sellZone.addEventListener("drop", (e) => { e.preventDefault(); sellZone.classList.remove("dragover"); sellZoneJustDropped = true; const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); if (nurseryCellKey) { const [ok, result] = addMatureBabyToSale(state, nurseryCellKey); if (!ok) { setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); playSound("error"); } else { setError(""); playSound("sell"); const listing = state.saleListings[state.saleListings.length - 1]; if (getApiBase() && listing) { createSale({ animalId: listing.animalId, isBaby: true, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setState(); }).catch(() => {}); } } lastActionWasDrop = true; setState(); return; } if (receptionCellKey) { const [ok, result] = addReceptionAnimalToSale(state, receptionCellKey); if (!ok) { setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); playSound("error"); } else { setError(""); playSound("sell"); const listing = state.saleListings[state.saleListings.length - 1]; if (getApiBase() && listing) { createSale({ animalId: listing.animalId, isBaby: false, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setState(); }).catch(() => {}); } } lastActionWasDrop = true; setState(); return; } const raw = e.dataTransfer.getData("text/plain"); if (!raw || !/^\d+_\d+$/.test(raw)) return; const [sx, sy] = raw.split("_").map(Number); const [ok, result] = sellAnimalToNpc(state, sx, sy); if (!ok) { setError(String(t.sellFailed).replace("%s", errorMessage[result] ?? result)); playSound("error"); } else { setError(""); playSound("sell"); state.truckSale = { toZooId: pickSaleTargetZoo(state), startAt: Date.now() }; } lastActionWasDrop = true; setState(); }); sellZone.addEventListener("click", () => { if (sellZoneJustDropped) { sellZoneJustDropped = false; return; } const truckLevel = state.truckLevel ?? 1; const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5; if (truckLevel >= truckMax) return; if (state.coins < getTruckUpgradeCost(truckLevel)) return; const [ok, reason] = tryUpgradeTruck(state); if (!ok) { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } else { setError(""); playSound("truckUpgrade"); } setState(); }); gridWrap.appendChild(sellZone); const visitorsLayer = document.createElement("div"); visitorsLayer.className = "visitors-layer"; visitorsLayer.setAttribute("aria-hidden", "true"); gridWrap.appendChild(visitorsLayer); panelZoo.appendChild(gridWrap); tabContent.appendChild(panelZoo); tabContent.appendChild(panelWorld); tabsWrap.appendChild(errEl); tabsWrap.appendChild(tabContent); gameBarActions.insertBefore(viewSwitcherWrap, gameBarActions.firstChild); root.appendChild(gameBar); root.appendChild(tabsWrap); function updateStatus() { statusBarCoins.valueEl.textContent = String(Math.floor(state.coins)); statusBarPlot.valueEl.textContent = String(Math.floor(state.plotLevel)); statusBarCell.valueEl.textContent = `${Math.floor(selected.x)} ${Math.floor(selected.y)}`; statusBarSkill.valueEl.textContent = String(Math.floor(getSkillLevel(state))); const visitors = getVisitorCount(state); statusBarVisitors.valueEl.textContent = String(Math.floor(visitors)); const offersCount = (state.conveyorOffers ?? []).length; statusBarOffers.valueEl.textContent = String(Math.floor(offersCount)); const phase = getTimePhase(state.timeOfDay ?? 6); const weather = weatherLabel[state.weather] ?? state.weather; statusBarTimeWeather.valueEl.textContent = `${timePhaseLabel[phase.phase]} · ${weather}`; statusBarTimeWeather.item.className = "status-bar-item status-bar-time-weather weather-" + (state.weather ?? "sun"); musicBtn.classList.toggle("muted", !isMusicEnabled()); autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false"); autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)"; autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique"); autoModeBtn.textContent = state.autoMode ? "🤖" : "✋"; prestigeBtn.title = String(prestigeHint).replace("%d", String(GameConfig.Prestige.MinCoinsToReset)); prestigeBtn.disabled = !canPrestige(state); const qList = state.quests ?? []; questListEl.innerHTML = qList.map((q) => { const desc = questDescription[q.descriptionKey]; const text = desc ? String(desc).replace("%d", String(q.target)) : q.descriptionKey; const done = q.done ? " ✓" : ""; return `
${text} : ${q.current}/${q.target}${done}
`; }).join(""); } const WORLD_MAP_GRID_COLS = 12; const WORLD_MAP_GRID_ROWS = 8; function renderWorldMap() { worldMapEl.innerHTML = ""; const playerZooId = state.myZooId ?? "player"; const api = state.salesFromApi; const myListingsFromApi = api?.asSeller ? api.asSeller.map(mapServerListingToClient) : null; const zooListingsForPlayer = myListingsFromApi ?? (state.saleListings ?? []).filter((s) => s.zooId === playerZooId); const salesPanel = document.createElement("div"); salesPanel.className = "world-map-sales-panel"; salesPanel.setAttribute("aria-label", salesPanelAriaLabel); if (api) { if (api.asSeller && api.asSeller.length > 0) { const sellerTitle = document.createElement("div"); sellerTitle.className = "sales-panel-title"; sellerTitle.textContent = salesPanelMySales; salesPanel.appendChild(sellerTitle); for (const s of api.asSeller) { const row = document.createElement("div"); row.className = "sales-panel-row"; const emoji = animalEmoji[s.animal_id] ?? "🐾"; const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); row.innerHTML = `${emoji}${label}${s.initial_price} 💰`; if (s.best_bid_amount != null) { const btnWrap = document.createElement("div"); btnWrap.className = "sales-panel-actions"; const acceptBtn = document.createElement("button"); acceptBtn.type = "button"; acceptBtn.textContent = salesBtnAccept; acceptBtn.className = "sales-btn-accept"; acceptBtn.addEventListener("click", () => { acceptSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); }); const rejectBtn = document.createElement("button"); rejectBtn.type = "button"; rejectBtn.textContent = salesBtnReject; rejectBtn.className = "sales-btn-reject"; rejectBtn.addEventListener("click", () => { rejectSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); }); btnWrap.appendChild(acceptBtn); btnWrap.appendChild(rejectBtn); row.appendChild(btnWrap); } salesPanel.appendChild(row); } } if (api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) { const buyerTitle = document.createElement("div"); buyerTitle.className = "sales-panel-title"; buyerTitle.textContent = salesPanelToRecover; salesPanel.appendChild(buyerTitle); for (const s of api.asBuyerUndelivered) { const row = document.createElement("div"); row.className = "sales-panel-row"; const emoji = animalEmoji[s.animal_id] ?? "🐾"; const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); row.innerHTML = `${emoji}${label}`; const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0; const nowMs = Date.now(); const pendingValidation = s.status === "sold" && validatedAtMs > nowMs; if (pendingValidation) { const remainingMin = Math.ceil((validatedAtMs - nowMs) / 60000); const pendingEl = document.createElement("span"); pendingEl.className = "sales-pending-validation"; pendingEl.setAttribute("aria-label", salesPendingValidation); pendingEl.textContent = `⏳ ${salesValidationInMinutes.replace("%s", String(remainingMin))}`; row.appendChild(pendingEl); } const deliverBtn = document.createElement("button"); deliverBtn.type = "button"; deliverBtn.textContent = salesBtnDeliver; deliverBtn.className = "sales-btn-deliver"; deliverBtn.disabled = pendingValidation; deliverBtn.addEventListener("click", () => { const [ok, keyOrReason] = s.is_baby ? addPendingBaby(state, s.animal_id, true) : addReceptionAnimal(state, s.animal_id); if (!ok) { setError(keyOrReason === "NoFreeNursery" ? noFreeNursery : keyOrReason === "NoFreeReception" ? noFreeReception : String(keyOrReason)); setState(); return; } setState(); deliverSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); }); row.appendChild(deliverBtn); salesPanel.appendChild(row); } } if (api.active && api.active.length > 0) { const activeTitle = document.createElement("div"); activeTitle.className = "sales-panel-title"; activeTitle.textContent = salesPanelAuctions; salesPanel.appendChild(activeTitle); for (const s of api.active) { if (s.seller_zoo_id === playerZooId) continue; const row = document.createElement("div"); row.className = "sales-panel-row sales-panel-row-bid"; const emoji = animalEmoji[s.animal_id] ?? "🐾"; const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); const minBid = (s.best_bid_amount ?? s.initial_price) + 1; row.innerHTML = `${emoji}${label}${s.initial_price} 💰`; const input = document.createElement("input"); input.type = "number"; input.min = String(minBid); input.value = String(minBid); input.className = "sales-bid-input"; input.setAttribute("aria-label", salesBidInputAriaLabel); const bidBtn = document.createElement("button"); bidBtn.type = "button"; bidBtn.textContent = salesBtnBid; bidBtn.className = "sales-btn-bid"; bidBtn.addEventListener("click", () => { const amount = Number(input.value) || minBid; placeBid(s.id, amount).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); }); row.appendChild(input); row.appendChild(bidBtn); salesPanel.appendChild(row); } } } if (salesPanel.childNodes.length > 0) worldMapEl.appendChild(salesPanel); const cellsLayer = document.createElement("div"); cellsLayer.className = "world-map-cells"; cellsLayer.setAttribute("aria-hidden", "true"); cellsLayer.style.gridTemplateColumns = `repeat(${WORLD_MAP_GRID_COLS}, 1fr)`; cellsLayer.style.gridTemplateRows = `repeat(${WORLD_MAP_GRID_ROWS}, 1fr)`; for (let row = 0; row < WORLD_MAP_GRID_ROWS; row++) { for (let col = 0; col < WORLD_MAP_GRID_COLS; col++) { const cellDiv = document.createElement("div"); cellDiv.className = "world-map-cell"; const biome = getCellBiome(WORLD_MAP_GRID_COLS, WORLD_MAP_GRID_ROWS, col + 1, row + 1); cellDiv.classList.add(`world-map-cell-${biome.toLowerCase()}`); cellsLayer.appendChild(cellDiv); } } worldMapEl.appendChild(cellsLayer); const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; const offers = state.conveyorOffers || []; for (const zoo of zoos) { const isPlayer = zoo.id === "player"; const zooOffers = offers.filter((o) => (o.zooId ?? "player") === zoo.id); const oneOffer = !isPlayer && zooOffers.length > 0 ? zooOffers[0] : null; const playerBabyOffer = isPlayer ? zooOffers.find((o) => o.type === "baby") : null; const playerAnimalOffer = isPlayer ? zooOffers.find((o) => o.type === "animal") : null; const node = document.createElement("div"); node.className = "world-map-zoo" + (isPlayer ? " world-map-zoo-player" : ""); node.style.left = `${zoo.x}%`; node.style.top = `${zoo.y}%`; node.dataset.zooId = zoo.id; const nameEl = document.createElement("div"); nameEl.className = "world-map-zoo-name"; nameEl.textContent = zoo.name; node.appendChild(nameEl); if (isPlayer) { const scoreEl = document.createElement("div"); scoreEl.className = "world-map-zoo-reproduction-score"; scoreEl.textContent = `Score repro: ${(state.reproductionScore ?? 0).toFixed(1)}`; node.appendChild(scoreEl); const attrEl = document.createElement("div"); attrEl.className = "world-map-zoo-attractivity-score"; attrEl.textContent = `Score attractivité: ${(state.attractivityScore ?? 0).toFixed(1)}`; node.appendChild(attrEl); } if (!isPlayer && zoo.botState) { const indEl = document.createElement("div"); indEl.className = "world-map-zoo-indicators"; indEl.textContent = `${Math.floor(zoo.botState.coins)} · Parcelle ${zoo.botState.plotLevel}`; node.appendChild(indEl); } const slotEl = document.createElement("div"); slotEl.className = "world-map-zoo-slot"; const zooListings = isPlayer ? zooListingsForPlayer : []; if (isPlayer && zooListings.length > 0) { for (const listing of zooListings.slice(0, 3)) { const el = document.createElement("div"); el.className = "world-map-sale-listing"; const emoji = animalEmoji[listing.animalId] ?? "🐾"; const label = listing.isBaby ? `Bébé ${animalLabel[listing.animalId] ?? listing.animalId}` : (animalLabel[listing.animalId] ?? listing.animalId); el.innerHTML = `${emoji}${label}${listing.price} 💰`; el.title = "En vente sur la carte (phase 10)"; slotEl.appendChild(el); } } else if (oneOffer) { const el = document.createElement("div"); el.className = "offer-btn world-map-offer world-map-offer-single"; el.setAttribute("role", "button"); el.setAttribute("tabindex", "0"); el.setAttribute("draggable", "true"); const name = eggTypeLabel[oneOffer.eggType] ?? oneOffer.eggType; el.innerHTML = `${EGG_EMOJI}${name}${oneOffer.price} pièces`; let dragStarted = false; el.addEventListener("dragstart", (e) => { dragStarted = true; e.dataTransfer.setData("application/x-builazoo-eggtype", oneOffer.eggType); e.dataTransfer.setData("application/x-builazoo-offer-zooid", zoo.id); e.dataTransfer.effectAllowed = "copy"; el.classList.add("dragging"); }); el.addEventListener("dragend", () => { dragStarted = false; el.classList.remove("dragging"); }); el.addEventListener("click", () => { if (dragStarted) return; setError("Glissez l'œuf sur le camion pour l'acheter."); setState(); }); el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.click(); } }); slotEl.appendChild(el); } else if (isPlayer && (playerBabyOffer || playerAnimalOffer)) { if (playerBabyOffer) { const el = document.createElement("div"); el.className = "offer-btn world-map-offer"; el.setAttribute("draggable", "true"); const emoji = animalEmoji[playerBabyOffer.animalId] ?? "🐾"; const name = animalLabel[playerBabyOffer.animalId] ?? playerBabyOffer.animalId; el.innerHTML = `${emoji}Bébé ${name}${playerBabyOffer.price}`; el.addEventListener("dragstart", (e) => { e.dataTransfer.setData("application/x-builazoo-baby-offer", `${playerBabyOffer.animalId}:${playerBabyOffer.price}`); e.dataTransfer.effectAllowed = "copy"; }); slotEl.appendChild(el); } if (playerAnimalOffer) { const el = document.createElement("div"); el.className = "offer-btn world-map-offer"; el.setAttribute("draggable", "true"); const emoji = animalEmoji[playerAnimalOffer.animalId] ?? "🐾"; const name = animalLabel[playerAnimalOffer.animalId] ?? playerAnimalOffer.animalId; el.innerHTML = `${emoji}${name}${playerAnimalOffer.price}`; el.addEventListener("dragstart", (e) => { e.dataTransfer.setData("application/x-builazoo-animal-offer", `${playerAnimalOffer.animalId}:${playerAnimalOffer.price}`); e.dataTransfer.effectAllowed = "copy"; }); slotEl.appendChild(el); } } else { const iconEl = document.createElement("span"); iconEl.className = "world-map-zoo-icon"; iconEl.setAttribute("aria-hidden", "true"); iconEl.textContent = "🏠"; slotEl.appendChild(iconEl); } node.appendChild(slotEl); worldMapEl.appendChild(node); } const cities = GameConfig.WorldMap?.Cities ?? []; for (const city of cities) { const cityEl = document.createElement("div"); cityEl.className = "world-map-city"; cityEl.style.left = `${city.x}%`; cityEl.style.top = `${city.y}%`; const maxVisitors = city.maxVisitorsTowardZoos ?? 0; cityEl.title = maxVisitors > 0 ? `${city.name} — max ${maxVisitors} visiteurs vers zoos` : city.name; cityEl.setAttribute("aria-label", maxVisitors > 0 ? `${city.name}, ${maxVisitors} visiteurs max vers zoos` : city.name); const icon = document.createElement("span"); icon.setAttribute("aria-hidden", "true"); icon.textContent = "🏙️"; cityEl.appendChild(icon); const cityLabel = document.createElement("div"); cityLabel.className = "world-map-city-label"; cityLabel.textContent = city.name; cityEl.appendChild(cityLabel); if (maxVisitors > 0) { const cityMax = document.createElement("div"); cityMax.className = "world-map-city-max-visitors"; cityMax.textContent = `max ${maxVisitors}`; cityMax.setAttribute("aria-hidden", "true"); cityEl.appendChild(cityMax); } worldMapEl.appendChild(cityEl); } const lab = GameConfig.WorldMap?.Laboratory; if (lab) { const labNode = document.createElement("div"); labNode.className = "world-map-lab"; labNode.style.left = `${lab.x}%`; labNode.style.top = `${lab.y}%`; labNode.dataset.poi = "laboratory"; const labNameEl = document.createElement("div"); labNameEl.className = "world-map-zoo-name"; labNameEl.textContent = lab.name ?? "Laboratoire"; labNode.appendChild(labNameEl); const labSlotEl = document.createElement("div"); labSlotEl.className = "world-map-zoo-slot"; const labOffer = state.laboratoryOffer; if (labOffer) { const el = document.createElement("div"); el.className = "offer-btn world-map-offer world-map-offer-single world-map-lab-offer"; el.setAttribute("role", "button"); el.setAttribute("tabindex", "0"); el.setAttribute("draggable", "true"); const name = eggTypeLabel[labOffer.eggType] ?? labOffer.eggType; el.innerHTML = `${EGG_EMOJI}${name}${labOffer.price} pièces`; let dragStarted = false; el.addEventListener("dragstart", (e) => { dragStarted = true; e.dataTransfer.setData("application/x-builazoo-eggtype", labOffer.eggType); e.dataTransfer.effectAllowed = "copy"; el.classList.add("dragging"); }); el.addEventListener("dragend", () => { dragStarted = false; el.classList.remove("dragging"); }); el.addEventListener("click", () => { if (dragStarted) return; const [ok, result] = tryBuyLabEgg(state, labOffer.eggType); if (!ok) { const msg = errorMessage[result] ?? result; setError(String(t.buyFailed).replace("%s", msg)); playSound("error"); setState(); return; } setError(""); playSound("buy"); pendingTokenByEggType[labOffer.eggType] = result.tokenId; setState(); }); el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.click(); } }); labSlotEl.appendChild(el); } else { const iconEl = document.createElement("span"); iconEl.className = "world-map-zoo-icon"; iconEl.setAttribute("aria-hidden", "true"); iconEl.textContent = "🔬"; labSlotEl.appendChild(iconEl); } labNode.appendChild(labSlotEl); worldMapEl.appendChild(labNode); } const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; const truckSale = state.truckSale; const eggPurchase = state.eggPurchaseTruck; const truckLevel = state.truckLevel ?? 1; if (truckSale && truckSale.toZooId) { const elapsed = Date.now() - (truckSale.startAt || 0); if (elapsed >= truckMs) { delete state.truckSale; } else { const fromZoo = zoos.find((z) => z.id === "player"); const toZoo = zoos.find((z) => z.id === truckSale.toZooId); if (fromZoo && toZoo) { const progress = elapsed / truckMs; const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress; const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress; worldMapTruckEl.style.display = "block"; worldMapTruckEl.style.left = `${x}%`; worldMapTruckEl.style.top = `${y}%`; worldMapTruckEl.textContent = "🚚"; setTimeout(setState, 50); } } } else if (eggPurchase && eggPurchase.startAt) { const durationMs = Math.max(1000, (truckMs * 2) / truckLevel); const elapsed = Date.now() - eggPurchase.startAt; if (elapsed >= durationMs) { delete state.eggPurchaseTruck; worldMapTruckEl.style.display = "none"; } else { const fromZoo = zoos.find((z) => z.id === eggPurchase.fromZooId); const toZoo = zoos.find((z) => z.id === eggPurchase.toZooId); if (fromZoo && toZoo) { const progress = elapsed / durationMs; let x; let y; if (progress < 0.5) { const leg = progress * 2; x = fromZoo.x + (toZoo.x - fromZoo.x) * leg; y = fromZoo.y + (toZoo.y - fromZoo.y) * leg; } else { const leg = (progress - 0.5) * 2; x = toZoo.x + (fromZoo.x - toZoo.x) * leg; y = toZoo.y + (fromZoo.y - toZoo.y) * leg; } worldMapTruckEl.style.display = "block"; worldMapTruckEl.style.left = `${x}%`; worldMapTruckEl.style.top = `${y}%`; worldMapTruckEl.textContent = "🚚"; setTimeout(setState, 50); } } } else { worldMapTruckEl.style.display = "none"; } worldMapNpcTrucksEl.innerHTML = ""; const npcTrucks = state.worldTruckSales ?? []; for (const t of npcTrucks) { const fromZoo = zoos.find((z) => z.id === t.fromZooId); const toZoo = zoos.find((z) => z.id === t.toZooId); if (fromZoo && toZoo) { const elapsed = Date.now() - (t.startAt || 0); if (elapsed < truckMs) { const progress = elapsed / truckMs; const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress; const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress; const truckDiv = document.createElement("div"); truckDiv.className = "world-map-truck world-map-truck-npc"; truckDiv.style.left = `${x}%`; truckDiv.style.top = `${y}%`; truckDiv.textContent = "🚚"; worldMapNpcTrucksEl.appendChild(truckDiv); } } } if (npcTrucks.length > 0 || (truckSale && truckSale.toZooId) || (eggPurchase && eggPurchase.startAt)) { setTimeout(setState, 50); } } function renderGrid() { gridEl.style.gridTemplateColumns = `repeat(${state.grid.width}, 48px)`; gridEl.style.gridTemplateRows = `repeat(${state.grid.height}, 48px)`; gridEl.innerHTML = ""; for (let y = 1; y <= state.grid.height; y++) { for (let x = 1; x <= state.grid.width; x++) { const key = `${x}_${y}`; const cell = state.grid.cells[key]; const div = document.createElement("div"); div.className = "cell"; const biome = getDisplayBiome(x, y, state.grid); const temp = getDisplayTemperature(x, y, state.grid); const tempBand = getTemperatureBand(temp); div.classList.add(`biome-${biome.toLowerCase()}`, `temp-${tempBand}`); const hatchedList = getHatched(); if (hatchedList.some((h) => h.x === x && h.y === y)) div.classList.add("just-hatched"); div.setAttribute("role", "button"); div.setAttribute("tabindex", "0"); div.dataset.x = String(x); div.dataset.y = String(y); const isSelected = selected.x === x && selected.y === y; if (isSelected) div.classList.add("selected"); if (cell === null || cell === undefined) { div.classList.add("empty"); if (emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { const nurseryCost = getNurseryBuildCost(); const shopCost = getSouvenirShopBuildCost(); const researchCost = getResearchBuildCost(); const billeterieCost = getBilleterieBuildCost(); const foodCost = getFoodBuildCost(); const receptionCost = getReceptionBuildCost(); const biomeColorCost = getBiomeChangeColorBuildCost(); const biomeTempCost = getBiomeChangeTempBuildCost(); const canNursery = state.coins >= nurseryCost; const canShop = state.coins >= shopCost; const canResearch = state.coins >= researchCost; const canBilleterie = state.coins >= billeterieCost; const canFood = state.coins >= foodCost; const canReception = state.coins >= receptionCost; const canBiomeColor = state.coins >= biomeColorCost; const canBiomeTemp = state.coins >= biomeTempCost; div.innerHTML = ``; div.classList.add("empty-choice"); } else { div.textContent = ""; } } else if (cell.kind === "school") { div.classList.add("school"); const schoolMaxLevel = (GameConfig.School && GameConfig.School.MaxLevel) || 8; const canUpgradeSchool = cell.level < schoolMaxLevel && state.coins >= getSchoolUpgradeCost(cell.level); if (canUpgradeSchool) div.classList.add("can-upgrade"); const arrow = canUpgradeSchool ? '' : ""; div.innerHTML = `🏫École ${cell.level}${arrow}`; } else if (cell.kind === "nursery") { div.classList.add("nursery"); const nurseryLevel = cell.level ?? 1; const nurseryMax = GameConfig.Nursery?.MaxLevel ?? 7; const canUpgradeNursery = nurseryLevel < nurseryMax && state.coins >= getNurseryUpgradeCost(nurseryLevel); if (canUpgradeNursery) div.classList.add("can-upgrade"); const pendingBaby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === key); const token = cell.tokenId !== null && cell.tokenId !== undefined ? state.pendingEggTokens.find((t) => t.tokenId === cell.tokenId) : null; if (pendingBaby) { const nowUnix = Math.floor(Date.now() / 1000); const isMature = nowUnix >= pendingBaby.readyAt; const emoji = animalEmoji[pendingBaby.animalId] ?? "🐾"; const label = isMature ? "Bébé prêt" : "Bébé…"; div.classList.add("cell-draggable"); div.draggable = isMature; div.innerHTML = `${emoji}${label}`; if (isMature) div.dataset.nurseryCellKey = key; } else if (token) { div.classList.add("cell-draggable"); div.draggable = true; const label = eggTypeLabel[token.eggType] ?? token.eggType; div.innerHTML = `${EGG_EMOJI}${label}`; div.dataset.tokenId = String(cell.tokenId); } else { const arrow = canUpgradeNursery ? '' : ""; div.innerHTML = `🐣Nurserie ${nurseryLevel}${arrow}`; } } else if (cell.kind === "souvenirShop") { div.classList.add("souvenir-shop"); const shopLevel = cell.level ?? 1; const shopMax = GameConfig.SouvenirShop?.MaxLevel ?? 7; const canUpgradeShop = shopLevel < shopMax && state.coins >= getSouvenirShopUpgradeCost(shopLevel); if (canUpgradeShop) div.classList.add("can-upgrade"); const arrow = canUpgradeShop ? '' : ""; div.innerHTML = `🛒Boutique ${shopLevel}${arrow}`; } else if (cell.kind === "research") { div.classList.add("research"); const level = cell.level ?? 1; const maxLevel = GameConfig.Research?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getResearchUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const arrow = canUpgrade ? '' : ""; div.innerHTML = `🔬Recherche ${level}${arrow}`; } else if (cell.kind === "billeterie") { div.classList.add("billeterie"); const level = cell.level ?? 1; const maxLevel = GameConfig.Billeterie?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getBilleterieUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const arrow = canUpgrade ? '' : ""; div.innerHTML = `🎫Billeterie ${level}${arrow}`; } else if (cell.kind === "food") { div.classList.add("food"); const level = cell.level ?? 1; const maxLevel = GameConfig.Food?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getFoodUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const arrow = canUpgrade ? '' : ""; div.innerHTML = `🥗Nourriture ${level}${arrow}`; } else if (cell.kind === "reception") { div.classList.add("reception"); const level = cell.level ?? 1; const maxLevel = GameConfig.Reception?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getReceptionUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const recAnimal = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === key); const nowUnix = Math.floor(Date.now() / 1000); if (recAnimal) { const isReady = nowUnix >= recAnimal.readyAt; const emoji = animalEmoji[recAnimal.animalId] ?? "🐾"; const label = isReady ? "Animal prêt" : "Acclimatation…"; div.classList.add("cell-draggable"); div.draggable = isReady; const arrow = canUpgrade ? '' : ""; div.innerHTML = `${emoji}${label}${arrow}`; if (isReady) div.dataset.receptionCellKey = key; } else { const arrow = canUpgrade ? '' : ""; div.innerHTML = `📥Accueil ${level}${arrow}`; } } else if (cell.kind === "biomeChangeColor") { div.classList.add("biome-change-color"); const level = cell.level ?? 1; const maxLevel = GameConfig.BiomeChangeColor?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeColorUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const arrow = canUpgrade ? '' : ""; div.innerHTML = `🎨Couleur ${level}${arrow}`; } else if (cell.kind === "biomeChangeTemp") { div.classList.add("biome-change-temp"); const level = cell.level ?? 1; const maxLevel = GameConfig.BiomeChangeTemp?.MaxLevel ?? 7; const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeTempUpgradeCost(level); if (canUpgrade) div.classList.add("can-upgrade"); const arrow = canUpgrade ? '' : ""; div.innerHTML = `🌡️Temp ${level}${arrow}`; } else if (cell.kind === "egg") { div.classList.add("egg", "cell-draggable"); div.draggable = true; const label = eggTypeLabel[cell.eggType] ?? cell.eggType; div.innerHTML = `${EGG_EMOJI}${label}`; } else { div.classList.add("animal", "cell-draggable"); div.draggable = true; const w = cell.cellsWide ?? 1; const h = cell.cellsHigh ?? 1; const isMulti = w > 1 || h > 1; const isOrigin = cell.originKey === null || cell.originKey === undefined || cell.originKey === key; if (isMulti) div.classList.add("multi-cell"); if (isMulti && isOrigin) div.classList.add("multi-cell-origin"); const emoji = animalEmoji[cell.id] ?? "🐾"; const label = animalLabel[cell.id] ?? cell.id; div.innerHTML = `${emoji}${label}`; } if (cell !== null && cell !== undefined && (cell.kind === "egg" || cell.kind === "animal" || (cell.kind === "nursery" && (cell.tokenId !== null && cell.tokenId !== undefined || div.dataset.nurseryCellKey)) || (cell.kind === "reception" && div.dataset.receptionCellKey))) { div.addEventListener("dragstart", (e) => { let dragX = x; let dragY = y; if (div.dataset.nurseryCellKey) { e.dataTransfer.setData("application/x-builazoo-nursery-cell-key", div.dataset.nurseryCellKey); e.dataTransfer.effectAllowed = "move"; } else if (div.dataset.receptionCellKey) { e.dataTransfer.setData("application/x-builazoo-reception-cell-key", div.dataset.receptionCellKey); e.dataTransfer.effectAllowed = "move"; } else if (cell.kind === "animal" && cell.originKey !== null && cell.originKey !== undefined) { const m = cell.originKey.match(/^(\d+)_(\d+)$/); if (m) { dragX = Number(m[1]); dragY = Number(m[2]); } } if (!div.dataset.nurseryCellKey && !div.dataset.receptionCellKey) { e.dataTransfer.setData("text/plain", `${dragX}_${dragY}`); } if (cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) e.dataTransfer.setData("application/x-builazoo-tokenid", String(cell.tokenId)); e.dataTransfer.effectAllowed = e.dataTransfer.effectAllowed || "move"; div.classList.add("dragging"); const ghost = div.cloneNode(true); ghost.classList.add("drag-ghost"); ghost.style.opacity = "1"; document.body.appendChild(ghost); e.dataTransfer.setDragImage(ghost, 24, 24); const cleanup = () => { ghost.remove(); }; div.addEventListener("dragend", cleanup, { once: true }); }); } if (cell !== null && cell !== undefined && (cell.kind === "egg" || cell.kind === "animal" || (cell.kind === "nursery" && (cell.tokenId !== null && cell.tokenId !== undefined || div.dataset.nurseryCellKey)) || (cell.kind === "reception" && div.dataset.receptionCellKey))) { div.addEventListener("dragend", () => { div.classList.remove("dragging"); gridEl.querySelectorAll(".cell").forEach((c) => c.classList.remove("dragover")); }); } div.addEventListener("dragover", (e) => { e.preventDefault(); const hasEggType = e.dataTransfer.types.includes("application/x-builazoo-eggtype"); const hasTokenId = e.dataTransfer.types.includes("application/x-builazoo-tokenid"); const hasNurseryKey = e.dataTransfer.types.includes("application/x-builazoo-nursery-cell-key"); const hasReceptionKey = e.dataTransfer.types.includes("application/x-builazoo-reception-cell-key"); e.dataTransfer.dropEffect = hasEggType || hasTokenId ? "copy" : "move"; if (cell === null || cell === undefined && (hasEggType || hasTokenId || hasNurseryKey || hasReceptionKey)) div.classList.add("dragover"); }); div.addEventListener("dragleave", () => { div.classList.remove("dragover"); }); div.addEventListener("drop", (e) => { e.preventDefault(); div.classList.remove("dragover"); const toX = Number(div.dataset.x); const toY = Number(div.dataset.y); const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); if (nurseryCellKey && cell === null || cell === undefined) { const nowUnix = Math.floor(Date.now() / 1000); const [ok, reason] = placeMatureBabyOnCell(state, { nurseryCellKey, toX, toY, nowUnix }); if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } lastActionWasDrop = true; setState(); return; } const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); if (receptionCellKey && cell === null || cell === undefined) { const nowUnix = Math.floor(Date.now() / 1000); const [ok, reason] = placeReceptionAnimalOnCell(state, { receptionCellKey, toX, toY, nowUnix }); if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } lastActionWasDrop = true; setState(); return; } const tokenIdStr = e.dataTransfer.getData("application/x-builazoo-tokenid"); if (tokenIdStr && cell === null || cell === undefined) { const tokenId = Number(tokenIdStr); if (!Number.isNaN(tokenId)) { const nowUnix = Math.floor(Date.now() / 1000); const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix }); if (placeOk) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); playSound("error"); } lastActionWasDrop = true; setState(); return; } } const eggTypeFromConveyor = e.dataTransfer.getData("application/x-builazoo-eggtype"); if (eggTypeFromConveyor && cell === null || cell === undefined) { const [buyOk, buyResult] = tryBuyEgg(state, eggTypeFromConveyor); if (!buyOk) { setError(String(t.buyFailed).replace("%s", errorMessage[buyResult] ?? buyResult)); playSound("error"); } else { const tokenId = buyResult.tokenId; const nowUnix = Math.floor(Date.now() / 1000); const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix }); if (placeOk) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); playSound("error"); } } lastActionWasDrop = true; setState(); return; } const raw = e.dataTransfer.getData("text/plain"); if (!raw || !/^\d+_\d+$/.test(raw)) return; const [sx, sy] = raw.split("_").map(Number); const [ok, reason] = moveCell(state, { fromX: sx, fromY: sy, toX, toY }); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); lastActionWasDrop = true; setState(); }); div.addEventListener("click", (e) => { if (lastActionWasDrop) { lastActionWasDrop = false; return; } const choiceBtn = e.target.closest(".cell-choice-btn"); if (choiceBtn && cell === null || cell === undefined && emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { const choice = choiceBtn.dataset.choice; if (choice === "nursery") { const [ok, reason] = tryBuildNursery(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "shop") { const [ok, reason] = tryBuildSouvenirShop(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "research") { const [ok, reason] = tryBuildResearch(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "billeterie") { const [ok, reason] = tryBuildBilleterie(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "food") { const [ok, reason] = tryBuildFood(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "reception") { const [ok, reason] = tryBuildReception(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "biomeColor") { const [ok, reason] = tryBuildBiomeChangeColor(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } else if (choice === "biomeTemp") { const [ok, reason] = tryBuildBiomeChangeTemp(state, x, y); if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError(""); emptyCellChoice = null; } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) { selectedTokenId = cell.tokenId; setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) { const hasBaby = (state.pendingBabies ?? []).some((p) => p.nurseryCellKey === key); if (!hasBaby) { const [ok, reason] = tryUpgradeNursery(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "NurseryMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") { const [ok, reason] = tryUpgradeSouvenirShop(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "SouvenirShopMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "research") { const [ok, reason] = tryUpgradeResearch(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ResearchMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "billeterie") { const [ok, reason] = tryUpgradeBilleterie(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BilleterieMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "food") { const [ok, reason] = tryUpgradeFood(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "FoodMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "reception") { const hasAnimal = (state.receptionAnimals ?? []).some((r) => r.receptionCellKey === key); if (!hasAnimal) { const [ok, reason] = tryUpgradeReception(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ReceptionMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "biomeChangeColor") { const [ok, reason] = tryUpgradeBiomeChangeColor(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeColorMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "biomeChangeTemp") { const [ok, reason] = tryUpgradeBiomeChangeTemp(state, x, y); if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeTempMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell !== null && cell !== undefined && cell.kind === "school") { const [ok, reason] = tryUpgradeSchool(state, x, y); if (!ok) { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } else { setError(""); playSound("schoolUpgrade"); } setState(); return; } selected.x = x; selected.y = y; clampSelection(); if (cell === null || cell === undefined && emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { emptyCellChoice = null; setState(); return; } const nurseryKeys = getNurseryCellKeysOrdered(state); let firstTokenId = null; for (const k of nurseryKeys) { const c = state.grid.cells[k]; if (c && c.kind === "nursery" && c.tokenId !== null && c.tokenId !== undefined) { firstTokenId = c.tokenId; break; } } const tokenId = selectedTokenId ?? firstTokenId; if (cell === null || cell === undefined && tokenId !== null && tokenId !== undefined) { const nowUnix = Math.floor(Date.now() / 1000); const [ok, reason] = tryPlaceEgg(state, { tokenId, x, y, nowUnix }); if (ok) { selectedTokenId = null; setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } setState(); return; } if (cell === null || cell === undefined) { emptyCellChoice = { x, y }; } setState(); }); div.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); div.click(); } }); gridEl.appendChild(div); } } } prestigeBtn.addEventListener("click", () => { if (!canPrestige(state)) return; doPrestige(state); refreshOffers(state, Math.floor(Date.now() / 1000)); setError(""); playSound("upgrade"); setState(); }); const fullRender = () => { clampSelection(); updateStatus(); const canUpTruck = (state.truckLevel ?? 1) < ((GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5) && state.coins >= getTruckUpgradeCost(state.truckLevel ?? 1); sellZone.classList.toggle("can-upgrade", canUpTruck); const truckArrow = sellZone.querySelector(".sell-zone-upgrade-arrow"); if (truckArrow) truckArrow.style.display = canUpTruck ? "" : "none"; const plotMaxLevel = GameConfig.Plot.MaxLevel || 8; const canUpgradePlot = (state.plotLevel ?? 1) < plotMaxLevel && state.coins >= getPlotUpgradeCost(state.plotLevel ?? 1); plotUpgradeZone.classList.toggle("can-upgrade", canUpgradePlot); const plotArrow = plotUpgradeZone.querySelector(".plot-upgrade-zone-arrow"); if (plotArrow) plotArrow.style.display = canUpgradePlot ? "" : "none"; const mapCfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; const mapMaxLevel = mapCfg ? mapCfg.MaxLevel : 5; const mapLevel = state.worldMapLevel ?? 1; const mapResearchCost = getWorldMapUpgradeResearchCost(mapLevel); const canUpgradeMap = mapLevel < mapMaxLevel && (state.researchPoints ?? 0) >= mapResearchCost; worldMapUpgradeZone.classList.toggle("can-upgrade", canUpgradeMap); worldMapUpgradeZone.title = mapLevel < mapMaxLevel ? `Agrandir la carte (${mapResearchCost} unités de recherche)` : "Agrandir la carte"; const mapCostEl = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-cost"); if (mapCostEl) mapCostEl.textContent = mapLevel < mapMaxLevel ? ` ${mapResearchCost} 🔬` : ""; const mapArrow = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-arrow"); if (mapArrow) mapArrow.style.display = canUpgradeMap ? "" : "none"; const babiesForSale = (state.saleListings ?? []).filter((s) => s.isBaby).length; const animalsForSale = (state.saleListings ?? []).filter((s) => !s.isBaby).length; const labsCount = GameConfig.WorldMap && GameConfig.WorldMap.Laboratory ? 1 : 0; const zoosCount = (state.worldZoos ?? []).length; const citiesCount = (GameConfig.WorldMap && GameConfig.WorldMap.Cities) ? GameConfig.WorldMap.Cities.length : 0; worldMapCounters.textContent = ""; const counterEntries = [ ["Bébés à vendre", babiesForSale], ["Animaux à vendre", animalsForSale], ["Laboratoires", labsCount], ["Zoos", zoosCount], ["Villes", citiesCount], ]; for (const [label, value] of counterEntries) { const span = document.createElement("span"); span.className = "world-map-counter"; span.title = label; span.setAttribute("aria-label", `${label}: ${value}`); span.textContent = `${label}: ${value}`; worldMapCounters.appendChild(span); } const eggPurchase = state.eggPurchaseTruck; if (eggPurchase && eggPurchase.startAt) { const truckLevel = state.truckLevel ?? 1; const baseMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; const durationMs = Math.max(1000, (baseMs * 2) / truckLevel); if (Date.now() - eggPurchase.startAt >= durationMs) delete state.eggPurchaseTruck; } renderWorldMap(); renderGrid(); }; fullRender(); return fullRender; }