232 lines
12 KiB
JavaScript
232 lines
12 KiB
JavaScript
|
|
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.`);
|
||
|
|
}
|
||
|
|
}
|