// tooltips.js /** * initializeTooltips(options) * * - options.app: the PlayCanvas AppBase instance * - options.cameraEntity: PlayCanvas camera Entity (utilisant le script orbitCamera) * - options.modelEntity: the main model entity (for relative positioning, optional) * - options.tooltipsUrl: URL to fetch JSON array of tooltip definitions * - options.defaultVisible: boolean: whether tooltips are visible initially * - options.moveDuration: number (seconds) for smooth camera move to selected tooltip * * JSON attendu pour chaque tooltip : * { * x, y, z, // position du tooltip (obligatoire) * title, description, imgUrl, // infos UI (optionnelles) * camX, camY, camZ // position de caméra cible (optionnelles) * } * * Comportement : * - Si camX/camY/camZ sont fournis, la caméra se déplacera exactement * vers (camX, camY, camZ) et s'orientera pour regarder le tooltip. * - Sinon, on conserve l'ancien comportement : la caméra orbite vers le tooltip * avec une distance calculée (zoom minimum + taille du tooltip). */ export async function initializeTooltips(options) { const { app, cameraEntity, modelEntity, // non utilisé directement ici mais conservé pour compat tooltipsUrl, defaultVisible, moveDuration = 0.6 } = options; if (!app || !cameraEntity || !tooltipsUrl) return; // --- Chargement du JSON de tooltips --- let tooltipsData; try { const resp = await fetch(tooltipsUrl); tooltipsData = await resp.json(); } catch (e) { // Échec du fetch/parse JSON -> on abandonne proprement return; } if (!Array.isArray(tooltipsData)) return; const tooltipEntities = []; // --- Matériau des sphères (tooltips) --- const mat = new pc.StandardMaterial(); mat.diffuse = new pc.Color(1, 0.8, 0); mat.specular = new pc.Color(1, 1, 1); mat.shininess = 20; mat.emissive = new pc.Color(0.85, 0.85, 0.85); mat.emissiveIntensity = 1; mat.useLighting = false; mat.update(); // --- Création des entités sphères pour chaque tooltip --- for (let i = 0; i < tooltipsData.length; i++) { const tt = tooltipsData[i]; const { x, y, z, title, description, imgUrl, camX, camY, camZ } = tt; const sphere = new pc.Entity("tooltip-" + i); sphere.addComponent("model", { type: "sphere" }); sphere.model.material = mat; // Taille par défaut des "pins" sphere.setLocalScale(0.05, 0.05, 0.05); sphere.setLocalPosition(x, y, z); // On stocke toutes les infos utiles sur l'entité sphere.tooltipData = { title, description, imgUrl, // Nouvelle partie : coordonnées de caméra cibles (optionnelles) camTarget: (Number.isFinite(camX) && Number.isFinite(camY) && Number.isFinite(camZ)) ? new pc.Vec3(camX, camY, camZ) : null }; app.root.addChild(sphere); tooltipEntities.push(sphere); } // --- Gestion de la visibilité des tooltips --- function setTooltipsVisibility(visible) { tooltipEntities.forEach(ent => { ent.enabled = visible; }); } setTooltipsVisibility(!!defaultVisible); // Écouteur externe (ex. UI HTML) pour afficher/masquer les tooltips document.addEventListener("toggle-tooltips", (evt) => { const { visible } = evt.detail; setTooltipsVisibility(!!visible); }); // --- Picking (détection de clic sur un tooltip) --- let currentTween = null; app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => { // Si une interpolation est en cours, on l'arrête proprement if (currentTween) { app.off("update", currentTween); currentTween = null; } const x = event.x, y = event.y; const from = new pc.Vec3(), to = new pc.Vec3(); const camera = cameraEntity.camera; // Ray picking écran -> monde camera.screenToWorld(x, y, camera.nearClip, from); camera.screenToWorld(x, y, camera.farClip, to); const dir = new pc.Vec3().sub2(to, from).normalize(); let closestT = Infinity; let pickedEntity = null; // Test d'intersection rayon/sphère (simple et suffisant ici) for (const ent of tooltipEntities) { if (!ent.enabled) continue; const center = ent.getPosition(); const worldRadius = 0.5 * ent.getLocalScale().x; const oc = new pc.Vec3().sub2(center, from); const tca = oc.dot(dir); if (tca < 0) continue; const d2 = oc.lengthSq() - (tca * tca); if (d2 > worldRadius * worldRadius) continue; const thc = Math.sqrt(worldRadius * worldRadius - d2); const t0 = tca - thc; if (t0 < closestT && t0 >= 0) { closestT = t0; pickedEntity = ent; } } if (pickedEntity) { // Notifier l'UI (titre, description, image) const { title, description, imgUrl } = pickedEntity.tooltipData; document.dispatchEvent(new CustomEvent("tooltip-selected", { detail: { title, description, imgUrl } })); // Si on a une position caméra cible, on l'utilise const desiredCamPos = pickedEntity.tooltipData.camTarget; tweenCameraToTooltip(pickedEntity, moveDuration, desiredCamPos); } }); // --- Helpers math/angles --- function shortestAngleDiff(target, current) { // Retourne l'écart angulaire [-180, 180] pour interpoler par le plus court chemin let delta = target - current; delta = ((delta + 180) % 360 + 360) % 360 - 180; return delta; } /** * Calcule {yaw, pitch, distance} pour une caméra à cameraPos regardant pivotPos, * selon la convention de l'orbitCamera. */ function computeOrbitFromPositions(cameraPos, pivotPos) { const tempEnt = new pc.Entity(); tempEnt.setPosition(cameraPos); tempEnt.lookAt(pivotPos); const rotation = tempEnt.getRotation(); // Direction "forward" (de la caméra vers le pivot) const forward = new pc.Vec3(); rotation.transformVector(pc.Vec3.FORWARD, forward); // Yaw : rotation horizontale const rawYaw = Math.atan2(-forward.x, -forward.z) * pc.math.RAD_TO_DEG; // Pitch : on retire d'abord l'influence du yaw pour isoler la composante verticale const yawQuat = new pc.Quat().setFromEulerAngles(0, -rawYaw, 0); const rotNoYaw = new pc.Quat().mul2(yawQuat, rotation); const fNoYaw = new pc.Vec3(); rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw); const rawPitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG; // Distance : norme du vecteur pivot - caméra const toPivot = new pc.Vec3().sub2(pivotPos, cameraPos); const dist = toPivot.length(); tempEnt.destroy(); return { yaw: rawYaw, pitch: rawPitch, distance: dist }; } /** * Animation caméra vers un tooltip. * - tooltipEnt: entité du tooltip cliqué * - duration: durée de l'interpolation (s) * - overrideCamWorldPos (pc.Vec3|null): si fourni, la caméra ira EXACTEMENT à cette position * tout en regardant le tooltip. */ function tweenCameraToTooltip(tooltipEnt, duration, overrideCamWorldPos = null) { const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera; if (!orbitCam) return; const targetPos = tooltipEnt.getPosition().clone(); // État initial (depuis l'orbitCamera) const startPivot = orbitCam.pivotPoint.clone(); const startYaw = orbitCam._yaw; const startPitch = orbitCam._pitch; const startDist = orbitCam._distance; // Valeurs finales à déterminer let endPivot = targetPos.clone(); let endYaw, endPitch, endDist; if (overrideCamWorldPos) { // --- Nouveau mode : position caméra imposée par le JSON --- // On calcule l'orbite (yaw/pitch/dist) qui correspond exactement à cette position const { yaw, pitch, distance } = computeOrbitFromPositions(overrideCamWorldPos, targetPos); // Interpolation par le plus court chemin depuis l'état courant endYaw = startYaw + shortestAngleDiff(yaw, startYaw); endPitch = startPitch + shortestAngleDiff(pitch, startPitch); endDist = distance; } else { // --- Comportement historique (aucune camX/Y/Z fournie) --- const worldRadius = 0.5 * tooltipEnt.getLocalScale().x; const minZoom = orbitCam.distanceMin || 0.1; const desiredDistance = Math.max(minZoom * 1.2, worldRadius * 4); // On garde la position caméra actuelle comme point de départ pour calculer les angles const camWorldPos = cameraEntity.getPosition().clone(); const { yaw, pitch } = computeOrbitFromPositions(camWorldPos, targetPos); endYaw = startYaw + shortestAngleDiff(yaw, startYaw); endPitch = startPitch + shortestAngleDiff(pitch, startPitch); endDist = desiredDistance; } // Sauvegarde des origines pour l'interpolation const orgPivot = startPivot.clone(); const orgYaw = startYaw; const orgPitch = startPitch; const orgDist = startDist; let elapsed = 0; // Si une interpolation était déjà en cours, on la débranche if (currentTween) { app.off("update", currentTween); currentTween = null; } // --- Lerp frame-by-frame --- function lerpUpdate(dt) { elapsed += dt; const t = Math.min(elapsed / duration, 1); // Pivot (regard) vers le tooltip const newPivot = new pc.Vec3().lerp(orgPivot, endPivot, t); orbitCam.pivotPoint.copy(newPivot); // Yaw, Pitch, Distance (on met aussi les "target" pour rester cohérent avec le script orbitCamera) const newYaw = pc.math.lerp(orgYaw, endYaw, t); const newPitch = pc.math.lerp(orgPitch, endPitch, t); const newDist = pc.math.lerp(orgDist, endDist, t); orbitCam._targetYaw = newYaw; orbitCam._yaw = newYaw; orbitCam._targetPitch = newPitch; orbitCam._pitch = newPitch; orbitCam._targetDistance = newDist; orbitCam._distance = newDist; // Mise à jour de la position monde de la caméra à partir des paramètres d'orbite orbitCam._updatePosition(); if (t >= 1) { app.off("update", lerpUpdate); currentTween = null; } } currentTween = lerpUpdate; app.on("update", lerpUpdate); } }