init commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RobloxEngine</title>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/js/clientmain.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Entry point for BO3 -->
|
||||
30
js/clientmain.js
Normal file
30
js/clientmain.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DataModel } from "./core/DataModel.js";
|
||||
import * as THREE from "three";
|
||||
import { NetworkService } from "./instances/NetworkService.js";
|
||||
import { ReplicatorService } from "./instances/ReplicatorService.js";
|
||||
import { RenderService } from "./instances/RenderService.js";
|
||||
import { Workspace } from "./instances/Workspace.js";
|
||||
import { DebugTextService } from "./instances/DebugTextService.js"; // Client-only debug overlay manager
|
||||
|
||||
const dm = new DataModel();
|
||||
dm.SetParent(null); // root
|
||||
|
||||
// Create workspace
|
||||
const ws = new Workspace();
|
||||
ws.SetParent(dm);
|
||||
|
||||
const net = new NetworkService();
|
||||
net.SetParent(dm);
|
||||
net.isServer = false; // client mode
|
||||
await net.connect("ws://localhost:8080"); // connect to server
|
||||
|
||||
const render = new RenderService(dm);
|
||||
render.SetParent(dm);
|
||||
render.start();
|
||||
|
||||
// Start replicator
|
||||
const replication = new ReplicatorService(dm, net); // Automatically parents to dm
|
||||
|
||||
// Create DebugTextService
|
||||
const debugText = new DebugTextService();
|
||||
debugText.SetParent(dm);
|
||||
40
js/core/BO3ScriptSignal.js
Normal file
40
js/core/BO3ScriptSignal.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// A simple event system that mimics Roblox's RBXScriptSignal.
|
||||
|
||||
export class BO3ScriptSignal {
|
||||
constructor() {
|
||||
this._connections = [];
|
||||
}
|
||||
|
||||
// Connect a function to the signal.
|
||||
Connect(fn) {
|
||||
const connection = { fn, connected: true };
|
||||
this._connections.push(connection);
|
||||
return {
|
||||
Disconnect: () => {
|
||||
connection.connected = false;
|
||||
this._connections = this._connections.filter(c => c !== connection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fire all connected callbacks with arguments.
|
||||
Fire(...args) {
|
||||
for (const c of this._connections) {
|
||||
if (c.connected) {
|
||||
try {
|
||||
c.fn(...args);
|
||||
} catch (err) {
|
||||
console.error("[BO3ScriptSignal] Error in connected function:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect all listeners.
|
||||
DisconnectAll() {
|
||||
this._connections = [];
|
||||
}
|
||||
get [Symbol.toStringTag]() {
|
||||
return "BO3ScriptSignal";
|
||||
}
|
||||
}
|
||||
15
js/core/DataModel.js
Normal file
15
js/core/DataModel.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import {Instance} from "../instances/Instance.js";
|
||||
|
||||
export class DataModel extends Instance {
|
||||
constructor() {
|
||||
super("DataModel");
|
||||
this.InstanceId = "DataModelRoot"; // Fixed ID for root
|
||||
}
|
||||
GetService(name) {
|
||||
// Loop through children by class name
|
||||
return this.Children.find(c => c.ClassName === name) || null;
|
||||
}
|
||||
GetDataModel() {return this;} // Override to return self
|
||||
} // Just a container for the whole instance tree
|
||||
// cuz this is an Entity Component System, we need a root entity
|
||||
// Assume all children of DataModel are services or top-level game objects
|
||||
140
js/core/util.js
Normal file
140
js/core/util.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
|
||||
function guidGenerator() {
|
||||
var S4 = function() {
|
||||
return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
|
||||
};
|
||||
return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
|
||||
}
|
||||
|
||||
// Pure JS SHA-256 (works in browser or Node)
|
||||
// Returns hex string
|
||||
function sha256(message) {
|
||||
// UTF-8 encode string to bytes
|
||||
const msgBytes = new TextEncoder().encode(message);
|
||||
|
||||
// Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes)
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
]);
|
||||
|
||||
// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes)
|
||||
let H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
|
||||
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
]);
|
||||
|
||||
// Pre-processing: padding the message
|
||||
const l = msgBytes.length * 8;
|
||||
// Append 0x80 then zero bytes, then 64-bit big-endian length
|
||||
const k = (512 - ((l + 8 + 64) % 512)) % 512; // number of zero bits
|
||||
const totalBits = l + 1 + k + 64;
|
||||
const totalBytes = totalBits / 8;
|
||||
const padded = new Uint8Array(totalBytes);
|
||||
|
||||
// copy message
|
||||
padded.set(msgBytes);
|
||||
|
||||
// append 1 bit (0x80)
|
||||
padded[msgBytes.length] = 0x80;
|
||||
|
||||
// append length in bits as 64-bit big-endian integer
|
||||
const lenPos = totalBytes - 8;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
padded[lenPos + 7 - i] = (l >>> (i * 8)) & 0xff;
|
||||
}
|
||||
|
||||
// Process the message in successive 512-bit chunks
|
||||
const W = new Uint32Array(64);
|
||||
|
||||
function ROTR(n, x) { return (x >>> n) | (x << (32 - n)); }
|
||||
function Σ0(x) { return ROTR(2,x) ^ ROTR(13,x) ^ ROTR(22,x); }
|
||||
function Σ1(x) { return ROTR(6,x) ^ ROTR(11,x) ^ ROTR(25,x); }
|
||||
function σ0(x) { return ROTR(7,x) ^ ROTR(18,x) ^ (x >>> 3); }
|
||||
function σ1(x) { return ROTR(17,x) ^ ROTR(19,x) ^ (x >>> 10); }
|
||||
|
||||
for (let chunkStart = 0; chunkStart < padded.length; chunkStart += 64) {
|
||||
// prepare message schedule W[0..63]
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const j = chunkStart + i * 4;
|
||||
W[i] = (padded[j] << 24) | (padded[j + 1] << 16) | (padded[j + 2] << 8) | (padded[j + 3]);
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
W[i] = (σ1(W[i - 2]) + W[i - 7] + σ0(W[i - 15]) + W[i - 16]) >>> 0;
|
||||
}
|
||||
|
||||
// initialize working variables
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3];
|
||||
let e = H[4], f = H[5], g = H[6], h = H[7];
|
||||
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const T1 = (h + Σ1(e) + ((e & f) ^ (~e & g)) + K[t] + W[t]) >>> 0;
|
||||
const T2 = (Σ0(a) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + T1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (T1 + T2) >>> 0;
|
||||
}
|
||||
|
||||
// compute intermediate hash value
|
||||
H[0] = (H[0] + a) >>> 0;
|
||||
H[1] = (H[1] + b) >>> 0;
|
||||
H[2] = (H[2] + c) >>> 0;
|
||||
H[3] = (H[3] + d) >>> 0;
|
||||
H[4] = (H[4] + e) >>> 0;
|
||||
H[5] = (H[5] + f) >>> 0;
|
||||
H[6] = (H[6] + g) >>> 0;
|
||||
H[7] = (H[7] + h) >>> 0;
|
||||
}
|
||||
|
||||
// produce the final hash (big-endian)
|
||||
let hex = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
hex += ("00000000" + H[i].toString(16)).slice(-8);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function v3Encoder(vec3) { // THREE.Vector3 to Float32Array(3)
|
||||
const arr = new Float32Array(3);
|
||||
arr[0] = vec3.x;
|
||||
arr[1] = vec3.y;
|
||||
arr[2] = vec3.z;
|
||||
return arr;
|
||||
}
|
||||
|
||||
function v3Decoder(arr) { // Float32Array(3) to THREE.Vector3
|
||||
return new THREE.Vector3(arr[0], arr[1], arr[2]);
|
||||
}
|
||||
|
||||
function eulerEncoder(euler) { // THREE.Euler to Float32Array(3)
|
||||
const arr = new Float32Array(3);
|
||||
arr[0] = euler.x;
|
||||
arr[1] = euler.y;
|
||||
arr[2] = euler.z;
|
||||
return arr;
|
||||
}
|
||||
|
||||
function eulerDecoder(arr) { // Float32Array(3) to THREE.Euler
|
||||
return new THREE.Euler(arr[0], arr[1], arr[2]);
|
||||
}
|
||||
|
||||
const encoders = {
|
||||
Vector3: { encode: v3Encoder, decode: v3Decoder },
|
||||
Euler: { encode: eulerEncoder, decode: eulerDecoder },
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
export { guidGenerator, sha256, encoders };
|
||||
152
js/instances/BasePart.js
Normal file
152
js/instances/BasePart.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as THREE from "three";
|
||||
import * as CANNON from "cannon-es";
|
||||
import { Instance } from "./Instance.js";
|
||||
|
||||
export class BasePart extends Instance {
|
||||
constructor() {
|
||||
super("BasePart");
|
||||
|
||||
// === Visual properties ===
|
||||
this.Color = 0x00ff00;
|
||||
this.Size = new THREE.Vector3(1, 1, 1);
|
||||
this.Position = new THREE.Vector3(0, 0, 0);
|
||||
this.Orientation = new THREE.Euler(0, 0, 0);
|
||||
this.Anchored = false; // Roblox-style toggle
|
||||
|
||||
// === Three.js setup ===
|
||||
const geometry = new THREE.BoxGeometry(this.Size.x, this.Size.y, this.Size.z);
|
||||
const material = new THREE.MeshStandardMaterial({ color: this.Color });
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
this.mesh.castShadow = true;
|
||||
this.mesh.receiveShadow = true;
|
||||
this.mesh.userData.basePart = this;
|
||||
|
||||
// === Cannon.js setup ===
|
||||
const shape = new CANNON.Box(
|
||||
new CANNON.Vec3(this.Size.x / 2, this.Size.y / 2, this.Size.z / 2)
|
||||
);
|
||||
this.body = new CANNON.Body({
|
||||
mass: 1,
|
||||
position: new CANNON.Vec3(this.Position.x, this.Position.y, this.Position.z),
|
||||
shape: shape,
|
||||
});
|
||||
this.body.userData = { basePart: this };
|
||||
|
||||
this._originalMass = this.body.mass; // store default mass
|
||||
this._renderService = null; // assigned when added to scene
|
||||
}
|
||||
|
||||
// === Scene lifecycle ===
|
||||
OnAddedToScene(parent) {
|
||||
const dm = this.GetDataModel();
|
||||
const renderService = dm.GetService("RenderService");
|
||||
this._renderService = renderService;
|
||||
|
||||
if (renderService.scene) renderService.scene.add(this.mesh);
|
||||
if (!renderService.physicsWorld) renderService._initPhysicsWorld();
|
||||
|
||||
renderService.physicsWorld.addBody(this.body);
|
||||
this._applyAnchoredState();
|
||||
}
|
||||
|
||||
// === Anchoring logic ===
|
||||
setAnchored(value) {
|
||||
if (this.Anchored === value) return;
|
||||
this.Anchored = value;
|
||||
this._applyAnchoredState();
|
||||
}
|
||||
|
||||
_applyAnchoredState() {
|
||||
if (!this.body) return;
|
||||
if (this.Anchored) {
|
||||
this.body.type = CANNON.Body.STATIC;
|
||||
this.body.mass = 0;
|
||||
this.body.velocity.set(0, 0, 0);
|
||||
this.body.angularVelocity.set(0, 0, 0);
|
||||
} else {
|
||||
this.body.type = CANNON.Body.DYNAMIC;
|
||||
this.body.mass = this._originalMass || 1;
|
||||
}
|
||||
this.body.updateMassProperties();
|
||||
}
|
||||
|
||||
// === Updates ===
|
||||
updateVisual() {
|
||||
// Only update mesh from physics if not anchored
|
||||
if (!this.Anchored) {
|
||||
this.mesh.position.copy(this.body.position);
|
||||
this.mesh.quaternion.copy(this.body.quaternion);
|
||||
}
|
||||
}
|
||||
|
||||
updateBodyPosition() {
|
||||
this.body.position.set(this.Position.x, this.Position.y, this.Position.z);
|
||||
this.body.quaternion.setFromEuler(
|
||||
this.Orientation.x,
|
||||
this.Orientation.y,
|
||||
this.Orientation.z
|
||||
);
|
||||
this.body.velocity.set(0, 0, 0);
|
||||
this.body.angularVelocity.set(0, 0, 0);
|
||||
}
|
||||
|
||||
updateSizes() {
|
||||
// Update Three geometry
|
||||
this.mesh.geometry.dispose();
|
||||
this.mesh.geometry = new THREE.BoxGeometry(this.Size.x, this.Size.y, this.Size.z);
|
||||
|
||||
// Update Cannon shape
|
||||
this.body.shapes = [];
|
||||
const newShape = new CANNON.Box(
|
||||
new CANNON.Vec3(this.Size.x / 2, this.Size.y / 2, this.Size.z / 2)
|
||||
);
|
||||
this.body.addShape(newShape);
|
||||
this.body.updateMassProperties();
|
||||
}
|
||||
|
||||
OnReplicated() {
|
||||
if (this.IsReplicated) {
|
||||
this.body.type = CANNON.Body.KINEMATIC;
|
||||
this.body.velocity.set(0, 0, 0);
|
||||
this.body.angularVelocity.set(0, 0, 0);
|
||||
}
|
||||
|
||||
this.body.position.copy(this.Position);
|
||||
this.body.quaternion.setFromEuler(
|
||||
this.Orientation.x,
|
||||
this.Orientation.y,
|
||||
this.Orientation.z
|
||||
);
|
||||
|
||||
this.updateVisual();
|
||||
}
|
||||
|
||||
OnPhysicsTick() {
|
||||
|
||||
if (this.body.position.y < -1000) {
|
||||
// Fell out of world, destroy like in Roblox
|
||||
this.Destroy();
|
||||
console.debug("BasePart fell out of world and was destroyed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.IsReplicated || this.Anchored) return;
|
||||
|
||||
this.Position.set(
|
||||
this.body.position.x,
|
||||
this.body.position.y,
|
||||
this.body.position.z
|
||||
);
|
||||
this.Orientation.setFromQuaternion(this.body.quaternion);
|
||||
}
|
||||
|
||||
// === Cleanup ===
|
||||
Destroy() {
|
||||
if (this._renderService) {
|
||||
if (this.mesh.parent) this.mesh.parent.remove(this.mesh);
|
||||
if (this._renderService.physicsWorld)
|
||||
this._renderService.physicsWorld.removeBody(this.body);
|
||||
}
|
||||
super.Destroy();
|
||||
}
|
||||
}
|
||||
105
js/instances/DebugTextService.js
Normal file
105
js/instances/DebugTextService.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
|
||||
export class DebugTextService extends Instance {
|
||||
constructor() {
|
||||
super("DebugTextService");
|
||||
|
||||
this.isClient = true; // Mark client-only
|
||||
this._renderService = null;
|
||||
this._networkService = null;
|
||||
this._lastBytes = 0;
|
||||
this._bps = 0;
|
||||
this._lastMeasure = performance.now();
|
||||
|
||||
// Tracks text lines
|
||||
this._lines = [];
|
||||
|
||||
// Hook into render loop later
|
||||
}
|
||||
|
||||
OnAddedToScene(parent) {
|
||||
const dm = this.GetDataModel();
|
||||
|
||||
// Get references to RenderService and NetworkService
|
||||
this._renderService = dm.GetService("RenderService");
|
||||
this._networkService = dm.GetService("NetworkService");
|
||||
|
||||
if (!this._renderService) {
|
||||
console.warn("[DebugTextService] No RenderService found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize debug text array
|
||||
this._renderService.debugText = [];
|
||||
|
||||
// Hook into RenderStepped
|
||||
this._renderService.RenderStepped.Connect((dt) => this._onRenderStep(dt));
|
||||
}
|
||||
|
||||
// Called each RenderStepped frame (~60fps)
|
||||
_onRenderStep(dt) {
|
||||
this._lines.length = 0; // clear
|
||||
|
||||
// === Network stats ===
|
||||
if (this._networkService) {
|
||||
const now = performance.now();
|
||||
const bytesNow = this._networkService.totalBytes || 0;
|
||||
const deltaBytes = bytesNow - this._lastBytes;
|
||||
const deltaTime = (now - this._lastMeasure) / 1000;
|
||||
|
||||
if (deltaTime > 0.25) { // smooth every 250ms
|
||||
this._bps = deltaBytes / deltaTime;
|
||||
this._lastBytes = bytesNow;
|
||||
this._lastMeasure = now;
|
||||
}
|
||||
|
||||
this._lines.push(
|
||||
`Net: ${(this._bps / 1024).toFixed(2)} KB/s`
|
||||
);
|
||||
}
|
||||
|
||||
// === Instance tree summary ===
|
||||
const dm = this.GetDataModel();
|
||||
if (dm && dm.Children) {
|
||||
const totalInstances = this._countInstances(dm);
|
||||
this._lines.push(`Instances: ${totalInstances}`);
|
||||
}
|
||||
|
||||
// === Misc stats ===
|
||||
if (this._renderService.physicsWorld) {
|
||||
const bodies = this._renderService.physicsWorld.bodies.length;
|
||||
this._lines.push(`Physics Bodies: ${bodies}`);
|
||||
}
|
||||
|
||||
// === Output ===
|
||||
this._renderService.debugText.length = 0;
|
||||
this._renderService.debugText.push(...this._lines);
|
||||
|
||||
// Generate instance tree (for future use)
|
||||
const instanceTree = this._generateTree(dm);
|
||||
// Push this to text overlay if needed
|
||||
this._renderService.debugText.push(...instanceTree);
|
||||
}
|
||||
|
||||
_countInstances(instance) {
|
||||
let count = 1;
|
||||
if (!instance.Children) return count;
|
||||
for (const child of instance.Children) {
|
||||
count += this._countInstances(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
_generateTree(instance, depth = 0) {
|
||||
let lines = [];
|
||||
const indent = " ".repeat(depth);
|
||||
lines.push(`${indent}- ${instance.ClassName} (${instance.Name}) [${instance.InstanceId}]`);
|
||||
if (instance.Children) {
|
||||
for (const child of instance.Children) {
|
||||
lines.push(...this._generateTree(child, depth + 1));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
// Displays debug text overlay on screen with network and instance stats
|
||||
6
js/instances/ExampleService.js
Normal file
6
js/instances/ExampleService.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
export class ExampleService extends Instance {
|
||||
constructor() {
|
||||
super("ExampleService");
|
||||
}
|
||||
} // Basic example of a service in BO3
|
||||
123
js/instances/Instance.js
Normal file
123
js/instances/Instance.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
import { guidGenerator } from "../core/util.js";
|
||||
let instanceCounter = 0;
|
||||
|
||||
export class Instance {
|
||||
constructor(className = "Instance") {
|
||||
this.ClassName = className;
|
||||
this.Name = `${className}${instanceCounter++}`;
|
||||
this.Parent = null;
|
||||
this.Children = [];
|
||||
this.InstanceId = guidGenerator(); // Unique identifier, used for replication
|
||||
|
||||
// Signals
|
||||
this.AncestryChanged = new BO3ScriptSignal();
|
||||
this.Changed = new BO3ScriptSignal();
|
||||
}
|
||||
|
||||
// Set a property dynamically
|
||||
SetProperty(prop, value) {
|
||||
this[prop] = value;
|
||||
this.Changed.Fire(prop, value);
|
||||
}
|
||||
|
||||
GetDataModel() { // Recursively calls itself on parent until DataModel is found, since DataModel overrides this method to return itself
|
||||
if (this.Parent) return this.Parent.GetDataModel();
|
||||
}
|
||||
|
||||
FindInstanceRecursively(instanceId) {
|
||||
if (this.InstanceId === instanceId) return this;
|
||||
if (this.Children) {
|
||||
for (const child of this.Children) {
|
||||
if (typeof child.FindInstanceRecursively === 'function') {
|
||||
const found = child.FindInstanceRecursively(instanceId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
OnReplicated() {
|
||||
// Hook for when instance is replicated to client. Override in subclasses.
|
||||
}
|
||||
|
||||
// Parent management
|
||||
SetParent(newParent) {
|
||||
if (this.Parent === newParent) return;
|
||||
|
||||
// Remove from old parent
|
||||
if (this.Parent) {
|
||||
const oldChildren = this.Parent.Children;
|
||||
this.Parent.Children = oldChildren.filter(c => c !== this);
|
||||
}
|
||||
|
||||
const oldParent = this.Parent;
|
||||
this.Parent = newParent;
|
||||
|
||||
// Add to new parent
|
||||
if (newParent) {
|
||||
newParent.Children.push(this);
|
||||
}
|
||||
|
||||
try {
|
||||
this.AncestryChanged.Fire(this, oldParent);
|
||||
} catch {
|
||||
console.warn("AncestryChanged signal error! May be caused by ReplicatorService during replication.");
|
||||
}
|
||||
if (oldParent === null && newParent !== null && this.OnAddedToScene) {
|
||||
// If added to scene (i.e. RenderService), call hook
|
||||
setTimeout(() => {
|
||||
this.OnAddedToScene(newParent);
|
||||
}, 0); // Defer to avoid issues during replication
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: find first child with name
|
||||
FindFirstChild(name) {
|
||||
return this.Children.find(c => c.Name === name) || null;
|
||||
}
|
||||
|
||||
// Utility: create deep clone
|
||||
Clone() {
|
||||
const clone = new Instance(this.ClassName);
|
||||
for (const key of Object.keys(this)) {
|
||||
if (key !== "Parent" && key !== "Children") {
|
||||
clone[key] = structuredClone(this[key]);
|
||||
}
|
||||
}
|
||||
for (const child of this.Children) {
|
||||
const childClone = child.Clone();
|
||||
childClone.SetParent(clone);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
Destroy() {
|
||||
// Remove from parent
|
||||
const dm = this.GetDataModel(); // Get DataModel for replication service, BEFORE removing parent. otherwise can't find it.
|
||||
this.SetParent(null);
|
||||
// Recursively destroy children
|
||||
for (const child of [...this.Children]) {
|
||||
if (typeof child.Destroy === 'function') {
|
||||
child.Destroy();
|
||||
} else {
|
||||
child.SetParent(null);
|
||||
}
|
||||
}
|
||||
// Clear signals
|
||||
this.AncestryChanged.DisconnectAll();
|
||||
this.Changed.DisconnectAll();
|
||||
// Request replicator to remove this instance if needed
|
||||
if (dm) {
|
||||
const replicator = dm.GetService("ReplicatorService");
|
||||
if (replicator) {
|
||||
replicator.requestReplicateDestroy(this);
|
||||
} else {
|
||||
console.warn("Destroy: No ReplicatorService found in DataModel.");
|
||||
}
|
||||
} else {
|
||||
console.warn("Destroy: No DataModel found in ancestry. Are you destroying an unparented instance?");
|
||||
}
|
||||
} // Garbage collected after this
|
||||
}
|
||||
63
js/instances/LerpedBasePart.js
Normal file
63
js/instances/LerpedBasePart.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as THREE from "three";
|
||||
import { BasePart } from "./BasePart.js";
|
||||
|
||||
export class LerpedBasePart extends BasePart { // Extends BasePart to add smooth interpolation for networked parts
|
||||
constructor() {
|
||||
super();
|
||||
this.ClassName = "LerpedBasePart"; // 👈 Roblox-style ClassName
|
||||
this._lastReplicatedState = null;
|
||||
this._nextReplicatedState = null;
|
||||
}
|
||||
|
||||
// Called when replication data arrives from the server
|
||||
OnReplicated() {
|
||||
// Keep old state to interpolate from
|
||||
this._lastReplicatedState = this._nextReplicatedState;
|
||||
|
||||
// Store new replication state
|
||||
this._nextReplicatedState = {
|
||||
pos: this.Position.clone(),
|
||||
quat: new THREE.Quaternion().setFromEuler(this.Orientation),
|
||||
time: performance.now(),
|
||||
};
|
||||
|
||||
// Apply anchored state from server
|
||||
this._applyAnchoredState?.();
|
||||
this.updateSizes(); // In case Size changed
|
||||
}
|
||||
|
||||
// Smooth visual updates
|
||||
updateVisual(dt) {
|
||||
// Don’t lerp anchored parts — they don’t move
|
||||
if (this.Anchored) {
|
||||
this.mesh.position.copy(this.Position);
|
||||
this.mesh.quaternion.setFromEuler(this.Orientation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._nextReplicatedState && this._lastReplicatedState) {
|
||||
// Interpolate between last and next states
|
||||
const now = performance.now();
|
||||
const t = Math.min(
|
||||
(now - this._lastReplicatedState.time) / 100, // ~10Hz replication → 100ms
|
||||
1
|
||||
);
|
||||
|
||||
this.mesh.position.lerpVectors(
|
||||
this._lastReplicatedState.pos,
|
||||
this._nextReplicatedState.pos,
|
||||
t
|
||||
);
|
||||
this.mesh.quaternion.slerpQuaternions(
|
||||
this._lastReplicatedState.quat,
|
||||
this._nextReplicatedState.quat,
|
||||
t
|
||||
);
|
||||
} else {
|
||||
// Fallback to default behavior
|
||||
this.mesh.position.copy(this.body.position);
|
||||
this.mesh.quaternion.copy(this.body.quaternion);
|
||||
console.warn("LerpedBasePart: No replication data yet, using physics state.");
|
||||
}
|
||||
}
|
||||
}
|
||||
200
js/instances/NetworkService.js
Normal file
200
js/instances/NetworkService.js
Normal file
@@ -0,0 +1,200 @@
|
||||
let WebSocketImpl;
|
||||
let WebSocketServer;
|
||||
if (typeof window !== "undefined") {
|
||||
WebSocketImpl = WebSocket;
|
||||
} else {
|
||||
// Dynamically require 'ws' only on server side
|
||||
const wsModule = await import("ws");
|
||||
WebSocketImpl = wsModule.default;
|
||||
WebSocketServer = wsModule.WebSocketServer;
|
||||
}
|
||||
import { Instance } from "./Instance.js";
|
||||
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
|
||||
/**
|
||||
* NetworkService
|
||||
* - Handles both client & server networking
|
||||
* - Binary framed packets: [uint32 length][ptype][psub][payload]
|
||||
* - Fires PacketReceived signal for incoming packets
|
||||
*/
|
||||
export class NetworkService extends Instance {
|
||||
constructor() {
|
||||
super("NetworkService");
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isServer = false;
|
||||
|
||||
/** @type {WebSocketServer|null} */
|
||||
this.wss = null;
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
this.client = null;
|
||||
|
||||
/** @type {Map<WebSocket, any>} */
|
||||
this.clients = new Map();
|
||||
|
||||
/** @type {Map<number, Function>} */
|
||||
this.handlers = new Map();
|
||||
|
||||
/** @type {BO3ScriptSignal} */
|
||||
this.PacketReceived = new BO3ScriptSignal();
|
||||
|
||||
// Total bytes received (for stats)
|
||||
this.totalBytes = 0;
|
||||
// Interval to clear totalBytes every second
|
||||
setInterval(() => {
|
||||
this.totalBytes = 0;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === Utility Functions ===
|
||||
// ==========================
|
||||
static pktKey(ptype, psub) {
|
||||
return (ptype << 8) | psub;
|
||||
}
|
||||
|
||||
registerHandler(ptype, psub, fn) {
|
||||
const key = NetworkService.pktKey(ptype, psub);
|
||||
this.handlers.set(key, fn);
|
||||
}
|
||||
|
||||
unregisterHandler(ptype, psub) {
|
||||
const key = NetworkService.pktKey(ptype, psub);
|
||||
this.handlers.delete(key);
|
||||
}
|
||||
|
||||
invokeHandler(ptype, psub, payload, client = null) {
|
||||
const key = NetworkService.pktKey(ptype, psub);
|
||||
const handler = this.handlers.get(key);
|
||||
if (handler) handler(payload, client);
|
||||
this.PacketReceived.Fire({ ptype, psub, payload, client });
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === Packet Encode ===
|
||||
// ======================
|
||||
encodePacket(ptype, psub, payload = new Uint8Array()) {
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
if (typeof payload === "string") {
|
||||
payload = new TextEncoder().encode(payload);
|
||||
} else if (payload instanceof ArrayBuffer) {
|
||||
payload = new Uint8Array(payload);
|
||||
} else {
|
||||
throw new Error("Payload must be Uint8Array, ArrayBuffer, or string");
|
||||
}
|
||||
}
|
||||
const body = new Uint8Array(2 + payload.length);
|
||||
body[0] = ptype;
|
||||
body[1] = psub;
|
||||
body.set(payload, 2);
|
||||
|
||||
const header = new ArrayBuffer(4);
|
||||
new DataView(header).setUint32(0, body.length);
|
||||
return new Uint8Array([...new Uint8Array(header), ...body]);
|
||||
}
|
||||
|
||||
decodePacket(buffer) {
|
||||
const view = new DataView(buffer);
|
||||
const length = view.getUint32(0);
|
||||
const body = new Uint8Array(buffer, 4, length);
|
||||
const ptype = body[0];
|
||||
const psub = body[1];
|
||||
const payload = body.slice(2);
|
||||
return { ptype, psub, payload };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === Client Functionality ===
|
||||
// ============================
|
||||
async connect(url) {
|
||||
this.isServer = false;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
this.client = ws;
|
||||
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`[NetworkService] Connected to ${url}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const buf = event.data instanceof ArrayBuffer ? event.data : event.data.buffer;
|
||||
this.totalBytes += Math.max(buf.byteLength,0); // safeguard
|
||||
const { ptype, psub, payload } = this.decodePacket(buf);
|
||||
this.invokeHandler(ptype, psub, payload);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => reject(err);
|
||||
ws.onclose = () => {
|
||||
console.warn("[NetworkService] Disconnected from server.")
|
||||
alert("Disconnected from server! If this was unintentional, please refresh the page. You might have been kicked by the server.\n\n(If you are the server host, check if the servers running.)");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
send(ptype, psub, payload) {
|
||||
if (!this.client || this.client.readyState !== WebSocket.OPEN) return;
|
||||
const packet = this.encodePacket(ptype, psub, payload);
|
||||
this.client.send(packet);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === Server Functionality ===
|
||||
// ============================
|
||||
listen(port = 3000, host = "0.0.0.0") {
|
||||
this.isServer = true;
|
||||
this.wss = new WebSocketServer({ port, host });
|
||||
console.log(`[NetworkService] Listening on ws://${host}:${port}`);
|
||||
|
||||
this.wss.on("connection", (ws) => {
|
||||
this.clients.set(ws, {});
|
||||
console.log("[NetworkService] New client connected. Total clients:", this.clients.size);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.on("message", (msg) => {
|
||||
const buf = msg instanceof ArrayBuffer ? msg : msg.buffer;
|
||||
const { ptype, psub, payload } = this.decodePacket(buf);
|
||||
this.invokeHandler(ptype, psub, payload, ws);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.clients.delete(ws)
|
||||
console.log("[NetworkService] Client disconnected. Total clients:", this.clients.size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(ptype, psub, payload) {
|
||||
if (!this.isServer || !this.wss) return;
|
||||
const packet = this.encodePacket(ptype, psub, payload);
|
||||
for (const ws of this.clients.keys()) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
|
||||
}
|
||||
}
|
||||
|
||||
sendToClient(ws, ptype, psub, payload) {
|
||||
if (!this.isServer) return;
|
||||
const packet = this.encodePacket(ptype, psub, payload);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
|
||||
}
|
||||
|
||||
Broadcast(ptype, psub, payload) {
|
||||
console.warn("[NetworkService] Broadcast is deprecated, use broadcast() instead.");
|
||||
this.broadcast(ptype, psub, payload);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === Shutdown ===
|
||||
// ============================
|
||||
close() {
|
||||
if (this.isServer && this.wss) {
|
||||
for (const ws of this.clients.keys()) ws.close();
|
||||
this.wss.close();
|
||||
} else if (this.client) {
|
||||
this.client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
185
js/instances/RenderService.js
Normal file
185
js/instances/RenderService.js
Normal file
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
232
js/instances/ReplicatorService.js
Normal file
232
js/instances/ReplicatorService.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
import { sha256, encoders } from "../core/util.js"; // Since SubtleCrypto isnt available in http only, we use a JS implementation
|
||||
export class ReplicatorService extends Instance {
|
||||
constructor(datamodel, network) {
|
||||
super("ReplicatorService");
|
||||
// Get tickloop from RenderService
|
||||
this._renderService = datamodel.GetService("RenderService");
|
||||
if (!this._renderService) {
|
||||
throw new Error("ReplicatorService requires RenderService in DataModel");
|
||||
}
|
||||
this._networkService = network;
|
||||
if (!this._networkService) {
|
||||
throw new Error("ReplicatorService requires NetworkService in DataModel");
|
||||
}
|
||||
this.bytesSent = 0; // For client stats
|
||||
this.bytesReceived = 0; // For client stats
|
||||
this.frameCounter = 0; // To prevent spamming the replication, run it every 10 frames
|
||||
if (!this._networkService.isServer) {
|
||||
this._renderService.Heartbeat.Connect((dt) => this._onHeartbeatCli(dt));
|
||||
this._networkService.registerHandler(0x00, 0x02, (payload) => {
|
||||
this.bytesReceived += payload.byteLength + 2; // +2 for ptype and psub
|
||||
const data = JSON.parse(new TextDecoder().decode(payload));
|
||||
// Find instance by InstanceId using FindInstanceRecursively on datamodel
|
||||
const inst = this.Parent.FindInstanceRecursively(data.InstanceId);
|
||||
if (!inst) {
|
||||
// Instance not found, create a new one
|
||||
import(`./${data.ClassName}.js`).then((module) => {
|
||||
const NewClass = module[data.ClassName];
|
||||
if (!NewClass) {
|
||||
console.warn(`ReplicatorService: Class ${data.ClassName} not found for replication.`);
|
||||
return;
|
||||
}
|
||||
const newInst = new NewClass();
|
||||
newInst.InstanceId = data.InstanceId; // Set the same InstanceId
|
||||
newInst.Name = data.Name;
|
||||
// Set properties
|
||||
for (const [key, value] of Object.entries(data.Properties)) {
|
||||
if (value && typeof value === 'object' && value._type && encoders[value._type]) {
|
||||
newInst[key] = encoders[value._type].decode(value.value);
|
||||
} else {
|
||||
newInst[key] = value;
|
||||
}
|
||||
}
|
||||
newInst.IsReplicated = true; // Mark as replicated to disable physics etc if needed
|
||||
console.debug('ReplicatorService: Received new instance!');
|
||||
// Set parent
|
||||
if (data.ParentId) {
|
||||
// Find parent by InstanceId using FindInstanceRecursively
|
||||
const parentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId);
|
||||
if (parentInst) {
|
||||
newInst.SetParent(parentInst);
|
||||
} else {
|
||||
console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found. Double check server code.`);
|
||||
}
|
||||
newInst.OnReplicated(); // Call hook for when instance is replicated
|
||||
} else {
|
||||
console.warn(`ReplicatorService: No ParentId provided for instance ${data.InstanceId}! Double check server code.`);
|
||||
}
|
||||
console.log(`ReplicatorService: Created new instance ${newInst.Name} (${newInst.InstanceId}) from server.`);
|
||||
}).catch(err => {
|
||||
console.error(`ReplicatorService: Failed to load class ${data.ClassName} for replication.`, err);
|
||||
});
|
||||
} else {
|
||||
// Instance found, update properties
|
||||
for (const [key, value] of Object.entries(data.Properties)) {
|
||||
if (value && typeof value === 'object' && value._type && encoders[value._type]) {
|
||||
inst[key] = encoders[value._type].decode(value.value);
|
||||
} else {
|
||||
inst[key] = value;
|
||||
}
|
||||
}
|
||||
// Handle destruction if ParentId is not provided (instance destroyed)
|
||||
if (!data.ParentId) {
|
||||
// Remove instance from parent/children and data model
|
||||
if (typeof inst.Destroy === 'function') {
|
||||
inst.Destroy();
|
||||
}
|
||||
console.log(`ReplicatorService: Instance ${inst.Name} (${inst.InstanceId}) destroyed (ParentId is nil).`);
|
||||
return;
|
||||
}
|
||||
// Update parent if changed
|
||||
if (data.ParentId) {
|
||||
if (!inst.Parent || inst.Parent.InstanceId !== data.ParentId) {
|
||||
// Find new parent by InstanceId using FindInstanceRecursively
|
||||
const newParentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId);
|
||||
if (newParentInst) {
|
||||
inst.SetParent(newParentInst);
|
||||
} else {
|
||||
console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found for existing instance. Double check server code.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`ReplicatorService: No ParentId provided for existing instance ${data.InstanceId}! Double check server code.`);
|
||||
}
|
||||
inst.OnReplicated(); // Call hook for when instance is replicated
|
||||
//console.log(`ReplicatorService: Updated instance ${inst.Name} (${inst.InstanceId}) from server.`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this._renderService.Heartbeat.Connect((dt) => this._onHeartbeat(dt));
|
||||
}
|
||||
this.SetParent(datamodel);
|
||||
this.lastClearedStatCheck = 0; // If above 1 second, clear stats
|
||||
console.log("ReplicatorService initialized.");
|
||||
}
|
||||
// Client-side heartbeat, receive updates from server
|
||||
_onHeartbeatCli(delta) {
|
||||
this.lastClearedStatCheck += delta;
|
||||
if (this.lastClearedStatCheck >= 1) {
|
||||
console.log(`Client stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`);
|
||||
this.bytesSent = 0;
|
||||
this.bytesReceived = 0;
|
||||
this.lastClearedStatCheck = 0;
|
||||
}
|
||||
}
|
||||
_onHeartbeat(delta) {
|
||||
// Clear stats every second
|
||||
this.lastClearedStatCheck += delta;
|
||||
if (this.lastClearedStatCheck >= 1) { console.log(`Server stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`);
|
||||
this.bytesSent = 0;
|
||||
this.bytesReceived = 0;
|
||||
this.lastClearedStatCheck = 0;
|
||||
|
||||
}
|
||||
this.frameCounter += delta;
|
||||
if (this.frameCounter < (1/30)) { // Run replication at ~30fps max
|
||||
return;
|
||||
} else {
|
||||
this.frameCounter = 0;
|
||||
};
|
||||
// For each instance in valid services, replicate its state to clients. Keep old state hash to avoid spamming unchanged data.
|
||||
const toReplicate = [];
|
||||
// Get workspace and its children
|
||||
const workspace = this.Parent.GetService("Workspace");
|
||||
if (workspace) {
|
||||
toReplicate.push(...workspace.Children);
|
||||
}
|
||||
// Send over the pipe
|
||||
for (const inst of toReplicate) {
|
||||
// Check property hashes to see if anything changed
|
||||
let hashState = "";
|
||||
for (const key of Object.keys(inst)) {
|
||||
if (key === "Parent" || key === "Children") continue;
|
||||
|
||||
let val;
|
||||
try {
|
||||
val = JSON.stringify(inst[key]);
|
||||
} catch {
|
||||
val = "[unserializable]";
|
||||
}
|
||||
|
||||
hashState += `${key}:${val};`;
|
||||
}
|
||||
|
||||
const hash = sha256(hashState);
|
||||
if (inst._lastHash !== hash) {
|
||||
// Something changed, replicate
|
||||
inst._lastHash = hash;
|
||||
const payload = {
|
||||
InstanceId: inst.InstanceId,
|
||||
ClassName: inst.ClassName,
|
||||
Name: inst.Name,
|
||||
Properties: {},
|
||||
ParentId: inst.Parent ? inst.Parent.InstanceId : null,
|
||||
};
|
||||
for (const key of Object.keys(inst)) {
|
||||
// Skip internals and heavy engine data
|
||||
if (
|
||||
key.startsWith("_") || // private/internal fields
|
||||
key === "Parent" ||
|
||||
key === "Children" ||
|
||||
key === "mesh" ||
|
||||
key === "body" ||
|
||||
key === "renderer" ||
|
||||
key === "scene" ||
|
||||
key === "controls" ||
|
||||
key === "physicsWorld" ||
|
||||
key === "loader"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get type
|
||||
const val = inst[key];
|
||||
const type = val && val.constructor ? val.constructor.name : typeof val;
|
||||
if (type === "function" || type === "undefined" || type === "symbol" || type === "BO3ScriptSignal") {
|
||||
continue; // skip non-serializable types
|
||||
}
|
||||
// If we have an encoder, encode and store type info
|
||||
if (encoders[type]) {
|
||||
payload.Properties[key] = { _type: type, value: encoders[type].encode(val) };
|
||||
} else {
|
||||
payload.Properties[key] = val;
|
||||
}
|
||||
|
||||
}
|
||||
//console.debug(JSON.stringify(payload));
|
||||
|
||||
// Send to all clients
|
||||
let outpay = JSON.stringify(payload);
|
||||
this.bytesSent += outpay.length + 2; // +2 for ptype and psub
|
||||
this._networkService.broadcast(0x00, 0x02, outpay); // Refer to ptype and psub definitions to see what these mean
|
||||
// Update the _lastHash property to avoid re-sending unchanged data
|
||||
inst._lastHash = hash;
|
||||
//console.log(`Replicated instance ${inst.Name} (${inst.InstanceId}) to clients.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestReplicateDestroy(instance,ignoreIS) { // Server side function to request destruction of an instance on clients
|
||||
if (!this._networkService.isServer) {
|
||||
if (!ignoreIS) console.warn("ReplicatorService: requestReplicateDestroy can only be called on server.");
|
||||
// ignoreIS is for internal server calls to avoid spamming the warning
|
||||
return;
|
||||
}
|
||||
if (!instance || !instance.InstanceId) {
|
||||
console.warn("ReplicatorService: requestReplicateDestroy requires a valid instance with InstanceId.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
InstanceId: instance.InstanceId,
|
||||
ClassName: instance.ClassName,
|
||||
Name: instance.Name,
|
||||
Properties: {}, // No properties needed for destruction
|
||||
ParentId: null, // Null ParentId indicates destruction
|
||||
};
|
||||
let outpay = JSON.stringify(payload);
|
||||
this.bytesSent += outpay.length + 2; // +2 for ptype and psub
|
||||
this._networkService.broadcast(0x00, 0x02, outpay); // Refer to ptype and psub definitions to see what these mean
|
||||
console.log(`ReplicatorService: Requested destruction of instance ${instance.Name} (${instance.InstanceId}) on clients.`);
|
||||
}
|
||||
}
|
||||
8
js/instances/Workspace.js
Normal file
8
js/instances/Workspace.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
export class Workspace extends Instance {
|
||||
constructor() {
|
||||
super("Workspace");
|
||||
this.InstanceId = "WorkspaceRoot"; // Fixed ID for Workspace (Bad idea to replicate services over network?)
|
||||
// Most likely: yes, but we can filter them out in ReplicatorService
|
||||
}
|
||||
} // Workspace is the root container for all 3D objects in the scene
|
||||
72
js/servermain.js
Normal file
72
js/servermain.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NetworkService } from "./instances/NetworkService.js";
|
||||
import { RenderService } from "./instances/RenderService.js";
|
||||
import { ReplicatorService } from "./instances/ReplicatorService.js";
|
||||
import { DataModel } from "./core/DataModel.js";
|
||||
import { Workspace } from "./instances/Workspace.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
const dm = new DataModel();
|
||||
dm.SetParent(null);
|
||||
|
||||
// === Workspace ===
|
||||
const ws = new Workspace();
|
||||
ws.SetParent(dm);
|
||||
|
||||
// === RenderService (headless physics) ===
|
||||
const render = new RenderService(dm);
|
||||
render.isServer = true;
|
||||
render.start();
|
||||
render.SetParent(dm);
|
||||
|
||||
// === Networking ===
|
||||
const net = new NetworkService();
|
||||
net.listen(8080);
|
||||
net.SetParent(dm);
|
||||
|
||||
// Simple echo handler
|
||||
net.registerHandler(0x01, 0x00, (payload, client) => {
|
||||
console.log("Received from client:", new TextDecoder().decode(payload));
|
||||
net.sendToClient(client, 0x01, 0x01, payload);
|
||||
});
|
||||
|
||||
// === Replication Service ===
|
||||
const replication = new ReplicatorService(dm, net);
|
||||
|
||||
// === Test parts ===
|
||||
const { LerpedBasePart } = await import("./instances/LerpedBasePart.js");
|
||||
|
||||
// Falling cube
|
||||
// Anchored ground
|
||||
const ground = new LerpedBasePart();
|
||||
ground.Size = new THREE.Vector3(10, 1, 10);
|
||||
ground.Position.set(0, -0.5, 0);
|
||||
ground.setAnchored(true);
|
||||
ground.SetParent(ws);
|
||||
ground.updateSizes();
|
||||
// Spin ground
|
||||
let angle = 0;
|
||||
setInterval(() => {
|
||||
angle += 0.01;
|
||||
ground.Orientation.set(0, angle, 0);
|
||||
ground.Position.set(5 * Math.sin(angle * 2), -0.5, 5 * Math.cos(angle * 2));
|
||||
}, 16);
|
||||
/*setInterval(() => {
|
||||
const part = new LerpedBasePart();
|
||||
part.Position.set(0, 35, 0);
|
||||
part.SetParent(ws);
|
||||
part.setAnchored(true);
|
||||
part.updateVisual(1 / 60); // Ensure physics is initialized
|
||||
setTimeout(() => {
|
||||
part.updateBodyPosition(); // Reset physics to new position
|
||||
part.setAnchored(false);
|
||||
}, 2000);
|
||||
part.updateSizes();
|
||||
}, 1000);*/
|
||||
// Falling cube singularity
|
||||
const part = new LerpedBasePart();
|
||||
part.Size = new THREE.Vector3(1, 1, 1);
|
||||
part.Position.set(10, 5, 0);
|
||||
part.setAnchored(false);
|
||||
part.SetParent(ws);
|
||||
part.updateSizes();
|
||||
|
||||
10
jsconfig.json
Normal file
10
jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// other options...
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"three/webgpu": ["node_modules/three/build/three.webgpu.js"],
|
||||
"three/tsl": ["node_modules/three/build/three.tsl.js"],
|
||||
},
|
||||
}
|
||||
}
|
||||
14
netcodedocs/ptype-psub-defs.md
Normal file
14
netcodedocs/ptype-psub-defs.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# PType and PSub Definitions
|
||||
|
||||
A BO3 GameServer packet has a 2 byte header.
|
||||
Byte 1 is PType (packet type), which is used to check whether its serverbound or clientbound, however nonstandard BO3 servers can have the PType above 0x01
|
||||
Byte 2 is PSub (packet subtype), which is the actual packet name. Each ptype can have 256 different subtypes
|
||||
|
||||
## PType
|
||||
0x00 = Clientbound (should never be sent by a client)
|
||||
0x01 = Serverbound (should never be recieved by a client)
|
||||
|
||||
## PSub
|
||||
|
||||
0x02 = Replication channel
|
||||
0x01 = Echo (Server will return what you send)
|
||||
1098
package-lock.json
generated
Normal file
1098
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "jsrobloxclone",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"cannon-es": "^0.20.0",
|
||||
"three": "^0.180.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.1.9"
|
||||
},
|
||||
"type":"module"
|
||||
}
|
||||
Reference in New Issue
Block a user