Files
bo3-js/js/instances/ReplicatorService.js

249 lines
13 KiB
JavaScript
Raw Normal View History

2025-10-12 18:03:20 +02:00
import { Instance } from "./Instance.js";
2025-10-23 20:58:19 +02:00
import { BaseService } from "./BaseService.js";
2025-10-12 18:03:20 +02:00
import { sha256, encoders } from "../core/util.js"; // Since SubtleCrypto isnt available in http only, we use a JS implementation
2025-10-23 20:58:19 +02:00
export class ReplicatorService extends BaseService {
2025-10-12 18:03:20 +02:00
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));
2025-10-23 20:58:19 +02:00
this._networkService.registerHandler(0x01, 0x02, (payload,ws) => {
}); // Server side replicator reciever, will be used later for client to server replication
// aka what roblox does for certain instances like under the player character
2025-10-12 18:03:20 +02:00
}
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");
2025-10-23 20:58:19 +02:00
const players = this.Parent.GetService("Players");
2025-10-12 18:03:20 +02:00
if (workspace) {
toReplicate.push(...workspace.Children);
}
2025-10-23 20:58:19 +02:00
if (players) {
toReplicate.push(...players.Children); // Replicate player instances too
// (so players can see each other)
}
2025-10-12 18:03:20 +02:00
// 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" ||
2025-10-23 20:58:19 +02:00
key === "loader" ||
key === "LuaState" // Script Lua state
2025-10-12 18:03:20 +02:00
) {
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
2025-10-23 20:58:19 +02:00
this._networkService.broadcast(0x00, 0x02, outpay,true,(ws) => {
const player = players ? players.getByWs(ws) : null;
// Dont send to the player if its their own character
if (player && inst.InstanceId === player.InstanceId) {
return true;
}
return false;
}); // Refer to ptype and psub definitions to see what these mean
2025-10-12 18:03:20 +02:00
// 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.`);
}
}