Merge pull request #5814 from mozilla/feature/networking-system-refactor

Feature/networking system refactor
This commit is contained in:
John Shaughnessy 2022-11-25 13:10:14 -05:00 коммит произвёл GitHub
Родитель 5ec5f987cf a1bcb95c8f
Коммит 5a4d3b54de
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
24 изменённых файлов: 642 добавлений и 603 удалений

Просмотреть файл

@ -8,7 +8,7 @@ import { MediaType, mediaTypeName, resolveMediaInfo } from "../utils/media-utils
import { defineQuery, enterQuery, exitQuery, hasComponent, removeComponent, removeEntity } from "bitecs";
import { MediaLoader, Networked } from "../bit-components";
import { crTimeout, crClearTimeout, cancelable, coroutine, makeCancelable } from "../utils/coroutine";
import { takeOwnership } from "./networking";
import { takeOwnership } from "../utils/take-ownership";
import { renderAsEntity } from "../utils/jsx-entity";
import { animate } from "../utils/animate";

Просмотреть файл

@ -0,0 +1,188 @@
import { addComponent, defineQuery, enterQuery, hasComponent, removeComponent, removeEntity } from "bitecs";
import { HubsWorld } from "../app";
import { Networked, Owned } from "../bit-components";
import { createNetworkedEntityFromRemote } from "../utils/create-networked-entity";
import { networkableComponents, schemas } from "../utils/network-schemas";
import type { StringID, UpdateMessage } from "../utils/networking-types";
import { hasPermissionToSpawn } from "../utils/permissions";
import { takeOwnershipWithTime } from "../utils/take-ownership-with-time";
import {
createMessageDatas,
isNetworkInstantiated,
localClientID,
networkedQuery,
pendingMessages,
pendingParts
} from "./networking";
const partedClientIds = new Set<StringID>();
const storedUpdates = new Map<StringID, UpdateMessage[]>();
const enteredNetworkedQuery = enterQuery(defineQuery([Networked]));
export function networkReceiveSystem(world: HubsWorld) {
if (!localClientID) return; // Not connected yet.
{
// When a user leaves, remove the entities created by that user
const networkedEntities = networkedQuery(world);
pendingParts.forEach(partingClientId => {
partedClientIds.add(partingClientId);
networkedEntities
.filter(eid => isNetworkInstantiated(eid) && Networked.creator[eid] === partingClientId)
.forEach(eid => removeEntity(world, eid));
});
}
// If we were hanging onto updates for any newly created non network instantiated entities
// we can now apply them. Network instantiated entities are handled when processing creates.
enteredNetworkedQuery(world).forEach(eid => {
const nid = Networked.id[eid];
if (storedUpdates.has(nid)) {
console.log("Had stored updates for", APP.getString(nid), storedUpdates.get(nid));
const updates = storedUpdates.get(nid)!;
for (let i = 0; i < updates.length; i++) {
const update = updates[i];
if (partedClientIds.has(APP.getSid(update.owner))) {
console.log("Rewriting update message from client who left.", JSON.stringify(update));
update.owner = NAF.clientId;
update.lastOwnerTime = update.timestamp;
}
}
pendingMessages.unshift({ creates: [], updates, deletes: [] });
storedUpdates.delete(nid);
}
});
for (let i = 0; i < pendingMessages.length; i++) {
const message = pendingMessages[i];
for (let j = 0; j < message.creates.length; j++) {
const [nidString, prefabName, initialData] = message.creates[j];
const creator = message.fromClientId;
if (!creator) {
// We do not expect to get here.
// We only check because we are synthesizing messages elsewhere;
// They should not have any create messages in them.
throw new Error("Received create message without a fromClientId.");
}
const nid = APP.getSid(nidString);
if (world.deletedNids.has(nid)) {
// TODO we may need to allow this for reconnects
console.log(`Received a create message for an entity I've already deleted. Skipping ${nidString}`);
} else if (world.nid2eid.has(nid)) {
console.log(`Received create message for entity I already created. Skipping ${nidString}`);
} else if (!hasPermissionToSpawn(creator, prefabName)) {
// this should only ever happen if there is a bug or the sender is maliciously modified
console.log(`Received create from a user who does not have permission to spawn ${prefabName}`);
world.ignoredNids.add(nid); // TODO should we just use deletedNids for this?
} else {
const eid = createNetworkedEntityFromRemote(world, prefabName, initialData, nidString, creator, creator);
console.log("got create message for", nidString, eid);
// If we were hanging onto updates for this nid we can now apply them. And they should be processed before other updates.
if (storedUpdates.has(nid)) {
console.log("had pending updates for", nidString, storedUpdates.get(nid));
Array.prototype.unshift.apply(message.updates, storedUpdates.get(nid));
storedUpdates.delete(nid);
}
}
}
for (let j = 0; j < message.updates.length; j++) {
const updateMessage = message.updates[j];
const nid = APP.getSid(updateMessage.nid);
if (world.ignoredNids.has(nid)) {
console.log(`Ignoring update for ignored entity ${updateMessage.nid}`);
continue;
}
if (world.deletedNids.has(nid)) {
console.log(`Ignoring update for deleted entity ${updateMessage.nid}`);
continue;
}
if (!world.nid2eid.has(nid)) {
console.log(`Holding onto an update for ${updateMessage.nid} because we don't have it yet.`);
// TODO: What if we will NEVER be able to apply this update?
// TODO would be nice if we could squash these updates
const updates = storedUpdates.get(nid) || [];
updates.push(updateMessage);
storedUpdates.set(nid, updates);
console.log(storedUpdates);
continue;
}
const eid = world.nid2eid.get(nid)!;
if (
Networked.lastOwnerTime[eid] > updateMessage.lastOwnerTime ||
(Networked.lastOwnerTime[eid] === updateMessage.lastOwnerTime &&
APP.getString(Networked.owner[eid])! < updateMessage.owner) // arbitrary (but consistent) tiebreak
) {
console.log(
"Received update from an old owner, skipping",
updateMessage.nid,
Networked.lastOwnerTime[eid],
updateMessage.lastOwnerTime
);
continue;
}
if (updateMessage.owner === NAF.clientId) {
console.log("Got a message telling us we are the owner.");
addComponent(world, Owned, eid);
} else if (hasComponent(world, Owned, eid)) {
console.log("Lost ownership: ", updateMessage.nid);
removeComponent(world, Owned, eid);
}
Networked.creator[eid] = APP.getSid(updateMessage.creator);
Networked.owner[eid] = APP.getSid(updateMessage.owner);
Networked.lastOwnerTime[eid] = updateMessage.lastOwnerTime;
Networked.timestamp[eid] = updateMessage.timestamp;
// TODO HACK simulating a buffer with a cursor using an array
updateMessage.data.cursor = 0;
for (let s = 0; s < updateMessage.componentIds.length; s++) {
const componentId = updateMessage.componentIds[s];
const schema = schemas.get(networkableComponents[componentId])!;
schema.deserialize(world, eid, updateMessage.data);
}
delete updateMessage.data.cursor;
}
for (let j = 0; j < message.deletes.length; j += 1) {
const nid = APP.getSid(message.deletes[j]);
if (world.deletedNids.has(nid)) continue;
world.deletedNids.add(nid);
const eid = world.nid2eid.get(nid)!;
createMessageDatas.delete(eid);
world.nid2eid.delete(nid);
removeEntity(world, eid);
console.log("Deleting ", APP.getString(nid));
}
}
pendingMessages.length = 0;
{
const networkedEntities = networkedQuery(world);
pendingParts.forEach(partingClientId => {
networkedEntities
.filter(eid => Networked.owner[eid] === partingClientId)
.forEach(eid => {
takeOwnershipWithTime(world, eid, Networked.timestamp[eid]);
});
});
pendingParts.length = 0;
}
// TODO If there's a scene-owned entity, we should take ownership of it
}

Просмотреть файл

@ -0,0 +1,86 @@
import { defineQuery, enterQuery, exitQuery } from "bitecs";
import { HubsWorld } from "../app";
import { Networked, Owned } from "../bit-components";
import { getServerTime } from "../phoenix-adapter";
import { messageFor } from "../utils/message-for";
import type { EntityID } from "../utils/networking-types";
import { createMessageDatas, isNetworkInstantiated, localClientID, networkedQuery, pendingJoins } from "./networking";
function isNetworkInstantiatedByMe(eid: EntityID) {
return isNetworkInstantiated(eid) && Networked.creator[eid] === APP.getSid(NAF.clientId);
}
const ticksPerSecond = 12;
const millisecondsBetweenTicks = 1000 / ticksPerSecond;
let nextTick = 0;
const ownedNetworkedQuery = defineQuery([Owned, Networked]);
const enteredNetworkedQuery = enterQuery(networkedQuery);
const enteredOwnedNetworkedQuery = enterQuery(ownedNetworkedQuery);
const exitedNetworkedQuery = exitQuery(networkedQuery);
export function networkSendSystem(world: HubsWorld) {
if (!localClientID) return; // Not connected yet
const now = performance.now();
if (now < nextTick) return;
if (now < nextTick + millisecondsBetweenTicks) {
nextTick = nextTick + millisecondsBetweenTicks; // The usual case
} else {
// An unusually long delay happened
nextTick = now + millisecondsBetweenTicks;
}
{
// TODO: Ensure getServerTime() is monotonically increasing.
// TODO: Get the server time from the websocket connection
// before we start sending any messages, in case of large local clock skew.
const timestamp = getServerTime();
ownedNetworkedQuery(world).forEach(eid => {
Networked.timestamp[eid] = timestamp;
});
}
// Tell joining users about entities I network instantiated, and full updates for entities I own
{
if (pendingJoins.length) {
const ownedNetworkedEntities = ownedNetworkedQuery(world);
const message = messageFor(
world,
networkedQuery(world).filter(isNetworkInstantiatedByMe),
ownedNetworkedEntities,
ownedNetworkedEntities,
[],
false
);
if (message) {
pendingJoins.forEach(clientId => NAF.connection.sendDataGuaranteed(APP.getString(clientId)!, "nn", message));
}
pendingJoins.length = 0;
}
}
// Tell everyone about entities I created, entities I own, and entities that were deleted
{
// Note: Many people may send delete messages about the same entity
const deletedEntities = exitedNetworkedQuery(world).filter(eid => {
return !world.deletedNids.has(Networked.id[eid]) && isNetworkInstantiated(eid);
});
const message = messageFor(
world,
enteredNetworkedQuery(world).filter(isNetworkInstantiatedByMe),
ownedNetworkedQuery(world),
enteredOwnedNetworkedQuery(world),
deletedEntities,
true
);
if (message) NAF.connection.broadcastDataGuaranteed("nn", message);
deletedEntities.forEach(eid => {
createMessageDatas.delete(eid);
world.deletedNids.add(Networked.id[eid]);
world.nid2eid.delete(Networked.id[eid]);
});
}
}

Просмотреть файл

@ -1,518 +1,15 @@
import {
addComponent,
Component,
defineQuery,
enterQuery,
exitQuery,
hasComponent,
removeComponent,
removeEntity
} from "bitecs";
import { HubsWorld } from "../app";
import { AEntity, Networked, NetworkedMediaFrame, NetworkedTransform, NetworkedVideo, Owned } from "../bit-components";
import { getServerTime } from "../phoenix-adapter";
import { CameraPrefab, CubeMediaFramePrefab } from "../prefabs/camera-tool";
import { MediaPrefab } from "../prefabs/media";
import { defineNetworkSchema } from "../utils/bit-utils";
import { renderAsEntity } from "../utils/jsx-entity";
const prefabs = new Map(
Object.entries({
camera: {
permission: "spawn_camera",
template: CameraPrefab
},
cube: {
template: CubeMediaFramePrefab
},
media: {
template: MediaPrefab
}
})
);
type EntityID = number;
export function takeOwnershipWithTime(world: HubsWorld, eid: EntityID, timestamp: number) {
if (hasComponent(world, AEntity, eid)) {
throw new Error("Cannot take ownership of AEntities with a specific timestamp.");
}
addComponent(world, Owned, eid);
Networked.lastOwnerTime[eid] = timestamp;
Networked.owner[eid] = APP.getSid(NAF.clientId);
}
let localClientID: ClientID | null = null;
import { defineQuery } from "bitecs";
import { Networked } from "../bit-components";
import type { ClientID, CreateMessageData, EntityID, Message, StringID } from "../utils/networking-types";
export let localClientID: ClientID | null = null;
export function setLocalClientID(clientID: ClientID) {
localClientID = clientID;
}
export function takeOwnership(world: HubsWorld, eid: EntityID) {
// TODO we do this to have a single API for taking ownership of things in new code, but it obviously relies on NAF/AFrame
if (hasComponent(world, AEntity, eid)) {
const el = world.eid2obj.get(eid)!.el!;
!NAF.utils.isMine(el) && NAF.utils.takeOwnership(el);
} else {
addComponent(world, Owned, eid);
Networked.lastOwnerTime[eid] = Math.max(getServerTime(), Networked.lastOwnerTime[eid] + 1);
Networked.owner[eid] = APP.getSid(NAF.clientId);
}
}
interface CreateMessageData {
prefabName: string;
initialData: InitialData;
}
const createMessageDatas: Map<EntityID, CreateMessageData> = new Map();
type InitialData = any;
export function createNetworkedEntityFromRemote(
world: HubsWorld,
prefabName: string,
initialData: InitialData,
rootNid: string,
creator: ClientID,
owner: ClientID
) {
const eid = renderAsEntity(world, prefabs.get(prefabName)!.template(initialData));
const obj = world.eid2obj.get(eid)!;
createMessageDatas.set(eid, { prefabName, initialData });
let i = 0;
obj.traverse(function (o) {
if (o.eid && hasComponent(world, Networked, o.eid)) {
const eid = o.eid;
Networked.id[eid] = APP.getSid(i === 0 ? rootNid : `${rootNid}.${i}`);
APP.world.nid2eid.set(Networked.id[eid], eid);
Networked.creator[eid] = APP.getSid(creator);
Networked.owner[eid] = APP.getSid(owner);
if (NAF.clientId === owner) takeOwnership(world, eid);
i += 1;
}
});
AFRAME.scenes[0].object3D.add(obj);
return eid;
}
function spawnAllowed(creator: ClientID, prefabName: string) {
const perm = prefabs.get(prefabName)!.permission;
return !perm || APP.hubChannel!.userCan(creator, perm);
}
export function createNetworkedEntity(world: HubsWorld, prefabName: string, initialData: InitialData) {
if (!spawnAllowed(NAF.clientId, prefabName)) throw new Error(`You do not have permission to spawn ${prefabName}`);
const rootNid = NAF.utils.createNetworkId();
return createNetworkedEntityFromRemote(world, prefabName, initialData, rootNid, NAF.clientId, NAF.clientId);
}
const networkedEntitiesQuery = defineQuery([Networked]);
const ownedNetworkedEntitiesQuery = defineQuery([Networked, Owned]);
interface NetworkSchema {
serialize: (
world: HubsWorld,
eid: EntityID,
data: CursorBuffer,
isFullSync: boolean,
writeToShadow: boolean
) => boolean;
deserialize: (world: HubsWorld, eid: EntityID, data: CursorBuffer) => void;
}
const schemas: Map<Component, NetworkSchema> = new Map();
schemas.set(NetworkedMediaFrame, defineNetworkSchema(NetworkedMediaFrame));
schemas.set(NetworkedTransform, defineNetworkSchema(NetworkedTransform));
schemas.set(NetworkedVideo, defineNetworkSchema(NetworkedVideo));
const networkableComponents = Array.from(schemas.keys());
type ClientID = string;
type NetworkID = string;
type CreateMessage = [networkId: NetworkID, prefabName: string, initialData: InitialData];
type UpdateMessage = {
nid: NetworkID;
lastOwnerTime: number;
timestamp: number;
owner: ClientID;
creator: ClientID;
componentIds: number[];
data: CursorBuffer;
};
type CursorBuffer = { cursor?: number; push: (data: any) => {} };
type DeleteMessage = NetworkID;
interface Message {
fromClientId?: ClientID;
creates: CreateMessage[];
updates: UpdateMessage[];
deletes: DeleteMessage[];
}
const pendingMessages: Message[] = [];
type StringID = number;
const pendingJoins: StringID[] = [];
const pendingParts: StringID[] = [];
const partedClientIds = new Set<StringID>();
type Emitter = {
on: (event: string, callback: (a: any) => any) => number;
off: (event: string, ref: number) => void;
trigger: (event: string, payload: any) => void;
getBindings: () => any[];
};
type PhoenixChannel = any;
export function listenForNetworkMessages(channel: PhoenixChannel, presenceEventEmitter: Emitter) {
presenceEventEmitter.on("hub:join", ({ key: nid }) => {
// TODO: Is it OK to use join events for our own client id?
pendingJoins.push(APP.getSid(nid));
});
presenceEventEmitter.on("hub:leave", ({ key: nid }) => {
pendingParts.push(APP.getSid(nid));
});
channel.on("naf", onNaf);
channel.on("nafr", onNafr);
}
type NafMessage = {
from_session_id: string;
data: any;
dataType: string;
source: string;
};
function onNaf({ from_session_id, data, dataType }: NafMessage) {
if (dataType == "nn") {
(data as Message).fromClientId = from_session_id;
pendingMessages.push(data);
}
}
type NafrMessage = {
from_session_id: string;
naf: string;
parsed?: NafMessage;
};
function onNafr(message: NafrMessage) {
const { from_session_id, naf: unparsedData } = message;
// Attach the parsed JSON to the message so that
// PhoenixAdapter can process it without parsing it again.
message.parsed = JSON.parse(unparsedData);
message.parsed!.from_session_id = from_session_id;
onNaf(message.parsed!);
}
function messageFor(
world: HubsWorld,
created: EntityID[],
updated: EntityID[],
needsFullSyncUpdate: EntityID[],
deleted: EntityID[],
isBroadcast: boolean
) {
const message: Message = {
creates: [],
updates: [],
deletes: []
};
created.forEach(eid => {
const { prefabName, initialData } = createMessageDatas.get(eid)!;
message.creates.push([APP.getString(Networked.id[eid])!, prefabName, initialData]);
});
updated.forEach(eid => {
const updateMessage: UpdateMessage = {
nid: APP.getString(Networked.id[eid])!,
lastOwnerTime: Networked.lastOwnerTime[eid],
timestamp: Networked.timestamp[eid],
owner: APP.getString(Networked.owner[eid])!, // This should always be NAF.clientId. If it's not, something bad happened
creator: APP.getString(Networked.creator[eid])!,
componentIds: [],
data: []
};
const isFullSync = needsFullSyncUpdate.includes(eid);
for (let j = 0; j < networkableComponents.length; j++) {
const component = networkableComponents[j];
if (hasComponent(world, component, eid)) {
if (schemas.get(component)!.serialize(world, eid, updateMessage.data, isFullSync, isBroadcast)) {
updateMessage.componentIds.push(j);
}
}
}
// TODO: If the owner/lastOwnerTime changed, we need to send this updateMessage
if (updateMessage.componentIds.length) {
message.updates.push(updateMessage);
}
});
deleted.forEach(eid => {
// TODO: We are reading component data of a deleted entity here.
const nid = Networked.id[eid];
message.deletes.push(APP.getString(nid)!);
});
if (message.creates.length || message.updates.length || message.deletes.length) {
return message;
}
return null;
}
function isNetworkInstantiated(eid: EntityID) {
export const createMessageDatas: Map<EntityID, CreateMessageData> = new Map();
export const networkedQuery = defineQuery([Networked]);
export const pendingMessages: Message[] = [];
export const pendingJoins: StringID[] = [];
export const pendingParts: StringID[] = [];
export function isNetworkInstantiated(eid: EntityID) {
return createMessageDatas.has(eid);
}
function isNetworkInstantiatedByMe(eid: EntityID) {
return isNetworkInstantiated(eid) && Networked.creator[eid] === APP.getSid(NAF.clientId);
}
const pendingUpdatesForNid = new Map<StringID, UpdateMessage[]>();
const rcvEnteredNetworkedEntitiesQuery = enterQuery(defineQuery([Networked]));
export function networkReceiveSystem(world: HubsWorld) {
if (!localClientID) return; // Not connected yet.
{
// When a user leaves, remove the entities created by that user
const networkedEntities = networkedEntitiesQuery(world);
pendingParts.forEach(partingClientId => {
partedClientIds.add(partingClientId);
networkedEntities
.filter(eid => isNetworkInstantiated(eid) && Networked.creator[eid] === partingClientId)
.forEach(eid => removeEntity(world, eid));
});
}
// If we were hanging onto updates for any newly created non network instantiated entities
// we can now apply them. Network instantiated entities are handled when processing creates.
rcvEnteredNetworkedEntitiesQuery(world).forEach(eid => {
const nid = Networked.id[eid];
if (pendingUpdatesForNid.has(nid)) {
console.log("Had pending updates for", APP.getString(nid), pendingUpdatesForNid.get(nid));
const updates = pendingUpdatesForNid.get(nid)!;
for (let i = 0; i < updates.length; i++) {
const update = updates[i];
if (partedClientIds.has(APP.getSid(update.owner))) {
console.log("Rewriting update message from client who left.", JSON.stringify(update));
update.owner = NAF.clientId;
update.lastOwnerTime = update.timestamp;
}
}
pendingMessages.unshift({ creates: [], updates, deletes: [] });
pendingUpdatesForNid.delete(nid);
}
});
for (let i = 0; i < pendingMessages.length; i++) {
const message = pendingMessages[i];
for (let j = 0; j < message.creates.length; j++) {
const [nidString, prefabName, initialData] = message.creates[j];
const creator = message.fromClientId;
if (!creator) {
// We do not expect to get here.
// We only check because we are synthesizing messages elsewhere;
// They should not have any create messages in them.
throw new Error("Received create message without a fromClientId.");
}
const nid = APP.getSid(nidString);
if (world.deletedNids.has(nid)) {
// TODO we may need to allow this for reconnects
console.log(`Received a create message for an entity I've already deleted. Skipping ${nidString}`);
} else if (world.nid2eid.has(nid)) {
console.log(`Received create message for entity I already created. Skipping ${nidString}`);
} else if (!spawnAllowed(creator, prefabName)) {
// this should only ever happen if there is a bug or the sender is maliciously modified
console.log(`Received create from a user who does not have permission to spawn ${prefabName}`);
world.ignoredNids.add(nid); // TODO should we just use deletedNids for this?
} else {
const eid = createNetworkedEntityFromRemote(world, prefabName, initialData, nidString, creator, creator);
console.log("got create message for", nidString, eid);
// If we were hanging onto updates for this nid we can now apply them. And they should be processed before other updates.
if (pendingUpdatesForNid.has(nid)) {
console.log("had pending updates for", nidString, pendingUpdatesForNid.get(nid));
Array.prototype.unshift.apply(message.updates, pendingUpdatesForNid.get(nid));
pendingUpdatesForNid.delete(nid);
}
}
}
for (let j = 0; j < message.updates.length; j++) {
const updateMessage = message.updates[j];
const nid = APP.getSid(updateMessage.nid);
if (world.ignoredNids.has(nid)) {
console.log(`Ignoring update for ignored entity ${updateMessage.nid}`);
continue;
}
if (world.deletedNids.has(nid)) {
console.log(`Ignoring update for deleted entity ${updateMessage.nid}`);
continue;
}
if (!world.nid2eid.has(nid)) {
console.log(`Holding onto an update for ${updateMessage.nid} because we don't have it yet.`);
// TODO: What if we will NEVER be able to apply this update?
// TODO would be nice if we could squash these updates
const updates = pendingUpdatesForNid.get(nid) || [];
updates.push(updateMessage);
pendingUpdatesForNid.set(nid, updates);
console.log(pendingUpdatesForNid);
continue;
}
const eid = world.nid2eid.get(nid)!;
if (
Networked.lastOwnerTime[eid] > updateMessage.lastOwnerTime ||
(Networked.lastOwnerTime[eid] === updateMessage.lastOwnerTime &&
APP.getString(Networked.owner[eid])! < updateMessage.owner) // arbitrary (but consistent) tiebreak
) {
console.log(
"Received update from an old owner, skipping",
updateMessage.nid,
Networked.lastOwnerTime[eid],
updateMessage.lastOwnerTime
);
continue;
}
if (updateMessage.owner === NAF.clientId) {
console.log("Got a message telling us we are the owner.");
addComponent(world, Owned, eid);
} else if (hasComponent(world, Owned, eid)) {
console.log("Lost ownership: ", updateMessage.nid);
removeComponent(world, Owned, eid);
}
Networked.creator[eid] = APP.getSid(updateMessage.creator);
Networked.owner[eid] = APP.getSid(updateMessage.owner);
Networked.lastOwnerTime[eid] = updateMessage.lastOwnerTime;
Networked.timestamp[eid] = updateMessage.timestamp;
// TODO HACK simulating a buffer with a cursor using an array
updateMessage.data.cursor = 0;
for (let s = 0; s < updateMessage.componentIds.length; s++) {
const componentId = updateMessage.componentIds[s];
const schema = schemas.get(networkableComponents[componentId])!;
schema.deserialize(world, eid, updateMessage.data);
}
delete updateMessage.data.cursor;
}
for (let j = 0; j < message.deletes.length; j += 1) {
const nid = APP.getSid(message.deletes[j]);
if (world.deletedNids.has(nid)) continue;
world.deletedNids.add(nid);
const eid = world.nid2eid.get(nid)!;
createMessageDatas.delete(eid);
world.nid2eid.delete(nid);
removeEntity(world, eid);
console.log("OK, deleting ", APP.getString(nid));
}
}
pendingMessages.length = 0;
{
const networkedEntities = networkedEntitiesQuery(world);
pendingParts.forEach(partingClientId => {
networkedEntities
.filter(eid => Networked.owner[eid] === partingClientId)
.forEach(eid => {
takeOwnershipWithTime(world, eid, Networked.timestamp[eid]);
});
});
pendingParts.length = 0;
}
// TODO If there's a scene-owned entity, we should take ownership of it
}
const ticksPerSecond = 12;
const millisecondsBetweenTicks = 1000 / ticksPerSecond;
let nextTick = 0;
const sendEnteredNetworkedEntitiesQuery = enterQuery(networkedEntitiesQuery);
const sendEnteredOwnedEntitiesQuery = enterQuery(ownedNetworkedEntitiesQuery);
const sendExitedNetworkedEntitiesQuery = exitQuery(networkedEntitiesQuery);
export function networkSendSystem(world: HubsWorld) {
if (!localClientID) return; // Not connected yet
const now = performance.now();
if (now < nextTick) return;
if (now < nextTick + millisecondsBetweenTicks) {
nextTick = nextTick + millisecondsBetweenTicks; // The usual case
} else {
// An unusually long delay happened
nextTick = now + millisecondsBetweenTicks;
}
{
// TODO: Ensure getServerTime() is monotonically increasing.
// TODO: Get the server time from the websocket connection
// before we start sending any messages, in case of large local clock skew.
const timestamp = getServerTime();
ownedNetworkedEntitiesQuery(world).forEach(eid => {
Networked.timestamp[eid] = timestamp;
});
}
// Tell joining users about entities I network instantiated, and full updates for entities I own
{
if (pendingJoins.length) {
const ownedNetworkedEntities = ownedNetworkedEntitiesQuery(world);
const message = messageFor(
world,
networkedEntitiesQuery(world).filter(isNetworkInstantiatedByMe),
ownedNetworkedEntities,
ownedNetworkedEntities,
[],
false
);
if (message) {
pendingJoins.forEach(clientId => NAF.connection.sendDataGuaranteed(APP.getString(clientId)!, "nn", message));
}
pendingJoins.length = 0;
}
}
// Tell everyone about entities I created, entities I own, and entities that were deleted
{
// Note: Many people may send delete messages about the same entity
const deleted = sendExitedNetworkedEntitiesQuery(world).filter(eid => {
return !world.deletedNids.has(Networked.id[eid]) && isNetworkInstantiated(eid);
});
const message = messageFor(
world,
sendEnteredNetworkedEntitiesQuery(world).filter(isNetworkInstantiatedByMe),
ownedNetworkedEntitiesQuery(world),
sendEnteredOwnedEntitiesQuery(world),
deleted,
true
);
if (message) NAF.connection.broadcastDataGuaranteed("nn", message);
deleted.forEach(eid => {
createMessageDatas.delete(eid);
world.deletedNids.add(Networked.id[eid]);
world.nid2eid.delete(Networked.id[eid]);
});
}
}
// TODO: Handle reconnect
// TODO: Handle blocking/unblocking. Does this already work?

Просмотреть файл

@ -14,7 +14,7 @@ import {
VideoMenuItem
} from "../bit-components";
import { timeFmt } from "../components/media-video";
import { takeOwnership } from "./networking";
import { takeOwnership } from "../utils/take-ownership";
import { paths } from "../systems/userinput/paths";
import { animate } from "../utils/animate";
import { coroutine } from "../utils/coroutine";

Просмотреть файл

@ -241,7 +241,8 @@ import { ThemeProvider } from "./react-components/styles/theme";
import { LogMessageType } from "./react-components/room/ChatSidebar";
import "./load-media-on-paste-or-drop";
import { swapActiveScene } from "./bit-systems/scene-loading";
import { listenForNetworkMessages, setLocalClientID } from "./bit-systems/networking";
import { setLocalClientID } from "./bit-systems/networking";
import { listenForNetworkMessages } from "./utils/listen-for-network-messages";
const PHOENIX_RELIABLE_NAF = "phx-reliable";
NAF.options.firstSyncSource = PHOENIX_RELIABLE_NAF;

Просмотреть файл

@ -1,4 +1,4 @@
import { createNetworkedEntity } from "./bit-systems/networking";
import { createNetworkedEntity } from "./utils/create-networked-entity";
import { upload, parseURL } from "./utils/media-utils";
import { guessContentType } from "./utils/media-url-utils";
import { AElement } from "aframe";

Просмотреть файл

@ -7,7 +7,7 @@ import ducky from "./assets/models/DuckyMesh.glb";
import { EventTarget } from "event-target-shim";
import { ExitReason } from "./react-components/room/ExitedRoomScreen";
import { LogMessageType } from "./react-components/room/ChatSidebar";
import { createNetworkedEntity } from "./bit-systems/networking";
import { createNetworkedEntity } from "./utils/create-networked-entity";
let uiRoot;
// Handles user-entered messages

20
src/prefabs/prefabs.ts Normal file
Просмотреть файл

@ -0,0 +1,20 @@
import { MediaLoaderParams } from "../inflators/media-loader";
import { CameraPrefab, CubeMediaFramePrefab } from "../prefabs/camera-tool";
import { MediaPrefab } from "../prefabs/media";
import { EntityDef } from "../utils/jsx-entity";
type CameraPrefabT = () => EntityDef;
type CubeMediaPrefabT = () => EntityDef;
type MediaPrefabT = (params: MediaLoaderParams) => EntityDef;
export type PrefabDefinition = {
permission?: "spawn_camera";
template: CameraPrefabT | CubeMediaPrefabT | MediaPrefabT;
};
export type PrefabName = "camera" | "cube" | "media";
export const prefabs = new Map<PrefabName, PrefabDefinition>();
prefabs.set("camera", { permission: "spawn_camera", template: CameraPrefab });
prefabs.set("cube", { template: CubeMediaFramePrefab });
prefabs.set("media", { template: MediaPrefab });

Просмотреть файл

@ -2,7 +2,7 @@ import qsTruthy from "./utils/qs_truthy";
import nextTick from "./utils/next-tick";
import { hackyMobileSafariTest } from "./utils/detect-touchscreen";
import { SignInMessages } from "./react-components/auth/SignInModal";
import { createNetworkedEntity } from "./bit-systems/networking";
import { createNetworkedEntity } from "./utils/create-networked-entity";
const isBotMode = qsTruthy("bot");
const isMobile = AFRAME.utils.device.isMobile();

Просмотреть файл

@ -21,7 +21,7 @@ import {
ConstraintRemoteLeft,
ConstraintRemoteRight
} from "../bit-components";
import { takeOwnership } from "../bit-systems/networking";
import { takeOwnership } from "../utils/take-ownership";
const queryRemoteRight = defineQuery([HeldRemoteRight, OffersRemoteConstraint]);
const queryEnterRemoteRight = enterQuery(queryRemoteRight);

Просмотреть файл

@ -16,7 +16,7 @@ import { addObject3DComponent } from "../utils/jsx-entity";
import { updateMaterials } from "../utils/material-utils";
import { MediaType } from "../utils/media-utils";
import { cloneObject3D, setMatrixWorld } from "../utils/three-utils";
import { takeOwnership } from "../bit-systems/networking";
import { takeOwnership } from "../utils/take-ownership";
const EMPTY_COLOR = 0x6fc0fd;
const HOVER_COLOR = 0x2f80ed;

Просмотреть файл

@ -32,7 +32,8 @@ import { EnvironmentSystem } from "./environment-system";
import { NameTagVisibilitySystem } from "./name-tag-visibility-system";
// new world
import { networkSendSystem, networkReceiveSystem } from "../bit-systems/networking";
import { networkReceiveSystem } from "../bit-systems/network-receive-system";
import { networkSendSystem } from "../bit-systems/network-send-system";
import { onOwnershipLost } from "./on-ownership-lost";
import { interactionSystem } from "./bit-interaction-system";
import { floatyObjectSystem } from "./floaty-object-system";

Просмотреть файл

@ -1,5 +1,4 @@
import { defineQuery, hasComponent } from "bitecs";
import { $isStringType, NetworkedMediaFrame } from "../bit-components";
import { findAncestor } from "./three-utils";
const queries = new Map();
@ -19,85 +18,6 @@ export function hasAnyComponent(world, components, eid) {
return false;
}
// TODO HACK gettting internal bitecs symbol, should expose createShadow
const $parentArray = Object.getOwnPropertySymbols(NetworkedMediaFrame.scale).find(s => s.description == "parentArray");
const $storeFlattened = Object.getOwnPropertySymbols(NetworkedMediaFrame).find(s => s.description == "storeFlattened");
const createShadow = (store, key) => {
if (!ArrayBuffer.isView(store)) {
const shadowStore = store[$parentArray].slice(0);
store[key] = store.map((_, eid) => {
const { length } = store[eid];
const start = length * eid;
const end = start + length;
return shadowStore.subarray(start, end);
});
} else {
store[key] = store.slice(0);
}
return key;
};
// TODO this array encoding is silly, use a buffer once we are not sending JSON
export function defineNetworkSchema(Component) {
const componentProps = Component[$storeFlattened];
const shadowSymbols = componentProps.map((prop, i) => {
return createShadow(prop, Symbol(`netshadow-${i}`));
});
return {
serialize(_world, eid, data, isFullSync, writeToShadow) {
const changedPids = [];
data.push(changedPids);
for (let pid = 0; pid < componentProps.length; pid++) {
const prop = componentProps[pid];
const shadow = prop[shadowSymbols[pid]];
// if property is an array
if (ArrayBuffer.isView(prop[eid])) {
for (let i = 0; i < prop[eid].length; i++) {
if (isFullSync || shadow[eid][i] !== prop[eid][i]) {
changedPids.push(pid);
// TODO handle EID type and arrays of strings
data.push(Array.from(prop[eid]));
break;
}
}
if (writeToShadow) shadow[eid].set(prop[eid]);
} else {
if (isFullSync || shadow[eid] !== prop[eid]) {
changedPids.push(pid);
// TODO handle EID type
data.push(prop[$isStringType] ? APP.getString(prop[eid]) : prop[eid]);
}
if (writeToShadow) shadow[eid] = prop[eid];
}
}
if (!changedPids.length) {
data.pop();
return false;
}
return true;
},
deserialize(_world, eid, data) {
const updatedPids = data[data.cursor++];
for (let i = 0; i < updatedPids.length; i++) {
const pid = updatedPids[i];
const prop = componentProps[pid];
const shadow = prop[shadowSymbols[pid]];
// TODO updating the shadow here is slightly odd. Should taking ownership do it?
if (ArrayBuffer.isView(prop[eid])) {
prop[eid].set(data[data.cursor++]);
shadow[eid].set(prop[eid]);
} else {
const val = data[data.cursor++];
prop[eid] = prop[$isStringType] ? APP.getSid(val) : val;
shadow[eid] = prop[eid];
}
}
}
};
}
export function findAncestorEntity(world, eid, predicate) {
const obj = findAncestor(world.eid2obj.get(eid), o => o.eid && predicate(o.eid));
return obj && obj.eid;

Просмотреть файл

@ -0,0 +1,46 @@
import { hasComponent } from "bitecs";
import { HubsWorld } from "../app";
import { Networked } from "../bit-components";
import { createMessageDatas } from "../bit-systems/networking";
import { PrefabName, prefabs } from "../prefabs/prefabs";
import { renderAsEntity } from "../utils/jsx-entity";
import { hasPermissionToSpawn } from "../utils/permissions";
import { takeOwnership } from "../utils/take-ownership";
import type { ClientID, InitialData } from "./networking-types";
export function createNetworkedEntity(world: HubsWorld, prefabName: PrefabName, initialData: InitialData) {
if (!hasPermissionToSpawn(NAF.clientId, prefabName))
throw new Error(`You do not have permission to spawn ${prefabName}`);
const rootNid = NAF.utils.createNetworkId();
return createNetworkedEntityFromRemote(world, prefabName, initialData, rootNid, NAF.clientId, NAF.clientId);
}
export function createNetworkedEntityFromRemote(
world: HubsWorld,
prefabName: PrefabName,
initialData: InitialData,
rootNid: string,
creator: ClientID,
owner: ClientID
) {
const eid = renderAsEntity(world, prefabs.get(prefabName)!.template(initialData));
const obj = world.eid2obj.get(eid)!;
createMessageDatas.set(eid, { prefabName, initialData });
let i = 0;
obj.traverse(function (o) {
if (o.eid && hasComponent(world, Networked, o.eid)) {
const eid = o.eid;
Networked.id[eid] = APP.getSid(i === 0 ? rootNid : `${rootNid}.${i}`);
APP.world.nid2eid.set(Networked.id[eid], eid);
Networked.creator[eid] = APP.getSid(creator);
Networked.owner[eid] = APP.getSid(owner);
if (NAF.clientId === owner) takeOwnership(world, eid);
i += 1;
}
});
AFRAME.scenes[0].object3D.add(obj);
return eid;
}

Просмотреть файл

@ -0,0 +1,80 @@
import { $isStringType, NetworkedMediaFrame } from "../bit-components";
// TODO HACK gettting internal bitecs symbol, should expose createShadow
const $parentArray = Object.getOwnPropertySymbols(NetworkedMediaFrame.scale).find(s => s.description == "parentArray");
const $storeFlattened = Object.getOwnPropertySymbols(NetworkedMediaFrame).find(s => s.description == "storeFlattened");
const createShadow = (store, key) => {
if (!ArrayBuffer.isView(store)) {
const shadowStore = store[$parentArray].slice(0);
store[key] = store.map((_, eid) => {
const { length } = store[eid];
const start = length * eid;
const end = start + length;
return shadowStore.subarray(start, end);
});
} else {
store[key] = store.slice(0);
}
return key;
};
// TODO this array encoding is silly, use a buffer once we are not sending JSON
export function defineNetworkSchema(Component) {
const componentProps = Component[$storeFlattened];
const shadowSymbols = componentProps.map((prop, i) => {
return createShadow(prop, Symbol(`netshadow-${i}`));
});
return {
serialize(_world, eid, data, isFullSync, writeToShadow) {
const changedPids = [];
data.push(changedPids);
for (let pid = 0; pid < componentProps.length; pid++) {
const prop = componentProps[pid];
const shadow = prop[shadowSymbols[pid]];
// if property is an array
if (ArrayBuffer.isView(prop[eid])) {
for (let i = 0; i < prop[eid].length; i++) {
if (isFullSync || shadow[eid][i] !== prop[eid][i]) {
changedPids.push(pid);
// TODO handle EID type and arrays of strings
data.push(Array.from(prop[eid]));
break;
}
}
if (writeToShadow) shadow[eid].set(prop[eid]);
} else {
if (isFullSync || shadow[eid] !== prop[eid]) {
changedPids.push(pid);
// TODO handle EID type
data.push(prop[$isStringType] ? APP.getString(prop[eid]) : prop[eid]);
}
if (writeToShadow) shadow[eid] = prop[eid];
}
}
if (!changedPids.length) {
data.pop();
return false;
}
return true;
},
deserialize(_world, eid, data) {
const updatedPids = data[data.cursor++];
for (let i = 0; i < updatedPids.length; i++) {
const pid = updatedPids[i];
const prop = componentProps[pid];
const shadow = prop[shadowSymbols[pid]];
// TODO updating the shadow here is slightly odd. Should taking ownership do it?
if (ArrayBuffer.isView(prop[eid])) {
prop[eid].set(data[data.cursor++]);
shadow[eid].set(prop[eid]);
} else {
const val = data[data.cursor++];
prop[eid] = prop[$isStringType] ? APP.getSid(val) : val;
shadow[eid] = prop[eid];
}
}
}
};
}

Просмотреть файл

@ -91,7 +91,7 @@ type Attrs = {
ref?: Ref;
};
type EntityDef = {
export type EntityDef = {
components: JSXComponentData;
attrs: Attrs;
children: EntityDef[];

Просмотреть файл

@ -0,0 +1,46 @@
import { pendingJoins, pendingMessages, pendingParts } from "../bit-systems/networking";
import type { Message } from "./networking-types";
type Emitter = {
on: (event: string, callback: (a: any) => any) => number;
off: (event: string, ref: number) => void;
trigger: (event: string, payload: any) => void;
getBindings: () => any[];
};
type PhoenixChannel = any;
export function listenForNetworkMessages(channel: PhoenixChannel, presenceEventEmitter: Emitter) {
presenceEventEmitter.on("hub:join", ({ key: nid }) => {
// TODO: Is it OK to use join events for our own client id?
pendingJoins.push(APP.getSid(nid));
});
presenceEventEmitter.on("hub:leave", ({ key: nid }) => {
pendingParts.push(APP.getSid(nid));
});
channel.on("naf", onNaf);
channel.on("nafr", onNafr);
}
type NafMessage = {
from_session_id: string;
data: any;
dataType: string;
source: string;
};
function onNaf({ from_session_id, data, dataType }: NafMessage) {
if (dataType == "nn") {
(data as Message).fromClientId = from_session_id;
pendingMessages.push(data);
}
}
type NafrMessage = {
from_session_id: string;
naf: string;
parsed?: NafMessage;
};
function onNafr(message: NafrMessage) {
const { from_session_id, naf: unparsedData } = message;
// Attach the parsed JSON to the message so that
// PhoenixAdapter can process it without parsing it again.
message.parsed = JSON.parse(unparsedData);
message.parsed!.from_session_id = from_session_id;
onNaf(message.parsed!);
}

65
src/utils/message-for.ts Normal file
Просмотреть файл

@ -0,0 +1,65 @@
import { hasComponent } from "bitecs";
import { HubsWorld } from "../app";
import { Networked } from "../bit-components";
import { createMessageDatas } from "../bit-systems/networking";
import { networkableComponents, schemas } from "./network-schemas";
import type { EntityID, Message, UpdateMessage } from "./networking-types";
export function messageFor(
world: HubsWorld,
created: EntityID[],
updated: EntityID[],
needsFullSyncUpdate: EntityID[],
deleted: EntityID[],
isBroadcast: boolean
) {
const message: Message = {
creates: [],
updates: [],
deletes: []
};
created.forEach(eid => {
const { prefabName, initialData } = createMessageDatas.get(eid)!;
message.creates.push([APP.getString(Networked.id[eid])!, prefabName, initialData]);
});
updated.forEach(eid => {
const updateMessage: UpdateMessage = {
nid: APP.getString(Networked.id[eid])!,
lastOwnerTime: Networked.lastOwnerTime[eid],
timestamp: Networked.timestamp[eid],
owner: APP.getString(Networked.owner[eid])!,
creator: APP.getString(Networked.creator[eid])!,
componentIds: [],
data: []
};
const isFullSync = needsFullSyncUpdate.includes(eid);
for (let j = 0; j < networkableComponents.length; j++) {
const component = networkableComponents[j];
if (hasComponent(world, component, eid)) {
if (schemas.get(component)!.serialize(world, eid, updateMessage.data, isFullSync, isBroadcast)) {
updateMessage.componentIds.push(j);
}
}
}
// TODO: If the owner/lastOwnerTime changed, we need to send this updateMessage
if (updateMessage.componentIds.length) {
message.updates.push(updateMessage);
}
});
deleted.forEach(eid => {
// TODO: We are reading component data of a deleted entity here.
const nid = Networked.id[eid];
message.deletes.push(APP.getString(nid)!);
});
if (message.creates.length || message.updates.length || message.deletes.length) {
return message;
}
return null;
}

Просмотреть файл

@ -0,0 +1,22 @@
import { Component } from "bitecs";
import { HubsWorld } from "../app";
import { NetworkedMediaFrame, NetworkedTransform, NetworkedVideo } from "../bit-components";
import { defineNetworkSchema } from "../utils/define-network-schema";
import type { EntityID, CursorBuffer } from "./networking-types";
interface NetworkSchema {
serialize: (
world: HubsWorld,
eid: EntityID,
data: CursorBuffer,
isFullSync: boolean,
writeToShadow: boolean
) => boolean;
deserialize: (world: HubsWorld, eid: EntityID, data: CursorBuffer) => void;
}
export const schemas: Map<Component, NetworkSchema> = new Map();
schemas.set(NetworkedMediaFrame, defineNetworkSchema(NetworkedMediaFrame));
schemas.set(NetworkedTransform, defineNetworkSchema(NetworkedTransform));
schemas.set(NetworkedVideo, defineNetworkSchema(NetworkedVideo));
export const networkableComponents = Array.from(schemas.keys());

Просмотреть файл

@ -0,0 +1,29 @@
import { PrefabName } from "../prefabs/prefabs";
export type EntityID = number;
export type InitialData = any;
export interface CreateMessageData {
prefabName: PrefabName;
initialData: InitialData;
}
export type ClientID = string;
export type NetworkID = string;
export type StringID = number;
export type CreateMessage = [networkId: NetworkID, prefabName: PrefabName, initialData: InitialData];
export type CursorBuffer = { cursor?: number; push: (data: any) => {} };
export type UpdateMessage = {
nid: NetworkID;
lastOwnerTime: number;
timestamp: number;
owner: ClientID;
creator: ClientID;
componentIds: number[];
data: CursorBuffer;
};
export type DeleteMessage = NetworkID;
export type Message = {
fromClientId?: ClientID;
creates: CreateMessage[];
updates: UpdateMessage[];
deletes: DeleteMessage[];
};

7
src/utils/permissions.ts Normal file
Просмотреть файл

@ -0,0 +1,7 @@
import { PrefabName, prefabs } from "../prefabs/prefabs";
import type { ClientID } from "./networking-types";
export function hasPermissionToSpawn(creator: ClientID, prefabName: PrefabName) {
const perm = prefabs.get(prefabName)!.permission;
return !perm || APP.hubChannel!.userCan(creator, perm);
}

Просмотреть файл

@ -0,0 +1,14 @@
import { addComponent, hasComponent } from "bitecs";
import { HubsWorld } from "../app";
import { AEntity, Networked, Owned } from "../bit-components";
import type { EntityID } from "./networking-types";
export function takeOwnershipWithTime(world: HubsWorld, eid: EntityID, timestamp: number) {
if (hasComponent(world, AEntity, eid)) {
throw new Error("Cannot take ownership of AEntities with a specific timestamp.");
}
addComponent(world, Owned, eid);
Networked.lastOwnerTime[eid] = timestamp;
Networked.owner[eid] = APP.getSid(NAF.clientId);
}

Просмотреть файл

@ -0,0 +1,17 @@
import { addComponent, hasComponent } from "bitecs";
import { HubsWorld } from "../app";
import { AEntity, Networked, Owned } from "../bit-components";
import { getServerTime } from "../phoenix-adapter";
import type { EntityID } from "./networking-types";
export function takeOwnership(world: HubsWorld, eid: EntityID) {
// TODO we do this to have a single API for taking ownership of things in new code, but it obviously relies on NAF/AFrame
if (hasComponent(world, AEntity, eid)) {
const el = world.eid2obj.get(eid)!.el!;
!NAF.utils.isMine(el) && NAF.utils.takeOwnership(el);
} else {
addComponent(world, Owned, eid);
Networked.lastOwnerTime[eid] = Math.max(getServerTime(), Networked.lastOwnerTime[eid] + 1);
Networked.owner[eid] = APP.getSid(NAF.clientId);
}
}