GameContextProtocol / frontend /game_viewer.html
ArturoNereu's picture
Improvements
0614dda
<!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 for FPS mode */
#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; /* Show only in FPS mode */
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>
<!-- Stats library for FPS counter -->
<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';
// Skybox and environment references
let sky = null;
let sun = new THREE.Vector3();
// Particle systems
let particleSystems = new Map();
let animatedModels = []; // Models with animation metadata
// UI overlay container
let uiContainer = null;
let uiElements = new Map();
// GLTF loader for brick models
const gltfLoader = new GLTFLoader();
// Get scene ID from URL
const sceneId = window.location.pathname.split('/').pop();
const baseUrl = window.location.origin;
// Check for control mode in URL params
const urlParams = new URLSearchParams(window.location.search);
const initialMode = urlParams.get('mode') || 'fps'; // Default to FPS mode
let scene, camera, renderer;
let orbitControls;
let controlMode = initialMode;
let sceneData = null;
// Postprocessing for outlines
let composer, outlinePass;
// Scene controls
let gridHelper = null;
let stats = null;
let wireframeEnabled = false;
// Object selection system (FPS mode)
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
let selectedObject = null;
let selectedObjectId = null;
const MAX_SELECT_DISTANCE = 10; // Max raycast distance for selection
// FPS movement and look variables (configurable via player_config)
let moveSpeed = 8.0; // Default walking speed in units/sec
const velocity = new THREE.Vector3();
let isMouseLocked = false;
let cameraRotationX = 0; // Pitch (up/down)
let cameraRotationY = 0; // Yaw (left/right)
let mouseSensitivity = 0.002;
let invertY = false;
let movementAcceleration = 0.0; // Phase 2
let airControl = 1.0; // Phase 2
let cameraFOV = 75.0; // Phase 2
let minPitch = -89.0; // Phase 2
let maxPitch = 89.0; // Phase 2
// Physics variables (configurable via player_config)
let physicsWorld;
let playerBody;
let groundBody;
let wallBodies = [];
let objectBodies = new Map(); // Maps object IDs to physics bodies
let PLAYER_HEIGHT = 1.7;
let PLAYER_RADIUS = 0.3;
let EYE_HEIGHT = 1.6; // Eye level for camera
let JUMP_FORCE = 5.0;
let GRAVITY = -9.82;
let PLAYER_MASS = 80.0;
let LINEAR_DAMPING = 0.0; // No damping - we control velocity directly
// World size from scene data (default 25x25)
let WORLD_SIZE = 25;
let WORLD_HALF = WORLD_SIZE / 2;
let isGrounded = false;
let canJump = true;
// Movement direction and keyboard state
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() {
/**
* Apply player configuration from scene data to runtime variables
* Allows MCP tools to customize player controller behavior
*/
if (!sceneData || !sceneData.player_config) {
return;
}
const config = sceneData.player_config;
// Apply movement settings
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() {
/**
* Apply initial environment settings from scene data
* Loads skybox, particles, and UI elements on startup
*/
if (!sceneData) return;
// Apply skybox if defined in scene data
if (sceneData.skybox) {
handleAddSkybox(sceneData.skybox);
}
// Apply particles if defined in scene data
if (sceneData.particles && Array.isArray(sceneData.particles)) {
sceneData.particles.forEach(particleConfig => {
handleAddParticles(particleConfig);
});
}
// Apply UI elements if defined in scene data
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 {
// Check for embedded scene data first (used when served via Gradio)
if (window.SCENE_DATA) {
sceneData = window.SCENE_DATA;
} else {
// Fetch scene data from API (used when served via FastAPI)
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();
}
// Apply world size from scene data
if (sceneData.world_width) {
WORLD_SIZE = sceneData.world_width;
WORLD_HALF = WORLD_SIZE / 2;
}
// Apply player configuration from scene data
applyPlayerConfig();
// Setup Three.js scene
setupScene();
// Setup physics world
setupPhysics();
// Render all game objects
renderGameObjects();
// Apply initial environment (skybox, particles, UI from scene data)
applyInitialEnvironment();
// Start animation loop
animate();
} catch (error) {
console.error('Error initializing viewer:', error);
}
}
function setupScene() {
// Create scene
scene = new THREE.Scene();
const bgColor = sceneData.environment?.background_color || '#87CEEB';
scene.background = new THREE.Color(bgColor);
// Create camera (FOV from player_config)
camera = new THREE.PerspectiveCamera(
cameraFOV,
window.innerWidth / window.innerHeight,
0.1,
1000
);
// Position camera at player eye height (will be synced with physics in animate loop)
camera.position.set(0, EYE_HEIGHT, 0);
// Create renderer with shadow support
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// Enable shadows for realistic lighting
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Soft shadows
// Use physically correct lighting model
renderer.physicallyCorrectLights = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.getElementById('viewer-container').appendChild(renderer.domElement);
// Setup postprocessing for object outlines
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; // Increased for better visibility
outlinePass.edgeGlow = 1.0; // Increased glow
outlinePass.edgeThickness = 3.0; // Thicker edge
outlinePass.pulsePeriod = 0; // No pulsing
outlinePass.visibleEdgeColor.set('#ff8800'); // Orange outline
outlinePass.hiddenEdgeColor.set('#ff4400'); // Darker orange for hidden edges
composer.addPass(outlinePass);
const outputPass = new OutputPass();
composer.addPass(outputPass);
// Add lights from scene data
// Best practices: Combine ambient (low intensity) + directional (sun) + point lights (accents)
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);
// Directional lights need their target in the scene to work properly
light.target.position.set(0, 0, 0);
scene.add(light.target);
if (lightData.cast_shadow) {
light.castShadow = true;
// Configure shadow map for better quality
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') {
// Point lights with distance and decay for realistic falloff
const distance = lightData.distance || 50;
const decay = lightData.decay || 2; // Physically correct decay
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') {
// Hemisphere light - great for outdoor scenes (sky + ground colors)
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);
}
});
// Add grid (initially hidden, can be toggled)
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);
// Initialize stats (FPS counter) - initially hidden
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'; // Hidden by default
document.getElementById('viewer-container').appendChild(stats.dom);
}
// Setup Orbit controls
orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
// Setup FPS mouse-look controls
setupFPSControls();
// Click handler for object inspection in orbit mode
renderer.domElement.addEventListener('click', (event) => {
// In orbit mode, allow object inspection
if (controlMode === 'orbit') {
onObjectClick(event);
}
});
// Keyboard controls for FPS movement
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(); // Prevent page scroll
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);
// Set initial control mode
setControlMode(controlMode);
// Handle window resize
window.addEventListener('resize', onWindowResize);
}
function setupFPSControls() {
// Mouse-look controls for FPS mode
renderer.domElement.addEventListener('mousedown', (event) => {
if (controlMode === 'fps' && event.button === 0) {
isMouseLocked = true;
// Wrap in try-catch to handle SecurityError when user exits lock quickly
try {
renderer.domElement.requestPointerLock();
} catch (e) {
isMouseLocked = false;
}
}
});
renderer.domElement.addEventListener('mouseup', () => {
// Keep mouse locked until Escape is pressed
});
// Mouse movement for camera rotation
document.addEventListener('mousemove', (event) => {
if (!isMouseLocked || controlMode !== 'fps') return;
const movementX = event.movementX || 0;
const movementY = event.movementY || 0;
// Update rotation (yaw and pitch)
cameraRotationY -= movementX * mouseSensitivity; // Yaw (left/right)
const pitchMultiplier = invertY ? 1 : -1; // Invert Y if configured
cameraRotationX += movementY * mouseSensitivity * pitchMultiplier; // Pitch (up/down)
// Clamp vertical rotation to configured limits
const minPitchRad = THREE.MathUtils.degToRad(minPitch);
const maxPitchRad = THREE.MathUtils.degToRad(maxPitch);
cameraRotationX = Math.max(minPitchRad, Math.min(maxPitchRad, cameraRotationX));
});
// Handle pointer lock changes
document.addEventListener('pointerlockchange', () => {
isMouseLocked = document.pointerLockElement === renderer.domElement;
});
document.addEventListener('pointerlockerror', () => {
// Silently handle pointer lock errors - common when user clicks away quickly
isMouseLocked = false;
});
}
function setupPhysics() {
// Create physics world
physicsWorld = new CANNON.World();
physicsWorld.gravity.set(0, GRAVITY, 0);
// Set up collision materials - zero friction since we control velocity directly
const defaultMaterial = new CANNON.Material('default');
const defaultContactMaterial = new CANNON.ContactMaterial(
defaultMaterial,
defaultMaterial,
{
friction: 0.0, // No friction - we set velocity directly each frame
restitution: 0.0, // No bounce
}
);
physicsWorld.addContactMaterial(defaultContactMaterial);
physicsWorld.defaultContactMaterial = defaultContactMaterial;
// Create blueprint-style grid texture for ground
function createBlueprintTexture(size = 512) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Background - darker blue
ctx.fillStyle = '#1a5a9e';
ctx.fillRect(0, 0, size, size);
// Major grid lines - white
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();
// Minor grid lines - white (semi-transparent)
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;
}
// Create ground plane
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); // 5 units per texture tile
const groundMaterial = new THREE.MeshBasicMaterial({
map: blueprintTexture
});
const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
groundMesh.rotation.x = -Math.PI / 2; // Rotate to be horizontal
groundMesh.position.y = 0;
groundMesh.receiveShadow = true;
groundMesh.userData = { isGround: true };
scene.add(groundMesh);
// Ground physics body
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);
// Create 4 boundary walls with blueprint texture
const wallHeight = 5;
const wallThickness = 0.5;
// Wall material - same blueprint texture
const wallTexture = new THREE.CanvasTexture(createBlueprintTexture());
wallTexture.wrapS = THREE.RepeatWrapping;
wallTexture.wrapT = THREE.RepeatWrapping;
const wallMaterial = new THREE.MeshBasicMaterial({
map: wallTexture
});
// North/South walls (along X axis)
const nsWallGeometry = new THREE.BoxGeometry(WORLD_SIZE, wallHeight, wallThickness);
// Texture repeat: width based on world size, height = 1 tile (5 units)
const nsWallMaterial = wallMaterial.clone();
nsWallMaterial.map = wallTexture.clone();
nsWallMaterial.map.repeat.set(WORLD_SIZE / 5, wallHeight / 5);
// North wall (z = +WORLD_HALF)
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);
// South wall (z = -WORLD_HALF)
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);
// East/West walls (along Z axis)
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);
// East wall (x = +WORLD_HALF)
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);
// West wall (x = -WORLD_HALF)
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);
// Ceiling - same material as floor/walls, positioned at wall height
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; // Rotate to face downward
ceilingMesh.position.y = wallHeight;
ceilingMesh.receiveShadow = false;
ceilingMesh.castShadow = false;
scene.add(ceilingMesh);
// Create player physics body (capsule approximated with cylinder)
const playerShape = new CANNON.Cylinder(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT, 8);
playerBody = new CANNON.Body({
mass: PLAYER_MASS, // kg (configurable via player_config)
material: defaultMaterial,
fixedRotation: true, // Prevent player from tipping over
linearDamping: LINEAR_DAMPING, // Air resistance for movement (configurable)
});
playerBody.addShape(playerShape);
// Set player starting position - spawn behind and facing the HF logo at (0, 1.5, 0)
playerBody.position.set(0, 1 + PLAYER_HEIGHT / 2, 8);
physicsWorld.addBody(playerBody);
// Add collision detection for grounded check
playerBody.addEventListener('collide', (e) => {
// Check if colliding with ground
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') {
// Switch to FPS
orbitControls.enabled = false;
// Exit pointer lock if active
if (document.pointerLockElement) {
document.exitPointerLock();
}
isMouseLocked = false;
// Show crosshair
if (crosshair) crosshair.style.display = 'block';
} else {
// Switch to Orbit
if (document.pointerLockElement) {
document.exitPointerLock();
}
isMouseLocked = false;
orbitControls.enabled = true;
// Hide crosshair
if (crosshair) crosshair.style.display = 'none';
// Clear selection
if (outlinePass) outlinePass.selectedObjects = [];
selectedObject = null;
selectedObjectId = null;
}
}
function createPhysicsShape(objType, scale) {
// Create Cannon.js physics shape based on object type
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':
// For planes, create a thin box
return new CANNON.Box(new CANNON.Vec3(scale.x / 2, 0.01, scale.y / 2));
case 'cone':
// Approximate cone with cylinder (Cannon doesn't have cone shape)
return new CANNON.Cylinder(0, scale.x, scale.y, 8);
case 'torus':
// Approximate torus with sphere
return new CANNON.Sphere(scale.x);
default:
// Default to box
return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2));
}
}
function renderGameObjects() {
sceneData.objects.forEach(obj => {
// Validate object is within bounds
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;
// Create geometry based on type
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':
// Load GLB/GLTF model asynchronously
if (obj.model_path) {
const loader = new GLTFLoader();
// Use static_base_url from scene data for correct server
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)
);
// Apply unlit material if specified in metadata
// This makes the model render at its true colors without lighting
if (obj.metadata?.unlit) {
model.traverse((child) => {
if (child.isMesh && child.material) {
// Preserve the original texture/color but make it unlit
const oldMaterial = child.material;
const newMaterial = new THREE.MeshBasicMaterial();
// Copy texture if exists
if (oldMaterial.map) {
newMaterial.map = oldMaterial.map;
}
// Copy color if no texture
if (oldMaterial.color) {
newMaterial.color = oldMaterial.color.clone();
}
// Preserve transparency
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);
// Track animated models
if (obj.metadata?.animate) {
animatedModels.push(model);
}
},
undefined,
(error) => console.error(`Failed to load model ${modelUrl}:`, error)
);
}
return; // Skip the rest of the geometry/material creation
default:
console.warn('Unknown object type:', obj.type);
return;
}
// Create material
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,
});
// Create mesh
mesh = new THREE.Mesh(geometry, material);
// Set position
mesh.position.set(obj.position.x, obj.position.y, obj.position.z);
// Set rotation (convert degrees to radians)
mesh.rotation.set(
THREE.MathUtils.degToRad(obj.rotation.x),
THREE.MathUtils.degToRad(obj.rotation.y),
THREE.MathUtils.degToRad(obj.rotation.z)
);
// Store metadata
mesh.userData = {
id: obj.id,
name: obj.name,
type: obj.type,
isSceneObject: true,
};
scene.add(mesh);
// Create physics body for collision
const physicsShape = createPhysicsShape(obj.type, obj.scale);
const physicsBody = new CANNON.Body({
mass: 0, // Static objects
position: new CANNON.Vec3(obj.position.x, obj.position.y, obj.position.z),
});
physicsBody.addShape(physicsShape);
// Apply rotation to physics body
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);
// Update composer
if (composer) {
composer.setSize(window.innerWidth, window.innerHeight);
}
}
function updateLookedAtObject() {
if (controlMode !== 'fps') {
if (selectedObject) {
outlinePass.selectedObjects = [];
selectedObject = null;
selectedObjectId = null;
}
return;
}
// Raycast from camera center (crosshair position)
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); // Center of screen
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];
// Send selection to parent (for chat commands)
if (window.parent) {
window.parent.postMessage({
action: 'objectSelected',
data: {
object_id: selectedObjectId,
object_type: newSelected.userData.type,
distance: intersects[0].distance.toFixed(2)
}
}, '*');
}
}
} else {
// No object in view
if (selectedObject) {
outlinePass.selectedObjects = [];
selectedObject = null;
selectedObjectId = null;
// Notify deselection
if (window.parent) {
window.parent.postMessage({
action: 'objectDeselected',
data: {}
}, '*');
}
}
}
}
function animate() {
requestAnimationFrame(animate);
// Update stats
if (stats) stats.begin();
const time = performance.now();
const delta = (time - prevTime) / 1000;
// Step physics simulation
if (physicsWorld) {
physicsWorld.step(1/60, delta, 3);
}
if (controlMode === 'fps' && playerBody) {
// Apply camera rotation from mouse-look
camera.rotation.order = 'YXZ'; // Ensure correct rotation order
camera.rotation.y = cameraRotationY;
camera.rotation.x = cameraRotationX;
camera.rotation.z = 0;
// Get input direction from keyboard
direction.z = Number(moveForward.value) - Number(moveBackward.value);
direction.x = Number(moveRight.value) - Number(moveLeft.value);
direction.y = 0; // No vertical movement from input
direction.normalize();
// Calculate movement direction relative to camera orientation
const forward = new THREE.Vector3(0, 0, -1);
const right = new THREE.Vector3(1, 0, 0);
// Apply camera rotation to get movement direction
forward.applyQuaternion(camera.quaternion);
right.applyQuaternion(camera.quaternion);
// Project to horizontal plane
forward.y = 0;
right.y = 0;
forward.normalize();
right.normalize();
// Apply movement to physics body (keep current Y velocity for gravity/jump)
// Reduce effectiveness when airborne based on air control setting
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;
// Don't modify playerBody.velocity.y - let physics handle gravity and jumping
// Check if grounded using a small raycast downward
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;
}
// Sync camera position to physics body (at eye height)
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') {
// Orbit controls
orbitControls.update();
}
// Update looked-at object (FPS mode only)
updateLookedAtObject();
// Update crosshair floor intersection and send to parent
updateCrosshairPosition();
// Update particle systems
updateParticleSystems(delta);
// Update animated models (rotate + bob up/down)
updateAnimatedModels(time);
// Render using composer (for outlines) instead of direct renderer
if (composer) {
composer.render();
} else {
renderer.render(scene, camera);
}
// End stats measurement
if (stats) stats.end();
}
// ==================== Crosshair Position Tracking ====================
let lastCrosshairPosition = null;
let crosshairUpdateThrottle = 0;
/**
* Update crosshair position by projecting ray to y=0 plane
* This allows the chat to know where to place objects when user doesn't specify position
*/
function updateCrosshairPosition() {
// Throttle updates to every 10 frames (~6 times per second at 60fps)
crosshairUpdateThrottle++;
if (crosshairUpdateThrottle < 10) return;
crosshairUpdateThrottle = 0;
// Only track in FPS mode
if (controlMode !== 'fps') {
if (lastCrosshairPosition !== null) {
lastCrosshairPosition = null;
window.parent.postMessage({
action: 'crosshairPosition',
data: null
}, '*');
}
return;
}
// Get ray from camera center (crosshair)
const crosshairRaycaster = new THREE.Raycaster();
crosshairRaycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
// Calculate intersection with y=0 plane (floor level)
// Ray: P = origin + t * direction
// Plane y=0: solve for t where origin.y + t * direction.y = 0
const origin = crosshairRaycaster.ray.origin;
const direction = crosshairRaycaster.ray.direction;
// Avoid division by zero (looking perfectly horizontal)
if (Math.abs(direction.y) < 0.0001) {
// Looking horizontal - project forward at a fixed distance
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;
// If t is negative, ray is pointing away from floor (looking up)
// Still calculate the position as if projected through
const intersectX = origin.x + direction.x * Math.abs(t);
const intersectZ = origin.z + direction.z * Math.abs(t);
// Clamp to world bounds
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) {
// Only send update if position changed significantly
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
}, '*');
}
}
// ==================== Scene Control Functions ====================
/**
* Toggle grid helper visibility
*/
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';
}
/**
* Capture screenshot of the current scene
*/
function captureScreenshot() {
if (!renderer) {
console.error('Renderer not initialized');
return;
}
// Render one more frame to ensure we have the latest scene
renderer.render(scene, camera);
// Get the canvas data as PNG
const dataURL = renderer.domElement.toDataURL('image/png');
// Send screenshot data back to parent window
window.parent.postMessage({
action: 'screenshot',
data: {
dataURL: dataURL,
timestamp: Date.now(),
sceneName: sceneData?.name || 'scene'
}
}, '*');
}
/**
* Handle object click for inspection
*/
function onObjectClick(event) {
// Calculate mouse position in normalized device coordinates (-1 to +1)
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;
// Update raycaster
raycaster.setFromCamera(mouse, camera);
// Find intersections (only check mesh objects, not lights or helpers)
const intersects = raycaster.intersectObjects(scene.children.filter(obj => obj.isMesh));
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
// Deselect previous object
if (selectedObject && selectedObject !== clickedObject) {
if (selectedObject.userData.originalColor) {
selectedObject.material.emissive.copy(selectedObject.userData.originalColor);
selectedObject.material.emissiveIntensity = 0;
}
}
// Select new object
selectedObject = clickedObject;
// Highlight selected object with persistent glow
if (!selectedObject.userData.originalColor) {
selectedObject.userData.originalColor = selectedObject.material.emissive.clone();
}
selectedObject.material.emissive = new THREE.Color(0x4444ff);
selectedObject.material.emissiveIntensity = 0.3;
// Get object properties
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()
};
// Send object info to parent window
window.parent.postMessage({
action: 'objectInspect',
data: objectInfo
}, '*');
}
}
// ==================== PostMessage API for Dynamic Updates ====================
/**
* Listen for messages from parent window (Gradio) to update scene dynamically
* This eliminates the need for iframe reloads
*/
window.addEventListener('message', (event) => {
// Security: verify origin in production
// if (event.origin !== window.location.origin) return;
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;
// Player configuration actions
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;
// Skybox actions
case 'addSkybox':
handleAddSkybox(data);
break;
case 'removeSkybox':
handleRemoveSkybox();
break;
// Particle actions
case 'addParticles':
handleAddParticles(data);
break;
case 'removeParticles':
handleRemoveParticles(data.particle_id);
break;
// UI actions
case 'renderText':
handleRenderText(data);
break;
case 'renderBar':
handleRenderBar(data);
break;
case 'removeUIElement':
handleRemoveUIElement(data.element_id);
break;
// Toon shading
case 'updateToonMaterial':
handleUpdateToonMaterial(data);
break;
// Brick blocks
case 'addBrick':
handleAddBrick(data);
break;
default:
console.warn('Unknown postMessage action:', action);
}
});
/**
* Calculate spawn position in front of camera (Minecraft-style placement)
* Distance is calculated based on object size so larger objects spawn further away
*
* @param {Object} scale - Object scale {x, y, z}
* @param {boolean} snapToGround - Whether to snap object to ground plane
* @returns {THREE.Vector3} - The calculated spawn position
*/
function getForwardSpawnPosition(scale = {x: 1, y: 1, z: 1}, snapToGround = true) {
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
// Calculate object's bounding size (largest dimension)
const objectSize = Math.max(scale.x || 1, scale.y || 1, scale.z || 1);
// Base distance + object size + small buffer
// This ensures the object spawns in front of player without clipping
const baseDistance = 2.0; // Minimum distance from player
const sizeMultiplier = 1.2; // Extra padding based on size
const distance = baseDistance + (objectSize * sizeMultiplier);
// Start from camera position and move forward
const spawnPos = new THREE.Vector3()
.copy(camera.position)
.add(dir.clone().multiplyScalar(distance));
// Snap to ground if requested
if (snapToGround) {
// Cast ray downward to find ground
const downRay = new THREE.Raycaster(
new THREE.Vector3(spawnPos.x, spawnPos.y + 20, spawnPos.z), // Start above
new THREE.Vector3(0, -1, 0) // Point down
);
// Find ground mesh (look for the floor plane)
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;
}
/**
* Dynamically add an object to the scene
*/
function handleAddObject(objData) {
if (!scene || !sceneData) {
console.error('Scene not initialized yet');
return;
}
// If position is at origin (0,0,0) or use_camera_position flag is set,
// spawn in front of the camera (Minecraft-style)
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);
// Offset Y by half the object height so it sits on ground
const halfHeight = (scale.y || 1) / 2;
spawnPos.y += halfHeight;
objData.position = {
x: spawnPos.x,
y: spawnPos.y,
z: spawnPos.z
};
}
// Validate object is within bounds
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;
}
// Add to scene data
sceneData.objects.push(objData);
// Create and add to Three.js scene
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);
// Create physics body for collision
const physicsShape = createPhysicsShape(objData.type, objData.scale);
const physicsBody = new CANNON.Body({
mass: 0, // Static objects
position: new CANNON.Vec3(objData.position.x, objData.position.y, objData.position.z),
});
physicsBody.addShape(physicsShape);
// Apply rotation to physics body
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);
// Add highlight effect
animateObjectHighlight(mesh);
}
/**
* Dynamically remove an object from the scene
*/
function handleRemoveObject(data) {
if (!scene || !sceneData) {
console.error('Scene not initialized yet');
return;
}
const { object_id } = data;
// Remove from Three.js scene
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();
}
// Remove physics body
const physicsBody = objectBodies.get(object_id);
if (physicsBody) {
physicsWorld.removeBody(physicsBody);
objectBodies.delete(object_id);
}
// Remove from scene data
sceneData.objects = sceneData.objects.filter(obj => obj.id !== object_id);
}
/**
* Dynamically update lighting
*/
function handleSetLighting(data) {
if (!scene || !sceneData) {
console.error('Scene not initialized yet');
return;
}
const { lights } = data;
// Remove all existing lights
const lightsToRemove = scene.children.filter(obj => obj.isLight);
lightsToRemove.forEach(light => scene.remove(light));
// Add new lights
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);
}
});
// Update scene data
sceneData.lights = lights;
}
/**
* Fully reload the scene from new data
*/
function handleUpdateScene(data) {
// For major updates, we can reload the entire scene
// This is a fallback for complex changes
location.reload();
}
/**
* Animate object highlight with enhanced color pulse and scale effect
*/
function animateObjectHighlight(mesh) {
const originalColor = mesh.material.color.clone();
const originalScale = mesh.scale.clone();
// Color pulse sequence: yellow -> cyan -> yellow
const pulseColors = [
new THREE.Color(0xffff00), // Yellow
new THREE.Color(0x00ffff), // Cyan
new THREE.Color(0xffff00), // Yellow
];
let progress = 0;
const duration = 90; // frames (1.5 seconds at 60fps)
const pulseIntensity = 0.6; // How much to mix highlight colors
function animateHighlight() {
if (progress < duration) {
// Calculate normalized progress (0 to 1)
const t = progress / duration;
// Color animation - cycle through pulse colors
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);
// Mix with original color using sine wave for smooth pulse
const pulseMix = Math.sin(t * Math.PI) * pulseIntensity;
mesh.material.color.lerpColors(originalColor, blendedColor, pulseMix);
}
// Scale animation - subtle "pop" effect
const scaleAmount = 1.0 + Math.sin(t * Math.PI) * 0.15; // 15% scale increase
mesh.scale.copy(originalScale).multiplyScalar(scaleAmount);
// Increase emissive for glow effect
if (mesh.material.emissive) {
const emissiveIntensity = Math.sin(t * Math.PI * 2) * 0.3;
mesh.material.emissiveIntensity = emissiveIntensity;
}
progress++;
requestAnimationFrame(animateHighlight);
} else {
// Reset to original state
mesh.material.color.copy(originalColor);
mesh.scale.copy(originalScale);
if (mesh.material.emissive) {
mesh.material.emissiveIntensity = 0;
}
}
}
// Enable emissive if not already set
if (!mesh.material.emissive) {
mesh.material.emissive = new THREE.Color(originalColor);
mesh.material.emissiveIntensity = 0;
}
animateHighlight();
}
// ==================== Rendering & Lighting Handler Functions ====================
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;
// Configure shadow map for better quality
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') {
// Point lights with distance and decay for realistic falloff
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') {
// Hemisphere light - great for outdoor scenes (sky + ground colors)
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') {
// Create gradient canvas
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);
}
}
// ==================== Skybox Handlers ====================
function handleAddSkybox(skyboxData) {
// Remove existing skybox if any
if (sky) {
scene.remove(sky);
}
// Create Sky mesh
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;
// Calculate sun position from elevation and azimuth
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);
// Update scene background to use sky
scene.background = null; // Sky will render as background
}
function handleRemoveSkybox() {
if (sky) {
scene.remove(sky);
sky = null;
}
// Revert to solid background
const bgColor = sceneData?.environment?.background_color || '#87CEEB';
scene.background = new THREE.Color(bgColor);
}
// ==================== Particle System Handlers ====================
function handleAddParticles(particleData) {
const id = particleData.id || particleData.particle_id;
// Remove existing particle system with same 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;
// Create particle geometry
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;
// Use forward spawn position if no position specified or default position
// Backend defaults to {0, 1, 0} when no position is provided
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) {
// For localized effects, spawn in front of player (don't snap to ground for particles)
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) {
// Localized effect (fire, smoke, sparkle)
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 {
// Weather effect (rain, snow) - covers world
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));
// Create particle material
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);
// Store particle system data for animation
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) {
// Animate models with sine wave bobbing (no rotation)
const timeInSeconds = time / 1000;
animatedModels.forEach(model => {
// Bob up and down with sine wave
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;
// Update position based on velocity
positions[i3] += system.velocities[i3] * delta;
positions[i3 + 1] += system.velocities[i3 + 1] * delta;
positions[i3 + 2] += system.velocities[i3 + 2] * delta;
// Update lifetime
system.lifetimes[i] += delta;
// Reset particle if lifetime exceeded
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 {
// Weather - respawn at top
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;
});
}
// ==================== UI Overlay Handlers ====================
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;
// Remove existing element with same 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;
// Remove existing element with same 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}%;
`;
// Add label if provided
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);
}
// Create bar container
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;
`;
// Create fill bar
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);
// Show value if requested
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);
}
}
// ==================== Toon Material Handler ====================
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) {
// Create toon material
const existingColor = obj.material?.color?.getHex() || 0xffffff;
const color = data.color ? new THREE.Color(data.color) : new THREE.Color(existingColor);
// Create gradient texture for toon shading
const steps = data.gradient_steps || 3;
const gradientMap = createToonGradientMap(steps);
const toonMaterial = new THREE.MeshToonMaterial({
color: color,
gradientMap: gradientMap
});
// Dispose old material
if (obj.material) obj.material.dispose();
obj.material = toonMaterial;
// Store toon settings in userData for reference
obj.userData.toonEnabled = true;
obj.userData.toonSettings = data;
} else {
// Revert to standard material
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;
}
// ==================== Brick Block Handler ====================
function handleAddBrick(brickData) {
if (!scene || !sceneData) {
console.error('Scene not initialized yet');
return;
}
// Use static_base_url from scene data for correct server
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');
// Load the GLTF model
gltfLoader.load(
modelPath,
(gltf) => {
const model = gltf.scene;
// Apply position
model.position.set(position.x, position.y, position.z);
// Apply rotation (convert degrees to radians)
model.rotation.set(
THREE.MathUtils.degToRad(rotation.x),
THREE.MathUtils.degToRad(rotation.y),
THREE.MathUtils.degToRad(rotation.z)
);
// Apply color to all meshes in the model
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;
}
});
// Store metadata
model.userData.id = brickData.id;
model.userData.type = 'brick';
model.userData.brick_type = brickData.brick_type;
model.userData.name = brickData.name;
// Add to scene
scene.add(model);
// Add to scene data for tracking
if (!sceneData.objects) sceneData.objects = [];
sceneData.objects.push(brickData);
},
undefined,
(error) => console.error('Error loading brick:', error)
);
}
// Start the application
init();
</script>
</body>
</html>