quackscape/assets/js/scene.js

369 lines
No EOL
11 KiB
JavaScript

import { getScene } from "./api";
import { loadSidebar } from "./scene/sidebar";
import { loadNavbar } from "./scene/navbar";
import aframe from "aframe";
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import "@phosphor-icons/web/light";
import "../scss/frontend.scss";
import "../css/scene.css";
// Scene loading state tracking
const loadingStates = new Map();
AFRAME.registerComponent('billboard', {
tick: function () {
const camera = document.querySelector('a-camera');
if (!camera) return;
// Make this entity face the camera
this.el.object3D.lookAt(camera.object3D.position);
}
});
// Define the <quackscape-scene> element
class QuackscapeScene extends HTMLElement {
connectedCallback() {
this.scene = this.getAttribute("scene");
this.embedded = this.hasAttribute("embedded");
this.x = parseInt(this.getAttribute("x")) || 0;
this.y = parseInt(this.getAttribute("y")) || 0;
this.z = parseInt(this.getAttribute("z")) || 0;
this.navbar = this.hasAttribute("navbar");
this.sidebar = this.hasAttribute("sidebar");
// Show loading indicator
this.showLoadingIndicator();
// Load the scene
loadScene(this.scene, this.x, this.y, this.z, this, this.embedded)
.then(() => {
// Scene loaded successfully
this.hideLoadingIndicator();
// Dispatch a loaded event
const loadedEvent = new CustomEvent("sceneLoaded", {
detail: { sceneId: this.scene }
});
this.dispatchEvent(loadedEvent);
})
.catch(error => {
console.error("Error loading scene:", error);
this.showErrorIndicator(error);
});
// Load UI components if needed
if (this.navbar) {
loadNavbar(this.scene, this);
} else if (this.sidebar) {
loadSidebar(this.scene, this);
}
}
showLoadingIndicator() {
// Create and show loading indicator
const loadingEl = document.createElement("div");
loadingEl.className = "scene-loading";
loadingEl.innerHTML = `
<div class="loading-animation">
<div class="loading-spinner"></div>
</div>
<div class="loading-text">Loading scene...</div>
`;
this.appendChild(loadingEl);
this.loadingIndicator = loadingEl;
}
hideLoadingIndicator() {
if (this.loadingIndicator) {
this.loadingIndicator.classList.add("loaded");
// Remove after animation completes
setTimeout(() => {
if (this.loadingIndicator && this.loadingIndicator.parentNode) {
this.loadingIndicator.parentNode.removeChild(this.loadingIndicator);
}
}, 500);
}
}
showErrorIndicator(error) {
if (this.loadingIndicator) {
this.loadingIndicator.innerHTML = `
<div class="error-icon">
<i class="ph-light ph-warning"></i>
</div>
<div class="error-text">Failed to load scene</div>
<div class="error-details">${error.message || "Unknown error"}</div>
<button class="btn btn-primary retry-btn">Retry</button>
`;
// Add retry functionality
const retryBtn = this.loadingIndicator.querySelector(".retry-btn");
if (retryBtn) {
retryBtn.addEventListener("click", () => {
this.hideLoadingIndicator();
this.showLoadingIndicator();
loadScene(this.scene, this.x, this.y, this.z, this, this.embedded)
.then(() => this.hideLoadingIndicator())
.catch(err => this.showErrorIndicator(err));
});
}
}
}
}
// Register the custom element
customElements.define("quackscape-scene", QuackscapeScene);
// Prevent context menu in scene for better mobile experience
document.addEventListener("contextmenu", function (event) {
const isScene = event.target.closest("a-scene");
if (isScene) {
event.preventDefault();
}
});
// Function to load a scene into a destination object
async function loadScene(
scene_id,
x = 0,
y = 0,
z = 0,
destination = null,
embedded = false
) {
// Check if we're already loading this scene
if (loadingStates.has(scene_id) && loadingStates.get(scene_id).loading) {
return loadingStates.get(scene_id).promise;
}
// Create a promise to track loading state
let resolvePromise, rejectPromise;
const promise = new Promise((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
// Set loading state
loadingStates.set(scene_id, {
loading: true,
promise
});
try {
// Get WebGL maximum texture size
const gl = getWebGLContext();
if (!gl) {
throw new Error("WebGL not supported by your browser");
}
const maxTextureSize = getMaxTextureSize(gl);
// Get scene information from API
const response = await getScene(scene_id);
const scene = response.obj;
// Find appropriate resolution
const content = findBestResolution(scene.base_content.resolutions, maxTextureSize);
if (!content) {
throw new Error("No suitable resolution found for this device");
}
// Select a destination element if not specified
if (!destination) {
destination = document.querySelector("quackscape-scene") || document.body;
}
destination.setAttribute("id", scene_id);
// Remove any existing scenes
const existingScene = destination.querySelector("a-scene");
if (existingScene) {
existingScene.remove();
}
// Create the A-Frame scene
const a_scene = createAFrameScene(embedded);
// Create camera rig with proper rotation
const rig = createCameraRig(x, y, z);
a_scene.appendChild(rig);
// Create assets container
const assets = document.createElement("a-assets");
// Add background image
const background = document.createElement("img");
background.setAttribute("id", content.id);
background.setAttribute("src", content.file);
background.setAttribute("crossorigin", "anonymous");
// Add loading event handlers
background.addEventListener("load", () => {
console.log("Background image loaded successfully");
});
background.addEventListener("error", (e) => {
console.error("Error loading background image:", e);
rejectPromise(new Error("Failed to load background image"));
});
assets.appendChild(background);
a_scene.appendChild(assets);
// Add sky with background image
const sky = document.createElement("a-sky");
sky.setAttribute("src", `#${content.id}`);
sky.setAttribute("rotation", "0 0 0");
sky.setAttribute("data-clickable", "");
a_scene.appendChild(sky);
// Add elements to scene
scene.elements.forEach((element) => {
const node = document.createElement(element.data.tag);
node.setAttribute("id", element.data.id);
for (const [key, value] of Object.entries(element.data.attributes)) {
node.setAttribute(key, value);
}
// Add children elements if they exist
if (element.data.children && Array.isArray(element.data.children)) {
element.data.children.forEach(child => {
const childNode = document.createElement(child.tag);
// Set attributes on child
for (const [key, value] of Object.entries(child.attributes || {})) {
childNode.setAttribute(key, value);
}
node.appendChild(childNode);
});
}
a_scene.appendChild(node);
});
// Add scene to destination
destination.appendChild(a_scene);
// Wait for scene to load
a_scene.addEventListener("loaded", function () {
console.log("A-Frame scene loaded");
// Update scene title in overlay if we have stored title
if (window.selectedSceneTitle) {
const titleOverlay = document.getElementById("sceneTitleOverlay");
if (titleOverlay) {
const titleElement = titleOverlay.querySelector("h1");
const categoryElement = titleOverlay.querySelector("p");
if (titleElement) {
titleElement.textContent = window.selectedSceneTitle;
}
if (categoryElement && window.selectedSceneCategory) {
categoryElement.textContent = window.selectedSceneCategory;
}
// Show title overlay
titleOverlay.classList.add("visible");
// Hide after delay
setTimeout(() => {
titleOverlay.classList.remove("visible");
}, 3000);
// Clear stored title after using it
window.selectedSceneTitle = null;
window.selectedSceneCategory = null;
}
}
// Dispatch a signal for the editor to pick up
const loaded_event = new CustomEvent("loadedQuackscapeScene", {
detail: { sceneId: scene_id }
});
document.dispatchEvent(loaded_event);
// Update loading state
loadingStates.set(scene_id, { loading: false });
resolvePromise();
});
return promise;
} catch (error) {
console.error("Error in loadScene:", error);
loadingStates.set(scene_id, { loading: false });
rejectPromise(error);
throw error;
}
}
// Helper functions
function getWebGLContext() {
const canvas = document.createElement("canvas");
return canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
}
function getMaxTextureSize(gl) {
let maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// iOS devices have issues with very large textures
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (iOS) {
maxTextureSize = Math.min(8192, maxTextureSize);
}
return maxTextureSize;
}
function findBestResolution(resolutions, maxTextureSize) {
// Sort resolutions by width (largest first)
const sortedResolutions = [...resolutions].sort((a, b) => b.width - a.width);
// Find the largest resolution that fits within maxTextureSize
return sortedResolutions.find(resolution => resolution.width <= maxTextureSize) ||
sortedResolutions[sortedResolutions.length - 1]; // Fallback to smallest if none fit
}
function createAFrameScene(embedded) {
const a_scene = document.createElement("a-scene");
a_scene.setAttribute("cursor", "rayOrigin: mouse");
a_scene.setAttribute("raycaster", "objects: [data-clickable]; far: 100");
if (embedded) {
a_scene.setAttribute("embedded", "true");
a_scene.setAttribute("vr-mode-ui", "enabled: false");
}
// Add loading screen
a_scene.setAttribute("loading-screen", "dotsColor: #4F46E5; backgroundColor: #FFFFFF");
return a_scene;
}
function createCameraRig(x, y, z) {
const rig = document.createElement("a-entity");
rig.setAttribute("id", "rig");
// Set rotation if specified
if (x !== -1 && y !== -1 && z !== -1) {
rig.setAttribute("rotation", `${x} ${y} ${z}`);
}
const camera = document.createElement("a-camera");
camera.setAttribute("wasd-controls-enabled", "false");
camera.setAttribute("look-controls", "reverseMouseDrag: false");
rig.appendChild(camera);
return rig;
}
// Make functions available globally
window.loadScene = loadScene;
window.aframe = aframe;
export { loadScene };