import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { Instance } from "./Instance.js"; import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js"; import * as CANNON from "cannon-es"; export class RenderService extends Instance { constructor() { super("RenderService"); // Add canvas to DOM if (typeof document !== "undefined") { // Three.js setup this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); this.renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(this.renderer.domElement); // Controls and loader this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.loader = new GLTFLoader(); } // Handle window resize if (typeof window !== "undefined") { window.addEventListener("resize", () => this.updateWindowSize()); // Basic lighting const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(5, 5, 5); this.scene.add(light); // Default test cube (you can remove this later) /*const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const cube = new THREE.Mesh(geometry, material); this.scene.add(cube);*/ // I commented out the cube because it was annoying me // just like the blender default cube // Camera position this.camera.position.z = 5; } // Rendering state this._running = false; this._lastFrame = 0; this.isServer = false; // If this is true it will ignore rendering and only run physics. Useful for dedicated servers. // Event signals this.RenderStepped = new BO3ScriptSignal(); this.Heartbeat = new BO3ScriptSignal(); this.Stepped = new BO3ScriptSignal(); // Physics world this.physicsWorld = null; this._initPhysicsWorld(); // Debug text this.debugText = ["Debug text!"]; // Line array // Debug overlay if (typeof document !== "undefined") { this.overlay = document.createElement("canvas"); this.overlay.style.position = "absolute"; this.overlay.style.top = "0"; this.overlay.style.left = "0"; this.overlay.style.pointerEvents = "none"; this.overlay.width = window.innerWidth; this.overlay.height = window.innerHeight; document.body.appendChild(this.overlay); this.debugCtx = this.overlay.getContext("2d"); } } // === Instance Overrides === Destroy() { if (typeof window !== "undefined") { window.removeEventListener("resize", this.updateWindowSize); } if (this.renderer.domElement?.parentNode) { this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); } } // === Rendering === updateWindowSize() { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); if (this.overlay) { this.overlay.width = window.innerWidth; this.overlay.height = window.innerHeight; } } // === Main loop === start() { if (this._running) return; this._running = true; const loop = (time) => { if (!this._running) return; const dt = (time - this._lastFrame) / 1000; this._lastFrame = time; this.renderFrame(dt); if (typeof window !== "undefined") requestAnimationFrame(loop); }; if (typeof window !== "undefined") { requestAnimationFrame(loop); } else { // Server-side loop at ~60Hz setInterval(() => { const now = Date.now(); const dt = (now - this._lastFrame) / 1000; this._lastFrame = now; this.renderFrame(dt); }, 1000 / 60); } } stop() { this._running = false; } _initPhysicsWorld() { this.physicsWorld = new CANNON.World(); this.physicsWorld.gravity.set(0, -9.81, 0); } renderFrame(dt) { // Emit Stepped signals this.Stepped.Fire(dt); if (this.physicsWorld) { this.physicsWorld.step(1 / 60, dt, 3); } if (!this.isServer) { // update all BaseParts’ visuals this.scene.traverse(obj => { const base = obj.userData?.basePart; if (base) { base.updateVisual(); //base.clientUpdate(dt); //console.debug("Updated visual for BasePart:", base.Name); } else if (obj.isMesh && !obj.userData?.checked) { obj.userData.checked = true; // mark it so we don’t warn every frame console.debug("[RenderService] Non-BasePart mesh (ignored):", obj.name || obj); } }); this.RenderStepped.Fire(dt); // Render debug text if (this.debugCtx && this.debugText.length > 0) { const ctx = this.debugCtx; ctx.clearRect(0, 0, this.overlay.width, this.overlay.height); ctx.save(); ctx.font = "14px Arial"; ctx.fillStyle = "white"; ctx.textAlign = "left"; ctx.textBaseline = "top"; this.debugText.forEach((line, i) => { ctx.fillText(line, 10, 10 + i * 16); }); ctx.restore(); } this.controls.update(); this.renderer.render(this.scene, this.camera); } else { // Update position variables for all BaseParts from physicsWorld, scene doesnt exist this.physicsWorld.bodies.forEach(body => { const base = body.userData?.basePart; if (base) { base.OnPhysicsTick(); } }); } this.Heartbeat.Fire(dt); } // Just like roblox, Heartbeat is always fired, RenderStepped only on clients, Stepped always }