Add support for hot reload of modified code (#49)

* Make copy of hello-world sample as basis for debuggable hot reloading sample

* Update the npm run debug script to watch for file changes

* Add VSCode launch configuration for attaching debugger to hot reloadable sample

* Add 1 second delay on launch when debugging to allow debugger time to connect

* Update README for debuggable hot reload sample

* Delete hello-world-with-debuggable-hot-reload sample in favor of making existing samples hot reloadable

* Update sample npm run debug scripts to watch for file system changes

* Avoid race condition between tsc compiler and nodemod file system watcher

* Add 1 second delay on launch when debugging to allow debugger time to connect

* Modify VSCode launch configurations to allow debugging while hot reloading

* Improve sample READMEs to include debugging instructions

* Update VSCode launch config to include both attach and launch configurations

* Move concurrently from dependencies to devDependencies

* Create separate debug-watch and debug commands

* Add attach configs to launch.json while keeping existing attach configs intact

* Only need one attach config for debugger

* Make copy of hello-world sample as basis for debuggable hot reloading sample

* Update the npm run debug script to watch for file changes

* Add VSCode launch configuration for attaching debugger to hot reloadable sample

* Add 1 second delay on launch when debugging to allow debugger time to connect

* Update README for debuggable hot reload sample

* Delete hello-world-with-debuggable-hot-reload sample in favor of making existing samples hot reloadable

* Update sample npm run debug scripts to watch for file system changes

* Avoid race condition between tsc compiler and nodemod file system watcher

* Add 1 second delay on launch when debugging to allow debugger time to connect

* Modify VSCode launch configurations to allow debugging while hot reloading

* Improve sample READMEs to include debugging instructions

* Update VSCode launch config to include both attach and launch configurations

* Move concurrently from dependencies to devDependencies

* Create separate debug-watch and debug commands

* Add attach configs to launch.json while keeping existing attach configs intact

* Only need one attach config for debugger

* Tweaks to hot reload and docs

* apostrophe

* Update package-lock.json

* Remove accidentally added eslint-plugin-header package

* Update package-lock.json files

* Remove startup delay from app.ts

* Add startup delay to other samples.

* Fix line endings.

* Remove opinionated text.

* Propagate doc updates to other samples.

Co-authored-by: Eric Anderson <eanders@microsoft.com>
This commit is contained in:
Don Alvarez 2020-04-03 09:34:51 -07:00 коммит произвёл GitHub
Родитель 470417ece9
Коммит 5be14ed370
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
23 изменённых файлов: 6387 добавлений и 1320 удалений

42
.vscode/launch.json поставляемый
Просмотреть файл

@ -4,22 +4,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch tic-tac-toe project",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"debug"
],
"port": 9229,
"cwd": "${workspaceFolder}/samples/tic-tac-toe",
"internalConsoleOptions": "openOnSessionStart",
"autoAttachChildProcesses": true,
"console": "internalConsole",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
@ -52,6 +36,22 @@
"console": "internalConsole",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Launch tic-tac-toe project",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"debug"
],
"port": 9229,
"cwd": "${workspaceFolder}/samples/tic-tac-toe",
"internalConsoleOptions": "openOnSessionStart",
"autoAttachChildProcesses": true,
"console": "internalConsole",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
@ -67,6 +67,14 @@
"autoAttachChildProcesses": true,
"console": "internalConsole",
"outputCapture": "std"
}
},
{
"type": "node",
"request": "attach",
"name": "Attach to running project",
"port": 9229,
"internalConsoleOptions": "openOnSessionStart",
"restart": true,
},
]
}

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

@ -1,11 +1,10 @@
## Building Sample Apps Against SDK Source Code
If you wish to build and debug the samples against the cloced source code for the SDK, we have provided a script to help
If you wish to build and debug the samples against the cloned source code for the SDK, we have provided a script to help
you to point your app to the local source code package of the SDK rather than the one published to NPM. To do this you
will first need to clone the [Mixed Reality Extension SDK](https://github.com/Microsoft/mixed-reality-extension-sdk)
and add a sdk-path-config.json file that contains the json `{ "sdkPath": "<path_to_sdk>"}` where the `<path_to_sdk>` is
the root folder of your SDK repo clone. This json file should be added to the `./scripts` directory where the
helper script is located.
the root folder of your SDK repo clone. This json file should be added to the `./scripts` directory where the helper script is located.
See `sdk-path-config-example.json` as an example that assumes you have cloned the SDK and sample repositories with their default names into the same parent folder.
Once added you can simply run the following npm scripts within the `./scripts` directory to
@ -14,4 +13,4 @@ switch between SDK source code and the NPM package for the sample apps.
- `npm run use-sdk-source` to use the SDK source code.
- `npm run use-sdk-npm` to use the SDK NPM package.
The script requires shelljs to be installed, so you may need to use `npm install shelljs`.
The script requires shelljs to be installed, so you may need to use `npm install shelljs`.

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

@ -23,7 +23,7 @@ little if there are no changes)
In AltspaceVR
* Go to your personal home
* Make sure you are signed in properly, not a guest
* Activate the Space Editor
* Activate the Space Editor (only available if you indicate you want to participate in the Early Access Program in your AltspaceVR settings)
* Click Basics group
* Click on SDKApp
* For the URL field, enter `ws://localhost:3901`
@ -47,10 +47,10 @@ To learn more about the SDK, please read the [MRE SDK readme](
https://github.com/Microsoft/mixed-reality-extension-sdk/blob/master/README.md).
## Sample Descriptions
* Hello World - shows a text that animates when highlighting or clicking on a
cube
* Solar System - loads a 3d model for each planet, generates keyframe
animations, and when all assets are ready, start all animations simultaneously.
* Hello World - Shows text and a cube that animates when highlighted or clicked. Demonstrates basic scene creation and interaction.
* Solar System - Loads a 3d model for each planet and animates planetary motion. Demonstrates animation generation and more advanced scene creation.
* Tic-Tac-Toe - The classic game also known as "Noughts & Crosses". Demonstrates gameplay with win/lose conditions.
* Wear A Hat - Users can choose a hat from a menu and it will appear on their head. Demonstrates attachments.
## Contributing

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

@ -1,15 +1,54 @@
Shows a text that animates when highlighting or clicking on a cube.
## Editing
## Setup
* Open this folder in VSCode.
* Open a command prompt to this sample's folder and run `npm install`. Keep the command prompt open if you wish to follow the command-oriented instructions that follow.
* Open the root folder of this repo in VSCode if you wish to follow the VSCode-oriented instructions.
## Building
## Build
* From inside VSCode: `Shift+Ctrl+B`
* From command line: `npm run build`
* Command line: `npm run build`.
* VSCode: `Shift+Ctrl+B`, then select 'build samples/hello-world'.
## Running
## Run
* From inside VSCode: `F5`
* From command line: `npm start`
* Command line: `npm start`.
* VSCode: Switch to the 'Run' tab (`Ctrl+Shift+D` will open it), select 'Launch hello-world project' from the dropdown at the top, and then `F5` to start it.
MRE apps are NodeJS servers. They operate akin to a web server. When you start your MRE, it won't do much until you connect to it from a client application like AltspaceVR or the MRETestBed.
## Test in [AltspaceVR](https://altvr.com)
* Download [AltspaceVR](https://altvr.com) and create an account.
* Launch the application and sign in. You'll start in your "home space".
* Open the World Editor (only available if you indicate you want to participate in the [Early Access Program](https://altvr.com/early-access-program/) in your AltspaceVR settings).
* Add a `Basics` / `SDK App` object with a Target URI of `ws://127.0.0.1:3901`.
* See the the app appear in your space.
## Test in Unity using the [MRETestBed](https://www.github.com/mixed-reality-extension-sdk-samples)
* Install [Unity](https://unity3d.com/get-unity/download), version 2019.2.12f1 or later.
* Clone the [MRE Unity repo](https://github.com/microsoft/mixed-reality-extension-unity).
* Open the 'MRETestBed' Unity project.
* Select the 'Standalone' scene. This scene is preconfigured to connect to your MRE running locally.
* Start the Unity scene, and see the app appear.
## Advanced: Debug with Hot-Reload
Whether developing an MRE or another kind of app, an efficient dev/test loop is essential. Devs familiar with making browser-based apps will be familiar with webpack's notion of "hot reload", that is, automatically applying changes as they're made without the need to explicitly stop/rebuild/restart your app. NodeJS apps can do this too.
This setup requires launching the app from a terminal. VSCode has a built-in terminal, or you can open a separate command prompt.
### Start the MRE with hot-reload enabled
1. In the terminal, in this project's folder, run: `npm run debug-watch`. This will build and start the MRE. The `debug-watch` task continues to run in the background, watching for code changes. It will rebuild and restart the app whenever files are modified.
2. In VSCode, press `Ctrl+Shift+D` to open the 'Run' tab, select 'Attach to running project' from the drop down at the top, then press `F5` to attach the VSCode debugger. This step isn't required, but allows you to set breakpoints and debug MRE execution.
### See hot-reload in action
Once you have your MRE up and running, and you've successfully spawned an instance in AltspaceVR or another supported platform, it is time to make some code changes and see hot reload in action:
* In VSCode, open `samples/hello-world/app.ts`.
* In the `App.started()` method, find the line that reads `contents: "Hello World!",` and change the string to `"Hello Brave New World!"`.
* Save the file.
* Watch how the changes to your code are automatically detected and reloaded. The text in your app should change to `"Hello Brave New World!"`

1326
samples/hello-world/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -23,14 +23,18 @@
"clean": "tsc --build --clean",
"build": "tsc --build && eslint --ext .ts src",
"build-only": "tsc --build",
"build-watch": "tsc --build --watch --preserveWatchOutput",
"lint": "eslint --ext .ts src",
"start": "node .",
"debug": "node --nolazy --inspect-brk=9229 ."
"debug": "node --nolazy --inspect-brk=9229 .",
"debug-watch": "npm run build-only && concurrently \"npm run build-watch\" \"nodemon --nolazy --inspect=9229 .\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.17.0",
"@typescript-eslint/parser": "^2.17.0",
"concurrently": "^5.1.0",
"eslint": "^6.8.0",
"nodemon": "^2.0.2",
"typescript": "^3.7.5"
},
"dependencies": {

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

@ -1,134 +1,134 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* The main class of this app. All the logic goes here.
*/
export default class HelloWorld {
private text: MRE.Actor = null;
private cube: MRE.Actor = null;
constructor(private context: MRE.Context, private baseUrl: string) {
this.context.onStarted(() => this.started());
}
/**
* Once the context is "started", initialize the app.
*/
private started() {
// Create a new actor with no mesh, but some text.
this.text = MRE.Actor.Create(this.context, {
actor: {
name: 'Text',
transform: {
app: { position: { x: 0, y: 0.5, z: 0 } }
},
text: {
contents: "Hello World!",
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: { r: 30 / 255, g: 206 / 255, b: 213 / 255 },
height: 0.3
}
}
});
// Here we create an animation on our text actor. Animations have three mandatory arguments:
// a name, an array of keyframes, and an array of events.
this.text.createAnimation(
// The name is a unique identifier for this animation. We'll pass it to "startAnimation" later.
"Spin", {
// Keyframes define the timeline for the animation: where the actor should be, and when.
// We're calling the generateSpinKeyframes function to produce a simple 20-second revolution.
keyframes: this.generateSpinKeyframes(20, MRE.Vector3.Up()),
// Events are points of interest during the animation. The animating actor will emit a given
// named event at the given timestamp with a given string value as an argument.
events: [],
// Optionally, we also repeat the animation infinitely. PingPong alternately runs the animation
// foward then backward.
wrapMode: MRE.AnimationWrapMode.PingPong
});
// Load a glTF model
this.cube = MRE.Actor.CreateFromGltf(new MRE.AssetContainer(this.context), {
// at the given URL
uri: `${this.baseUrl}/altspace-cube.glb`,
// and spawn box colliders around the meshes.
colliderType: 'box',
// Also apply the following generic actor properties.
actor: {
name: 'Altspace Cube',
// Parent the glTF model to the text actor.
parentId: this.text.id,
transform: {
local: {
position: { x: 0, y: -1, z: 0 },
scale: { x: 0.4, y: 0.4, z: 0.4 }
}
}
}
});
// Create some animations on the cube.
this.cube.createAnimation(
'DoAFlip', {
keyframes: this.generateSpinKeyframes(1.0, MRE.Vector3.Right()),
events: []
});
// Now that the text and its animation are all being set up, we can start playing
// the animation.
this.text.enableAnimation('Spin');
// Set up cursor interaction. We add the input behavior ButtonBehavior to the cube.
// Button behaviors have two pairs of events: hover start/stop, and click start/stop.
const buttonBehavior = this.cube.setBehavior(MRE.ButtonBehavior);
// Trigger the grow/shrink animations on hover.
buttonBehavior.onHover('enter', () => {
this.cube.animateTo(
{ transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } },
0.3,
MRE.AnimationEaseCurves.EaseOutSine);
});
buttonBehavior.onHover('exit', () => {
this.cube.animateTo(
{ transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } },
0.3,
MRE.AnimationEaseCurves.EaseOutSine);
});
// When clicked, do a 360 sideways.
buttonBehavior.onClick(_ => {
this.cube.enableAnimation('DoAFlip');
});
}
/**
* Generate keyframe data for a simple spin animation.
* @param duration The length of time in seconds it takes to complete a full revolution.
* @param axis The axis of rotation in local space.
*/
private generateSpinKeyframes(duration: number, axis: MRE.Vector3): MRE.AnimationKeyframe[] {
return [{
time: 0 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 0) } } }
}, {
time: 0.25 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI / 2) } } }
}, {
time: 0.5 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI) } } }
}, {
time: 0.75 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 3 * Math.PI / 2) } } }
}, {
time: 1 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 2 * Math.PI) } } }
}];
}
}
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* The main class of this app. All the logic goes here.
*/
export default class HelloWorld {
private text: MRE.Actor = null;
private cube: MRE.Actor = null;
constructor(private context: MRE.Context, private baseUrl: string) {
this.context.onStarted(() => this.started());
}
/**
* Once the context is "started", initialize the app.
*/
private started() {
// Create a new actor with no mesh, but some text.
this.text = MRE.Actor.Create(this.context, {
actor: {
name: 'Text',
transform: {
app: { position: { x: 0, y: 0.5, z: 0 } }
},
text: {
contents: "Hello World!",
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: { r: 30 / 255, g: 206 / 255, b: 213 / 255 },
height: 0.3
}
}
});
// Here we create an animation on our text actor. Animations have three mandatory arguments:
// a name, an array of keyframes, and an array of events.
this.text.createAnimation(
// The name is a unique identifier for this animation. We'll pass it to "startAnimation" later.
"Spin", {
// Keyframes define the timeline for the animation: where the actor should be, and when.
// We're calling the generateSpinKeyframes function to produce a simple 20-second revolution.
keyframes: this.generateSpinKeyframes(20, MRE.Vector3.Up()),
// Events are points of interest during the animation. The animating actor will emit a given
// named event at the given timestamp with a given string value as an argument.
events: [],
// Optionally, we also repeat the animation infinitely. PingPong alternately runs the animation
// foward then backward.
wrapMode: MRE.AnimationWrapMode.PingPong
});
// Load a glTF model
this.cube = MRE.Actor.CreateFromGltf(new MRE.AssetContainer(this.context), {
// at the given URL
uri: `${this.baseUrl}/altspace-cube.glb`,
// and spawn box colliders around the meshes.
colliderType: 'box',
// Also apply the following generic actor properties.
actor: {
name: 'Altspace Cube',
// Parent the glTF model to the text actor.
parentId: this.text.id,
transform: {
local: {
position: { x: 0, y: -1, z: 0 },
scale: { x: 0.4, y: 0.4, z: 0.4 }
}
}
}
});
// Create some animations on the cube.
this.cube.createAnimation(
'DoAFlip', {
keyframes: this.generateSpinKeyframes(1.0, MRE.Vector3.Right()),
events: []
});
// Now that the text and its animation are all being set up, we can start playing
// the animation.
this.text.enableAnimation('Spin');
// Set up cursor interaction. We add the input behavior ButtonBehavior to the cube.
// Button behaviors have two pairs of events: hover start/stop, and click start/stop.
const buttonBehavior = this.cube.setBehavior(MRE.ButtonBehavior);
// Trigger the grow/shrink animations on hover.
buttonBehavior.onHover('enter', () => {
this.cube.animateTo(
{ transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } },
0.3,
MRE.AnimationEaseCurves.EaseOutSine);
});
buttonBehavior.onHover('exit', () => {
this.cube.animateTo(
{ transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } },
0.3,
MRE.AnimationEaseCurves.EaseOutSine);
});
// When clicked, do a 360 sideways.
buttonBehavior.onClick(_ => {
this.cube.enableAnimation('DoAFlip');
});
}
/**
* Generate keyframe data for a simple spin animation.
* @param duration The length of time in seconds it takes to complete a full revolution.
* @param axis The axis of rotation in local space.
*/
private generateSpinKeyframes(duration: number, axis: MRE.Vector3): MRE.AnimationKeyframe[] {
return [{
time: 0 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 0) } } }
}, {
time: 0.25 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI / 2) } } }
}, {
time: 0.5 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI) } } }
}, {
time: 0.75 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 3 * Math.PI / 2) } } }
}, {
time: 1 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 2 * Math.PI) } } }
}];
}
}

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

@ -16,11 +16,33 @@ process.on('unhandledRejection', reason => console.log('unhandledRejection', rea
// Read .env if file exists
dotenv.config();
// Start listening for connections, and serve static files
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// This function starts the MRE server. It will be called immediately unless
// we detect that the code is running in a debuggable environment. If so, a
// small delay is introduced allowing time for the debugger to attach before
// the server starts accepting connections.
function runApp() {
// Start listening for connections, and serve static files.
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
}
// Check whether code is running in a debuggable watched filesystem
// environment and if so, delay starting the app by one second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your machine.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
if (isDebug) {
setTimeout(runApp, delay);
} else {
runApp();
}

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

@ -1,18 +1,57 @@
Loads a 3D model for each planet and generates keyframe animations for planetary orbit and rotation. When all assets are ready, start all animations simultaneously.
## Editing
## Setup
* Open this folder in VSCode.
* Open a command prompt to this sample's folder and run `npm install`. Keep the command prompt open if you wish to follow the command-oriented instructions that follow.
* Open the root folder of this repo in VSCode if you wish to follow the VSCode-oriented instructions.
## Building
## Build
* From inside VSCode: `Shift+Ctrl+B`
* From command line: `npm run build`
* Command line: `npm run build`.
* VSCode: `Shift+Ctrl+B`, then select 'build samples/hello-world'.
## Running
## Run
* From inside VSCode: `F5`
* From command line: `npm start`
* Command line: `npm start`.
* VSCode: Switch to the 'Run' tab (`Ctrl+Shift+D` will open it), select 'Launch hello-world project' from the dropdown at the top, and then `F5` to start it.
MRE apps are NodeJS servers. They operate akin to a web server. When you start your MRE, it won't do much until you connect to it from a client application like AltspaceVR or the MRETestBed.
## Test in [AltspaceVR](https://altvr.com)
* Download [AltspaceVR](https://altvr.com) and create an account.
* Launch the application and sign in. You'll start in your "home space".
* Open the World Editor (only available if you indicate you want to participate in the [Early Access Program](https://altvr.com/early-access-program/) in your AltspaceVR settings).
* Add a `Basics` / `SDK App` object with a Target URI of `ws://127.0.0.1:3901`.
* See the the app appear in your space.
## Test in Unity using the [MRETestBed](https://www.github.com/mixed-reality-extension-sdk-samples)
* Install [Unity](https://unity3d.com/get-unity/download), version 2019.2.12f1 or later.
* Clone the [MRE Unity repo](https://github.com/microsoft/mixed-reality-extension-unity).
* Open the 'MRETestBed' Unity project.
* Select the 'Standalone' scene. This scene is preconfigured to connect to your MRE running locally.
* Start the Unity scene, and see the app appear.
## Advanced: Debug with Hot-Reload
Whether developing an MRE or another kind of app, an efficient dev/test loop is essential. Devs familiar with making browser-based apps will be familiar with webpack's notion of "hot reload", that is, automatically applying changes as they're made without the need to explicitly stop/rebuild/restart your app. NodeJS apps can do this too.
This setup requires launching the app from a terminal. VSCode has a built-in terminal, or you can open a separate command prompt.
### Start the MRE with hot-reload enabled
1. In the terminal, in this project's folder, run: `npm run debug-watch`. This will build and start the MRE. The `debug-watch` task continues to run in the background, watching for code changes. It will rebuild and restart the app whenever files are modified.
2. In VSCode, press `Ctrl+Shift+D` to open the 'Run' tab, select 'Attach to running project' from the drop down at the top, then press `F5` to attach the VSCode debugger. This step isn't required, but allows you to set breakpoints and debug MRE execution.
### See hot-reload in action
Once you have your MRE up and running, and you've successfully spawned an instance in AltspaceVR or another supported platform, it is time to make some code changes and see hot reload in action:
* In VSCode, open `samples/solar-system/app.ts`.
* Find the line `contents: bodyName,` near the bottom of the file and change it to `contents: "hi " + bodyName,`.
* Save the file.
* Watch how the changes to your code are automatically detected and reloaded. See the text over the planets respond to your new line of code.
## Attribution

1385
samples/solar-system/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -23,20 +23,24 @@
"clean": "tsc --build --clean",
"build": "tsc --build && eslint --ext .ts src",
"build-only": "tsc --build",
"build-watch": "tsc --build -w",
"lint": "eslint --ext .ts src",
"start-watch": "nodemon --nolazy --inspect .",
"start": "node .",
"debug": "node --nolazy --inspect-brk=9229 ."
"debug": "npm run build && concurrently \"npm run *-watch\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.17.0",
"@typescript-eslint/parser": "^2.17.0",
"eslint": "^6.8.0",
"nodemon": "^2.0.2",
"typescript": "^3.7.5"
},
"dependencies": {
"@microsoft/mixed-reality-extension-sdk": "^0.16.1",
"@types/dotenv": "^6.1.0",
"@types/node": "^10.3.1",
"concurrently": "^5.1.0",
"dotenv": "^6.2.0"
}
}

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

@ -1,329 +1,358 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* Solar system database
*/
interface Database {
[key: string]: DatabaseRecord;
}
interface DatabaseRecord {
name: string;
parent: string;
diameter: number; // km
distance: number; // 10^6 km
day: number; // hours
year: number; // days
inclination: number; // degrees
obliquity: number; // degrees
retrograde: boolean;
}
interface CelestialBody {
inclination: MRE.Actor;
position: MRE.Actor;
obliquity0: MRE.Actor;
obliquity1: MRE.Actor;
model: MRE.Actor;
}
interface CelestialBodySet {
[key: string]: CelestialBody;
}
// Data source: https://nssdc.gsfc.nasa.gov/planetary/dataheet/
// (some settings modified for scale and dramatic effect)
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const database: Database = require('../public/database.json');
/**
* Solar System Application
*/
export default class SolarSystem {
private celestialBodies: CelestialBodySet = {};
private animationsRunning = false;
private assets: MRE.AssetContainer;
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
this.context.onStarted(() => this.started());
}
private started() {
this.createSolarSystem();
const sunEntity = this.celestialBodies.sol;
if (sunEntity && sunEntity.model) {
const sun = sunEntity.model;
const sunPrimitives = sun.findChildrenByName('Primitive', true);
sunPrimitives.forEach((prim) => {
// Add a collider so that the behavior system will work properly on Unity host apps.
const radius = 3;
prim.setCollider(MRE.ColliderType.Sphere, false, radius);
const buttonBehavior = prim.setBehavior(MRE.ButtonBehavior);
buttonBehavior.onClick(_ => {
if (this.animationsRunning) {
this.pauseAnimations();
this.animationsRunning = false;
} else {
this.resumeAnimations();
this.animationsRunning = true;
}
});
});
}
this.resumeAnimations();
this.animationsRunning = true;
}
private createSolarSystem() {
const keys = Object.keys(database);
for (const bodyName of keys) {
this.createBody(bodyName);
}
}
private resumeAnimations() {
const keys = Object.keys(database);
for (const bodyName of keys) {
const celestialBody = this.celestialBodies[bodyName];
celestialBody.model.resumeAnimation(`${bodyName}:axial`);
celestialBody.position.resumeAnimation(`${bodyName}:orbital`);
}
}
private pauseAnimations() {
const keys = Object.keys(database);
for (const bodyName of keys) {
const celestialBody = this.celestialBodies[bodyName];
celestialBody.model.pauseAnimation(`${bodyName}:axial`);
celestialBody.position.pauseAnimation(`${bodyName}:orbital`);
}
}
private createBody(bodyName: string) {
const facts = database[bodyName];
const distanceMultiplier = Math.pow(facts.distance, 1 / 3);
const scaleMultiplier = Math.pow(facts.diameter, 1 / 3) / 25;
const positionValue = { x: distanceMultiplier, y: 0, z: 0 };
const scaleValue = { x: scaleMultiplier / 2, y: scaleMultiplier / 2, z: scaleMultiplier / 2 };
const obliquityValue = MRE.Quaternion.RotationAxis(
MRE.Vector3.Forward(), facts.obliquity * MRE.DegreesToRadians);
const inclinationValue = MRE.Quaternion.RotationAxis(
MRE.Vector3.Forward(), facts.inclination * MRE.DegreesToRadians);
// Object layout for celestial body is:
// inclination -- orbital plane. centered on sol and tilted
// position -- position of center of celestial body (orbits sol)
// label -- centered above position. location of label.
// obliquity0 -- centered on position. hidden node to account
// for the fact that obliquity is a world-relative axis
// obliquity1 -- centered on position. tilt of obliquity axis
// model -- centered on position. the celestial body (rotates)
try {
const inclination = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-inclination`,
transform: {
app: { rotation: inclinationValue }
}
}
});
const position = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-position`,
parentId: inclination.id,
transform: {
local: { position: positionValue }
}
}
});
const label = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-label`,
parentId: position.id,
transform: {
local: { position: { y: 0.1 + Math.pow(scaleMultiplier, 1 / 2.5) } }
}
}
});
const obliquity0 = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-obliquity0`,
parentId: position.id
}
});
const obliquity1 = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-obliquity1`,
parentId: obliquity0.id,
transform: {
local: { rotation: obliquityValue }
}
}
});
const model = MRE.Actor.CreateFromGltf(this.assets, {
uri: `${this.baseUrl}/assets/${bodyName}.gltf`,
actor: {
name: `${bodyName}-body`,
parentId: obliquity1.id,
transform: {
local: { scale: scaleValue }
},
collider: {
geometry: {
shape: MRE.ColliderType.Sphere,
radius: 0.5
}
}
}
});
label.enableText({
contents: bodyName,
height: 0.5,
pixelsPerLine: 50,
color: MRE.Color3.Yellow(),
anchor: MRE.TextAnchorLocation.TopCenter,
justify: MRE.TextJustify.Center,
});
setTimeout(() => {
label.text.color = MRE.Color3.White();
}, 5000);
this.celestialBodies[bodyName] = {
inclination,
position,
obliquity0,
obliquity1,
model
} as CelestialBody;
this.createAnimations(bodyName);
} catch (e) {
MRE.log.info('app', `createBody failed ${bodyName}, ${e}`);
}
}
private createAnimations(bodyName: string) {
this.createAxialAnimation(bodyName);
this.createOrbitalAnimation(bodyName);
}
public readonly timeFactor = 40;
public readonly axialKeyframeCount = 90;
public readonly orbitalKeyframeCount = 90;
private createAxialAnimation(bodyName: string) {
const facts = database[bodyName];
const celestialBody = this.celestialBodies[bodyName];
if (facts.day > 0) {
const spin = facts.retrograde ? -1 : 1;
// days = seconds (not in agreement with orbital animation)
const axisTimeInSeconds = facts.day / this.timeFactor;
const timeStep = axisTimeInSeconds / this.axialKeyframeCount;
const keyframes: MRE.AnimationKeyframe[] = [];
const angleStep = 360 / this.axialKeyframeCount;
const initial = celestialBody.model.transform.local.rotation.clone();
let value: Partial<MRE.ActorLike>;
for (let i = 0; i < this.axialKeyframeCount; ++i) {
const rotDelta = MRE.Quaternion.RotationAxis(
MRE.Vector3.Up(), (-angleStep * i * spin) * MRE.DegreesToRadians);
const rotation = initial.multiply(rotDelta);
value = {
transform: {
local: { rotation }
}
};
keyframes.push({
time: timeStep * i,
value,
});
}
// Final frame
value = {
transform: {
local: { rotation: celestialBody.model.transform.local.rotation }
}
};
keyframes.push({
time: axisTimeInSeconds,
value,
});
// Create the animation on the actor
celestialBody.model.createAnimation(
`${bodyName}:axial`, {
keyframes,
events: [],
wrapMode: MRE.AnimationWrapMode.Loop
});
}
}
private createOrbitalAnimation(bodyName: string) {
const facts = database[bodyName];
const celestialBody = this.celestialBodies[bodyName];
if (facts.year > 0) {
// years = seconds (not in agreement with axial animation)
const orbitTimeInSeconds = facts.year / this.timeFactor;
const timeStep = orbitTimeInSeconds / this.orbitalKeyframeCount;
const angleStep = 360 / this.orbitalKeyframeCount;
const keyframes: MRE.AnimationKeyframe[] = [];
const initial = celestialBody.position.transform.local.position.clone();
let value: Partial<MRE.ActorLike>;
for (let i = 0; i < this.orbitalKeyframeCount; ++i) {
const rotDelta = MRE.Quaternion.RotationAxis(
MRE.Vector3.Up(), (-angleStep * i) * MRE.DegreesToRadians);
const position = initial.rotateByQuaternionToRef(rotDelta, new MRE.Vector3());
value = {
transform: {
local: { position }
}
};
keyframes.push({
time: timeStep * i,
value,
});
}
// Final frame
value = {
transform: {
local: { position: celestialBody.position.transform.local.position }
}
};
keyframes.push({
time: orbitTimeInSeconds,
value,
});
// Create the animation on the actor
celestialBody.position.createAnimation(
`${bodyName}:orbital`, {
keyframes,
events: [],
wrapMode: MRE.AnimationWrapMode.Loop
});
}
}
}
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* Solar system database
*/
interface Database {
[key: string]: DatabaseRecord;
}
interface DatabaseRecord {
name: string;
parent: string;
diameter: number; // km
distance: number; // 10^6 km
day: number; // hours
year: number; // days
inclination: number; // degrees
obliquity: number; // degrees
retrograde: boolean;
}
interface CelestialBody {
inclination: MRE.Actor;
position: MRE.Actor;
obliquity0: MRE.Actor;
obliquity1: MRE.Actor;
model: MRE.Actor;
}
interface CelestialBodySet {
[key: string]: CelestialBody;
}
// Data source: https://nssdc.gsfc.nasa.gov/planetary/dataheet/
// (some settings modified for scale and dramatic effect)
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const database: Database = require('../public/database.json');
/**
* Solar System Application
*/
export default class SolarSystem {
private celestialBodies: CelestialBodySet = {};
private animationsRunning = false;
private assets: MRE.AssetContainer;
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
this.context.onStarted(() => this.started());
}
private started() {
// Check whether code is running in a debuggable watched filesystem
// environment and if so delay starting the app by 1 second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your PC.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
// version to use with non-async code
if (isDebug) {
setTimeout(this.startedImpl, delay);
} else {
this.startedImpl();
}
// // version to use with async code
// if (isDebug) {
// await new Promise(resolve => setTimeout(resolve, delay));
// await this.startedImpl();
// } else {
// await this.startedImpl();
// }
}
// use () => {} syntax here to get proper scope binding when called via setTimeout()
// if async is required, next line becomes private startedImpl = async () => {
private startedImpl = () => {
this.createSolarSystem();
const sunEntity = this.celestialBodies.sol;
if (sunEntity && sunEntity.model) {
const sun = sunEntity.model;
const sunPrimitives = sun.findChildrenByName('Primitive', true);
sunPrimitives.forEach((prim) => {
// Add a collider so that the behavior system will work properly on Unity host apps.
const radius = 3;
prim.setCollider(MRE.ColliderType.Sphere, false, radius);
const buttonBehavior = prim.setBehavior(MRE.ButtonBehavior);
buttonBehavior.onClick(_ => {
if (this.animationsRunning) {
this.pauseAnimations();
this.animationsRunning = false;
} else {
this.resumeAnimations();
this.animationsRunning = true;
}
});
});
}
this.resumeAnimations();
this.animationsRunning = true;
}
private createSolarSystem() {
const keys = Object.keys(database);
for (const bodyName of keys) {
this.createBody(bodyName);
}
}
private resumeAnimations() {
const keys = Object.keys(database);
for (const bodyName of keys) {
const celestialBody = this.celestialBodies[bodyName];
celestialBody.model.resumeAnimation(`${bodyName}:axial`);
celestialBody.position.resumeAnimation(`${bodyName}:orbital`);
}
}
private pauseAnimations() {
const keys = Object.keys(database);
for (const bodyName of keys) {
const celestialBody = this.celestialBodies[bodyName];
celestialBody.model.pauseAnimation(`${bodyName}:axial`);
celestialBody.position.pauseAnimation(`${bodyName}:orbital`);
}
}
private createBody(bodyName: string) {
const facts = database[bodyName];
const distanceMultiplier = Math.pow(facts.distance, 1 / 3);
const scaleMultiplier = Math.pow(facts.diameter, 1 / 3) / 25;
const positionValue = { x: distanceMultiplier, y: 0, z: 0 };
const scaleValue = { x: scaleMultiplier / 2, y: scaleMultiplier / 2, z: scaleMultiplier / 2 };
const obliquityValue = MRE.Quaternion.RotationAxis(
MRE.Vector3.Forward(), facts.obliquity * MRE.DegreesToRadians);
const inclinationValue = MRE.Quaternion.RotationAxis(
MRE.Vector3.Forward(), facts.inclination * MRE.DegreesToRadians);
// Object layout for celestial body is:
// inclination -- orbital plane. centered on sol and tilted
// position -- position of center of celestial body (orbits sol)
// label -- centered above position. location of label.
// obliquity0 -- centered on position. hidden node to account
// for the fact that obliquity is a world-relative axis
// obliquity1 -- centered on position. tilt of obliquity axis
// model -- centered on position. the celestial body (rotates)
try {
const inclination = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-inclination`,
transform: {
app: { rotation: inclinationValue }
}
}
});
const position = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-position`,
parentId: inclination.id,
transform: {
local: { position: positionValue }
}
}
});
const label = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-label`,
parentId: position.id,
transform: {
local: { position: { y: 0.1 + Math.pow(scaleMultiplier, 1 / 2.5) } }
}
}
});
const obliquity0 = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-obliquity0`,
parentId: position.id
}
});
const obliquity1 = MRE.Actor.Create(this.context, {
actor: {
name: `${bodyName}-obliquity1`,
parentId: obliquity0.id,
transform: {
local: { rotation: obliquityValue }
}
}
});
const model = MRE.Actor.CreateFromGltf(this.assets, {
uri: `${this.baseUrl}/assets/${bodyName}.gltf`,
actor: {
name: `${bodyName}-body`,
parentId: obliquity1.id,
transform: {
local: { scale: scaleValue }
},
collider: {
geometry: {
shape: MRE.ColliderType.Sphere,
radius: 0.5
}
}
}
});
label.enableText({
contents: bodyName,
height: 0.5,
pixelsPerLine: 50,
color: MRE.Color3.Yellow(),
anchor: MRE.TextAnchorLocation.TopCenter,
justify: MRE.TextJustify.Center,
});
setTimeout(() => {
label.text.color = MRE.Color3.White();
}, 5000);
this.celestialBodies[bodyName] = {
inclination,
position,
obliquity0,
obliquity1,
model
} as CelestialBody;
this.createAnimations(bodyName);
} catch (e) {
MRE.log.info('app', `createBody failed ${bodyName}, ${e}`);
}
}
private createAnimations(bodyName: string) {
this.createAxialAnimation(bodyName);
this.createOrbitalAnimation(bodyName);
}
public readonly timeFactor = 40;
public readonly axialKeyframeCount = 90;
public readonly orbitalKeyframeCount = 90;
private createAxialAnimation(bodyName: string) {
const facts = database[bodyName];
const celestialBody = this.celestialBodies[bodyName];
if (facts.day > 0) {
const spin = facts.retrograde ? -1 : 1;
// days = seconds (not in agreement with orbital animation)
const axisTimeInSeconds = facts.day / this.timeFactor;
const timeStep = axisTimeInSeconds / this.axialKeyframeCount;
const keyframes: MRE.AnimationKeyframe[] = [];
const angleStep = 360 / this.axialKeyframeCount;
const initial = celestialBody.model.transform.local.rotation.clone();
let value: Partial<MRE.ActorLike>;
for (let i = 0; i < this.axialKeyframeCount; ++i) {
const rotDelta = MRE.Quaternion.RotationAxis(
MRE.Vector3.Up(), (-angleStep * i * spin) * MRE.DegreesToRadians);
const rotation = initial.multiply(rotDelta);
value = {
transform: {
local: { rotation }
}
};
keyframes.push({
time: timeStep * i,
value,
});
}
// Final frame
value = {
transform: {
local: { rotation: celestialBody.model.transform.local.rotation }
}
};
keyframes.push({
time: axisTimeInSeconds,
value,
});
// Create the animation on the actor
celestialBody.model.createAnimation(
`${bodyName}:axial`, {
keyframes,
events: [],
wrapMode: MRE.AnimationWrapMode.Loop
});
}
}
private createOrbitalAnimation(bodyName: string) {
const facts = database[bodyName];
const celestialBody = this.celestialBodies[bodyName];
if (facts.year > 0) {
// years = seconds (not in agreement with axial animation)
const orbitTimeInSeconds = facts.year / this.timeFactor;
const timeStep = orbitTimeInSeconds / this.orbitalKeyframeCount;
const angleStep = 360 / this.orbitalKeyframeCount;
const keyframes: MRE.AnimationKeyframe[] = [];
const initial = celestialBody.position.transform.local.position.clone();
let value: Partial<MRE.ActorLike>;
for (let i = 0; i < this.orbitalKeyframeCount; ++i) {
const rotDelta = MRE.Quaternion.RotationAxis(
MRE.Vector3.Up(), (-angleStep * i) * MRE.DegreesToRadians);
const position = initial.rotateByQuaternionToRef(rotDelta, new MRE.Vector3());
value = {
transform: {
local: { position }
}
};
keyframes.push({
time: timeStep * i,
value,
});
}
// Final frame
value = {
transform: {
local: { position: celestialBody.position.transform.local.position }
}
};
keyframes.push({
time: orbitTimeInSeconds,
value,
});
// Create the animation on the actor
celestialBody.position.createAnimation(
`${bodyName}:orbital`, {
keyframes,
events: [],
wrapMode: MRE.AnimationWrapMode.Loop
});
}
}
}

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

@ -16,11 +16,33 @@ process.on('unhandledRejection', reason => console.log('unhandledRejection', rea
// Read .env if file exists
dotenv.config();
// Start listening for connections, and serve static files
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// This function starts the MRE server. It will be called immediately unless
// we detect that the code is running in a debuggable environment. If so, a
// small delay is introduced allowing time for the debugger to attach before
// the server starts accepting connections.
function runApp() {
// Start listening for connections, and serve static files.
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
}
// Check whether code is running in a debuggable watched filesystem
// environment and if so, delay starting the app by one second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your machine.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
if (isDebug) {
setTimeout(runApp, delay);
} else {
runApp();
}

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

@ -1,26 +1,64 @@
A Tic-tac-toe game, including step-by-step guide for how it was built.
If you are new to the MRE SDK, welcome! It's fun and (relatively)
easy to get started building your own MREs. But before you try to go
through this tutorial, please first try to build and deploy the Hello
easy to get started building your own MREs. But before you try to go
through this tutorial, please first try to build and deploy the Hello
World sample.
To go through the tutorial, look at the tutorial-steps folder. It
contains a subfolder for each step, including the app.ts for that
To go through the tutorial, look at the tutorial-steps folder. It
contains a subfolder for each step, including the app.ts for that
step, as well as a link to a side-by-side comparison of the folder
before and after the step.
## Setup
## Editing
* Open a command prompt to this sample's folder and run `npm install`. Keep the command prompt open if you wish to follow the command-oriented instructions that follow.
* Open the root folder of this repo in VSCode if you wish to follow the VSCode-oriented instructions.
* Open this folder in VSCode.
## Build
## Building
* Command line: `npm run build`.
* VSCode: `Shift+Ctrl+B`, then select 'build samples/hello-world'.
* From inside VSCode: `Shift+Ctrl+B`
* From command line: `npm run build`
## Run
## Running
* Command line: `npm start`.
* VSCode: Switch to the 'Run' tab (`Ctrl+Shift+D` will open it), select 'Launch hello-world project' from the dropdown at the top, and then `F5` to start it.
* From inside VSCode: `F5`
* From command line: `npm start`
MRE apps are NodeJS servers. They operate akin to a web server. When you start your MRE, it won't do much until you connect to it from a client application like AltspaceVR or the MRETestBed.
## Test in [AltspaceVR](https://altvr.com)
* Download [AltspaceVR](https://altvr.com) and create an account.
* Launch the application and sign in. You'll start in your "home space".
* Open the World Editor (only available if you indicate you want to participate in the [Early Access Program](https://altvr.com/early-access-program/) in your AltspaceVR settings).
* Add a `Basics` / `SDK App` object with a Target URI of `ws://127.0.0.1:3901`.
* See the the app appear in your space.
## Test in Unity using the [MRETestBed](https://www.github.com/mixed-reality-extension-sdk-samples)
* Install [Unity](https://unity3d.com/get-unity/download), version 2019.2.12f1 or later.
* Clone the [MRE Unity repo](https://github.com/microsoft/mixed-reality-extension-unity).
* Open the 'MRETestBed' Unity project.
* Select the 'Standalone' scene. This scene is preconfigured to connect to your MRE running locally.
* Start the Unity scene, and see the app appear.
## Advanced: Debug with Hot-Reload
Whether developing an MRE or another kind of app, an efficient dev/test loop is essential. Devs familiar with making browser-based apps will be familiar with webpack's notion of "hot reload", that is, automatically applying changes as they're made without the need to explicitly stop/rebuild/restart your app. NodeJS apps can do this too.
This setup requires launching the app from a terminal. VSCode has a built-in terminal, or you can open a separate command prompt.
### Start the MRE with hot-reload enabled
1. In the terminal, in this project's folder, run: `npm run debug-watch`. This will build and start the MRE. The `debug-watch` task continues to run in the background, watching for code changes. It will rebuild and restart the app whenever files are modified.
2. In VSCode, press `Ctrl+Shift+D` to open the 'Run' tab, select 'Attach to running project' from the drop down at the top, then press `F5` to attach the VSCode debugger. This step isn't required, but allows you to set breakpoints and debug MRE execution.
### See hot-reload in action
Once you have your MRE up and running, and you've successfully spawned an instance in AltspaceVR or another supported platform, it is time to make some code changes and see hot reload in action:
* In VSCode, open `samples/tic-tac-toe/app.ts`.
* Find the line `this.text.text.contents = "Tic-Tac-Toe\nClick To Play";` near the bottom of the file and change it to `this.text.text.contents = "Hello Tic-Tac-Toe\nClick To Play";`.
* Save the file.
* Watch how the changes to your code are automatically detected and reloaded. See the spinning text in your AltspaceVR world change to your updated text.

1188
samples/tic-tac-toe/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -23,20 +23,24 @@
"clean": "tsc --build --clean",
"build": "tsc --build && eslint --ext .ts src",
"build-only": "tsc --build",
"build-watch": "tsc --build -w",
"lint": "eslint --ext .ts src",
"start-watch": "nodemon --nolazy --inspect .",
"start": "node .",
"debug": "node --nolazy --inspect-brk=9229 ."
"debug": "npm run build && concurrently \"npm run *-watch\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.17.0",
"@typescript-eslint/parser": "^2.17.0",
"eslint": "^6.8.0",
"nodemon": "^2.0.2",
"typescript": "^3.7.5"
},
"dependencies": {
"@microsoft/mixed-reality-extension-sdk": "^0.16.1",
"@types/dotenv": "^6.1.0",
"@types/node": "^10.3.1",
"concurrently": "^5.1.0",
"dotenv": "^6.2.0"
}
}

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

@ -1,338 +1,369 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
enum GameState {
Intro,
Play,
Celebration
}
enum GamePiece {
X,
O
}
/**
* The main class of this app. All the logic goes here.
*/
export default class TicTacToe {
private assets: MRE.AssetContainer;
private text: MRE.Actor = null;
private textAnchor: MRE.Actor = null;
private light: MRE.Actor = null;
private gameState: GameState;
private currentPlayerGamePiece: GamePiece;
private nextPlayerGamePiece: GamePiece;
private boardState: GamePiece[];
private gamePieceActors: MRE.Actor[];
private victoryChecks = [
[0 * 3 + 0, 0 * 3 + 1, 0 * 3 + 2],
[1 * 3 + 0, 1 * 3 + 1, 1 * 3 + 2],
[2 * 3 + 0, 2 * 3 + 1, 2 * 3 + 2],
[0 * 3 + 0, 1 * 3 + 0, 2 * 3 + 0],
[0 * 3 + 1, 1 * 3 + 1, 2 * 3 + 1],
[0 * 3 + 2, 1 * 3 + 2, 2 * 3 + 2],
[0 * 3 + 0, 1 * 3 + 1, 2 * 3 + 2],
[2 * 3 + 0, 1 * 3 + 1, 0 * 3 + 2]
];
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
this.context.onStarted(() => this.started());
}
/**
* Once the context is "started", initialize the app.
*/
private async started() {
// Create a new actor with no mesh, but some text.
this.textAnchor = MRE.Actor.Create(this.context, {
actor: {
name: 'TextAnchor',
transform: {
app: { position: { x: 0, y: 1.2, z: 0 } }
},
}
});
this.text = MRE.Actor.Create(this.context, {
actor: {
parentId: this.textAnchor.id,
name: 'Text',
transform: {
local: { position: { x: 0, y: 0.0, z: -1.5 } }
},
text: {
contents: "Tic-Tac-Toe!",
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: { r: 30 / 255, g: 206 / 255, b: 213 / 255 },
height: 0.3
},
}
});
this.light = MRE.Actor.Create(this.context, {
actor: {
parentId: this.text.id,
name: 'Light',
transform: {
local: {
position: { x: 0, y: 1.0, z: -0.5 },
rotation: MRE.Quaternion.RotationAxis(MRE.Vector3.Left(), -45.0 * MRE.DegreesToRadians),
}
},
light: {
color: { r: 1, g: 0.6, b: 0.3 },
type: 'spot',
intensity: 20,
range: 6,
spotAngle: 45 * MRE.DegreesToRadians
},
}
});
// Here we create an animation on our text actor. Animations have three mandatory arguments:
// a name, an array of keyframes, and an array of events.
this.textAnchor.createAnimation(
// The name is a unique identifier for this animation. We'll pass it to "startAnimation" later.
"Spin", {
// Keyframes define the timeline for the animation: where the actor should be, and when.
// We're calling the generateSpinKeyframes function to produce a simple 20-second revolution.
keyframes: this.generateSpinKeyframes(20, MRE.Vector3.Up()),
// Events are points of interest during the animation. The animating actor will emit a given
// named event at the given timestamp with a given string value as an argument.
events: [],
// Optionally, we also repeat the animation infinitely.
wrapMode: MRE.AnimationWrapMode.Loop
}
);
// Load box model from glTF
const gltf = await this.assets.loadGltf(`${this.baseUrl}/altspace-cube.glb`, 'box');
// Also load the player choice markers now, for efficiency's sake
const circle = this.assets.createCylinderMesh('circle', 0.2, 0.4, 'y', 16);
const square = this.assets.createBoxMesh('square', 0.70, 0.2, 0.70);
for (let tileIndexX = 0; tileIndexX < 3; tileIndexX++) {
for (let tileIndexZ = 0; tileIndexZ < 3; tileIndexZ++) {
// Create a glTF actor
const cube = MRE.Actor.CreateFromPrefab(this.context, {
// Use the preloaded glTF for each box
firstPrefabFrom: gltf,
// Also apply the following generic actor properties.
actor: {
name: 'Altspace Cube',
transform: {
app: {
position: { x: (tileIndexX) - 1.0, y: 0.5, z: (tileIndexZ) - 1.0 },
},
local: { scale: { x: 0.4, y: 0.4, z: 0.4 } }
}
}
});
// Create some animations on the cube.
cube.createAnimation(
'GrowIn', {
keyframes: this.growAnimationData,
events: []
});
cube.createAnimation(
'ShrinkOut', {
keyframes: this.shrinkAnimationData,
events: []
});
cube.createAnimation(
'DoAFlip', {
keyframes: this.generateSpinKeyframes(1.0, MRE.Vector3.Right()),
events: []
});
// Set up cursor interaction. We add the input behavior ButtonBehavior to the cube.
// Button behaviors have two pairs of events: hover start/stop, and click start/stop.
const buttonBehavior = cube.setBehavior(MRE.ButtonBehavior);
// Trigger the grow/shrink animations on hover.
buttonBehavior.onHover('enter', () => {
if (this.gameState === GameState.Play &&
this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
cube.enableAnimation('GrowIn');
}
});
buttonBehavior.onHover('exit', () => {
if (this.gameState === GameState.Play &&
this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
cube.enableAnimation('ShrinkOut');
}
});
buttonBehavior.onClick(() => {
switch (this.gameState) {
case GameState.Intro:
this.beginGameStatePlay();
cube.enableAnimation('GrowIn');
break;
case GameState.Play:
// When clicked, put down a tile, and do a victory check
if (this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
MRE.log.info("app", "Putting an " + GamePiece[this.currentPlayerGamePiece] +
" on: (" + tileIndexX + "," + tileIndexZ + ")");
const gamePiecePosition = new MRE.Vector3(
cube.transform.local.position.x,
cube.transform.local.position.y + 0.55,
cube.transform.local.position.z);
if (this.currentPlayerGamePiece === GamePiece.O) {
this.gamePieceActors.push(MRE.Actor.Create(this.context, {
actor: {
name: 'O',
appearance: { meshId: circle.id },
transform: {
local: { position: gamePiecePosition }
}
}
}));
} else {
this.gamePieceActors.push(MRE.Actor.Create(this.context, {
actor: {
name: 'X',
appearance: { meshId: square.id },
transform: {
local: { position: gamePiecePosition }
}
}
}));
}
this.boardState[tileIndexX * 3 + tileIndexZ] = this.currentPlayerGamePiece;
cube.disableAnimation('GrowIn');
cube.enableAnimation('ShrinkOut');
const tempGamePiece = this.currentPlayerGamePiece;
this.currentPlayerGamePiece = this.nextPlayerGamePiece;
this.nextPlayerGamePiece = tempGamePiece;
this.text.text.contents = "Next Piece: " + GamePiece[this.currentPlayerGamePiece];
for (const victoryCheck of this.victoryChecks) {
if (this.boardState[victoryCheck[0]] !== undefined &&
this.boardState[victoryCheck[0]] === this.boardState[victoryCheck[1]] &&
this.boardState[victoryCheck[0]] === this.boardState[victoryCheck[2]]) {
this.beginGameStateCelebration(tempGamePiece);
break;
}
}
let hasEmptySpace = false;
for (let i = 0; i < 3 * 3; i++) {
if (this.boardState[i] === undefined) {
hasEmptySpace = true;
}
}
if (hasEmptySpace === false) {
this.beginGameStateCelebration(undefined);
}
}
break;
case GameState.Celebration:
default:
this.beginGameStateIntro();
break;
}
});
}
}
// Now that the text and its animation are all being set up, we can start playing
// the animation.
this.textAnchor.enableAnimation('Spin');
this.beginGameStateIntro();
}
private beginGameStateCelebration(winner: GamePiece) {
MRE.log.info("app", "BeginGameState Celebration");
this.gameState = GameState.Celebration;
this.light.light.color = { r: 0.3, g: 1.0, b: 0.3 };
if (winner === undefined) {
MRE.log.info("app", "Tie");
this.text.text.contents = "Tie";
} else {
MRE.log.info("app", "Winner: " + GamePiece[winner]);
this.text.text.contents = "Winner: " + GamePiece[winner];
}
}
private beginGameStateIntro() {
MRE.log.info("app", "BeginGameState Intro");
this.gameState = GameState.Intro;
this.text.text.contents = "Tic-Tac-Toe\nClick To Play";
this.currentPlayerGamePiece = GamePiece.X;
this.nextPlayerGamePiece = GamePiece.O;
this.boardState = [];
this.light.light.color = { r: 1, g: 0.6, b: 0.3 };
if (this.gamePieceActors !== undefined) {
for (const actor of this.gamePieceActors) {
actor.destroy();
}
}
this.gamePieceActors = [];
}
private beginGameStatePlay() {
MRE.log.info("app", "BeginGameState Play");
this.gameState = GameState.Play;
this.text.text.contents = "First Piece: " + GamePiece[this.currentPlayerGamePiece];
}
/**
* Generate keyframe data for a simple spin animation.
* @param duration The length of time in seconds it takes to complete a full revolution.
* @param axis The axis of rotation in local space.
*/
private generateSpinKeyframes(duration: number, axis: MRE.Vector3): MRE.AnimationKeyframe[] {
return [{
time: 0 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 0) } } }
}, {
time: 0.25 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI / 2) } } }
}, {
time: 0.5 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI) } } }
}, {
time: 0.75 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 3 * Math.PI / 2) } } }
}, {
time: 1 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 2 * Math.PI) } } }
}];
}
private growAnimationData: MRE.AnimationKeyframe[] = [{
time: 0,
value: { transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } }
}, {
time: 0.3,
value: { transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } }
}];
private shrinkAnimationData: MRE.AnimationKeyframe[] = [{
time: 0,
value: { transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } }
}, {
time: 0.3,
value: { transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } }
}];
}
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
enum GameState {
Intro,
Play,
Celebration
}
enum GamePiece {
X,
O
}
/**
* The main class of this app. All the logic goes here.
*/
export default class TicTacToe {
private assets: MRE.AssetContainer;
private text: MRE.Actor = null;
private textAnchor: MRE.Actor = null;
private light: MRE.Actor = null;
private gameState: GameState;
private currentPlayerGamePiece: GamePiece;
private nextPlayerGamePiece: GamePiece;
private boardState: GamePiece[];
private gamePieceActors: MRE.Actor[];
private victoryChecks = [
[0 * 3 + 0, 0 * 3 + 1, 0 * 3 + 2],
[1 * 3 + 0, 1 * 3 + 1, 1 * 3 + 2],
[2 * 3 + 0, 2 * 3 + 1, 2 * 3 + 2],
[0 * 3 + 0, 1 * 3 + 0, 2 * 3 + 0],
[0 * 3 + 1, 1 * 3 + 1, 2 * 3 + 1],
[0 * 3 + 2, 1 * 3 + 2, 2 * 3 + 2],
[0 * 3 + 0, 1 * 3 + 1, 2 * 3 + 2],
[2 * 3 + 0, 1 * 3 + 1, 0 * 3 + 2]
];
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
this.context.onStarted(() => this.started());
}
/**
* Once the context is "started", initialize the app.
*/
private async started() {
// Check whether code is running in a debuggable watched filesystem
// environment and if so delay starting the app by 1 second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your PC.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
// // version to use with non-async code
// if (isDebug) {
// setTimeout(this.startedImpl, delay);
// } else {
// this.startedImpl();
// }
// version to use with async code
if (isDebug) {
await new Promise(resolve => setTimeout(resolve, delay));
await this.startedImpl();
} else {
await this.startedImpl();
}
}
// use () => {} syntax here to get proper scope binding when called via setTimeout()
// if async is required, next line becomes private startedImpl = async () => {
private startedImpl = async () => {
// Create a new actor with no mesh, but some text.
this.textAnchor = MRE.Actor.Create(this.context, {
actor: {
name: 'TextAnchor',
transform: {
app: { position: { x: 0, y: 1.2, z: 0 } }
},
}
});
this.text = MRE.Actor.Create(this.context, {
actor: {
parentId: this.textAnchor.id,
name: 'Text',
transform: {
local: { position: { x: 0, y: 0.0, z: -1.5 } }
},
text: {
// NOTE: this is NOT the spinning text you see in your world
// that Tic-Tac-Toe! text is in the beginGameStateIntro() function below
contents: "Tic-Tac-Toe!",
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: { r: 30 / 255, g: 206 / 255, b: 213 / 255 },
height: 0.3
},
}
});
this.light = MRE.Actor.Create(this.context, {
actor: {
parentId: this.text.id,
name: 'Light',
transform: {
local: {
position: { x: 0, y: 1.0, z: -0.5 },
rotation: MRE.Quaternion.RotationAxis(MRE.Vector3.Left(), -45.0 * MRE.DegreesToRadians),
}
},
light: {
color: { r: 1, g: 0.6, b: 0.3 },
type: 'spot',
intensity: 20,
range: 6,
spotAngle: 45 * MRE.DegreesToRadians
},
}
});
// Here we create an animation on our text actor. Animations have three mandatory arguments:
// a name, an array of keyframes, and an array of events.
this.textAnchor.createAnimation(
// The name is a unique identifier for this animation. We'll pass it to "startAnimation" later.
"Spin", {
// Keyframes define the timeline for the animation: where the actor should be, and when.
// We're calling the generateSpinKeyframes function to produce a simple 20-second revolution.
keyframes: this.generateSpinKeyframes(20, MRE.Vector3.Up()),
// Events are points of interest during the animation. The animating actor will emit a given
// named event at the given timestamp with a given string value as an argument.
events: [],
// Optionally, we also repeat the animation infinitely.
wrapMode: MRE.AnimationWrapMode.Loop
}
);
// Load box model from glTF
const gltf = await this.assets.loadGltf(`${this.baseUrl}/altspace-cube.glb`, 'box');
// Also load the player choice markers now, for efficiency's sake
const circle = this.assets.createCylinderMesh('circle', 0.2, 0.4, 'y', 16);
const square = this.assets.createBoxMesh('square', 0.70, 0.2, 0.70);
for (let tileIndexX = 0; tileIndexX < 3; tileIndexX++) {
for (let tileIndexZ = 0; tileIndexZ < 3; tileIndexZ++) {
// Create a glTF actor
const cube = MRE.Actor.CreateFromPrefab(this.context, {
// Use the preloaded glTF for each box
firstPrefabFrom: gltf,
// Also apply the following generic actor properties.
actor: {
name: 'Altspace Cube',
transform: {
app: {
position: { x: (tileIndexX) - 1.0, y: 0.5, z: (tileIndexZ) - 1.0 },
},
local: { scale: { x: 0.4, y: 0.4, z: 0.4 } }
}
}
});
// Create some animations on the cube.
cube.createAnimation(
'GrowIn', {
keyframes: this.growAnimationData,
events: []
});
cube.createAnimation(
'ShrinkOut', {
keyframes: this.shrinkAnimationData,
events: []
});
cube.createAnimation(
'DoAFlip', {
keyframes: this.generateSpinKeyframes(1.0, MRE.Vector3.Right()),
events: []
});
// Set up cursor interaction. We add the input behavior ButtonBehavior to the cube.
// Button behaviors have two pairs of events: hover start/stop, and click start/stop.
const buttonBehavior = cube.setBehavior(MRE.ButtonBehavior);
// Trigger the grow/shrink animations on hover.
buttonBehavior.onHover('enter', () => {
if (this.gameState === GameState.Play &&
this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
cube.enableAnimation('GrowIn');
}
});
buttonBehavior.onHover('exit', () => {
if (this.gameState === GameState.Play &&
this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
cube.enableAnimation('ShrinkOut');
}
});
buttonBehavior.onClick(() => {
switch (this.gameState) {
case GameState.Intro:
this.beginGameStatePlay();
cube.enableAnimation('GrowIn');
break;
case GameState.Play:
// When clicked, put down a tile, and do a victory check
if (this.boardState[tileIndexX * 3 + tileIndexZ] === undefined) {
MRE.log.info("app", "Putting an " + GamePiece[this.currentPlayerGamePiece] +
" on: (" + tileIndexX + "," + tileIndexZ + ")");
const gamePiecePosition = new MRE.Vector3(
cube.transform.local.position.x,
cube.transform.local.position.y + 0.55,
cube.transform.local.position.z);
if (this.currentPlayerGamePiece === GamePiece.O) {
this.gamePieceActors.push(MRE.Actor.Create(this.context, {
actor: {
name: 'O',
appearance: { meshId: circle.id },
transform: {
local: { position: gamePiecePosition }
}
}
}));
} else {
this.gamePieceActors.push(MRE.Actor.Create(this.context, {
actor: {
name: 'X',
appearance: { meshId: square.id },
transform: {
local: { position: gamePiecePosition }
}
}
}));
}
this.boardState[tileIndexX * 3 + tileIndexZ] = this.currentPlayerGamePiece;
cube.disableAnimation('GrowIn');
cube.enableAnimation('ShrinkOut');
const tempGamePiece = this.currentPlayerGamePiece;
this.currentPlayerGamePiece = this.nextPlayerGamePiece;
this.nextPlayerGamePiece = tempGamePiece;
this.text.text.contents = "Next Piece: " + GamePiece[this.currentPlayerGamePiece];
for (const victoryCheck of this.victoryChecks) {
if (this.boardState[victoryCheck[0]] !== undefined &&
this.boardState[victoryCheck[0]] === this.boardState[victoryCheck[1]] &&
this.boardState[victoryCheck[0]] === this.boardState[victoryCheck[2]]) {
this.beginGameStateCelebration(tempGamePiece);
break;
}
}
let hasEmptySpace = false;
for (let i = 0; i < 3 * 3; i++) {
if (this.boardState[i] === undefined) {
hasEmptySpace = true;
}
}
if (hasEmptySpace === false) {
this.beginGameStateCelebration(undefined);
}
}
break;
case GameState.Celebration:
default:
this.beginGameStateIntro();
break;
}
});
}
}
// Now that the text and its animation are all being set up, we can start playing
// the animation.
this.textAnchor.enableAnimation('Spin');
this.beginGameStateIntro();
}
private beginGameStateCelebration(winner: GamePiece) {
MRE.log.info("app", "BeginGameState Celebration");
this.gameState = GameState.Celebration;
this.light.light.color = { r: 0.3, g: 1.0, b: 0.3 };
if (winner === undefined) {
MRE.log.info("app", "Tie");
this.text.text.contents = "Tie";
} else {
MRE.log.info("app", "Winner: " + GamePiece[winner]);
this.text.text.contents = "Winner: " + GamePiece[winner];
}
}
private beginGameStateIntro() {
MRE.log.info("app", "BeginGameState Intro");
this.gameState = GameState.Intro;
this.text.text.contents = "Tic-Tac-Toe\nClick To Play";
this.currentPlayerGamePiece = GamePiece.X;
this.nextPlayerGamePiece = GamePiece.O;
this.boardState = [];
this.light.light.color = { r: 1, g: 0.6, b: 0.3 };
if (this.gamePieceActors !== undefined) {
for (const actor of this.gamePieceActors) {
actor.destroy();
}
}
this.gamePieceActors = [];
}
private beginGameStatePlay() {
MRE.log.info("app", "BeginGameState Play");
this.gameState = GameState.Play;
this.text.text.contents = "First Piece: " + GamePiece[this.currentPlayerGamePiece];
}
/**
* Generate keyframe data for a simple spin animation.
* @param duration The length of time in seconds it takes to complete a full revolution.
* @param axis The axis of rotation in local space.
*/
private generateSpinKeyframes(duration: number, axis: MRE.Vector3): MRE.AnimationKeyframe[] {
return [{
time: 0 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 0) } } }
}, {
time: 0.25 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI / 2) } } }
}, {
time: 0.5 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, Math.PI) } } }
}, {
time: 0.75 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 3 * Math.PI / 2) } } }
}, {
time: 1 * duration,
value: { transform: { local: { rotation: MRE.Quaternion.RotationAxis(axis, 2 * Math.PI) } } }
}];
}
private growAnimationData: MRE.AnimationKeyframe[] = [{
time: 0,
value: { transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } }
}, {
time: 0.3,
value: { transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } }
}];
private shrinkAnimationData: MRE.AnimationKeyframe[] = [{
time: 0,
value: { transform: { local: { scale: { x: 0.5, y: 0.5, z: 0.5 } } } }
}, {
time: 0.3,
value: { transform: { local: { scale: { x: 0.4, y: 0.4, z: 0.4 } } } }
}];
}

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

@ -16,11 +16,33 @@ process.on('unhandledRejection', reason => console.log('unhandledRejection', rea
// Read .env if file exists
dotenv.config();
// Start listening for connections, and serve static files
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// This function starts the MRE server. It will be called immediately unless
// we detect that the code is running in a debuggable environment. If so, a
// small delay is introduced allowing time for the debugger to attach before
// the server starts accepting connections.
function runApp() {
// Start listening for connections, and serve static files.
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
}
// Check whether code is running in a debuggable watched filesystem
// environment and if so, delay starting the app by one second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your machine.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
if (isDebug) {
setTimeout(runApp, delay);
} else {
runApp();
}

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

@ -1,18 +1,57 @@
Displays a menu of hats that avatars can wear. Showcases avatar attachments.
## Editing
## Setup
* Open this folder in VSCode.
* Open a command prompt to this sample's folder and run `npm install`. Keep the command prompt open if you wish to follow the command-oriented instructions that follow.
* Open the root folder of this repo in VSCode if you wish to follow the VSCode-oriented instructions.
## Building
## Build
* From inside VSCode: `Shift+Ctrl+B`
* From command line: `npm run build`
* Command line: `npm run build`.
* VSCode: `Shift+Ctrl+B`, then select 'build samples/hello-world'.
## Running
## Run
* From inside VSCode: `F5`
* From command line: `npm start`
* Command line: `npm start`.
* VSCode: Switch to the 'Run' tab (`Ctrl+Shift+D` will open it), select 'Launch hello-world project' from the dropdown at the top, and then `F5` to start it.
MRE apps are NodeJS servers. They operate akin to a web server. When you start your MRE, it won't do much until you connect to it from a client application like AltspaceVR or the MRETestBed.
## Test in [AltspaceVR](https://altvr.com)
* Download [AltspaceVR](https://altvr.com) and create an account.
* Launch the application and sign in. You'll start in your "home space".
* Open the World Editor (only available if you indicate you want to participate in the [Early Access Program](https://altvr.com/early-access-program/) in your AltspaceVR settings).
* Add a `Basics` / `SDK App` object with a Target URI of `ws://127.0.0.1:3901`.
* See the the app appear in your space.
## Test in Unity using the [MRETestBed](https://www.github.com/mixed-reality-extension-sdk-samples)
* Install [Unity](https://unity3d.com/get-unity/download), version 2019.2.12f1 or later.
* Clone the [MRE Unity repo](https://github.com/microsoft/mixed-reality-extension-unity).
* Open the 'MRETestBed' Unity project.
* Select the 'Standalone' scene. This scene is preconfigured to connect to your MRE running locally.
* Start the Unity scene, and see the app appear.
## Advanced: Debug with Hot-Reload
Whether developing an MRE or another kind of app, an efficient dev/test loop is essential. Devs familiar with making browser-based apps will be familiar with webpack's notion of "hot reload", that is, automatically applying changes as they're made without the need to explicitly stop/rebuild/restart your app. NodeJS apps can do this too.
This setup requires launching the app from a terminal. VSCode has a built-in terminal, or you can open a separate command prompt.
### Start the MRE with hot-reload enabled
1. In the terminal, in this project's folder, run: `npm run debug-watch`. This will build and start the MRE. The `debug-watch` task continues to run in the background, watching for code changes. It will rebuild and restart the app whenever files are modified.
2. In VSCode, press `Ctrl+Shift+D` to open the 'Run' tab, select 'Attach to running project' from the drop down at the top, then press `F5` to attach the VSCode debugger. This step isn't required, but allows you to set breakpoints and debug MRE execution.
### See hot-reload in action
Once you have your MRE up and running, and you've successfully spawned an instance in AltspaceVR or another supported platform, it is time to make some code changes and see hot reload in action:
* In VSCode, open `samples/wear-a-hat/app.ts`.
* Find the line `contents: ''.padStart(8, ' ') + "Wear a Hat",` near the bottom of the file and change it to `contents: ''.padStart(8, ' ') + "Pick a Hat",`.
* Save the file.
* Watch how the changes to your code are automatically detected and reloaded. See the menu text change in response to your modified code.
## Attribution

1231
samples/wear-a-hat/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -23,20 +23,24 @@
"clean": "tsc --build --clean",
"build": "tsc --build && eslint --ext .ts src",
"build-only": "tsc --build",
"build-watch": "tsc --build -w",
"lint": "eslint --ext .ts src",
"start-watch": "nodemon --nolazy --inspect .",
"start": "node .",
"debug": "node --nolazy --inspect-brk=9229 ."
"debug": "npm run build && concurrently \"npm run *-watch\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.17.0",
"@typescript-eslint/parser": "^2.17.0",
"eslint": "^6.8.0",
"nodemon": "^2.0.2",
"typescript": "^3.7.5"
},
"dependencies": {
"@microsoft/mixed-reality-extension-sdk": "^0.16.1",
"@types/dotenv": "^6.1.0",
"@types/node": "^10.3.1",
"concurrently": "^5.1.0",
"dotenv": "^6.2.0"
}
}

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

@ -1,216 +1,245 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* The structure of a hat entry in the hat database.
*/
type HatDescriptor = {
displayName: string;
resourceName: string;
scale: {
x: number;
y: number;
z: number;
};
rotation: {
x: number;
y: number;
z: number;
};
position: {
x: number;
y: number;
z: number;
};
};
/**
* The structure of the hat database.
*/
type HatDatabase = {
[key: string]: HatDescriptor;
};
// Load the database of hats.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const HatDatabase: HatDatabase = require('../public/hats.json');
/**
* WearAHat Application - Showcasing avatar attachments.
*/
export default class WearAHat {
// Container for preloaded hat prefabs.
private assets: MRE.AssetContainer;
private prefabs: { [key: string]: MRE.Prefab } = {};
// Container for instantiated hats.
private attachedHats = new Map<MRE.Guid, MRE.Actor>();
/**
* Constructs a new instance of this class.
* @param context The MRE SDK context.
* @param baseUrl The baseUrl to this project's `./public` folder.
*/
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
// Hook the context events we're interested in.
this.context.onStarted(() => this.started());
this.context.onUserLeft(user => this.userLeft(user));
}
/**
* Called when a Hats application session starts up.
*/
private async started() {
// Preload all the hat models.
await this.preloadHats();
// Show the hat menu.
this.showHatMenu();
}
/**
* Called when a user leaves the application (probably left the Altspace world where this app is running).
* @param user The user that left the building.
*/
private userLeft(user: MRE.User) {
// If the user was wearing a hat, destroy it. Otherwise it would be
// orphaned in the world.
this.removeHats(user);
}
/**
* Show a menu of hat selections.
*/
private showHatMenu() {
// Create a parent object for all the menu items.
const menu = MRE.Actor.Create(this.context, {});
let y = 0.3;
// Create menu button
const buttonMesh = this.assets.createBoxMesh('button', 0.3, 0.3, 0.01);
// Loop over the hat database, creating a menu item for each entry.
for (const hatId of Object.keys(HatDatabase)) {
// Create a clickable button.
const button = MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: hatId,
appearance: { meshId: buttonMesh.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } },
transform: {
local: { position: { x: 0, y, z: 0 } }
}
}
});
// Set a click handler on the button.
button.setBehavior(MRE.ButtonBehavior)
.onClick(user => this.wearHat(hatId, user.id));
// Create a label for the menu entry.
MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: 'label',
text: {
contents: HatDatabase[hatId].displayName,
height: 0.5,
anchor: MRE.TextAnchorLocation.MiddleLeft
},
transform: {
local: { position: { x: 0.5, y, z: 0 } }
}
}
});
y = y + 0.5;
}
// Create a label for the menu title.
MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: 'label',
text: {
contents: ''.padStart(8, ' ') + "Wear a Hat",
height: 0.8,
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: MRE.Color3.Yellow()
},
transform: {
local: { position: { x: 0.5, y: y + 0.25, z: 0 } }
}
}
});
}
/**
* Preload all hat resources. This makes instantiating them faster and more efficient.
*/
private preloadHats() {
// Loop over the hat database, preloading each hat resource.
// Return a promise of all the in-progress load promises. This
// allows the caller to wait until all hats are done preloading
// before continuing.
return Promise.all(
Object.keys(HatDatabase).map(hatId => {
const hatRecord = HatDatabase[hatId];
if (hatRecord.resourceName) {
return this.assets.loadGltf(
`${this.baseUrl}/${hatRecord.resourceName}`)
.then(assets => {
this.prefabs[hatId] = assets.find(a => a.prefab !== null) as MRE.Prefab;
})
.catch(e => MRE.log.error("app", e));
} else {
return Promise.resolve();
}
}));
}
/**
* Instantiate a hat and attach it to the avatar's head.
* @param hatId The id of the hat in the hat database.
* @param userId The id of the user we will attach the hat to.
*/
private wearHat(hatId: string, userId: MRE.Guid) {
// If the user is wearing a hat, destroy it.
this.removeHats(this.context.user(userId));
const hatRecord = HatDatabase[hatId];
// If the user selected 'none', then early out.
if (!hatRecord.resourceName) {
return;
}
// Create the hat model and attach it to the avatar's head.
this.attachedHats.set(userId, MRE.Actor.CreateFromPrefab(this.context, {
prefabId: this.prefabs[hatId].id,
actor: {
transform: {
local: {
position: hatRecord.position,
rotation: MRE.Quaternion.FromEulerAngles(
hatRecord.rotation.x * MRE.DegreesToRadians,
hatRecord.rotation.y * MRE.DegreesToRadians,
hatRecord.rotation.z * MRE.DegreesToRadians),
scale: hatRecord.scale,
}
},
attachment: {
attachPoint: 'head',
userId
}
}
}));
}
private removeHats(user: MRE.User) {
if (this.attachedHats.has(user.id)) { this.attachedHats.get(user.id).destroy(); }
this.attachedHats.delete(user.id);
}
}
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
/**
* The structure of a hat entry in the hat database.
*/
type HatDescriptor = {
displayName: string;
resourceName: string;
scale: {
x: number;
y: number;
z: number;
};
rotation: {
x: number;
y: number;
z: number;
};
position: {
x: number;
y: number;
z: number;
};
};
/**
* The structure of the hat database.
*/
type HatDatabase = {
[key: string]: HatDescriptor;
};
// Load the database of hats.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const HatDatabase: HatDatabase = require('../public/hats.json');
/**
* WearAHat Application - Showcasing avatar attachments.
*/
export default class WearAHat {
// Container for preloaded hat prefabs.
private assets: MRE.AssetContainer;
private prefabs: { [key: string]: MRE.Prefab } = {};
// Container for instantiated hats.
private attachedHats = new Map<MRE.Guid, MRE.Actor>();
/**
* Constructs a new instance of this class.
* @param context The MRE SDK context.
* @param baseUrl The baseUrl to this project's `./public` folder.
*/
constructor(private context: MRE.Context, private baseUrl: string) {
this.assets = new MRE.AssetContainer(context);
// Hook the context events we're interested in.
this.context.onStarted(() => this.started());
this.context.onUserLeft(user => this.userLeft(user));
}
/**
* Called when a Hats application session starts up.
*/
private async started() {
// Check whether code is running in a debuggable watched filesystem
// environment and if so delay starting the app by 1 second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your PC.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
// // version to use with non-async code
// if (isDebug) {
// setTimeout(this.startedImpl, delay);
// } else {
// this.startedImpl();
// }
// version to use with async code
if (isDebug) {
await new Promise(resolve => setTimeout(resolve, delay));
await this.startedImpl();
} else {
await this.startedImpl();
}
}
// use () => {} syntax here to get proper scope binding when called via setTimeout()
// if async is required, next line becomes private startedImpl = async () => {
private startedImpl = async () => {
// Preload all the hat models.
await this.preloadHats();
// Show the hat menu.
this.showHatMenu();
}
/**
* Called when a user leaves the application (probably left the Altspace world where this app is running).
* @param user The user that left the building.
*/
private userLeft(user: MRE.User) {
// If the user was wearing a hat, destroy it. Otherwise it would be
// orphaned in the world.
this.removeHats(user);
}
/**
* Show a menu of hat selections.
*/
private showHatMenu() {
// Create a parent object for all the menu items.
const menu = MRE.Actor.Create(this.context, {});
let y = 0.3;
// Create menu button
const buttonMesh = this.assets.createBoxMesh('button', 0.3, 0.3, 0.01);
// Loop over the hat database, creating a menu item for each entry.
for (const hatId of Object.keys(HatDatabase)) {
// Create a clickable button.
const button = MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: hatId,
appearance: { meshId: buttonMesh.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } },
transform: {
local: { position: { x: 0, y, z: 0 } }
}
}
});
// Set a click handler on the button.
button.setBehavior(MRE.ButtonBehavior)
.onClick(user => this.wearHat(hatId, user.id));
// Create a label for the menu entry.
MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: 'label',
text: {
contents: HatDatabase[hatId].displayName,
height: 0.5,
anchor: MRE.TextAnchorLocation.MiddleLeft
},
transform: {
local: { position: { x: 0.5, y, z: 0 } }
}
}
});
y = y + 0.5;
}
// Create a label for the menu title.
MRE.Actor.Create(this.context, {
actor: {
parentId: menu.id,
name: 'label',
text: {
contents: ''.padStart(8, ' ') + "Wear a Hat",
height: 0.8,
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: MRE.Color3.Yellow()
},
transform: {
local: { position: { x: 0.5, y: y + 0.25, z: 0 } }
}
}
});
}
/**
* Preload all hat resources. This makes instantiating them faster and more efficient.
*/
private preloadHats() {
// Loop over the hat database, preloading each hat resource.
// Return a promise of all the in-progress load promises. This
// allows the caller to wait until all hats are done preloading
// before continuing.
return Promise.all(
Object.keys(HatDatabase).map(hatId => {
const hatRecord = HatDatabase[hatId];
if (hatRecord.resourceName) {
return this.assets.loadGltf(
`${this.baseUrl}/${hatRecord.resourceName}`)
.then(assets => {
this.prefabs[hatId] = assets.find(a => a.prefab !== null) as MRE.Prefab;
})
.catch(e => MRE.log.error("app", e));
} else {
return Promise.resolve();
}
}));
}
/**
* Instantiate a hat and attach it to the avatar's head.
* @param hatId The id of the hat in the hat database.
* @param userId The id of the user we will attach the hat to.
*/
private wearHat(hatId: string, userId: MRE.Guid) {
// If the user is wearing a hat, destroy it.
this.removeHats(this.context.user(userId));
const hatRecord = HatDatabase[hatId];
// If the user selected 'none', then early out.
if (!hatRecord.resourceName) {
return;
}
// Create the hat model and attach it to the avatar's head.
this.attachedHats.set(userId, MRE.Actor.CreateFromPrefab(this.context, {
prefabId: this.prefabs[hatId].id,
actor: {
transform: {
local: {
position: hatRecord.position,
rotation: MRE.Quaternion.FromEulerAngles(
hatRecord.rotation.x * MRE.DegreesToRadians,
hatRecord.rotation.y * MRE.DegreesToRadians,
hatRecord.rotation.z * MRE.DegreesToRadians),
scale: hatRecord.scale,
}
},
attachment: {
attachPoint: 'head',
userId
}
}
}));
}
private removeHats(user: MRE.User) {
if (this.attachedHats.has(user.id)) { this.attachedHats.get(user.id).destroy(); }
this.attachedHats.delete(user.id);
}
}

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

@ -16,11 +16,33 @@ process.on('unhandledRejection', reason => console.log('unhandledRejection', rea
// Read .env if file exists
dotenv.config();
// Start listening for connections, and serve static files
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// This function starts the MRE server. It will be called immediately unless
// we detect that the code is running in a debuggable environment. If so, a
// small delay is introduced allowing time for the debugger to attach before
// the server starts accepting connections.
function runApp() {
// Start listening for connections, and serve static files.
const server = new WebHost({
// baseUrl: 'http://<ngrok-id>.ngrok.io',
baseDir: resolvePath(__dirname, '../public')
});
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
// Handle new application sessions
server.adapter.onConnection(context => new App(context, server.baseUrl));
}
// Check whether code is running in a debuggable watched filesystem
// environment and if so, delay starting the app by one second to give
// the debugger time to detect that the server has restarted and reconnect.
// The delay value below is in milliseconds so 1000 is a one second delay.
// You may need to increase the delay or be able to decrease it depending
// on the speed of your machine.
const delay = 1000;
const argv = process.execArgv.join();
const isDebug = argv.includes('inspect') || argv.includes('debug');
if (isDebug) {
setTimeout(runApp, delay);
} else {
runApp();
}