Add launchable
This commit is contained in:
@@ -5,7 +5,7 @@ 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
|
||||
|
||||
import { Players } from "./instances/Players.js";
|
||||
const dm = new DataModel();
|
||||
dm.SetParent(null); // root
|
||||
|
||||
@@ -16,6 +16,11 @@ ws.SetParent(dm);
|
||||
const net = new NetworkService();
|
||||
net.SetParent(dm);
|
||||
net.isServer = false; // client mode
|
||||
|
||||
// Create a players container (client-side crippled version)
|
||||
const players = new Players(net, true);
|
||||
players.SetParent(dm);
|
||||
|
||||
await net.connect("ws://localhost:8080"); // connect to server
|
||||
|
||||
const render = new RenderService(dm);
|
||||
@@ -27,4 +32,17 @@ const replication = new ReplicatorService(dm, net); // Automatically parents to
|
||||
|
||||
// Create DebugTextService
|
||||
const debugText = new DebugTextService();
|
||||
debugText.SetParent(dm);
|
||||
debugText.SetParent(dm);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Prompting for token...");
|
||||
let sessionToken = prompt("Enter your session token: (any string will do for this test except 'fake')");
|
||||
console.log("Got token:", sessionToken);
|
||||
if (sessionToken) {
|
||||
const encoder = new TextEncoder();
|
||||
const payload = encoder.encode(sessionToken);
|
||||
net.send(0x01, 0x03, payload);
|
||||
} else {
|
||||
console.warn("No token entered — not sending packet.");
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
36
js/core/Auth.js
Normal file
36
js/core/Auth.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export class Auth {
|
||||
constructor(baseUrl = "http://localhost:5000") {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a session token against the fake auth API.
|
||||
* Returns the user ID if valid, or null if invalid.
|
||||
*/
|
||||
async verifySessionToken(token) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/verify/${encodeURIComponent(token)}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.id ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[Auth] verifySessionToken error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the user's display name by ID.
|
||||
*/
|
||||
async getUserNameById(id) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/users`);
|
||||
if (!res.ok) return "Unknown";
|
||||
const data = await res.json();
|
||||
return data[id] ?? `User${id}`;
|
||||
} catch (err) {
|
||||
console.warn("[Auth] getUserNameById error:", err);
|
||||
return `User${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
js/core/AuthStub.js
Normal file
28
js/core/AuthStub.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// For whoever is going to use this engine later, please modify this file to actually do authentication.
|
||||
|
||||
export class Auth {
|
||||
/**
|
||||
* Verifies a session token and returns a user ID.
|
||||
* In this dummy version, it just returns a random numeric ID.
|
||||
*
|
||||
* @param {string} token - The player's session token.
|
||||
* @returns {number|null} - The verified user ID, or null if invalid.
|
||||
*/
|
||||
verifySessionToken(token) {
|
||||
// Dummy implementation: always "verifies" successfully.
|
||||
if (token === "fake") return null; // Simulate invalid token
|
||||
return Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user's display name by their ID.
|
||||
* Replace this with a real lookup (e.g., database or API call).
|
||||
*
|
||||
* @param {number|string} userId - The player's user ID.
|
||||
* @returns {string} - The player's display name.
|
||||
*/
|
||||
getUserNameById(userId) {
|
||||
// Dummy implementation
|
||||
return `User${userId}`;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ export class DataModel extends Instance {
|
||||
return this.Children.find(c => c.ClassName === name) || null;
|
||||
}
|
||||
GetDataModel() {return this;} // Override to return self
|
||||
|
||||
LuaBridge () {
|
||||
const obj = super.LuaBridge();
|
||||
obj.GetService = (name) => {
|
||||
if (name === "RunService") name = "RenderService"; // Alias
|
||||
const service = this.GetService(name);
|
||||
return service ? service.LuaBridge() : null;
|
||||
}
|
||||
}
|
||||
|
||||
} // 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
|
||||
@@ -134,6 +134,7 @@ function eulerDecoder(arr) { // Float32Array(3) to THREE.Euler
|
||||
const encoders = {
|
||||
Vector3: { encode: v3Encoder, decode: v3Decoder },
|
||||
Euler: { encode: eulerEncoder, decode: eulerDecoder },
|
||||
bool: { encode: (bool)=>bool, decode:(bool)=>bool}
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
|
||||
13
js/instances/BaseService.js
Normal file
13
js/instances/BaseService.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
export class BaseService extends Instance {
|
||||
constructor(name) {
|
||||
super(name);
|
||||
}
|
||||
SetParent(parent) {
|
||||
if (this.Parent === null) {
|
||||
super.SetParent(parent);
|
||||
return;
|
||||
} // Only accept it once
|
||||
throw new Error("Cannot reparent a service instance.");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
import { BaseService } from "./BaseService.js";
|
||||
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
|
||||
export class DebugTextService extends Instance {
|
||||
export class DebugTextService extends BaseService {
|
||||
constructor() {
|
||||
super("DebugTextService");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
export class ExampleService extends Instance {
|
||||
import { BaseService } from "./BaseService.js";
|
||||
export class ExampleService extends BaseService {
|
||||
constructor() {
|
||||
super("ExampleService");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ let instanceCounter = 0;
|
||||
export class Instance {
|
||||
constructor(className = "Instance") {
|
||||
this.ClassName = className;
|
||||
this.Name = `${className}${instanceCounter++}`;
|
||||
this.Name = `${className}`;
|
||||
this.Parent = null;
|
||||
this.Children = [];
|
||||
this.InstanceId = guidGenerator(); // Unique identifier, used for replication
|
||||
@@ -120,4 +120,21 @@ export class Instance {
|
||||
console.warn("Destroy: No DataModel found in ancestry. Are you destroying an unparented instance?");
|
||||
}
|
||||
} // Garbage collected after this
|
||||
|
||||
LuaBridge(exclude, visited = new Set()) { // Convert to a plain object for LuaBridge. Should be overridden in subclasses to add properties.
|
||||
if (visited.has(this.InstanceId)) {
|
||||
// Already visited, avoid circular reference
|
||||
return null;
|
||||
}
|
||||
visited.add(this.InstanceId);
|
||||
const obj = {
|
||||
ClassName: this.ClassName,
|
||||
Name: this.Name,
|
||||
InstanceId: this.InstanceId,
|
||||
Properties: {},
|
||||
Children: this.Children.filter(c => c !== exclude).map(c => c.LuaBridge(this, visited)).filter(Boolean),
|
||||
Parent: this.Parent && this.Parent !== exclude ? this.Parent.LuaBridge(this, visited) : null // Avoid circular reference by excluding self
|
||||
};
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ if (typeof window !== "undefined") {
|
||||
WebSocketServer = wsModule.WebSocketServer;
|
||||
}
|
||||
import { Instance } from "./Instance.js";
|
||||
import { BaseService } from "./BaseService.js";
|
||||
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
|
||||
* - Binary framed packets: [uint32 length][ptype][psub][payload]
|
||||
* - Fires PacketReceived signal for incoming packets
|
||||
*/
|
||||
export class NetworkService extends Instance {
|
||||
export class NetworkService extends BaseService {
|
||||
constructor() {
|
||||
super("NetworkService");
|
||||
|
||||
@@ -132,6 +133,17 @@ export class NetworkService extends Instance {
|
||||
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.)");
|
||||
};
|
||||
// Register 0xFF kick handler
|
||||
this.registerHandler(0x00, 0xFF, (payload) => {
|
||||
let reason = "Kicked by server.";
|
||||
try {
|
||||
reason = new TextDecoder().decode(payload);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
console.warn("[NetworkService] Kicked by server:", reason);
|
||||
alert("You have been kicked by the server:\n\n" + reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +163,7 @@ export class NetworkService extends Instance {
|
||||
|
||||
this.wss.on("connection", (ws) => {
|
||||
this.clients.set(ws, {});
|
||||
ws.isAuthed = false; // Determine whether to send any data except auth packets
|
||||
console.log("[NetworkService] New client connected. Total clients:", this.clients.size);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -167,10 +180,12 @@ export class NetworkService extends Instance {
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(ptype, psub, payload) {
|
||||
broadcast(ptype, psub, payload,ignoreUnauthed,ignoreCheck) {
|
||||
if (!this.isServer || !this.wss) return;
|
||||
const packet = this.encodePacket(ptype, psub, payload);
|
||||
for (const ws of this.clients.keys()) {
|
||||
if(ignoreUnauthed && !ws.isAuthed) continue;
|
||||
if (ignoreCheck && ignoreCheck(ws)) continue;
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
|
||||
}
|
||||
}
|
||||
@@ -181,6 +196,17 @@ export class NetworkService extends Instance {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
|
||||
}
|
||||
|
||||
KickClientLater(ws,code=4000,reason="") {
|
||||
// Send a kick packet
|
||||
this.sendToClient(ws, 0x00, 0xFF, new TextEncoder().encode(reason));
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(code,reason);
|
||||
}
|
||||
}, 500); // Slight delay to ensure any pending packets are sent
|
||||
}
|
||||
|
||||
|
||||
Broadcast(ptype, psub, payload) {
|
||||
console.warn("[NetworkService] Broadcast is deprecated, use broadcast() instead.");
|
||||
this.broadcast(ptype, psub, payload);
|
||||
|
||||
11
js/instances/Player.js
Normal file
11
js/instances/Player.js
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Instance } from "./Instance.js";
|
||||
export class Player extends Instance {
|
||||
constructor( id, name, ws ) {
|
||||
super("Player");
|
||||
this.InstanceId = id; // To fix jank with replication system
|
||||
this.UserId = id; // For compatibility
|
||||
this.Name = name;
|
||||
this.ws = ws; // WebSocket or client reference
|
||||
}
|
||||
}
|
||||
105
js/instances/Players.js
Normal file
105
js/instances/Players.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NetworkService } from "./NetworkService.js";
|
||||
import { Player } from "./Player.js";
|
||||
import { BaseService } from "./BaseService.js";
|
||||
import { Auth } from "../core/Auth.js";
|
||||
/**
|
||||
* Players manager
|
||||
* - Tracks connected players after successful auth (0x01 0x03)
|
||||
*/
|
||||
export class Players extends BaseService {
|
||||
constructor(networkService,isClient) {
|
||||
super("Players");
|
||||
this.InstanceId = "Players"; // For replication system
|
||||
if (isClient) {
|
||||
this.networkService = networkService;
|
||||
this.networkService.registerHandler(0x00, 0x04, (payload) => {
|
||||
// Auth response from server, parse it
|
||||
const decoder = new TextDecoder();
|
||||
const msg = decoder.decode(payload);
|
||||
console.debug("[Players] Received auth response from server:", msg);
|
||||
if (msg.startsWith("AUTH_SUCCESS|")) {
|
||||
const playerName = msg.split("|")[1];
|
||||
const id = msg.split("|")[2];
|
||||
console.debug(`[Players] You have logged in as ${playerName}`);
|
||||
// Create a local Player instance
|
||||
const player = new Player( id, playerName, null );
|
||||
this.players = new Map();
|
||||
this.players.set(null, player); // single local player
|
||||
this.LocalPlayer = player;
|
||||
player.SetParent(this);
|
||||
} else {
|
||||
console.warn("[Players] Auth failed or unknown response:", msg);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.networkService = networkService;
|
||||
this.players = new Map(); // id -> Player
|
||||
this.authenticator = new Auth();
|
||||
|
||||
// Listen for auth packets
|
||||
this.networkService.registerHandler(0x01, 0x03, async (payload, ws) => {
|
||||
if (ws.isAuthed) return; // Already authed, ignore additional auth attempts
|
||||
console.debug("[Players] Received auth packet:", "CENSORED");
|
||||
// Assume payload is a string (player name)
|
||||
let token;
|
||||
try {
|
||||
// Decode bytes
|
||||
const decoder = new TextDecoder();
|
||||
token = decoder.decode(payload);
|
||||
} catch (e) {
|
||||
console.warn("[Players] Invalid auth payload:", payload);
|
||||
networkService.KickClientLater(ws, 4000, "Invalid auth payload, please try again.");
|
||||
|
||||
return;
|
||||
}
|
||||
// Verify token (dummy implementation)
|
||||
const id = await this.authenticator.verifySessionToken(token);
|
||||
if (id === null) {
|
||||
// Kick off the client
|
||||
networkService.KickClientLater(ws, 4001, "Authentication failed; are you logged in?");
|
||||
// Reason must be understandable as its shown to the user
|
||||
return;
|
||||
}
|
||||
console.debug(`[Players] Client authenticated as ${id}`);
|
||||
ws.isAuthed = true; // Mark ws as authed
|
||||
// Create Player instance
|
||||
const name = await this.authenticator.getUserNameById(id);
|
||||
const player = new Player(id, name, ws );
|
||||
this.players.set(ws, player);
|
||||
player.SetParent(this);
|
||||
// Listen for disconnection
|
||||
ws.on("close", () => {
|
||||
this.players.delete(ws);
|
||||
player.Destroy();
|
||||
console.debug(`[Players] Player ${name} (${id}) disconnected.`);
|
||||
});
|
||||
console.debug(`[Players] Player ${name} (${id}) connected.`);
|
||||
// Send auth success packet
|
||||
const encoder = new TextEncoder();
|
||||
const successPayload = encoder.encode("AUTH_SUCCESS|" + name + "|" + id);
|
||||
this.networkService.sendToClient(ws, 0x00, 0x04, successPayload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected players
|
||||
*/
|
||||
getAll() {
|
||||
return Array.from(this.players.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove player by ws
|
||||
*/
|
||||
removeByWs(ws) {
|
||||
this.players.delete(ws);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player by ws
|
||||
*/
|
||||
getByWs(ws) {
|
||||
return this.players.get(ws);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ 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 {
|
||||
import { BaseService } from "./BaseService.js";
|
||||
export class RenderService extends BaseService {
|
||||
constructor() {
|
||||
super("RenderService");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Instance } from "./Instance.js";
|
||||
import { BaseService } from "./BaseService.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 {
|
||||
export class ReplicatorService extends BaseService {
|
||||
constructor(datamodel, network) {
|
||||
super("ReplicatorService");
|
||||
// Get tickloop from RenderService
|
||||
@@ -98,6 +99,9 @@ export class ReplicatorService extends Instance {
|
||||
});
|
||||
} else {
|
||||
this._renderService.Heartbeat.Connect((dt) => this._onHeartbeat(dt));
|
||||
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
|
||||
}
|
||||
this.SetParent(datamodel);
|
||||
this.lastClearedStatCheck = 0; // If above 1 second, clear stats
|
||||
@@ -132,9 +136,14 @@ export class ReplicatorService extends Instance {
|
||||
const toReplicate = [];
|
||||
// Get workspace and its children
|
||||
const workspace = this.Parent.GetService("Workspace");
|
||||
const players = this.Parent.GetService("Players");
|
||||
if (workspace) {
|
||||
toReplicate.push(...workspace.Children);
|
||||
}
|
||||
if (players) {
|
||||
toReplicate.push(...players.Children); // Replicate player instances too
|
||||
// (so players can see each other)
|
||||
}
|
||||
// Send over the pipe
|
||||
for (const inst of toReplicate) {
|
||||
// Check property hashes to see if anything changed
|
||||
@@ -175,7 +184,8 @@ export class ReplicatorService extends Instance {
|
||||
key === "scene" ||
|
||||
key === "controls" ||
|
||||
key === "physicsWorld" ||
|
||||
key === "loader"
|
||||
key === "loader" ||
|
||||
key === "LuaState" // Script Lua state
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -199,7 +209,14 @@ export class ReplicatorService extends Instance {
|
||||
// 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
|
||||
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
|
||||
// Update the _lastHash property to avoid re-sending unchanged data
|
||||
inst._lastHash = hash;
|
||||
//console.log(`Replicated instance ${inst.Name} (${inst.InstanceId}) to clients.`);
|
||||
|
||||
63
js/instances/Script.js
Normal file
63
js/instances/Script.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { lua, lauxlib, lualib, to_luastring } from "fengari";
|
||||
import { ScriptSchedulerService } from "./ScriptSchedulerService.js";
|
||||
import { Instance } from "./Instance.js"; // your engine's instance class
|
||||
|
||||
// Import interop from fengari-interop
|
||||
import { push, luaopen_js } from "fengari-interop";
|
||||
|
||||
export class Script extends Instance {
|
||||
constructor(sourceCode) {
|
||||
super("Script");
|
||||
this.Source = sourceCode;
|
||||
this.LuaState = null;
|
||||
}
|
||||
|
||||
Run() {
|
||||
const L = lauxlib.luaL_newstate();
|
||||
luaopen_js(L);
|
||||
lualib.luaL_openlibs(L);
|
||||
|
||||
// Bridge all engine APIs
|
||||
this._registerGlobals(L);
|
||||
|
||||
// Compile the source
|
||||
const status = lauxlib.luaL_loadstring(L, to_luastring(this.Source));
|
||||
if (status !== lua.LUA_OK) {
|
||||
const err = lua.lua_tojsstring(L, -1);
|
||||
throw new Error("Lua load error: " + err);
|
||||
}
|
||||
|
||||
// Create a coroutine thread
|
||||
const thread = lua.lua_newthread(L);
|
||||
|
||||
// Schedule with scheduler
|
||||
this._getScheduler().scheduleFengariCoroutine(thread, L);
|
||||
this.LuaState = L;
|
||||
}
|
||||
|
||||
_registerGlobals(L) {
|
||||
// game global
|
||||
push(L, globalThis.game);
|
||||
lua.lua_setglobal(L, to_luastring("game"));
|
||||
|
||||
// task + wait bridge
|
||||
lua.lua_pushjsfunction(L, () => {
|
||||
const secs = lua.lua_isnumber(L, 1) ? lua.lua_tonumber(L, 1) : 0;
|
||||
const yieldVal = JSON.stringify({ wait: secs });
|
||||
lua.lua_pushstring(L, to_luastring(yieldVal));
|
||||
return lua.lua_yield(L, 1);
|
||||
});
|
||||
lua.lua_setglobal(L, to_luastring("task_wait"));
|
||||
|
||||
// Lua API helpers
|
||||
lua.lua_pushjsfunction(L, () => {
|
||||
const inst = new Instance("Part"); // example
|
||||
return inst.LuaBridge(); // converts to Lua table/proxy
|
||||
});
|
||||
lua.lua_setglobal(L, to_luastring("Instance_new"));
|
||||
}
|
||||
|
||||
_getScheduler() {
|
||||
return game.GetService("ScriptSchedulerService");
|
||||
}
|
||||
}
|
||||
111
js/instances/ScriptSchedulerService.js
Normal file
111
js/instances/ScriptSchedulerService.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { lua, lauxlib, lualib, to_luastring } from "fengari";
|
||||
const lua_resume = lua.lua_resume;
|
||||
const LUA_OK = lua.LUA_OK;
|
||||
const LUA_YIELD = lua.LUA_YIELD;
|
||||
import { BaseService } from "./BaseService.js";
|
||||
export class ScriptSchedulerService extends BaseService {
|
||||
constructor() {
|
||||
super("ScriptSchedulerService");
|
||||
this._scheduled = [];
|
||||
this._waiting = [];
|
||||
this._running = false;
|
||||
}
|
||||
|
||||
// Add a script (generator function) to the scheduler
|
||||
schedule(scriptGen) {
|
||||
if (typeof scriptGen === "function") {
|
||||
const gen = scriptGen();
|
||||
this._scheduled.push({ gen, wakeTime: 0 });
|
||||
} else if (scriptGen && typeof scriptGen.next === "function") {
|
||||
this._scheduled.push({ gen: scriptGen, wakeTime: 0 });
|
||||
} else {
|
||||
throw new Error("Script must be a generator or generator function");
|
||||
}
|
||||
}
|
||||
|
||||
// Add a Fengari coroutine (thread) to the scheduler
|
||||
scheduleFengariCoroutine(luaThread, luaState) {
|
||||
// luaThread: the coroutine (thread) object from Fengari
|
||||
// luaState: the Lua state (L) from Fengari
|
||||
console.debug("Scheduling Fengari coroutine");
|
||||
this._scheduled.push({ fengari: true, thread: luaThread, state: luaState, wakeTime: 0 });
|
||||
}
|
||||
|
||||
// Main update loop, call this every frame with dt (delta time in seconds)
|
||||
step(dt) {
|
||||
const now = Date.now();
|
||||
// Wake up any waiting scripts whose time has come
|
||||
for (let i = this._waiting.length - 1; i >= 0; i--) {
|
||||
const item = this._waiting[i];
|
||||
if (now >= item.wakeTime) {
|
||||
this._scheduled.push(item);
|
||||
this._waiting.splice(i, 1);
|
||||
console.log("Woke up routine",i,"from waiting.");
|
||||
}
|
||||
}
|
||||
// Run scheduled scripts
|
||||
for (let i = this._scheduled.length - 1; i >= 0; i--) {
|
||||
const item = this._scheduled[i];
|
||||
if (item.fengari) {
|
||||
// Fengari coroutine
|
||||
const L = item.state;
|
||||
const thread = item.thread;
|
||||
// Resume the coroutine
|
||||
const status = lua_resume(thread, L, 0);
|
||||
if (status === LUA_OK) {
|
||||
// Coroutine finished
|
||||
this._scheduled.splice(i, 1);
|
||||
console.log("Woke up routine",i,"from fengari, finished.");
|
||||
} else if (status === LUA_YIELD) {
|
||||
// Check for yield value (wait)
|
||||
// You must implement a way for Lua to yield with a wait time, e.g. coroutine.yield({wait=seconds})
|
||||
// For now, we assume the top of the stack is a table with a 'wait' field
|
||||
const luaTable = lua.lua_tojsstring(L, -1);
|
||||
let waitSeconds = 0;
|
||||
try {
|
||||
// Try to parse the yielded value as JSON (if you yield a JSON string)
|
||||
const val = JSON.parse(luaTable);
|
||||
if (val && val.wait) waitSeconds = val.wait;
|
||||
} catch (e) { }
|
||||
if (waitSeconds > 0) {
|
||||
this._waiting.push({ ...item, wakeTime: now + waitSeconds * 1000 });
|
||||
this._scheduled.splice(i, 1);
|
||||
}
|
||||
// Otherwise, just continue next frame
|
||||
} else {
|
||||
// Error or unknown status
|
||||
this._scheduled.splice(i, 1);
|
||||
}
|
||||
} else {
|
||||
// JavaScript generator
|
||||
const { value, done } = item.gen.next();
|
||||
if (done) {
|
||||
this._scheduled.splice(i, 1);
|
||||
console.log("Woke up routine",i,"from JS generator, finished.");
|
||||
} else if (value && value.wait) {
|
||||
// Script yielded with {wait: seconds}
|
||||
const ms = value.wait * 1000;
|
||||
this._waiting.push({ gen: item.gen, wakeTime: now + ms });
|
||||
this._scheduled.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility for scripts to yield for a certain time (in seconds)
|
||||
static wait(seconds) {
|
||||
return { wait: seconds };
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
// function* myScript() {
|
||||
// console.log("Script start");
|
||||
// yield ScriptSchedulerService.wait(1);
|
||||
// console.log("1 second later");
|
||||
// yield ScriptSchedulerService.wait(2);
|
||||
// console.log("2 more seconds later");
|
||||
// }
|
||||
// const scheduler = new ScriptSchedulerService();
|
||||
// scheduler.schedule(myScript);
|
||||
// setInterval(() => scheduler.step(1/60), 1000/60);
|
||||
117
js/instances/ServiceService.js
Normal file
117
js/instances/ServiceService.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// ServiceService.js
|
||||
import { BaseService } from "./BaseService.js";
|
||||
import { NetworkService } from "./NetworkService.js";
|
||||
import { RenderService } from "./RenderService.js";
|
||||
import { ReplicatorService } from "./ReplicatorService.js";
|
||||
import { ScriptSchedulerService } from "./ScriptSchedulerService.js";
|
||||
import { Players } from "./Players.js";
|
||||
import { Workspace } from "./Workspace.js";
|
||||
import { DataModel } from "../core/DataModel.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
export class ServiceService extends BaseService {
|
||||
constructor() {
|
||||
super("ServiceService");
|
||||
this._services = new Map();
|
||||
}
|
||||
|
||||
register(name, ctor, ...args) {
|
||||
if (this._services.has(name)) return this._services.get(name);
|
||||
const instance = new ctor(...args);
|
||||
this._services.set(name, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
GetService(name) {
|
||||
return this._services.get(name);
|
||||
}
|
||||
GetDataModel() {
|
||||
return globalThis.game;
|
||||
}
|
||||
|
||||
initAll(port) {
|
||||
// === Core DataModel ===
|
||||
const dm = this.register("DataModel", DataModel);
|
||||
dm.SetParent(null);
|
||||
this.SetParent(dm); // Parent myself to the DataModel
|
||||
globalThis.game = dm;
|
||||
|
||||
// === Workspace ===
|
||||
const ws = this.register("Workspace", Workspace);
|
||||
ws.SetParent(dm);
|
||||
|
||||
// === RenderService ===
|
||||
const render = this.register("RenderService", RenderService, dm);
|
||||
render.isServer = true;
|
||||
render.start();
|
||||
render.SetParent(dm);
|
||||
|
||||
// === Networking ===
|
||||
const net = this.register("NetworkService", NetworkService);
|
||||
net.listen(port);
|
||||
net.SetParent(dm);
|
||||
|
||||
// === Players ===
|
||||
const players = this.register("Players", Players, net);
|
||||
players.SetParent(dm);
|
||||
|
||||
// === ReplicatorService ===
|
||||
const replication = this.register("ReplicatorService", ReplicatorService, dm, net);
|
||||
|
||||
// === ScriptSchedulerService ===
|
||||
const scriptScheduler = this.register(
|
||||
"ScriptSchedulerService",
|
||||
ScriptSchedulerService,
|
||||
dm
|
||||
);
|
||||
scriptScheduler.SetParent(dm);
|
||||
// Connect to RenderService for update loop
|
||||
render.Stepped.Connect((dt) => {
|
||||
scriptScheduler.step(dt);
|
||||
});
|
||||
|
||||
// Cross-link
|
||||
replication.Players = players;
|
||||
players.Replicator = replication;
|
||||
|
||||
// === Example echo handler ===
|
||||
net.registerHandler(0x01, 0x00, (payload, client) => {
|
||||
console.log("Echo:", new TextDecoder().decode(payload));
|
||||
net.sendToClient(client, 0x01, 0x01, payload);
|
||||
});
|
||||
|
||||
// === Example test parts ===
|
||||
//this._spawnDemoScene(ws);
|
||||
|
||||
console.log("[ServiceService] Initialized all services.");
|
||||
}
|
||||
|
||||
async _spawnDemoScene(ws) {
|
||||
const { LerpedBasePart } = await import("./LerpedBasePart.js");
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseService } from "./BaseService.js";
|
||||
import { Instance } from "./Instance.js";
|
||||
export class Workspace extends Instance {
|
||||
export class Workspace extends BaseService {
|
||||
constructor() {
|
||||
super("Workspace");
|
||||
this.InstanceId = "WorkspaceRoot"; // Fixed ID for Workspace (Bad idea to replicate services over network?)
|
||||
|
||||
40
js/place.json
Normal file
40
js/place.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"objects": [
|
||||
{
|
||||
"InstanceId": "2",
|
||||
"ClassName": "BasePart",
|
||||
"Name": "Ground",
|
||||
"ParentId": "WorkspaceRoot",
|
||||
"properties": {
|
||||
"Color": { "_type": "Color", "value": 65280 },
|
||||
"Size": { "_type": "Vector3", "value": { "x": 50, "y": 1, "z": 50 } },
|
||||
"Position": { "_type": "Vector3", "value": { "x": 0, "y": -0.5, "z": 0 } },
|
||||
"Anchored": { "_type": "bool", "value": true }
|
||||
}
|
||||
},
|
||||
{
|
||||
"InstanceId": "3",
|
||||
"ClassName": "BasePart",
|
||||
"Name": "FallingCube",
|
||||
"ParentId": "WorkspaceRoot",
|
||||
"properties": {
|
||||
"Color": { "_type": "Color", "value": 16711680 },
|
||||
"Size": { "_type": "Vector3", "value": { "x": 2, "y": 2, "z": 2 } },
|
||||
"Position": { "_type": "Vector3", "value": { "x": 0, "y": 15, "z": 0 } },
|
||||
"Anchored": { "_type": "bool", "value": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"InstanceId": "4",
|
||||
"ClassName": "BasePart",
|
||||
"Name": "Wall",
|
||||
"ParentId": "WorkspaceRoot",
|
||||
"properties": {
|
||||
"Color": { "_type": "Color", "value": 255 },
|
||||
"Size": { "_type": "Vector3", "value": { "x": 1, "y": 10, "z": 20 } },
|
||||
"Position": { "_type": "Vector3", "value": { "x": 10, "y": 5, "z": 0 } },
|
||||
"Anchored": { "_type": "bool", "value": true }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
56
js/serverParams.js
Normal file
56
js/serverParams.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ServiceService } from "./instances/ServiceService.js";
|
||||
|
||||
import fs from 'fs';
|
||||
import { program } from "commander";
|
||||
import { sha256, encoders } from "./core/util.js";
|
||||
import { exit } from "process";
|
||||
|
||||
program
|
||||
.option("-p, --port <number>", "Port to listen on", "8080")
|
||||
.option("-a, --place <url>", "Place JSON file", "https://example.com/asset.php?id=1818");
|
||||
program.parse();
|
||||
|
||||
const options = program.opts();
|
||||
options.port = parseInt(options.port, 10); // optional, if you need a number
|
||||
const Services = new ServiceService(options.port);
|
||||
Services.initAll();
|
||||
|
||||
globalThis.Services = Services; // optional global shortcut
|
||||
|
||||
|
||||
// Load from place.json
|
||||
|
||||
// import FS
|
||||
|
||||
(async () => {
|
||||
const placeReq = await fetch(options.place);
|
||||
if (!placeData.ok) {
|
||||
console.error("Failed to fetch place data!");
|
||||
exit(1);
|
||||
}
|
||||
const placeData = await placeReq.json();
|
||||
// Create instances off this
|
||||
for (let obj of placeData.objects) {
|
||||
const parent = Services.GetDataModel().FindInstanceRecursively(obj.ParentId)
|
||||
if (parent) {
|
||||
import(`./instances/${obj.ClassName}.js`).then((module) => {
|
||||
const NewClass = module[obj.ClassName];
|
||||
if (!NewClass) {
|
||||
console.warn(`Init: Class ${obj.ClassName} not found for startup. Is this server up to date?`);
|
||||
return;
|
||||
}
|
||||
const newInst = new NewClass();
|
||||
newInst.InstanceId = obj.InstanceId; // Set the same InstanceId
|
||||
newInst.Name = obj.Name;
|
||||
for (let prop in obj.properties) {
|
||||
const type = prop._type
|
||||
const value = prop.value
|
||||
if (encoders[type]) obj.value = encoders[type].decode(value);
|
||||
else obj.value = value;
|
||||
};
|
||||
newInst.SetParent(parent)
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log("Server initialized and listening on port " + options.port.toString() + "!");
|
||||
})();
|
||||
@@ -1,72 +1,22 @@
|
||||
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";
|
||||
import { ServiceService } from "./instances/ServiceService.js";
|
||||
|
||||
const dm = new DataModel();
|
||||
dm.SetParent(null);
|
||||
const Services = new ServiceService();
|
||||
Services.initAll();
|
||||
|
||||
// === Workspace ===
|
||||
const ws = new Workspace();
|
||||
ws.SetParent(dm);
|
||||
globalThis.Services = Services; // optional global shortcut
|
||||
|
||||
// === RenderService (headless physics) ===
|
||||
const render = new RenderService(dm);
|
||||
render.isServer = true;
|
||||
render.start();
|
||||
render.SetParent(dm);
|
||||
console.log("Server initialized and listening on port 8080.");
|
||||
|
||||
// === 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();
|
||||
// Create a test script instance
|
||||
import { Script } from "./instances/Script.js";
|
||||
const testScript = new Script(`print("Hello from Lua script!")
|
||||
for i=1,5 do
|
||||
print("Waiting...", i)
|
||||
task_wait(1) -- wait 1 second
|
||||
end
|
||||
print("Lua script finished!")`);
|
||||
|
||||
// Parent to Workspace
|
||||
const workspace = Services.GetService("Workspace");
|
||||
testScript.SetParent(workspace);
|
||||
testScript.Run();
|
||||
Reference in New Issue
Block a user