| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>3D Game Viewer</title> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| overflow: hidden; |
| background: #0a0a0a; |
| } |
| |
| #viewer-container { |
| width: 100vw; |
| height: 100vh; |
| position: relative; |
| } |
| |
| |
| #crosshair { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 6px; |
| height: 6px; |
| background: #000000; |
| border-radius: 50%; |
| pointer-events: none; |
| display: none; |
| z-index: 100; |
| box-shadow: 0 0 2px rgba(255, 255, 255, 0.5); |
| } |
| </style> |
| </head> |
| <body> |
| <div id="viewer-container"> |
| <div id="crosshair"></div> |
| </div> |
|
|
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/", |
| "cannon-es": "https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js" |
| } |
| } |
| </script> |
|
|
| |
| <script src="https://cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js"></script> |
|
|
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; |
| import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; |
| import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'; |
| import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; |
| import { Sky } from 'three/addons/objects/Sky.js'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
| import * as CANNON from 'cannon-es'; |
| |
| |
| let sky = null; |
| let sun = new THREE.Vector3(); |
| |
| |
| let particleSystems = new Map(); |
| let animatedModels = []; |
| |
| |
| let uiContainer = null; |
| let uiElements = new Map(); |
| |
| |
| const gltfLoader = new GLTFLoader(); |
| |
| |
| const sceneId = window.location.pathname.split('/').pop(); |
| const baseUrl = window.location.origin; |
| |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| const initialMode = urlParams.get('mode') || 'fps'; |
| |
| |
| let scene, camera, renderer; |
| let orbitControls; |
| let controlMode = initialMode; |
| let sceneData = null; |
| |
| |
| let composer, outlinePass; |
| |
| |
| let gridHelper = null; |
| let stats = null; |
| let wireframeEnabled = false; |
| |
| |
| let raycaster = new THREE.Raycaster(); |
| let mouse = new THREE.Vector2(); |
| let selectedObject = null; |
| let selectedObjectId = null; |
| const MAX_SELECT_DISTANCE = 10; |
| |
| |
| let moveSpeed = 8.0; |
| const velocity = new THREE.Vector3(); |
| let isMouseLocked = false; |
| let cameraRotationX = 0; |
| let cameraRotationY = 0; |
| let mouseSensitivity = 0.002; |
| let invertY = false; |
| let movementAcceleration = 0.0; |
| let airControl = 1.0; |
| let cameraFOV = 75.0; |
| let minPitch = -89.0; |
| let maxPitch = 89.0; |
| |
| |
| let physicsWorld; |
| let playerBody; |
| let groundBody; |
| let wallBodies = []; |
| let objectBodies = new Map(); |
| let PLAYER_HEIGHT = 1.7; |
| let PLAYER_RADIUS = 0.3; |
| let EYE_HEIGHT = 1.6; |
| let JUMP_FORCE = 5.0; |
| let GRAVITY = -9.82; |
| let PLAYER_MASS = 80.0; |
| let LINEAR_DAMPING = 0.0; |
| |
| let WORLD_SIZE = 25; |
| let WORLD_HALF = WORLD_SIZE / 2; |
| let isGrounded = false; |
| let canJump = true; |
| |
| |
| const direction = new THREE.Vector3(); |
| const moveForward = { value: false }; |
| const moveBackward = { value: false }; |
| const moveLeft = { value: false }; |
| const moveRight = { value: false }; |
| const moveUp = { value: false }; |
| const moveDown = { value: false }; |
| let prevTime = performance.now(); |
| |
| function applyPlayerConfig() { |
| |
| |
| |
| |
| if (!sceneData || !sceneData.player_config) { |
| return; |
| } |
| |
| const config = sceneData.player_config; |
| |
| |
| if (config.move_speed !== undefined) moveSpeed = config.move_speed; |
| if (config.jump_force !== undefined) JUMP_FORCE = config.jump_force; |
| if (config.mouse_sensitivity !== undefined) mouseSensitivity = config.mouse_sensitivity; |
| if (config.invert_y !== undefined) invertY = config.invert_y; |
| if (config.gravity !== undefined) GRAVITY = config.gravity; |
| if (config.player_height !== undefined) PLAYER_HEIGHT = config.player_height; |
| if (config.player_radius !== undefined) PLAYER_RADIUS = config.player_radius; |
| if (config.eye_height !== undefined) EYE_HEIGHT = config.eye_height; |
| if (config.player_mass !== undefined) PLAYER_MASS = config.player_mass; |
| if (config.linear_damping !== undefined) LINEAR_DAMPING = config.linear_damping; |
| if (config.movement_acceleration !== undefined) movementAcceleration = config.movement_acceleration; |
| if (config.air_control !== undefined) airControl = config.air_control; |
| if (config.camera_fov !== undefined) cameraFOV = config.camera_fov; |
| if (config.min_pitch !== undefined) minPitch = config.min_pitch; |
| if (config.max_pitch !== undefined) maxPitch = config.max_pitch; |
| } |
| |
| function applyInitialEnvironment() { |
| |
| |
| |
| |
| if (!sceneData) return; |
| |
| |
| if (sceneData.skybox) { |
| handleAddSkybox(sceneData.skybox); |
| } |
| |
| |
| if (sceneData.particles && Array.isArray(sceneData.particles)) { |
| sceneData.particles.forEach(particleConfig => { |
| handleAddParticles(particleConfig); |
| }); |
| } |
| |
| |
| if (sceneData.ui_elements && Array.isArray(sceneData.ui_elements)) { |
| sceneData.ui_elements.forEach(uiConfig => { |
| if (uiConfig.type === 'text') { |
| handleRenderText(uiConfig); |
| } else if (uiConfig.type === 'bar') { |
| handleRenderBar(uiConfig); |
| } |
| }); |
| } |
| } |
| |
| async function init() { |
| try { |
| |
| if (window.SCENE_DATA) { |
| sceneData = window.SCENE_DATA; |
| } else { |
| |
| const response = await fetch(`${baseUrl}/api/scenes/${sceneId}`); |
| |
| if (!response.ok) { |
| const errorText = await response.text(); |
| console.error('Failed to fetch scene:', errorText); |
| throw new Error(`Scene not found (${response.status}): ${errorText}`); |
| } |
| sceneData = await response.json(); |
| } |
| |
| |
| if (sceneData.world_width) { |
| WORLD_SIZE = sceneData.world_width; |
| WORLD_HALF = WORLD_SIZE / 2; |
| } |
| |
| |
| applyPlayerConfig(); |
| |
| |
| setupScene(); |
| |
| |
| setupPhysics(); |
| |
| |
| renderGameObjects(); |
| |
| |
| applyInitialEnvironment(); |
| |
| |
| animate(); |
| |
| } catch (error) { |
| console.error('Error initializing viewer:', error); |
| } |
| } |
| |
| function setupScene() { |
| |
| scene = new THREE.Scene(); |
| const bgColor = sceneData.environment?.background_color || '#87CEEB'; |
| scene.background = new THREE.Color(bgColor); |
| |
| |
| camera = new THREE.PerspectiveCamera( |
| cameraFOV, |
| window.innerWidth / window.innerHeight, |
| 0.1, |
| 1000 |
| ); |
| |
| |
| camera.position.set(0, EYE_HEIGHT, 0); |
| |
| |
| renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| |
| renderer.physicallyCorrectLights = true; |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.0; |
| document.getElementById('viewer-container').appendChild(renderer.domElement); |
| |
| |
| composer = new EffectComposer(renderer); |
| |
| const renderPass = new RenderPass(scene, camera); |
| composer.addPass(renderPass); |
| |
| outlinePass = new OutlinePass(new THREE.Vector2(window.innerWidth, window.innerHeight), scene, camera); |
| outlinePass.edgeStrength = 5.0; |
| outlinePass.edgeGlow = 1.0; |
| outlinePass.edgeThickness = 3.0; |
| outlinePass.pulsePeriod = 0; |
| outlinePass.visibleEdgeColor.set('#ff8800'); |
| outlinePass.hiddenEdgeColor.set('#ff4400'); |
| composer.addPass(outlinePass); |
| |
| const outputPass = new OutputPass(); |
| composer.addPass(outputPass); |
| |
| |
| |
| sceneData.lights.forEach(lightData => { |
| let light; |
| |
| if (lightData.type === 'ambient') { |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); |
| } else if (lightData.type === 'directional') { |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| |
| light.target.position.set(0, 0, 0); |
| scene.add(light.target); |
| if (lightData.cast_shadow) { |
| light.castShadow = true; |
| |
| light.shadow.mapSize.width = 2048; |
| light.shadow.mapSize.height = 2048; |
| light.shadow.camera.near = 0.5; |
| light.shadow.camera.far = 100; |
| light.shadow.camera.left = -30; |
| light.shadow.camera.right = 30; |
| light.shadow.camera.top = 30; |
| light.shadow.camera.bottom = -30; |
| light.shadow.bias = -0.0001; |
| } |
| } else if (lightData.type === 'point') { |
| |
| const distance = lightData.distance || 50; |
| const decay = lightData.decay || 2; |
| light = new THREE.PointLight(lightData.color, lightData.intensity, distance, decay); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| } else if (lightData.type === 'hemisphere') { |
| |
| const skyColor = lightData.color || '#87CEEB'; |
| const groundColor = lightData.ground_color || '#444444'; |
| light = new THREE.HemisphereLight(skyColor, groundColor, lightData.intensity); |
| if (lightData.position) { |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| } |
| } |
| |
| if (light) { |
| light.name = lightData.name || lightData.type; |
| scene.add(light); |
| } |
| }); |
| |
| |
| const gridSize = sceneData.grid_size || 100; |
| const divisions = sceneData.grid_divisions || 20; |
| gridHelper = new THREE.GridHelper(gridSize, divisions, 0x444444, 0x222222); |
| gridHelper.visible = sceneData.show_grid || false; |
| scene.add(gridHelper); |
| |
| |
| if (typeof Stats !== 'undefined') { |
| stats = new Stats(); |
| stats.dom.style.position = 'absolute'; |
| stats.dom.style.top = '0px'; |
| stats.dom.style.left = '0px'; |
| stats.dom.style.display = 'none'; |
| document.getElementById('viewer-container').appendChild(stats.dom); |
| } |
| |
| |
| orbitControls = new OrbitControls(camera, renderer.domElement); |
| orbitControls.enableDamping = true; |
| orbitControls.dampingFactor = 0.05; |
| |
| |
| setupFPSControls(); |
| |
| |
| renderer.domElement.addEventListener('click', (event) => { |
| |
| if (controlMode === 'orbit') { |
| onObjectClick(event); |
| } |
| }); |
| |
| |
| const onKeyDown = (event) => { |
| switch (event.code) { |
| case 'KeyW': moveForward.value = true; break; |
| case 'KeyA': moveLeft.value = true; break; |
| case 'KeyS': moveBackward.value = true; break; |
| case 'KeyD': moveRight.value = true; break; |
| case 'Space': |
| event.preventDefault(); |
| if (canJump && isGrounded && playerBody) { |
| playerBody.velocity.y = JUMP_FORCE; |
| canJump = false; |
| isGrounded = false; |
| } |
| break; |
| case 'KeyC': toggleControlMode(); break; |
| } |
| }; |
| |
| const onKeyUp = (event) => { |
| switch (event.code) { |
| case 'KeyW': moveForward.value = false; break; |
| case 'KeyA': moveLeft.value = false; break; |
| case 'KeyS': moveBackward.value = false; break; |
| case 'KeyD': moveRight.value = false; break; |
| } |
| }; |
| |
| document.addEventListener('keydown', onKeyDown); |
| document.addEventListener('keyup', onKeyUp); |
| |
| |
| setControlMode(controlMode); |
| |
| |
| window.addEventListener('resize', onWindowResize); |
| } |
| |
| function setupFPSControls() { |
| |
| renderer.domElement.addEventListener('mousedown', (event) => { |
| if (controlMode === 'fps' && event.button === 0) { |
| isMouseLocked = true; |
| |
| try { |
| renderer.domElement.requestPointerLock(); |
| } catch (e) { |
| isMouseLocked = false; |
| } |
| } |
| }); |
| |
| renderer.domElement.addEventListener('mouseup', () => { |
| |
| }); |
| |
| |
| document.addEventListener('mousemove', (event) => { |
| if (!isMouseLocked || controlMode !== 'fps') return; |
| |
| const movementX = event.movementX || 0; |
| const movementY = event.movementY || 0; |
| |
| |
| cameraRotationY -= movementX * mouseSensitivity; |
| const pitchMultiplier = invertY ? 1 : -1; |
| cameraRotationX += movementY * mouseSensitivity * pitchMultiplier; |
| |
| |
| const minPitchRad = THREE.MathUtils.degToRad(minPitch); |
| const maxPitchRad = THREE.MathUtils.degToRad(maxPitch); |
| cameraRotationX = Math.max(minPitchRad, Math.min(maxPitchRad, cameraRotationX)); |
| }); |
| |
| |
| document.addEventListener('pointerlockchange', () => { |
| isMouseLocked = document.pointerLockElement === renderer.domElement; |
| }); |
| |
| document.addEventListener('pointerlockerror', () => { |
| |
| isMouseLocked = false; |
| }); |
| |
| } |
| |
| function setupPhysics() { |
| |
| |
| physicsWorld = new CANNON.World(); |
| physicsWorld.gravity.set(0, GRAVITY, 0); |
| |
| |
| const defaultMaterial = new CANNON.Material('default'); |
| const defaultContactMaterial = new CANNON.ContactMaterial( |
| defaultMaterial, |
| defaultMaterial, |
| { |
| friction: 0.0, |
| restitution: 0.0, |
| } |
| ); |
| physicsWorld.addContactMaterial(defaultContactMaterial); |
| physicsWorld.defaultContactMaterial = defaultContactMaterial; |
| |
| |
| function createBlueprintTexture(size = 512) { |
| const canvas = document.createElement('canvas'); |
| canvas.width = size; |
| canvas.height = size; |
| const ctx = canvas.getContext('2d'); |
| |
| |
| ctx.fillStyle = '#1a5a9e'; |
| ctx.fillRect(0, 0, size, size); |
| |
| |
| const majorSpacing = size / 8; |
| ctx.strokeStyle = '#ffffff'; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| for (let i = 0; i <= 8; i++) { |
| const pos = i * majorSpacing; |
| ctx.moveTo(pos, 0); |
| ctx.lineTo(pos, size); |
| ctx.moveTo(0, pos); |
| ctx.lineTo(size, pos); |
| } |
| ctx.stroke(); |
| |
| |
| const minorSpacing = majorSpacing / 4; |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| for (let i = 0; i <= 32; i++) { |
| const pos = i * minorSpacing; |
| ctx.moveTo(pos, 0); |
| ctx.lineTo(pos, size); |
| ctx.moveTo(0, pos); |
| ctx.lineTo(size, pos); |
| } |
| ctx.stroke(); |
| |
| return canvas; |
| } |
| |
| |
| const groundGeometry = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE); |
| const blueprintCanvas = createBlueprintTexture(); |
| const blueprintTexture = new THREE.CanvasTexture(blueprintCanvas); |
| blueprintTexture.wrapS = THREE.RepeatWrapping; |
| blueprintTexture.wrapT = THREE.RepeatWrapping; |
| blueprintTexture.repeat.set(WORLD_SIZE / 5, WORLD_SIZE / 5); |
| |
| const groundMaterial = new THREE.MeshBasicMaterial({ |
| map: blueprintTexture |
| }); |
| const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial); |
| groundMesh.rotation.x = -Math.PI / 2; |
| groundMesh.position.y = 0; |
| groundMesh.receiveShadow = true; |
| groundMesh.userData = { isGround: true }; |
| scene.add(groundMesh); |
| |
| |
| const groundShape = new CANNON.Plane(); |
| groundBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); |
| groundBody.addShape(groundShape); |
| groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); |
| physicsWorld.addBody(groundBody); |
| |
| |
| const wallHeight = 5; |
| const wallThickness = 0.5; |
| |
| |
| const wallTexture = new THREE.CanvasTexture(createBlueprintTexture()); |
| wallTexture.wrapS = THREE.RepeatWrapping; |
| wallTexture.wrapT = THREE.RepeatWrapping; |
| |
| const wallMaterial = new THREE.MeshBasicMaterial({ |
| map: wallTexture |
| }); |
| |
| |
| const nsWallGeometry = new THREE.BoxGeometry(WORLD_SIZE, wallHeight, wallThickness); |
| |
| const nsWallMaterial = wallMaterial.clone(); |
| nsWallMaterial.map = wallTexture.clone(); |
| nsWallMaterial.map.repeat.set(WORLD_SIZE / 5, wallHeight / 5); |
| |
| |
| const northWallMesh = new THREE.Mesh(nsWallGeometry, nsWallMaterial); |
| northWallMesh.position.set(0, wallHeight / 2, WORLD_HALF); |
| northWallMesh.receiveShadow = true; |
| northWallMesh.castShadow = false; |
| scene.add(northWallMesh); |
| |
| const northWallShape = new CANNON.Box(new CANNON.Vec3(WORLD_SIZE / 2, wallHeight / 2, wallThickness / 2)); |
| const northWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); |
| northWallBody.addShape(northWallShape); |
| northWallBody.position.copy(northWallMesh.position); |
| physicsWorld.addBody(northWallBody); |
| wallBodies.push(northWallBody); |
| |
| |
| const southWallMesh = new THREE.Mesh(nsWallGeometry, nsWallMaterial); |
| southWallMesh.position.set(0, wallHeight / 2, -WORLD_HALF); |
| southWallMesh.receiveShadow = true; |
| southWallMesh.castShadow = false; |
| scene.add(southWallMesh); |
| |
| const southWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); |
| southWallBody.addShape(northWallShape); |
| southWallBody.position.copy(southWallMesh.position); |
| physicsWorld.addBody(southWallBody); |
| wallBodies.push(southWallBody); |
| |
| |
| const ewWallGeometry = new THREE.BoxGeometry(wallThickness, wallHeight, WORLD_SIZE); |
| const ewWallMaterial = wallMaterial.clone(); |
| ewWallMaterial.map = wallTexture.clone(); |
| ewWallMaterial.map.repeat.set(WORLD_SIZE / 5, wallHeight / 5); |
| |
| |
| const eastWallMesh = new THREE.Mesh(ewWallGeometry, ewWallMaterial); |
| eastWallMesh.position.set(WORLD_HALF, wallHeight / 2, 0); |
| eastWallMesh.receiveShadow = true; |
| eastWallMesh.castShadow = false; |
| scene.add(eastWallMesh); |
| |
| const eastWallShape = new CANNON.Box(new CANNON.Vec3(wallThickness / 2, wallHeight / 2, WORLD_SIZE / 2)); |
| const eastWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); |
| eastWallBody.addShape(eastWallShape); |
| eastWallBody.position.copy(eastWallMesh.position); |
| physicsWorld.addBody(eastWallBody); |
| wallBodies.push(eastWallBody); |
| |
| |
| const westWallMesh = new THREE.Mesh(ewWallGeometry, ewWallMaterial); |
| westWallMesh.position.set(-WORLD_HALF, wallHeight / 2, 0); |
| westWallMesh.receiveShadow = true; |
| westWallMesh.castShadow = false; |
| scene.add(westWallMesh); |
| |
| const westWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial }); |
| westWallBody.addShape(eastWallShape); |
| westWallBody.position.copy(westWallMesh.position); |
| physicsWorld.addBody(westWallBody); |
| wallBodies.push(westWallBody); |
| |
| |
| const ceilingGeometry = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE); |
| const ceilingMaterial = new THREE.MeshBasicMaterial({ |
| map: blueprintTexture.clone() |
| }); |
| ceilingMaterial.map.repeat.set(WORLD_SIZE / 5, WORLD_SIZE / 5); |
| const ceilingMesh = new THREE.Mesh(ceilingGeometry, ceilingMaterial); |
| ceilingMesh.rotation.x = Math.PI / 2; |
| ceilingMesh.position.y = wallHeight; |
| ceilingMesh.receiveShadow = false; |
| ceilingMesh.castShadow = false; |
| scene.add(ceilingMesh); |
| |
| |
| const playerShape = new CANNON.Cylinder(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT, 8); |
| playerBody = new CANNON.Body({ |
| mass: PLAYER_MASS, |
| material: defaultMaterial, |
| fixedRotation: true, |
| linearDamping: LINEAR_DAMPING, |
| }); |
| playerBody.addShape(playerShape); |
| |
| |
| playerBody.position.set(0, 1 + PLAYER_HEIGHT / 2, 8); |
| physicsWorld.addBody(playerBody); |
| |
| |
| playerBody.addEventListener('collide', (e) => { |
| |
| if (e.body === groundBody) { |
| isGrounded = true; |
| canJump = true; |
| } |
| }); |
| |
| } |
| |
| function toggleControlMode() { |
| const newMode = controlMode === 'orbit' ? 'fps' : 'orbit'; |
| setControlMode(newMode); |
| } |
| |
| function setControlMode(mode) { |
| controlMode = mode; |
| const crosshair = document.getElementById('crosshair'); |
| |
| if (mode === 'fps') { |
| |
| orbitControls.enabled = false; |
| |
| if (document.pointerLockElement) { |
| document.exitPointerLock(); |
| } |
| isMouseLocked = false; |
| |
| |
| if (crosshair) crosshair.style.display = 'block'; |
| } else { |
| |
| if (document.pointerLockElement) { |
| document.exitPointerLock(); |
| } |
| isMouseLocked = false; |
| orbitControls.enabled = true; |
| |
| |
| if (crosshair) crosshair.style.display = 'none'; |
| |
| |
| if (outlinePass) outlinePass.selectedObjects = []; |
| selectedObject = null; |
| selectedObjectId = null; |
| } |
| } |
| |
| function createPhysicsShape(objType, scale) { |
| |
| switch (objType) { |
| case 'cube': |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2)); |
| case 'sphere': |
| return new CANNON.Sphere(scale.x); |
| case 'cylinder': |
| return new CANNON.Cylinder(scale.x, scale.x, scale.y, 8); |
| case 'plane': |
| |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, 0.01, scale.y / 2)); |
| case 'cone': |
| |
| return new CANNON.Cylinder(0, scale.x, scale.y, 8); |
| case 'torus': |
| |
| return new CANNON.Sphere(scale.x); |
| default: |
| |
| return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2)); |
| } |
| } |
| |
| function renderGameObjects() { |
| |
| sceneData.objects.forEach(obj => { |
| |
| if (Math.abs(obj.position.x) > WORLD_HALF || Math.abs(obj.position.z) > WORLD_HALF) { |
| console.warn(`Object ${obj.name} at (${obj.position.x}, ${obj.position.z}) is outside 10x10 world bounds - skipping`); |
| return; |
| } |
| let geometry, mesh; |
| |
| |
| switch (obj.type) { |
| case 'cube': |
| geometry = new THREE.BoxGeometry( |
| obj.scale.x, |
| obj.scale.y, |
| obj.scale.z |
| ); |
| break; |
| case 'sphere': |
| geometry = new THREE.SphereGeometry(obj.scale.x, 32, 32); |
| break; |
| case 'cylinder': |
| geometry = new THREE.CylinderGeometry( |
| obj.scale.x, |
| obj.scale.x, |
| obj.scale.y, |
| 32 |
| ); |
| break; |
| case 'plane': |
| geometry = new THREE.PlaneGeometry(obj.scale.x, obj.scale.y); |
| break; |
| case 'cone': |
| geometry = new THREE.ConeGeometry(obj.scale.x, obj.scale.y, 32); |
| break; |
| case 'torus': |
| geometry = new THREE.TorusGeometry(obj.scale.x, obj.scale.x * 0.4, 16, 100); |
| break; |
| case 'model': |
| |
| if (obj.model_path) { |
| const loader = new GLTFLoader(); |
| |
| const staticBase = sceneData.static_base_url || ''; |
| const modelUrl = staticBase + obj.model_path; |
| loader.load( |
| modelUrl, |
| (gltf) => { |
| const model = gltf.scene; |
| model.position.set(obj.position.x, obj.position.y, obj.position.z); |
| model.scale.set(obj.scale.x, obj.scale.y, obj.scale.z); |
| model.rotation.set( |
| THREE.MathUtils.degToRad(obj.rotation.x), |
| THREE.MathUtils.degToRad(obj.rotation.y), |
| THREE.MathUtils.degToRad(obj.rotation.z) |
| ); |
| |
| |
| |
| if (obj.metadata?.unlit) { |
| model.traverse((child) => { |
| if (child.isMesh && child.material) { |
| |
| const oldMaterial = child.material; |
| const newMaterial = new THREE.MeshBasicMaterial(); |
| |
| |
| if (oldMaterial.map) { |
| newMaterial.map = oldMaterial.map; |
| } |
| |
| if (oldMaterial.color) { |
| newMaterial.color = oldMaterial.color.clone(); |
| } |
| |
| if (oldMaterial.transparent) { |
| newMaterial.transparent = true; |
| newMaterial.opacity = oldMaterial.opacity; |
| } |
| if (oldMaterial.alphaMap) { |
| newMaterial.alphaMap = oldMaterial.alphaMap; |
| } |
| |
| child.material = newMaterial; |
| } |
| }); |
| } |
| |
| model.userData = { |
| id: obj.id, |
| name: obj.name, |
| type: 'model', |
| isSceneObject: true, |
| animate: obj.metadata?.animate || false, |
| baseY: obj.metadata?.baseY || obj.position.y, |
| unlit: obj.metadata?.unlit || false, |
| }; |
| scene.add(model); |
| |
| |
| if (obj.metadata?.animate) { |
| animatedModels.push(model); |
| } |
| }, |
| undefined, |
| (error) => console.error(`Failed to load model ${modelUrl}:`, error) |
| ); |
| } |
| return; |
| default: |
| console.warn('Unknown object type:', obj.type); |
| return; |
| } |
| |
| |
| const material = new THREE.MeshStandardMaterial({ |
| color: obj.material.color, |
| metalness: obj.material.metalness || 0.5, |
| roughness: obj.material.roughness || 0.5, |
| opacity: obj.material.opacity || 1.0, |
| transparent: obj.material.opacity < 1.0, |
| wireframe: obj.material.wireframe || false, |
| }); |
| |
| |
| mesh = new THREE.Mesh(geometry, material); |
| |
| |
| mesh.position.set(obj.position.x, obj.position.y, obj.position.z); |
| |
| |
| mesh.rotation.set( |
| THREE.MathUtils.degToRad(obj.rotation.x), |
| THREE.MathUtils.degToRad(obj.rotation.y), |
| THREE.MathUtils.degToRad(obj.rotation.z) |
| ); |
| |
| |
| mesh.userData = { |
| id: obj.id, |
| name: obj.name, |
| type: obj.type, |
| isSceneObject: true, |
| }; |
| |
| scene.add(mesh); |
| |
| |
| const physicsShape = createPhysicsShape(obj.type, obj.scale); |
| const physicsBody = new CANNON.Body({ |
| mass: 0, |
| position: new CANNON.Vec3(obj.position.x, obj.position.y, obj.position.z), |
| }); |
| physicsBody.addShape(physicsShape); |
| |
| |
| const quaternion = new CANNON.Quaternion(); |
| quaternion.setFromEuler( |
| THREE.MathUtils.degToRad(obj.rotation.x), |
| THREE.MathUtils.degToRad(obj.rotation.y), |
| THREE.MathUtils.degToRad(obj.rotation.z), |
| 'XYZ' |
| ); |
| physicsBody.quaternion.copy(quaternion); |
| |
| physicsWorld.addBody(physicsBody); |
| objectBodies.set(obj.id, physicsBody); |
| |
| }); |
| } |
| |
| function onWindowResize() { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| |
| |
| if (composer) { |
| composer.setSize(window.innerWidth, window.innerHeight); |
| } |
| } |
| |
| function updateLookedAtObject() { |
| if (controlMode !== 'fps') { |
| if (selectedObject) { |
| outlinePass.selectedObjects = []; |
| selectedObject = null; |
| selectedObjectId = null; |
| } |
| return; |
| } |
| |
| |
| const raycaster = new THREE.Raycaster(); |
| raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); |
| |
| const selectableObjects = scene.children.filter(obj => obj.userData.isSceneObject && obj.userData.id); |
| const intersects = raycaster.intersectObjects(selectableObjects); |
| |
| if (intersects.length > 0 && intersects[0].distance < MAX_SELECT_DISTANCE) { |
| const newSelected = intersects[0].object; |
| if (newSelected !== selectedObject) { |
| selectedObject = newSelected; |
| selectedObjectId = newSelected.userData.id; |
| outlinePass.selectedObjects = [selectedObject]; |
| |
| |
| if (window.parent) { |
| window.parent.postMessage({ |
| action: 'objectSelected', |
| data: { |
| object_id: selectedObjectId, |
| object_type: newSelected.userData.type, |
| distance: intersects[0].distance.toFixed(2) |
| } |
| }, '*'); |
| } |
| } |
| } else { |
| |
| if (selectedObject) { |
| outlinePass.selectedObjects = []; |
| selectedObject = null; |
| selectedObjectId = null; |
| |
| |
| if (window.parent) { |
| window.parent.postMessage({ |
| action: 'objectDeselected', |
| data: {} |
| }, '*'); |
| } |
| } |
| } |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| |
| if (stats) stats.begin(); |
| |
| const time = performance.now(); |
| const delta = (time - prevTime) / 1000; |
| |
| |
| if (physicsWorld) { |
| physicsWorld.step(1/60, delta, 3); |
| } |
| |
| if (controlMode === 'fps' && playerBody) { |
| |
| camera.rotation.order = 'YXZ'; |
| camera.rotation.y = cameraRotationY; |
| camera.rotation.x = cameraRotationX; |
| camera.rotation.z = 0; |
| |
| |
| direction.z = Number(moveForward.value) - Number(moveBackward.value); |
| direction.x = Number(moveRight.value) - Number(moveLeft.value); |
| direction.y = 0; |
| direction.normalize(); |
| |
| |
| const forward = new THREE.Vector3(0, 0, -1); |
| const right = new THREE.Vector3(1, 0, 0); |
| |
| |
| forward.applyQuaternion(camera.quaternion); |
| right.applyQuaternion(camera.quaternion); |
| |
| |
| forward.y = 0; |
| right.y = 0; |
| forward.normalize(); |
| right.normalize(); |
| |
| |
| |
| const controlFactor = isGrounded ? 1.0 : airControl; |
| const moveX = (direction.x * right.x + direction.z * forward.x) * moveSpeed * controlFactor; |
| const moveZ = (direction.x * right.z + direction.z * forward.z) * moveSpeed * controlFactor; |
| |
| playerBody.velocity.x = moveX; |
| playerBody.velocity.z = moveZ; |
| |
| |
| |
| const groundRaycaster = new THREE.Raycaster( |
| new THREE.Vector3(playerBody.position.x, playerBody.position.y, playerBody.position.z), |
| new THREE.Vector3(0, -1, 0), |
| 0, |
| PLAYER_HEIGHT / 2 + 0.1 |
| ); |
| const groundIntersects = groundRaycaster.intersectObjects( |
| scene.children.filter(obj => obj.userData.isGround || obj.userData.isWall || obj.userData.isSceneObject) |
| ); |
| if (groundIntersects.length > 0) { |
| isGrounded = true; |
| canJump = true; |
| } else { |
| isGrounded = false; |
| } |
| |
| |
| camera.position.x = playerBody.position.x; |
| camera.position.y = playerBody.position.y - PLAYER_HEIGHT / 2 + EYE_HEIGHT; |
| camera.position.z = playerBody.position.z; |
| |
| prevTime = time; |
| } else if (controlMode === 'orbit') { |
| |
| orbitControls.update(); |
| } |
| |
| |
| updateLookedAtObject(); |
| |
| |
| updateCrosshairPosition(); |
| |
| |
| updateParticleSystems(delta); |
| |
| |
| updateAnimatedModels(time); |
| |
| |
| if (composer) { |
| composer.render(); |
| } else { |
| renderer.render(scene, camera); |
| } |
| |
| |
| if (stats) stats.end(); |
| } |
| |
| |
| |
| let lastCrosshairPosition = null; |
| let crosshairUpdateThrottle = 0; |
| |
| |
| |
| |
| |
| function updateCrosshairPosition() { |
| |
| crosshairUpdateThrottle++; |
| if (crosshairUpdateThrottle < 10) return; |
| crosshairUpdateThrottle = 0; |
| |
| |
| if (controlMode !== 'fps') { |
| if (lastCrosshairPosition !== null) { |
| lastCrosshairPosition = null; |
| window.parent.postMessage({ |
| action: 'crosshairPosition', |
| data: null |
| }, '*'); |
| } |
| return; |
| } |
| |
| |
| const crosshairRaycaster = new THREE.Raycaster(); |
| crosshairRaycaster.setFromCamera(new THREE.Vector2(0, 0), camera); |
| |
| |
| |
| |
| const origin = crosshairRaycaster.ray.origin; |
| const direction = crosshairRaycaster.ray.direction; |
| |
| |
| if (Math.abs(direction.y) < 0.0001) { |
| |
| const distance = 10; |
| const newPosition = { |
| x: Math.round((origin.x + direction.x * distance) * 100) / 100, |
| y: 0, |
| z: Math.round((origin.z + direction.z * distance) * 100) / 100 |
| }; |
| sendCrosshairUpdate(newPosition); |
| return; |
| } |
| |
| const t = -origin.y / direction.y; |
| |
| |
| |
| const intersectX = origin.x + direction.x * Math.abs(t); |
| const intersectZ = origin.z + direction.z * Math.abs(t); |
| |
| |
| const clampedX = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, intersectX)); |
| const clampedZ = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, intersectZ)); |
| |
| const newPosition = { |
| x: Math.round(clampedX * 100) / 100, |
| y: 0, |
| z: Math.round(clampedZ * 100) / 100 |
| }; |
| |
| sendCrosshairUpdate(newPosition); |
| } |
| |
| function sendCrosshairUpdate(newPosition) { |
| |
| if (!lastCrosshairPosition || |
| Math.abs(newPosition.x - lastCrosshairPosition.x) > 0.1 || |
| Math.abs(newPosition.z - lastCrosshairPosition.z) > 0.1) { |
| |
| lastCrosshairPosition = newPosition; |
| window.parent.postMessage({ |
| action: 'crosshairPosition', |
| data: newPosition |
| }, '*'); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| function toggleGrid(enabled) { |
| if (gridHelper) gridHelper.visible = enabled; |
| } |
| |
| function toggleWireframe(enabled) { |
| wireframeEnabled = enabled; |
| scene.traverse((object) => { |
| if (object.isMesh && object.material) { |
| object.material.wireframe = enabled; |
| } |
| }); |
| } |
| |
| function toggleStats(enabled) { |
| if (stats) stats.dom.style.display = enabled ? 'block' : 'none'; |
| } |
| |
| |
| |
| |
| function captureScreenshot() { |
| if (!renderer) { |
| console.error('Renderer not initialized'); |
| return; |
| } |
| |
| |
| renderer.render(scene, camera); |
| |
| |
| const dataURL = renderer.domElement.toDataURL('image/png'); |
| |
| |
| window.parent.postMessage({ |
| action: 'screenshot', |
| data: { |
| dataURL: dataURL, |
| timestamp: Date.now(), |
| sceneName: sceneData?.name || 'scene' |
| } |
| }, '*'); |
| } |
| |
| |
| |
| |
| function onObjectClick(event) { |
| |
| const rect = renderer.domElement.getBoundingClientRect(); |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; |
| |
| |
| raycaster.setFromCamera(mouse, camera); |
| |
| |
| const intersects = raycaster.intersectObjects(scene.children.filter(obj => obj.isMesh)); |
| |
| if (intersects.length > 0) { |
| const clickedObject = intersects[0].object; |
| |
| |
| if (selectedObject && selectedObject !== clickedObject) { |
| if (selectedObject.userData.originalColor) { |
| selectedObject.material.emissive.copy(selectedObject.userData.originalColor); |
| selectedObject.material.emissiveIntensity = 0; |
| } |
| } |
| |
| |
| selectedObject = clickedObject; |
| |
| |
| if (!selectedObject.userData.originalColor) { |
| selectedObject.userData.originalColor = selectedObject.material.emissive.clone(); |
| } |
| selectedObject.material.emissive = new THREE.Color(0x4444ff); |
| selectedObject.material.emissiveIntensity = 0.3; |
| |
| |
| const objectInfo = { |
| id: clickedObject.userData.id, |
| name: clickedObject.userData.name || 'Unnamed Object', |
| type: clickedObject.userData.type || 'unknown', |
| position: { |
| x: clickedObject.position.x.toFixed(2), |
| y: clickedObject.position.y.toFixed(2), |
| z: clickedObject.position.z.toFixed(2) |
| }, |
| rotation: { |
| x: THREE.MathUtils.radToDeg(clickedObject.rotation.x).toFixed(2), |
| y: THREE.MathUtils.radToDeg(clickedObject.rotation.y).toFixed(2), |
| z: THREE.MathUtils.radToDeg(clickedObject.rotation.z).toFixed(2) |
| }, |
| scale: { |
| x: clickedObject.scale.x.toFixed(2), |
| y: clickedObject.scale.y.toFixed(2), |
| z: clickedObject.scale.z.toFixed(2) |
| }, |
| color: '#' + clickedObject.material.color.getHexString() |
| }; |
| |
| |
| window.parent.postMessage({ |
| action: 'objectInspect', |
| data: objectInfo |
| }, '*'); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| window.addEventListener('message', (event) => { |
| |
| |
| |
| const { action, data } = event.data; |
| |
| switch (action) { |
| case 'addObject': |
| handleAddObject(data); |
| break; |
| case 'removeObject': |
| handleRemoveObject(data); |
| break; |
| case 'setLighting': |
| handleSetLighting(data); |
| break; |
| case 'updateScene': |
| handleUpdateScene(data); |
| break; |
| case 'setControlMode': |
| setControlMode(data.mode); |
| break; |
| case 'toggleGrid': |
| toggleGrid(data.enabled); |
| break; |
| case 'toggleWireframe': |
| toggleWireframe(data.enabled); |
| break; |
| case 'toggleStats': |
| toggleStats(data.enabled); |
| break; |
| case 'takeScreenshot': |
| captureScreenshot(); |
| break; |
| case 'addLight': |
| addLightToScene(data); |
| break; |
| case 'removeLight': |
| removeLightFromScene(data.light_name); |
| break; |
| case 'updateLight': |
| updateSceneLight(data.light_name, data); |
| break; |
| case 'updateMaterial': |
| updateObjectMaterial(data.object_id, data); |
| break; |
| case 'setBackground': |
| setSceneBackground(data); |
| break; |
| case 'setFog': |
| setSceneFog(data); |
| break; |
| |
| case 'setPlayerSpeed': |
| moveSpeed = data.walk_speed || 5.0; |
| break; |
| case 'setJumpForce': |
| JUMP_FORCE = data.jump_force || 5.0; |
| break; |
| case 'setGravity': |
| if (physicsWorld) physicsWorld.gravity.set(0, data.gravity || -9.82, 0); |
| break; |
| case 'setCameraFov': |
| if (camera) { |
| camera.fov = data.fov || 75; |
| camera.updateProjectionMatrix(); |
| } |
| break; |
| case 'setMouseSensitivity': |
| mouseSensitivity = data.sensitivity || 0.002; |
| if (data.invert_y !== undefined) invertY = data.invert_y; |
| break; |
| case 'setPlayerDimensions': |
| if (data.height) PLAYER_HEIGHT = data.height; |
| if (data.radius) PLAYER_RADIUS = data.radius; |
| break; |
| |
| case 'addSkybox': |
| handleAddSkybox(data); |
| break; |
| case 'removeSkybox': |
| handleRemoveSkybox(); |
| break; |
| |
| case 'addParticles': |
| handleAddParticles(data); |
| break; |
| case 'removeParticles': |
| handleRemoveParticles(data.particle_id); |
| break; |
| |
| case 'renderText': |
| handleRenderText(data); |
| break; |
| case 'renderBar': |
| handleRenderBar(data); |
| break; |
| case 'removeUIElement': |
| handleRemoveUIElement(data.element_id); |
| break; |
| |
| case 'updateToonMaterial': |
| handleUpdateToonMaterial(data); |
| break; |
| |
| case 'addBrick': |
| handleAddBrick(data); |
| break; |
| default: |
| console.warn('Unknown postMessage action:', action); |
| } |
| }); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function getForwardSpawnPosition(scale = {x: 1, y: 1, z: 1}, snapToGround = true) { |
| const dir = new THREE.Vector3(); |
| camera.getWorldDirection(dir); |
| |
| |
| const objectSize = Math.max(scale.x || 1, scale.y || 1, scale.z || 1); |
| |
| |
| |
| const baseDistance = 2.0; |
| const sizeMultiplier = 1.2; |
| const distance = baseDistance + (objectSize * sizeMultiplier); |
| |
| |
| const spawnPos = new THREE.Vector3() |
| .copy(camera.position) |
| .add(dir.clone().multiplyScalar(distance)); |
| |
| |
| if (snapToGround) { |
| |
| const downRay = new THREE.Raycaster( |
| new THREE.Vector3(spawnPos.x, spawnPos.y + 20, spawnPos.z), |
| new THREE.Vector3(0, -1, 0) |
| ); |
| |
| |
| const groundObjects = scene.children.filter(obj => |
| obj.userData.isGround || obj.name === 'ground' || obj.name === 'floor' |
| ); |
| |
| if (groundObjects.length > 0) { |
| const hits = downRay.intersectObjects(groundObjects); |
| if (hits.length > 0) { |
| spawnPos.y = hits[0].point.y; |
| } else { |
| spawnPos.y = 0; |
| } |
| } else { |
| spawnPos.y = 0; |
| } |
| } |
| |
| return spawnPos; |
| } |
| |
| |
| |
| |
| function handleAddObject(objData) { |
| if (!scene || !sceneData) { |
| console.error('Scene not initialized yet'); |
| return; |
| } |
| |
| |
| |
| const isDefaultPosition = |
| objData.position.x === 0 && |
| objData.position.y === 0 && |
| objData.position.z === 0; |
| |
| if (objData.use_camera_position || isDefaultPosition) { |
| const scale = objData.scale || {x: 1, y: 1, z: 1}; |
| const spawnPos = getForwardSpawnPosition(scale, true); |
| |
| |
| const halfHeight = (scale.y || 1) / 2; |
| spawnPos.y += halfHeight; |
| |
| objData.position = { |
| x: spawnPos.x, |
| y: spawnPos.y, |
| z: spawnPos.z |
| }; |
| } |
| |
| |
| if (Math.abs(objData.position.x) > WORLD_HALF || Math.abs(objData.position.z) > WORLD_HALF) { |
| console.error(`Cannot add object at (${objData.position.x}, ${objData.position.z}) - outside world bounds`); |
| return; |
| } |
| |
| |
| sceneData.objects.push(objData); |
| |
| |
| let geometry; |
| switch (objData.type) { |
| case 'cube': |
| geometry = new THREE.BoxGeometry(objData.scale.x, objData.scale.y, objData.scale.z); |
| break; |
| case 'sphere': |
| geometry = new THREE.SphereGeometry(objData.scale.x, 32, 32); |
| break; |
| case 'cylinder': |
| geometry = new THREE.CylinderGeometry(objData.scale.x, objData.scale.x, objData.scale.y, 32); |
| break; |
| case 'plane': |
| geometry = new THREE.PlaneGeometry(objData.scale.x, objData.scale.y); |
| break; |
| case 'cone': |
| geometry = new THREE.ConeGeometry(objData.scale.x, objData.scale.y, 32); |
| break; |
| case 'torus': |
| geometry = new THREE.TorusGeometry(objData.scale.x, objData.scale.x * 0.4, 16, 100); |
| break; |
| default: |
| console.warn('Unknown object type:', objData.type); |
| return; |
| } |
| |
| const material = new THREE.MeshStandardMaterial({ |
| color: objData.material.color, |
| metalness: objData.material.metalness || 0.5, |
| roughness: objData.material.roughness || 0.5, |
| opacity: objData.material.opacity || 1.0, |
| transparent: objData.material.opacity < 1.0, |
| wireframe: wireframeEnabled || objData.material.wireframe || false, |
| }); |
| |
| const mesh = new THREE.Mesh(geometry, material); |
| mesh.position.set(objData.position.x, objData.position.y, objData.position.z); |
| mesh.rotation.set( |
| THREE.MathUtils.degToRad(objData.rotation.x), |
| THREE.MathUtils.degToRad(objData.rotation.y), |
| THREE.MathUtils.degToRad(objData.rotation.z) |
| ); |
| mesh.userData = { |
| id: objData.id, |
| name: objData.name, |
| type: objData.type, |
| isSceneObject: true, |
| }; |
| |
| scene.add(mesh); |
| |
| |
| const physicsShape = createPhysicsShape(objData.type, objData.scale); |
| const physicsBody = new CANNON.Body({ |
| mass: 0, |
| position: new CANNON.Vec3(objData.position.x, objData.position.y, objData.position.z), |
| }); |
| physicsBody.addShape(physicsShape); |
| |
| |
| const quaternion = new CANNON.Quaternion(); |
| quaternion.setFromEuler( |
| THREE.MathUtils.degToRad(objData.rotation.x), |
| THREE.MathUtils.degToRad(objData.rotation.y), |
| THREE.MathUtils.degToRad(objData.rotation.z), |
| 'XYZ' |
| ); |
| physicsBody.quaternion.copy(quaternion); |
| |
| physicsWorld.addBody(physicsBody); |
| objectBodies.set(objData.id, physicsBody); |
| |
| |
| animateObjectHighlight(mesh); |
| |
| } |
| |
| |
| |
| |
| function handleRemoveObject(data) { |
| if (!scene || !sceneData) { |
| console.error('Scene not initialized yet'); |
| return; |
| } |
| |
| const { object_id } = data; |
| |
| |
| const objectToRemove = scene.children.find(obj => obj.userData && obj.userData.id === object_id); |
| if (objectToRemove) { |
| scene.remove(objectToRemove); |
| if (objectToRemove.geometry) objectToRemove.geometry.dispose(); |
| if (objectToRemove.material) objectToRemove.material.dispose(); |
| } |
| |
| |
| const physicsBody = objectBodies.get(object_id); |
| if (physicsBody) { |
| physicsWorld.removeBody(physicsBody); |
| objectBodies.delete(object_id); |
| } |
| |
| |
| sceneData.objects = sceneData.objects.filter(obj => obj.id !== object_id); |
| |
| } |
| |
| |
| |
| |
| function handleSetLighting(data) { |
| if (!scene || !sceneData) { |
| console.error('Scene not initialized yet'); |
| return; |
| } |
| |
| const { lights } = data; |
| |
| |
| const lightsToRemove = scene.children.filter(obj => obj.isLight); |
| lightsToRemove.forEach(light => scene.remove(light)); |
| |
| |
| lights.forEach(lightData => { |
| let light; |
| |
| if (lightData.type === 'ambient') { |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); |
| } else if (lightData.type === 'directional') { |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| light.target.position.set(0, 0, 0); |
| scene.add(light.target); |
| if (lightData.cast_shadow) { |
| light.castShadow = true; |
| } |
| } else if (lightData.type === 'point') { |
| light = new THREE.PointLight(lightData.color, lightData.intensity); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| } |
| |
| if (light) { |
| scene.add(light); |
| } |
| }); |
| |
| |
| sceneData.lights = lights; |
| |
| } |
| |
| |
| |
| |
| function handleUpdateScene(data) { |
| |
| |
| location.reload(); |
| } |
| |
| |
| |
| |
| function animateObjectHighlight(mesh) { |
| const originalColor = mesh.material.color.clone(); |
| const originalScale = mesh.scale.clone(); |
| |
| |
| const pulseColors = [ |
| new THREE.Color(0xffff00), |
| new THREE.Color(0x00ffff), |
| new THREE.Color(0xffff00), |
| ]; |
| |
| let progress = 0; |
| const duration = 90; |
| const pulseIntensity = 0.6; |
| |
| function animateHighlight() { |
| if (progress < duration) { |
| |
| const t = progress / duration; |
| |
| |
| const colorPhase = t * (pulseColors.length - 1); |
| const colorIndex = Math.floor(colorPhase); |
| const colorBlend = colorPhase - colorIndex; |
| |
| if (colorIndex < pulseColors.length - 1) { |
| const color1 = pulseColors[colorIndex]; |
| const color2 = pulseColors[colorIndex + 1]; |
| const blendedColor = color1.clone().lerp(color2, colorBlend); |
| |
| |
| const pulseMix = Math.sin(t * Math.PI) * pulseIntensity; |
| mesh.material.color.lerpColors(originalColor, blendedColor, pulseMix); |
| } |
| |
| |
| const scaleAmount = 1.0 + Math.sin(t * Math.PI) * 0.15; |
| mesh.scale.copy(originalScale).multiplyScalar(scaleAmount); |
| |
| |
| if (mesh.material.emissive) { |
| const emissiveIntensity = Math.sin(t * Math.PI * 2) * 0.3; |
| mesh.material.emissiveIntensity = emissiveIntensity; |
| } |
| |
| progress++; |
| requestAnimationFrame(animateHighlight); |
| } else { |
| |
| mesh.material.color.copy(originalColor); |
| mesh.scale.copy(originalScale); |
| if (mesh.material.emissive) { |
| mesh.material.emissiveIntensity = 0; |
| } |
| } |
| } |
| |
| |
| if (!mesh.material.emissive) { |
| mesh.material.emissive = new THREE.Color(originalColor); |
| mesh.material.emissiveIntensity = 0; |
| } |
| |
| animateHighlight(); |
| } |
| |
| |
| |
| function addLightToScene(lightData) { |
| let light; |
| |
| if (lightData.light_type === 'ambient') { |
| light = new THREE.AmbientLight(lightData.color, lightData.intensity); |
| } else if (lightData.light_type === 'directional') { |
| light = new THREE.DirectionalLight(lightData.color, lightData.intensity); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| if (lightData.target) { |
| light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z); |
| scene.add(light.target); |
| } |
| if (lightData.cast_shadow) { |
| light.castShadow = true; |
| |
| light.shadow.mapSize.width = 2048; |
| light.shadow.mapSize.height = 2048; |
| light.shadow.camera.near = 0.5; |
| light.shadow.camera.far = 100; |
| light.shadow.camera.left = -30; |
| light.shadow.camera.right = 30; |
| light.shadow.camera.top = 30; |
| light.shadow.camera.bottom = -30; |
| light.shadow.bias = -0.0001; |
| } |
| } else if (lightData.light_type === 'point') { |
| |
| const distance = lightData.distance || 50; |
| const decay = lightData.decay || 2; |
| light = new THREE.PointLight(lightData.color, lightData.intensity, distance, decay); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| } else if (lightData.light_type === 'spot') { |
| light = new THREE.SpotLight(lightData.color, lightData.intensity); |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| light.angle = THREE.MathUtils.degToRad(lightData.spot_angle || 45); |
| if (lightData.target) { |
| light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z); |
| scene.add(light.target); |
| } |
| if (lightData.cast_shadow) { |
| light.castShadow = true; |
| light.shadow.mapSize.width = 1024; |
| light.shadow.mapSize.height = 1024; |
| } |
| } else if (lightData.light_type === 'hemisphere') { |
| |
| const skyColor = lightData.color || '#87CEEB'; |
| const groundColor = lightData.ground_color || '#444444'; |
| light = new THREE.HemisphereLight(skyColor, groundColor, lightData.intensity); |
| if (lightData.position) { |
| light.position.set(lightData.position.x, lightData.position.y, lightData.position.z); |
| } |
| } |
| |
| if (light) { |
| light.name = lightData.name; |
| scene.add(light); |
| } else { |
| console.error('Failed to create light:', lightData); |
| } |
| } |
| |
| function removeLightFromScene(lightName) { |
| const light = scene.getObjectByName(lightName); |
| if (light) scene.remove(light); |
| } |
| |
| function updateSceneLight(lightName, updates) { |
| const light = scene.getObjectByName(lightName); |
| if (!light) return; |
| |
| if (updates.color) light.color.set(updates.color); |
| if (updates.intensity !== undefined) light.intensity = updates.intensity; |
| if (updates.position) { |
| light.position.set(updates.position.x, updates.position.y, updates.position.z); |
| } |
| if (updates.cast_shadow !== undefined) light.castShadow = updates.cast_shadow; |
| } |
| |
| function updateObjectMaterial(objectId, materialData) { |
| const obj = scene.children.find(child => child.userData.id === objectId); |
| if (!obj || !obj.material) return; |
| |
| if (materialData.color) obj.material.color.set(materialData.color); |
| if (materialData.metalness !== undefined) obj.material.metalness = materialData.metalness; |
| if (materialData.roughness !== undefined) obj.material.roughness = materialData.roughness; |
| if (materialData.opacity !== undefined) { |
| obj.material.opacity = materialData.opacity; |
| obj.material.transparent = materialData.opacity < 1.0; |
| } |
| if (materialData.emissive) obj.material.emissive = new THREE.Color(materialData.emissive); |
| if (materialData.emissive_intensity !== undefined) obj.material.emissiveIntensity = materialData.emissive_intensity; |
| |
| obj.material.needsUpdate = true; |
| } |
| |
| function setSceneBackground(bgData) { |
| if (bgData.background_type === 'gradient') { |
| |
| const canvas = document.createElement('canvas'); |
| canvas.width = 2; |
| canvas.height = 256; |
| const ctx = canvas.getContext('2d'); |
| const gradient = ctx.createLinearGradient(0, 0, 0, 256); |
| gradient.addColorStop(0, bgData.background_gradient_top); |
| gradient.addColorStop(1, bgData.background_gradient_bottom); |
| ctx.fillStyle = gradient; |
| ctx.fillRect(0, 0, 2, 256); |
| |
| const texture = new THREE.CanvasTexture(canvas); |
| scene.background = texture; |
| } else { |
| scene.background = new THREE.Color(bgData.background_color); |
| } |
| |
| } |
| |
| function setSceneFog(fogData) { |
| if (!fogData.enabled) { |
| scene.fog = null; |
| return; |
| } |
| |
| const color = new THREE.Color(fogData.color); |
| if (fogData.type === 'exponential') { |
| scene.fog = new THREE.FogExp2(color, fogData.density); |
| } else { |
| scene.fog = new THREE.Fog(color, fogData.near, fogData.far); |
| } |
| } |
| |
| |
| |
| function handleAddSkybox(skyboxData) { |
| |
| if (sky) { |
| scene.remove(sky); |
| } |
| |
| |
| sky = new Sky(); |
| sky.scale.setScalar(450000); |
| scene.add(sky); |
| |
| const skyUniforms = sky.material.uniforms; |
| skyUniforms['turbidity'].value = skyboxData.turbidity || 10; |
| skyUniforms['rayleigh'].value = skyboxData.rayleigh || 2; |
| skyUniforms['mieCoefficient'].value = 0.005; |
| skyUniforms['mieDirectionalG'].value = 0.8; |
| |
| |
| const phi = THREE.MathUtils.degToRad(90 - (skyboxData.sun_elevation || 45)); |
| const theta = THREE.MathUtils.degToRad(skyboxData.sun_azimuth || 180); |
| sun.setFromSphericalCoords(1, phi, theta); |
| skyUniforms['sunPosition'].value.copy(sun); |
| |
| |
| scene.background = null; |
| } |
| |
| function handleRemoveSkybox() { |
| if (sky) { |
| scene.remove(sky); |
| sky = null; |
| } |
| |
| const bgColor = sceneData?.environment?.background_color || '#87CEEB'; |
| scene.background = new THREE.Color(bgColor); |
| } |
| |
| |
| |
| function handleAddParticles(particleData) { |
| const id = particleData.id || particleData.particle_id; |
| |
| |
| if (particleSystems.has(id)) { |
| const existingSystem = particleSystems.get(id); |
| scene.remove(existingSystem.points); |
| particleSystems.delete(id); |
| } |
| |
| const config = particleData; |
| const count = config.count || 100; |
| |
| |
| const geometry = new THREE.BufferGeometry(); |
| const positions = new Float32Array(count * 3); |
| const velocities = new Float32Array(count * 3); |
| const lifetimes = new Float32Array(count); |
| |
| const spread = config.spread || 1.0; |
| |
| |
| |
| let pos = config.position || { x: 0, y: 0, z: 0 }; |
| const isDefaultPosition = pos.x === 0 && pos.z === 0 && (pos.y === 0 || pos.y === 1); |
| |
| if (config.localized !== false && isDefaultPosition) { |
| |
| const spawnPos = getForwardSpawnPosition({x: spread, y: spread, z: spread}, false); |
| pos = { x: spawnPos.x, y: spawnPos.y, z: spawnPos.z }; |
| } |
| |
| for (let i = 0; i < count; i++) { |
| const i3 = i * 3; |
| |
| if (config.localized !== false) { |
| |
| positions[i3] = pos.x + (Math.random() - 0.5) * spread; |
| positions[i3 + 1] = pos.y + Math.random() * spread; |
| positions[i3 + 2] = pos.z + (Math.random() - 0.5) * spread; |
| } else { |
| |
| positions[i3] = (Math.random() - 0.5) * WORLD_SIZE * 2; |
| positions[i3 + 1] = Math.random() * 20; |
| positions[i3 + 2] = (Math.random() - 0.5) * WORLD_SIZE * 2; |
| } |
| |
| const vel = config.velocity || { x: 0, y: 1, z: 0 }; |
| velocities[i3] = vel.x + (Math.random() - 0.5) * 0.5; |
| velocities[i3 + 1] = vel.y + (Math.random() - 0.5) * 0.5; |
| velocities[i3 + 2] = vel.z + (Math.random() - 0.5) * 0.5; |
| |
| lifetimes[i] = Math.random() * (config.lifetime || 2.0); |
| } |
| |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
| |
| |
| const startColor = new THREE.Color(config.color_start || '#ffffff'); |
| const material = new THREE.PointsMaterial({ |
| size: config.size || 0.1, |
| color: startColor, |
| transparent: true, |
| opacity: 0.8, |
| blending: THREE.AdditiveBlending, |
| depthWrite: false |
| }); |
| |
| const points = new THREE.Points(geometry, material); |
| points.name = `particles_${id}`; |
| scene.add(points); |
| |
| |
| particleSystems.set(id, { |
| points, |
| geometry, |
| velocities, |
| lifetimes, |
| config, |
| maxLifetime: config.lifetime || 2.0, |
| startColor, |
| endColor: new THREE.Color(config.color_end || config.color_start || '#ffffff') |
| }); |
| |
| } |
| |
| function handleRemoveParticles(particleId) { |
| if (particleSystems.has(particleId)) { |
| const system = particleSystems.get(particleId); |
| scene.remove(system.points); |
| system.geometry.dispose(); |
| system.points.material.dispose(); |
| particleSystems.delete(particleId); |
| } |
| } |
| |
| function updateAnimatedModels(time) { |
| |
| const timeInSeconds = time / 1000; |
| animatedModels.forEach(model => { |
| |
| const baseY = model.userData.baseY || 1.5; |
| model.position.y = baseY + Math.sin(timeInSeconds * 2) * 0.3; |
| }); |
| } |
| |
| function updateParticleSystems(delta) { |
| particleSystems.forEach((system, id) => { |
| const positions = system.geometry.attributes.position.array; |
| const count = positions.length / 3; |
| const config = system.config; |
| const pos = config.position || { x: 0, y: 0, z: 0 }; |
| const spread = config.spread || 1.0; |
| |
| for (let i = 0; i < count; i++) { |
| const i3 = i * 3; |
| |
| |
| positions[i3] += system.velocities[i3] * delta; |
| positions[i3 + 1] += system.velocities[i3 + 1] * delta; |
| positions[i3 + 2] += system.velocities[i3 + 2] * delta; |
| |
| |
| system.lifetimes[i] += delta; |
| |
| |
| if (system.lifetimes[i] >= system.maxLifetime) { |
| system.lifetimes[i] = 0; |
| |
| if (config.localized !== false) { |
| positions[i3] = pos.x + (Math.random() - 0.5) * spread; |
| positions[i3 + 1] = pos.y; |
| positions[i3 + 2] = pos.z + (Math.random() - 0.5) * spread; |
| } else { |
| |
| positions[i3] = (Math.random() - 0.5) * WORLD_SIZE * 2; |
| positions[i3 + 1] = 20; |
| positions[i3 + 2] = (Math.random() - 0.5) * WORLD_SIZE * 2; |
| } |
| } |
| } |
| |
| system.geometry.attributes.position.needsUpdate = true; |
| }); |
| } |
| |
| |
| |
| function ensureUIContainer() { |
| if (!uiContainer) { |
| uiContainer = document.createElement('div'); |
| uiContainer.id = 'ui-overlay'; |
| uiContainer.style.cssText = ` |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| pointer-events: none; |
| z-index: 50; |
| `; |
| document.getElementById('viewer-container').appendChild(uiContainer); |
| } |
| } |
| |
| function handleRenderText(textData) { |
| ensureUIContainer(); |
| |
| const id = textData.id || textData.text_id; |
| |
| |
| if (uiElements.has(id)) { |
| uiContainer.removeChild(uiElements.get(id)); |
| } |
| |
| const element = document.createElement('div'); |
| element.id = `ui-${id}`; |
| |
| let bgStyle = ''; |
| if (textData.background_color) { |
| bgStyle = `background-color: ${textData.background_color}; padding: ${textData.padding || 8}px; border-radius: 4px;`; |
| } |
| |
| element.style.cssText = ` |
| position: absolute; |
| left: ${textData.x}%; |
| top: ${textData.y}%; |
| transform: translate(-50%, 0); |
| color: ${textData.color || '#ffffff'}; |
| font-family: ${textData.font_family || 'Arial'}, sans-serif; |
| font-size: ${textData.font_size || 24}px; |
| text-align: ${textData.text_align || 'center'}; |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.5); |
| white-space: nowrap; |
| ${bgStyle} |
| `; |
| element.textContent = textData.text; |
| |
| uiContainer.appendChild(element); |
| uiElements.set(id, element); |
| |
| } |
| |
| function handleRenderBar(barData) { |
| ensureUIContainer(); |
| |
| const id = barData.id || barData.bar_id; |
| |
| |
| if (uiElements.has(id)) { |
| uiContainer.removeChild(uiElements.get(id)); |
| } |
| |
| const percentage = barData.percentage || |
| ((barData.value / barData.max_value) * 100); |
| |
| const container = document.createElement('div'); |
| container.id = `ui-${id}`; |
| container.style.cssText = ` |
| position: absolute; |
| left: ${barData.x}%; |
| top: ${barData.y}%; |
| `; |
| |
| |
| if (barData.label) { |
| const label = document.createElement('div'); |
| label.style.cssText = ` |
| color: #ffffff; |
| font-family: Arial, sans-serif; |
| font-size: 14px; |
| margin-bottom: 4px; |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.5); |
| `; |
| label.textContent = barData.label; |
| container.appendChild(label); |
| } |
| |
| |
| const barContainer = document.createElement('div'); |
| barContainer.style.cssText = ` |
| width: ${barData.width || 200}px; |
| height: ${barData.height || 20}px; |
| background-color: ${barData.background_color || '#333333'}; |
| border: 2px solid ${barData.border_color || '#ffffff'}; |
| border-radius: 4px; |
| overflow: hidden; |
| position: relative; |
| `; |
| |
| |
| const fill = document.createElement('div'); |
| fill.style.cssText = ` |
| width: ${percentage}%; |
| height: 100%; |
| background-color: ${barData.bar_color || '#00ff00'}; |
| transition: width 0.3s ease; |
| `; |
| barContainer.appendChild(fill); |
| |
| |
| if (barData.show_value) { |
| const valueText = document.createElement('div'); |
| valueText.style.cssText = ` |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| color: #ffffff; |
| font-family: Arial, sans-serif; |
| font-size: 12px; |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.8); |
| `; |
| valueText.textContent = `${Math.round(barData.value)}/${Math.round(barData.max_value)}`; |
| barContainer.appendChild(valueText); |
| } |
| |
| container.appendChild(barContainer); |
| uiContainer.appendChild(container); |
| uiElements.set(id, container); |
| |
| } |
| |
| function handleRemoveUIElement(elementId) { |
| if (uiElements.has(elementId)) { |
| uiContainer.removeChild(uiElements.get(elementId)); |
| uiElements.delete(elementId); |
| } |
| } |
| |
| |
| |
| function handleUpdateToonMaterial(data) { |
| const obj = scene.children.find(child => |
| child.userData.id === data.object_id || |
| child.userData.object_id === data.object_id |
| ); |
| |
| if (!obj) { |
| console.error('Object not found for toon material:', data.object_id); |
| return; |
| } |
| |
| if (data.enabled !== false) { |
| |
| const existingColor = obj.material?.color?.getHex() || 0xffffff; |
| const color = data.color ? new THREE.Color(data.color) : new THREE.Color(existingColor); |
| |
| |
| const steps = data.gradient_steps || 3; |
| const gradientMap = createToonGradientMap(steps); |
| |
| const toonMaterial = new THREE.MeshToonMaterial({ |
| color: color, |
| gradientMap: gradientMap |
| }); |
| |
| |
| if (obj.material) obj.material.dispose(); |
| obj.material = toonMaterial; |
| |
| |
| obj.userData.toonEnabled = true; |
| obj.userData.toonSettings = data; |
| |
| } else { |
| |
| const existingColor = obj.material?.color?.getHex() || 0xffffff; |
| |
| const standardMaterial = new THREE.MeshStandardMaterial({ |
| color: existingColor, |
| roughness: 0.7, |
| metalness: 0.0 |
| }); |
| |
| if (obj.material) obj.material.dispose(); |
| obj.material = standardMaterial; |
| obj.userData.toonEnabled = false; |
| |
| } |
| } |
| |
| function createToonGradientMap(steps) { |
| const canvas = document.createElement('canvas'); |
| canvas.width = steps; |
| canvas.height = 1; |
| const ctx = canvas.getContext('2d'); |
| |
| for (let i = 0; i < steps; i++) { |
| const value = Math.floor((i / (steps - 1)) * 255); |
| ctx.fillStyle = `rgb(${value},${value},${value})`; |
| ctx.fillRect(i, 0, 1, 1); |
| } |
| |
| const texture = new THREE.CanvasTexture(canvas); |
| texture.minFilter = THREE.NearestFilter; |
| texture.magFilter = THREE.NearestFilter; |
| |
| return texture; |
| } |
| |
| |
| |
| function handleAddBrick(brickData) { |
| if (!scene || !sceneData) { |
| console.error('Scene not initialized yet'); |
| return; |
| } |
| |
| |
| const staticBase = sceneData.static_base_url || ''; |
| const modelPath = staticBase + brickData.model_path; |
| const position = brickData.position || { x: 0, y: 0, z: 0 }; |
| const rotation = brickData.rotation || { x: 0, y: 0, z: 0 }; |
| const color = new THREE.Color(brickData.material?.color || '#ff0000'); |
| |
| |
| gltfLoader.load( |
| modelPath, |
| (gltf) => { |
| const model = gltf.scene; |
| |
| |
| model.position.set(position.x, position.y, position.z); |
| |
| |
| model.rotation.set( |
| THREE.MathUtils.degToRad(rotation.x), |
| THREE.MathUtils.degToRad(rotation.y), |
| THREE.MathUtils.degToRad(rotation.z) |
| ); |
| |
| |
| model.traverse((child) => { |
| if (child.isMesh) { |
| child.material = new THREE.MeshStandardMaterial({ |
| color: color, |
| metalness: brickData.material?.metalness || 0.1, |
| roughness: brickData.material?.roughness || 0.7 |
| }); |
| child.castShadow = true; |
| child.receiveShadow = true; |
| } |
| }); |
| |
| |
| model.userData.id = brickData.id; |
| model.userData.type = 'brick'; |
| model.userData.brick_type = brickData.brick_type; |
| model.userData.name = brickData.name; |
| |
| |
| scene.add(model); |
| |
| |
| if (!sceneData.objects) sceneData.objects = []; |
| sceneData.objects.push(brickData); |
| }, |
| undefined, |
| (error) => console.error('Error loading brick:', error) |
| ); |
| } |
| |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|