// tooltips.js /** * initializeTooltips(options) * * - options.app: the PlayCanvas App instance * - options.cameraEntity: the PlayCanvas camera Entity * - options.modelEntity: the main model entity (for any 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 */ export async function initializeTooltips(options) { const { app, cameraEntity, modelEntity, tooltipsUrl, defaultVisible, moveDuration = 0.6 } = options; if (!app || !cameraEntity || !tooltipsUrl) { console.error("tooltips.js → missing required initialization options"); return; } // Load JSON of tooltips: let tooltipsData; try { const resp = await fetch(tooltipsUrl); tooltipsData = await resp.json(); } catch (e) { console.error("tooltips.js → failed fetching tooltips.json:", e); return; } if (!Array.isArray(tooltipsData)) { console.error("tooltips.js → tooltips.json must be an array"); return; } const tooltipEntities = []; // Create a material for tooltip spheres 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.update(); // Build each tooltip sphere + attach custom data for (let i = 0; i < tooltipsData.length; i++) { const tt = tooltipsData[i]; const { x, y, z, title, description, imgUrl } = tt; const sphere = new pc.Entity("tooltip-" + i); sphere.addComponent("model", { type: "sphere" }); sphere.model.material = mat; // Scale small (primitive sphere radius = 0.5) sphere.setLocalScale(0.05, 0.05, 0.05); sphere.setLocalPosition(x, y, z); sphere.tooltipData = { title, description, imgUrl }; app.root.addChild(sphere); tooltipEntities.push(sphere); } // Show/hide all tooltip spheres function setTooltipsVisibility(visible) { tooltipEntities.forEach(ent => { ent.enabled = visible; }); } setTooltipsVisibility(!!defaultVisible); // Respond to toggle-tooltips event from interface.js document.addEventListener("toggle-tooltips", (evt) => { const { visible } = evt.detail; setTooltipsVisibility(!!visible); }); // Keep track of any in-flight camera tween so we can cancel it let currentTween = null; // On mouse down (or touch equivalent), perform manual ray‐sphere intersection app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => { // If a tween is running, cancel it immediately if (currentTween) { app.off("update", currentTween); currentTween = null; } const x = event.x; const y = event.y; const from = new pc.Vec3(); const to = new pc.Vec3(); const camera = cameraEntity.camera; 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; 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) { const { title, description, imgUrl } = pickedEntity.tooltipData; document.dispatchEvent(new CustomEvent("tooltip-selected", { detail: { title, description, imgUrl } })); tweenCameraToTooltip(pickedEntity, moveDuration); } }); // Also close tooltip panel if user interacts (mouse or touch) on the canvas const canvasId = app.graphicsDevice.canvas.id; const htmlCanvas = document.getElementById(canvasId); if (htmlCanvas) { htmlCanvas.addEventListener("mousedown", () => { document.dispatchEvent(new CustomEvent("hide-tooltip")); }); htmlCanvas.addEventListener("touchstart", () => { document.dispatchEvent(new CustomEvent("hide-tooltip")); }); } // Helper to normalize angle difference into [-180, +180] function shortestAngleDiff(target, current) { let delta = target - current; delta = ((delta + 180) % 360 + 360) % 360 - 180; return delta; } // Tween helper: smoothly move and reorient camera to focus the chosen tooltip sphere function tweenCameraToTooltip(tooltipEnt, duration) { const orbitCam = cameraEntity.script.orbitCamera; if (!orbitCam) return; // Compute target pivot exactly at the sphere center const targetPos = tooltipEnt.getPosition().clone(); // Compute current state const startPivot = orbitCam.pivotPoint.clone(); const startYaw = orbitCam._yaw; const startPitch = orbitCam._pitch; const startDist = orbitCam._distance; // Compute direction & candidate distance: const worldRadius = 0.5 * tooltipEnt.getLocalScale().x; const minZoom = orbitCam.distanceMin; const desiredDistance = Math.max(minZoom * 1.2, worldRadius * 4); // Compute raw target yaw/pitch from camera pointing at targetPos const camWorldPos = cameraEntity.getPosition().clone(); const tempEnt = new pc.Entity(); tempEnt.setPosition(camWorldPos); tempEnt.lookAt(targetPos); const rotation = tempEnt.getRotation(); const forward = new pc.Vec3(); rotation.transformVector(pc.Vec3.FORWARD, forward); const rawTgtYaw = Math.atan2(-forward.x, -forward.z) * pc.math.RAD_TO_DEG; const yawDelta = shortestAngleDiff(rawTgtYaw, startYaw); const endYaw = startYaw + yawDelta; // Pitch calculation and normalization const yawQuat = new pc.Quat().setFromEulerAngles(0, -rawTgtYaw, 0); const rotNoYaw = new pc.Quat().mul2(yawQuat, rotation); const fNoYaw = new pc.Vec3(); rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw); const rawTgtPitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG; const pitchDelta = shortestAngleDiff(rawTgtPitch, startPitch); const endPitch = startPitch + pitchDelta; tempEnt.destroy(); // Target state: const endPivot = targetPos.clone(); const endDist = desiredDistance; let elapsed = 0; const orgPivot = startPivot.clone(); const orgYaw = startYaw; const orgPitch = startPitch; const orgDist = startDist; // If another tween is running, cancel it if (currentTween) { app.off("update", currentTween); currentTween = null; } // Per-frame update function lerpUpdate(dt) { elapsed += dt; const t = Math.min(elapsed / duration, 1); // Interpolate pivot (vector lerp) const newPivot = new pc.Vec3().lerp(orgPivot, endPivot, t); orbitCam.pivotPoint.copy(newPivot); // Interpolate yaw/pitch/distance (simple lerp) 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; orbitCam._updatePosition(); if (t >= 1) { app.off("update", lerpUpdate); currentTween = null; } } currentTween = lerpUpdate; app.on("update", lerpUpdate); } }