2025-10-12 18:03:20 +02:00
|
|
|
|
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";
|
2025-10-23 20:58:19 +02:00
|
|
|
|
import { BaseService } from "./BaseService.js";
|
|
|
|
|
|
export class RenderService extends BaseService {
|
2025-10-12 18:03:20 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|