@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in
349 lines (295 loc) • 14.3 kB
text/typescript
import { Object3D } from "three";
import { AssetReference } from "../../engine/engine_addressables.js";
import { RoomEvents } from "../../engine/engine_networking.js";
import { syncField } from "../../engine/engine_networking_auto.js"
import { syncDestroy } from "../../engine/engine_networking_instantiate.js";
import { serializable } from "../../engine/engine_serialization_decorator.js";
import type { ComponentInit, IGameObject } from "../../engine/engine_types.js";
import { delay, getParam } from "../../engine/engine_utils.js";
import { Behaviour, Component, GameObject } from "../../engine-components/Component.js";
import { EventList } from "../../engine-components/EventList.js";
const debug = getParam("debugplayersync");
declare type PlayerSyncWithAsset = PlayerSync & Required<Pick<PlayerSync, "asset">>;
/**
* This component instantiates an asset for each player that joins a networked room. The asset will be destroyed when the player leaves the room.
* The asset should have a PlayerState component, and can have other components like SyncedTransform, custom components, etc.
* @category Networking
*/
export class PlayerSync extends Behaviour {
/**
* This API is experimental and may change or be removed in the future.
* Create a PlayerSync instance at runtime from a given URL
* @example
* ```typescript
* const res = await PlayerSync.setupFrom("/assets/demo.glb");
* addComponent(res.asset?.asset, DragControls);
* addComponent(res.asset?.asset, SyncedTransform);
* scene.add(res.gameObject);
* ```
*/
static async setupFrom(url: string, init?: Omit<ComponentInit<PlayerSync>, "asset">): Promise<PlayerSyncWithAsset> {
const assetReference = AssetReference.getOrCreateFromUrl(url);
if (!assetReference.asset) {
const i = await assetReference.loadAssetAsync();
GameObject.getOrAddComponent(i, PlayerState);
}
const ps = new PlayerSync();
ps._internalInit(init);
ps.asset = assetReference;
const obj = new Object3D();
obj["guid"] = url;
GameObject.addComponent(obj, ps);
return ps as PlayerSyncWithAsset;
}
/** when enabled PlayerSync will automatically load and instantiate the assigned asset when joining a networked room */
()
autoSync: boolean = true;
/** This asset will be loaded and instantiated when PlayerSync becomes active and joins a networked room */
(AssetReference)
asset?: AssetReference;
/** Event called when an instance is spawned */
(EventList)
onPlayerSpawned?: EventList<Object3D>;
private _localInstance?: Promise<IGameObject>;
awake(): void {
this.watchTabVisible();
if (!this.onPlayerSpawned) this.onPlayerSpawned = new EventList();
}
onEnable(): void {
this.context.connection.beginListen(RoomEvents.RoomStateSent, this.onJoinedRoom);
this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
this.context.connection.beginListen(RoomEvents.LeftRoom, this.destroyInstance);
if (this.context.connection.isInRoom) {
this.onJoinedRoom();
}
}
onDisable(): void {
this.context.connection.stopListen(RoomEvents.RoomStateSent, this.onJoinedRoom);
this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
this.context.connection.stopListen(RoomEvents.LeftRoom, this.destroyInstance);
}
private onJoinedRoom = () => {
if (debug) console.log("PlayerSync.joinedRoom. autoSync is set to " + this.autoSync);
if (this.autoSync) this.getInstance();
}
async getInstance() {
if (this._localInstance) return this._localInstance;
if (debug) console.log("PlayerSync.createInstance", this.asset?.url);
if (!this.asset?.asset && !this.asset?.url) {
console.error("PlayerSync: can not create an instance because \"asset\" is not set and or has no URL!");
return null;
}
if (!this.gameObject.guid) {
console.warn("PlayerSync: gameObject has no guid! This might cause issues with syncing the player state.");
}
this._localInstance = this.asset?.instantiateSynced({ parent: this.gameObject, deleteOnDisconnect: true }, true) as Promise<IGameObject>;
const instance = await this._localInstance;
if (instance) {
const pl = GameObject.getComponentsInChildren(instance, PlayerState);
if (pl?.length) {
for (const state of pl)
state.owner = this.context.connection.connectionId!;
this.onPlayerSpawned?.invoke(instance);
}
else {
this._localInstance = undefined;
console.error("<strong>Failed finding PlayerState on " + this.asset?.url + "</strong>: please make sure the asset has a PlayerState component!");
GameObject.destroySynced(instance);
}
}
else {
this._localInstance = undefined;
console.warn("PlayerSync: failed instantiating asset!")
}
return this._localInstance;
}
destroyInstance = () => {
this._localInstance?.then(go => {
if (debug) console.log("PlayerSync.destroyInstance", go);
syncDestroy(go, this.context.connection, true, { saveInRoom: false });
});
this._localInstance = undefined;
}
private watchTabVisible() {
window.addEventListener("visibilitychange", _ => {
if (document.visibilityState === "visible") {
for (let i = PlayerState.all.length - 1; i >= 0; i--) {
const pl = PlayerState.all[i];
if (!pl.owner || !this.context.connection.userIsInRoom(pl.owner)) {
pl.doDestroy();
}
}
}
});
}
}
export enum PlayerStateEvent {
OwnerChanged = "ownerChanged",
}
export declare interface PlayerStateOwnerChangedArgs {
playerState: PlayerState;
oldValue: string;
newValue: string;
}
export declare type PlayerStateEventCallback = (args: CustomEvent<PlayerStateOwnerChangedArgs>) => void;
export class PlayerState extends Behaviour {
private static _all: PlayerState[] = [];
/** all instances for all players */
static get all(): PlayerState[] {
return PlayerState._all;
};
private static _local: PlayerState[] = [];
/** all instances for the local player */
static get local(): PlayerState[] {
return PlayerState._local;
}
static getFor(obj: Object3D | Component) {
if (obj instanceof Object3D) {
return GameObject.getComponentInParent(obj, PlayerState);
}
else if (obj instanceof Component) {
return GameObject.getComponentInParent(obj.gameObject, PlayerState);
}
return undefined;
}
//** use to check if a component or gameobject is part of a instance owned by the local player */
static isLocalPlayer(obj: Object3D | Component): boolean {
const state = PlayerState.getFor(obj);
return state?.isLocalPlayer ?? false;
}
// static Callback
private static _callbacks: { [key: string]: PlayerStateEventCallback[] } = {};
/**
* Add a callback for a PlayerStateEvent
*/
static addEventListener(event: PlayerStateEvent, cb: PlayerStateEventCallback) {
if (!this._callbacks[event]) this._callbacks[event] = [];
this._callbacks[event].push(cb);
return cb;
}
static removeEventListener(event: PlayerStateEvent, cb: PlayerStateEventCallback) {
if (!this._callbacks[event]) return;
const index = this._callbacks[event].indexOf(cb);
if (index >= 0) this._callbacks[event].splice(index, 1);
}
private static dispatchEvent(event: PlayerStateEvent, args: CustomEvent<PlayerStateOwnerChangedArgs>) {
if (!this._callbacks[event]) return;
for (const cb of this._callbacks[event]) {
cb(args);
}
}
public onOwnerChangeEvent = new EventList();
public onFirstOwnerChangeEvent = new EventList();
public hasOwner = false;
(PlayerState.prototype.onOwnerChange)
owner?: string;
/** when enabled PlayerSync will not destroy itself when not connected anymore */
dontDestroy: boolean = false;
get isLocalPlayer(): boolean {
return this.owner === this.context.connection.connectionId;
}
private onOwnerChange(newOwner: string, oldOwner: string) {
if (debug) console.log("PlayerSync.onOwnerChange", this, "newOwner", newOwner, "oldOwner", oldOwner);
// Remove from local owner array if it was local before
const index = PlayerState._local.indexOf(this);
if (index >= 0) PlayerState._local.splice(index, 1);
// Args to use for dispatching events
const detail: PlayerStateOwnerChangedArgs = {
playerState: this,
oldValue: oldOwner,
newValue: newOwner
}
// call local events
if (!this.hasOwner) {
this.hasOwner = true;
this.onFirstOwnerChangeEvent?.invoke(detail);
}
this.onOwnerChangeEvent?.invoke(detail);
// call remote events
if (this.owner === this.context.connection.connectionId) {
PlayerState._local.push(this);
// console.warn(this.gameObject.guid, this.guid, this.owner, this.isLocalPlayer, PlayerState.isLocalPlayer(this));
const evt = new CustomEvent("local-owner-changed", { detail: detail });
this.dispatchEvent(evt);
}
const evt = new CustomEvent("owner-changed", { detail: detail });
this.dispatchEvent(evt);
PlayerState.dispatchEvent(PlayerStateEvent.OwnerChanged, evt);
}
awake(): void {
PlayerState.all.push(this);
if (debug) console.log("Registered new PlayerState", this.guid, PlayerState.all.length - 1, PlayerState.all)
this.context.connection.beginListen(RoomEvents.UserLeftRoom, this.onUserLeftRoom);
}
private onUserLeftRoom = (model: { userId: string }) => {
if (model.userId === this.owner) {
if (debug)
console.log("PLAYERSYNC LEFT", this.owner)
this.doDestroy();
return;
}
}
async start() {
if (debug) console.log("PLAYERSTATE.START, owner: " + this.owner, this.context.connection.usersInRoom([]))
// generate number from owner
// if (this.owner) {
// // string to number
// let num = 0;
// for (let i = 0; i < this.owner.length; i++) {
// num += this.owner.charCodeAt(i);
// }
// console.log(num)
// num = num / 1000
// this.gameObject.position.y = num;
// }
// If a player is spawned but not in the room anymore we want to destroy it
// this might happen in a case where all users get disconnected at once and the server
// still has the syncInstantiate messages that are sent to all clients
if (this.owner) {
// a slight delay is necessary right now because the syncInstantiate call might has created this object already with the owner assigned but the user has not yet joined the room
if (!this.context.connection.isInRoom) await delay(300);
if (this.context.connection.userIsInRoom(this.owner) == false) {
if (debug) console.log(`PlayerSync.start → doDestroy \"${this.name}\" because user \"${this.owner}\" is not in room anymore...`, "Currently in room:", ...this.context.connection.usersInRoom())
this.doDestroy();
}
}
else if (!this.owner) {
if (debug) console.warn("PlayerState.start → owner is undefined!", this.name);
// we can delete it here immediately because it is not synced anymore or the owner has left the room
// we could also do this in a timeout and check if the owner is still not assigned after a second (but that would be a hack)
setTimeout(() => {
if (!this.destroyed && !this.owner) {
if (!this.dontDestroy) {
if (debug) console.warn(`PlayerState.start → owner is still undefined: destroying \"${this.name}\" instance now`);
this.doDestroy();
}
else if (debug) console.warn("PlayerState.start → owner is still undefined but dontDestroy is set to true", this.name);
}
else if(debug) console.log("PlayerState.start → owner is assigned", this.owner);
}, 2000);
}
}
// onEnable() {
// if (debug) this.startCoroutine(this.debugRoutine());
// }
// *debugRoutine() {
// while (!this.destroyed && this.activeAndEnabled) {
// Gizmos.DrawLabel(this.gameObject.worldPosition, this.owner ?? "no owner");
// yield;
// }
// }
/** this tells the server that this client has been destroyed and the networking message for the instantiate will be removed */
doDestroy() {
if (debug) console.log("PlayerSync.doDestroy → syncDestroy", this.name);
syncDestroy(this.gameObject, this.context.connection, true, { saveInRoom: false });
}
onDestroy() {
this.context.connection.stopListen(RoomEvents.UserLeftRoom, this.onUserLeftRoom);
PlayerState.all.splice(PlayerState.all.indexOf(this), 1);
if (this.isLocalPlayer) {
const index = PlayerState._local.indexOf(this);
if (index >= 0)
PlayerState._local.splice(index, 1);
}
}
}