Files
builazoo/web/js/ui.js
Nicolas Cantu e031c9a1d2 Initial commit
**Motivations:**
- Initialisation du versionning git pour le projet

**Root causes:**
- N/A (Nouveau projet)

**Correctifs:**
- N/A

**Evolutions:**
- Structure initiale du projet
- Ajout du .gitignore

**Pages affectées:**
- Tous les fichiers
2026-03-03 22:24:17 +01:00

1607 lines
80 KiB
JavaScript

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<import("./types.js").GameState>) => 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 = "<span class=\"world-map-upgrade-zone-icon\" aria-hidden=\"true\">🗺️</span><span class=\"world-map-upgrade-zone-label\">Agrandir carte</span><span class=\"world-map-upgrade-zone-cost\" aria-hidden=\"true\"></span><span class=\"world-map-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
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 = "<span class=\"world-map-truck-drop-icon\" aria-hidden=\"true\">🚚</span><span class=\"world-map-truck-drop-label\">Acheter œuf</span>";
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 = "<span class=\"plot-upgrade-zone-icon\" aria-hidden=\"true\">📐</span><span class=\"plot-upgrade-zone-label\">Agrandir zoo</span><span class=\"plot-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
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 = "<span class=\"sell-zone-icon\" aria-hidden=\"true\">🚚</span><span class=\"sell-zone-label\">" + sellZoneShortLabel + "</span><span class=\"sell-zone-upgrade-arrow\" aria-hidden=\"true\">▲</span>";
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 `<div class="quest-item ${q.done ? "done" : ""}">${text} : ${q.current}/${q.target}${done}</div>`;
}).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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span>`;
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${listing.price} 💰</span>`;
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 = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${oneOffer.price} pièces</span>`;
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">Bébé ${name}</span><span class="offer-price">${playerBabyOffer.price}</span>`;
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${name}</span><span class="offer-price">${playerAnimalOffer.price}</span>`;
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 = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${labOffer.price} pièces</span>`;
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 = `<button type="button" class="cell-choice-btn" data-choice="nursery" ${canNursery ? "" : "disabled"}>🐣 Nurserie (${nurseryCost})</button><button type="button" class="cell-choice-btn" data-choice="shop" ${canShop ? "" : "disabled"}>🛒 Boutique (${shopCost})</button><button type="button" class="cell-choice-btn" data-choice="research" ${canResearch ? "" : "disabled"}>🔬 Recherche (${researchCost})</button><button type="button" class="cell-choice-btn" data-choice="billeterie" ${canBilleterie ? "" : "disabled"}>🎫 Billeterie (${billeterieCost})</button><button type="button" class="cell-choice-btn" data-choice="food" ${canFood ? "" : "disabled"}>🥗 Nourriture (${foodCost})</button><button type="button" class="cell-choice-btn" data-choice="reception" ${canReception ? "" : "disabled"}>📥 Accueil (${receptionCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeColor" ${canBiomeColor ? "" : "disabled"}>🎨 Couleur (${biomeColorCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeTemp" ${canBiomeTemp ? "" : "disabled"}>🌡️ Temp (${biomeTempCost})</button>`;
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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🏫</span><span class="cell-label">École ${cell.level}</span>${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 = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
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 = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
div.dataset.tokenId = String(cell.tokenId);
} else {
const arrow = canUpgradeNursery ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🐣</span><span class="cell-label">Nurserie ${nurseryLevel}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🛒</span><span class="cell-label">Boutique ${shopLevel}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🔬</span><span class="cell-label">Recherche ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🎫</span><span class="cell-label">Billeterie ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🥗</span><span class="cell-label">Nourriture ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>${arrow}`;
if (isReady) div.dataset.receptionCellKey = key;
} else {
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">📥</span><span class="cell-label">Accueil ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🎨</span><span class="cell-label">Couleur ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🌡️</span><span class="cell-label">Temp ${level}</span>${arrow}`;
} else if (cell.kind === "egg") {
div.classList.add("egg", "cell-draggable");
div.draggable = true;
const label = eggTypeLabel[cell.eggType] ?? cell.eggType;
div.innerHTML = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
} 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 = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
}
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;
}