import * as THREE from "three"; /** * AREngine abstracts two run modes: * - "xr": real WebXR immersive-ar session (Android Chrome etc.) * - "fallback": getUserMedia camera feed + DeviceOrientation gyro * Both expose the same scene/camera/renderer + a per-frame callback. */ export class AREngine { constructor(canvas, video) { this.canvas = canvas; this.video = video; this.mode = null; this.onFrame = null; this.scene = new THREE.Scene(); this.clock = new THREE.Clock(); this._orientationEnabled = false; this._deviceQuat = new THREE.Quaternion(); this._screenTransform = new THREE.Quaternion(); this._worldAlign = new THREE.Quaternion(); } /** Detect best available mode without starting it. */ static async detect() { if (navigator.xr && (await navigator.xr.isSessionSupported?.("immersive-ar").catch(() => false))) { return "xr"; } if (navigator.mediaDevices?.getUserMedia) return "fallback"; return "none"; } async start(mode) { this.mode = mode; this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true, preserveDrawingBuffer: false, }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setSize(window.innerWidth, window.innerHeight); if (mode === "xr") { await this._startXR(); } else { await this._startFallback(); } } /* ---------------- WebXR PATH ---------------- */ async _startXR() { this.renderer.xr.enabled = true; this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.01, 50); this.session = await navigator.xr.requestSession("immersive-ar", { requiredFeatures: ["local-floor"], optionalFeatures: ["hit-test", "dom-overlay"], domOverlay: { root: document.getElementById("hunt-screen") }, }); await this.renderer.xr.setSession(this.session); this.renderer.setAnimationLoop((t, frame) => { const dt = this.clock.getDelta(); this.camera = this.renderer.xr.getCamera(); if (this.onFrame) this.onFrame(dt, this.camera, frame); this.renderer.render(this.scene, this.camera); }); this.session.addEventListener("end", () => { if (this._onEnd) this._onEnd(); }); } /* ---------------- FALLBACK PATH ---------------- */ async _startFallback() { this.video.classList.add("active"); this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 50); this.camera.position.set(0, 0, 0); // Camera feed try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false, }); this.video.srcObject = stream; this.stream = stream; await this.video.play().catch(() => {}); } catch (e) { console.warn("Camera unavailable, running on plain background", e); } // Device orientation for look-around await this._enableOrientation(); window.addEventListener("resize", () => this._resize()); this._resize(); const loop = () => { this._raf = requestAnimationFrame(loop); const dt = this.clock.getDelta(); this._applyOrientation(); if (this.onFrame) this.onFrame(dt, this.camera, null); this.renderer.render(this.scene, this.camera); }; loop(); } async _enableOrientation() { const DOE = window.DeviceOrientationEvent; if (DOE && typeof DOE.requestPermission === "function") { try { const res = await DOE.requestPermission(); if (res !== "granted") return; } catch { return; } } this._orientationEnabled = true; window.addEventListener("deviceorientation", (e) => this._onOrientation(e), true); } _onOrientation(e) { if (e.alpha == null) return; const deg = THREE.MathUtils.degToRad; const alpha = deg(e.alpha); const beta = deg(e.beta); const gamma = deg(e.gamma); const orient = deg(window.orientation || 0); // Standard device-orientation -> quaternion (Three.js convention) const euler = new THREE.Euler(beta, alpha, -gamma, "YXZ"); this._deviceQuat.setFromEuler(euler); // Tilt the world so screen looks "forward" const q1 = new THREE.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5)); // -90deg X this._deviceQuat.multiply(q1); // Account for screen rotation this._screenTransform.setFromAxisAngle(new THREE.Vector3(0, 0, 1), -orient); this._deviceQuat.multiply(this._screenTransform); this._hasOrientation = true; } _applyOrientation() { if (this._hasOrientation) { this.camera.quaternion.copy(this._deviceQuat); } } _resize() { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); } onEnd(cb) { this._onEnd = cb; } stop() { if (this.session) this.session.end().catch(() => {}); if (this._raf) cancelAnimationFrame(this._raf); if (this.renderer) this.renderer.setAnimationLoop(null); if (this.stream) this.stream.getTracks().forEach((t) => t.stop()); this.video.classList.remove("active"); this.video.srcObject = null; } }