Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved modularity, disposability and multi-instance support #45

Merged
merged 3 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:prettier/recommended",
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended'
Expand Down
67 changes: 67 additions & 0 deletions example/local-multi-web-client/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as esbuild from "esbuild";
import { copy } from "esbuild-plugin-copy";

const buildMode = "--build";
const watchMode = "--watch";

const helpString = `Mode must be provided as one of ${buildMode} or ${watchMode}`;

const args = process.argv.splice(2);

if (args.length !== 1) {
console.error(helpString);
process.exit(1);
}

const mode = args[0];

const buildOptions: esbuild.BuildOptions = {
entryPoints: {
index: "src/index.ts",
},
bundle: true,
write: true,
sourcemap: "linked",
outdir: "./build/",
assetNames: "[dir]/[name]-[hash]",
preserveSymlinks: true,
loader: {
".html": "text",
".svg": "file",
".png": "file",
".jpg": "file",
".glb": "file",
".hdr": "file",
},
outbase: "../",
sourceRoot: "./src",
publicPath: "/local-multi-web-client/",
plugins: [
copy({
resolveFrom: "cwd",
assets: {
from: ["./public/**/*"],
to: ["./build/"],
},
}),
],
};

switch (mode) {
case buildMode:
esbuild.build(buildOptions).catch(() => process.exit(1));
break;
case watchMode:
esbuild
.context({
...buildOptions,
banner: {
js: ` (() => new WebSocket((window.location.protocol === "https:" ? "wss://" : "ws://")+window.location.host+'/local-multi-web-client-build').addEventListener('message', () => location.reload()))();`,
},
})
.then((context) => context.watch())
.catch(() => process.exit(1));
break;
default:
console.error(helpString);
}
27 changes: 27 additions & 0 deletions example/local-multi-web-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@example/local-multi-web-client",
"private": true,
"version": "0.8.0",
"files": [
"/build"
],
"type": "module",
"scripts": {
"build": "rimraf ./build && tsx ./build.ts --build",
"iterate": "tsx ./build.ts --watch",
"type-check": "tsc --noEmit",
"lint": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --max-warnings 0",
"lint-fix": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --fix"
},
"dependencies": {
"@mml-io/3d-web-client-core": "^0.8.0",
"@mml-io/3d-web-text-chat": "^0.8.0",
"@mml-io/3d-web-user-networking": "^0.8.0",
"@mml-io/3d-web-voice-chat": "^0.8.0",
"mml-web-runner": "0.9.0",
"three": "0.153.0"
},
"devDependencies": {
"@types/three": "0.153.0"
}
}
1 change: 1 addition & 0 deletions example/local-multi-web-client/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions example/local-multi-web-client/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg" href="/local-multi-web-client/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MML 3D Web Experience</title>
<link rel="stylesheet" href="/local-multi-web-client/style.css" />
</head>
<body>
<div id="app"></div>
<div id="text-chat-ui"></div>
<div id="voice-chat-ui"></div>
<script type="application/javascript" src="/local-multi-web-client/index.js"></script>
</body>
</html>
56 changes: 56 additions & 0 deletions example/local-multi-web-client/public/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: black;
background-color: #121212;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}

a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}

body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}

h1 {
font-size: 3.2em;
line-height: 1.1;
}

#app {
max-width: 100vw;
margin: 0 auto;
padding: 0;
text-align: center;
}

@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
184 changes: 184 additions & 0 deletions example/local-multi-web-client/src/LocalAvatarClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {
CameraManager,
CharacterDescription,
CharacterManager,
CharacterModelLoader,
CharacterState,
CollisionsManager,
Composer,
KeyInputManager,
MMLCompositionScene,
TimeManager,
} from "@mml-io/3d-web-client-core";
import { EditableNetworkedDOM, NetworkedDOM } from "@mml-io/networked-dom-document";
import { MMLWebRunnerClient } from "mml-web-runner";
import { AudioListener, Euler, Scene, Vector3 } from "three";

import hdrUrl from "../../assets/hdr/industrial_sunset_2k.hdr";
import airAnimationFileUrl from "../../assets/models/unreal-air.glb";
import idleAnimationFileUrl from "../../assets/models/unreal-idle.glb";
import jogAnimationFileUrl from "../../assets/models/unreal-jog.glb";
import meshFileUrl from "../../assets/models/unreal-mesh.glb";
import sprintAnimationFileUrl from "../../assets/models/unreal-run.glb";

import { LocalAvatarServer } from "./LocalAvatarServer";
import { Room } from "./Room";

const characterDescription: CharacterDescription = {
airAnimationFileUrl,
idleAnimationFileUrl,
jogAnimationFileUrl,
meshFileUrl,
sprintAnimationFileUrl,
modelScale: 1,
};

export class LocalAvatarClient {
public element: HTMLDivElement;

private readonly scene = new Scene();
private readonly audioListener = new AudioListener();
private readonly characterModelLoader = new CharacterModelLoader();
public readonly composer: Composer;
private readonly timeManager = new TimeManager();
private readonly keyInputManager = new KeyInputManager(() => {
return this.cameraManager.dragging;
});
private readonly characterManager: CharacterManager;
private readonly cameraManager: CameraManager;

private readonly collisionsManager = new CollisionsManager(this.scene);
private readonly remoteUserStates = new Map<number, CharacterState>();

private mmlComposition: MMLCompositionScene;
private resizeObserver: ResizeObserver;
private documentRunnerClients = new Set<MMLWebRunnerClient>();
private animationFrameRequest: number | null = null;

constructor(
private localAvatarServer: LocalAvatarServer,
private localClientId: number,
spawnPosition: Vector3,
spawnRotation: Euler,
) {
this.element = document.createElement("div");
this.element.style.position = "absolute";
this.element.style.width = "100%";
this.element.style.height = "100%";

document.addEventListener("mousedown", () => {
if (this.audioListener.context.state === "suspended") {
this.audioListener.context.resume();
}
});

this.cameraManager = new CameraManager(
this.element,
this.collisionsManager,
Math.PI / 2,
Math.PI / 2,
);
this.cameraManager.camera.add(this.audioListener);

this.composer = new Composer(this.scene, this.cameraManager.camera, true);
this.composer.useHDRI(hdrUrl);
this.element.appendChild(this.composer.renderer.domElement);

this.resizeObserver = new ResizeObserver(() => {
this.composer.fitContainer();
});
this.resizeObserver.observe(this.element);

this.localAvatarServer.addClient(
localClientId,
(clientId: number, userNetworkingClientUpdate: null | CharacterState) => {
if (userNetworkingClientUpdate === null) {
this.remoteUserStates.delete(clientId);
} else {
this.remoteUserStates.set(clientId, userNetworkingClientUpdate);
}
},
);

this.characterManager = new CharacterManager(
this.composer,
this.characterModelLoader,
this.collisionsManager,
this.cameraManager,
this.timeManager,
this.keyInputManager,
this.remoteUserStates,
(characterState: CharacterState) => {
localAvatarServer.send(localClientId, characterState);
},
);
this.scene.add(this.characterManager.group);

this.mmlComposition = new MMLCompositionScene(
this.element,
this.composer.renderer,
this.scene,
this.cameraManager.camera,
this.audioListener,
this.collisionsManager,
() => {
return this.characterManager.getLocalCharacterPositionAndRotation();
},
);
this.scene.add(this.mmlComposition.group);

const room = new Room();
this.collisionsManager.addMeshesGroup(room);
this.scene.add(room);

this.characterManager.spawnCharacter(
characterDescription!,
localClientId,
true,
spawnPosition,
spawnRotation,
);
}

public dispose() {
if (this.animationFrameRequest !== null) {
cancelAnimationFrame(this.animationFrameRequest);
}
for (const documentRunnerClient of this.documentRunnerClients) {
documentRunnerClient.dispose();
}
this.localAvatarServer.removeClient(this.localClientId);
this.documentRunnerClients.clear();
this.resizeObserver.disconnect();
this.mmlComposition.dispose();
this.characterManager.clear();
this.cameraManager.dispose();
this.composer.dispose();
this.element.remove();
}

public update(): void {
this.timeManager.update();
this.characterManager.update();
this.cameraManager.update();
this.composer.sun?.updateCharacterPosition(this.characterManager.character?.position);
this.composer.render(this.timeManager);
this.animationFrameRequest = requestAnimationFrame(() => {
this.update();
});
}

public addDocument(
mmlDocument: NetworkedDOM | EditableNetworkedDOM,
windowTarget: Window,
remoteHolderElement: HTMLElement,
) {
const mmlWebRunnerClient = new MMLWebRunnerClient(
windowTarget,
remoteHolderElement,
this.mmlComposition.mmlScene,
);
mmlWebRunnerClient.connect(mmlDocument);
this.documentRunnerClients.add(mmlWebRunnerClient);
}
}
27 changes: 27 additions & 0 deletions example/local-multi-web-client/src/LocalAvatarServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CharacterState } from "@mml-io/3d-web-client-core";

export class LocalAvatarServer {
private callbacks = new Map<
number,
(clientId: number, userNetworkingClientUpdate: null | CharacterState) => void
>();

send(clientId: number, userNetworkingClientUpdate: null | CharacterState) {
this.callbacks.forEach((callback, callbackClientId) => {
if (callbackClientId !== clientId) {
callback(clientId, userNetworkingClientUpdate);
}
});
}

addClient(
clientId: number,
callback: (clientId: number, userNetworkingClientUpdate: null | CharacterState) => void,
) {
this.callbacks.set(clientId, callback);
}

removeClient(clientId: number) {
this.callbacks.delete(clientId);
}
}
Loading