Repository: googlecreativelab/webvr-musicalforest
Branch: master
Commit: 99d6007715d2
Files: 176
Total size: 1.0 MB
Directory structure:
gitextract_yh_t2yfb/
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app.yaml
├── backend/
│ ├── .babelrc
│ ├── .gitignore
│ ├── README.md
│ ├── package.json
│ └── src/
│ ├── config.js
│ ├── datastore/
│ │ ├── datastore-constants.js
│ │ └── index.js
│ ├── index.js
│ ├── logger/
│ │ ├── cloud-logger.js
│ │ └── index.js
│ ├── messages/
│ │ ├── index.js
│ │ ├── message-actions.js
│ │ ├── message-constants.js
│ │ ├── message-handler.js
│ │ ├── message-rate-limiter.js
│ │ ├── message-sagas.js
│ │ ├── message-schema.js
│ │ └── message-validator.js
│ ├── pubsub/
│ │ ├── index.js
│ │ └── pubsub-client.js
│ ├── rooms/
│ │ ├── index.js
│ │ ├── room-data-actions.js
│ │ ├── room-data-constants.js
│ │ ├── room-data-reducer.js
│ │ ├── room-names.js
│ │ ├── room-sagas.js
│ │ ├── room-state-actions.js
│ │ ├── room-state-constants.js
│ │ ├── room-state-machine.js
│ │ ├── room-state-reducer.js
│ │ └── room-state-utils.js
│ ├── s11n.js
│ ├── server/
│ │ ├── app-server.js
│ │ ├── balancer.js
│ │ ├── index.js
│ │ ├── server-actions.js
│ │ ├── server-constants.js
│ │ ├── server-reducer.js
│ │ ├── server-sagas.js
│ │ ├── server-setup.js
│ │ ├── server-startup.js
│ │ ├── server-sync-handlers.js
│ │ ├── server-sync.js
│ │ ├── target-server-utils.js
│ │ └── websocket-server.js
│ ├── spheres/
│ │ ├── index.js
│ │ ├── sphere-constants.js
│ │ └── trees/
│ │ ├── 0.js
│ │ ├── 1.js
│ │ ├── 2.js
│ │ ├── 3.js
│ │ ├── 4.js
│ │ └── 5.js
│ ├── store.js
│ └── utils/
│ ├── index.js
│ ├── reducer-utils.js
│ ├── saga-utils.js
│ ├── string-utils.js
│ ├── url-utils.js
│ └── websocket-utils.js
├── config.js
├── config.template
├── index.html
├── js/
│ ├── ascene.js
│ ├── components/
│ │ ├── background-objects.js
│ │ ├── ball.js
│ │ ├── bg-tree-ring-material.js
│ │ ├── clicker.js
│ │ ├── controller-material.js
│ │ ├── controllers.js
│ │ ├── copresence-server-messages.js
│ │ ├── copresence-server.js
│ │ ├── daydream-manager.js
│ │ ├── daydream-pointer.js
│ │ ├── fake-light.js
│ │ ├── ga.js
│ │ ├── gaze.js
│ │ ├── grab-move.js
│ │ ├── haptics.js
│ │ ├── headset-material.js
│ │ ├── listener.js
│ │ ├── palette.js
│ │ ├── proximity-check.js
│ │ ├── quaternion.js
│ │ ├── smooth-motion.js
│ │ ├── teleport.js
│ │ ├── tone.js
│ │ ├── tool-tips.js
│ │ ├── touch-color.js
│ │ ├── tree.js
│ │ └── wasd-boundaries.js
│ ├── core/
│ │ ├── color-set.js
│ │ ├── colors.js
│ │ ├── instrument.js
│ │ ├── instruments.js
│ │ ├── shape-data.js
│ │ └── shapes.js
│ ├── index.js
│ ├── notes/
│ │ ├── note-head-cube.js
│ │ ├── note-head-sphere.js
│ │ ├── note-head-tetra.js
│ │ ├── note-head.js
│ │ ├── note-shadow-cube.js
│ │ ├── note-shadow-sphere.js
│ │ ├── note-shadow-tetra.js
│ │ ├── note-shadow.js
│ │ ├── note-stem.js
│ │ └── note.js
│ ├── orientation-arm-model.js
│ ├── shaders/
│ │ ├── ball-shader.js
│ │ ├── bg-tree-shader.js
│ │ ├── circle-shader.js
│ │ └── shader-chunks.js
│ ├── splash.js
│ ├── util/
│ │ ├── browserCheck.js
│ │ ├── random-range-1d.js
│ │ ├── random-range-3d.js
│ │ ├── tetrahedron.js
│ │ └── trace.js
│ └── util.js
├── package.json
├── python/
│ ├── __init__.py
│ ├── base/
│ │ ├── __init__.py
│ │ ├── api_fixer.py
│ │ ├── api_fixer_test.py
│ │ ├── constants.py
│ │ ├── handlers.py
│ │ ├── handlers_test.py
│ │ ├── models.py
│ │ ├── models_test.py
│ │ ├── xsrf.py
│ │ └── xsrf_test.py
│ ├── country_servers.py
│ ├── handlers.py
│ ├── main.py
│ └── main_test.py
├── static/
│ ├── audio/
│ │ ├── bg.ogg
│ │ ├── marimba/
│ │ │ ├── 0.ogg
│ │ │ ├── 1.ogg
│ │ │ ├── 2.ogg
│ │ │ ├── 3.ogg
│ │ │ ├── 4.ogg
│ │ │ └── 5.ogg
│ │ ├── percussion/
│ │ │ ├── 0.ogg
│ │ │ ├── 1.ogg
│ │ │ ├── 2.ogg
│ │ │ ├── 3.ogg
│ │ │ ├── 4.ogg
│ │ │ └── 5.ogg
│ │ └── voice/
│ │ ├── 0.ogg
│ │ ├── 1.ogg
│ │ ├── 2.ogg
│ │ ├── 3.ogg
│ │ ├── 4.ogg
│ │ └── 5.ogg
│ └── models/
│ ├── bg-tree-1.obj
│ ├── bg-tree-2.obj
│ ├── bg-tree-3.obj
│ ├── bg-tree-ring.obj
│ ├── controller.dae
│ ├── door-frame.obj
│ ├── door-glow.obj
│ ├── door.dae
│ ├── headset.dae
│ ├── stump-shadow.obj
│ ├── stump.obj
│ └── target.obj
├── style/
│ └── splash.scss
└── third_party/
└── aframe-daydream-controller-component/
├── LICENSE
├── METADATA
└── daydream-controller.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
/node_modules
/.idea
.DS_Store
build/main.js
*.pyc
build/
*.swp
.sass-cache
npm-debug.log
*.asd
================================================
FILE: CONTRIBUTING.md
================================================
# How to contribute
We'd love to accept your patches and contributions to this project. There are
just a few small guidelines you need to follow.
## Contributor License Agreement
Contributions to this project must be accompanied by a Contributor License
Agreement. You (or your employer) retain the copyright to your contribution,
this simply gives us permission to use and redistribute your contributions as
part of the project. Head over to to see
your current agreements on file or to sign a new one.
You generally only need to submit a CLA once, so if you've already submitted one
(even if it was for a different project), you probably don't need to do it
again.
## Code reviews
All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult [GitHub Help] for more
information on using pull requests.
[GitHub Help]: https://help.github.com/articles/about-pull-requests/
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
This project is no longer actively maintained by the Google Creative Lab but remains here in a read-only Archive mode so that it can continue to assist developers that may find the examples helpful. We aren’t able to address all pull requests or bug reports but outstanding issues will remain in read-only mode for reference purposes. Also, please note that some of the dependencies may not be up to date and there hasn’t been any QA done in a while so your mileage may vary.
For more details on how Archiving affects Github repositories see this documentation .
We welcome users to fork this repository should there be more useful, community-driven efforts that can help continue what this project began.
# Musical Forest
Musical Forest is a multiplayer musical experiment in virtual reality. It uses copresence —the synchronization of multiple people in a virtual space— to allow people to play music together in VR. WebSockets are used to sync all of the connected players in real-time.
Musical Forest, a [WebVR Experiment](https://webvrexperiments.com/).
## Basic Interaction
Each of the shapes triggers a note when it’s hit. Users can navigate the space, play notes and also hear and play with all of the other players in the space at the same time.

## Objects & Audio
There are three different shapes of musical objects corresponding to three different sets of sounds. Each set has six notes, chosen from a pentatonic scale.
* Spheres: Percussion (Conga, Woodblock, COWBELL!)
* Triangular Pyramids: Voice + Flute
* Cubes: Marimba
Sounds are positioned in 3D space using the Web Audio API’s PannerNode.
## Headsets & Interaction Models
Musical Forest responsively adapts features depending on the capabilities of the VR Hardware.
### Vive/Oculus
Play: hit the shapes with your controller to hear its sound. The volume of the sound changes depending on how hard you hit it.
Create: tap the trigger to create a new shape. Rotate the circular pad to change the note.
Rearrange: place the controller over an existing object, press and hold the trigger to grab it. Move your controller and release to move it to a new space. Hovering your controller over an object and rotating the circular pad will change the type of shape and it’s sound.
Navigation: move within the bounds of your roomscale environment to interact with the objects within the experience.
### Daydream
Play: Hit the shapes with your controller to hear their sounds.
Navigation: point the Daydream controller at the ground and a circle will appear. Press the main button on the controller to teleport to that highlighted spot.
### Cardboard
Play: gaze at an object and see it glow. Tap the interaction button to hear that object.
Navigation: gaze at the ground and a circle will appear. Tap the interaction button to teleport to the highlighted spot.
### Magic Window
Interaction: tap any object to hear the sound of that object.
Navigation: gaze at the ground and a circle will appear. Tap anywhere to teleport to where the reticle is pointing.
### Desktop
Interaction: use the mouse to click any object to hear its sound. The volume of the sound is dictated by the object’s distance from the user.
Navigation: use the WASD keys on the keyboard. Use the mouse to change the field of view by clicking in empty space and dragging.
## Technologies Used
### Frontend
Musical Forest uses [A-Frame](https://aframe.io) which is built with the WebVR standard and [Tone.js](https://github.com/Tonejs/Tone.js/) for sound.
### Backend
The backend is developed in Node.js. To get a full overview of the technologies and libraries used, see the [backend readme](backend/README.md#Description)
## Running the Frontend Code
Download the source code, and install all dependencies by running `npm install`. To run the frontend, run `npm start`, this will start a local webserver using `budo` connecting to the default backend server. If you have a local backend server running, append `?server=localhost` to the url.
## Acknowledgements
[Manny Tan](https://github.com/mannytan), [Igor Clark](https://github.com/igorclark), [Yotam Mann](https://github.com/tambien), [Alexander Chen](https://github.com/alexanderchen), [Jonas Jongejan](https://github.com/halfdanj), [Jeremy Abel](https://github.com/jeremyabel), [Saad Moosajee](https://github.com/moosajee), Alex Jacobo-Blonder, [Ryan Burke](https://github.com/ryburke), and many others at Google Creative Lab.
================================================
FILE: app.yaml
================================================
# Copyright 2017 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
###########################################################################
# DO NOT MODIFY THIS FILE WITHOUT UNDERSTANDING THE SECURITY IMPLICATIONS #
###########################################################################
# The version is automatically generated based on the current git hash.
# If there are uncommitted changes, a '-dev' suffix will be added. You do
# not need to modify it here.
runtime: python27
api_version: 1
threadsafe: true
handlers:
# - url: /
# static_files: "index.html"
# secure: always
# upload: index\.html
# http_headers:
# X-Frame-Options: "DENY"
# Strict-Transport-Security: "max-age=2592000; includeSubdomains"
# X-Content-Type-Options: "nosniff"
# X-XSS-Protection: "1; mode=block"
- url: /build/
static_dir: build/
secure: always
http_headers:
X-Frame-Options: "DENY"
Strict-Transport-Security: "max-age=2592000; includeSubdomains"
X-Content-Type-Options: "nosniff"
X-XSS-Protection: "1; mode=block"
- url: /static/
static_dir: static/
secure: always
http_headers:
X-Frame-Options: "DENY"
Strict-Transport-Security: "max-age=2592000; includeSubdomains"
X-Content-Type-Options: "nosniff"
X-XSS-Protection: "1; mode=block"
# All URLs should be mapped via the *_ROUTES variables in the src/main.py file.
# See https://webapp-improved.appspot.com/guide/routing.html for information on
# how URLs are routed in the webapp2 framework. Do not add additional handlers
# directly here.
- url: /.*
script: python.main.app
secure: always
libraries:
- name: django
version: latest
- name: jinja2
version: latest
- name: webapp2
version: latest
skip_files:
- ^(.*/)?#.*#$
- ^(.*/)?.*~$
- ^(.*/)?.*\.py[co]$
- ^(.*/)?.*/RCS/.*$
- ^(.*/)?\..*$
- README
- util.sh
- run_tests.py
- .*_test.py
- js/.*
- backend/.*
- ^node_modules/(.*/)?
- ^(.*/)?app\.yaml
- ^(.*/)?app\.yml
- ^\.git/.*
================================================
FILE: backend/.babelrc
================================================
{
"presets": ["es2015", "stage-2"],
"plugins": []
}
================================================
FILE: backend/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
# tmp/testing
scratch
tmp
*.bak
# vim swap files
*.swp
*.swo
# Mac filesystem entries
.DS_Store
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# babel-translated es5 output
dist
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# vagrant anything
.vagrant
# allow .gitignore files
!.gitignore
================================================
FILE: backend/README.md
================================================
# webvr-experiments: Musical Forest back-end
## Contents
* [Description](#description)
* [Running the app locally](#running-the-app-locally)
* [Deploying in production](#deploying-in-production)
* [How the app works](#how-the-app-works)
* [Basic operation](#basic-operation)
* [Managing state](#managing-state)
* [Starting the server](#starting-the-server)
* [Handling SSL connections](#handling-ssl-connections)
* [Managing connections](#managing-connections)
* [Managing peer servers](#managing-peer-servers)
* [Managing rooms](#managing-rooms)
* [Handling client messages](#handling-client-messages)
* [Connecting clients to the server](#connecting-clients-to-the-server)
* [Messages](#messages)
* [URLs](#urls)
* [Interacting in rooms](#interacting-in-rooms)
---
## Description
This is the backend server for the Chrome WebVR Experiment [Musical Forest](https://forest.webvrexperiments.com/). It's a dynamic room-based [WebSocket](https://en.wikipedia.org/wiki/WebSocket) server app using Google Cloud [PubSub](https://cloud.google.com/pubsub/) and [Datastore](https://cloud.google.com/datastore/). It's written in Javascript ES6 using [`node.js`](https://nodejs.org/) and the following [`npm`](https://www.npmjs.com/ )modules:
* `uws` websocket server
* `redux` and `javascript-state-machine` for state management
* `redux-sagas` for managing asynchronous/long-running tasks and composite async actions
* `@google-cloud/*` for using GCP services
* `http-proxy` and `hashring` to distribute connections consistently between app instances
* `fast-ratelimit` to hard-limit per-client message rates
* `tracer` for logging
---
## Running the app locally
#### Install prerequisites
* [Install gcloud](https://cloud.google.com/sdk/downloads)
* `node.js` LTS version 6 + `npm` (Mac: `brew install node@6 npm` - [notes about installing `node` on Mac with Homebrew](http://apple.stackexchange.com/questions/171530/how-do-i-downgrade-node-or-install-a-specific-previous-version-using-homebrew/207883))
* `gcc` C compiler for native `node.js` modules (Xcode 8.1+ if running on Mac)
* [`jq`](https://stedolan.github.io/jq/) (Mac: `brew install jq`)
#### Authenticate with GCP
```
gcloud auth login
gcloud config set project
gcloud auth application-default login
```
#### Install node.js modules
`npm install --no-optional`
#### Run the app
Start the `datastore` & `pubsub` emulators:
`npm run emulators`
In a new shell, start the app in dev mode using the emulators:
`npm run start_emulated`
You can now connect to the server from the main web app by appending `?server=ws://localhost:9100` to the URL.
#### Test a connection to the app from command line (optional)
Install a solid command-line websocket client:
* Install [go](https://golang.org/) (Mac: `brew install go # make sure to set up a ${GOPATH} dir & env var`)
* `go get -u github.com/hashrocket/ws`
Connect to a websocket:
* `ws ws://localhost:9100/` (see [URLs](#urls) below regarding viewer types)
* _paste in a message, e.g. `{"t":"g_s","d":{"spId":"973c742d-866f-4b25-9ae6-8830e7f13e59"}}` as above_
#### Run multiple local app instances (optional)
The app defaults to using ports `8100` for the application server and `9100` for the inter-node connection balancer (see `src/server/server-startup.js` and `src/server/balancer.js`).
To run multiple instances, `export WS_SERVER_PORT=` and `export WS_BALANCER_PORT=` in a new shell before running `npm run start_emulated`. Instances will coordinate via the local PubSub and Datastore emulators.
---
## Deploying in production
The app is designed to be deployed as a series of single-threaded application instances (pods) running in a Docker container cluster via [Kubernetes](https://kubernetes.io/) on [Google Container Engine](https://cloud.google.com/container-engine/). Deploying and running clustered applications is beyond the scope of this document, but the following configuration and setup points are necessary to run this app in such environments.
You'll need to create a `Dockerfile` for your environment along with an `entrypoint` script to run the application on the pods, and modify the application configuration as follows:
1. Your `entrypoint` script must `export` the following environment variables:
* `PROJECT_ID` - your Google Cloud Platform project name
* `ENVIRONMENT_NAME` - a unique identifier to separate different deployment environments, and keeps Datastore and PubSub resources separate for each deployment within a project. As an example, running the app locally via `package.json` uses your computer's `hostname`; you might want to name a particular deployment by region or type
* `LOCAL_IP_ADDRESS` - the private-range IP address by which each pod is accessible and identifies itself to other nodes in the cluster, used by pods to health-check each other when necessary
2. When automatically scaled up or down, application nodes synchronize with new and removed nodes using a PubSub topic. The name of this topic is constructed from your `ENVIRONMENT_NAME` at startup in `src/config.js`
3. Set `PRODUCTION_ENVIRONMENT_PROJECT_ID` and `PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN` in `src/config.js` to reflect the Google Cloud Platform project name and [WebSocket Origin header](https://en.wikipedia.org/wiki/WebSocket#Protocol_handshake) required for connections to your production cluster
Refer to the [Container Engine](https://cloud.google.com/container-engine/docs/) and [Kubernetes](https://kubernetes.io/docs/home/) documentation for further information on how to set up and manage clusters, deployments, services, etc.
---
## How the app works
##### Basic operation
The app accepts client connections to its websocket server (`src/server/websocket-server.js`), parses the requested URL to work out headset type and whether a specific room has been chosen, and then tries to connect the client to a room. It maintains state about which clients and spheres are in which room, which rooms are full or available, and handles all communications from and to clients in all rooms.
##### Managing state
The app holds state in a `redux` state store (`src/store.js`), mutated in reducers (`src/*/*-reducer.js`) via messages `dispatch()`'d to the store, and accessed from the store in async or composite action sequences (`src/server/server-sagas.js`, `src/rooms/room-sagas.js`, `src/messages/message-sagas.js`) using methods provided by the `redux-sagas` module.
##### Starting the server
Servers are started (`src/server/server-startup.js`) via a series of composite actions (`src/server/server-sagas.js`, `src/server/server-setup.js`) defined as `redux-sagas` generator functions, initiated by a server setup action dispatched to the `redux` store at startup (`src/index.js`).
The server requires the environment variables `ENVIRONMENT_NAME`, `LOCAL_IP_ADDRESS` and `PROJECT_ID` in order to run. These are set by the `npm` and `gke` startup tasks, according to where the app's being run.
##### Handling SSL connections
If the environment variable `USE_SSL` is set to `true`, the server requires the environment variable `SSL_CERT_HOST_NAME` to be set too, and retrieves the SSL certificate for that hostname on startup from a [Google Cloud Storage](https://cloud.google.com/storage/) (GCS) bucket in the GCP project, named `-ssl`. The full certificate chain file (named `fullchain.pem`) and the private key file (`privkey.pem`) must be stored in a sub-folder of that GCS bucket with the same name as the required hostname.
##### Managing connections
All connections to a given room are handled by the same instance of the server. The app manages this by running a public/client-facing 'load-balancer' (`src/server/balancer.js`) which proxies client connections either to a local or peer websocket app server, according to which server is associated with the chosen room name in a persistent hash-ring. (See the `hashring` [docs](https://github.com/3rd-Eden/node-hashring) for implementation, and a helpful [explanation](http://blog.plasmaconduit.com/consistent-hashing/).) The hash-ring is stored in the server state (`src/server/server-reducer.js`).
When a connection arrives, the server takes the specified room name or chooses one dynamically according to which rooms are available locally (using `avails` in `src/rooms/room-data-reducer.js` and the state machine in `src/rooms/room-state-machine.js'). It then looks up which server should handle the connection in the hash-ring and proxies the connection to that server.
References to all websocket connections held by a given server are kept in the server state (`src/server/server-reducer.js`). The only non-store-managed state consists of properties (`id` and current-room info) set directly on websocket connection objects by the websocket server.
##### Managing peer servers
In order to maintain a list of peers during automatic scaling of the app in production, the server connects to a PubSub 'sync channel' on startup (`src/server/server-setup.js`), and listens out for sync 'heartbeat' messages from other servers on the channel. It maintains a list of which servers are connected, adding newly-arrived servers to the hash-ring and removing them from it when they time out (stop sending sync heartbeats for a given period).
Each server instance periodically writes information about its state and its PubSub subscription to a ServerSubscriptionInfo record in the Datastore (`src/server/server-sagas.js`), allowing the administrator to retrieve aggregate status info, and enabling the app to check up on timed-out or otherwise dead app instances, removing their status info & PubSub subscriptions as appropriate (also in `src/server/server-sagas.js`).
##### Managing rooms
The server manages room status info (available, full, setting up, etc) using a state machine (`src/rooms/room-state-machine.js`) for each room, initialised at app startup and accessed via the `redux` store. A series of long-running tasks (`src/rooms/room-sagas.js`) handles activity on the rooms (initialising room content, starting/stopping heartbeats, client activity and sphere hold timeouts) using generator functions managed using the `redux-sagas` module.
##### Handling client messages
Clients are restricted to a fixed set of messages which are specified in a JSON schema (`src/messages/message-schema.js`). The websocket server passes received client messages to a message-handler module (`src/messages/message-handler.js`). The message-handler validates incoming messages against the schema (`src/messages/message-validator.js`) and dispatches valid messages to the `redux` state store, using messages constructed from the schema (`src/messages/message-actions.js`). Invalid messages are dropped before getting to dispatch, so any message that gets through at least passes validation.
A series of long-running tasks implemented as `redux-sagas` (`src/messages/message-sagas.js`) handle the validated messages and carry out the requested actions, i.e. sending client position updates and interacting with spheres.
* All possible message interactions between clients and servers are specified in the JSON schema and carried out in the "sagas" - any messages not specified like so are silently dropped.
* Any superfluous information in messages received from clients is silently dropped.
* The websocket server rate-limits client messages on both a per-message-type and per-client basis. Default rates are specified in `RATE_LIMIT_INFO` constants (`src/config.js`), and overridden by values retrieved from `PerEnvironmentRateLimitInfo` Datastore records on startup if present (`src/server/server-sagas.js`). You can request the app reload the limit values from Datastore by sending a `SYNC_MESSAGES.LOAD_RATE_LIMITER_INFO` message to the app via PubSub. (That's a deploy/admin task not implemented in the app source, but the mechanism is visible in `sendSyncMessageOfType()` in `src/server/server-sync.js`.) Messages above the thresholds are silently dropped.
* Any messages received from clients with a 'viewer' headset-type are silently dropped.
---
## Connecting clients to the server
The app accepts standard WebSocket connections to "rooms" in the forest. The server receives messages over the WebSocket, sends responses and broadcasts messages from other occupants of the room all over the same connection.
----
## Messages
Here's an example of the message format:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c", // 'from' id of client or server sending the message
"m": { // message
"t": "",
"d": ""
}
}
```
Message labels (e.g. `f` or `m` above) are set as constants in the file `src/messages/message-constants.js`. In front-end and back-end code, messages are always constructed and consumed using the labels as property names rather than fixed properties, so that the values used can be changed to friendly names during development if required, and restricted to short strings for use in production.
---
## URLs
Clients make connections via URLs of the format `ws://://`.
`` can be `3dof`, `6dof` or `viewer`.
Any action messages sent by `viewer` connections are dropped by the server when running in production.
* To join a specific room, supply both the `` and `` URL components.
* To have a room chosen for you, supply only the `` component.
Any other URLs are invalid and connections to them will be closed immediately with an `INVALID_URL` (`i_u`) message:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "i_u" // invalid_url
}
}
```
---
## Interacting in rooms
* All interactions with the server are carried out using messages in the above format over the websocket connection.
* Clients don't need to identify themselves with `` fields, as IDs are associated directly with client websocket connections on the server.
* The server validates messages received from clients against the appropriate JSON schema for their ``, and immediately drops anything non-conforming, according to the schema in `src/messages/message-schema.js`. It also removes any superfluous content/properties in the messages before storing anything to the room state or broadcasting updates to other clients in the room.
### Joining a room
When the first client joins a room, the server kicks off a heartbeat, and sends an incrementing `ROOM_HEARTBEAT` (`r_h`) sync message to all clients present in the room, every 5 seconds:
```
{
"f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
"m": {
"t": "r_h",
"d": {
"c": 3, // heartbeat count since room start
"s": 15 // heartbeat seconds since room start
}
}
}
```
When a new client joins a room, it gets sent a `CONNECTION_INFO` (`c_i`) message containing its ID and the ID of the server it's connected to:
```
{
"f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
"m": {
"t": "c_i",
"d": {
"cId": "cb439961-3b57-4403-bcc5-208b47565dd1", // clientId
"srId": "a738cbe0-4dac-4389-aa20-1e6a86c166a9" // serverId
}
}
}
```
... followed immediately by a `ROOM_STATUS_INFO` (`r_s_i`) message containing the `ROOM_NAME` (`r_n`), a list of other occupants of the room grouped by viewer type and keyed by their IDs, and a list of spheres in the room with their tones & positions:
```
{
"f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
"m": {
"t": "r_s_i",
"d": {
"rn": "ctyw", // room name
"sb": 1, // soundbank
"c": { // clients, grouped by type
"3dof": [
"cb439961-3b57-4403-bcc5-208b47565dd1"
]
},
"s": { // spheres
"6567cbaa-e6c5-457f-b338-2949b13ff17a": {
"t": 4, // tone
"p": { // position
"x": -0.5858704723117225,
"y": 1.4647926895710155,
"z": 0.2328255217809887
}
},
"0e19b0dd-a90b-4fc8-93d5-24a96365ce2c": {
"t": 6,
"p": {
"x": -0.07124214717156585,
"y": 0.9642806694401945,
"z": 1.181824016202171
}
}
}
}
}
}
```
Existing clients get a `ROOM_CLIENT_JOIN` (`r_c_j`) message indicating a new client has joined along with its id:
```
{
"f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
"m": {
"t": "r_c_j",
"d": {
"cId": "6211a260-d86e-4db9-be00-bd2eba1c2794", // clientId
"ht": "3dof" // headsetType
}
}
}
```
If the server is full, overloaded or in the process of scaling, the server sends a `BUSY_TRY_AGAIN` (`b_t_a`) message, in which case the client should just try to connect again:
```
{
"f": "d02922d2-6db0-4ad1-93e1-0d778250461e",
"m": {
"t": "b_t_a",
"d": {}
}
}
```
### Leaving a room
Clients can disconnect by sending a `ROOM_EXIT` (`r_e`) message:
```
{
"t": "e_r"
}
```
in which case the server will send a `ROOM_EXIT_SUCCESS` (`r_e_s`) message and close the connection:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "r_e_s",
"d": {
"rn": "batr" // room name
}
}
}
```
The client can also just close the websocket connection. 😏
When a client disconnects, its positional data is removed from the room state and any remaining clients get a `ROOM_CLIENT_EXIT` (`r_c_e`) message telling them it's left:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "r_c_e",
"d": {
"cId": "56252c73-2801-4fff-86f9-d214bdd52b94"
}
}
}
```
### In-room actions
Once connected to a room, clients can send positional info, and interact with spheres in the room. All interactions are carried out using the following messages.
#### `UPDATE_CLIENT_COORDS` (`u_c_c`)
This is the most frequent message clients send, notifying a change in client position:
```
{
"t": "u_c_c",
"d": {
"r": { // right
"r": { // rotation
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": { // position
"x": 19.54384,
"y": 25.3323,
"z": 9.81832
}
},
"l": { // left
"r": {
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": {
"x": 19.54384,
"y": 25.3323,
"z": 9.81832
}
},
"h": { // head
"r": {
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": {
"z": 79.618,
"y": 0,
"x": 19.888
}
}
}
}
```
These are broadcast to other clients in the same room as `ROOM_CLIENT_COORDS_UPDATED` (`r_c_c_u`), providing rotation (`r`) and position (`p`) coordinates for each of head (`h`), left hand (`l`) and right hand (`r`):
```
{
"f": "41bd2e10-0f67-4b14-acee-84eeed1764b7",
"m": {
"t": "r_c_c_u",
"d": {
"r": {
"r": {
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": {
"x": 19.54384,
"y": 25.3323,
"z": 9.81832
}
},
"l": {
"r": {
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": {
"x": 19.54384,
"y": 25.3323,
"z": 9.81832
}
},
"h": {
"r": {
"x": 9.234324,
"y": 55.232423,
"z": 7.234234
},
"p": {
"z": 79.618,
"y": 0,
"x": 19.888
}
}
}
}
}
```
Clients can optionally include sphere positions in the `UPDATE_CLIENT_COORDS` messages, for any spheres they currently hold (see below). To do so, they add an extra `spheres` object at the same level as the `head`, `left` and `right`:
```
{
"f": "41bd2e10-0f67-4b14-acee-84eeed1764b7",
"m": {
"t": "r_c_c_u",
"d": {
"s": [ // spheres
{
"spId": "df0a769c-653d-44ec-9f17-e5a9d2c0f167",
"p": {
"x": 156,
"y": 22,
"z": 10
}
},
{
"spId": "69143b9a-1342-4aec-a7da-1daac29cee3d",
"p": {
"x": 2,
"y": 3,
"z": 4
}
}
],
"r": { [...] }, // right
"l": { [...] }, // left
"h": { [...] } // head
}
}
}
```
Clients can interact with spheres in the room by sending messages as follows.
#### `CREATE_SPHERE_OF_TONE_AT_POSITION` (`c_s_o_t_a_p`)
```
{
"t": "c_s_o_t_a_p",
"d": {
"t": 0, // tone
"p": { // position
"x": 1,
"y": 1,
"z": 3
}
}
}
```
On successful creation, the server sends a `CREATE_SPHERE_SUCESS` (`c_s_s`) message:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c", // from
"m": {
"t": "c_s_s",
"d": {
"spId": "cad0a7d4-a055-49b2-a3f8-4e08d636dbce"
}
}
}
```
#### `GRAB_SPHERE` (`g_s`)
```
{
"t": "g_s",
"d": {
"spId": "090253c9-1c68-44c2-8aac-dba707c6aef7"
}
}
```
On successful grab, server sends `GRAB_SPHERE_SUCCESS` (`g_s_s`):
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "g_s_s",
"d": {
"spId": "31654801-9e61-4a5e-bdd6-a29e8ecd1001"
}
}
}
```
Before accepting a grab, the server checks its state to see whether the sphere exists and whether it's already held by any other clients. Any problems are communicated back to the client. Possible errors are:
`NON_EXISTENT_SPHERE` (`n_e_s`)
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "n_e_s",
"d": {
"spId": "66b93b19-03d6-4220-b44f-5b33d5d59269" // sphereId
}
}
}
```
`CLIENT_HOLDING_SPHERE` (`c_h_s`):
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "c_h_s",
"d": {
"spId": "29312c56-b1a5-4530-8c7b-2c968e158683",
"hId": "1eec65a7-2c9a-4fe4-a998-55d3f1754f21" // holderId - same as client in this case
}
}
}
```
`SPHERE_ALREADY_HELD` (`s_a_h`) by another client:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "s_a_h",
"d": {
"spId": "66b93b19-03d6-4220-b44f-5b33d5d59269",
"hId": "055fa916-5e99-44dd-9e23-2f9323bb6f4c"
}
}
}
```
`CLIENT_HOLDING_MAX_SPHERES` (`c_h_m_s`) - a client can only hold one sphere in each hand:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "c_h_m_s",
"d": {}
}
}
```
A successful grab creates a "hold" on the sphere, stopping other clients from acting on the sphere, and lasting until either the client sends a `RELEASE_SPHERE` (`r_s`) message, or is inactive (sends no messages relating to the held sphere) for a period defined in `roomConstants.SPHERE_INFO.SPHERE_HOLD_TIMEOUT`, in the file `src/rooms/room-data-constants.js`, currently set to 10 seconds. If the hold times out, the server sends a `SPHERE_HOLD_TIMEOUT` (`s_h_t`) to the client:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "s_h_t",
"d": {
"spId": "922fbed6-5c57-4c14-bf0f-080abf5fa285"
}
}
}
```
and a `ROOM_SPHERE_RELEASED` (`r_s_r`) message to any other clients in the room:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "r_s_r",
"d": {
"spId": "922fbed6-5c57-4c14-bf0f-080abf5fa285",
"cId": "1fe992a5-05d9-4791-8482-c598f31dbe22"
}
}
}
```
#### `RELEASE_SPHERE` (`r_s`)
```
{
"t": "r_s",
"d": {
"spId": "6f0e19bd-2451-43a0-be9d-b6e208e65ee2"
}
}
```
On receiving a `RELEASE_SPHERE` message, the server checks that the client is holding it, and if so sends a `RELEASE_SPHERE_SUCCESS` (`r_sp_s`) message to the client:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "r_sp_s",
"d": {
"spId": "a62cabcd-e7d9-4687-beac-0c6c7fb9b953"
}
}
}
```
and a `ROOM_SPHERE_RELEASED` (`r_s_r`) message to any other clients in the room:
```
{
"f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
"m": {
"t": "r_s_r",
"d": {
"spId": "a62cabcd-e7d9-4687-beac-0c6c7fb9b953",
"cId": "d8f59526-60b6-47ee-ab86-f39f441b8aaa" // released by this client
}
}
}
```
Possible errors are `NON_EXISTENT_SPHERE` (as in `GRAB_SPHERE` above) and `RELEASE_SPHERE_INVALID` (`r_sp_i`):
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "r_sp_i",
"d": {
"spId": "9c674bab-e2a6-4588-beff-2342d10a97b0"
}
}
}
```
#### `DELETE_SPHERE` (`d_s`)
```
{
"t": "d_s",
"d": {
"spId": "cfa16485-51e5-4db0-b54a-1cab6b6f003f"
}
}
```
On successful delete the server sends `DELETE_SPHERE_SUCCESS` (`d_s_s`):
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "d_s_s",
"d": {
"spId": "cfa16485-51e5-4db0-b54a-1cab6b6f003f"
}
}
}
```
and notifies any other clients with a `ROOM_SPHERE_DELETED` (`r_s_d`) message:
```
{
"f": "5e44fd4e-54cb-42d7-ac41-f381a3844fee",
"m": {
"t": "r_s_d",
"d": {
"spId": "9de12a8d-96a4-42e8-842e-4230db33b306",
"cId": "5e44fd4e-54cb-42d7-ac41-f381a3844fee"
}
}
}
```
Possible errors include `NON_EXISTENT_SPHERE` (as above), `DELETE_SPHERE_DENIED` (`d_s_d`) if another client is holding the sphere:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "d_s_d",
"d": {
"spId": "9de12a8d-96a4-42e8-842e-4230db33b306",
"hId": "9847a6ac-ae7c-43a8-8599-83b3079c9f1a"
}
}
}
```
#### `STRIKE_SPHERE` (`s_s`)
```
{
"t": "s_s",
"d": {
"spId": "aabb5ecd-1662-4db5-91f9-6bd1d8f2b0f9",
"v": 0.5
}
}
```
Sphere strikes are broadcast to other clients in the room via a `ROOM_SPHERE_STRUCK` (`r_s_s`) message:
```
{
"f": "1a3823f3-79e6-4cd1-9e33-2044d16da300",
"m": {
"t": "r_s_s",
"d": {
"spId": "aabb5ecd-1662-4db5-91f9-6bd1d8f2b0f9",
"v": 0.5,
"cId": "1a3823f3-79e6-4cd1-9e33-2044d16da300"
}
}
}
```
Strikes sent to non-existent spheres are silently dropped.
#### `SET_SPHERE_TONE` (`s_s_t`)
```
{
"t": "s_s_t",
"d": {
"spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33",
"t": 10 // tone
}
}
```
On successful set, the server sends a `SET_SPHERE_TONE_SUCCESS` (`s_s_t_s`) message:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "s_s_t_s",
"d": {
"spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33"
}
}
}
```
and notifies other clients of the change in tone with a `ROOM_SPHERE_TONE_SET` (`r_s_t_s`) message:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "r_s_t_s",
"d": {
"spId": "cd3aa6cf-f15c-4d7a-be6e-d862912fbd62",
"t": 10, // tone
"cId": "13eb8894-330a-4e37-8ce3-645edf3c6773"
}
}
}
```
Clients don't need to hold a sphere to set its tone, but if it's currently held by another client, the server will send a `SET_SPHERE_TONE_DENIED` (`s_s_t_d`) message:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "s_s_t_d",
"d": {
"spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33",
"hId": "9e8e4e40-a523-4345-8e21-b274177fe073"
}
}
}
```
As above, the server will send a `NON_EXISTENT_SPHERE` message if the specified sphere doesn't exist in the room.
#### `SET_SPHERE_CONNECTIONS` (`s_s_c`)
```
{
"t": "s_s_c",
"d": {
"spId": "973c742d-866f-4b25-9ae6-8830e7f13e59",
"c": [ // connections
"221c3e00-c0e9-4bd9-bdb3-187c5dcf5499",
"03d6c324-73e0-4804-9b84-9b203a303a38"
]
}
}
```
As in `SET_SPHERE_TONE`, clients don't need to hold a sphere to set its connections, but if it's currently held by another client, the server will send a `SET_SPHERE_CONNECTIONS_DENIED` (`s_s_c_d`) message:
```
{
"f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
"m": {
"t": "s_s_c_d",
"d": {
"spId": "973c742d-866f-4b25-9ae6-8830e7f13e59",
"hId": "7bf0b7a2-ee3e-4b76-9d68-675aa4e97f2a"
}
}
}
```
As above, the server will send a `NON_EXISTENT_SPHERE` message if the specified sphere doesn't exist in the room.
================================================
FILE: backend/package.json
================================================
{
"name": "webvr-musicalforest-backend",
"version": "1.0.0",
"description": "node.js websocket app server for forest.webvrexperiments.com",
"main": "index.js",
"scripts": {
"start": "ENVIRONMENT_NAME=$(hostname) LOCAL_IP_ADDRESS=$(/sbin/ifconfig en0|grep -w inet|awk '{print $2}') PROJECT_ID=$(gcloud config list project --format json | jq -r .core.project) babel-node src/index.js",
"build": "babel src -d dist",
"clean": "rm -rf dist",
"serve": "NODE_ENV=production ENVIRONMENT_NAME=$(hostname) LOCAL_IP_ADDRESS=$(/sbin/ifconfig en0|grep -w inet|awk '{print $2}') PROJECT_ID=$(gcloud config list project --format json | jq -r .core.project) node dist/index.js",
"emulators": "parallelshell 'gcloud beta emulators datastore start --no-store-on-disk' 'gcloud beta emulators pubsub start'",
"start_emulated": "$(gcloud beta emulators datastore env-init) && $(gcloud beta emulators pubsub env-init) && NODE_ENV=production npm start"
},
"repository": {
"type": "git",
"url": "https://github.com/googlecreativelab/webvr-musicalforest/"
},
"author": "Google Creative Lab",
"license": "Apache-2.0",
"devDependencies": {
"babel-cli": "6.18.0",
"babel-polyfill": "6.16.0",
"babel-preset-es2015": "6.18.0",
"babel-preset-stage-2": "6.18.0",
"mocha": "3.2.0",
"nodemon": "1.11.0",
"parallelshell": "^2.0.0"
},
"dependencies": {
"@google-cloud/datastore": "0.7.0",
"@google-cloud/logging": "0.7.0",
"@google-cloud/pubsub": "0.8.0",
"@google-cloud/storage": "0.7.0",
"ajv": "4.10.0",
"arrify": "1.0.1",
"dateformat": "2.0.0",
"express": "4.14.0",
"fast-ratelimit": "2.0.6",
"grpc": "1.0.1",
"hashring": "3.2.0",
"hashtable": "2.0.1",
"http-proxy": "1.16.2",
"javascript-state-machine": "2.4.0",
"node-fetch": "1.6.3",
"object-sizeof": "1.1.1",
"redux": "3.6.0",
"redux-actions": "1.1.0",
"redux-saga": "0.14.3",
"tracer": "0.8.7",
"uuid": "3.0.1",
"uws": "0.14.1"
}
}
================================================
FILE: backend/src/config.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import uuid from 'uuid';
import dateformat from 'dateformat';
import storage from '@google-cloud/storage';
import messageConstants from './messages/message-constants';
import serverConstants from './server/server-constants';
import { formatStringAsGcpResourceName } from './utils/string-utils';
const logError = ( errorMsg ) => {
let ts = dateformat( new Date(), LOG_DATE_FORMAT );
console.error( `[${SHORT_SERVER_ID}|${ts}] => `, errorMsg );
};
/*******************************************************************************
* REQUIRED ENVIRONMENT VARIABLES
*******************************************************************************/
// nothing will work without a GCP PROJECT_ID
if( !process.env.PROJECT_ID ) {
logError( `PROJECT_ID not specified, exiting.` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}
const PROJECT_ID = process.env.PROJECT_ID;
// nothing will work without a LOCAL_IP_ADDRESS
if( !process.env.LOCAL_IP_ADDRESS ) {
logError( `LOCAL_IP_ADDRESS not specified, exiting.` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}
const LOCAL_IP_ADDRESS = process.env.LOCAL_IP_ADDRESS;
// nothing will work without an ENVIRONMENT_NAME
if( !process.env.ENVIRONMENT_NAME ) {
logError( `ENVIRONMENT_NAME not specified, exiting.` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
};
const ENVIRONMENT_NAME = process.env.ENVIRONMENT_NAME;
/*******************************************************************************
* LOG FORMAT INFO
*******************************************************************************/
// generate a server id
const SERVER_ID = uuid();
const SHORT_SERVER_ID = SERVER_ID.split( '-' ).slice(0, 2).join( '-' );
const LOG_DATE_FORMAT = "yyyymmdd|HH:MM:ss.l";
/*******************************************************************************
* PUBSUB SYNC CHANNEL INFO
*******************************************************************************/
// setup PubSub sync channel topic name, distinct per execution group
// e.g. 'gce', 'gke', 'mymac.config'
const syncTopicNameComponents = [
serverConstants.SYNC_INFO.SYNC_TOPIC_NAME,
'-',
ENVIRONMENT_NAME
];
const SYNC_TOPIC_NAME = formatStringAsGcpResourceName(
syncTopicNameComponents.join( '' )
);
// only log to Stackdriver explicitly if requested
const LOG_TO_CLOUD = process.env.LOG_TO_CLOUD === 'true' ? true : false;
/*******************************************************************************
* NETWORK IP/PORT INFO
*******************************************************************************/
// set up some defaults
const WS_SERVER_PORT = process.env.WS_SERVER_PORT ? Number( process.env.WS_SERVER_PORT ) : 8100;
const WS_BALANCER_PORT = process.env.WS_BALANCER_PORT ? Number( process.env.WS_BALANCER_PORT ) : 9100;
const MAX_CLIENTS_PER_ROOM = process.env.MAX_CLIENTS_PER_ROOM ? Number( process.env.MAX_CLIENTS_PER_ROOM ) : 10;
// construct balancer identifier
const LOCAL_IP_PORT_STRING = `${LOCAL_IP_ADDRESS}:${WS_SERVER_PORT}`;
/*******************************************************************************
* SSL INFO FOR HOSTING ON GKE
*******************************************************************************/
// GKE deploys provide USE_SSL=true in the environment
const USE_SSL = process.env.USE_SSL === "true" ? true : false;
// tell the app whether to serve SSL
let SSL_INFO = {
useSsl: USE_SSL
};
// if so, set up the certificate info
if( USE_SSL === true ) {
// make sure there's a specified certificate name
if( !process.env.SSL_CERT_HOST_NAME ) {
logError( `USE_SSL=true requires a specified SSL_CERT_HOST_NAME` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}
// prepare for export, set up GCS bucket/file info
SSL_INFO.sslCertHostName = process.env.SSL_CERT_HOST_NAME;
SSL_INFO.sslStorageBucketName = `${PROJECT_ID}-ssl`;
SSL_INFO.privKeyFileName = `${process.env.SSL_CERT_HOST_NAME}/privkey.pem`;
SSL_INFO.fullChainFileName = `${process.env.SSL_CERT_HOST_NAME}/fullchain.pem`;
}
/*******************************************************************************
* ROOM CHOICE THRESHOLD CONFIGS
*******************************************************************************/
// provide shortcuts for headset type constants
const HT_3DOF = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
const HT_6DOF = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
const HT_VIEWER = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;
// set up headset threshold rules
const HEADSET_RULES = {
[ HT_3DOF ]: {
threshold: 3
},
[ HT_6DOF ]: {
threshold: 3
},
[ HT_VIEWER ]: {
threshold: 10
}
};
/*******************************************************************************
* CHOICE OF WHETHER TO TIMEOUT SPHERE HOLDS AUTOMATICALLY
*******************************************************************************/
const TIMEOUT_SPHERE_HOLDS = process.env.TIMEOUT_SPHERE_HOLDS === 'false' ? false : true;;
/*******************************************************************************
* CHOICE OF WHETHER TO DROP VIEWER MESSAGES
*******************************************************************************/
const DROP_VIEWER_MESSAGES_IN_PRODUCTION = process.env.DROP_VIEWER_MESSAGES_IN_PRODUCTION === 'true' ? true : false;
/*******************************************************************************
* CHOICE OF WHETHER TO RATE LIMIT CLIENT/SPHERE POSITION UPDATES
*******************************************************************************/
const PER_CLIENT_RATE_LIMIT = process.env.PER_CLIENT_RATE_LIMIT === 'false' ? false : true;
const PER_MESSAGE_TYPE_RATE_LIMIT = process.env.PER_MESSAGE_TYPE_RATE_LIMIT === 'false' ? false : true;
const RATE_LIMIT_INFO = {
perClientRateLimit: PER_CLIENT_RATE_LIMIT, // switch per-clientrate limiting on or off
perMessageTypeRateLimit: PER_MESSAGE_TYPE_RATE_LIMIT, // switch rate limiting on or off
perTypeMsgThreshold: 1, // 1 message of each type per client
perTypeMsgTtl: 0.2, // per 200ms
perClientMsgThreshold: 20, // 20 message of any type per client
perClientMsgTtl: 1 // per 1s
};
/*******************************************************************************
* NAME OF PRODUCTION ENVIRONMENT GCP PROJECT
*******************************************************************************/
const PRODUCTION_ENVIRONMENT_PROJECT_ID = '';
const PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN = '';
/*******************************************************************************
* EXPORT CONFIG
*******************************************************************************/
export default {
environmentName: ENVIRONMENT_NAME,
syncTopicName: SYNC_TOPIC_NAME,
logDateFormat: LOG_DATE_FORMAT,
logToCloud: LOG_TO_CLOUD,
localIpAddress: LOCAL_IP_ADDRESS,
localIpPortString: LOCAL_IP_PORT_STRING,
serverPort: WS_SERVER_PORT,
balancerPort: WS_BALANCER_PORT,
projectId: PROJECT_ID,
dropViewerMessagesInProduction: DROP_VIEWER_MESSAGES_IN_PRODUCTION,
productionEnvironmentProjectId: PRODUCTION_ENVIRONMENT_PROJECT_ID,
productionEnvironmentRequiredOrigin: PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN,
maxClientsPerRoom: MAX_CLIENTS_PER_ROOM,
clientRoomJoinDeadlineInMs: 5000,
roomHeartbeatDelayInMs: 5000,
serverId: SERVER_ID,
shortServerId: SHORT_SERVER_ID,
sslInfo: SSL_INFO,
headsetRules: HEADSET_RULES,
rateLimitInfo: RATE_LIMIT_INFO,
timeoutSphereHolds: TIMEOUT_SPHERE_HOLDS
};
================================================
FILE: backend/src/datastore/datastore-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const DATASTORE = {
// entity types
DS_ENTITY_KEY_SERVER_SUBSCRIPTION_INFO: 'ServerSubscriptionInfo',
DS_ENTITY_KEY_PER_ENVIRONMENT_RATE_LIMIT_INFO: 'PerEnvironmentRateLimitInfo'
};
export default DATASTORE;
================================================
FILE: backend/src/datastore/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import datastore from '@google-cloud/datastore';
import config from '../config';
import { logger } from '../logger';
import datastoreConstants from './datastore-constants';
// create a module-wrapped datastore client object at startup
let dsClient = ( ( conf ) => {
if( !dsClient ) {
if( process.env.DATASTORE_EMULATOR_HOST ) {
logger.info( `using Datastore emulator running at ${process.env.DATASTORE_EMULATOR_HOST}` );
}
else {
logger.info( `using live Datastore on GCP` );
}
dsClient = datastore( conf );
}
return dsClient;
})( config );
const runQuery = function* ( query ) {
return yield dsClient.runQuery( query );
};
const save = function* ( entities ) {
return yield dsClient.save( entities );
};
const key = function ( keyComponents ) {
return dsClient.key( keyComponents );
};
// export functions to the default namespace, to make a 'datastore' import for other modules
export default {
runQuery,
save,
key
};
// export constants individually, to make a { datastoreConstants } import for other modules
export {
datastoreConstants,
dsClient
}
================================================
FILE: backend/src/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import "babel-polyfill";
import config from './config';
import { dispatchStoreAction } from './store';
import { logger } from './logger';
import { serverActions } from './server';
// create a server setup request action
let setupAction = serverActions.startServerSetupRequestAction();
/*
* dispatch the setup request to the store.
* all the action now happens via setupServer() in
* server/server-sagas.js
*/
dispatchStoreAction( setupAction );
================================================
FILE: backend/src/logger/cloud-logger.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Logging from '@google-cloud/logging';
import config from '../config';
import { formatStringAsGcpResourceName } from '../utils/string-utils';
// create a client
const loggingClient = Logging( config );
// the name of the log to write to
const logName = formatStringAsGcpResourceName(
`${config.environmentName}-cloud-app-log`
);
// choose a log to write to
const selectedLog = loggingClient.log( logName );
// set up resource metadata
const metadata = {
resource: {
type: 'project',
labels: {
project_id: config.projectId
}
}
};
// log function wrapper
const writeCloudLogEntry = ( tracerData ) => {
const entry = selectedLog.entry( metadata, tracerData.output );
selectedLog.write(entry).catch(
( error ) => {
console.error( `error logging message: ${error.message}` );
}
);
};
export {
writeCloudLogEntry
};
================================================
FILE: backend/src/logger/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import tracer from 'tracer';
import config from '../config';
import { writeCloudLogEntry } from './cloud-logger';
let selectedConsole = config.logToCloud ? tracer.console : tracer.colorConsole;
let consoleOptions = {
format: "[{{shortServerId}}|{{timestamp}}] {{sigil}} {{message}} [{{file}}:{{line}}]",
dateformat: config.logDateFormat,
preprocess: (data) => {
data.shortServerId = config.shortServerId;
data.localIpAddress = config.localIpAddress;
data.sigil = {
error: `=>`,
warn: `**`,
info: `->`,
debug: `||`,
trace: `##`,
log: ``
}[ data.title ];
}
};
// only do info() and above in production
if( config.projectId === config.productionEnvironmentProjectId ) {
consoleOptions.level = 'info';
consoleOptions.format = "[{{shortServerId}}|{{localIpAddress}}|{{timestamp}}] {{sigil}} {{message}} [{{file}}:{{line}}]";
}
// write log via Stackdriver API?
if( config.logToCloud ) {
consoleOptions.transport = writeCloudLogEntry;
}
let l = selectedConsole( consoleOptions );
let logger = {
error: l.error,
warn: l.warn,
info: l.info,
debug: l.debug,
trace: l.trace,
log: l.log
};
export {
logger
};
================================================
FILE: backend/src/messages/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import messageActions from './message-actions';
import messageConstants from './message-constants';
import messageHandler from './message-handler';
import messageSagas from './message-sagas';
import messageValidator from './message-validator';
export {
messageActions,
messageConstants,
messageHandler,
messageSagas,
messageValidator
};
================================================
FILE: backend/src/messages/message-actions.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import messageConstants from './message-constants';
import { messageSchema } from './message-schema';
/*******************************************************************************
*
* reference of data structure from JSON schema
*
* {
* definitions: {
* ...,
* room_client_position_update:
* { type: 'object',
* properties: [Object],
* required: [Object] }
* },
*
* properties: {
* ...,
* room_client_position_update:
* { '$ref': '#/definitions/room_client_position_update' }
* }
* }
*
* room_client_position_update: { type: { type: 'string', minLength: 29, maxLength: 29 },
* data:
* { type: 'object',
* properties:
* { head: [Object],
* left: [Object],
* right: [Object],
* userdata: [Object] },
* required: [ 'head', 'left', 'right' ] } }
*
********************************************************************************/
/*******************************************************************************
* see https://github.com/acdlite/redux-actions
*
* redux-actions generates action request objects for use as triggers in redux.
*
* example:
* {
* type: 'INCREMENT',
* payload: 42,
* otherVar: true
* }
*
*******************************************************************************/
// generates 'redux-actions' action functions for all incoming message types in schema
const messageRequestActions = Object.keys( messageSchema.properties ).reduce(
// for each key in messageSchema.properties
( acc, type ) => {
// create a function which ...
let fn = function() {
// combines the provided type with fn args to generate an action request object
return Object.assign( { type }, arguments[ 0 ] );
};
// key the function by the incoming message type name, to export them all
acc[ type ] = fn;
return acc;
},
{} // empty starting accumulator
);
// export the generated functions for use as "import messageActions from 'message-actions'"
export default messageRequestActions;
================================================
FILE: backend/src/messages/message-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const sphereIdLabel = 'spId';
const clientIdLabel = 'cId';
const holderIdLabel = 'hId';
const serverIdLabel = 'srId';
const toneLabel = 't';
const positionLabel = 'p';
const rotationLabel = 'r';
const headLabel = 'h';
const leftLabel = 'l';
const rightLabel = 'r';
const sphereLabel = 's';
const headsetTypeLabel = 'ht';
const velocityLabel = 'v';
const connectionsLabel = 'c';
const meristemLabel = 'm';
const fromLabel = 'f';
const typeLabel = 't';
const dataLabel = 'd';
const msgLabel = 'm';
const roomNameLabel = 'rn';
const soundbankLabel = 'sb';
const clientsLabel = 'c';
const spheresLabel = 's';
const INCOMING_MESSAGE_TYPES = {
EXIT_ROOM: 'e_r',
UPDATE_CLIENT_COORDS: 'u_c_c', // position msg for individual client
CREATE_SPHERE_OF_TONE_AT_POSITION: 'c_s_o_t_a_p',
GRAB_SPHERE: 'g_s',
RELEASE_SPHERE: 'r_s',
DELETE_SPHERE: 'd_s',
STRIKE_SPHERE: 's_s',
SET_SPHERE_TONE: 's_s_t',
SET_SPHERE_CONNECTIONS: 's_s_c'
};
const INCOMING_MESSAGE_COMPONENTS = {
ALL_MESSAGES: {
FROM: fromLabel,
MSG: msgLabel,
TYPE: typeLabel,
DATA: dataLabel
},
REFERENCES: {
COORDINATE: 'coordinate',
COORDINATE_SET: 'coordinateSet'
},
REF_COORDINATE: {
X: 'x',
Y: 'y',
Z: 'z'
},
REF_COORDINATE_SET: {
POSITION: positionLabel,
ROTATION: rotationLabel
},
UPDATE_CLIENT_COORDS: {
HEAD: headLabel,
LEFT: leftLabel,
RIGHT: rightLabel,
SPHERES: spheresLabel,
SPHERE_ID: sphereIdLabel,
SPHERE_POSITION: positionLabel
},
CREATE_SPHERE_OF_TONE_AT_POSITION: {
TONE: toneLabel,
POSITION: positionLabel
},
GRAB_SPHERE: {
SPHERE_ID: sphereIdLabel
},
RELEASE_SPHERE: {
SPHERE_ID: sphereIdLabel
},
DELETE_SPHERE: {
SPHERE_ID: sphereIdLabel
},
STRIKE_SPHERE: {
SPHERE_ID: sphereIdLabel,
VELOCITY: velocityLabel
},
SET_SPHERE_TONE: {
SPHERE_ID: sphereIdLabel,
TONE: toneLabel
},
SET_SPHERE_CONNECTIONS: {
SPHERE_ID: sphereIdLabel,
CONNECTIONS: connectionsLabel
}
};
const OUTGOING_MESSAGE_TYPES = {
CONNECTION_INFO: 'c_i', // sent to new clients on connect
ROOM_STATUS_INFO: 'r_s_i', // sent to new clients on joining a room
ROOM_EXIT_SUCCESS: 'r_e_s', // sent to clients leaving a room
ROOM_HEARTBEAT: 'r_h', // server heartbeat message for a room
ROOM_CLIENT_JOIN: 'r_c_j', // sent to existing clients on client join
ROOM_CLIENT_EXIT: 'r_c_e', // sent to remaining clients on client exit
ROOM_CLIENT_COORDS_UPDATED: 'r_c_c_u',
ROOM_SPHERE_CREATED: 'r_s_c',
ROOM_SPHERE_GRABBED: 'r_s_g',
ROOM_SPHERE_POSITION_UPDATED: 'r_s_p_u',
ROOM_SPHERE_TONE_SET: 'r_s_t_s',
ROOM_SPHERE_CONNECTIONS_SET: 'r_s_c_s',
ROOM_SPHERE_RELEASED: 'r_s_r',
ROOM_SPHERE_STRUCK: 'r_s_s',
ROOM_SPHERE_DELETED: 'r_s_d',
CREATE_SPHERE_DENIED: 'c_s_d',
CREATE_SPHERE_SUCCESS: 'c_s_s',
GRAB_SPHERE_DENIED: 'g_s_d',
GRAB_SPHERE_SUCCESS: 'g_s_s',
RELEASE_SPHERE_DENIED: 'r_sp_d',
RELEASE_SPHERE_INVALID: 'r_sp_i',
RELEASE_SPHERE_SUCCESS: 'r_sp_s',
DELETE_SPHERE_DENIED: 'd_s_d',
DELETE_SPHERE_INVALID: 'd_s_i',
DELETE_SPHERE_SUCCESS: 'd_s_s',
SET_SPHERE_TONE_DENIED: 's_s_t_d',
SET_SPHERE_TONE_INVALID: 's_s_t_i',
SET_SPHERE_TONE_SUCCESS: 's_s_t_s',
SET_SPHERE_CONNECTIONS_DENIED: 's_s_c_d',
SET_SPHERE_CONNECTIONS_INVALID: 's_s_c_i',
SET_SPHERE_CONNECTIONS_SUCCESS: 's_s_c_s',
CONNECT_SPHERES_IDENTICAL: 'c_s_id',
CONNECT_SPHERES_INVALID: 'c_s_in',
CONNECT_SPHERES_MISSING: 'c_s_m'
};
const OUTGOING_MESSAGE_COMPONENTS = {
ALL_MESSAGES: {
DATA: dataLabel,
FROM: fromLabel,
MSG: msgLabel,
TYPE: typeLabel,
CLIENT_ID: clientIdLabel
},
CONNECTION_INFO: {
CLIENT_ID: clientIdLabel,
SERVER_ID: serverIdLabel
},
ROOM_STATUS_INFO: {
ROOM_NAME: roomNameLabel,
SOUNDBANK: soundbankLabel,
CLIENTS: clientsLabel,
CLIENT_ID: clientIdLabel,
CLIENT_HEADSET_TYPE: headsetTypeLabel,
SPHERES: spheresLabel,
SPHERE_ID: sphereIdLabel,
TONE: toneLabel,
POSITION: positionLabel,
CONNECTIONS: connectionsLabel,
MERISTEM: meristemLabel
},
ROOM_EXIT_SUCCESS: {
ROOM_NAME: roomNameLabel
},
CREATE_SPHERE_SUCCESS: {
SPHERE_ID: sphereIdLabel
},
SPHERE_ACTION_DENIED: {
SPHERE_ID: sphereIdLabel,
HOLDER_ID: holderIdLabel,
},
ROOM_SPHERE_CREATED: {
SPHERE_ID: sphereIdLabel,
TONE: toneLabel,
POSITION: positionLabel,
CLIENT_ID: clientIdLabel
},
GRAB_SPHERE_SUCCESS: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel
},
RELEASE_SPHERE_SUCCESS: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel
},
SPHERE_HOLD_TIMEOUT: {
SPHERE_ID: sphereIdLabel,
},
ROOM_SPHERE_RELEASED: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel
},
ROOM_CLIENT_COORDS_UPDATED: {
HEAD: headLabel,
LEFT: leftLabel,
RIGHT: rightLabel,
SPHERES: sphereLabel
},
ROOM_SPHERE_POSITION_UPDATED: {
SPHERE_ID: sphereIdLabel,
POSITION: positionLabel,
CLIENT_ID: clientIdLabel
},
SET_SPHERE_TONE_SUCCESS: {
SPHERE_ID: sphereIdLabel
},
ROOM_SPHERE_TONE_SET: {
SPHERE_ID: sphereIdLabel,
TONE: toneLabel,
CLIENT_ID: clientIdLabel
},
SET_SPHERE_CONNECTIONS_SUCCESS: {
SPHERE_ID: sphereIdLabel
},
ROOM_SPHERE_CONNECTIONS_SET: {
SPHERE_ID: sphereIdLabel,
CONNECTIONS: connectionsLabel,
CLIENT_ID: clientIdLabel
},
DELETE_SPHERE_SUCCESS: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel
},
ROOM_SPHERE_DELETED: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel
},
ROOM_SPHERE_STRUCK: {
SPHERE_ID: sphereIdLabel,
CLIENT_ID: clientIdLabel,
VELOCITY: velocityLabel
},
ROOM_CLIENT_JOIN: {
CLIENT_ID: clientIdLabel,
CLIENT_HEADSET_TYPE: headsetTypeLabel
},
ROOM_CLIENT_EXIT: {
CLIENT_ID: clientIdLabel
},
ROOM_HEARTBEAT: {
COUNT: 'c',
SECONDS: 's'
}
};
const HEADSET_TYPES = {
HEADSET_TYPE_3DOF: '3dof',
HEADSET_TYPE_6DOF: '6dof',
HEADSET_TYPE_VIEWER: 'viewer'
};
const ERROR_TYPES = {
INVALID_URL: 'i_u',
NO_ROOMS_AVAILABLE: 'n_r_a',
NOT_IN_ROOM: 'n_i_r',
NO_SUCH_ROOM: 'n_s_r',
ROOM_QUEUE_FULL: 'r_q_f',
ROOM_FULL: 'r_f',
ROOM_UNAVAILABLE: 'r_u',
BUSY_TRY_AGAIN: 'b_t_a',
ROOM_NOT_READY: 'r_n_r',
ROOM_JOIN_TIMEOUT: 'r_j_t',
ALREADY_IN_ROOM: 'a_i_r',
ALREADY_IN_ROOM_QUEUE: 'a_i_r_q',
CREATE_SPHERE_UNAVAILABLE: 'c_s_u',
NON_EXISTENT_SPHERE: 'n_e_s',
SPHERE_ALREADY_HELD: 's_a_h',
CLIENT_HOLDING_SPHERE: 'c_h_s',
CLIENT_HOLDING_MAX_SPHERES: 'c_h_m_s',
TOO_MANY_SPHERE_CONNECTIONS: 't_m_s_c',
GRAB_SPHERE_ERROR: 'g_s_e',
RELEASE_SPHERE_ERROR: 'r_s_e',
DELETE_SPHERE_ERROR: 'd_s_e',
CONNECT_SPHERE_ERROR: 'c_s_e',
SPHERE_HOLD_TIMEOUT: 's_h_t',
CLIENT_INACTIVITY_TIMEOUT: 'c_i_t',
SYSTEM_ERROR: 's_'
};
export default {
INCOMING_MESSAGE_TYPES,
INCOMING_MESSAGE_COMPONENTS,
OUTGOING_MESSAGE_TYPES,
OUTGOING_MESSAGE_COMPONENTS,
HEADSET_TYPES,
ERROR_TYPES
};
export {
HEADSET_TYPES
};
================================================
FILE: backend/src/messages/message-handler.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
roomDataActions,
roomContentConstants,
roomDataConstants,
roomStateConstants
} from '../rooms';
import { logger } from '../logger';
import { roomStateActions } from '../rooms'
import { serialize } from '../s11n';
import { sendWsMessageWithLogger } from '../utils/websocket-utils';
import {
dispatchStoreAction,
getStoreState
} from '../store';
import config from '../config';
import messageActions from './message-actions';
import messageConstants from './message-constants';
import { perMessageTypeRateLimiter } from './message-rate-limiter';
import messageValidator from './message-validator';
const messageComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
/*******************************************************************************
* HANDLER FOR INCOMING ROOM MESSAGES FROM THIS SERVER'S WEBSOCKET CLIENTS
*******************************************************************************/
/*
* this function validates an incoming message against the JSON message schema,
* and dispatches successfully validated messages to the appropriate handler
* defined in messageActions, generated from the message types in the schema.
*/
const handleWebsocketMessage = ( ws, message ) => {
// drop empty messages
if( typeof message === 'undefined' || message === '' || message === null ) {
return;
}
// make sure the client's in a room
if( typeof ws.currentRoom === 'undefined' ) {
return;
}
// make sure message format is valid, drop any invalid messages
let validatedMessage;
try {
validatedMessage = messageValidator.validateMessage( message );
}
catch( error ) {
logger.debug( `error validating incoming message from client ${ws.id}: ${error.message}` );
return;
}
// messageActions exports action functions generated from schema, hence keyed the same
let validatedMessageType = validatedMessage[ messageComponents.ALL_MESSAGES.TYPE ];
// if rate limiting's switched off
if( config.rateLimitInfo.perMessageTypeRateLimit === false ) {
// handle the message straightaway
handleValidatedMessageOfTypeForWebsocket( validatedMessage, validatedMessageType, ws );
return;
}
// otherwise, rate-limit messages per type, per client
const rateLimiterNamespace = `${ws.id}/${validatedMessageType}`;
perMessageTypeRateLimiter.consume( rateLimiterNamespace )
// as long as the client is within limits for this message type
.then(
() => {
// handle the message
handleValidatedMessageOfTypeForWebsocket( validatedMessage, validatedMessageType, ws );
}
)
// otherwise
.catch(
( error ) => {
// silently drop the message
}
);
};
const handleValidatedMessageOfTypeForWebsocket = ( validatedMessage, validatedMessageType, ws ) => {
// add id to msg, dispatch generated messageAction required by message type
let attributedMessage = {
ws,
[ messageComponents.ALL_MESSAGES.FROM ]: ws.id,
[ messageComponents.ALL_MESSAGES.MSG ]: validatedMessage
};
// tell the message saga which room it's in
attributedMessage.roomName = ws.currentRoom;
// check the room is in the right state to receive messages
let roomState = getStoreState().roomStateReducer.rooms[ ws.currentRoom ];
// rooms need to be in specific states to accept messages
let roomStatesAcceptingMessages = [
roomStateConstants.STATES.READY,
roomStateConstants.STATES.ROOM_FULL
];
if( roomStatesAcceptingMessages.indexOf( roomState.status ) < 0 ) {
let roomNotReadyMessage = {
from: config.serverId,
msg: {
type: messageConstants.ERROR_TYPES.ROOM_NOT_READY
}
};
sendWsMessageWithLogger( ws, roomNotReadyMessage, logger );
return;
}
dispatchStoreAction( messageActions[ validatedMessageType ]( attributedMessage ) );
};
export default {
handleWebsocketMessage
};
================================================
FILE: backend/src/messages/message-rate-limiter.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FastRateLimit } from 'fast-ratelimit';
import config from '../config';
import { logger } from '../logger';
// rate-limits overall messages per client, regardless of type
let perClientRateLimiter = new FastRateLimit({
threshold: config.rateLimitInfo.perClientMsgThreshold,
ttl: config.rateLimitInfo.perClientMsgTtl
});
if( config.rateLimitInfo.perClientRateLimit ) {
logger.info(
`default overall client message rate limiter allowing ${perClientRateLimiter.__options.threshold}` +
`msg/${perClientRateLimiter.__options.ttl_millisec}ms`
);
}
// rate-limits individual message types per-client
let perMessageTypeRateLimiter = new FastRateLimit({
threshold: config.rateLimitInfo.perTypeMsgThreshold,
ttl: config.rateLimitInfo.perTypeMsgTtl
});
if( config.rateLimitInfo.perMessageTypeRateLimit ) {
logger.info(
`default per-type client message rate limiter allowing ${perMessageTypeRateLimiter.__options.threshold}` +
`msg/${perMessageTypeRateLimiter.__options.ttl_millisec}ms`
);
}
// allows dynamic setting of per-client rate limiter threshold
const setPerClientRateLimiterOptions = ( options ) => {
perClientRateLimiter.__options = options;
logger.info(
`set overall client message rate limiter to allow ${perClientRateLimiter.__options.threshold}` +
`msg/${perClientRateLimiter.__options.ttl_millisec}ms`
);
};
// allows dynamic setting of per-message-type rate limiter threshold
const setPerMessageTypeRateLimiterOptions = ( options ) => {
perMessageTypeRateLimiter.__options = options;
logger.info(
`set per-type client message rate limiter to allow ${perMessageTypeRateLimiter.__options.threshold}` +
`msg/${perMessageTypeRateLimiter.__options.ttl_millisec}ms`
);
};
export {
perClientRateLimiter,
perMessageTypeRateLimiter,
setPerClientRateLimiterOptions,
setPerMessageTypeRateLimiterOptions
};
================================================
FILE: backend/src/messages/message-sagas.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import uuid from 'uuid';
import { delay, takeEvery, takeLatest } from 'redux-saga';
import { call, put, fork, select } from 'redux-saga/effects';
import { logger } from '../logger';
import { sphereConstants } from '../spheres';
import {
roomDataActions,
roomNames,
roomStateActions,
roomStateConstants
} from '../rooms';
import {
makeWsReplyMessage,
makeWsBroadcastMessage,
sendWsMessageWithLogger
} from '../utils/websocket-utils';
import config from '../config';
import { sagaUtils } from '../utils';
import messageActions from './message-actions';
import messageConstants from './message-constants';
const incomingMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const outgoingMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;
/*******************************************************************************
* EXIT ROOM
*******************************************************************************/
const exitRoom = function* ( message ) {
logger.trace( `client ${message.ws.id} is leaving room ${message.ws.currentRoom}` );
// send a goodbye message to the client
let roomExitSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_EXIT_SUCCESS,
{ [ outgoingMsgComponents.ROOM_EXIT_SUCCESS.ROOM_NAME ]: message.ws.currentRoom }
);
sendWsMessageWithLogger( message.ws, roomExitSuccessMessage, logger );
// remove it from the state
yield put(
roomDataActions.removeLocalClientFromRoomRequestAction(
message.ws.id,
message.ws.currentRoom
)
);
// close the connection
message.ws.close();
};
/*******************************************************************************
* UPDATE ROOM CLIENT POSITION
*******************************************************************************/
const updateClientCoords = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// check whether the coords include any sphere position update
let spherePositionUpdates = msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ];
if( typeof spherePositionUpdates !== 'undefined' ) {
let roomState = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
let spheresHeldByClient = roomState[ action.roomName ].content.clients[ action.ws.id ].spheresHeld;
let checkedSpherePositionUpdates = [];
// spherePositionUpdates will be an array if it exists, as it's been validated
const spherePositionUpdateCount = spherePositionUpdates.length;
for( let i = 0; i < spherePositionUpdateCount; i++ ) {
let updatedSphere = spherePositionUpdates[ i ];
let updatedSphereId = updatedSphere[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_ID ];
// check that this client is holding this sphere
if( typeof spheresHeldByClient[ updatedSphereId ] === 'undefined' ) {
continue;
}
let updatedSpherePosition = updatedSphere[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION ];
if( spheresHeldByClient[ updatedSphereId ] === true ) {
// set the sphere position in state so new clients see it
yield put(
roomDataActions.setPositionForSphereInRoomRequestAction(
updatedSpherePosition,
updatedSphereId,
action.roomName
)
);
// add it to the list of checked updates
checkedSpherePositionUpdates.push( updatedSphere );
}
}
// set a populated checked array back in the message
if( checkedSpherePositionUpdates.length > 0 ) {
msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ] = checkedSpherePositionUpdates;
}
// or just remove an empty one (= no valid sphere updates)
else {
delete msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ];
}
}
// don't record any client state change, just broadcast its new position to other clients
let updatedCoordsMessage = makeWsBroadcastMessage(
action[ incomingMsgComponents.ALL_MESSAGES.FROM ],
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_CLIENT_COORDS_UPDATED,
action[ incomingMsgComponents.ALL_MESSAGES.MSG ][ incomingMsgComponents.ALL_MESSAGES.DATA ]
);
yield put( roomDataActions.publishMessageToRoomRequestAction( updatedCoordsMessage, action.roomName ) );
};
/*******************************************************************************
* CREATE SPHERE AT POSITION IN ROOM
*******************************************************************************/
const createSphereOfToneAtPosition = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
let roomData = yield select( ( state ) => { return state.roomDataReducer; } );
let sphereState = roomData.rooms[ action.roomName ].content.spheres;
// make sure there's sphere space in the room
if( Object.keys( sphereState ).length >= sphereConstants.SPHERE_INFO.MAX_NUMBER_OF_SPHERES_PER_ROOM ) {
denySphereAction(
action.ws,
null, // no sphere under discussion, so ...
null, // ... no client holding it
messageConstants.ERROR_TYPES.CREATE_SPHERE_UNAVAILABLE
);
return;
}
// snag relevant data
let newSphereId = uuid();
let tone = msgData[ incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE ];
let position = msgData[ incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION ];
let clientId = action.ws.id;
// create the sphere, save in sate
yield put(
roomDataActions.addSphereOfToneAtPositionInRoomRequestAction(
newSphereId,
tone,
position,
action.roomName
)
);
// tell the client it created the sphere OK
let createSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.CREATE_SPHERE_SUCCESS,
{ [ outgoingMsgComponents.CREATE_SPHERE_SUCCESS.SPHERE_ID ]: newSphereId }
);
sendWsMessageWithLogger( action.ws, createSuccessMessage, logger );
// broadcast 'sphere created' action
let data = {
[ outgoingMsgComponents.ROOM_SPHERE_CREATED.SPHERE_ID ]: newSphereId,
[ outgoingMsgComponents.ROOM_SPHERE_CREATED.TONE ]: tone,
[ outgoingMsgComponents.ROOM_SPHERE_CREATED.POSITION ]: position,
[ outgoingMsgComponents.ROOM_SPHERE_CREATED.CLIENT_ID ]: clientId
};
let sphereMsg = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_CREATED,
data
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereMsg, action.roomName ) );
};
/*******************************************************************************
* GRAB SPHERE BY ID
*******************************************************************************/
const grabSphere = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let roomState = yield select(
( state ) => {
return state.roomDataReducer.rooms[ action.roomName ];
}
);
// check the client has a spare hand
let spheresHeldByClient = roomState.content.clients[ action.ws.id ].spheresHeld;
// if it's already holding more than 1 ...
if( Object.keys( spheresHeldByClient ).length > 1 ) {
// it can't grab any more
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.CLIENT_HOLDING_MAX_SPHERES
);
return;
}
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.GRAB_SPHERE.SPHERE_ID ];
let sphereState = roomState.content.spheres;
// does sphere exist?
if( !sphereState[ sphereId ] ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
);
return;
};
// is it already held ...
if( sphereState[ sphereId ].hold ) {
let alreadyHeldErrorCode;
let holderId = sphereState[ sphereId ].hold.clientId;
// ... by this client?
if( holderId === action.ws.id ) {
alreadyHeldErrorCode = messageConstants.ERROR_TYPES.CLIENT_HOLDING_SPHERE;
}
// or another client?
else {
alreadyHeldErrorCode = messageConstants.ERROR_TYPES.SPHERE_ALREADY_HELD;
}
// either way, no go
denySphereAction(
action.ws,
sphereId,
holderId, // it would be invalid for this ws to grab it
alreadyHeldErrorCode
);
return;
}
// create a hold on this sphere for this client
yield put(
roomDataActions.createHoldOnSphereForClientInRoomRequestAction(
sphereId,
action.ws.id,
action.roomName
)
);
// TODO check the client got the hold?
// tell the client it got a hold
let grabSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.GRAB_SPHERE_SUCCESS,
{ [ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
);
sendWsMessageWithLogger( action.ws, grabSuccessMessage, logger );
// broadcast 'sphere grabbed' action
let sphereGrabbedData = {
[ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.CLIENT_ID ]: action.ws.id
};
let sphereGrabbedMsg = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_GRABBED,
sphereGrabbedData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereGrabbedMsg, action.roomName ) );
};
/*******************************************************************************
* RELEASE SPHERE BY ID
*******************************************************************************/
const releaseSphere = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let sphereState = yield getSphereStateForRoom( action.roomName );
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.RELEASE_SPHERE.SPHERE_ID ];
// does this sphere exist?
if( !sphereState[ sphereId ] ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
);
return;
};
// is it held at all?
if( !sphereState[ sphereId ].hold ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to release it
messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_INVALID
);
return;
}
let holderId = sphereState[ sphereId ].hold.clientId;
if( typeof holderId === 'undefined' ) {
denySphereAction(
action.ws,
sphereId,
null,
messageConstants.ERROR_TYPES.SYSTEM_ERROR
);
return;
}
// is it held by another client?
if( holderId !== action.ws.id ) {
denySphereAction(
action.ws,
sphereId,
holderId, // denied because this other one's holding it
messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_DENIED
);
return;
}
// remove the hold on this sphere for this client
yield put(
roomDataActions.removeHoldOnSphereForClientInRoomRequestAction(
sphereId,
action.ws.id,
action.roomName
)
);
// tell the client it released the sphere OK
let releaseSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_SUCCESS,
{ [ outgoingMsgComponents.RELEASE_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
);
sendWsMessageWithLogger( action.ws, releaseSuccessMessage, logger );
// broadcast to the room that the sphere's been released
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_RELEASED.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_RELEASED.CLIENT_ID ]: action.ws.id
};
let sphereReleasedMessage = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_RELEASED,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereReleasedMessage, action.roomName ) );
};
/*******************************************************************************
* SET SPHERE TONE BY ID
*******************************************************************************/
const setSphereTone = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let sphereState = yield getSphereStateForRoom( action.roomName );
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.SET_SPHERE_TONE.SPHERE_ID ];
let tone = msgData[ incomingMsgComponents.SET_SPHERE_TONE.TONE ];
// does this sphere exist?
if( !sphereState[ sphereId ] ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
);
return;
};
// is it held by another client?
if( sphereState[ sphereId ].hold ) {
let holderId = sphereState[ sphereId ].hold.clientId;
if( typeof holderId === 'undefined' ) {
denySphereAction(
action.ws,
sphereId,
null,
messageConstants.ERROR_TYPES.SYSTEM_ERROR
);
return;
}
if( holderId !== action.ws.id ) {
denySphereAction(
action.ws,
sphereId,
holderId, // denied because this other one's holding it
messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_TONE_DENIED
);
return;
}
}
// set the tone for this sphere
yield put(
roomDataActions.setToneForSphereInRoomRequestAction(
tone,
sphereId,
action.roomName
)
);
// tell the client it set the tone OK
let toneSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_TONE_SUCCESS,
{ [ outgoingMsgComponents.SET_SPHERE_TONE_SUCCESS.SPHERE_ID ]: sphereId }
);
sendWsMessageWithLogger( action.ws, toneSuccessMessage, logger );
// broadcast the new tone to the room
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.TONE ]: tone,
[ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.CLIENT_ID ]: action.ws.id
};
let sphereToneMsg = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_TONE_SET,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereToneMsg, action.roomName ) );
};
/*******************************************************************************
* SET SPHERE CONNECTIONS BY ID
*******************************************************************************/
const setSphereConnections = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let sphereState = yield getSphereStateForRoom( action.roomName );
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID ];
let connections = msgData[ incomingMsgComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS ];
// make sure there aren't too many connections
if( connections.length > sphereConstants.SPHERE_INFO.MAX_NUMBER_OF_CONNECTIONS_PER_SPHERE ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.TOO_MANY_SPHERE_CONNECTIONS
);
return;
}
// does sphere exist?
if( !sphereState[ sphereId ] ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
);
return;
};
// is it already held ...
if( sphereState[ sphereId ].hold ) {
let holderId = sphereState[ sphereId ].hold.clientId;
// ... by another client?
if( holderId !== action.ws.id ) {
denySphereAction(
action.ws,
sphereId,
holderId, // it would be invalid for this ws to grab it
messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_DENIED
);
return;
}
}
// if it's a non-empty list of spheres to connect to
if( connections.length > 0 ) {
// make sure that ...
let uniqueAvailableConnections = connections.filter(
// spheres are not the same
( sphereIdIterator ) => {
return sphereIdIterator !== sphereId;
}
).filter(
// spheres exist in room
( sphereIdIterator ) => {
return Object.keys( sphereState ).indexOf( sphereIdIterator ) >= 0;
}
);
if( uniqueAvailableConnections.length == 0 ) {
logger.trace( `setSphereConnections: no unique available connections requested` );
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to set it
messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_DENIED
);
return;
}
}
// set the list of connections for this sphere
yield put(
roomDataActions.setConnectionsForSphereInRoomRequestAction(
connections,
sphereId,
action.roomName
)
);
// tell the client it connected the spheres OK
let connectSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_SUCCESS,
{ [ outgoingMsgComponents.SET_SPHERE_CONNECTIONS_SUCCESS.SPHERE_ID ]: sphereId }
);
sendWsMessageWithLogger( action.ws, connectSuccessMessage, logger );
// broadcast the new connections to the room
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.CONNECTIONS ]: connections,
[ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.CLIENT_ID ]: action.ws.id
};
let sphereConnectionsMsg = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_CONNECTIONS_SET,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereConnectionsMsg, action.roomName ) );
};
/*******************************************************************************
* DELETE SPHERE BY ID
*******************************************************************************/
const deleteSphere = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let sphereState = yield getSphereStateForRoom( action.roomName );
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.DELETE_SPHERE.SPHERE_ID ];
// does this sphere exist?
if( !sphereState[ sphereId ] ) {
denySphereAction(
action.ws,
sphereId,
null, // it would be invalid for this ws to grab it
messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
);
return;
};
// is it held at all?
if( sphereState[ sphereId ].hold ) {
let holderId = sphereState[ sphereId ].hold.clientId;
if( typeof holderId === 'undefined' ) {
denySphereAction(
action.ws,
sphereId,
null,
messageConstants.ERROR_TYPES.SYSTEM_ERROR
);
return;
}
// is it held by another client?
if( holderId !== action.ws.id ) {
denySphereAction(
action.ws,
sphereId,
holderId, // denied because this other one's holding it
messageConstants.OUTGOING_MESSAGE_TYPES.DELETE_SPHERE_DENIED
);
return;
}
}
// remove the hold on this sphere for this client
yield put(
roomDataActions.deleteSphereFromRoomRequestAction(
sphereId,
action.roomName
)
);
// tell the client it deleted the sphere OK
let deleteSuccessMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.DELETE_SPHERE_SUCCESS,
{ [ outgoingMsgComponents.DELETE_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
);
sendWsMessageWithLogger( action.ws, deleteSuccessMessage, logger );
// broadcast 'sphere deleted' action
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_DELETED.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_DELETED.CLIENT_ID ]: action.ws.id
};
let sphereDeletedMsg = makeWsBroadcastMessage(
action.ws.id,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_DELETED,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereDeletedMsg, action.roomName ) );
};
/*******************************************************************************
* STRIKE SPHERE BY ID
*******************************************************************************/
const strikeSphere = function* ( action ) {
let { msgType, msgData } = getTypeAndDataForMessage( action );
// get necessary state
let sphereState = yield getSphereStateForRoom( action.roomName );
// snag relevant data
let sphereId = msgData[ incomingMsgComponents.STRIKE_SPHERE.SPHERE_ID ];
let velocity = msgData[ incomingMsgComponents.STRIKE_SPHERE.VELOCITY ];
let fromId = action[ incomingMsgComponents.ALL_MESSAGES.FROM ];
// does sphere exist?
if( !sphereState[ sphereId ] ) {
// drop silently if no such sphere
return;
};
// no state change here, just broadcast 'sphere strike' action
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_STRUCK.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_STRUCK.VELOCITY ]: velocity,
[ outgoingMsgComponents.ROOM_SPHERE_STRUCK.CLIENT_ID ]: fromId
};
let sphereStruckMsg = makeWsBroadcastMessage(
action.ws.id,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_STRUCK,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereStruckMsg, action.roomName ) );
};
/*******************************************************************************
* HELPER FUNCTIONS
*******************************************************************************/
const getTypeAndDataForMessage = ( message ) => {
return {
msgType: message[ incomingMsgComponents.ALL_MESSAGES.TYPE ],
msgData: message[ incomingMsgComponents.ALL_MESSAGES.MSG ][ incomingMsgComponents.ALL_MESSAGES.DATA ]
};
};
const getSphereStateForRoom = function* ( roomName ) {
let sphereState = yield select(
( state ) => {
return state.roomDataReducer.rooms[ roomName ].content.spheres;
}
);
return sphereState;
};
const denySphereAction = ( ws, sphereId, holderId, denialType ) => {
let data = {};
if( sphereId ) {
data[ outgoingMsgComponents.SPHERE_ACTION_DENIED.SPHERE_ID ] = sphereId;
}
if( holderId ) {
data[ outgoingMsgComponents.SPHERE_ACTION_DENIED.HOLDER_ID ] = holderId;
}
let actionDeniedMessage = makeWsReplyMessage(
config.serverId,
denialType,
data
);
sendWsMessageWithLogger( ws, actionDeniedMessage, logger );
return;
};
/*******************************************************************************
* SAGA WATCHER FUNCTIONS
* these takeEvery clauses match for INCOMING_MESSAGE_TYPES auto-dispatched by
* messageHandler.handleWebsocketMessage() after message validation
*******************************************************************************/
const watchExitRoomRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.EXIT_ROOM,
exitRoom
);
};
const watchUpdateCoordsForClientRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.UPDATE_CLIENT_COORDS,
updateClientCoords
);
};
const watchCreateSphereAtPositionRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.CREATE_SPHERE_OF_TONE_AT_POSITION,
createSphereOfToneAtPosition
);
};
const watchGrabSphereRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.GRAB_SPHERE,
grabSphere
);
};
const watchReleaseSphereRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.RELEASE_SPHERE,
releaseSphere
);
};
const watchDeleteSphereRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.DELETE_SPHERE,
deleteSphere
);
};
const watchSetSphereToneRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.SET_SPHERE_TONE,
setSphereTone
);
};
const watchSetSphereConnectionsRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS,
setSphereConnections
);
};
const watchStrikeSphereRequests = function* () {
yield takeEvery(
messageConstants.INCOMING_MESSAGE_TYPES.STRIKE_SPHERE,
strikeSphere
);
};
export default {
rootSaga: function* () {
const sagas = [
watchExitRoomRequests,
watchUpdateCoordsForClientRequests,
watchCreateSphereAtPositionRequests,
watchGrabSphereRequests,
watchReleaseSphereRequests,
watchDeleteSphereRequests,
watchSetSphereToneRequests,
watchSetSphereConnectionsRequests,
watchStrikeSphereRequests
];
yield sagaUtils.spawnAutoRestartingSagas( sagas );
}
};
================================================
FILE: backend/src/messages/message-schema.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*******************************************************************************
*
* this module implements a JSON schema for incoming messages.
* it's specified directly as JS objects so we can:
*
* - have comments in the schema
* - include other sections without needing separate JSON files
* - distinguish between JSON schema property names (barewords)
* and schema entry types ('quoted')
* - dynamically construct the schema's "properties" list
* from INCOMING_MESSAGE_TYPES constants
*
*******************************************************************************/
import { sphereConstants } from '../spheres';
import messageConstants from './message-constants';
// shorthand for dictionary of INCOMING_MESSAGE_TYPES constants
let messageTypes = messageConstants.INCOMING_MESSAGE_TYPES;
let messageComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
/*******************************************************************************
* UTILITY FUNCTIONS
*******************************************************************************/
// make 'type: "string"' schema definition entry using a messageTypes constant
const makeStringTypeForMessageTypeName = ( name ) => {
return {
type: 'string',
minLength: ( () => { return name.length; } )(),
maxLength: ( () => { return name.length; } )()
}
};
/*******************************************************************************
* INCOMING MESSAGE TYPE SCHEMA DEFINITIONS
*******************************************************************************/
// exit current room
const exitRoomMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => {
return makeStringTypeForMessageTypeName( messageTypes.EXIT_ROOM );
} )()
},
required: [ messageComponents.ALL_MESSAGES.TYPE ]
};
// "client position update" message
const updateClientCoordsMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => {
return makeStringTypeForMessageTypeName( messageTypes.UPDATE_CLIENT_COORDS );
} )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.UPDATE_CLIENT_COORDS.HEAD ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
[ messageComponents.UPDATE_CLIENT_COORDS.LEFT ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
[ messageComponents.UPDATE_CLIENT_COORDS.RIGHT ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
// optional spheres, only used if client is holding spheres
[ messageComponents.UPDATE_CLIENT_COORDS.SPHERES ]: {
type: 'array',
items: {
type: 'object',
properties: {
[ messageComponents.UPDATE_CLIENT_COORDS.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
},
[ messageComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
},
required: [
messageComponents.UPDATE_CLIENT_COORDS.SPHERE_ID,
messageComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION
]
}
}
},
required: [
messageComponents.UPDATE_CLIENT_COORDS.HEAD,
messageComponents.UPDATE_CLIENT_COORDS.LEFT,
messageComponents.UPDATE_CLIENT_COORDS.RIGHT
]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'create sphere at position' message
const createSphereOfToneAtPositionMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.CREATE_SPHERE_OF_TONE_AT_POSITION ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE ]: {
type: 'number',
minimum: sphereConstants.SPHERE_INFO.LOWEST_TONE,
maximum: sphereConstants.SPHERE_INFO.HIGHEST_TONE
},
[ messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
},
required: [
messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE,
messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION
]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'grab sphere'
const grabSphereMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.GRAB_SPHERE ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.GRAB_SPHERE.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
}
},
required: [ messageComponents.GRAB_SPHERE.SPHERE_ID ]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'release sphere'
const releaseSphereMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.RELEASE_SPHERE ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.RELEASE_SPHERE.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
}
},
required: [ messageComponents.RELEASE_SPHERE.SPHERE_ID ]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'delete sphere'
const deleteSphereMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.DELETE_SPHERE ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.DELETE_SPHERE.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
}
},
required: [ messageComponents.DELETE_SPHERE.SPHERE_ID ]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'strike sphere'
const strikeSphereMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.STRIKE_SPHERE ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.STRIKE_SPHERE.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
},
[ messageComponents.STRIKE_SPHERE.VELOCITY ]: {
type: 'number',
minimum: sphereConstants.SPHERE_INFO.MINIMUM_STRIKE_VELOCITY,
maximum: sphereConstants.SPHERE_INFO.MAXIMUM_STRIKE_VELOCITY
}
},
required: [
messageComponents.STRIKE_SPHERE.SPHERE_ID,
messageComponents.STRIKE_SPHERE.VELOCITY
]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'set sphere tone'
const setSphereToneMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.SET_SPHERE_TONE ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.SET_SPHERE_TONE.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
},
[ messageComponents.SET_SPHERE_TONE.TONE ]: {
type: 'number',
minimum: sphereConstants.SPHERE_INFO.LOWEST_TONE,
maximum: sphereConstants.SPHERE_INFO.HIGHEST_TONE
}
},
required: [
messageComponents.SET_SPHERE_TONE.SPHERE_ID,
messageComponents.SET_SPHERE_TONE.TONE
]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
// 'set sphere connections'
const setSphereConnectionsMessageSchema = {
type: 'object',
properties: {
[ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.SET_SPHERE_CONNECTIONS ) )(),
[ messageComponents.ALL_MESSAGES.DATA ]: {
type: 'object',
properties: {
[ messageComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID ]: {
type: 'string',
format: 'uuid'
},
[ messageComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS ]: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
}
},
required: [
messageComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID,
messageComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS
]
}
},
required: [
messageComponents.ALL_MESSAGES.TYPE,
messageComponents.ALL_MESSAGES.DATA
]
};
/*******************************************************************************
* MAPPING FROM INCOMING MESSAGE TYPES TO APPROPRIATE SCHEMA COMPONENTS
********************************************************************************/
const incomingMessageSchemaDefinitions = ( () => {
let definitions = {};
let m = messageTypes;
definitions[ m.EXIT_ROOM ] = exitRoomMessageSchema;
definitions[ m.UPDATE_CLIENT_COORDS ] = updateClientCoordsMessageSchema;
definitions[ m.CREATE_SPHERE_OF_TONE_AT_POSITION ] = createSphereOfToneAtPositionMessageSchema;
definitions[ m.GRAB_SPHERE ] = grabSphereMessageSchema;
definitions[ m.RELEASE_SPHERE ] = releaseSphereMessageSchema;
definitions[ m.DELETE_SPHERE ] = deleteSphereMessageSchema;
definitions[ m.STRIKE_SPHERE ] = strikeSphereMessageSchema;
definitions[ m.SET_SPHERE_TONE ] = setSphereToneMessageSchema;
definitions[ m.SET_SPHERE_CONNECTIONS ] = setSphereConnectionsMessageSchema;
return definitions;
})();
/*******************************************************************************
* COMPONENT DEFINITIONS REFERRED TO IN MESSAGE TYPES, MUST BE INCLUDED IN SCHEMA
********************************************************************************/
const componentDefinitions = {
// an x-y-z coordinate
[ messageComponents.REFERENCES.COORDINATE ]: {
type: 'object',
properties: {
[ messageComponents.REF_COORDINATE.X ]: { type: 'number' },
[ messageComponents.REF_COORDINATE.Y ]: { type: 'number' },
[ messageComponents.REF_COORDINATE.Z ]: { type: 'number' }
},
required: [
messageComponents.REF_COORDINATE.X,
messageComponents.REF_COORDINATE.Y,
messageComponents.REF_COORDINATE.Z
]
},
// a pair of coordinates for rotation/position
[ messageComponents.REFERENCES.COORDINATE_SET ]: {
type: 'object',
properties: {
[ messageComponents.REF_COORDINATE_SET.POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` },
[ messageComponents.REF_COORDINATE_SET.ROTATION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
},
required: [
messageComponents.REF_COORDINATE_SET.POSITION,
messageComponents.REF_COORDINATE_SET.ROTATION
]
}
};
/*******************************************************************************
* COMBINED LIST OF COMPONENT AND MESSAGE DEFINITIONS INCLUDED IN MAIN SCHEMA
********************************************************************************/
const messageSchemaDefinitions = Object.assign(
componentDefinitions,
incomingMessageSchemaDefinitions
);
/*******************************************************************************
* PROPERTIES USED IN MAIN SCHEMA
********************************************************************************/
/*
* list of message types defined as properties in schema,
* dynamically defined from the list of incoming message schema definitions
*
* comes out looking like this, as per JSON schema reqs:
*
* properties = {
* room_client_position_update: {
* '$ref': '#/definitions/room_client_position_update'
* }
* }
*/
const messageSchemaProperties = ( () => {
let properties = {};
Object.keys( incomingMessageSchemaDefinitions ).forEach(
( messageTypeName ) => {
properties[ messageTypeName ] = {
'$ref': `#/definitions/${messageTypeName}`
}
}
);
return properties;
})();
/*******************************************************************************
* MAIN SCHEMA OBJECT INCORPORATING DEFINITIONS AND PROPERTIES FROM ABOVE
*******************************************************************************/
const messageSchema = {
// these are type definitions shared by multiple objects
definitions: messageSchemaDefinitions,
// these are the various types
properties: messageSchemaProperties
};
export {
messageSchema
}
================================================
FILE: backend/src/messages/message-validator.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ajv from 'ajv';
import { deserialize } from '../s11n';
import { messageSchema } from './message-schema';
import messageConstants from './message-constants';
const messageComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
/*
* create a validator object for all incoming messages
*/
let ajvOptions = {
allErrors: true, // log all validation errors, not just first
removeAdditional: "all" // this modifies the original message(!)
};
let validator = new ajv( ajvOptions );
let validate = validator.compile( messageSchema );
let messageTypesNotRequiringData = [
messageConstants.INCOMING_MESSAGE_TYPES.EXIT_ROOM
];
/*
* validates messages received over the wire against the appropriate schema.
* called from messageHandler:handleWebsocketMessageWithStore().
*
* any kind of validation issue throws an exception, and messageHandler
* drops the message.
*/
const validateMessage = ( wireMessage ) => {
let deserializedMessage;
try {
deserializedMessage = deserialize( wireMessage );
}
catch( error ) {
throw error;
}
// only validate if there's a message type
if( !deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) {
throw new Error( `incoming message has no '${messageComponents.ALL_MESSAGES.TYPE}' property` );
}
// only validate if there's message data
if( !deserializedMessage[ messageComponents.ALL_MESSAGES.DATA ] ) {
// except for message types that don't require message data
if( messageTypesNotRequiringData.indexOf( deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) < 0 ) {
throw new Error( `incoming message has no '${messageComponents.ALL_MESSAGES.DATA}' property` );
}
}
// only validate if it's an acceptable message type
if( !isValidMessageType( deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) ) {
throw new Error( `incoming message has invalid type '${deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ]}'` );
}
// run the validator
return validateMessageOfType( deserializedMessage, deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] );
};
/*
* check the provided type is in the top level 'properties' of the messageSchema
*/
const isValidMessageType = ( type ) => {
return Object.keys( messageSchema.properties ).indexOf( type ) > -1;
};
/*
* validator function for incoming messages
*/
const validateMessageOfType = ( message, type ) => {
let objectToValidate = {};
objectToValidate[ type ] = message;
let valid = validate( objectToValidate )
if( valid !== true ) {
let errorMsgs = validate.errors.map(
( error ) => {
return `${error.dataPath} ${error.message}`;
}
);
let errorMsg = errorMsgs.join( '; ' );
throw new Error( errorMsg );
}
return message;
};
export default {
validateMessage
};
================================================
FILE: backend/src/pubsub/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import pubsubClient from './pubsub-client';
export {
pubsubClient
};
================================================
FILE: backend/src/pubsub/pubsub-client.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import pubsub from '@google-cloud/pubsub';
import config from '../config';
import { logger } from '../logger';
let client = ( ( conf ) => {
if( !client ) {
if( process.env.PUBSUB_EMULATOR_HOST ) {
logger.info( `using PubSub emulator running at ${process.env.PUBSUB_EMULATOR_HOST}` );
}
else {
logger.info( `using live PubSub on GCP` );
}
client = pubsub( conf );
}
return client;
})( config );
const createPubsubTopic = function* ( topicName ) {
return yield client.createTopic( topicName );
};
const getPubsubTopic = function* ( topicName ) {
return yield client.topic( topicName );
};
export default {
createPubsubTopic,
getPubsubTopic
};
================================================
FILE: backend/src/rooms/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import roomDataActions from './room-data-actions';
import roomDataConstants from './room-data-constants';
import roomDataReducer from './room-data-reducer';
import roomNames from './room-names';
import roomSagas from './room-sagas';
import roomStateActions from './room-state-actions';
import roomStateConstants from './room-state-constants';
import roomStateReducer from './room-state-reducer';
import roomStateUtils from './room-state-utils';
export {
roomDataActions,
roomDataConstants,
roomDataReducer,
roomNames,
roomSagas,
roomStateActions,
roomStateConstants,
roomStateReducer,
roomStateUtils
};
================================================
FILE: backend/src/rooms/room-data-actions.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import constants from './room-data-constants';
/*******************************************************************************
* ADD/REMOVE CLIENTS TO/FROM ROOMS
*******************************************************************************/
const addWebsocketToQueueForRoomRequestAction = ( wsId, roomName ) => {
let type = constants.ACTION_TYPES.ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM;
return { type, wsId, roomName };
};
const removeWebsocketFromQueueForRoomRequestAction = ( wsId, roomName ) => {
let type = constants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM;
return { type, wsId, roomName };
};
const addLocalClientToRoomRequestAction = ( clientId, clientHeadsetType, roomName ) => {
let type = constants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM;
return { type, clientId, clientHeadsetType, roomName };
};
const removeLocalClientFromRoomRequestAction = ( clientId, roomName ) => {
let type = constants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM;
return { type, clientId, roomName };
};
/*******************************************************************************
* ROOM SETUP/MAINTAIN/TEARDOWN
*******************************************************************************/
const setLastHeartbeatCountForRoomRequestAction = ( count, roomName ) => {
let type = constants.ACTION_TYPES.UPDATE_LAST_HEARTBEAT_FOR_ROOM;
return { type, count, roomName };
};
const setHeartbeatTaskForRoomRequestAction = ( heartbeatTask, roomName ) => {
let type = constants.ACTION_TYPES.SET_HEARTBEAT_TASK_FOR_ROOM;
return { type, heartbeatTask, roomName };
};
const removeHeartbeatTaskForRoomRequestAction = ( roomName ) => {
let type = constants.ACTION_TYPES.REMOVE_HEARTBEAT_TASK_FOR_ROOM;
return { type, roomName };
};
const cancelHeartbeatTaskForRoomRequestAction = ( roomName ) => {
let type = constants.ACTION_TYPES.CANCEL_HEARTBEAT_TASK_FOR_ROOM;
return { type, roomName };
};
const checkForEmptyRoomRequestAction = ( roomName ) => {
let type = constants.ACTION_TYPES.CHECK_FOR_EMPTY_ROOM;
return { type, roomName };
};
/*******************************************************************************
* SPHERE STATE ACTIONS
*******************************************************************************/
const addSphereOfToneAtPositionInRoomRequestAction = ( newSphereId, tone, position, roomName ) => {
let type = constants.ACTION_TYPES.ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM;
return { type, newSphereId, tone, position, roomName };
};
const createHoldOnSphereForClientInRoomRequestAction = ( sphereId, clientId, roomName ) => {
let type = constants.ACTION_TYPES.CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM;
return { type, sphereId, clientId, roomName };
};
const setHoldTimeoutTaskForSphereInRoomRequestAction = ( timeoutTask, sphereId, roomName ) => {
let type = constants.ACTION_TYPES.SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM;
return { type, timeoutTask, sphereId, roomName };
};
const deleteHoldTimeoutTaskForSphereInRoomRequestAction = ( sphereId, roomName ) => {
let type = constants.ACTION_TYPES.DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM;
return { type, sphereId, roomName };
};
const setPositionForSphereInRoomRequestAction = ( position, sphereId, roomName ) => {
let type = constants.ACTION_TYPES.SET_POSITION_FOR_SPHERE_IN_ROOM;
return { type, position, sphereId, roomName };
};
const setToneForSphereInRoomRequestAction = ( tone, sphereId, roomName ) => {
let type = constants.ACTION_TYPES.SET_TONE_FOR_SPHERE_IN_ROOM;
return { type, tone, sphereId, roomName };
};
const setConnectionsForSphereInRoomRequestAction = ( connections, sphereId, roomName ) => {
let type = constants.ACTION_TYPES.SET_CONNECTIONS_FOR_SPHERE_IN_ROOM;
return { type, connections, sphereId, roomName };
};
const removeHoldOnSphereForClientInRoomRequestAction = ( sphereId, clientId, roomName ) => {
let type = constants.ACTION_TYPES.REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM;
return { type, sphereId, clientId, roomName };
};
const deleteSphereFromRoomRequestAction = ( sphereId, roomName ) => {
let type = constants.ACTION_TYPES.DELETE_SPHERE_FROM_ROOM;
return { type, sphereId, roomName };
};
/*******************************************************************************
* PUBLISH MESSAGE
*******************************************************************************/
const publishMessageToRoomRequestAction = ( message, roomName ) => {
let type = constants.ACTION_TYPES.PUBLISH_MESSAGE_TO_ROOM;
return { type, message, roomName };
};
export default {
// add/remove clients to/from rooms
addWebsocketToQueueForRoomRequestAction,
removeWebsocketFromQueueForRoomRequestAction,
addLocalClientToRoomRequestAction,
removeLocalClientFromRoomRequestAction,
// room setup/maintain/teardown
setLastHeartbeatCountForRoomRequestAction,
setHeartbeatTaskForRoomRequestAction,
removeHeartbeatTaskForRoomRequestAction,
cancelHeartbeatTaskForRoomRequestAction,
checkForEmptyRoomRequestAction,
// sphere state actions
addSphereOfToneAtPositionInRoomRequestAction,
createHoldOnSphereForClientInRoomRequestAction,
setHoldTimeoutTaskForSphereInRoomRequestAction,
deleteHoldTimeoutTaskForSphereInRoomRequestAction,
setPositionForSphereInRoomRequestAction,
setToneForSphereInRoomRequestAction,
setConnectionsForSphereInRoomRequestAction,
removeHoldOnSphereForClientInRoomRequestAction,
deleteSphereFromRoomRequestAction,
// publish message saga action
publishMessageToRoomRequestAction
};
================================================
FILE: backend/src/rooms/room-data-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HEADSET_TYPES } from '../messages/message-constants';
const ACTION_TYPES = {
ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM: 'add_websocket_to_queue_for_room',
REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM: 'remove_websocket_from_queue_for_room',
ADD_LOCAL_CLIENT_TO_ROOM: 'add_local_client_to_room',
REMOVE_LOCAL_CLIENT_FROM_ROOM: 'remove_local_client_from_room',
// room setup/teardown
UPDATE_LAST_HEARTBEAT_FOR_ROOM: 'update_last_heartbeat_for_room',
SET_HEARTBEAT_TASK_FOR_ROOM: 'set_heartbeat_task_for_room',
REMOVE_HEARTBEAT_TASK_FOR_ROOM: 'remove_heartbeat_task_for_room',
CANCEL_HEARTBEAT_TASK_FOR_ROOM: 'cancel_heartbeat_task_for_room',
CHECK_FOR_EMPTY_ROOM: 'check_for_empty_room',
// client state actions
SET_COORDS_FOR_CLIENT_IN_ROOM: 'set_coords_for_client_in_room',
REMOVE_CLIENT_FROM_ROOM: 'remove_client_from_room',
// sphere state actions
ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM: 'add_sphere_of_tone_at_position_in_room',
SET_POSITION_FOR_SPHERE_IN_ROOM: 'set_position_for_sphere_in_room',
SET_TONE_FOR_SPHERE_IN_ROOM: 'set_tone_for_sphere_in_room',
SET_CONNECTIONS_FOR_SPHERE_IN_ROOM: 'set_connections_for_sphere_in_room',
CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM: 'create_hold_on_sphere_for_client_in_room',
SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM: 'set_hold_timeout_task_for_sphere_in_room',
DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM: 'delete_hold_timeout_task_for_sphere_in_room',
REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM: 'remove_hold_on_sphere_for_client_in_room',
DELETE_SPHERE_FROM_ROOM: 'delete_sphere_from_room',
// publish to clients in room
PUBLISH_MESSAGE_TO_ROOM: 'publish_message_to_room'
};
const CLIENT_INFO = {
CLIENT_TYPE_LOCAL: 'client_type_local',
CLIENT_TYPE_REMOTE: 'client_type_remote',
CLIENT_INACTIVITY_TIMEOUTS_IN_MS: {
[ HEADSET_TYPES.HEADSET_TYPE_3DOF ]: 120000, // 2m
[ HEADSET_TYPES.HEADSET_TYPE_6DOF ]: 120000, // 2m
[ HEADSET_TYPES.HEADSET_TYPE_VIEWER ]: 120000, // 2m
},
CLIENT_INACTIVITY_TIMEOUT_CHECKS_IN_MS: {
[ HEADSET_TYPES.HEADSET_TYPE_3DOF ]: 30000, // 30s
[ HEADSET_TYPES.HEADSET_TYPE_6DOF ]: 30000, // 30s
[ HEADSET_TYPES.HEADSET_TYPE_VIEWER ]: 30000, // 30s
}
};
const ERROR_TYPES = {
ROOM_TIMEOUT: 'room_timeout',
JOINING_ROOM: 'error_joining_room',
ROOM_FULL: 'error_room_full',
WAITING_FOR_STATE: 'waiting_for_state'
};
const ROOM_INFO = {
MINIMUM_SOUNDBANK_NUMBER: 0,
MAXIMUM_SOUNDBANK_NUMBER: 2
};
const SPHERE_INFO = {
SPHERE_HOLD_TIMEOUT_IN_MS: 10000,
SPHERE_HOLD_TIMEOUT_CHECK_IN_MS: 2500
};
export default {
ACTION_TYPES,
CLIENT_INFO,
ERROR_TYPES,
ROOM_INFO,
SPHERE_INFO
};
================================================
FILE: backend/src/rooms/room-data-reducer.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import redux from 'redux';
import uuid from 'uuid';
import sizeof from 'object-sizeof';
import util from 'util';
import {
initStateObject,
createFilteredActionHandler
} from '../utils/reducer-utils';
import { logger } from '../logger';
import messageConstants from '../messages/message-constants';
const HT_3DOF = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
const HT_6DOF = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
const HT_VIEW = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;
const CONNECTIONS_LABEL = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.CONNECTIONS;
const POSITION_LABEL = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.POSITION;
const TONE_LABEL = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.TONE;
import {
makeSphereOfToneAtPosition,
sphereConstants,
trees
} from '../spheres';
import config from '../config';
import roomDataConstants from './room-data-constants';
import roomStateConstants from './room-state-constants';
import roomNames from './room-names';
/*******************************************************************************
* HELPER FUNCTIONS
*******************************************************************************/
const createEmptyRoom = () => {
return {
heartbeatTask: null,
lastHeartbeat: {
count: 0,
timestamp: 0
},
setupComplete: false,
waitingForStateStatus: false,
checkingForEmptyRoomStatus: false,
soundbank: null,
spheres: {},
websockets: {},
clients: {}
};
};
const createNewRoom = () => {
let emptyRoom = createEmptyRoom();
emptyRoom.soundbank = getRandomInt(
roomDataConstants.ROOM_INFO.MINIMUM_SOUNDBANK_NUMBER,
roomDataConstants.ROOM_INFO.MAXIMUM_SOUNDBANK_NUMBER
);
emptyRoom.spheres = generateSpheresForRoom( emptyRoom.soundbank );
return emptyRoom;
};
const generateSpheresForRoom = ( soundbank ) => {
const roomSpheres = {}
function dist(a, b){
return Math.sqrt(Math.pow(a.x - b.x, 2) +
// Math.pow(a.y - b.y, 2) +
Math.pow(a.z - b.z, 2))
}
function randomPos(){
const rad = 2.5
const pos = {
x : Math.random() * rad - rad/2,
y : Math.random() * 0.7 + 0.9,
z : Math.random() * rad - rad/2
}
// check that the position is not too close to any other sphere
let closestDist = Infinity
for (let id in roomSpheres){
const roomPos = roomSpheres[id][POSITION_LABEL]
closestDist = Math.min(dist(pos, roomPos), closestDist)
}
//only return the position if its greater than the thresh
if (closestDist > 0.5){
return pos
} else {
//otherwise generate a new one
return randomPos()
}
}
for (let i = 0; i < 10; i++){
roomSpheres[uuid()] = {
[TONE_LABEL] : getRandomInt(sphereConstants.SPHERE_INFO.LOWEST_TONE,
sphereConstants.SPHERE_INFO.HIGHEST_TONE),
[POSITION_LABEL] : randomPos()
}
}
// return spheres
return roomSpheres;
};
const getRandomInt = ( min, max ) => {
let intMin = Math.ceil( min );
let intMax = Math.floor( max );
return Math.floor( Math.random() * ( intMax - intMin + 1 ) ) + intMin;
};
const getZeroCoordsForNewClient = () => {
return {
head: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
},
left: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
},
right: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
};
};
/*******************************************************************************
* GENERATE INITIAL EMPTY STATE FOR ALL ROOMS
*******************************************************************************/
// dictionary of empty room data objects for all names
const initialState = ( () => {
let createFunction = () => {
return {
queue: [],
tasks: {}
};
};
return {
rooms: initStateObject( roomNames, createFunction, 'room data objects', logger ),
avails: ( () => {
let obj = {};
Object.values( messageConstants.HEADSET_TYPES ).forEach(
( headsetType ) => {
obj[ headsetType ] = {};
}
);
return obj;
} )()
};
})();
/*******************************************************************************
* ROOM INFO/STATUS REDUCER FUNCTIONS
*******************************************************************************/
// create a new room state including spheres
// action: { roomName }
const initRoomContent = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].content = createNewRoom();
logger.trace( `created default content for new room ${action.roomName}` );
return state;
};
// action: { wsId, roomName }
const addWebsocketToQueueForRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( state.rooms[ action.roomName ].queue.indexOf( action.wsId ) >= 0 ) {
return state;
}
state.rooms[ action.roomName ].queue.push( action.wsId );
logger.trace( `[+] ${state.rooms[ action.roomName ].queue.length} websockets in queue for room ${action.roomName}` );
return state;
};
// action: { wsId, roomName }
const removeWebsocketFromQueueForRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].queue = state.rooms[ action.roomName ].queue.filter(
( clientId ) => {
return clientId != action.wsId;
}
);
logger.trace( `[-] ${state.rooms[ action.roomName ].queue.length} websockets in queue for room ${action.roomName}` );
return state;
};
/*******************************************************************************
* CLIENT REDUCER FUNCTIONS
*******************************************************************************/
// action: { clientId, roomName }
const addLocalClientToRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
let roomClients = state.rooms[ action.roomName ].content.clients;
if( typeof roomClients[ action.clientId ] !== 'undefined' ) {
return state;
}
roomClients[ action.clientId ] = {
headsetType: action.clientHeadsetType,
coords: getZeroCoordsForNewClient(),
spheresHeld: {}
}
logger.trace( `[+] there are now ${Object.keys( roomClients ).length} total clients in room '${action.roomName}'` );
return updateClientCountsForRoom( state, action );
};
// action: { clientId, roomName }
const removeLocalClientFromRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].content.clients[ action.clientId ] = null;
delete state.rooms[ action.roomName ].content.clients[ action.clientId ];
let roomClients = state.rooms[ action.roomName ].content.clients;
logger.trace( `[-] there are now ${Object.keys( roomClients ).length} total clients in room '${action.roomName}'` );
return updateClientCountsForRoom( state, action );
};
/*
* for a room to be available to 6dof:
*
* 6dof must be less than 6dofThreshold
* 3dof + 6dof must be less than 3dofThreshold + 6dofThreshold
* 3dof + 6dof + viewers must be less than maxClientsPerRoom
*
* for a room to be available to 3dof:
*
* 3dof must be less than 3dofThreshold
* 3dof + 6dof must be less than 3dofThreshold + 6dofThreshold
* 3dof + 6dof + viewers must be less than maxClientsPerRoom
*
* for a room to be available to viewer:
*
* 3dof + 6dof + viewers must be less than maxClientsPerRoom
*/
const updateClientCountsForRoom = ( state, action ) => {
// this gives { '3dof': 2, '6dof': 1, viewer: 4 }
let clientCounts = Object.values( state.rooms[ action.roomName ].content.clients ).reduce(
( acc, client ) => {
acc[ client.headsetType ] = acc[ client.headsetType ] + 1;
return acc;
},
( () => {
let obj = {};
Object.values( messageConstants.HEADSET_TYPES ).forEach(
( headsetType ) => {
obj[ headsetType ] = 0;
}
);
return obj;
} )()
);
// this gives { '3dof': 2 + '6dof': 1 + viewer: 4 } = totalClients: 7
let totalClients = Object.values( clientCounts ).reduce(
( acc, count ) => {
return acc + count;
},
0
);
// if the room's full, it's not available at all;
// if it's empty, it doesn't go in the availability lists,
// as it'll be found from the list of INIT-state rooms
// in roomStateReducer.roomsByState
if( totalClients >= config.maxClientsPerRoom || totalClients === 0) {
delete state.avails[ HT_6DOF ][ action.roomName ];
delete state.avails[ HT_3DOF ][ action.roomName ];
delete state.avails[ HT_VIEW ][ action.roomName ];
return state;
}
else {
state.avails[ HT_VIEW ][ action.roomName ] = true;
}
let num6dof = clientCounts[ HT_6DOF ];
let num3dof = clientCounts[ HT_3DOF ];
let numViewers = clientCounts[ HT_VIEW ];
let threshold6dof = config.headsetRules[ HT_6DOF ].threshold;
let threshold3dof = config.headsetRules[ HT_3DOF ].threshold;
let thresholdViewer = config.headsetRules[ HT_VIEW ].threshold;
let totalStrikers = num6dof + num3dof;
let strikerThreshold = threshold6dof + threshold3dof;
if( num6dof < threshold6dof && totalStrikers < strikerThreshold ) {
state.avails[ HT_6DOF ][ action.roomName ] = true;
}
else {
delete state.avails[ HT_6DOF ][ action.roomName ];
}
if( num3dof < threshold3dof && totalStrikers < strikerThreshold ) {
state.avails[ HT_3DOF ][ action.roomName ] = true;
}
else {
delete state.avails[ HT_3DOF ][ action.roomName ];
}
return state;
};
// action: { count, roomName }
const setLastHeartbeatCountForRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
let newHeartbeat = {
count: action.count,
timestamp: Date.now()
};
state.rooms[ action.roomName ].content.lastHeartbeat = newHeartbeat;
return state;
};
// action: { heartbeatTask, roomName }
const setHeartbeatTaskForRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].tasks.heartbeat = action.heartbeatTask;
return state;
};
// action: { roomName }
const removeHeartbeatTaskForRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].tasks.heartbeat = null;
delete state.rooms[ action.roomName ].tasks.heartbeat;
return state;
};
/*******************************************************************************
* SPHERE REDUCER FUNCTIONS
*******************************************************************************/
// - called via redux-action from message-sagas:createSphereAtPositionInRoom
// action: { newSphereId, position, tone, roomName }
const addSphereOfToneAtPositionInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
try {
state.rooms[ action.roomName ].content.spheres[ action.newSphereId ] = makeSphereOfToneAtPosition(
action.tone,
action.position
);
return state;
}
catch( error ) {
logger.warn( `caught error trying to create new sphere: ${error.message}` );
}
};
// - called via redux-action from message-sagas:updateClientCoords
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// - sphere.position is a coordinates object as per message schema
// action: { position, sphereId, roomName }
const setPositionForSphereInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
const positionLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.UPDATE_CLIENT_COORDS.SPHERE_POSITION;
state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ positionLabel ] = action.position;
if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
}
return state;
};
// - called via redux-action from message-sagas:setSphereConnections
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// action: { connections, sphereId, roomName }
const setConnectionsForSphereInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
const connectionsLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.SET_SPHERE_CONNECTIONS.CONNECTIONS;
state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ connectionsLabel ] = action.connections;
if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
}
return state;
};
// - called via redux-action from message-sagas:setSphereTone
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// action: { tone, sphereId, roomName }
const setToneForSphereInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
const toneLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.SET_SPHERE_TONE.TONE;
state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ toneLabel ] = action.tone;
if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
}
return state;
};
// - called via redux-action from message-sagas:grabSphere
// action: { sphereId, clientId, roomName }
const createHoldOnSphereForClientInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.clients[ action.clientId ] === 'undefined' ) {
return state;
}
// set a hold property on the sphere
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold = {
clientId: action.clientId,
ts: Date.now()
};
// mark the client as holding this sphere
state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ] = true;
return state;
};
// - called from room-sagas:startSphereHoldTimeout
// - triggered when a sphere hold is created
// action: { timeoutTask, sphereId, roomName }
const setHoldTimeoutTaskForSphereInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask = action.timeoutTask;
return state;
};
// action: { sphereId, roomName }
const deleteHoldTimeoutTaskForSphereInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask = null;
delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask;
return state;
};
// - called via redux-action from message-sagas:releaseSphere
// action: { sphereId, clientId, roomName }
const removeHoldOnSphereForClientInRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.clients[ action.clientId ] === 'undefined' ) {
return state;
}
if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.clientId == action.clientId ) {
// release the sphere hold so other clients can grab it
state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold = null;
delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold;
// disassociate the sphere from the client so it can't update it any more
state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ] = null;
delete state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ];
}
return state;
};
// - called via redux-action from message-sagas:deleteSphere
// action: { sphereId, roomName }
const deleteSphereFromRoom = ( state, action ) => {
if( typeof action.roomName === 'undefined' ) {
return state;
}
if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
return state;
}
state.rooms[ action.roomName ].content.spheres[ action.sphereId ] = null;
delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ];
return state;
};
const actionHandlers = {
// room setup/teardown
[ roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT ]: initRoomContent,
[ roomDataConstants.ACTION_TYPES.ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM ]: addWebsocketToQueueForRoom,
[ roomDataConstants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM ]: removeWebsocketFromQueueForRoom,
[ roomDataConstants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM ]: addLocalClientToRoom,
[ roomDataConstants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM ]: removeLocalClientFromRoom,
[ roomDataConstants.ACTION_TYPES.UPDATE_LAST_HEARTBEAT_FOR_ROOM ]: setLastHeartbeatCountForRoom,
[ roomDataConstants.ACTION_TYPES.SET_HEARTBEAT_TASK_FOR_ROOM ]: setHeartbeatTaskForRoom,
[ roomDataConstants.ACTION_TYPES.REMOVE_HEARTBEAT_TASK_FOR_ROOM ]: removeHeartbeatTaskForRoom,
// sphere state actions
[ roomDataConstants.ACTION_TYPES.ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM ]: addSphereOfToneAtPositionInRoom,
[ roomDataConstants.ACTION_TYPES.SET_POSITION_FOR_SPHERE_IN_ROOM ]: setPositionForSphereInRoom,
[ roomDataConstants.ACTION_TYPES.SET_TONE_FOR_SPHERE_IN_ROOM ]: setToneForSphereInRoom,
[ roomDataConstants.ACTION_TYPES.SET_CONNECTIONS_FOR_SPHERE_IN_ROOM ]: setConnectionsForSphereInRoom,
[ roomDataConstants.ACTION_TYPES.CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM ]: createHoldOnSphereForClientInRoom,
[ roomDataConstants.ACTION_TYPES.SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM ]: setHoldTimeoutTaskForSphereInRoom,
[ roomDataConstants.ACTION_TYPES.DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM ]: deleteHoldTimeoutTaskForSphereInRoom,
[ roomDataConstants.ACTION_TYPES.REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM ]: removeHoldOnSphereForClientInRoom,
[ roomDataConstants.ACTION_TYPES.DELETE_SPHERE_FROM_ROOM ]: deleteSphereFromRoom
};
export default createFilteredActionHandler( actionHandlers, initialState );
================================================
FILE: backend/src/rooms/room-names.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const roomNames = ["aaag","aace","aadx","aafo","aafx","aalu","aamd","aamm","aany","aaoc","aarm","aasm","aavz","aawl","aaxz","aayr","aayz","abbo","abip","abji","ablh","ablk","ablo","abod","abrn","abrw","abux","abvo","abvt","abvv","abyi","accr","acdc","acdr","aceq","acgv","achk","acjo","acnh","acod","acpe","acso","actg","acwb","acwy","acxg","adao","adfx","adga","adgo","adgw","adjg","adqo","adtg","advp","advy","adyp","aebs","aech","aefp","aehz","aejm","aemd","aemk","aenx","aeoh","aeqk","aeqm","aerd","aerf","aerj","aetn","aevs","aevv","aewp","aexv","afaq","afdf","affs","afig","afla","aflg","aflx","afqs","afrx","afux","afva","afvx","afwc","afzr","agfw","aggl","agjc","agkt","agkz","aglr","agsr","agss","agtk","agus","agvu","agxz","ahai","ahbh","ahen","ahjz","ahki","ahlk","ahmb","ahnn","ahyu","ahzn","ahzu","aiao","aibs","aibu","aicj","aidl","aigj","aiia","aikm","aima","aiml","aimr","aino","aiok","aiss","aiuo","aiut","aivn","aivy","aiwa","aixt","aizp","ajaa","ajam","ajbj","ajdn","ajdp","ajhp","ajka","ajxi","ajyp","ajzh","ajzq","akam","akdl","aked","akgr","akgt","akpo","akpz","aksr","akvz","akwp","akwq","akxc","alac","alak","alaw","alca","alcn","alfk","alie","aljw","alkg","allc","almr","aloq","alor","alpa","alqt","altq","alua","alub","alwf","alwj","alyf","amaw","amcj","amfk","amoy","ampu","amqj","amtj","amxj","andt","anet","aneu","anfm","angl","anhj","anjf","anmx","annr","anoq","anov","anqm","ansp","anyd","anzn","aobu","aofe","aogj","aogm","aohx","aoka","aonv","aopv","aoqq","aorg","aote","aoyn","apbv","apdy","aphv","apjq","apjx","apkk","apkp","apne","appd","appl","appr","aptc","apzr","aqbr","aqcc","aqcn","aqdm","aqfw","aqgp","aqgy","aqht","aqhx","aqkv","aqln","aqpf","aqpx","aqra","aqrd","aqsr","aqvu","argg","argh","ariq","arli","armq","arny","arrs","arsy","arum","aryo","arzg","arzq","asgc","asit","asiw","aske","asma","asmh","asnm","aspb","asrs","asth","asul","asvi","asvk","aszk","atbm","atds","atec","atgx","athr","atkt","atno","atph","atql","atws","auao","aufd","auhe","auja","aujp","auof","ausw","auux","auvq","avag","avas","avfs","avgv","avix","avnu","avqe","avug","avvr","avvv","avwl","avzb","awbu","awdi","awex","awfg","awhx","awir","awki","awku","awmn","awog","awpt","awrf","awrw","awsh","awvz","awze","awzx","axdh","axlp","axne","axpp","axsi","axtj","axuj","axvr","axxg","axzc","ayfj","ayfq","ayim","ayio","ayjx","aymf","aymu","ayqs","ayry","ayxm","ayzk","ayzx","azbx","azex","azfm","azlq","azlu","azqt","azsm","aztu","azvz","azxd","azxr","azyn","babg","bafj","bakq","bamh","bana","batr","bawg","bazd","bbag","bbct","bbgw","bbhn","bbkj","bbmt","bbog","bbow","bbqh","bbqi","bbqk","bbsg","bbtb","bbte","bbuz","bbwv","bbxn","bbxr","bbyp","bcab","bcae","bcay","bcde","bcfb","bcgj","bchj","bckq","bcll","bcmg","bcoj","bcoq","bcpa","bcuz","bcvw","bcwk","bcwq","bcxh","bdcd","bddt","bddv","bdgd","bdia","bdix","bdju","bdkp","bdlt","bdlu","bdqf","bdtb","bdtv","bdun","beax","bebk","bebu","bedb","bede","befw","behg","behh","beib","beke","belr","bert","bete","beus","beut","beve","beyb","beyj","bezh","bfar","bfbo","bfbx","bfdh","bfdk","bfdr","bfds","bfed","bfhc","bfjt","bfps","bfri","bfuy","bfxc","bfzu","bgak","bgbr","bgcf","bgdd","bgen","bgge","bghh","bghl","bghq","bgjb","bgrh","bgwe","bgwp","bgwr","bgwv","bgzv","bhhx","bhie","bhlz","bhrj","bhub","bhzr","bhzt","bhzy","biam","bica","bidp","biej","bifd","bigz","bilu","bimx","biqa","bitq","biuw","biwe","biwp","bjaa","bjen","bjey","bjgd","bjgk","bjhc","bjhi","bjhx","bjia","bjid","bjiq","bjkn","bjoa","bjph","bjpm","bjrd","bjri","bjrv","bjuu","bjyh","bjyu","bjzw","bkfy","bkoy","bkpr","bkqq","bksm","bkum","bkvh","bkvs","bkxi","blbz","blct","blej","blfo","blhg","bljl","blkj","blkq","bllk","blnk","blqm","blsj","blsu","bltm","bltt","blwu","blxi","blxx","blyk","blyp","blzd","bmbc","bmbe","bmdl","bmef","bmeh","bmgz","bmjr","bmly","bmmu","bmpv","bmqc","bmqm","bmrn","bmry","bmst","bmtu","bmzj","bnai","bnal","bnat","bncf","bnfd","bnfv","bnhw","bnhz","bnmo","bnnb","bnqi","bnqk","bnrg","bnrp","bnsl","bntl","bnto","bnxe","bnxm","bnzt","bobm","bokn","bolr","bonm","boov","boqb","bosd","botr","boub","bpcz","bpjn","bpkp","bpkz","bpll","bpmw","bpov","bpsl","bpwk","bpxb","bpyp","bqbs","bqbz","bqef","bqgi","bqia","bqid","bqil","bqke","bqlf","bqml","bqql","bqqp","bqqs","bqqv","bqru","bqst","bquz","bqvw","bqwm","bqws","bqxl","bqxn","bqxv","bqxw","brgy","briv","brkk","brld","brlj","brnw","bruv","brvg","brvu","bryb","bsat","bsdl","bshd","bsjy","bsll","bslw","bsmg","bsns","bsog","bsqj","bssd","bssq","bsst","bswe","btcs","btdp","btez","btfn","btgw","btid","btij","btir","btnw","btps","btpz","bttf","btuh","btxj","btzf","buao","bubm","bucf","bucj","budu","buen","buge","bugw","buhx","buiv","bulw","bumw","buoo","buop","buqy","busg","buxg","bvar","bvhx","bvii","bvkg","bvkh","bvki","bvpu","bvqa","bvxd","bvxj","bvxv","bvxx","bvzh","bwda","bwei","bwfg","bwgu","bwhz","bwko","bwkt","bwle","bwof","bwpi","bwqw","bwqx","bwto","bwtx","bwxp","bxad","bxdb","bxdj","bxep","bxfq","bxha","bxjp","bxjq","bxjr","bxkn","bxly","bxnd","bxnp","bxsz","bxxe","bxxn","bxzf","bxzm","bxzq","bybo","bycs","byjb","byju","byly","bynh","bypp","byrr","byrv","bysd","bytt","byuo","byxi","bzam","bzaq","bzbp","bzcw","bzgd","bziq","bzjo","bzkh","bzln","bzms","bznt","bzoq","bzpu","bztq","bztw","bzwl","cacc","cahy","caji","cajp","calo","cani","canp","caof","caot","capd","capx","cban","cbbs","cbbv","cbcm","cbgf","cbip","cbkj","cbmi","cbmr","cbmt","cbok","cbou","cbsq","cbwm","cbxr","cbyi","cbzj","ccav","ccbl","ccbz","cccg","cccj","ccet","ccfo","ccfq","ccgg","cchg","cchl","cchr","ccjp","cclg","ccoj","ccor","ccph","ccpq","ccrx","ccry","ccvq","ccwt","ccxb","ccxq","ccyk","cdfy","cdit","cdjq","cdjs","cdml","cdol","cdpj","cdpo","cdpw","cdra","cdrk","cdrz","cdsr","cdts","cdvf","cdvq","cdxm","cedc","cedm","cedt","cedz","cegt","cein","ceiv","cejx","ceoj","cepq","cerm","ceun","cevl","cevv","cewu","ceyj","cfbe","cfit","cfjc","cflz","cfol","cfrs","cfuh","cfxt","cfyp","cfzr","cgcg","cgch","cggt","cgjn","cgkh","cgoo","cgot","cgpv","cgqu","cgsq","cgsz","cgye","cgzl","chbz","chcy","chdd","chfo","chgm","chio","chji","chjt","chjw","chmh","chnb","chnm","chpz","chvy","chxy","chxz","chyo","cian","ciby","cidv","cihr","cijz","cimy","cinw","cjai","cjdd","cjdl","cjeu","cjfe","cjgb","cjgh","cjja","cjmt","cjsr","cjtj","cjuf","cjva","cjwo","ckbc","ckbu","ckck","ckdh","ckhm","ckjw","ckjy","ckkd","ckks","ckpk","ckpw","ckqk","ckrx","ckst","cktn","ckts","ckvg","ckxf","ckzh","clbx","clcs","clcu","clej","cleo","clgn","clhd","clnx","cloj","clox","clqf","clsu","cluu","clxe","cmbl","cmeb","cmgo","cmkn","cmlz","cmnq","cmpc","cmpo","cmta","cmuv","cmuw","cmxd","cmxe","cmyw","cngp","cnhn","cnhs","cnih","cnjj","cnnh","cnps","cnpv","cnqi","cnse","cnsm","cnul","cnxp","cnxt","codd","codg","coga","cojk","cojw","cosp","cotd","cotz","covb","covl","covm","cowg","cowj","coyx","cpan","cpay","cpbl","cpcp","cpfk","cpgl","cpgm","cpgw","cphw","cpii","cpjc","cpjl","cpls","cpmm","cpra","cpre","cprm","cpst","cqfl","cqod","cqos","cqrh","cqsm","cqve","cqvg","cqwp","cqwv","cqzp","crdr","crhp","crhz","crir","crit","crkh","crks","crqz","crsd","cruw","crxk","cryj","crzt","crzx","cscj","csdb","csfi","csfv","csgy","csis","cskc","cskv","csmi","csnd","csqn","csqw","cssg","cstd","cstl","csug","csvx","cszn","ctcv","ctgb","cthj","ctmw","ctpb","ctpd","ctpo","ctqg","ctto","cttz","ctuq","ctvj","ctvz","ctwj","ctxf","ctxh","ctxn","ctyw","ctzx","cubo","cuhb","cuhj","cuhp","cuhy","cujr","cuqe","cuqk","cutl","cuvq","cuwx","cuxy","cuzk","cuzq","cvci","cvcn","cvdj","cvdr","cvft","cvhr","cvhu","cvrr","cvtv","cvze","cwdj","cwee","cwfv","cwfw","cwma","cwmb","cwph","cwrz","cwwk","cwxw","cxbm","cxbt","cxdv","cxhv","cxkb","cxla","cxlm","cxmp","cxoa","cxpe","cxsx","cxvm","cxvp","cxzl","cyau","cybu","cycg","cydd","cydh","cydl","cydu","cygg","cyhe","cyhw","cykl","cyoi","cytc","cytr","cyxs","cyyg","cyyp","czay","czdv","czfa","czfo","czfp","cziv","czkg","czku","czle","czlx","cznd","cznx","czpw","czse","czwc","czws","czxd","czzk","czzu","czzz","dacv","danx","dapq","daqk","daqt","daso","dasp","dasv","dawl","dawv","daxc","daxd","dazj","dbbs","dbgi","dbox","dbrf","dbsd","dbvq","dbxk","dbzf","dcax","dcct","dcee","dcer","dcic","dcin","dciq","dckg","dclz","dcnd","dcok","dcoq","dcpp","dcrn","dcuh","dcul","dcvd","dcwn","dcxb","dczk","ddav","dddj","ddfc","ddjo","ddmr","ddof","ddqv","ddro","ddtb","dduu","ddyq","ddzd","debp","debs","decw","defg","degt","dehl","dejb","dely","demx","deoe","deov","deph","derc","deup","deur","deuw","dewf","dfba","dfcg","dfdn","dfgt","dfiz","dfjx","dfjy","dfkj","dfko","dfli","dfsy","dftj","dftk","dftl","dgaq","dgcq","dgfl","dgii","dgjl","dgjq","dgkg","dgmc","dgnm","dgpt","dgqt","dgsr","dgzq","dhas","dhbq","dhff","dhfh","dhfx","dhhd","dhkk","dhlq","dhlv","dhrt","dhss","dhus","dhyu","dhyy","dico","dihg","diia","dijk","diki","dikm","dimm","dior","dirg","diua","diue","diuu","dixd","dixg","djal","djbz","djfc","djfr","djfy","djhe","djie","djll","djlp","djmb","djmf","djnm","djop","djqd","djse","djsm","djuv","djvz","djwi","djyn","dkae","dkbq","dkel","dkic","dkjd","dkjv","dklq","dkmc","dknz","dksn","dksp","dkun","dkvr","dkwf","dlci","dlkm","dlky","dlmt","dlof","dlqa","dlrl","dlwt","dlyt","dlzc","dmby","dmcu","dmjw","dmmx","dmrh","dmtj","dmtk","dmui","dmum","dmuy","dmwq","dmxi","dmxp","dmxs","dnad","dnbs","dnhn","dnhw","dnic","dnjt","dnlu","dnoa","dnod","dnon","dnor","dnpb","dnph","dnqu","dnsp","dnsr","dnua","dnuz","doam","dobn","doeb","dohc","domo","donn","doqv","doue","dovc","dowx","doxz","dozc","dpbj","dpez","dpfp","dpgj","dpif","dpkv","dpoi","dprs","dpsm","dptw","dpvi","dpwd","dpwh","dpwm","dpxq","dqay","dqbk","dqbx","dqdh","dqeg","dqgl","dqhz","dqie","dqka","dqlo","dqnp","dqns","dqom","dqqc","dqrf","dqrk","dqsn","dqtd","dqtu","dquh","dqul","dqum","dqwl","dqxb","dqxp","dqzq","dqzv","drbh","drgj","drgm","drgp","drgv","drjz","drnl","drpv","drrn","drtf","dryy","drzc","drzf","drzx","dsad","dsaj","dsak","dscn","dsdy","dsey","dsfy","dsif","dsjm","dsla","dsld","dsna","dspm","dsty","dswe","dsxu","dsyt","dtej","dtfi","dtfj","dtgo","dtio","dtiu","dtji","dtjz","dtlg","dtmh","dtmk","dtml","dtwb","dtwm","dtza","duab","dudr","dueu","dugy","dulx","dunz","duom","duph","dupl","duto","duws","duxd","duyo","duys","dvad","dvck","dvev","dvip","dvlh","dvli","dvmq","dvmt","dvns","dvrv","dvvb","dvwg","dvxn","dvxo","dvyn","dwbb","dweu","dwfe","dwfh","dwfy","dwgz","dwho","dwhp","dwlf","dwol","dwqc","dwqj","dwrs","dwsf","dwsx","dwtx","dwus","dwuw","dwzr","dwzw","dxcc","dxdc","dxdd","dxdn","dxef","dxfe","dxiq","dxla","dxnp","dxoe","dxpj","dxpx","dxta","dxxk","dxzw","dyaq","dyba","dycw","dyeh","dyfb","dyix","dykq","dykv","dymt","dyoi","dyqh","dyqs","dysk","dyyn","dyzu","dzbu","dzfo","dzjy","dzlr","dzms","dznd","dzoq","dzps","dzqg","dzqy","dzrc","dzvi","dzvl","dzwd","eada","eadf","eaeq","eaez","eaff","eagf","eahw","eaio","eajs","ealq","eanh","eank","eaps","eaxv","eazg","eazz","ebaz","ebdi","ebgj","ebkd","ebkl","eblr","ebmj","ebnw","ebob","eboz","ebsh","ebzn","ecbq","echi","ecja","eckz","ecmz","ecsx","ecwj","edal","ediz","edme","edmf","edoh","edtg","edtx","edwy","edxp","eeaw","eeew","eejr","eelb","eelt","eelz","eene","eepv","eepz","eeqy","eesj","eesn","eeso","eetw","eewc","eeyn","eeys","eezm","effl","efhl","efhn","efhp","efhx","efjq","efkn","eflx","efmq","efnl","efnp","efqn","efsn","efun","egas","egeu","eghv","egik","egiv","egkp","eglw","egpb","egrb","egro","egxn","egye","ehdg","ehgg","ehhh","ehhq","ehih","ehiu","ehjq","ehnv","ehoe","ehuw","ehwk","ehyq","eiba","eicd","eict","eief","eifq","eige","eigf","eigh","eiix","eije","eikc","eils","eilz","eiok","eisv","eivn","eivv","eiwk","ejab","ejad","ejag","ejbd","ejbk","ejcn","ejdq","ejds","ejeb","ejex","ejfl","ejgz","ejjx","ejkj","ejkq","ejlc","ejne","ejos","ejtl","ejul","ejvv","ejyl","ekes","ekkh","ekku","ekla","ekml","ekne","ekrq","ekru","eksl","ekvp","ekxe","ekyl","elac","elch","elcn","elhp","elmj","elmp","elrx","elsj","eltr","elub","elxf","elyj","embq","emds","emgs","emvs","emwy","emzi","emzr","enao","endc","endi","enfy","enhz","enix","enkt","enlm","enmc","enmq","enuh","enuo","enup","enyw","eoat","eock","eocp","eocu","eoeg","eogt","eojc","eolw","eomg","eoqw","eowb","eoxy","eoyk","eozn","epao","epcs","epda","epeo","epey","epfw","epke","eplx","eppa","eppy","epre","epth","epuw","epzw","eqan","eqbg","eqcj","eqgd","eqjb","eqln","eqmt","eqmu","eqmz","eqpu","eqpz","eqra","eqxd","eqxt","eqye","eqyw","eqza","eqzx","erfo","erkt","erlj","erqf","erue","erxj","esbn","eseu","esfg","esfh","eshb","esht","esip","eslr","eslz","esnn","esnx","espz","esty","esvh","eswb","esxe","esxo","esya","etcj","etfr","etgd","etgz","ethd","etiq","etpe","etqi","etwr","eudo","euhq","euia","euiq","eumq","euny","euon","euos","eurb","eurh","eutk","euui","euul","euus","euve","euvk","euwc","euyb","euyk","euzb","euzp","euzw","evcb","evfp","evgl","evgy","evif","evlr","evnf","evqe","evrn","evza","ewae","ewat","ewbe","ewiz","ewju","ewki","ewlv","ewmi","ewnh","ewni","ewot","ewpc","ewrg","ewrz","ewxq","ewyo","ewzk","exat","exbk","excb","exeo","exfd","exfw","exgj","exin","exjg","exjt","exmd","exmh","exni","expd","exsc","exuu","exyg","eyba","eycb","eydm","eydr","eyft","eyhw","eyih","eyjf","eyll","eynu","eyoa","eypv","eytm","eyue","eyvj","eyvp","eywh","eyxo","ezee","ezkf","ezli","ezma","ezqy","ezxx","ezyj","ezyv","fadm","fahl","falb","fals","fasy","fato","fatz","faue","fazl","fbey","fbfz","fbga","fbgf","fbjj","fbju","fbol","fbon","fbps","fbqx","fbtk","fbtt","fbue","fbxz","fbzv","fcai","fcfj","fchx","fcin","fcja","fckz","fclb","fclv","fcmq","fcmt","fcos","fcss","fcta","fcux","fcvc","fcyk","fczk","fddj","fdgl","fdgv","fdhi","fdkm","fdkw","fdnd","fdnq","fdpg","fdqi","fdsy","fdwj","fdwr","fdxb","fdxx","fdzl","fecn","fedo","feek","fefw","fegd","fehi","fekg","fekp","felp","feno","feon","fers","fetb","fevj","ffbu","ffcr","ffek","ffhi","ffrb","ffsi","ffsx","ffta","ffyc","ffym","ffyx","ffzt","fgcm","fgdj","fgdt","fgjq","fglj","fglw","fgnh","fgqk","fgqn","fgsw","fgtm","fgvj","fgyo","fhak","fhax","fhca","fhcc","fhdw","fhej","fhgf","fhjf","fhkj","fhkq","fhkr","fhli","fhlz","fhmv","fhor","fhtn","fhug","fhul","fhwb","ficx","fiej","fies","figk","fiid","finf","fipr","fiqh","firt","fitt","fiuo","fivq","fiwe","fixj","fiyq","fizn","fjcb","fjdr","fjfj","fjfl","fjfo","fjgt","fjmw","fjne","fjqi","fjrr","fjrz","fjwa","fjxk","fjxz","fjyz","fkbr","fkez","fkgz","fkib","fkjs","fkkn","fkns","fkor","fkph","fkpk","fkpn","fkqx","fkrc","fkrk","fktw","fkup","fkvh","fkye","fkzl","flfw","flke","flkh","flkm","flll","flma","flmt","flmy","flrp","fltn","flvy","flwh","flyg","flzl","fmaq","fmbi","fmej","fmgs","fmin","fmjv","fmly","fmmb","fmmm","fmmn","fmnt","fmph","fmrb","fmtu","fmvd","fmxj","fnep","fngp","fnhb","fnkr","fnkz","fnla","fnme","fnmu","fnpa","fnse","fnsn","fntv","fnyn","fnyr","foax","fobe","fofx","fogq","fohr","fohy","fokr","foks","fokt","foku","folq","fopy","foqx","forc","fost","fotv","fovc","fpac","fpae","fpat","fpbs","fpdp","fpfx","fpgn","fpiu","fpjb","fpka","fpmm","fpov","fppr","fpqy","fpre","fprh","fprz","fpsh","fpsl","fqaa","fqbv","fqda","fqdo","fqfg","fqie","fqis","fqiv","fqjc","fqje","fqkx","fqme","fqnq","fqoi","fqux","fqwg","fqwy","fqxo","frbh","frbi","frcy","frey","frff","frgy","frhd","frhf","frkf","frkm","frkx","frkz","frmd","frnj","frnq","frns","frob","froh","frop","froq","frqv","frrh","frsd","frsp","frvv","frwb","frww","frxa","fryt","fryu","fsby","fscj","fsef","fshm","fsii","fsjb","fskh","fskj","fskw","fsmu","fspg","fssu","fswa","fsxi","ftbp","ftff","ftfj","ftgm","fthe","fthn","ftii","ftoa","ftrk","ftsp","ftvf","ftwg","fuas","fubo","fucx","fucy","fugl","fugt","fugy","fuhj","fukg","fukq","fulf","fuok","fusy","fuud","fuum","fuwz","fuyv","fvbe","fvcb","fvdq","fvfr","fvfu","fvgt","fvid","fvim","fvkp","fvmq","fvox","fvoz","fvpp","fvra","fvrb","fvrc","fvso","fvzo","fwad","fwap","fwca","fwdc","fwds","fwew","fwud","fwum","fwuy","fwvu","fwxj","fxdm","fxen","fxfh","fxfi","fxgt","fxsc","fxtw","fybe","fycl","fycx","fydr","fyed","fyeo","fyhx","fylt","fyoq","fyqb","fyss","fyuf","fyvz","fyyh","fzbt","fzdi","fzig","fzlo","fzmi","fznc","fzni","fznm","fzpp","fzre","fzvi","fzvm","fzyi","gaac","gaaz","gadn","gaij","gama","gamt","gaos","gaow","garj","gasq","gavk","gavq","gawg","gbac","gbdj","gbez","gbih","gbkq","gbld","gblk","gbnj","gboe","gboh","gbvg","gbwo","gbwx","gbye","gbzf","gcar","gchv","gcke","gcln","gclr","gcmk","gcpb","gcrw","gcvd","gcvt","gcwl","gczh","gdbs","gdco","gdfq","gdlc","gdnv","gdrh","gdsq","gdta","gdty","gdyn","gdyw","gebf","gecx","gedg","geew","gefz","gelr","gelz","genb","geyh","gfbi","gfde","gfdv","gfge","gfgx","gfhv","gfjh","gfjz","gflk","gfsg","gftp","gfuq","gfvt","gfwv","ggfb","gghc","gghk","gghn","gglm","ggnb","ggpp","ggqx","ggrg","ggtw","ggun","ggvi","ggwb","ggzo","ghas","ghbf","ghcc","ghda","ghdd","ghdh","ghko","ghkq","ghku","ghlu","ghmi","ghpi","ghvz","ghxu","ghys","ghzz","giai","giaj","gibx","gicl","gict","gidc","gifk","gigd","gijs","giln","gimi","ginm","gipq","giub","giux","gjfe","gjjn","gjlm","gjoa","gjol","gjoz","gjpy","gjrk","gjrw","gjtv","gjtw","gjul","gjur","gjvw","gjwk","gjxy","gjyh","gjzd","gjzp","gkcl","gkcn","gkeh","gkff","gkfh","gkgx","gkhg","gkks","gkmf","gkmg","gkpb","gkrr","gkvu","glah","glbk","glch","gldb","glep","glfe","gljh","glmj","glni","glnz","glot","gloz","glph","glwt","glyh","glyk","glyu","glzz","gmhc","gmkd","gmlh","gmmg","gmmq","gmpb","gmpc","gmpi","gmpo","gmpt","gmqg","gmtv","gmwu","gmxl","gmxz","gmyk","gmzs","gnds","gnfz","gnlr","gnmc","gnnh","gnni","gnqn","gnul","gnuy","gnvz","gnwj","gnzm","goae","goam","gobg","gobr","godk","gohg","goki","gokp","goot","gooz","gopd","gopg","gord","goth","goys","gozx","gpbb","gpcs","gpdm","gpdw","gpgt","gpkk","gpkt","gplc","gpll","gpoj","gpsc","gpuu","gpwb","gpzi","gqar","gqgw","gqiq","gqit","gqle","gqlf","gqli","gqlm","gqoj","gqpd","gqum","gqvv","gqyx","gqzb","gqzf","grah","grec","grfi","grfm","grhj","grhx","grnc","grnm","grnn","grnq","grov","grtx","gryu","grzd","grzv","gsca","gscd","gsct","gscy","gsew","gsfz","gslr","gsnf","gsqt","gsrz","gssl","gsuv","gszf","gszi","gszy","gtcz","gtdp","gtev","gtfl","gthn","gtkr","gtmt","gtnh","gtom","gtqo","gtqq","gtqv","gtrt","gtsf","gtvr","gtwf","gtxj","gtyu","gueb","gueo","gufb","gugz","guik","gumi","gumr","gupj","gurg","gusx","guti","guyh","guyu","gvao","gvcf","gvci","gvdz","gvej","gvfl","gvgu","gvij","gvjd","gvjg","gvkx","gvln","gvok","gvox","gvue","gvus","gvzp","gwhj","gwlu","gwnz","gwom","gwpw","gwss","gwtd","gwwj","gwxo","gwym","gxam","gxaz","gxbk","gxbz","gxdf","gxdu","gxgq","gxgv","gxhu","gxhw","gxii","gxio","gxjg","gxly","gxmi","gxnt","gxow","gxpn","gxpz","gxrv","gxsg","gxwk","gxww","gxxc","gxxq","gxxs","gxxw","gydn","gydv","gyfj","gyjr","gykl","gyqb","gyrf","gyrw","gytm","gywl","gyzc","gzas","gzbl","gzdj","gzea","gzir","gzkj","gzml","gznq","gzpx","gzqi","gzso","gzsz","gztm","gzxv","haah","haeo","hagw","hahm","haia","haif","hakl","hari","hase","hatk","haus","havh","hazg","hbaj","hbey","hbgb","hbgm","hbiv","hbqq","hbrq","hbtp","hbtw","hbuq","hbuu","hbxf","hcdj","hcdr","hcdx","hcef","hcgo","hchp","hcji","hckm","hcku","hcma","hcnp","hcnz","hcoa","hcom","hcos","hcxh","hcxi","hczk","hdbp","hdca","hdel","hdex","hdft","hdnp","hdrz","hdty","hdum","hdwf","hdww","heay","hecl","hedg","hehw","heih","heni","hepr","herr","hesx","hesy","heuj","hevh","hevw","hews","heyi","hfcu","hfcz","hfdi","hfdp","hfef","hffp","hfix","hfni","hfof","hfpf","hfqe","hfqi","hfrg","hftg","hfth","hfvm","hfxb","hfxf","hfyw","hfzb","hgaw","hgbj","hgea","hged","hgen","hgft","hggm","hghk","hghn","hgip","hgkg","hgkn","hgna","hgnp","hgpc","hgru","hgtu","hguu","hguz","hgwn","hgzj","hgzu","hhbm","hhbp","hhcj","hhdb","hhmb","hhmh","hhmu","hhny","hhpo","hhpp","hhqj","hhqy","hhwf","hhxe","hhyr","hhyw","hiav","hiev","higq","hihk","hikn","himg","himw","hion","hipx","hipy","hisa","hitc","hitp","hiug","hiuw","hivm","hiwr","hiyw","hjdd","hjef","hjer","hjhp","hjiq","hjke","hjkz","hjlv","hjnk","hjns","hjoi","hjql","hjqz","hjrw","hjsr","hjtk","hjwd","hjzn","hjzt","hkcq","hkdq","hkfy","hkgy","hkhh","hkkz","hklm","hknp","hknz","hkoi","hkpw","hkqw","hkra","hkrk","hkty","hkwa","hkwi","hkyp","hlbl","hlce","hlct","hlfu","hlhl","hlho","hllg","hlnb","hlpk","hlqs","hltd","hlxq","hlzn","hlzz","hmap","hmbb","hmgu","hmkh","hmow","hmti","hmto","hmuf","hmwn","hmxl","hmzj","hncv","hnez","hnie","hnjk","hnkz","hnoo","hnpn","hoad","hobh","hocm","hodv","hofb","hojs","hojy","hokz","holg","homu","honb","honp","hoom","hoph","hopj","hosd","houi","hous","hovf","hovi","hoxt","hoyr","hpbg","hpbn","hpcg","hpcl","hpgh","hphe","hpjt","hplh","hpmq","hpoj","hppz","hprv","hpse","hptu","hpwo","hpxs","hqdw","hqfd","hqfk","hqfl","hqfy","hqhy","hqla","hqlm","hqmu","hqnd","hqou","hqpz","hqqf","hqsh","hquc","hquu","hqvo","hqww","hqxk","hqzs","hrav","hrdk","hrgm","hrit","hrjs","hrkd","hrkk","hrkv","hrmb","hrqb","hrwa","hrzp","hrzw","hsfv","hsgv","hsjl","hskl","hsku","hskw","hsnc","hsnk","hspy","hsqm","hsru","hstd","hsvu","hsxx","hsze","htae","htas","htbc","htbj","hteb","hten","htey","htfr","htfs","htgy","hthw","htjf","htnt","htoj","htrc","httf","htug","htuh","htuo","htvx","htxw","huaj","hufr","hugn","hujk","husz","huui","huvm","huyp","huzb","huzt","hvae","hvco","hvec","hvek","hveo","hvho","hvjx","hvlb","hvlq","hvoa","hvoo","hvoq","hvov","hvtv","hvuf","hvus","hvxd","hvzu","hwao","hwdf","hwgd","hwhc","hwie","hwkv","hwmg","hwmv","hwoh","hwqh","hwqu","hwrj","hwsp","hwsu","hwyt","hxbc","hxcw","hxfl","hxnc","hxpm","hxpx","hxsh","hxvc","hxvy","hxwo","hxyg","hxyn","hxza","hxzs","hybz","hych","hygp","hygq","hygz","hyhn","hypm","hyrt","hysj","hyvq","hyzf","hzch","hzfu","hzjf","hzkk","hzmn","hzob","hzqz","hzrg","hzui","hzxx","hzze","hzzx","hzzz","iaap","iadf","iaff","iajg","iakw","iaom","iasl","iasx","iats","iatx","iava","iavk","iavw","iaxs","iayx","ibfl","ibiv","ibiy","ibjk","iblu","ibnd","ibnf","iboe","ibph","ibqb","ibqq","ibri","ibxk","ibxp","icar","icbp","iccw","icgb","icgs","iclp","icme","icsh","ictz","icuf","icuq","icut","iczu","idcp","idds","iddz","ided","ideh","idhg","idhq","idie","idkd","idnt","idnw","idqz","idtn","iduo","iduu","idvy","idyt","iedn","ieei","iefu","iemf","iemr","ieod","iepi","iepx","iequ","iesn","iesp","iesq","ieut","ieuz","ievd","ievj","ifbg","ifcf","ifcq","ifdq","ifej","ifft","ifht","ifiy","ifjc","ifjr","ifkr","ifni","ifob","iftt","ifui","ifxj","ifyv","ifzp","ifzq","ifzu","igaj","igbf","iged","igft","iggj","iggl","iggs","igil","igiz","igmm","igoa","igrs","iguh","igyu","igzu","ihbj","ihcf","ihfv","ihgm","ihie","ihkf","ihkg","ihnr","ihod","ihrf","ihsb","ihsg","ihsv","ihsw","ihuw","ihvm","ihwf","ihzb","iiaw","iici","iicn","iidp","iigc","iign","iijd","iikf","iikt","iiky","iile","iilq","iinf","iiom","iior","iioy","iipg","iiqt","iire","iitu","iizo","ijak","ijeb","ijeg","ijer","ijet","ijge","ijju","ijlh","ijlm","ijns","ijqj","ijrv","ijuk","ijxc","ijzr","ijzy","ikac","ikba","ikdi","ikdu","ikeb","ikgo","ikgs","ikit","ikjy","ikko","iklr","iklv","ikmo","ikuj","ikxy","ikym","ilal","ilay","ilbh","ilcj","ilkr","ilng","ilnn","ilqo","ilrk","ilrv","iltk","ilvg","ilwd","ilwf","ilyt","ilzi","imau","imav","imcm","imdb","imeh","imey","imfh","imgx","imjn","imjz","imla","imlc","imoe","imqd","imrq","imrt","imtn","imwg","imwp","imxq","imyq","imzj","indw","infm","infv","ingd","ingn","ings","inhr","inhv","inhx","iniw","inje","injv","inmh","innj","inpb","inpk","inqo","inzf","ioao","iodl","iody","iokt","iolv","iomw","ionc","iopj","iopo","iosd","ioty","ioyc","ioyz","ipew","ipfu","ipif","ipmk","ipmq","ipnv","ippc","ippd","ipqi","iprn","ipxy","ipyz","iqcy","iqex","iqfc","iqfk","iqfx","iqlr","iqns","iqri","iqrw","iqso","iqvb","iqwk","iqxp","iqxx","iqyl","iqyy","iqzy","iral","irdk","irjk","irlw","irmp","irna","iroz","irpo","irux","iryg","iryy","isaf","isao","isbz","isds","isfc","ishi","isif","isml","isoo","isqe","isqr","isrt","istg","istt","isvh","isxy","isza","iszx","itct","itfn","ithi","itip","itiw","itmp","itpf","itpl","itqa","itud","itwg","ityx","iubd","iubi","iudw","iugh","iuhx","iuie","iulz","iuoc","iupm","iuth","iuto","iuvt","iuwx","ivaf","ivbo","ivcd","ivcf","ivea","ivfy","ivhg","ivjj","ivmo","ivmp","ivok","ivrk","ivxh","ivxx","ivyb","ivys","iwek","iwfo","iwim","iwnh","iwnj","iwpo","iwth","iwum","iwvr","iwvv","iwwh","iwxe","iwxs","ixac","ixdf","ixgz","ixiy","ixlo","ixly","ixml","ixor","ixvc","ixvr","ixwz","ixyo","ixyx","iyaz","iyci","iydq","iyfj","iyfl","iylh","iyma","iymo","iynr","iypx","iyrb","iysh","iyyx","iyyy","iyza","izak","izat","izcy","izep","izfk","izjn","izoe","izoz","izqy","izrm","iztw","izut","izvk","izwg","izyi","jacx","jada","jadu","jafi","jafo","jaig","jaka","jamf","jaso","java","jazc","jazp","jbbd","jbbv","jbeb","jbfv","jbfx","jbgn","jbiu","jbiw","jbmk","jbnf","jboe","jbpw","jbqf","jbqg","jbql","jbru","jbtl","jbwf","jbwl","jbwx","jcah","jccg","jcdd","jcdu","jcew","jcgw","jchh","jckb","jclq","jcmo","jcnp","jcoh","jcpr","jctj","jcux","jcvl","jcwe","jczh","jdaf","jdcd","jdce","jdhf","jdhh","jdhi","jdhk","jdlz","jdmn","jdmp","jdog","jdpb","jdtb","jduj","jdwd","jdxv","jdyj","jeah","jeak","jecf","jefs","jegc","jegg","jehp","jejc","jeje","jend","jewx","jfal","jfdv","jfff","jfgb","jfie","jfjv","jfkr","jfkw","jfox","jfqf","jftz","jfvq","jfwc","jfwg","jgbl","jgef","jgfd","jgfg","jggy","jght","jgir","jgkb","jgmw","jgom","jgqg","jgte","jgug","jgus","jgvg","jgvj","jgvu","jgwk","jgxl","jgzb","jhad","jhbj","jhey","jhfb","jhfr","jhis","jhli","jhnn","jhrw","jhse","jhsl","jhss","jhug","jhuy","jhwy","jhxc","jhxf","jhzb","jibt","jifk","jihj","jiib","jiin","jilh","jilm","jimg","jins","jiob","jioi","jipl","jirr","jiur","jiyj","jjah","jjez","jjgf","jjik","jjkp","jjmi","jjnq","jjph","jjrn","jjtb","jjum","jjxm","jjyf","jjyj","jkaf","jkaq","jkcw","jkem","jkgl","jkia","jkjs","jkkw","jklf","jklm","jklo","jkre","jksa","jkth","jktr","jkun","jkwp","jkww","jkya","jkyy","jlbs","jlea","jlep","jleq","jlhj","jlkr","jlmb","jlni","jlse","jltj","jlwi","jlyn","jlza","jlzt","jman","jmbb","jmbi","jmbo","jmcm","jmem","jmev","jmgy","jmiy","jmke","jmkx","jmnt","jmpv","jmqe","jmuj","jmvo","jmxn","jmxz","jmyv","jnbb","jnby","jncr","jndb","jnfz","jniw","jnjf","jnke","jnlg","jnln","jnok","jnyh","jnzp","joaq","jodo","joen","jofi","jofw","john","jojp","jokl","joks","jonj","jooa","joqj","josl","jotv","jovr","jovs","joyy","jozj","jozr","jpah","jpbn","jpdn","jpln","jpnk","jpnn","jpou","jpqh","jpsp","jptg","jptj","jpwf","jpwn","jpxn","jqal","jqbi","jqcl","jqer","jqfi","jqjw","jqlk","jqnx","jqob","jqqh","jqro","jqtb","jqur","jqvk","jqvp","jqxn","jqyx","jqzz","jraf","jrap","jrbs","jrdn","jrgj","jriq","jrky","jrlu","jrmy","jrnu","jrnx","jrps","jrtn","jrvt","jrzl","jscm","jsdl","jsey","jsfb","jsfs","jsku","jsnj","jsoa","jsoq","jsry","jssi","jsvz","jsxj","jsyn","jszl","jteg","jtgh","jthm","jthq","jthv","jtjb","jtkd","jtlb","jtmw","jtpo","jtql","jtuw","jtwm","jtwq","jtyd","jtym","juaa","judh","juen","jueq","jufw","juij","juis","julo","juqr","jutx","jutz","juua","juxy","juzl","jvbl","jvbm","jvbo","jvcb","jvix","jvka","jvkv","jvlg","jvqc","jvst","jvtd","jvtq","jvwz","jvyf","jvzi","jvzp","jwau","jwcc","jwhh","jwhr","jwjq","jwli","jwoy","jwwg","jwwz","jwxf","jwxg","jwzx","jxbd","jxdq","jxdu","jxfo","jxfr","jxfz","jxiu","jxje","jxnf","jxrs","jxsu","jxsy","jxtm","jxyj","jxyq","jxzo","jybk","jycw","jydg","jyhe","jyhv","jyjd","jyku","jykv","jylo","jyrh","jyvc","jyxb","jyzv","jzav","jzbt","jzbu","jzej","jzfy","jzgj","jzgs","jzib","jzij","jzkq","jznz","jzoh","jzqh","jzsr","jzvd","jzwy","jzxh","jzzc","jzzy","kaba","kaby","kaci","kafd","kafh","kajf","kakc","kamw","kaun","kavy","kaxv","kazv","kbau","kbci","kbfw","kbiv","kbnt","kbop","kbph","kbtn","kbts","kbvg","kbvu","kbxw","kbyv","kcct","kcda","kcfw","kcik","kciu","kcjx","kckk","kcmx","kcnd","kcoa","kcpb","kcpp","kcqm","kcrr","kcru","kcso","kcwv","kcya","kdad","kdav","kddh","kdef","kdgn","kdhc","kdju","kdjy","kdke","kdkn","kdlk","kdne","kdog","kdtw","kdyc","kdyl","keaq","kebx","kebz","kecg","kede","kego","kegq","kekj","kenl","keol","keox","kesf","kevf","kexj","kfbh","kfbi","kfda","kfdq","kfdz","kfgt","kfjq","kfko","kfns","kfqg","kfqs","kfss","kftp","kfuw","kfvg","kfxs","kfyt","kfze","kgbv","kgcd","kgdf","kgdj","kgdo","kggn","kgip","kgix","kgji","kgkv","kgle","kglz","kgmu","kgnu","kgok","kgoq","kgph","kgzf","kgzw","khav","khdr","khej","khep","khez","khfd","khlx","khmq","khmt","khnc","khod","khpm","khrx","khwa","khxc","kibr","kiht","kiig","kijk","kijr","kikp","kilj","kiok","kioq","kipv","kird","kisb","kisq","kitu","kivo","kiwu","kizc","kizs","kjbz","kjch","kjdf","kjdl","kjhx","kjid","kjii","kjir","kjll","kjlm","kjop","kjpm","kjpt","kjrx","kjtg","kjut","kjwy","kjxh","kjyg","kjyi","kjyv","kkav","kkdy","kken","kkhl","kkhn","kkja","kkot","kkxg","kkyd","klbv","klct","klda","klfw","klgg","klix","kljv","klny","klpf","kltj","klvc","klve","klvr","klxg","kmba","kmbl","kmbw","kmdh","kmfo","kmhc","kmjj","kmol","kmor","kmpb","kmru","kmrv","kmux","kmwv","kmzr","knak","kndd","kndf","kndg","knem","knim","knjd","knpj","knuz","knwf","knxb","knxy","kobe","kodo","kogr","kogt","koiw","kolx","kome","komp","konz","kosx","kotg","kott","kout","kowk","kpap","kpbj","kpct","kpeo","kpie","kpig","kpjc","kpjh","kpli","kplx","kpmy","kprl","kpti","kptq","kpuf","kpwm","kpwz","kqan","kqhp","kqjh","kqjz","kqla","kqlt","kqmn","kqnn","kqoj","kqtg","kqva","kqzh","krbd","krcc","krcf","kreq","krff","krga","krhq","krhw","krhx","kriu","krjn","krkt","krlj","krow","krqb","krsc","krts","kruj","krwv","krxb","krxe","ksdm","ksez","ksgs","ksgx","kspj","ksqb","ksse","kssv","ksvb","kswl","ksxe","ksym","kszg","ktbm","ktcc","ktcv","ktin","ktiv","ktiy","ktjo","ktkh","ktll","ktox","kttp","ktun","ktwu","kudz","kuia","kukg","kukk","kulo","kutn","kuuk","kuvx","kuwo","kuwq","kuxi","kuzo","kvbv","kvdg","kvdx","kvfs","kvhh","kvhn","kvla","kvmq","kvog","kvuc","kvvc","kvyt","kvzq","kwbg","kwde","kwdo","kwdp","kwgf","kwhh","kwig","kwjd","kwnf","kwqn","kwws","kwxi","kxba","kxef","kxek","kxfz","kxga","kxgp","kxgx","kxmm","kxnb","kxnp","kxpe","kxqb","kxuc","kxyp","kyad","kycq","kyhe","kyhx","kyic","kykq","kykt","kyll","kyns","kyps","kyso","kywe","kywz","kyyb","kzat","kzav","kzcz","kzdm","kzhq","kziq","kzky","kzln","kzmf","kzrr","kzrv","kztd","kztn","kzul","kzvq","labp","lacf","lacp","lacw","ladc","lafr","lahw","lamw","laqr","larx","lasi","lasn","lasu","laxx","layz","lbgc","lbiv","lbjp","lbko","lbos","lbqq","lbvg","lbxt","lbyn","lbze","lcbi","lcfs","lchn","lcky","lclf","lcmt","lcqw","lcsy","lcuv","lcvj","lcwg","lcws","lcyp","lcza","ldar","ldbf","ldbt","ldca","lddl","lddq","ldee","ldir","ldjp","ldjx","ldlw","ldma","ldnm","ldpj","ldpl","ldrl","ldrq","ldtw","ldus","ldzz","leba","lebj","ledo","ledx","lefi","legu","lehg","lehp","leld","lemw","lerh","lesq","lesv","levn","lexb","lfau","lfbb","lfbu","lfer","lfhb","lfhn","lfjn","lfkj","lflu","lfme","lfqg","lfql","lfxi","lfxs","lfxz","lfza","lgda","lggb","lgit","lgle","lgou","lgsy","lgvh","lgwe","lgzj","lhdg","lhdu","lher","lhfh","lhfi","lhiz","lhna","lhni","lhnl","lhnm","lhoc","lhqq","lhro","libc","licf","lict","liek","ligv","lihv","liif","lije","lijs","likm","liky","lilg","limv","linc","linv","lior","lios","lirh","lisn","litr","ljbh","ljcb","ljfv","ljgg","ljjk","ljlf","ljlh","ljnp","ljqe","ljti","ljtp","ljtr","ljuf","ljxg","ljxs","ljzm","lkdc","lkei","lkle","lklf","lkmz","lkoc","lkrj","lkrk","lksd","lkub","lkwm","llcw","llcx","lldn","llju","llla","llmb","llmi","llse","llsr","llss","llsx","lltf","lluh","llwo","llym","llzb","lmab","lmaj","lmaw","lmdl","lmfv","lmiy","lmle","lmsu","lmvm","lmxn","lmzy","lnaj","lnbs","lnfu","lnfv","lngo","lngu","lnir","lnjk","lnmz","lnoq","lnua","lnux","lodl","loey","loez","lofs","lofy","logd","lohy","loiq","lojd","lojw","lokq","lonj","lopb","lopz","loqx","loti","lowc","lowm","loxq","loyf","loys","lpaj","lpca","lpht","lpiq","lpir","lpjb","lpjk","lpks","lplh","lpmp","lppa","lppz","lprg","lpsz","lpta","lpti","lpwu","lpxf","lpyb","lpyc","lpyq","lqax","lqcx","lqed","lqei","lqfx","lqjj","lqlx","lqmm","lqpt","lqqs","lrbr","lres","lrgf","lrgp","lrjz","lrki","lrlv","lrma","lrmd","lrmh","lrmj","lrmy","lrnv","lrnx","lrpa","lrpx","lrqi","lrrg","lrsc","lrtv","lrvj","lrvm","lrzl","lsar","lscg","lsif","lsjg","lskv","lsna","lssm","lsvj","lswc","lsxz","lszw","ltaz","ltdk","ltdt","ltgp","ltgs","ltjs","ltme","ltom","lton","ltou","ltri","ltto","ltvd","ltyn","ltyq","lubd","lubl","lufd","lufz","luji","lukb","luln","luns","luom","luqx","luub","luvi","luxu","luyi","lvbr","lvgs","lvhv","lvoy","lvpp","lvpt","lvrc","lvrq","lvse","lvvb","lvxg","lvxq","lwad","lwfe","lwok","lwqs","lwss","lwtk","lwyl","lwzt","lxam","lxap","lxeq","lxfl","lxjw","lxjz","lxkp","lxla","lxlv","lxni","lxrb","lxui","lxuk","lyeh","lygn","lygw","lyiz","lyjo","lykc","lykq","lymz","lyod","lyrv","lyvq","lyvr","lyxv","lyyb","lyzn","lzag","lzci","lzeo","lzgs","lzjg","lzmw","lzoe","lzoi","lzom","lzqx","lzsg","lzuf","lzvv","lzwn","maab","madw","mafz","makt","maod","maof","maqv","maui","maxr","mbbe","mbbo","mbbr","mbdm","mbgf","mblm","mbls","mbqh","mbqm","mbqw","mbsl","mbso","mbtg","mbtj","mbtl","mbwg","mbwo","mbym","mbze","mcan","mcds","mcfq","mcgw","mcha","mcki","mcma","mcmg","mcmv","mcnv","mcof","mcpa","mcqn","mcro","mcso","mctk","mctu","mcub","mcuv","mcuw","mcxg","mcxy","mcyo","mdad","mday","mdba","mdek","mdjt","mdkb","mdkt","mdlq","mdne","mdnj","mdod","mdoj","mdsa","mdsc","mdtc","mdwt","mdxx","meaz","meck","medi","mefy","meko","memb","memd","meov","merq","mers","mesj","meuo","mevx","mezd","mezk","mfce","mfho","mfji","mflx","mfnr","mfoc","mfpg","mfrf","mftr","mfug","mfvo","mfxn","mfzj","mfzu","mfzv","mfzx","mgcm","mgdu","mges","mgfc","mggr","mggy","mghv","mgie","mgln","mgve","mgyw","mhbf","mhbu","mhcl","mhem","mhgm","mhgs","mhiq","mhkb","mhkz","mhpb","mhpx","mhrs","mhrw","mhyc","mhyr","mibh","midz","miei","miet","mige","mihy","mijg","miji","mika","mikk","mikx","mioe","mirl","misx","miti","miuc","miun","mivr","miyf","mjcb","mjez","mjff","mjhq","mjjj","mjjr","mjkn","mjlj","mjlr","mjor","mjpa","mjrt","mjuy","mjxr","mjzo","mkbt","mkdv","mkgp","mkiz","mkkh","mkmb","mkmi","mknp","mkpc","mkqq","mkrf","mksa","mksu","mktv","mkud","mkvd","mkwr","mkxh","mlat","mlce","mley","mlfo","mlgc","mlgr","mlhu","mliv","mlkr","mllf","mlli","mlly","mllz","mlqd","mlsw","mlvb","mlvu","mlwv","mlxb","mlye","mmab","mmai","mmbz","mmem","mmfq","mmib","mmlh","mmlo","mmmi","mmqt","mmrx","mmvd","mmzm","mnab","mnaz","mnbl","mnbz","mncc","mngm","mngp","mnli","mnln","mnme","mnnu","mnsd","mnsl","mnut","mnxf","mnyi","mnyv","mnzl","mogu","mois","mooz","mopc","mord","morf","mosk","moyk","mpbr","mpcd","mpfi","mphv","mpjh","mpkh","mply","mpne","mppe","mppv","mprf","mpro","mpvt","mpwe","mpxh","mpyy","mqby","mqco","mqcz","mqep","mqif","mqkx","mqlj","mqmm","mqnn","mqns","mqoy","mqpl","mqqt","mqti","mqvw","mqwb","mqxt","mqxw","mqyi","mrbn","mrem","mrex","mrgq","mrhj","mrio","mrjj","mrmd","mrmh","mrnh","mrot","mrpd","mrpn","mrsl","mrtp","msdf","mses","mske","msko","msmb","msmi","msoj","msrf","msrz","mssc","mtdx","mtea","mtgy","mthj","mtnw","mtog","mtop","mtse","mtwa","mtwu","mtxw","mtzl","mual","muay","mudv","mueh","mufp","mukv","muma","mumk","mumq","muoj","muqr","musn","mutm","muvz","muxg","muyg","muzd","muzg","mvci","mvco","mvdz","mvek","mvgd","mvgl","mviy","mvjp","mvjx","mvms","mvnm","mvoy","mvrk","mvyb","mwap","mwbj","mwdw","mwef","mwei","mwik","mwve","mwwj","mwxo","mwyu","mxai","mxcp","mxeq","mxgg","mxgl","mxhf","mxhi","mxhj","mxiw","mxjk","mxjr","mxkv","mxmf","mxvd","mxvi","mxvs","mxwu","mybs","mycx","myeo","myhi","myni","myri","myrv","mytc","myvq","mywp","myyq","myzq","mzdg","mzfp","mzgy","mzih","mzkj","mzkl","mzth","mztl","mzut","nabp","nadk","nall","nama","namx","naqo","naqw","naqx","narb","natu","nauv","nayg","nbdh","nbdj","nbig","nbim","nbjm","nbmz","nbnc","nbsd","nbvn","nbxi","nbxo","ncbq","ncfd","ncgo","ncgu","ncgx","nchk","ncjp","ncme","ncmh","ncmz","ncng","ncov","ncps","ncsy","nctq","ncuo","ncvc","ncwc","nczz","ndag","ndce","ndej","ndex","ndgc","ndjl","ndkn","ndku","ndlh","ndpm","ndqg","ndte","ndua","nduj","neaw","nede","neeb","nefb","nehe","nejp","nekk","nekq","nekr","nell","nely","nens","neoj","nepo","nerr","neug","nevx","nezm","nfcn","nfcv","nfhn","nfhz","nfkf","nfmo","nfmu","nfoo","nfpb","nfsq","nfws","ngeh","ngey","nghm","ngim","ngit","nglp","ngno","ngpn","ngqi","nguw","ngve","ngvz","ngyz","ngzo","nhaa","nhdv","nhfh","nhfp","nhhf","nhia","nhnk","nhog","nhrk","nhsb","nhvd","nhwf","nhxo","nhxx","nhye","nhyu","nieo","nifj","nifu","nigv","nijq","nild","nipz","nist","niuk","niva","niwn","niwp","nizc","nizw","njbd","njbw","njjb","njoj","njpd","njqn","njtk","njxr","njxx","njzd","njzr","nkbt","nkbz","nkdb","nkew","nkfj","nkgj","nkia","nkle","nknc","nkny","nkou","nksw","nkvk","nkxs","nkyg","nkzu","nlcv","nlfv","nlgg","nljk","nljr","nlny","nluu","nlwb","nlwd","nlwv","nmbu","nmez","nmja","nmki","nmlq","nmnn","nmom","nmpc","nmrl","nmsm","nmtj","nmvn","nmvv","nmwh","nmwx","nmyi","nmym","nnbs","nncz","nndb","nngh","nnqc","nnra","nnwq","nnwr","nnxn","nnya","nnyp","nnzj","nnzp","noax","nocc","nogi","nogm","nohw","noja","nokg","nolg","nomg","noni","nopj","nopn","nost","novk","nowi","noxb","noxu","npbn","npde","npdv","nped","npeo","npeu","nphy","npjs","nppf","nqbh","nqea","nqem","nqey","nqfu","nqgi","nqki","nqko","nqlq","nqma","nqom","nqqz","nqrp","nqsh","nqum","nqwu","nqzj","nral","nram","nrbs","nrce","nrct","nrcz","nrdc","nrfn","nrfv","nrib","nrkf","nrnr","nroo","nror","nrqb","nrry","nrsi","nrst","nrtw","nruc","nrud","nrui","nrve","nrvq","nrzb","nrzh","nsas","nscw","nsdm","nseu","nsfj","nshw","nsis","nsjr","nskn","nsoy","nspy","nsrf","nsvt","nswg","nsys","ntbh","ntea","ntfc","ntii","ntiq","ntjg","ntli","ntlk","ntne","ntop","ntqw","nttn","ntxs","ntxx","nuax","nugl","nugr","nujc","nukm","nuol","nuos","nuqd","nuta","nuuz","nuvx","nuya","nuzf","nvax","nvba","nvbh","nvda","nveh","nvlc","nvld","nvlk","nvnm","nvrj","nvrs","nvto","nvtt","nvtu","nvuz","nvwc","nvxp","nvyy","nvzy","nwcx","nwdd","nwez","nwge","nwgr","nwgw","nwho","nwjm","nwmd","nwne","nwqb","nwri","nwvv","nwyb","nxbb","nxbc","nxbl","nxci","nxdf","nxgs","nxhj","nxib","nxii","nxjq","nxpz","nxut","nxwp","nxyf","nybo","nyec","nyfv","nygx","nyhq","nyje","nymk","nyqb","nyqo","nyqy","nysz","nyvx","nywh","nywx","nyxx","nyyd","nyyu","nzcb","nzdp","nzhn","nzjz","nzlr","nzlw","nzmd","nzpn","nzqr","nzrt","nzsq","nzto","nzwq","nzya","oaba","oabf","oaeb","oagd","oajk","oakx","oamc","oamf","oasf","oavb","oawa","oaxu","oazd","obbb","obcu","obgr","objm","objp","obkf","oblq","obnw","obpb","obrw","obtd","obyw","ocbn","occg","oces","ocgw","ochc","ockd","ockw","oclr","ocme","ocnn","ocor","octm","ocua","ocuu","ocyw","odar","odas","odat","oddg","oddt","odfc","odfr","odix","odod","odsj","odva","odwf","odwl","odxs","odyj","oeab","oecl","oeen","oefs","oehe","oeib","oeiq","oejf","oejj","oeko","oeli","oene","oeod","oerq","oesj","oetv","oetw","oevi","oevq","oevu","oeya","oeyn","oeyo","oeyx","ofhg","ofhl","ofhm","ofjb","ofli","ofmi","ofms","ofpd","ofrh","ofro","ofte","oftr","oftu","ofvc","ofvg","ofyk","ofzy","ogab","ogaj","ogao","ogau","ogdi","ogdx","ogdz","ogeu","oghj","ogkw","ogll","ognj","ogug","ogvc","ogvs","ogws","ogyb","ogzs","ohay","ohdd","ohgo","ohiz","ohkr","ohks","ohky","ohls","ohni","ohph","ohpx","ohxo","oiak","oidi","oifl","oigc","oiik","oiod","oioh","oiol","oiom","oiqh","oiqq","oiqx","oiqy","oiry","oism","oiss","oiti","oivk","oixy","oiyj","ojae","ojbh","ojbj","ojip","ojiw","ojkr","ojku","ojme","ojmx","ojqe","ojrj","ojtf","ojuy","ojvt","ojwu","ojxx","ojyw","okbt","okgh","okhe","okhj","okhz","okic","okkl","okoh","okoo","oksa","okvn","okxs","okzf","okzs","oldd","olez","olgy","olih","olju","olko","olls","olsd","olus","olvf","omae","ombz","omdm","omee","omeo","omfd","omil","omll","omlz","omtx","omwk","omxa","omxj","omyc","omyn","omzd","onaq","onav","onhg","onjs","onjy","onmf","onrk","onzt","onzw","ooad","ooal","ooay","oodf","oonc","oonf","oonq","ooob","oooz","ooqm","ootb","ooth","oowj","ooyf","opbe","opcf","opdm","opdp","opff","opgs","opgx","opjs","opju","opkp","oplr","opon","opqu","optf","opwj","opzw","oqdu","oqec","oqge","oqhe","oqin","oqio","oqjc","oqjg","oqle","oqlt","oqlx","oqml","oqni","oqnx","oqop","oqsc","oqtn","oqwb","oqyf","orbw","orim","orjo","ormg","ormj","orns","orpp","orsr","ortf","orvp","orxz","orzd","oseb","osem","osjk","oskp","oslj","osoy","osqn","ossl","ossp","ostq","osun","osxl","oszi","otae","otbb","otct","otff","otfj","oths","otia","otij","otjq","otkp","otkw","otlb","otlv","otlz","otpf","otqa","otsj","otui","otyc","otyw","ouap","oucp","oucx","oucy","ougv","ounx","oupd","ouqh","ouqk","ouwe","ouxo","ouys","ovbb","ovbd","ovce","ovjd","ovjn","ovly","ovmx","ovnf","ovni","ovnj","ovnk","ovnu","ovol","ovot","ovqc","ovrd","ovrw","ovvn","ovvz","ovxt","ovzp","owae","owdt","owfg","owid","owme","ownu","owpf","owpv","owqu","owqz","owwa","owzc","owzw","oxam","oxbh","oxbz","oxdo","oxhq","oxhs","oxhy","oxib","oxiy","oxky","oxpx","oxrv","oxui","oxuu","oxvi","oxvx","oxzi","oyab","oycl","oyeu","oyfd","oyfg","oyfr","oyhr","oyja","oykx","oyll","oymc","oymf","oynb","oyoq","oypd","oytg","oyth","oytr","oyuy","oyvm","oywo","oyxc","oyxj","ozao","ozcm","ozdb","ozdh","ozfl","ozfx","ozhr","ozky","ozns","ozpj","ozqr","ozsc","ozsh","ozuk","ozup","ozuv","ozvr","ozxo","ozzb","ozzm","paad","paev","pafs","pagy","pahd","paix","pakc","pakk","pamr","panr","paos","papd","pauv","pawo","paxl","payd","pbbu","pbff","pbfp","pbge","pbiu","pblh","pbnh","pbnt","pbrv","pbva","pbxg","pbzc","pcco","pcei","pcfs","pcft","pcgi","pcgj","pchw","pcjq","pcqi","pcsc","pcsy","pcxu","pczb","pdbr","pdeo","pdpt","pdqq","pdso","pdxz","pebz","peci","pect","pedn","peef","pegf","pegk","peie","peix","pejp","peku","pelj","peln","pemc","pemn","penf","penh","peqg","perh","pevj","pexu","pfby","pfdi","pfdv","pfep","pfhg","pfic","pfih","pfmw","pfpb","pfvq","pfxj","pgfg","pggq","pgli","pgpd","pgrh","pgtk","pguv","pgwj","pgxi","pgym","pgyn","pgzi","phad","phdd","phjo","phjz","phkq","phmb","phmi","phqk","phqv","phrq","phrr","phrs","phsf","phss","phsu","phta","phvi","phyx","phzc","phzt","piab","piar","pias","pibj","pift","pifu","pigh","pihp","pija","pijw","pimu","pisj","pitc","pjah","pjdf","pjed","pjfn","pjgb","pjhm","pjhx","pjin","pjiq","pjir","pjmh","pjpc","pjpm","pjqe","pjsa","pjti","pjut","pjvm","pjvt","pjvz","pjwg","pjwv","pkcq","pkcr","pkif","pkiy","pkiz","pkkj","pkkq","pklj","pknx","pkoi","pkpj","pkrn","pkrp","pksr","pktw","pkuf","pkvf","pkxs","plbl","plca","plct","pldq","plgo","pljx","pljz","plob","plok","plpe","plpk","plpn","plrs","plue","plva","plya","plym","pmfa","pmhk","pmif","pmkx","pmmd","pmmp","pmpa","pmst","pmtm","pmvl","pnba","pngn","pnio","pnlr","pnnh","pnrg","pnsd","pnsk","pntl","pntp","pnzl","pnzx","pobq","podk","podv","poeh","poic","pokm","ponk","pooo","porh","potb","ppau","ppbc","ppbk","ppgq","ppjg","ppju","ppms","pppw","ppri","pprp","ppss","ppvc","ppze","pqct","pqea","pqfp","pqgd","pqhx","pqjz","pqkc","pqkq","pqnf","pqos","pqra","pqrs","pquk","pqvd","pqvh","pqvp","prai","prbd","prca","prgf","prgx","prkk","prkx","prla","prna","prok","prpl","prps","prrm","prsn","prst","prtf","prth","prvg","prvi","prwb","psbp","pseg","psgt","psig","psjc","psjz","psps","pspv","psqj","psrr","pssj","pssm","pstf","psuq","psur","psuy","psvc","pswf","psxn","psyo","pszv","ptan","ptbb","ptbc","ptbz","ptcp","ptiz","ptju","ptmy","ptnk","ptpg","pttj","ptvd","ptvg","ptwr","ptzp","pubh","pubq","puco","puiv","pulr","pulv","pumt","pura","purq","purv","pusb","puso","pusw","puyq","pvca","pvdj","pvez","pvhe","pvnb","pvqj","pvte","pvwl","pvyb","pvzb","pwad","pwdh","pwec","pwex","pwio","pwix","pwse","pwtd","pwvy","pwxf","pxei","pxfz","pxjy","pxod","pxpb","pxpt","pxtk","pxtm","pxun","pxvo","pxvp","pyac","pycy","pydq","pyge","pygk","pyhr","pyjv","pylk","pylo","pymv","pyns","pyzc","pzbo","pzdh","pzfq","pzjq","pznl","pzsc","pzsl","pzuo","pzvt","pzwp","pzym","pzyz","qaaf","qagu","qajq","qajx","qalr","qamj","qapf","qaum","qauq","qauy","qavu","qawa","qaxp","qayl","qazk","qbel","qbez","qbim","qbjs","qblc","qbln","qbma","qbmi","qbnx","qbqq","qbrc","qbss","qbts","qbuh","qbvk","qbvu","qcbp","qcbx","qceo","qcff","qchj","qcip","qcir","qcjj","qcmp","qcnh","qcwx","qdei","qdem","qdhw","qdlt","qdqv","qdrm","qdrq","qdtz","qdux","qdvu","qdyy","qdzk","qead","qecl","qegk","qegt","qeii","qeja","qejs","qeku","qend","qeuq","qevk","qeyd","qeyy","qfdp","qffo","qfgg","qfgl","qfie","qfif","qfix","qfjb","qflw","qflx","qfmg","qfnp","qfpc","qfpm","qfqm","qfrr","qfuj","qfvm","qfwy","qfxv","qfzq","qgbo","qgda","qgfl","qgju","qgnf","qgng","qgnr","qgpv","qgul","qgus","qgvl","qgvm","qgxl","qhao","qhba","qhcm","qhfb","qhfi","qhjq","qhmd","qhmx","qhnh","qhnr","qhoz","qhrg","qhvm","qhwk","qhyq","qhzt","qidc","qier","qijf","qijl","qilp","qipw","qipx","qisq","qitj","qiug","qixg","qixs","qiyd","qjbz","qjdp","qjdu","qjkt","qjmd","qjna","qjnv","qjoc","qjuj","qjvx","qjwz","qjyg","qkcl","qkdc","qkeg","qkgj","qkka","qkko","qkob","qkqe","qkqi","qkte","qkth","qktp","qkun","qkus","qkwi","qkyq","qkzd","qlbb","qlcc","qles","qlhj","qlhq","qlib","qlid","qliv","qlma","qlmi","qlpk","qlql","qltt","qmag","qmff","qmmc","qmpv","qmqf","qmqt","qmrg","qmtg","qmts","qmyu","qmzd","qnbt","qndu","qnil","qnjh","qnov","qnta","qnth","qntu","qnyh","qnyu","qnzt","qobq","qocv","qodd","qodi","qofv","qojq","qona","qonj","qooz","qora","qowr","qoye","qoyz","qpdh","qpdo","qpfw","qpgx","qphv","qpig","qpmn","qpni","qpop","qpqs","qpsh","qptl","qpup","qpxk","qpyy","qqay","qqbt","qqea","qqfa","qqgs","qqks","qqmb","qqnq","qqnt","qqog","qqok","qqpe","qqub","qqzr","qqzz","qrav","qrck","qrct","qrfj","qrhv","qrji","qrkp","qrnh","qrnw","qrpg","qrtl","qruf","qruw","qrvq","qrvs","qsbr","qsci","qsdf","qsdh","qsfa","qsfb","qsht","qsij","qslr","qsqf","qsvb","qsvx","qswz","qsxk","qsyv","qsze","qtap","qtcx","qtdm","qtdu","qtel","qtfl","qtgv","qthc","qtiu","qtlh","qtoi","qtte","qttm","qtuk","qtxg","qtxl","quau","qubr","qudc","queh","quer","qufd","quhh","quhi","quje","qujl","qule","qulk","qult","qunm","quoo","quoy","quqm","qurb","qurj","qury","qusz","quwg","quxg","quxj","qvca","qvdm","qveq","qvfc","qvfx","qvgp","qvhh","qvlj","qvlk","qvlq","qvmz","qvsz","qvtg","qvve","qvvq","qvvz","qvyh","qvzf","qwbd","qwbv","qwck","qwda","qwdr","qwfm","qwhw","qwoc","qwoq","qwqd","qwve","qxbh","qxbj","qxcs","qxcw","qxed","qxhg","qxjd","qxoa","qxot","qxpo","qxqr","qxrn","qxuy","qxve","qxxv","qyjr","qykv","qylk","qymn","qyqv","qyvk","qywk","qzaa","qzdk","qzgf","qzgx","qzik","qzmr","qznr","qzou","qzsf","qzsr","qztw","qzue","qzwa","qzwy","qzyr","qzzv","raat","rabi","racm","rago","rahr","rait","ramg","ranb","raom","rapz","rats","raue","raxm","rayp","rayq","rbal","rbaw","rbbk","rbcz","rbdr","rbeg","rbfx","rbgs","rbjh","rbjn","rbjw","rbqe","rbqx","rbtc","rbvv","rbxm","rbxp","rbxv","rbya","rbzy","rcae","rcay","rccf","rcfa","rcfd","rcin","rcjx","rcmv","rcoq","rcqx","rcts","rcvb","rcvn","rcwm","rcxs","rcyn","rczw","rczy","rdae","rdar","rdcf","rdcn","rdec","rdgd","rdhz","rdjm","rdjr","rdjt","rdjz","rdme","rdnz","rdoc","rdow","rdqg","rdrc","rdtz","rdue","rduf","rduh","rdva","rdzw","rdzy","reaz","rebb","rebl","redn","refp","regw","rems","reoc","repk","rete","retr","reyb","reyd","reyi","reyx","rfac","rffp","rfgo","rfkk","rfqk","rfql","rfsk","rftc","rftj","rftn","rfud","rfvv","rfxp","rfyc","rfzx","rgbb","rgdh","rgdo","rgfn","rgnc","rgof","rgpj","rgrn","rgsb","rgsn","rgso","rgtr","rgtv","rgui","rgva","rhac","rhbw","rhca","rhcf","rheh","rhel","rhjf","rhkb","rhkm","rhmc","rhmp","rhqv","rhqz","rhvu","rhxy","rhya","rhyk","ribw","ricp","rifn","rihq","rihz","rijr","rikf","rimj","rioz","ripl","rirx","riuj","rixp","riyq","rizr","rjao","rjap","rjgi","rjio","rjmg","rjrg","rjsp","rjva","rjwq","rjyi","rkcb","rkcc","rkcd","rkcv","rkhr","rkib","rklw","rkmo","rkmy","rkoz","rkph","rkqp","rktc","rkuh","rkve","rkxx","rlae","rlaq","rlfg","rlit","rllb","rlny","rlrl","rlsv","rltf","rlun","rluu","rlvn","rlyr","rmav","rmgf","rmiy","rmla","rmmj","rmpb","rmps","rmqi","rmqq","rmrn","rnbn","rncx","rndw","rnmv","rnof","rnpk","rnqe","rnra","rnri","rnrp","rnst","rntr","rntv","rnuf","rnuo","rnvb","rnzo","rofn","rohk","rohx","roix","ropc","rosk","rouo","rovo","roxx","rpbo","rpdq","rpmf","rpnq","rpoj","rpor","rpsc","rpsw","rpsx","rpta","rpux","rpva","rpxt","rpzq","rqav","rqcg","rqlx","rqlz","rqok","rqqb","rqvb","rqwv","rqyp","rqze","rrbq","rrex","rrib","rrih","rrkr","rrnr","rrok","rrpr","rrst","rrvh","rrzw","rsai","rsef","rsfj","rsgc","rsgn","rshl","rsjs","rskr","rspa","rssr","rsuq","rsuv","rsvj","rtcs","rtdj","rtee","rteh","rtfg","rtgg","rthu","rtii","rtil","rtkj","rttg","rtwo","rtws","rtwv","rtyx","rubd","rudu","rufx","ruhe","ruht","rulg","rumg","rumq","ruqw","rurj","rute","ruty","ruue","ruvt","ruwg","rvac","rvcq","rvem","rveu","rvif","rvle","rvln","rvlu","rvod","rvpo","rvpy","rvrp","rvsj","rvsn","rvti","rvtk","rvxo","rwdz","rwez","rwft","rwik","rwkk","rwlk","rwoo","rwpd","rwtc","rwuy","rwvx","rwwk","rwwn","rwxu","rwym","rxbl","rxbq","rxbr","rxct","rxez","rxfh","rxga","rxgm","rxpg","rxqm","rxqq","rxta","rxte","rxul","rxvu","rxxp","rxzr","ryad","rybw","ryci","rycl","rydw","ryes","ryhv","ryio","ryiv","ryjv","rykk","rykw","rylr","rymh","rypn","ryqh","ryqx","ryth","rytu","ryvv","rywl","rywo","rywx","ryyl","ryyq","ryzz","rzap","rzaq","rzba","rzbp","rzct","rzdi","rzif","rzii","rzij","rzip","rzit","rzjd","rzkl","rzoy","rzqo","rzub","rzwf","rzwi","rzxo","rzzg","saev","sakw","sanl","sapk","saqw","saqx","saxl","sayl","sbah","sbck","sbco","sbfs","sbiw","sbiz","sbjm","sbor","sbpy","sbul","sbux","sbvs","sbzj","sbzl","scah","scai","scas","scem","scfx","scgl","schr","schy","scjr","scjz","scle","scvf","scvn","scwd","scxb","sdas","sddw","sdec","sdkd","sdng","sdno","sdnr","sdnt","sdoh","sdpu","sdrq","sdze","seda","sedh","sedv","segn","sehg","sehi","seib","seix","sekk","semr","seno","sesh","sfax","sfdw","sffc","sffn","sfhz","sfkh","sfkp","sfmf","sfqj","sfql","sfsl","sfsm","sfue","sfur","sfuy","sfvx","sfwl","sfxr","sgab","sgbe","sgjd","sgmj","sgml","sgmz","sgnv","sgpc","sgpo","sgrz","sgtb","sguk","sgvg","sgvp","sgwr","sgws","sgwt","sgyz","sgzv","shdu","sheh","shia","shjb","shjc","shln","shmq","shmz","shon","shqn","shrx","shsv","shta","shum","shwu","shxi","shzv","sidk","sidl","sidx","sifl","sihf","siiq","sinq","siqj","siql","siru","sivk","siyk","siyt","sizp","sjcy","sjds","sjea","sjhg","sjjy","sjlt","sjmd","sjns","sjql","sjtj","sjui","sjuo","sjuv","sjwt","sjxu","sjye","skbh","skcd","skdo","skdx","sket","skfg","skjj","skpf","sktd","skus","skvw","skwm","skzp","slal","slan","slcd","sldd","slgf","slio","slox","slqn","slqo","slqs","slrr","slsr","slxf","slym","slyr","smbp","smdv","smel","smfu","smhm","smid","smin","smlh","smlz","smob","smoq","smpn","smpv","smqa","smtg","smwu","smzl","sncm","snfi","snhl","snkc","snlx","snnl","snnm","snny","snqq","snqs","snsm","snsv","snue","snwi","snwm","snyo","snyu","snyz","soag","soas","sobb","soet","sohq","soij","sojh","sonw","sopi","soqs","sork","sowj","soxc","sozy","spap","spbm","spdc","spdw","spev","spfr","splj","spmz","spra","spsn","spsz","sptp","spuu","spvn","sqax","sqay","sqdg","sqeh","sqjx","sqou","sqqq","sqrd","sqsm","sqzf","srak","sram","srbh","srcw","srda","srhv","srjb","srlo","srls","sros","srpe","srxc","sryt","srzu","sscg","sscu","ssfn","ssfr","ssgj","ssmh","ssmt","ssol","ssse","sstj","ssuj","sszm","stau","stbv","stfh","sthq","sthx","stjz","stwb","stxn","subi","sudx","sudz","sufu","sugq","suiw","sujf","sumi","summ","sumo","sunx","supq","suvq","suxo","svas","svbk","svcd","svgr","svie","sviu","svjm","svjx","svna","svpq","svqc","svrt","svtf","svwb","swbd","swqv","swrx","swsw","swte","swvf","swwf","swxg","sxak","sxam","sxbg","sxdr","sxiw","sxkp","sxmq","sxqb","sxun","sxuo","sxxz","sxzf","sxzr","syat","sybh","syct","syfq","syfv","sygl","syid","syir","synq","syoj","syou","sypc","syqf","syqp","syra","syrf","syue","sywg","syxy","syzu","szam","szdc","szee","szgb","szlv","szmc","szod","szoj","szqj","tage","tagy","taie","taiq","tajg","tako","tamk","tapj","tapv","tast","tasw","tatt","tayw","tayx","tazt","tbcv","tbhg","tbjc","tbjm","tbkf","tblh","tbnn","tbqq","tbsa","tbtj","tbtr","tbwj","tbwy","tbxa","tbxp","tbyc","tcaf","tcao","tcbj","tcbn","tcbo","tcen","tcgg","tchc","tchv","tcji","tckc","tcms","tcrl","tcsc","tcvi","tcwp","tcyn","tcyp","tdat","tdbp","tdde","tdfj","tdfw","tdiv","tdji","tdkk","tdlc","tdld","tdlr","tdmt","tdod","tdoy","tdsf","tdvp","tdvt","tdyf","teab","tebc","tecc","tefw","teid","tejo","tejs","temf","tepd","teqq","teqy","teue","teuu","tewc","tewu","tewz","texb","tfbn","tfcy","tfew","tffu","tfmo","tfpb","tfpm","tfqy","tfrw","tfry","tfzi","tfzo","tgfo","tgjo","tgkv","tgqm","tgss","tguz","tgws","tgxb","tgxv","tgyg","tgyv","tgzy","thch","thdb","thdu","thet","thfn","thgy","thim","thkh","thmg","thpx","thsv","thtk","thtw","thua","thvt","thwy","tiau","ticw","tiez","tifa","tigg","tiiz","tije","tikz","tipc","tirm","tirs","tirw","tisu","tith","tity","tiuk","tivv","tiwy","tixm","tjag","tjbs","tjbt","tjcp","tjey","tjhb","tjiu","tjjh","tjjl","tjll","tjly","tjqx","tjtp","tjuh","tkbc","tkic","tkid","tkkx","tkli","tklp","tkpe","tkqp","tkrf","tkrl","tkrw","tkyx","tkzg","tlcz","tldf","tldm","tlgh","tlke","tlmi","tlnr","tlqn","tlry","tluc","tlvn","tmco","tmee","tmfh","tmjt","tmkm","tmkn","tmlk","tmmi","tmmr","tmno","tmqh","tmqm","tmro","tmry","tmsh","tmvn","tmxi","tmxj","tmxx","tmyj","tnad","tncj","tndi","tnmb","tnpn","tnrw","tnxl","tnyq","toes","tolr","toma","topg","torr","toul","toyh","toys","tpan","tpau","tpbe","tpdx","tpes","tpfr","tpgp","tphc","tphg","tphv","tpip","tpkp","tpmp","tpow","tpoy","tppm","tppo","tpps","tpqp","tpsy","tptf","tpto","tpuc","tpza","tpzk","tqbf","tqbq","tqbz","tqcf","tqdi","tqhw","tqlo","tqlv","tqrg","tqrk","tqrz","tqtz","tquw","tqyd","tqza","trai","trdp","trge","trgk","trjo","trjs","trma","trmh","trmn","troo","trry","trsw","trtp","truj","truq","trux","truz","trvz","trwo","trxg","trxw","tscy","tseg","tsfa","tsfh","tsfq","tsif","tsjj","tskb","tsmx","tsno","tsom","tsor","tsql","tsuj","tsum","tsux","tsvz","tswu","tswz","ttbv","ttef","tthw","ttji","ttkn","ttlj","ttnv","ttoe","ttor","ttsi","ttsk","ttsp","ttya","ttyc","ttyr","tubo","tubp","tufc","tufk","tufp","tugp","tugu","tuhx","tuic","tujl","tukz","tunb","tunz","tuph","tuvf","tuvm","tuvn","tvap","tvcr","tver","tvez","tvhb","tvko","tvng","tvqd","tvqp","tvub","tvud","tvuu","tvwq","tvxc","tvxn","tvxt","tvyf","twbj","twbq","twcr","twou","twpc","twqs","twsj","twso","twwv","twwy","twxi","txbe","txfk","txhc","txir","txjv","txkz","txlg","txnl","txnn","txpd","txqn","txrj","txuv","txvw","tyau","tyct","tydl","tyeh","tyfu","tygb","tyjy","tykd","tync","tyqq","tyrv","tyui","tyur","tyvm","tyvs","tyxh","tyyh","tzae","tzax","tzby","tzcn","tzcs","tzef","tzgi","tzgv","tzhb","tzhd","tzii","tzjj","tzjl","tzkc","tzoa","tzou","tzqp","tzrh","tztu","tztw","tzvg","tzvo","uabi","uabn","uafd","uagi","uaha","uahd","uahk","uajr","uals","uaml","uamv","uanj","uaog","uaqb","uask","uawv","uaxo","uazb","ubab","ubaz","ubcu","ubhe","ubhr","ubib","ubis","ubkb","ubkk","ubkw","ubno","ubsq","ubue","ubxi","ubxu","ucaz","ucbg","uchf","ucjc","ucjn","uclg","ucnx","ucok","ucov","ucqs","ucrw","ucwv","ucxx","ucym","ucyu","udad","udbo","udbs","udcm","uddz","uden","udev","udff","udfo","udfx","udgp","udit","udos","udqd","udqh","udtb","udtz","udud","udvq","udwn","udwt","uebp","uedz","uefp","ueia","uejf","uejj","uepy","uetf","ueuj","uexr","ufaf","ufay","ufdr","uffq","uffv","ufhg","ufkf","ufls","uflz","ufnr","ufpg","ufpw","ufsr","ufsz","ufur","ufvq","ufww","ufyn","ufza","ugar","ugav","ugco","uggs","ughh","ugmq","ugnp","ugnz","ugot","ugtf","uguc","ugxd","ugyu","ugzo","uhbs","uhbw","uhcs","uhcv","uhkj","uhkq","uhpg","uhpi","uhql","uhqn","uhsf","uhvj","uiaq","uics","uidc","uigf","uili","uilz","uind","uitr","uiux","uivs","uizj","ujar","ujcb","ujcv","ujcz","ujez","ujfm","ujkq","ujky","ujod","ujur","ujut","ujwa","ujwh","ujxd","ujyn","ukaa","ukah","ukas","ukfr","ukfv","ukgm","ukhy","ukjc","uknw","ukpg","ukre","ukwk","ukxm","ukxv","ukzu","ulal","ulbb","ulbc","ulbr","uldj","ulha","ulhq","ulmm","ulok","ulom","ulpg","ulvx","ulwh","ulws","umbf","umbz","umem","umfi","umga","umgh","umjv","umlj","umlk","umlr","ummu","umom","umxm","unab","unac","unar","uncu","unev","unfk","unfn","unmz","unne","unox","unph","unrl","unsd","unss","unvg","unvt","unxr","uoam","uodf","uoho","uokq","uomf","uomp","uood","uopm","uoql","uotg","upar","upep","upfi","upke","upkt","upln","uppl","upqi","upry","upst","uptt","upxf","upxs","upzp","uqej","uqel","uqgr","uqgz","uqib","uqik","uqjf","uqkh","uqmc","uqok","uqot","uqvf","uqvj","uqxn","uqyn","uqyu","uqyw","urak","urce","urhb","urnh","uroy","uroz","urpx","ursd","ursn","urtl","uruf","urui","uruj","urwj","usbr","usey","usgb","usgp","usgz","usjo","uslm","uslu","usmb","usmp","usog","usvm","usyd","uszz","utdu","utef","utie","utjg","utjp","utlx","utow","utpc","utsb","utse","utsh","utui","utvj","utwh","utzc","utzv","uuak","uucz","uudl","uuhj","uuia","uujs","uumr","uupi","uupm","uuti","uutl","uuur","uuzi","uuzp","uvaj","uvbf","uvcx","uvdj","uveq","uvet","uvfg","uvfx","uvhu","uvjp","uvok","uvpn","uvsl","uvsr","uvuv","uvvg","uvwf","uvww","uwbx","uwcn","uwdg","uwdv","uwff","uwhu","uwjq","uwkl","uwmr","uwny","uwsj","uwte","uwul","uwuz","uwvh","uwvv","uwxn","uxce","uxdo","uxel","uxez","uxft","uxio","uxir","uxjs","uxjz","uxmo","uxoj","uxrc","uxsv","uxyo","uxyt","uyah","uyav","uyaz","uycg","uydt","uyge","uygj","uyhf","uyia","uyoj","uyrm","uyuh","uyxf","uyyi","uzac","uzap","uzbn","uzcp","uzcr","uzdz","uzhg","uzik","uzlh","uzrb","uzvr","uzxm","uzxn","uzyv","vabs","vagk","vagn","vajl","varo","vars","vauo","vawa","vbam","vban","vbay","vbdl","vbdu","vbfq","vbki","vbps","vbtz","vbvv","vbwd","vbwn","vbxs","vbxy","vbzn","vcbg","vcbj","vcfs","vcfw","vcha","vcju","vclh","vcmx","vcnd","vcob","vcps","vcsl","vctd","vcvf","vcwm","vcyj","vcyv","vdag","vdbl","vdcc","vddp","vdkg","vdlq","vdnc","vdsg","vdsv","vdul","vdvl","vdvz","vdxe","vdze","vecd","vedb","vego","vegr","vegv","vekk","velh","veoa","veqq","verf","veuv","vewq","vexj","veye","vfag","vfdu","vffb","vffl","vfgt","vfiz","vfnd","vfob","vfpu","vfqp","vfrg","vfrh","vfts","vfvj","vfyc","vgbq","vgkq","vgkr","vgnl","vgqp","vgsv","vgsw","vgvx","vgzb","vgzc","vhbt","vhbv","vhdr","vhfi","vhfs","vhgn","vhkp","vhtc","vhwe","vhyn","vibo","vidm","vije","vijq","vind","viok","vioo","vitf","viwu","vixh","vixt","viya","vjbg","vjdc","vjdn","vjdw","vjdz","vjhg","vjhm","vjiu","vjjo","vjkc","vjmk","vjng","vjou","vjoy","vjpe","vjqn","vjrc","vjrr","vjsi","vjsx","vjxt","vkaa","vkcc","vkdn","vkft","vkid","vkmm","vknq","vkqx","vksd","vkxv","vkyj","vlcy","vlda","vleo","vlid","vlku","vllu","vlmi","vlmj","vltc","vltn","vltq","vlwd","vlyj","vlzo","vlzp","vmas","vmfb","vmfr","vmhr","vmhu","vmlh","vmly","vmnb","vmnq","vmra","vmtz","vmui","vmyv","vmyw","vnai","vnas","vnfn","vnhc","vnhk","vnhp","vnke","vnmu","vnqb","vnqo","vntk","vnwx","vnxa","vnyk","vobo","voby","vocc","voco","vocu","vofi","vofo","voga","voif","vojp","vokw","vomt","vopn","voqt","vory","votf","voua","vouc","vowg","vozx","vpan","vpcw","vpev","vpkg","vple","vpnz","vpoo","vpqw","vprh","vptm","vpvq","vpwg","vpzp","vqcj","vqeb","vqfc","vqgt","vqkk","vqmq","vqnv","vqoy","vqpm","vqpp","vqug","vqva","vqvw","vqww","vrba","vrbw","vrev","vrfu","vrfx","vrhy","vrna","vror","vrpw","vrrq","vrrs","vrvb","vrxa","vsca","vsel","vsfn","vsfr","vshi","vsjf","vsji","vsli","vslr","vspa","vsql","vsrr","vsrz","vssm","vsvl","vsvw","vtcl","vtcv","vtcy","vtet","vtja","vtjh","vtxi","vuab","vuat","vubq","vuco","vuew","vufr","vuik","vujq","vula","vumv","vuqs","vurr","vurt","vuwf","vuwr","vuwu","vuyh","vuyl","vvgu","vvhc","vvhq","vvik","vvkz","vvme","vvoz","vvqh","vvul","vvur","vvzt","vwah","vwbt","vwcm","vwdl","vwgr","vwgt","vwnj","vwoe","vwpy","vwqx","vwrs","vwuo","vxar","vxbg","vxee","vxeu","vxfr","vxfv","vxgx","vxhk","vxhm","vxkm","vxmn","vxpx","vxra","vxtb","vxwp","vxxi","vxyb","vxza","vybs","vycm","vyda","vygl","vyhl","vyij","vyjh","vyjr","vyof","vyon","vysg","vytm","vytu","vyun","vyzn","vzag","vzbi","vzbv","vzcf","vzfe","vzgv","vztz","vzuq","vzyg","vzza","wadc","wadw","wafs","wagr","waht","waju","waqk","wara","wati","watz","waum","wawe","waxe","waxq","waxs","wazo","wbaa","wbaj","wbav","wbay","wbba","wbca","wbcm","wbcr","wbcs","wbeq","wbfw","wbjb","wbjq","wbkj","wbmc","wbmj","wbow","wbvr","wcak","wcau","wcbg","wccl","wcea","wchf","wcja","wckj","wckm","wclv","wcnj","wcqr","wcsc","wcsp","wcxn","wcym","wcyp","wdan","wdbj","wdct","wdcu","wdda","wddt","wddy","wdgv","wdjx","wdor","wdph","wdpr","wdqz","wdux","wdzp","weco","wefz","weiu","wekp","welg","welj","wemm","wemy","weph","weqg","wesd","wexn","weyy","wffj","wfhf","wfhl","wfjz","wfna","wfne","wfnm","wfqp","wfqw","wfrg","wfrn","wfro","wfrr","wfsn","wfvg","wgci","wgdk","wger","wghd","wgja","wgjf","wgkv","wgky","wglw","wgng","wgte","wgvf","wgvr","wgwt","wgxl","wgyk","wgyp","wgzh","whep","whex","whff","whmh","whog","whse","whsf","whtq","whuk","whvf","whvk","whvp","whwi","whxi","whyl","wich","wicj","wicp","widd","widr","wiey","wiga","wihw","wiil","wiiy","wijs","wimg","wiru","wisi","wisk","witr","wiyb","wjbj","wjcp","wjdb","wjga","wjjc","wjml","wjrp","wjsq","wjti","wjtp","wjtu","wjuo","wjvi","wjwi","wjwz","wjyu","wjzm","wkbt","wkdv","wken","wkhp","wkii","wklw","wknd","wkpu","wkss","wkud","wkvf","wkwk","wkwm","wkyt","wkzg","wkzx","wlaw","wlcx","wleb","wlha","wlhb","wlhy","wljj","wlkz","wlnc","wlno","wlnr","wloz","wlqd","wlvq","wlwb","wlxl","wlya","wlyl","wlzz","wmbj","wmdi","wmew","wmfz","wmkj","wmmc","wmrd","wmru","wmsv","wmta","wmvd","wmvo","wmzx","wnfr","wngi","wngx","wnie","wnls","wnnp","wnny","wnqm","wntw","wnvd","wnwz","wnxe","wnyj","woei","wofe","wogk","woie","woio","woma","wooa","woor","woqt","wour","wpci","wpdl","wpgd","wpht","wphv","wpji","wpko","wplw","wpmy","wpnd","wpqk","wpqq","wpug","wpwc","wpxr","wqaj","wqav","wqba","wqcv","wqon","wqsi","wqyu","wqzh","wral","wrbe","wrjr","wrpr","wrqj","wrxq","wryi","wryw","wscs","wset","wsfc","wsfq","wsgx","wshh","wshr","wsks","wsky","wssb","wssk","wsst","wssx","wsuf","wsul","wsuv","wswb","wtdo","wtey","wtgq","wtiy","wtmb","wtmf","wtmt","wtrp","wttg","wuac","wubn","wubx","wueh","wuie","wujk","wukr","wuku","wukz","wulv","wumw","wuny","wusx","wuwh","wuzk","wuzp","wvay","wvek","wvid","wvij","wviu","wvjv","wvlh","wvmj","wvmp","wvny","wvpx","wvqv","wvtz","wvxp","wwao","wwas","wwjs","wwkm","wwku","wwkz","wwlp","wwlx","wwmf","wwnf","wwnl","wwsu","wwxm","wwxu","wwzs","wxbf","wxes","wxfc","wxfw","wxfx","wxgt","wxir","wxky","wxlq","wxnj","wxpg","wxpk","wxpl","wxri","wxtu","wxud","wxvv","wxxc","wxyb","wxyw","wybj","wybm","wydk","wygd","wyhz","wylz","wymr","wypv","wyul","wyvj","wzau","wzbw","wzcb","wzdw","wzep","wzge","wzhk","wzjw","wzof","wzpf","wztm","wztr","wzuv","wzzo","xadu","xafc","xagt","xahw","xakd","xakj","xarh","xaui","xbaj","xbcr","xber","xbkt","xblq","xbmm","xbnp","xboe","xbof","xbpg","xbsm","xbsw","xbtc","xbug","xbum","xbzg","xcba","xccg","xcck","xcgj","xchn","xckg","xctm","xcuo","xcvv","xcxl","xcyc","xczr","xdav","xdaz","xddh","xddr","xdeq","xdjy","xdms","xdnk","xdno","xdpk","xdpr","xdqo","xdum","xdxn","xdzd","xech","xecr","xegr","xegt","xehp","xehy","xejc","xely","xemt","xeoc","xepr","xerh","xerl","xesf","xeub","xewt","xeyn","xezz","xffj","xfgm","xfnh","xfpn","xfvx","xfxo","xgat","xgcf","xgcy","xgez","xgfm","xgia","xgiu","xgjp","xgkg","xgoa","xgqe","xgqp","xgrk","xgse","xgsq","xguy","xgvp","xgye","xgyj","xhaa","xhbh","xhda","xhdm","xheg","xhgk","xhir","xhjd","xhry","xhsa","xhvf","xhza","xibb","xico","xicy","xifd","xifp","xihe","xiip","xiku","xiny","xiok","xipd","xite","xitg","xith","xiuc","xivt","xjbj","xjdu","xjdv","xjdy","xjhg","xjii","xjjn","xjkh","xjko","xjni","xjqa","xjtc","xjvb","xjvn","xjvt","xjvu","xkcm","xkdn","xkff","xkic","xkmq","xkmw","xkmx","xkot","xkqa","xkqe","xkqo","xksy","xkus","xkyg","xkzi","xkzu","xlhp","xljf","xlkq","xlmc","xlmt","xlnl","xlnn","xlps","xlru","xluc","xlvy","xlyu","xmad","xmdw","xmgi","xmgq","xmjo","xmlu","xmmt","xmmz","xmnq","xmor","xmow","xmsb","xmxq","xmze","xnao","xnco","xnfk","xngb","xnhu","xnid","xnik","xnjo","xnow","xnoz","xnpb","xnqn","xnqs","xnqy","xnrq","xnso","xnui","xnvg","xnwl","xnwp","xnwt","xnwz","xnyq","xoac","xoaf","xocd","xocu","xodf","xoel","xoep","xoev","xofo","xoia","xois","xolz","xopx","xopz","xoqp","xoty","xovq","xoyc","xpaz","xpbp","xpbq","xpei","xpfo","xpgk","xppu","xpru","xptr","xpvs","xpxy","xpyr","xpzm","xqfw","xqhe","xqmv","xqnv","xqok","xqol","xqri","xqrm","xqrx","xqrz","xqsg","xqsh","xqud","xquf","xqun","xquv","xqvc","xrcq","xrcy","xrei","xrfk","xrft","xrgg","xriq","xrkx","xrlp","xrma","xrmw","xrnh","xroo","xrsc","xrse","xrut","xrwz","xrxm","xrxn","xryn","xsda","xsdm","xsdo","xsef","xsgd","xsgq","xsjz","xsly","xsox","xspk","xsru","xsrv","xsup","xsxv","xtbm","xtct","xtem","xtfi","xtia","xtli","xtlk","xtoi","xtro","xtrq","xtsw","xttn","xttz","xtvp","xtyg","xtzr","xtzs","xuah","xufd","xuge","xuje","xuju","xuko","xuku","xuly","xumc","xuqz","xurn","xusj","xuxy","xuyb","xuyn","xuzq","xvbj","xvbz","xvez","xvgk","xvhk","xvip","xvjc","xvkf","xvki","xvlm","xvmu","xvou","xvqe","xvso","xvvs","xvyu","xvzw","xwco","xwdg","xwfd","xwic","xwjc","xwjh","xwmw","xwnh","xwnu","xwnz","xwog","xwot","xwox","xwpm","xwrg","xwri","xwsx","xwtk","xwue","xwvx","xwxq","xwyp","xxaj","xxas","xxce","xxhn","xxhx","xxif","xxke","xxla","xxqi","xxuc","xxuv","xxvo","xxzu","xyaa","xyap","xycl","xycr","xyge","xyjc","xylh","xylp","xyoq","xypa","xyrl","xyrv","xysg","xysn","xywf","xyyk","xyzr","xzap","xzhe","xzkd","xzlv","xzlw","xzmj","xzpq","xzse","xztd","xztr","xzud","xzui","xzum","xzup","xzuq","xzzg","yabf","yafl","yahd","yahp","yahx","yaii","yais","yajd","yakq","yakx","yaof","yaqc","yaqr","yaqs","yaua","yauw","yavr","yavz","yawf","yazc","ybbu","ybcs","ybdp","ybhf","ybhq","ybni","ybom","ybox","ybwe","ybwg","ybxe","ybxn","ybyg","ybzj","ycca","yccs","ycdc","ycel","ycgi","ycjx","yckl","yclp","ycnc","ycnk","ycoz","ycqm","ycqq","ycuh","ycya","ycyr","ydec","ydei","ydew","ydhn","ydhp","ydjg","ydnu","ydpf","ydpy","ydqg","ydrh","ydto","yduc","ydui","ydul","yduy","ydvm","yedm","yegb","yeha","yekh","yenm","yesv","yeux","yfaz","yffx","yfgr","yfhy","yfju","yfpy","yfqg","yfql","yfsi","yfst","yfta","yfwb","yfyv","yfzi","yggn","ygim","yglq","ygny","ygoa","ygpk","ygrd","ygtr","ygus","ygwk","ygym","yhbl","yhcs","yhew","yhkr","yhnu","yhpl","yhre","yhry","yhsy","yhue","yhxt","yhxx","yhze","yhzp","yiah","yiat","yiaz","yibg","yibs","yieh","yifi","yigj","yiqq","yirh","yivl","yiwc","yixz","yizn","yizv","yjau","yjdf","yjjq","yjjy","yjol","yjpn","yjpx","yjri","yjud","yjuz","yjwv","yjye","ykan","ykde","ykdu","ykel","ykhc","yknk","ykri","ykrp","yksh","ykui","ykvr","ykxl","ykye","ykzl","ykzu","ylfz","ylgc","ylhb","ylih","yliu","yliw","ylki","ylks","ylpf","ylqk","ylqs","yltp","ylwj","ylzv","ymcg","ymco","ymdr","ymga","ymii","ymjj","ymlp","ymlz","ymma","ymne","ymoo","ymoz","ymqp","ymrg","ymso","ymtn","ymvf","ynba","ynco","yndz","ynei","ynfs","ynhg","ynig","ynis","ynjn","ynkr","ynqh","ynqr","ynxn","ynxs","yoaq","yoct","yoiw","yokt","yone","yosm","youe","yowx","ypak","ypdz","ypgo","yprm","ypro","ypue","yqax","yqcl","yqdf","yqea","yqfu","yqfy","yqjo","yqku","yqlh","yqnn","yqon","yqpx","yqqx","yqsx","yqub","yqup","yqvg","yqvu","yqyn","yqyp","yrdh","yrfa","yrfq","yrfu","yrhl","yrkq","yrqp","yrtd","yrte","yrtu","yrwm","yrwy","yrxo","yscr","ysdq","ysel","ysgb","yshj","ysji","ysjv","yskj","ysph","ysrx","ysut","ysyb","ysyh","yszg","yszr","ytam","ytap","ytbe","ytbk","ytbn","ytda","ytdj","ytdr","ytdy","yteu","ytfx","ytjc","ytjt","ytlm","ytnd","ytpu","ytqb","ytrg","ytzt","yufj","yujs","yuka","yuop","yupc","yuqj","yuud","yuwf","yuxj","yvab","yvbi","yvee","yveo","yvhm","yvhu","yvim","yvpg","yvpu","yvse","yvso","yvus","yvxa","yvxb","yvxg","yvxl","yvyz","ywaj","ywam","ywdr","ywea","ywfc","ywfj","ywkj","ywly","ywnq","ywnx","ywny","ywpd","ywpf","ywpk","ywpq","ywqc","ywqo","ywtu","ywwc","ywxr","ywyd","ywyw","yxaz","yxdo","yxki","yxlw","yxmu","yxob","yxom","yxoq","yxpn","yxvk","yxvn","yxvz","yxws","yxxi","yxzb","yxzd","yyfk","yygr","yyij","yylj","yynf","yyoe","yyqr","yyqs","yyrb","yyss","yyue","yyxb","yyyd","yyzo","yyzu","yzgd","yzja","yzof","yzpa","yzqg","yzrd","yzrt","yzru","yzvs","yzwn","yzwu","yzyj","yzzz","zagv","zaif","zaim","zaki","zale","zalj","zamm","zaoy","zapd","zbaj","zbba","zbbv","zbci","zbdk","zbjs","zbms","zbmy","zbnq","zbrw","zbsk","zbue","zbwu","zbxb","zbzg","zcfd","zchv","zclx","zcmg","zcnh","zcny","zcpf","zcrw","zcsm","zcsz","zcuq","zcvb","zcxk","zdab","zdaq","zdcl","zddi","zdhv","zdkd","zdlg","zdmd","zdph","zdtn","zduq","zdyb","zeeu","zefn","zehr","zehz","zeie","zeju","zeph","zern","zerx","zesr","zeuy","zevc","zezr","zfab","zfci","zfdj","zffy","zfhv","zfiv","zfjf","zfjg","zfkq","zflu","zfmd","zfqd","zfri","zfuq","zfvi","zfzf","zgca","zgjg","zgna","zgnm","zgpk","zgpo","zgqz","zguh","zgur","zgvr","zgzj","zhak","zham","zhbm","zhed","zhfq","zhmi","zhpy","zhso","zhsr","zhxv","zhxx","zhyd","ziar","zibm","zicj","zify","zihf","zija","zikb","zikl","zilh","zimw","zinb","zins","zioi","ziow","zipf","ziqe","ziqn","zise","zism","ziyr","zjeg","zjgk","zjhd","zjin","zjjm","zjka","zjkh","zjmy","zjnj","zjpe","zjqv","zjry","zjts","zjuh","zjya","zkas","zkfj","zkfq","zkhc","zkjl","zkml","zkmr","zkmw","zknq","zknt","zkqi","zkta","zkul","zkvp","zkyw","zkzh","zkzr","zlhc","zlhl","zlko","zlqx","zltv","zlul","zlwd","zlwe","zlxs","zmad","zmaq","zmbd","zmea","zmkf","zmkn","zmko","zmqu","zmrm","zmuf","zmvb","zmvf","zmvs","zmwr","zmxa","zmzr","znbr","znbw","znbz","zncu","zndf","znem","znij","zniv","znjy","znls","znlz","znoz","znqi","znrp","znsp","znvq","znyx","zoel","zoen","zohx","zoim","zokg","zoky","zomv","zonn","zora","zosu","zoth","zous","zovc","zovx","zowv","zpaw","zpbc","zpbj","zpdt","zpge","zplc","zpmh","zpnt","zppm","zprd","zpti","zpwg","zpwz","zqal","zqch","zqco","zqfp","zqjz","zqml","zqof","zqqc","zqsb","zqsz","zqwg","zqxn","zqzx","zqzy","zrbp","zrde","zree","zrfa","zrgb","zrgl","zrgt","zrnh","zrok","zroq","zrqh","zrqo","zryq","zryy","zsdv","zsem","zsen","zshp","zsjm","zskd","zsmk","zsml","zsrv","zsuc","zsuk","zsvk","zsww","zsxd","zszs","zszu","ztay","ztbv","ztcb","ztgl","ztgo","ztnd","ztop","ztot","ztps","zttk","ztuh","ztuw","ztuz","ztvg","ztvz","ztxc","ztxs","ztzy","zucr","zudr","zulj","zumv","zunt","zuqi","zurf","zusi","zutl","zutp","zuvu","zuwp","zuzl","zvao","zvcd","zvcr","zvdm","zvfh","zvgm","zvhg","zvhr","zvil","zvmj","zvrk","zvtf","zvui","zvxa","zvxb","zvxt","zvxx","zvyf","zvzq","zvzx","zwbr","zwbx","zwel","zwfh","zwhx","zwin","zwjh","zwkm","zwmn","zwoc","zwpq","zwtj","zwwk","zwxb","zxhq","zxiw","zxkr","zxpi","zxtj","zxzi","zybb","zybf","zydg","zyem","zyep","zyjp","zykh","zylv","zyoh","zyph","zyqf","zyuy","zzah","zzcv","zzfe","zzft","zzgw","zzkr","zzmc","zzmh","zzoh","zzqz","zzrz","zzxr","zzzw"];
export default roomNames;
================================================
FILE: backend/src/rooms/room-sagas.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { delay, takeEvery, takeLatest } from 'redux-saga';
import { call, cancel, cancelled, put, fork, spawn, select } from 'redux-saga/effects';
import uuid from 'uuid';
import { logger } from '../logger';
import { messageHandler } from '../messages';
import { serverActions, serverConstants } from '../server';
import { sphereConstants } from '../spheres';
import {
makeWsReplyMessage,
makeWsBroadcastMessage,
sendWsMessageWithLogger,
sendWsErrorWithLogger
} from '../utils/websocket-utils';
import config from '../config';
import { sagaUtils } from '../utils';
import messageConstants from '../messages/message-constants';
import roomDataActions from './room-data-actions';
import roomDataConstants from './room-data-constants';
import roomNames from './room-names';
import roomStateActions from './room-state-actions';
import roomStateConstants from './room-state-constants';
import roomStateUtils from './room-state-utils';
const incomingMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const outgoingMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;
/*******************************************************************************
* START SETTING UP THE ROOM
*******************************************************************************/
const startRoomSetup = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.STARTING_ROOM_SETUP
);
if( correctState !== true ) {
logger.warn( `not doing startRoomSetup, room ${action.roomName} not in state STARTING_ROOM_SETUP` );
return;
}
// tell the state machine this worked OK
yield put( roomStateActions.notifyStartRoomSetupSuccessRequestAction( action.roomName ) );
};
const handleStartRoomSetupSuccess = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.ROOM_SETUP_STARTED
);
if( correctState !== true ) {
logger.warn( `not doing handleStartRoomSetupSuccess, room ${action.roomName} not in state ROOM_SETUP_STARTED` );
return;
}
// initialise the room state
yield put( roomStateActions.initRoomContentRequestAction( action.roomName ) );
// check if it was initialised correctly
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms[ action.roomName ]; } );
// tell the state machine what happened
if( typeof roomData.content === 'object' ) {
yield put( roomStateActions.notifyInitRoomContentSuccessRequestAction( action.roomName ) );
}
else {
yield put( roomStateActions.notifyInitRoomContentFailureRequestAction( action.roomName ) );
}
};
/*******************************************************************************
* HANDLE SUCCESSFUL ROOM STATE INIT
*******************************************************************************/
const handleInitRoomContentSuccess = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.ROOM_CONTENT_INITED
);
if( correctState !== true ) {
logger.warn( `not doing handleInitRoomContentSuccess, room ${action.roomName} not in state ROOM_CONTENT_INITED` );
return;
}
// this is possibly an unnecessary state change(!), but it's explicit
yield put( roomStateActions.startRoomHeartbeatRequestAction( action.roomName ) );
};
/*******************************************************************************
* START HEARTBEAT FOR ROOM
*******************************************************************************/
const startHeartbeatForRoom = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.STARTING_HEARTBEAT
);
if( correctState !== true ) {
logger.warn( `not doing startHeartbeatForRoom, room ${action.roomName} not in state ${acceptableStates}` );
return;
}
let { roomData, roomState } = yield select(
( state ) => {
return {
roomData: state.roomDataReducer.rooms,
roomState: state.roomStateReducer
};
}
);
let lastHeartbeat, heartbeatCount;
try {
lastHeartbeat = roomData[ action.roomName ].content.lastHeartbeat;
let roomStatus = roomState.rooms[ action.roomName ].status;
logger.trace( `starting heartbeat for new room: ${action.roomName}` );
}
catch( error ) {
logger.error( `error trying to start heartbeat for room '${action.roomName}': ${error.message}` );
yield put( roomStateActions.notifyStartRoomHeartbeatFailureRequestAction( action.roomName ) );
return;
}
// fork a heartbeat task
let heartbeatTask = yield fork( heartbeatTaskFunction, action.roomName );
// set it in state
yield put( roomDataActions.setHeartbeatTaskForRoomRequestAction( heartbeatTask, action.roomName ) );
// check it got there
roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
// tell the state-machine for the room what's happening
if( roomData[ action.roomName ].tasks.heartbeat === heartbeatTask ) {
yield put( roomStateActions.notifyStartRoomHeartbeatSuccessRequestAction( action.roomName ) );
}
else {
yield put( roomStateActions.notifyStartRoomHeartbeatFailureRequestAction( action.roomName ) );
}
return;
};
const heartbeatTaskFunction = function* ( roomName ) {
try {
let lastHeartbeatCount = 0;
while( true ) {
// get the latest status
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
// if the state's been cleared, stop sending
if( !roomData[ roomName ] ) {
logger.trace( `state for room ${roomName} has been cleared, finishing heartbeat` );
break;
}
// update the second count
let secondCount = ( lastHeartbeatCount * config.roomHeartbeatDelayInMs ) / 1000 ;
// create a heartbeat message
let heartbeatData = {
[ outgoingMsgComponents.ROOM_HEARTBEAT.COUNT ]: lastHeartbeatCount,
[ outgoingMsgComponents.ROOM_HEARTBEAT.SECONDS ]: secondCount
};
let heartbeatMessage = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_HEARTBEAT,
heartbeatData
);
// set the latest count in the state
yield put(
roomDataActions.setLastHeartbeatCountForRoomRequestAction(
lastHeartbeatCount,
roomName
)
);
// publish to room
yield put( roomDataActions.publishMessageToRoomRequestAction( heartbeatMessage, roomName ) );
// update the heartbeat count
lastHeartbeatCount = lastHeartbeatCount + 1;
// pause until next heartbeat
yield delay( config.roomHeartbeatDelayInMs );
}
}
finally {
if( yield cancelled() ) {
logger.trace( `cancelling heartbeat task for room ${roomName} on request` );
yield put( roomDataActions.removeHeartbeatTaskForRoomRequestAction( roomName ) );
yield put( roomStateActions.notifyStopHeartbeatSuccessRequestAction( roomName ) );
}
}
};
/*******************************************************************************
* ADD QUEUED WEBSOCKETS INTO ROOM AFTER SETUP
*******************************************************************************/
const startProcessingQueue = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.HEARTBEAT_STARTED
);
if( correctState !== true ) {
logger.warn( `not doing startProcessingQueue, room ${action.roomName} not in state ${acceptableStates}` );
return;
}
// tell the state machine the queue's being processed
yield put( roomStateActions.startProcessingQueueRequestAction( action.roomName ) );
let { serverState, roomData } = yield select(
( state ) => {
return {
serverState: state.serverReducer,
roomData: state.roomDataReducer.rooms
};
}
);
logger.trace( `processing ${roomData[ action.roomName ].queue.length} websockets in queue for room ${action.roomName}` );
// if all the local clients in the queue left before the setup completed
if( roomData[ action.roomName ].queue.length < 1 ) {
logger.trace( `no clients left in queue for room ${action.roomName}, closing down room` );
// start closing down the room on this server
yield put( roomStateActions.checkIfRoomEmptyRequestAction( action.roomName ) );
return;
}
for( let clientId of roomData[ action.roomName ].queue ) {
let clientHeadsetType = serverState.websockets[ clientId ].headsetType;
yield put( roomDataActions.addLocalClientToRoomRequestAction( clientId, clientHeadsetType, action.roomName ) );
yield put( roomDataActions.removeWebsocketFromQueueForRoomRequestAction( clientId, action.roomName ) );
}
// tell the state machine the queue's been processed
yield put( roomStateActions.notifyProcessQueueSuccessRequestAction( action.roomName ) );
};
const handleProcessQueueSuccess = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.READY
);
if( correctState !== true ) {
logger.warn( `not doing handleProcessQueueSuccess, room ${action.roomName} not in state READY` );
return;
}
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
// if the room's full
if( Object.keys( roomData[ action.roomName ].content.clients ).length >= config.maxClientsPerRoom ) {
// tell the state machine about it
yield put( roomStateActions.setRoomFullRequestAction( action.roomName ) );
}
};
/*******************************************************************************
* CONNECT A WEBSOCKET CONNECTION TO A SPECIFIED ROOM
*******************************************************************************/
const connectWebsocketToRoom = function* ( action ) {
// belt & braces
if( roomNames.indexOf( action.ws.requestedRoomName ) < 0 ) {
let noSuchRoomMessage = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.NO_SUCH_ROOM,
{ roomName }
);
sendWsMessageWithLogger( action.ws, noSuchRoomMessage, logger );
return;
}
logger.trace(
`joining ${action.ws.headsetType} websocket ${action.ws.id} ` +
`to room '${action.ws.requestedRoomName}'`
);
let roomName = action.ws.requestedRoomName;
// get the current state for chosen room
let { roomData, roomState } = yield select(
( state ) => {
return {
roomData: state.roomDataReducer.rooms[ roomName ],
roomState: state.roomStateReducer.rooms[ roomName ]
};
}
);
// work out what FSM state it's in
switch( roomState.status ) {
// idle, waiting for setup
case roomStateConstants.STATES.INIT:
// kick off the room setup
yield put( roomStateActions.initRoomContentRequestAction( roomName ) );
// check if it was initialised correctly
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms[ roomName ]; } );
// tell the state machine what happened
if( typeof roomData.content === 'object' ) {
yield put( roomStateActions.notifyInitRoomContentSuccessRequestAction( roomName ) );
}
else {
yield put( roomStateActions.notifyInitRoomContentFailureRequestAction( roomName ) );
return;
}
// put the connection in the queue for the room
yield put(
roomDataActions.addWebsocketToQueueForRoomRequestAction(
action.ws.id,
roomName
)
);
break;
// in process of being set up
case roomStateConstants.STATES.INITING_ROOM_CONTENT:
case roomStateConstants.STATES.ROOM_CONTENT_INITED:
case roomStateConstants.STATES.STARTING_HEARTBEAT:
case roomStateConstants.STATES.HEARTBEAT_STARTED:
// check if there's space in the queue
if( roomData.queue.length >= config.maxClientsPerRoom ) {
let queueFullMessage = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.ROOM_QUEUE_FULL,
{ roomName }
);
sendWsMessageWithLogger( action.ws, queueFullMessage, logger );
logger.warn( `closing client ${action.ws.id}: queue for ${roomName} is full` );
action.ws.close();
return;
}
// put the connection in the queue for the room
yield put(
roomDataActions.addWebsocketToQueueForRoomRequestAction(
action.ws.id,
roomName
)
);
break;
// full
case roomStateConstants.STATES.ROOM_FULL:
let roomFullMessage = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.BUSY_TRY_AGAIN,
{}
);
sendWsMessageWithLogger( action.ws, roomFullMessage, logger );
logger.warn( `closing client ${action.ws.id}: queue for ${roomName} is full` );
action.ws.close();
return;
// up and running
case roomStateConstants.STATES.READY:
// add the connection to the room
yield put(
roomDataActions.addLocalClientToRoomRequestAction(
action.ws.id,
action.ws.headsetType,
roomName
)
);
break;
// anything else
default:
logger.warn( `tried to join ${action.ws.id} to room ${roomName} while in state ${roomState.status}` );
let roomUnavailableMessage = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.ROOM_UNAVAILABLE,
{ roomName }
);
sendWsMessageWithLogger( action.ws, roomUnavailableMessage, logger );
break;
}
};
/*******************************************************************************
* ADD CLIENT TO ROOM DATA AND INFORM EXISTING CLIENTS IT'S JOINED
*******************************************************************************/
// action: { clientId, roomName }
const handleAddLocalClientToRoom = function* ( action ) {
let { serverState, roomState, roomData } = yield select(
( state ) => {
return {
serverState: state.serverReducer,
roomState: state.roomStateReducer,
roomData: state.roomDataReducer.rooms
};
}
);
if( roomState.rooms[ action.roomName ].status === roomStateConstants.STATES.READY ) {
// if the room's full
if( Object.keys( roomData[ action.roomName ].content.clients ).length >= config.maxClientsPerRoom ) {
// tell the state machine about it
yield put( roomStateActions.setRoomFullRequestAction( action.roomName ) );
}
}
let ws = serverState.websockets[ action.clientId ];
if( typeof ws === 'undefined' ) {
logger.warn( `error trying to sendRoomInfoToClient: no websocket object for ws id ${action.clientId}` );
return;
}
let roomName = action.roomName;
if( typeof roomName === 'undefined' ) {
logger.warn( `error trying to sendRoomInfoToClient: no currentRoom for ws id ${ws.id}` );
sendWsErrorWithLogger( config.serverId, ws, messageConstants.ERROR_TYPES.SYSTEM_ERROR, roomName );
return;
}
let roomClients = roomData[ roomName ].content.clients;
let clients = {};
// copy of client list, without the connecting client
Object.keys( roomClients ).filter(
( clientId ) => {
return clientId !== action.clientId;
}
)
.forEach(
( clientId ) => {
const headsetType = roomClients[ clientId ].headsetType;
if( typeof clients[ headsetType ] === 'undefined' ) {
clients[ headsetType ] = []
}
clients[ headsetType ].push( clientId );
}
);
// get copy of spheres from room state
let spheres = JSON.parse( JSON.stringify( roomData[ roomName ].content.spheres ) );
// add to client message
let roomInfo = {
[ outgoingMsgComponents.ROOM_STATUS_INFO.ROOM_NAME ]: roomName,
[ outgoingMsgComponents.ROOM_STATUS_INFO.SOUNDBANK ]: roomData[ roomName ].content.soundbank,
[ outgoingMsgComponents.ROOM_STATUS_INFO.CLIENTS ]: clients,
[ outgoingMsgComponents.ROOM_STATUS_INFO.SPHERES ]: spheres
};
let roomInfoMessage = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_STATUS_INFO,
roomInfo
);
try {
sendWsMessageWithLogger( ws, roomInfoMessage, logger );
}
catch( error ) {
logger.trace( `error sending room info message to client ${ws.id}`, error.message );
}
// tell all other clients in the room that this one has joined
let clientData = {
[ outgoingMsgComponents.ROOM_CLIENT_JOIN.CLIENT_ID ]: ws.id,
[ outgoingMsgComponents.ROOM_CLIENT_JOIN.CLIENT_HEADSET_TYPE ]: ws.headsetType
};
let clientJoinMessage = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_CLIENT_JOIN,
clientData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( clientJoinMessage, roomName ) );
// start off a client inactivty check, to eject the client when it goes idle
yield fork( clientInactivityTimeoutTaskFunction, ws.id, ws.headsetType );
};
const clientInactivityTimeoutTaskFunction = function* ( clientId, headsetType ) {
let serverState;
let ws;
let inactivityTimeoutPeriod = roomDataConstants.CLIENT_INFO.CLIENT_INACTIVITY_TIMEOUTS_IN_MS[ headsetType ];
try {
do {
serverState = yield select( ( state ) => { return state.serverReducer; } );
ws = serverState.websockets[ clientId ];
// websocket has disconnected, finish
if( typeof ws === 'undefined' ) {
break;
}
// websocket has gone too long without activity, eject
if( Date.now() - ws.lastAction > inactivityTimeoutPeriod ) {
// tell the client why it's being closed
let clientInactivityTimeoutMsg = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.CLIENT_INACTIVITY_TIMEOUT,
undefined
);
sendWsMessageWithLogger( ws, clientInactivityTimeoutMsg, logger );
// close it
ws.close();
// stop checking this client
break;
}
yield delay( roomDataConstants.CLIENT_INFO.CLIENT_INACTIVITY_TIMEOUT_CHECKS_IN_MS[ headsetType ] );
}
while( true );
}
catch( error ) {
logger.trace( `error checking client activity: ${error.message}` );
}
};
/*******************************************************************************
* REMOVE CLIENT FROM ROOM DATA AND INFORM EXISTING CLIENTS IT'S LEFT
*******************************************************************************/
// when a local client is removed from a room, check whether it's got any local clients left
// action: { clientId, roomName }
const handleRemoveLocalClientFromRoom = function* ( action ) {
let { roomState, roomData } = yield select(
( state ) => {
return {
roomState: state.roomStateReducer,
roomData: state.roomDataReducer.rooms
};
}
);
// find any spheres (THERE CAN BE ONLY ONE) held by this client ...
let spheresHeld = Object.keys( roomData[ action.roomName ].content.spheres ).filter(
( sphereId ) => {
if( !roomData[ action.roomName ].content.spheres[ sphereId ].hold ) {
return false;
}
return roomData[ action.roomName ].content.spheres[ sphereId ].hold.clientId === action.clientId;
}
);
// ... and release each one (THERE CAN BE ONLY ONE)
for( let sphereId of spheresHeld ) {
yield call( cancelSphereHold, sphereId, action.clientId, action.roomName );
}
if( roomState.rooms[ action.roomName ].status === roomStateConstants.STATES.ROOM_FULL ) {
// if the room's full
if( Object.keys( roomData[ action.roomName ].content.clients ).length < config.maxClientsPerRoom ) {
// tell the state machine about it
yield put( roomStateActions.unsetRoomFullRequestAction( action.roomName ) );
}
}
// tell remaining clients that this one's left
try {
let clientExitMessage = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_CLIENT_EXIT,
{ [ outgoingMsgComponents.ROOM_CLIENT_EXIT.CLIENT_ID ]: action.clientId }
);
yield put( roomDataActions.publishMessageToRoomRequestAction( clientExitMessage, action.roomName ) );
}
catch( error ) {
logger.trace( `broadcast error trying to notify client exit for ${action.clientId}: ${error.message}` );
}
// tell the state machine to move to CHECKING_IF_EMPTY
yield put( roomStateActions.checkIfRoomEmptyRequestAction( action.roomName ) );
};
/*******************************************************************************
* REMOVE A SPHERE HOLD WHEN CLIENT LEAVES OR TIMES OUT
*******************************************************************************/
const cancelSphereHold = function* ( sphereId, clientId, roomName) {
// remove the hold on this sphere for this client
yield put(
roomDataActions.removeHoldOnSphereForClientInRoomRequestAction(
sphereId,
clientId,
roomName
)
);
// broadcast to the room that the sphere's been released
let outgoingMsgData = {
[ outgoingMsgComponents.ROOM_SPHERE_RELEASED.SPHERE_ID ]: sphereId,
[ outgoingMsgComponents.ROOM_SPHERE_RELEASED.CLIENT_ID ]: clientId
};
let sphereReleasedMessage = makeWsBroadcastMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_RELEASED,
outgoingMsgData
);
yield put( roomDataActions.publishMessageToRoomRequestAction( sphereReleasedMessage, roomName ) );
};
/*******************************************************************************
* WORK OUT WHETHER ANY CLIENTS ARE LEFT IN A ROOM, CLOSE ROOM IF NOT
*******************************************************************************/
// asked explicitly by state-machine event CHECK_IF_EMPTY
const checkIfRoomEmpty = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.CHECKING_IF_EMPTY
);
if( correctState !== true ) {
logger.warn( `not doing checkIfRoomEmpty, room ${action.roomName} not in state CHECKING_IF_EMPTY` );
return;
}
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
let numberClientsLeft = Object.keys( roomData[ action.roomName ].content.clients ).length;
if( numberClientsLeft > 0 ) {
yield put( roomStateActions.notifyRoomEmptyCheckFailureRequestAction( action.roomName ) );
}
else {
yield put( roomStateActions.notifyRoomEmptyCheckSuccessRequestAction( action.roomName ) );
}
};
// if a room is empty of local clients
const handleRoomEmptyCheckSuccess = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.ROOM_EMPTY
);
if( correctState !== true ) {
logger.warn( `not doing handleRoomEmptyCheckSuccess, room ${action.roomName} not in state ROOM_EMPTY` );
return;
}
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; });
// if there's a heartbeat task running, it should stop
if( typeof roomData[ action.roomName ].tasks.heartbeat !== undefined ) {
yield put( roomStateActions.stopHeartbeatForRoomRequestAction( action.roomName ) );
return;
}
};
// asked explicitly by state-machine event STOP_HEARTBEAT
const stopHeartbeatForRoom = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.STOPPING_HEARTBEAT
);
if( correctState !== true ) {
logger.warn( `not doing stopHeartbeatForRoom, room ${action.roomName} not in state STOPPING_HEARTBEAT` );
return;
}
let roomData = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
// belt & braces double-check to avoid crashing on cancel
if( typeof roomData[ action.roomName ].tasks.heartbeat !== undefined ) {
// ask the heartbeatTaskFunction to cancel
yield cancel( roomData[ action.roomName ].tasks.heartbeat );
}
else {
logger.warn( `problem stopping heartbeat for room ${action.roomName}: no task found` );
yield put( roomStateActions.notifyStopHeartbeatFailureRequestAction( action.roomName ) );
}
};
// once a server's stopped sending heartbeats for a room, it should re-INIT the room
const handleStopHeartbeatSuccessEvent = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.HEARTBEAT_STOPPED
);
if( correctState !== true ) {
logger.warn( `not doing handleStopHeartbeatSuccessEvent, room ${action.roomName} not in state HEARTBEAT_STOPPED` );
return;
}
yield put( roomStateActions.closeRoomRequestAction( action.roomName ) );
};
// just log out room close event
const handleRoomCloseEvent = function* ( action ) {
let correctState = yield roomStateUtils.roomIsCurrentlyInState(
action.roomName,
roomStateConstants.STATES.INIT
);
if( correctState !== true ) {
logger.warn( `not doing handleRoomCloseEvent, room ${action.roomName} not in state INIT` );
return;
}
logger.trace( `closed room ${action.roomName}` );
};
/*******************************************************************************
* START A HOLD TIMEOUT WHEN A CLIENT GRABS A SPHERE
*******************************************************************************/
// triggers on roomDataActions.createHoldOnSphereForClientInRoomRequestAction
// action: { sphereId, clientId, roomName }
const startSphereHoldTimeout = function* ( action ) {
if( config.timeoutSphereHolds !== true ) {
return;
}
let sphereHoldTimeoutTask = yield fork( sphereHoldTimeoutTaskFunction, action.sphereId, action.roomName );
yield put(
roomDataActions.setHoldTimeoutTaskForSphereInRoomRequestAction(
sphereHoldTimeoutTask,
action.sphereId,
action.roomName
)
);
};
const sphereHoldTimeoutTaskFunction = function* ( sphereId, roomName ) {
try {
let sphereState;
let lastSphereAction;
do {
sphereState = yield select( ( state ) => { return state.roomDataReducer.rooms[ roomName ].content.spheres; } );
if( typeof sphereState[ sphereId ] === 'undefined' ) {
// sphere has been deleted
break;
}
if( typeof sphereState[ sphereId ].hold === 'undefined' ) {
// sphere has been released
break;
}
// check the last time anything was done with the sphere
lastSphereAction = sphereState[ sphereId ].hold.ts;
if( Date.now() - lastSphereAction >= roomDataConstants.SPHERE_INFO.SPHERE_HOLD_TIMEOUT_IN_MS ) {
// work out which client's holding it
let clientId = sphereState[ sphereId ].hold.clientId;
// get the websocket for the client
let serverState = yield select( ( state ) => { return state.serverReducer; } );
let client = serverState.websockets[ clientId ];
let timeoutMsgData = {
[ messageConstants.OUTGOING_MESSAGE_COMPONENTS.SPHERE_HOLD_TIMEOUT.SPHERE_ID ]: sphereId
};
// tell the client it's losing the hold
let sphereHoldTimeOutMessage = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.SPHERE_HOLD_TIMEOUT,
timeoutMsgData
);
sendWsMessageWithLogger( client, sphereHoldTimeOutMessage, logger );
// delete the hold, tell the room
yield call( cancelSphereHold, sphereId, clientId, roomName );
break;
}
yield delay( roomDataConstants.SPHERE_INFO.SPHERE_HOLD_TIMEOUT_CHECK_IN_MS );
} while ( true );
}
catch( error ) {
logger.warn( `error in sphere hold timeout task for sphere ${sphereId}: ${error.message}` );
}
finally {
yield put( roomDataActions.deleteHoldTimeoutTaskForSphereInRoomRequestAction( sphereId, roomName ) );
}
};
/*******************************************************************************
* PUBLISH A MESSAGE TO CLIENTS IN A ROOM, EXCEPT THE SENDER
*******************************************************************************/
const publishMessageToRoom = function* ( action ) {
let roomName = action.roomName;
let messagePacket = action.message;
let messageFromId = messagePacket[ outgoingMsgComponents.ALL_MESSAGES.FROM ];
let messageDataEnvelope = messagePacket[ outgoingMsgComponents.ALL_MESSAGES.MSG ];
let messageData = messageDataEnvelope[ outgoingMsgComponents.ALL_MESSAGES.DATA ];
// construct a message for interested websockets
let websocketMessage = {
[ outgoingMsgComponents.ALL_MESSAGES.FROM ]: messageFromId,
[ outgoingMsgComponents.ALL_MESSAGES.MSG ]: messageDataEnvelope
};
let { roomData, serverState } = yield select(
( state ) => {
return {
roomData: state.roomDataReducer.rooms,
serverState: state.serverReducer
}
}
);
let roomClients = roomData[ roomName ].content.clients;
let roomClientIds = Object.keys( roomClients );
let clientsLength = roomClientIds.length;
let serverSockets = serverState.websockets;
for( let i = 0; i < clientsLength; i++ ) {
let clientId = roomClientIds[ i ];
if( clientId === messageFromId ) {
continue;
}
if( clientId === messageData[ outgoingMsgComponents.ALL_MESSAGES.CLIENT_ID ] ) {
continue;
}
if( typeof serverSockets[ clientId ] === 'undefined' ) {
continue;
}
let ws = serverSockets[ clientId ];
try {
sendWsMessageWithLogger( ws, websocketMessage, logger );
}
catch( error ) {
logger.warn( `error sending ${messageType} message to client ${clientId}: ${error.message}` );
}
}
};
/*******************************************************************************
* WATCHER FUNCTIONS TO CATCH REDUX ACTIONS
*******************************************************************************/
const watchStartRoomSetupRequests = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.START_ROOM_SETUP,
startRoomSetup
);
};
const watchStartRoomSetupSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.START_ROOM_SETUP_SUCCESS,
handleStartRoomSetupSuccess
);
};
const watchInitRoomContentSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT_SUCCESS,
handleInitRoomContentSuccess
);
};
const watchStartHeartbeatRequestEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.START_HEARTBEAT,
startHeartbeatForRoom
);
};
const watchStartHeartbeatSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.START_HEARTBEAT_SUCCESS,
startProcessingQueue
);
};
const watchProcessQueueSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.PROCESS_QUEUE_SUCCESS,
handleProcessQueueSuccess
);
};
const watchAddWebsocketToServerStateRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.ADD_WEBSOCKET_TO_STATE,
connectWebsocketToRoom
);
};
const watchAddLocalClientToRoomRequests = function* () {
yield takeEvery(
roomDataConstants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM,
handleAddLocalClientToRoom
);
};
const watchPublishMessageRequestActions = function* () {
yield takeEvery(
roomDataConstants.ACTION_TYPES.PUBLISH_MESSAGE_TO_ROOM,
publishMessageToRoom
);
};
const watchStopHeartbeatRequests = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.STOP_HEARTBEAT,
stopHeartbeatForRoom
);
};
const watchStopHeartbeatSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.STOP_HEARTBEAT_SUCCESS,
handleStopHeartbeatSuccessEvent
);
};
const watchRoomCloseEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.CLOSE,
handleRoomCloseEvent
);
};
const watchCreateHoldOnSphereEvents = function* () {
yield takeEvery(
roomDataConstants.ACTION_TYPES.CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM,
startSphereHoldTimeout
);
};
const watchRemoveLocalClientFromRoomRequests = function* () {
yield takeEvery(
roomDataConstants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM,
handleRemoveLocalClientFromRoom
);
};
const watchCheckIfRoomEmptyRequests = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.CHECK_IF_EMPTY,
checkIfRoomEmpty
);
};
const watchRoomEmptyCheckSuccessEvents = function* () {
yield takeEvery(
roomStateConstants.EVENT_TYPES.EMPTY_CHECK_SUCCESS,
handleRoomEmptyCheckSuccess
);
};
/*******************************************************************************
* EXPORT SAGAS
*******************************************************************************/
export default {
setupSaga: function* () {
const sagas = [
watchStartRoomSetupRequests,
watchStartRoomSetupSuccessEvents,
watchInitRoomContentSuccessEvents,
watchStartHeartbeatRequestEvents,
watchStartHeartbeatSuccessEvents,
watchProcessQueueSuccessEvents,
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
},
connectionSaga: function* () {
const sagas = [
watchAddWebsocketToServerStateRequests,
watchAddLocalClientToRoomRequests,
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
},
publishSaga: function* () {
const sagas = [
watchPublishMessageRequestActions
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
},
heartbeatSaga: function* () {
const sagas = [
watchStopHeartbeatRequests,
watchStopHeartbeatSuccessEvents,
watchRoomCloseEvents
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
},
sphereHoldTimeoutSaga: function* () {
const sagas = [
watchCreateHoldOnSphereEvents
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
},
disconnectionSaga: function* () {
const sagas = [
watchRemoveLocalClientFromRoomRequests,
watchCheckIfRoomEmptyRequests,
watchRoomEmptyCheckSuccessEvents
];
yield call( sagaUtils.spawnAutoRestartingSagas, sagas );
}
};
================================================
FILE: backend/src/rooms/room-state-actions.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import roomStateConstants from './room-state-constants';
// setup room
const startRoomSetupRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_ROOM_SETUP;
return { type, roomName };
};
const notifyStartRoomSetupSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_ROOM_SETUP_SUCCESS;
return { type, roomName };
};
const notifyStartRoomSetupFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_ROOM_SETUP_FAILURE;
return { type, roomName };
};
// initialise room content
const initRoomContentRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT;
return { type, roomName };
};
const notifyInitRoomContentSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT_SUCCESS;
return { type, roomName };
};
const notifyInitRoomContentFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT_FAILURE;
return { type, roomName };
};
// start a heartbeat for a room
const startRoomHeartbeatRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_HEARTBEAT;
return { type, roomName };
};
const notifyStartRoomHeartbeatSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_HEARTBEAT_SUCCESS;
return { type, roomName };
};
const notifyStartRoomHeartbeatFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.START_HEARTBEAT_FAILURE;
return { type, roomName };
};
// processing the queue for a newly-inited room
const startProcessingQueueRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.PROCESS_QUEUE;
return { type, roomName };
};
const notifyProcessQueueSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.PROCESS_QUEUE_SUCCESS;
return { type, roomName };
};
const notifyProcessQueueFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.PROCESS_QUEUE_FAILURE;
return { type, roomName };
};
// mark a room full or not-full
const setRoomFullRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.SET_ROOM_FULL;
return { type, roomName };
};
const unsetRoomFullRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.UNSET_ROOM_FULL;
return { type, roomName };
};
// check if a room's empty when a client leaves
const checkIfRoomEmptyRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.CHECK_IF_EMPTY;
return { type, roomName };
};
const notifyRoomEmptyCheckSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.EMPTY_CHECK_SUCCESS;
return { type, roomName };
};
const notifyRoomEmptyCheckFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.EMPTY_CHECK_FAILURE;
return { type, roomName };
};
// stop a heartbeat for a room
const stopHeartbeatForRoomRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.STOP_HEARTBEAT;
return { type, roomName };
};
const notifyStopHeartbeatSuccessRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.STOP_HEARTBEAT_SUCCESS;
return { type, roomName };
};
const notifyStopHeartbeatFailureRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.STOP_HEARTBEAT_FAILURE;
return { type, roomName };
};
// close a room so it can be re-opened
const closeRoomRequestAction = ( roomName ) => {
let type = roomStateConstants.EVENT_TYPES.CLOSE;
return { type, roomName };
};
export default {
// setup a room on request
startRoomSetupRequestAction,
notifyStartRoomSetupSuccessRequestAction,
notifyStartRoomSetupFailureRequestAction,
// initialise room content
initRoomContentRequestAction,
notifyInitRoomContentSuccessRequestAction,
notifyInitRoomContentFailureRequestAction,
// start a heartbeat for a room
startRoomHeartbeatRequestAction,
notifyStartRoomHeartbeatSuccessRequestAction,
notifyStartRoomHeartbeatFailureRequestAction,
// processing the queue for a newly-inited room
startProcessingQueueRequestAction,
notifyProcessQueueSuccessRequestAction,
notifyProcessQueueFailureRequestAction,
// mark a room full or not-full
setRoomFullRequestAction,
unsetRoomFullRequestAction,
// check if a room's empty when a client leaves
checkIfRoomEmptyRequestAction,
notifyRoomEmptyCheckSuccessRequestAction,
notifyRoomEmptyCheckFailureRequestAction,
// stop a heartbeat for a room
stopHeartbeatForRoomRequestAction,
notifyStopHeartbeatSuccessRequestAction,
notifyStopHeartbeatFailureRequestAction,
// close a room so it can be re-opened
closeRoomRequestAction
};
================================================
FILE: backend/src/rooms/room-state-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const MODULE_NAME = 'room-state';
const EVENT_TYPES = ( () => {
let eventTypes = {
START_ROOM_SETUP: 'start_room_setup',
START_ROOM_SETUP_SUCCESS: 'start_room_setup_success',
START_ROOM_SETUP_FAILURE: 'start_room_setup_failure',
INIT_ROOM_CONTENT: 'init_room_content',
INIT_ROOM_CONTENT_SUCCESS: 'init_room_content_success',
INIT_ROOM_CONTENT_FAILURE: 'init_room_content_failure',
START_HEARTBEAT: 'start_heartbeat',
START_HEARTBEAT_SUCCESS: 'start_heartbeat_success',
START_HEARTBEAT_FAILURE: 'start_heartbeat_failure',
PROCESS_QUEUE: 'process_queue',
PROCESS_QUEUE_SUCCESS: 'process_queue_success',
PROCESS_QUEUE_FAILURE: 'process_queue_failure',
SET_ROOM_FULL: 'set_room_full',
UNSET_ROOM_FULL: 'unset_room_full',
CHECK_IF_EMPTY: 'check_if_empty',
EMPTY_CHECK_SUCCESS: 'empty_check_success',
EMPTY_CHECK_FAILURE: 'empty_check_failure',
STOP_HEARTBEAT: 'stop_heartbeat',
STOP_HEARTBEAT_SUCCESS: 'stop_heartbeat_success',
STOP_HEARTBEAT_FAILURE: 'stop_heartbeat_failure',
CLOSE: 'close',
RESET: 'reset',
RESET_DONE: 'reset_done'
};
Object.keys( eventTypes ).forEach(
( eventType ) => {
eventTypes[ eventType ] = `${MODULE_NAME}/${eventTypes[ eventType ]}`;
}
);
return eventTypes;
})();
const STATES = ( () => {
let stateNames = {
INIT: 'init',
ERROR: 'error',
STARTING_ROOM_SETUP: 'starting_room_setup',
ROOM_SETUP_STARTED: 'room_setup_started',
INITING_ROOM_CONTENT: 'initing_room_content',
ROOM_CONTENT_INITED: 'room_content_inited',
STARTING_HEARTBEAT: 'starting_heartbeat',
HEARTBEAT_STARTED: 'heartbeat_started',
STOPPING_HEARTBEAT: 'stopping_heartbeat',
HEARTBEAT_STOPPED: 'heartbeat_stopped',
PROCESSING_QUEUE: 'processing_queue',
ROOM_FULL: 'room_full',
READY: 'ready',
CHECKING_IF_EMPTY: 'checking_if_empty',
ROOM_EMPTY: 'room_empty',
RESETTING: 'resetting'
};
Object.keys( stateNames ).forEach(
( stateName ) => {
stateNames[ stateName ] = `${MODULE_NAME}/${stateNames[ stateName ]}`;
}
);
return stateNames;
})();
export {
EVENT_TYPES,
STATES
};
export default {
EVENT_TYPES,
STATES
};
================================================
FILE: backend/src/rooms/room-state-machine.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* heavily inspired by, and modified to
* accommodate multiple-room states from:
* https://github.com/realb0t/redux-state-machine
*
* basically provides a simple Redux wrapper around
* https://github.com/jakesgordon/javascript-state-machine
*
* dynamically creates a new FSM/state-store per room
* when requested in Redux actions, e.g.:
* { type: 'ACTION_TYPE', roomName: 'my-room' }
*/
import StateMachine from 'javascript-state-machine';
import sizeof from 'object-sizeof';
import { fill, zipObject } from 'lodash';
import { logger } from '../logger';
import { initStateObject } from '../utils/reducer-utils';
import roomNames from './room-names';
import roomStateConstants from './room-state-constants';
const initialFsmState = roomStateConstants.STATES.INIT;
/**
* Return new state object with status param
* @param {String} status status name param
* @param {Object} { action, error } addition params
* @return {Object} new state object
*/
const buildState = ( status, { action = null, error = null } = {} ) => {
return { [ status ]: true, action, error, status };
}
/**
* Default state object
* @type {Object}
*/
const defaultState = {};
/**
* @param {Object} fsmConfig - Config as for javascript-state-machine
*/
const reducerBuilder = ( fsmConfig ) => {
const { events } = fsmConfig;
const eventNames = events.map( ( event ) => { return event.name; } );
const eventExists = zipObject(
eventNames,
fill( Array( eventNames.length ), true, 0, eventNames.length )
);
// list of state machine objects keyed by room name
const machines = ( () => {
let createFunction = () => {
return StateMachine.create(
Object.assign( {}, fsmConfig, { initialFsmState } )
);
};
return initStateObject( roomNames, createFunction, 'room state machines', logger );
} )();
// dictionary of
// .rooms: machine-state objects for rooms to be keyed by room name
// .roomsByState: lists of rooms keyed by current state
const states = ( () => {
let createFunction = () => {
return buildState( initialFsmState );
};
return {
// objects containing the state for the machine for each room
rooms: initStateObject( roomNames, createFunction, 'room state objects', logger ),
// lookup table of which rooms are in which state on this server
roomsByState: ( () => {
let r = {};
Object.keys( roomStateConstants.STATES ).forEach(
( stateName ) => {
r[ roomStateConstants.STATES[ stateName ] ] = {};
}
);
roomNames.forEach(
( roomName ) => {
r[ roomStateConstants.STATES.INIT ][ roomName ] = true;
}
);
logger.trace( `initialised lookup table of ${Object.keys( r[ roomStateConstants.STATES.INIT ] ).length} rooms to INIT state` );
return r;
} )()
};
} )();
// Create reducer function
const reducer = ( state = states, action ) => {
// ignore events the FSM doesn't handle
if (typeof eventExists[ action.type ] === 'undefined') {
return state;
}
if( typeof action.type === 'undefined' ) {
logger.warn( `state-machine cannot execute ${action.type} action.type, check room-state-constants` );
return state;
}
if( typeof action.roomName === 'undefined' ) {
return state;
}
// make sure this is a room we have a state machine for
if( typeof machines[ action.roomName ] === 'undefined' ) {
logger.warn( `FSM for room ${action.roomName} does not exist` );
return state;
}
// make sure the states this module keeps track of includes this room
if( typeof states.rooms[ action.roomName ] === 'undefined' ) {
logger.warn( `FSM state for room ${action.roomName} does not exist` );
return state;
}
/*
logger.warn( `state machine asked to do ${action.type} from ${states.rooms[ action.roomName ].status}` );
*/
// keep the two in sync
if ( machines[ action.roomName ].current !== states.rooms[ action.roomName ].status ) {
machines[ action.roomName ].current = states.rooms[ action.roomName ].status;
}
// check the requested transition is possible
if ( machines[ action.roomName ].cannot( action.type ) ) {
logger.error( `room ${action.roomName} cannot do ${action.type} from ${states.rooms[ action.roomName ].status}` );
// set error if not
states.rooms[ action.roomName ] = {
status: states.rooms[ action.roomName ].status,
error: true,
[ states.rooms[ action.roomName ].status ]: true,
action
};
return states;
}
let previousState = states.rooms[ action.roomName ].status;
// fire event as function
machines[ action.roomName ][ action.type ]( action );
// return state list containing new state for this room after transition
states.rooms[ action.roomName ] = {
status: JSON.parse( JSON.stringify( machines[ action.roomName ].current ) ),
error: null,
[ JSON.parse( JSON.stringify( machines[ action.roomName ].current ) ) ]: true,
action
};
// states.roomsByState[ STATES.FETCHING_TOPIC ][ 'mrko' ] = true;
states.roomsByState[ states.rooms[ action.roomName ].status ][ action.roomName ] = true;
states.roomsByState[ previousState ][ action.roomName ] = null;
delete states.roomsByState[ previousState ][ action.roomName ];
/*
logger.warn( `state machine did ${action.type} on ${action.roomName}` );
logger.warn( `room ${action.roomName} is now in state ${states.rooms[ action.roomName ].status}` );
*/
return states;
};
// Open FSMs
reducer.machines = machines;
reducer.states = states;
reducer.eventExists = eventExists;
return reducer;
};
export default reducerBuilder;
================================================
FILE: backend/src/rooms/room-state-reducer.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import reducerBuilder from './room-state-machine';
import { EVENT_TYPES, STATES } from './room-state-constants';
let e = EVENT_TYPES, s = STATES;
const reducer = reducerBuilder({
events: [
// send a start message so room-sagas can send a PubSub syncRoomSetup message
{ name: e.INIT_ROOM_CONTENT, from: s.INIT, to: s.INITING_ROOM_CONTENT },
{ name: e.INIT_ROOM_CONTENT_SUCCESS, from: s.INITING_ROOM_CONTENT, to: s.ROOM_CONTENT_INITED },
{ name: e.INIT_ROOM_CONTENT_FAILURE, from: s.INITING_ROOM_CONTENT, to: s.ERROR },
// once it's initialised the room, it should start a heartbeat
{ name: e.START_HEARTBEAT, from: s.ROOM_CONTENT_INITED, to: s.STARTING_HEARTBEAT },
{ name: e.START_HEARTBEAT_SUCCESS, from: s.STARTING_HEARTBEAT, to: s.HEARTBEAT_STARTED },
{ name: e.START_HEARTBEAT_FAILURE, from: s.STARTING_HEARTBEAT, to: s.ERROR },
// when it's started a heartbeat, the server should connect websockets from the queue
{ name: e.PROCESS_QUEUE, from: s.HEARTBEAT_STARTED, to: s.PROCESSING_QUEUE },
// when it's received all state from peers, the server should connect websockets from the queue
{ name: e.PROCESS_QUEUE, from: s.ALL_STATE_RECEIVED, to: s.PROCESSING_QUEUE },
{ name: e.PROCESS_QUEUE_SUCCESS, from: s.PROCESSING_QUEUE, to: s.READY },
{ name: e.PROCESS_QUEUE_FAILURE, from: s.PROCESSING_QUEUE, to: s.ERROR },
// if all the clients in the queue left before the subscription was completed, start room closedown
{ name: e.CHECK_IF_EMPTY, from: s.PROCESSING_QUEUE, to: s.CHECKING_IF_EMPTY },
// when config.maxClients have arrived, the server should mark the room as full
{ name: e.SET_ROOM_FULL, from: s.READY, to: s.ROOM_FULL },
{ name: e.UNSET_ROOM_FULL, from: s.ROOM_FULL, to: s.READY },
// when a client leaves the room, the server should check if there are any local clients left
{ name: e.CHECK_IF_EMPTY, from: s.READY, to: s.CHECKING_IF_EMPTY },
{ name: e.CHECK_IF_EMPTY, from: s.ROOM_FULL, to: s.CHECKING_IF_EMPTY },
// if so, it should just go back to waiting for others
{ name: e.EMPTY_CHECK_FAILURE, from: s.CHECKING_IF_EMPTY, to: s.READY },
// it not, it should unsubscribe from the room
{ name: e.EMPTY_CHECK_SUCCESS, from: s.CHECKING_IF_EMPTY, to: s.ROOM_EMPTY },
// if there aren't any local clients left, the handleRoomEmpty saga should work out whether to stop a heartbeat
{ name: e.STOP_HEARTBEAT, from: s.ROOM_EMPTY, to: s.STOPPING_HEARTBEAT },
{ name: e.STOP_HEARTBEAT_SUCCESS, from: s.STOPPING_HEARTBEAT, to: s.HEARTBEAT_STOPPED },
{ name: e.STOP_HEARTBEAT_FAILURE, from: s.STOPPING_HEARTBEAT, to: s.ERROR },
// when the heartbeat's stopped, reset the room to its INIT state
{ name: e.CLOSE, from: s.HEARTBEAT_STOPPED, to: s.INIT },
// if it gets into an error state, it should trigger a cleanup action
{ name: e.RESET, from: s.ERROR, to: s.RESETTING },
// cleaning up a room resets it to init status
{ name: e.RESET_SUCCESS, from: s.RESETTING, to: s.INIT }
]
});
export default reducer;
================================================
FILE: backend/src/rooms/room-state-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { select } from 'redux-saga/effects';
import arrify from 'arrify';
const roomIsCurrentlyInState = function* ( roomName, desiredState ) {
let roomState = yield select( ( state ) => { return state.roomStateReducer; } );
if( typeof roomState.rooms[ roomName ] === 'undefined' ) {
return false;
}
let statesToCheck = arrify( desiredState );
let currentState = roomState.rooms[ roomName ].status;
let roomIsInDesiredState = false;
for( let state of statesToCheck ) {
if( state === currentState ) {
return true;
}
}
return false;
};
export default {
roomIsCurrentlyInState
};
================================================
FILE: backend/src/s11n.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const serialize = ( obj ) => {
return JSON.stringify( obj );
};
const deserialize = ( msg ) => {
try {
return JSON.parse( msg );
}
catch( error ) {
throw error;
}
};
export { serialize, deserialize };
================================================
FILE: backend/src/server/app-server.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
const lbHealthCheckUrl = "/lb-health-check";
/*
* here's where we'd implement any custom HTTP routes
*/
const createApp = () => {
let app = express();
// don't announce server software
app.use(
(req, res, next) => {
res.removeHeader('X-Powered-By');
next();
}
);
// say something at the root
app.get(
'/',
( req, res ) => {
res.send( `forest\n` );
}
);
// lb health check 200 response
app.get(
lbHealthCheckUrl,
( req, res ) => {
res.send( `OK` );
}
);
return app;
};
export default { createApp };
export { lbHealthCheckUrl };
================================================
FILE: backend/src/server/balancer.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { logger } from '../logger';
import { messageConstants } from '../messages';
import { roomNames } from '../rooms';
import {
dsClient,
datastoreConstants
}
from '../datastore';
import config from '../config';
import { urlUtils } from '../utils';
import roomStateConstants from '../rooms/room-state-constants';
import { getStoreState } from '../store';
import { privatiseTargetServerIfLocal } from './target-server-utils';
import { CUSTOM_PROXY_HEADERS } from './server-constants';
const getBalancerFunction = ( proxy ) => {
let lastPortUsed = 0;
let headsetTypes = Object.values( messageConstants.HEADSET_TYPES );
let serverReducer = getStoreState().serverReducer;
let ring = serverReducer.hashRing;
return ( req, socket, head ) => {
// make sure client didn't try to use our custom headers
Object.values( CUSTOM_PROXY_HEADERS ).forEach(
( headerName ) => {
delete req.headers[ headerName ];
}
);
let target;
let requestInfo;
// work out headsetType & optional roomName from URL
try {
requestInfo = urlUtils.getClientInfoFromUrl( req.url );
// pass headset type in custom proxy request header
req.headers[ CUSTOM_PROXY_HEADERS.X_CLIENT_HEADSET_TYPE ] = requestInfo.clientHeadsetType;
// pass requested room name if present
if( typeof requestInfo.roomName !== 'undefined' ) {
req.headers[ CUSTOM_PROXY_HEADERS.X_REQUESTED_ROOM_NAME ] = requestInfo.roomName;
}
}
catch( error ) {
// WS client requested an invalid URL
// forward to any WS server to close the connection gracefully
target = 'ws://' + privatiseTargetServerIfLocal( ring.get( req.url ) );
req.headers[ CUSTOM_PROXY_HEADERS.X_PLEASE_CLOSE_THIS_CONNECTION ] = true;
proxy.ws( req, socket, head, { target } );
return;
}
// a room was specified
if( typeof requestInfo.roomName !== 'undefined' ) {
// but it wasn't one in the pre-defined list
if( roomNames.indexOf( requestInfo.roomName ) < 0 ) {
// forward to any WS server to close the connection gracefully
target = 'ws://' + privatiseTargetServerIfLocal( ring.get( req.url ) );
req.headers[ CUSTOM_PROXY_HEADERS.X_PLEASE_CLOSE_THIS_CONNECTION ] = true;
proxy.ws( req, socket, head, { target } );
return;
}
// the specified room name was valid, proxy to the appropriate server
target = 'ws://' + privatiseTargetServerIfLocal( ring.get( requestInfo.roomName ) );
// tell the WS server this room was specified directly by the client
req.headers[ CUSTOM_PROXY_HEADERS.X_CLIENT_SPECIFIED_ROOM_NAME ] = true;
proxy.ws( req, socket, head, { target } );
}
// no room was specified, choose one
else {
let chosenRoomName;
// try to find one with availability for this type
let state = getStoreState();
let avails = state.roomDataReducer.avails[ requestInfo.clientHeadsetType ];
let availableRoomNames = Object.keys( avails );
let availableRoomCount = availableRoomNames.length;
if( availableRoomCount > 0 ) {
// choose one at random from the available list
chosenRoomName = availableRoomNames[ Math.floor( Math.random() * availableRoomCount ) ];
}
// otherwise, get an empty room that's servable from this server
else {
let emptyRoomNames = Object.keys( state.roomStateReducer.roomsByState[ roomStateConstants.STATES.INIT ] );
let servableRoomNames = state.serverReducer.servableRooms;
let servableEmptyRoomNames = containsAll( emptyRoomNames, servableRoomNames );
let servableEmptyRoomCount = servableEmptyRoomNames.length;
if( servableEmptyRoomCount === 0 ) {
// client has to try again
return;
}
chosenRoomName = servableEmptyRoomNames[ Math.floor( Math.random() * servableEmptyRoomCount ) ];
}
// the specified room name was valid, proxy to the appropriate server
target = 'ws://' + privatiseTargetServerIfLocal( ring.get( chosenRoomName ) );
req.headers[ CUSTOM_PROXY_HEADERS.X_REQUESTED_ROOM_NAME ] = chosenRoomName;
proxy.ws( req, socket, head, { target } );
}
}
};
// http://stackoverflow.com/a/11076088
// ES6 arrow functions don't bind 'arguments'
// so this has to be a 'function()'
const containsAll = function(/* pass all arrays here */) {
var output = [];
var cntObj = {};
var array, item, cnt;
// for each array passed as an argument to the function
for (var i = 0; i < arguments.length; i++) {
array = arguments[i];
// for each element in the array
for (var j = 0; j < array.length; j++) {
item = "-" + array[j];
cnt = cntObj[item] || 0;
// if cnt is exactly the number of previous arrays,
// then increment by one so we count only one per array
if (cnt == i) {
cntObj[item] = cnt + 1;
}
}
}
// now collect all results that are in all arrays
for (item in cntObj) {
if (cntObj.hasOwnProperty(item) && cntObj[item] === arguments.length) {
output.push(item.substring(1));
}
}
return(output);
};
export {
getBalancerFunction
};
================================================
FILE: backend/src/server/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverActions from './server-actions';
import serverConstants from './server-constants';
import serverReducer from './server-reducer';
import serverSagas from './server-sagas';
export {
serverActions,
serverConstants,
serverReducer,
serverSagas
};
================================================
FILE: backend/src/server/server-actions.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import constants from './server-constants';
// reducer actions
const setSyncTopicRequestAction = ( syncTopic ) => {
let type = constants.ACTION_TYPES.SET_SYNC_TOPIC;
return { type, syncTopic };
};
const setSyncSubscriptionRequestAction = ( syncSubscription ) => {
let type = constants.ACTION_TYPES.SET_SYNC_SUBSCRIPTION;
return { type, syncSubscription };
};
const setAppServerRequestAction = ( appServer ) => {
let type = constants.ACTION_TYPES.SET_APP_SERVER;
return { type, appServer };
};
const setWebsocketServerRequestAction = ( websocketServer ) => {
let type = constants.ACTION_TYPES.SET_WEBSOCKET_SERVER;
return { type, websocketServer };
};
const setBalancerRequestAction = ( balancer ) => {
let type = constants.ACTION_TYPES.SET_BALANCER;
return { type, balancer };
};
const setMonitorTaskForPeerRequestAction = ( monitorTask, peerId ) => {
let type = constants.ACTION_TYPES.SET_MONITOR_TASK_FOR_PEER;
return { type, monitorTask, peerId };
};
const setLastHeartbeatForPeerRequestAction = ( data, peerId ) => {
let type = constants.ACTION_TYPES.SET_LAST_HEARTBEAT_FOR_PEER;
return { type, data, peerId };
};
const removePeerFromListRequestAction = ( peerId ) => {
let type = constants.ACTION_TYPES.REMOVE_PEER_FROM_LIST;
return { type, peerId };
};
const addWebsocketToStateRequestAction = ( ws ) => {
let type = constants.ACTION_TYPES.ADD_WEBSOCKET_TO_STATE;
return { type, ws };
};
const setWebsocketTimeoutTaskForClientRequestAction = ( timeoutTask, clientId ) => {
let type = constants.ACTION_TYPES.SET_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT;
return { type, timeoutTask, clientId };
};
const cancelWebsocketRoomJoinTimeoutTaskForClientRequestAction = ( clientId ) => {
let type = constants.ACTION_TYPES.CANCEL_WEBSOCKET_ROOM_JOIN_TIMEOUT_TASK_FOR_CLIENT;
return { type, clientId };
};
const removeWebsocketTimeoutTaskForClientRequestAction = ( clientId ) => {
let type = constants.ACTION_TYPES.REMOVE_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT;
return { type, clientId };
};
const removeWebsocketFromStateRequestAction = ( ws ) => {
let type = constants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_STATE;
return { type, ws };
};
// saga actions
const startServerSetupRequestAction = () => {
let type = constants.ACTION_TYPES.START_SERVER_SETUP;
return { type };
};
const finishServerSetupRequestAction = () => {
let type = constants.ACTION_TYPES.FINISH_SERVER_SETUP;
return { type };
};
const startSyncSubscriptionWarmupRequestAction = () => {
let type = constants.ACTION_TYPES.START_SYNC_SUBSCRIPTION_WARMUP;
return { type };
};
const addSyncSubscriptionWarmupResponseRequestAction = () => {
let type = constants.ACTION_TYPES.ADD_SYNC_SUBSCRIPTION_WARMUP_RESPONSE;
return { type };
};
const startSendingSyncHeartbeatRequestAction = () => {
let type = constants.ACTION_TYPES.START_SENDING_SYNC_HEARTBEAT;
return { type };
};
const startSavingSubscriptionInfoRequestAction = () => {
let type = constants.ACTION_TYPES.START_SAVING_SUBSCRIPTION_INFO;
return { type };
};
const loadRateLimiterInfoRequestAction = () => {
let type = constants.ACTION_TYPES.LOAD_RATE_LIMITER_INFO_FROM_DATASTORE;
return { type };
};
const startCheckingDeadPeerSubscriptionsRequestAction = () => {
let type = constants.ACTION_TYPES.START_CHECKING_DEAD_PEER_SUBSCRIPTIONS;
return { type };
};
const startMonitorForPeerRequestAction = ( serverId ) => {
let type = constants.ACTION_TYPES.START_MONITOR_FOR_PEER;
return { type, serverId };
};
export default {
// reducer actions
setSyncTopicRequestAction,
setSyncSubscriptionRequestAction,
setAppServerRequestAction,
setWebsocketServerRequestAction,
setBalancerRequestAction,
setMonitorTaskForPeerRequestAction,
setLastHeartbeatForPeerRequestAction,
removePeerFromListRequestAction,
addWebsocketToStateRequestAction,
setWebsocketTimeoutTaskForClientRequestAction,
cancelWebsocketRoomJoinTimeoutTaskForClientRequestAction,
removeWebsocketTimeoutTaskForClientRequestAction,
removeWebsocketFromStateRequestAction,
// saga actions
startServerSetupRequestAction,
finishServerSetupRequestAction,
startSyncSubscriptionWarmupRequestAction,
addSyncSubscriptionWarmupResponseRequestAction,
startSendingSyncHeartbeatRequestAction,
startSavingSubscriptionInfoRequestAction,
loadRateLimiterInfoRequestAction,
startCheckingDeadPeerSubscriptionsRequestAction,
startMonitorForPeerRequestAction
};
================================================
FILE: backend/src/server/server-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const ACTION_TYPES = {
// reducer actions
SET_SYNC_TOPIC: 'set_sync_topic',
SET_SYNC_SUBSCRIPTION: 'set_sync_subscription',
SET_APP_SERVER: 'set_app_server',
SET_WEBSOCKET_SERVER: 'set_websocket_server',
SET_BALANCER: 'set_balancer',
SET_MONITOR_TASK_FOR_PEER: 'set_monitor_task_for_peer',
SET_LAST_HEARTBEAT_FOR_PEER: 'set_last_heartbeat_for_peer',
REMOVE_PEER_FROM_LIST: 'remove_peer_from_list',
ADD_WEBSOCKET_TO_STATE: 'add_websocket_to_state',
SET_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT: 'set_websocket_timeout_task_for_client',
CANCEL_WEBSOCKET_ROOM_JOIN_TIMEOUT_TASK_FOR_CLIENT: 'cancel_websocket_room_join_timeout_task_for_client',
REMOVE_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT: 'remove_websocket_timeout_task_for_client',
REMOVE_WEBSOCKET_FROM_STATE: 'remove_websocket_from_state',
// saga actions
START_SERVER_SETUP: 'start_server_setup',
FINISH_SERVER_SETUP: 'finish_server_setup',
START_SYNC_SUBSCRIPTION_WARMUP: 'start_sync_subscription_warmup',
ADD_SYNC_SUBSCRIPTION_WARMUP_RESPONSE: 'add_sync_subscription_warmup_response',
START_SENDING_SYNC_HEARTBEAT: 'start_sending_sync_heartbeat',
START_SAVING_SUBSCRIPTION_INFO: 'start_saving_subscription_info',
LOAD_RATE_LIMITER_INFO_FROM_DATASTORE: 'load_rate_limiter_info_from_datastore',
START_CHECKING_DEAD_PEER_SUBSCRIPTIONS: 'start_checking_dead_peer_subscriptions',
START_MONITOR_FOR_PEER: 'start_monitor_for_peer',
};
const SYNC_INFO = {
SYNC_TOPIC_NAME: 'server-sync',
SYNC_WARMUP_MESSAGE_LIST_LENGTH: 10,
SYNC_HEARTBEAT_PERIOD_IN_MS: 3000,
SYNC_HEARTBEAT_TIMEOUT_IN_MS: 15000,
};
const SYNC_MESSAGES = {
SYNC_HEARTBEAT: 'sync_heartbeat',
SYNC_WARMUP: 'sync_warmup',
LOAD_RATE_LIMITER_INFO: 'load_rate_limiter_info'
};
const saveSubscriptionInfoPeriodInMs = 5000; // save subscription info to datastore every 5s
const checkSubscriptionInfoPeriodInMs = saveSubscriptionInfoPeriodInMs * 6; // check for dead peers twice a minute
const SAVE_INFO = {
SAVE_SUBSCRIPTION_INFO_PERIOD_IN_MS: saveSubscriptionInfoPeriodInMs,
CHECK_SUBSCRIPTION_INFO_PERIOD_IN_MS: checkSubscriptionInfoPeriodInMs
};
const CUSTOM_PROXY_HEADERS = {
X_CLIENT_HEADSET_TYPE: 'x-client-headset-type',
X_CLIENT_SPECIFIED_ROOM_NAME: 'x-client-specified-room-name',
X_NO_ROOMS_AVAILABLE: 'x-no-rooms-available',
X_ROOM_ABOVE_THRESHOLD: 'x-room-above-threshold',
X_PLEASE_CLOSE_THIS_CONNECTION: 'x-please-close-this-connection',
X_REQUESTED_ROOM_NAME: 'x-requested-room-name'
};
const ERROR_TYPES = {
ROOM_NAME_REQUIRED: 'room_name_required',
ILLEGAL_ROOM_NAME: 'illegal_room_name',
BAD_OS_EXIT_ERROR_CODE: 1
};
export default {
ACTION_TYPES,
SYNC_INFO,
SYNC_MESSAGES,
SAVE_INFO,
CUSTOM_PROXY_HEADERS,
ERROR_TYPES
};
export {
CUSTOM_PROXY_HEADERS,
};
================================================
FILE: backend/src/server/server-reducer.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import redux from 'redux';
import uuid from 'uuid';
import HashRing from 'hashring';
import { logger } from '../logger';
import roomDataConstants from '../rooms/room-data-constants';
import roomNames from '../rooms/room-names';
import { createFilteredActionHandler } from '../utils/reducer-utils';
import config from '../config';
import serverConstants from './server-constants';
const mapServableRooms = ( roomList, hashRing ) => {
return roomList.filter(
( roomName ) => {
return hashRing.get( roomName ) === config.localIpPortString;
}
);
};
const initialState = ( () => {
let obj = {
serverId: config.serverId,
syncTopic: null,
syncSubscription: null,
syncWarmup: {
warmupStatus: false,
receiptTimes: []
},
appServer: null,
websocketServer: null,
balancer: null,
peers: {},
websockets: {}
};
// used to consistently hash room names to target server addresses
obj.hashRing = new HashRing(
[ `${config.localIpAddress}:${config.serverPort}` ],
'md5',
{ 'max cache size': 10000 }
);
// balancing based on what's available on the server a client arrives at
obj.servableRooms = mapServableRooms( roomNames, obj.hashRing );
return obj;
} )();
const addSyncSubscriptionWarmupResponse = ( state, action ) => {
if( state.syncWarmup.receiptTimes.length >= serverConstants.SYNC_INFO.SYNC_WARMUP_MESSAGE_LIST_LENGTH ) {
state.syncWarmup.receiptTimes.shift();
}
state.syncWarmup.receiptTimes.push( Date.now() );
return state;
};
// action: { syncTopic }
const setSyncTopic = ( state, action ) => {
if( typeof action.syncTopic === 'undefined' ) {
return state;
}
state.syncTopic = action.syncTopic;
return state;
};
// action: { syncSubscription }
const setSyncSubscription = ( state, action ) => {
if( typeof action.syncSubscription === 'undefined' ) {
return state;
}
state.syncSubscription = action.syncSubscription;
return state;
};
// action: { appServer }
const setAppServer = ( state, action ) => {
if( typeof action.appServer === 'undefined' ) {
return state;
}
state.appServer = action.appServer;
return state;
};
// action: { websocketServer }
const setWebsocketServer = ( state, action ) => {
if( typeof action.websocketServer === 'undefined' ) {
return state;
}
state.websocketServer = action.websocketServer;
return state;
};
// action: { balancer }
const setBalancer = ( state, action ) => {
if( typeof action.peerId === 'undefined' ) {
return state;
}
state.balancer = action.balancer;
return state;
};
// action: { monitorTask, peerId }
const setMonitorTaskForPeer = ( state, action ) => {
if( typeof action.peerId === 'undefined' ) {
return state;
}
state.peers[ action.peerId ].monitorTask = action.monitorTask;
return state;
};
// action: { peerId, data }
const setLastHeartbeatForPeer = ( state, action ) => {
if( typeof action.peerId === 'undefined' ) {
return state;
}
if( !state.peers[ action.peerId ] ) {
state.peers[ action.peerId ] = {
lastHeartbeatTime: 0,
ip: action.data.ip,
port: action.data.port
};
// add peer to balancer hash ring
let peerIpPortString = `${action.data.ip}:${action.data.port}`;
logger.info( `adding peer ${peerIpPortString}` );
state.hashRing.add( peerIpPortString );
logger.trace( `balancer hash ring is now:`, Object.keys( state.hashRing.vnodes ) );
// remap list of rooms this server should serve, based on new hash ring
let start = Date.now();
state.servableRooms = mapServableRooms( roomNames, state.hashRing );
let end = Date.now();
logger.info( `addPeerToList remapped ${state.servableRooms.length} rooms in ${end - start}ms` );
}
state.peers[ action.peerId ].lastHeartbeatTime = Date.now();
return state;
};
// action: { serverId }
const removePeerFromList = ( state, action ) => {
if( typeof action.peerId === 'undefined' ) {
return state;
}
if( state.peers[ action.peerId ] ) {
let peer = state.peers[ action.peerId ];
let peerIpPortString = `${peer.ip}:${peer.port}`;
// remove peer from balancer hash ring
logger.info( `removing peer ${peerIpPortString}` );
state.hashRing.remove( peerIpPortString );
logger.trace( `balancer hash ring is now:`, Object.keys( state.hashRing.vnodes ) );
// remap list of rooms this server should serve, based on new hash ring
let start = Date.now();
state.servableRooms = mapServableRooms( roomNames, state.hashRing );
let end = Date.now();
logger.info( `removePeerFromList remapped ${state.servableRooms.length} rooms in ${end - start}ms` );
state.peers[ action.peerId ] = null;
delete state.peers[ action.peerId ];
}
let remainingPeers = Object.keys( state.peers );
if( remainingPeers.length > 0 ) {
logger.info( `remaining peers: `, Object.keys( state.peers ));
}
else {
logger.info( `no remaining peers` );
}
return state;
};
// action: { ws }
const addWebsocketToState = ( state, action ) => {
if( typeof action.ws === 'undefined' ) {
return state;
}
state.websockets[ action.ws.id ] = action.ws;
let count = Object.keys( state.websockets ).length;
logger.trace( `[+] this server now has ${count} websockets` );
return state;
};
const setWebsocketTimeoutTaskForClient = ( state, action ) => {
if( typeof action.clientId === 'undefined' ) {
return state;
}
state.websockets[ action.clientId ].timeoutTask = action.timeoutTask;
return state;
};
const removeWebsocketTimeoutTaskFromClient = ( state, action ) => {
if( typeof action.clientId === 'undefined' ) {
return state;
}
state.websockets[ action.clientId ].timeoutTask = null;
delete state.websockets[ action.clientId ].timeoutTask;
return state;
};
// action: { ws, roomName }
const removeWebsocketFromState = ( state, action ) => {
if( typeof action.ws === 'undefined' ) {
return state;
}
if( !state.websockets[ action.ws.id ] ) {
return state;
}
// state.websockets[ action.ws.id ] = null;
delete state.websockets[ action.ws.id ];
let count = Object.keys( state.websockets ).length;
logger.trace( `[-] this server now has ${count} websockets` );
return state;
};
// action: { roomName, wsId }
const setQueuedRoomForWebsocket = ( state, action ) => {
if( typeof action.wsId === 'undefined' ) {
return state;
}
state.websockets[ action.wsId ].queuedRoom = action.roomName;
return state;
};
// action: { roomName, wsId }
const removeQueuedRoomForWebsocket = ( state, action ) => {
if( typeof action.wsId === 'undefined' ) {
return state;
}
state.websockets[ action.wsId ].queuedRoom = null;
delete state.websockets[ action.wsId ].queuedRoom;
return state;
};
// action: { clientId, roomName }
const setCurrentRoomForWebsocket = ( state, action ) => {
if( typeof action.clientId === 'undefined' ) {
return state;
}
if( !state.websockets[ action.clientId ] ) {
return state;
}
state.websockets[ action.clientId ].currentRoom = action.roomName;
state.websockets[ action.clientId ].queuedRoom = null;
delete state.websockets[ action.clientId ].queuedRoom;
return state;
};
// action: { clientId, roomName }
const removeCurrentRoomFromWebsocket = ( state, action ) => {
if( typeof action.clientId === 'undefined' ) {
return state;
}
if( !state.websockets[ action.clientId ] ) {
return state;
}
state.websockets[ action.clientId ].currentRoom = null;
delete state.websockets[ action.clientId ].currentRoom;
return state;
};
export default createFilteredActionHandler( {
[ serverConstants.ACTION_TYPES.ADD_SYNC_SUBSCRIPTION_WARMUP_RESPONSE ]: addSyncSubscriptionWarmupResponse,
[ serverConstants.ACTION_TYPES.SET_SYNC_TOPIC ]: setSyncTopic,
[ serverConstants.ACTION_TYPES.SET_SYNC_SUBSCRIPTION ]: setSyncSubscription,
[ serverConstants.ACTION_TYPES.SET_APP_SERVER ]: setAppServer,
[ serverConstants.ACTION_TYPES.SET_WEBSOCKET_SERVER ]: setWebsocketServer,
[ serverConstants.ACTION_TYPES.SET_BALANCER ]: setBalancer,
[ serverConstants.ACTION_TYPES.SET_MONITOR_TASK_FOR_PEER ]: setMonitorTaskForPeer,
[ serverConstants.ACTION_TYPES.SET_LAST_HEARTBEAT_FOR_PEER ]: setLastHeartbeatForPeer,
[ serverConstants.ACTION_TYPES.REMOVE_PEER_FROM_LIST ]: removePeerFromList,
[ serverConstants.ACTION_TYPES.ADD_WEBSOCKET_TO_STATE ]: addWebsocketToState,
[ serverConstants.ACTION_TYPES.SET_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT ]: setWebsocketTimeoutTaskForClient,
[ serverConstants.ACTION_TYPES.REMOVE_WEBSOCKET_TIMEOUT_TASK_FOR_CLIENT ]: removeWebsocketTimeoutTaskFromClient,
[ serverConstants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_STATE ]: removeWebsocketFromState,
[ roomDataConstants.ACTION_TYPES.ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM ]: setQueuedRoomForWebsocket,
[ roomDataConstants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM ]: setCurrentRoomForWebsocket,
[ roomDataConstants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM ]: removeCurrentRoomFromWebsocket,
}, initialState );
================================================
FILE: backend/src/server/server-sagas.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { delay, takeEvery, takeLatest } from 'redux-saga';
import { call, cancel, cancelled, put, fork, select } from 'redux-saga/effects';
import datastore from '@google-cloud/datastore';
import fetch from 'node-fetch';
import { logger } from '../logger';
import { messageConstants } from '../messages';
import { pubsubClient } from '../pubsub';
import { sendWsMessageWithLogger } from '../utils/websocket-utils';
import {
setPerClientRateLimiterOptions,
setPerMessageTypeRateLimiterOptions
} from '../messages/message-rate-limiter';
import {
dsClient,
datastoreConstants
} from '../datastore';
import {
roomDataActions,
roomDataConstants,
roomStateActions,
roomStateConstants
} from '../rooms';
import config from '../config';
import { sagaUtils } from '../utils';
import serverActions from './server-actions';
import serverConstants from './server-constants';
import serverSetup from './server-setup';
import serverSync from './server-sync';
import { lbHealthCheckUrl } from './app-server';
/*******************************************************************************
* SERVERS SEND SYNC HEARTBEATS UNTIL THEY DIE
*******************************************************************************/
const startSendingSyncHeartbeat = function* () {
logger.info(
`starting to send sync heartbeats every ` +
`${serverConstants.SYNC_INFO.SYNC_HEARTBEAT_PERIOD_IN_MS}ms`
);
do {
try {
yield serverSync.sendSyncMessageOfType(
// send the local ip and the port of the websocket app server
// for remote balancers to add to their lists
// and check every SYNC_HEARTBEAT_TIMEOUT_IN_MS
{ ip: config.localIpAddress, port: config.serverPort },
serverConstants.SYNC_MESSAGES.SYNC_HEARTBEAT
);
}
catch( error ) {
logger.error( `error sending sync heartbeat: ${error.message}` );
}
yield delay( serverConstants.SYNC_INFO.SYNC_HEARTBEAT_PERIOD_IN_MS );
} while ( true );
};
/*******************************************************************************
* SERVERS CAN LOAD/RELOAD THEIR RATE LIMIT INFO FROM DATASTORE
*******************************************************************************/
const loadRateLimiterInfo = function* () {
logger.info( `loading rate limiter info for ${config.environmentName}` );
// check if any limit info is in the datastore for this environment
let query = dsClient.createQuery( datastoreConstants.DS_ENTITY_KEY_PER_ENVIRONMENT_RATE_LIMIT_INFO )
.filter( 'environmentName', '=', config.environmentName );
let queryResult = yield dsClient.runQuery( query );
if( queryResult[ 0 ].length <= 0 ) {
logger.trace( `no rate limiter info stored for ${config.environmentName}` );
return;
}
let rateLimitInfo = queryResult[ 0 ][ 0 ];
const limitTypes = [
'perClient_threshold',
'perClient_ttl_millisec',
'perMsgType_threshold',
'perMsgType_ttl_millisec'
];
for( let t of limitTypes ) {
if( typeof rateLimitInfo[ t ] !== 'number' ) {
logger.trace( `${t} ${rateLimitInfo[ t ]} is not a number, discarding rate limit info` );
return;
}
}
// update the per-client rate limiter
let perClientRateLimiterOptions = {
threshold: rateLimitInfo.perClient_threshold,
ttl_millisec: rateLimitInfo.perClient_ttl_millisec
};
setPerClientRateLimiterOptions( perClientRateLimiterOptions );
// update the per-message-type rate limiter
let perMessageTypeRateLimiterOptions = {
threshold: rateLimitInfo.perMsgType_threshold,
ttl_millisec: rateLimitInfo.perMsgType_ttl_millisec
};
setPerMessageTypeRateLimiterOptions( perMessageTypeRateLimiterOptions );
};
/*******************************************************************************
* SERVERS SAVE THEIR SUBSCRIPTION NAME WITH CURRENT TIMESTAMP UNTIL THEY DIE
*******************************************************************************/
const startSavingSubscriptionInfo = function* () {
logger.info(
`starting to save subscription info every ` +
`${serverConstants.SAVE_INFO.SAVE_SUBSCRIPTION_INFO_PERIOD_IN_MS}ms`
);
let { roomState, serverState } = yield select(
( state ) => {
return {
roomState: state.roomStateReducer,
serverState: state.serverReducer
};
}
);
let clientCount = Object.keys( serverState.websockets ).length;
let roomsInUse = Object.keys( roomState.roomsByState[ roomStateConstants.STATES.READY ] ).length;
const topicName = serverState.syncTopic.name.split( '/' ).pop();
const subscriptionName = serverState.syncSubscription.name.split( '/' ).pop();
const key = dsClient.key( [ datastoreConstants.DS_ENTITY_KEY_SERVER_SUBSCRIPTION_INFO, config.serverId ] );
let data = {
serverId: config.serverId,
ip: config.localIpAddress,
port: config.serverPort,
topicName,
subscriptionName,
clientCount,
roomsInUse
};
do {
( { roomState, serverState } = yield select(
( state ) => {
return {
roomState: state.roomStateReducer,
serverState: state.serverReducer
};
}
) );
data.clientCount = Object.keys( serverState.websockets ).length;
data.roomsInUse =
Object.keys( roomState.roomsByState[ roomStateConstants.STATES.READY ] ).length +
Object.keys( roomState.roomsByState[ roomStateConstants.STATES.ROOM_FULL ] ).length;
data.ts = new Date();
let entity = {
key,
method: 'upsert',
data
};
try {
let result = yield dsClient.save( entity );
}
catch( error ) {
logger.error( `error saving subscription info to datastore: ${error.message}` );
}
yield delay( serverConstants.SAVE_INFO.SAVE_SUBSCRIPTION_INFO_PERIOD_IN_MS );
} while ( true );
};
/*******************************************************************************
* SERVERS PERIODICALLY CHECK DATASTORE FOR DEAD PEER SUBSCRIPTION INFO
*******************************************************************************/
const startCheckingDeadPeerSubscriptions = function* () {
let possiblyDeadPeerPeriod = serverConstants.SAVE_INFO.SAVE_SUBSCRIPTION_INFO_PERIOD_IN_MS * 5;
logger.info( `starting to check peer subscriptions every ${possiblyDeadPeerPeriod}ms`);
do {
try {
let deadPeer = yield call( findFirstDeadPeerForCurrentDeployment, possiblyDeadPeerPeriod );
// any results
if( typeof deadPeer !== 'undefined' ) {
// that aren't somehow from this server
if( deadPeer.serverId !== config.serverId ) {
// should maybe be deleted
yield maybeDeletePeerSubscription( deadPeer );
}
else {
logger.trace( `not deleting my own sync subscription` );
}
}
}
catch( error ) {
logger.error( `error trying to check dead peer subscriptions: ${error.message}` );
}
yield delay( serverConstants.SAVE_INFO.CHECK_SUBSCRIPTION_INFO_PERIOD_IN_MS );
} while( true );
};
const findFirstDeadPeerForCurrentDeployment = ( possiblyDeadPeerPeriod ) => {
return new Promise(
( resolve, reject ) => {
let dateNow = Date.now();
let olderThanDateThreshold = new Date( dateNow - possiblyDeadPeerPeriod );
let query = dsClient.createQuery( datastoreConstants.DS_ENTITY_KEY_SERVER_SUBSCRIPTION_INFO )
.filter( 'ts', '<', olderThanDateThreshold )
.order( 'ts', { descending: true } );
let stream = query.runStream();
let deadPeer;
stream
.on(
'error',
reject
)
.on(
'data',
( entity ) => {
if( typeof entity[ 'topicName' ] === 'undefined' ) {
return;
}
if( entity.topicName !== config.syncTopicName ) {
return;
}
let timeAgo = new Date( dateNow ) - entity.ts;
if( timeAgo < possiblyDeadPeerPeriod ) {
return;
}
deadPeer = entity;
stream.end();
}
)
.on(
'info',
( info ) => {}
)
.on(
'end',
() => {
resolve( deadPeer );
}
)
}
);
};
const maybeDeletePeerSubscription = function* ( entity ) {
let shouldDeletePeerSubscription = false;
try {
// first, check against the HTTP endpoint to see if it's still alive
let healthCheckUrl = `${'http://'}${entity.ip}:${entity.port}${lbHealthCheckUrl}`;
let options = {
timeout: 10000
};
logger.info( `health-checking possibly-dead peer: ${entity.topicName} | ${entity.serverId} | ${entity.ip}:${entity.port} | ${entity.ts.toISOString().replace(/^([^T]+)T([^Z]+)Z$/, '$1 $2' )}` );
// fetch() throws on network errors & timeouts
let result = yield fetch(
healthCheckUrl,
options
);
logger.trace( `got HTTP status ${result.status} from health-check for peer ${entity.serverId}` );
}
catch( error ) {
logger.info( `got network/timeout error health-checking peer ${entity.serverId}, marking for deletion` );
shouldDeletePeerSubscription = true;
}
if( shouldDeletePeerSubscription === false ) {
logger.trace( `found peer alive at ${entity.ip}:${entity.port} while checking subscription for peer ${entity.serverId}` );
let peerState = yield select( ( state ) => { return state.serverReducer.peers; } );
// if the peer's IP is alive but the ID isn't in the peer list,
// another server must have taken over the IP address, so this
// server should carry on and delete the old subscription info
if( Object.keys( peerState ).indexOf( entity.serverId ) > 0 ) {
return;
}
logger.info( `IP is responding but server ${entity.serverId} is not in peer list, deleting old subscription info` );
}
let topic = yield pubsubClient.getPubsubTopic( entity.topicName );
if( !topic.exists() ) {
logger.warn( `not deleting subscription to topic ${entity.topicName}, no such topic exists` );
return;
}
let subscription = topic.subscription( entity.subscriptionName );
if( !subscription.exists() ) {
logger.warn( `not deleting subscription ${entity.subscriptionName}, no such subscription exists` );
return;
}
try {
logger.info( `deleting PubSub subscription ${entity.subscriptionName}` );
let pubsubDeleteResult = yield subscription.delete();
}
catch( error ) {
// 404 just means another peer deleted this first
if( error.code !== 404 ) {
// other errors need to be flagged
logger.error( `error deleting subscription ${entity.subscriptionName} for supposedly-dead peer ${entity.serverId}: ${error.message}` );
return;
}
}
try {
logger.info( `deleting Datastore record for subscription ${entity.subscriptionName}` );
let datastoreDeleteResult = yield dsClient.delete( entity[ datastore.KEY ] );
}
catch( error ) {
logger.error( `error deleting Datastore record for supposedly-dead peer ${entity.serverId}: ${error.message}` );
}
};
const checkMonitorForPeer = function* ( action ) {
let serverState = yield select( ( state ) => { return state.serverReducer; } );
// check if a monitor already exists
if( typeof serverState.peers[ action.peerId ].monitorTask === 'undefined' ) {
try {
// create one
let task = yield fork(
peerMonitorTaskFunction,
action.peerId,
action.data.ip,
action.data.port
);
// add to state so next check passes
yield put(
serverActions.setMonitorTaskForPeerRequestAction(
task,
action.peerId
)
);
}
catch( error ) {
logger.error( `error trying to create monitor task for peer ${action.peerId}` );
}
}
};
const peerMonitorTaskFunction = function* ( peerId, ip, port ) {
try {
logger.info( `starting monitor for peer ${peerId} at ${ip}:${port}` );
do {
let peerState = yield select( ( state ) => { return state.serverReducer.peers; } );
let lastHeartbeatTime = peerState[ peerId ].lastHeartbeatTime;
// assuming the sync heartbeat has started
if( lastHeartbeatTime > 0 ) {
// if the last one was too long ago
if( ( Date.now() - lastHeartbeatTime ) > serverConstants.SYNC_INFO.SYNC_HEARTBEAT_TIMEOUT_IN_MS ) {
try {
// first, check against the HTTP endpoint to see if it's still alive
let healthCheckUrl = `${'http://'}${ip}:${port}${lbHealthCheckUrl}`;
let options = {
timeout: 5000
};
// fetch() throws on network errors & timeouts
let result = yield fetch(
healthCheckUrl,
options
);
// if it's not returning HTTP 200 OK, throw to trigger removal from peer list
if( result.status !== 200 ) {
throw new Error( `received HTTP status ${result.status} from health-check` );
}
}
catch( error ) {
// assume it's died
logger.warn( `error health-checking peer ${peerId} after monitor timeout: ${error.message}` );
// remove from peer list
yield put(
serverActions.removePeerFromListRequestAction( peerId )
);
break;
}
}
}
// check every half second or so, assuming 5-second timeout
yield delay( Math.floor( serverConstants.SYNC_INFO.SYNC_HEARTBEAT_TIMEOUT_IN_MS / 8 ) );
} while ( true );
}
catch( error ) {
logger.warn( `error starting monitor for peer ${peerId} at ${ip}:${port}` );
}
finally {
if( yield cancelled() ) {
logger.trace( `cancelled monitor for peer ${peerId}` );
}
}
};
/*******************************************************************************
* SAGA WATCHERS
*******************************************************************************/
const watchStartServerSetupRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.START_SERVER_SETUP,
serverSetup.startServerSetup
);
};
const watchStartSyncSubscriptionWarmupRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.START_SYNC_SUBSCRIPTION_WARMUP,
serverSetup.startSyncSubscriptionWarmup
);
};
const watchFinishServerSetupRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.FINISH_SERVER_SETUP,
serverSetup.finishServerSetup
);
};
const watchStartSendingSyncHeartbeatRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.START_SENDING_SYNC_HEARTBEAT,
startSendingSyncHeartbeat
);
};
const watchLoadRateLimiterInfoRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.LOAD_RATE_LIMITER_INFO_FROM_DATASTORE,
loadRateLimiterInfo
);
};
const watchStartSavingSubscriptionInfoRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.START_SAVING_SUBSCRIPTION_INFO,
startSavingSubscriptionInfo
);
};
const watchStartCheckingDeadPeerSubscriptionsRequests = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.START_CHECKING_DEAD_PEER_SUBSCRIPTIONS,
startCheckingDeadPeerSubscriptions
);
};
/*******************************************************************************
* WHEN SERVERS INTRODUCE THEMSELVES, START MONITORING THEIR HEARTBEATS
*******************************************************************************/
const watchPeerHeartbeatEvents = function* () {
yield takeEvery(
serverConstants.ACTION_TYPES.SET_LAST_HEARTBEAT_FOR_PEER,
checkMonitorForPeer
);
};
export default {
setupServerSaga: function* () {
const sagas = [
watchStartServerSetupRequests,
watchFinishServerSetupRequests
];
yield sagaUtils.spawnAutoRestartingSagas( sagas );
},
syncSaga: function* () {
const sagas = [
watchStartSyncSubscriptionWarmupRequests,
watchStartSendingSyncHeartbeatRequests,
watchLoadRateLimiterInfoRequests,
watchStartSavingSubscriptionInfoRequests,
watchStartCheckingDeadPeerSubscriptionsRequests,
watchPeerHeartbeatEvents,
];
yield sagaUtils.spawnAutoRestartingSagas( sagas );
}
};
================================================
FILE: backend/src/server/server-setup.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import storage from '@google-cloud/storage';
import { put, select } from 'redux-saga/effects';
import { delay } from 'redux-saga';
import { logger } from '../logger';
import { pubsubClient } from '../pubsub';
import { getStoreState } from '../store';
import config from '../config';
import serverActions from './server-actions';
import serverConstants from './server-constants';
import serverSync from './server-sync';
import serverStartup from './server-startup';
/*
* kick off setup, get sync channel topic & subscription
*/
const startServerSetup = function* ( action ) {
logger.info( `connecting to sync channel '${config.syncTopicName}'` );
// get the PubSub sync topic
let topic;
try {
topic = yield getPubsubSyncTopic();
}
catch( error ) {
logger.error( `pubsub error creating sync channel: ${error.message}` );
process.exit( 1 );
}
// create a subscription for the sync topic
let subscription;
try {
subscription = yield getPubsubSyncSubscriptionForTopic( topic );
}
catch( error ) {
logger.error( `pubsub error subscribing to sync channel: ${error.message}` );
process.exit( 1 );
}
// set message & error handlers for the sync subscription
setSyncSubscriptionListeners( subscription );
// add sync subscription to server state
yield put( serverActions.setSyncSubscriptionRequestAction( subscription ) );
// add sync topic to server state
yield put( serverActions.setSyncTopicRequestAction( topic ) );
logger.trace( `connected to sync channel, subscription ${subscription.name}` );
// make sure the pubsub sync topic is warmed up so it doesn't kill monitors
yield put( serverActions.startSyncSubscriptionWarmupRequestAction() );
yield put( serverActions.loadRateLimiterInfoRequestAction() );
};
/*
* warm up the sync channel Pubsub subscription
*/
const startSyncSubscriptionWarmup = function* ( action ) {
logger.info( `starting sync channel subscription warmup` );
let syncWarmupStartTime = Date.now();
while( true ) {
try {
yield serverSync.sendSyncMessageOfType(
null,
serverConstants.SYNC_MESSAGES.SYNC_WARMUP
);
}
catch( error ) {
// this will be a PubSub operational issue, and thus transient
};
// small delay period = lots of messages = hopefully warm up faster
yield delay( 10 );
let syncIsWarmedUp = yield getSyncWarmedUpStatus();
if( syncIsWarmedUp ) {
break;
}
}
// ready to roll
let syncWarmupTotalTime = (Date.now() - syncWarmupStartTime) / 1000;
logger.trace( `sync channel subscription warmup took ${syncWarmupTotalTime} seconds` );
// sync channel subscription is warmed up, start up the app/websocket servers
yield put( serverActions.finishServerSetupRequestAction() );
};
/*
* work out whether the sync channel is warmed up yet
*/
const getSyncWarmedUpStatus = function* () {
let receiptTimes = getStoreState().serverReducer.syncWarmup.receiptTimes;
// make sure we have enough receipt times to make it worth calculating
if( receiptTimes.length < serverConstants.SYNC_INFO.SYNC_WARMUP_MESSAGE_LIST_LENGTH ) {
return false;
}
// work out the differences between the receipt times
let differences = [];
for( let i = 0; i < receiptTimes.length - 1 ; i++ ) {
differences[ i ] = receiptTimes[ i+1 ] - receiptTimes[ i ];
}
// 0 difference means they're still being batched
let nonZeroDifferences = differences.reduce(
( acc, difference ) => {
if( difference == 0 ) {
return acc;
}
acc.push( 1 );
return acc;
},
[]
);
// fail if less than half the list are still being batched
if( nonZeroDifferences.length < ( Math.ceil( serverConstants.SYNC_INFO.SYNC_WARMUP_MESSAGE_LIST_LENGTH ) / 2 ) ) {
return false;
}
// should be warmed up by now
return true;
}
/*
* broadcast intro on sync channel, start app/websocket servers
*/
const finishServerSetup = function* ( action ) {
// decide whether to serve SSL, & get certs from storage if so
let sslInfo = config.sslInfo.useSsl ? yield getSslCertInfo() : { useSsl: false };
// start sending heartbeats to the sync channel
yield put( serverActions.startSendingSyncHeartbeatRequestAction() );
// start saving ID & sync subscription name to datastore
// so that peers can clear the subscription if this server dies
yield put( serverActions.startSavingSubscriptionInfoRequestAction() );
// start checking the datastore for peer subscription info saved
// long enough ago that the peer might be dead
yield put( serverActions.startCheckingDeadPeerSubscriptionsRequestAction() );
// create the app server & websocket server
let { app, wsServer, balancer } = serverStartup.startServer( sslInfo );
// save the app/websocket server & balancer references in the server state
yield put( serverActions.setAppServerRequestAction( app ) );
yield put( serverActions.setWebsocketServerRequestAction( wsServer ) );
yield put( serverActions.setBalancerRequestAction( balancer ) );
};
/*
* retrieve SSL certificate data from Cloud Storage
*/
const getSslCertInfo = function* () {
logger.info(
`retrieving SSL cert data for ${config.sslInfo.sslCertHostName} ` +
`from bucket ${config.sslInfo.sslStorageBucketName}`
);
const gcs = storage( { projectId: config.projectId } );
const bucket = gcs.bucket( config.sslInfo.sslStorageBucketName );
const privKeyFile = bucket.file( config.sslInfo.privKeyFileName );
const fullChainFile = bucket.file( config.sslInfo.fullChainFileName );
try {
let privKeyResult = yield privKeyFile.download();
let privKeyData = privKeyResult[ 0 ];
let fullChainResult = yield fullChainFile.download();
let fullChainData = fullChainResult[ 0 ];
return {
useSsl: true,
options: {
key: privKeyData,
cert: fullChainData
}
};
}
catch( error ) {
logger.error(
`error retrieving SSL cert data for ${config.sslInfo.sslCertHostName}, ` +
`unable to start SSL: got error code ${error.code} (${error.message}) ` +
`fetching ${error.response.req.path}`
);
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}
};
const getPubsubSyncTopic = function* () {
// get the sync topic
let topic = yield pubsubClient.getPubsubTopic( config.syncTopicName );
// it should already exist, autoCreate only on first server/app boot.
// if it throws, let it throw, and be caught & exited above -
// this is critical to the app running.
let topicResult = yield topic.get( { autoCreate: true } );
topic = topicResult[ 0 ];
return topic;
};
const getPubsubSyncSubscriptionForTopic = function* ( topic ) {
// create a sync channel subscription for this server
let subscriptionName = [ config.syncTopicName, '-', config.serverId ].join( '' );
let subscriptionOptions = {
autoAck: true,
maxInProgress: 20
};
// subscribe it to the topic
let subscriptionResult = yield topic.subscribe( subscriptionName, subscriptionOptions );
let subscription = subscriptionResult[ 0 ];
return subscription;
};
const setSyncSubscriptionListeners = ( subscription ) => {
// set a message listener
let syncChannelMessageHandler = ( message ) => {
serverSync.handleSyncMessageWithLocalServerId( message, config.serverId );
};
subscription.on( 'message', syncChannelMessageHandler );
// set error handler
let syncChannelErrorHandler = ( error ) => {
serverSync.handleSyncErrorWithLocalServerId( error, config.serverId );
};
subscription.on( 'error', syncChannelErrorHandler );
};
export default {
startServerSetup,
startSyncSubscriptionWarmup,
finishServerSetup
};
================================================
FILE: backend/src/server/server-startup.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'http';
import https from 'https';
import httpProxy from 'http-proxy';
import { FastRateLimit } from 'fast-ratelimit';
import { logger } from '../logger';
import config from '../config';
import { getBalancerFunction } from './balancer';
import app_server from './app-server';
import ws_server from './websocket-server';
import serverConstants from './server-constants';
const errorHandler = ( error ) => {
logger.error( `error starting server: ${error.message}` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
};
const startServer = ( sslInfo ) => {
// http server structure for room-server
let server = http.createServer();
// create websocket server to run the websocket app
let wsServer = ws_server.startWebsocketServer( server );
wsServer.on( 'error', errorHandler );
// create express app to handle HTTP requests
let app = app_server.createApp();
app.on( 'error', errorHandler );
// attach express app to plain-http server
server.on( 'request', app );
// create balancing proxy server to send room requests to correct nodes
let proxyServer;
let proxyTimeout = 2000;
let balancer;
// don't serve non-WS HTTP(S) requests
let httpFunction = ( req, res ) => {
res.statusCode = 403;
res.end();
return;
};
// decide whether to serve HTTPS ...
if( sslInfo.useSsl ) {
logger.info( `serving app over SSL using certs for hostname ${config.sslInfo.sslCertHostName}` );
let proxyOptions = {
ssl: sslInfo.options,
proxyTimeout
};
proxyServer = httpProxy.createProxyServer( proxyOptions );
balancer = https.createServer( sslInfo.options, httpFunction );
}
// ... or HTTP
else {
logger.info( `serving app over plain HTTP` );
let proxyOptions = { proxyTimeout };
proxyServer = httpProxy.createProxyServer( proxyOptions );
balancer = http.createServer( httpFunction );
}
// attach error handler to whichever of http/https is being used
proxyServer.on(
'error',
( err ) => {
logger.error( `proxy error ${err.code} trying to '${err.syscall}' to ${err.address}:${err.port}` );
}
);
balancer.on(
'error',
( err ) => {
logger.error( `balancer error:`, err );
}
);
// add the node-balancing websocket handler to the balancer server
balancer.on( 'upgrade', getBalancerFunction( proxyServer ) );
// run the server & the balancer
try {
server.listen(
config.serverPort,
() => {
logger.info( `starting WS server on port ${config.serverPort}` );
logger.info( `allowing ${config.maxClientsPerRoom} clients per room` );
}
);
balancer.listen(
config.balancerPort,
() => {
logger.info( `starting balancer on port ${config.balancerPort}` );
}
);
return {
app,
wsServer,
balancer
};
}
catch( error ) {
logger.error( `=> error starting server: ${error.message}` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}
};
export default {
startServer
};
================================================
FILE: backend/src/server/server-sync-handlers.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverActions from './server-actions';
import serverConstants from './server-constants';
import { logger } from '../logger';
import {
dispatchStoreAction,
getStoreState
} from '../store';
// new server, possibly this one, has joined & is warming up its sync channel subscription
const handleSyncWarmup = ( msgPayload, myId ) => {
// not interested in other servers' subscription warmup messages
if( msgPayload.from !== myId ) {
return;
}
// update our state with a new warmup response
dispatchStoreAction( serverActions.addSyncSubscriptionWarmupResponseRequestAction() );
};
// store peer's last sync heartbeat so as to be able to monitor over time
const handleSyncHeartbeat = ( msgPayload, myId ) => {
dispatchStoreAction(
serverActions.setLastHeartbeatForPeerRequestAction(
msgPayload.data,
msgPayload.from
)
);
};
// handle pubsub request to reload rater limiter info from datastore
const handleLoadRateLimiterInfo = () => {
dispatchStoreAction( serverActions.loadRateLimiterInfoRequestAction() );
};
export default {
handleSyncWarmup,
handleSyncHeartbeat,
handleLoadRateLimiterInfo
};
================================================
FILE: backend/src/server/server-sync.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { select } from 'redux-saga/effects';
import { logger } from '../logger';
import { serialize, deserialize } from '../s11n';
import config from '../config';
import serverConstants from './server-constants';
import serverSyncHandlers from './server-sync-handlers';
// wrapped in a closure because node.js doesn't seem to parse dots in object literal key definitions
const handlerFunctions = ( () => {
let m = serverConstants.SYNC_MESSAGES;
let obj = {};
obj[ m.SYNC_WARMUP ] = serverSyncHandlers.handleSyncWarmup;
obj[ m.SYNC_HEARTBEAT ] = serverSyncHandlers.handleSyncHeartbeat;
obj[ m.LOAD_RATE_LIMITER_INFO ] = serverSyncHandlers.handleLoadRateLimiterInfo;
return obj;
} )();
const handleSyncMessageWithLocalServerId = ( message, localServerId ) => {
let msgPayload = deserialize( message.data );
// the only self-sent messages servers care about is warmup
let interestingSelfSentMessageTypes = [
serverConstants.SYNC_MESSAGES.SYNC_WARMUP
];
// disregard uninteresting local messages
if( interestingSelfSentMessageTypes.indexOf( msgPayload.type ) < 0 ) {
if( msgPayload.from === localServerId ) {
return;
}
}
if( handlerFunctions[ msgPayload.type ] ) {
handlerFunctions[ msgPayload.type ]( msgPayload, localServerId );
}
else {
logger.trace( `got sync message of type ${msgPayload.type}: `, msgPayload );
}
};
const handleSyncErrorWithLocalServerId = ( error, localServerId ) => {
logger.error( `server ${localServerId} exiting because of error on sync channel: ${error.message}` );
process.exit( 1 );
};
const sendSyncMessageOfType = function* ( message, type ) {
let serverState = yield select( ( state ) => { return state.serverReducer; } );
let syncTopic = serverState.syncTopic;
let messageObject = {
from: config.serverId,
type: type
};
if( message ) {
messageObject.data = message;
}
let serializedMessage = serialize( messageObject );
return yield syncTopic.publish( serializedMessage );
};
export default {
handleSyncMessageWithLocalServerId,
handleSyncErrorWithLocalServerId,
sendSyncMessageOfType
};
================================================
FILE: backend/src/server/target-server-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import config from '../config';
const localTargetServer = `${config.localIpAddress}:${config.serverPort}`;
const privateTargetServer = `127.0.0.1:${config.serverPort}`;
const privatiseTargetServerIfLocal = ( targetServer ) => {
if( targetServer === localTargetServer ) {
return privateTargetServer;
}
return targetServer;
};
export {
privatiseTargetServerIfLocal
};
================================================
FILE: backend/src/server/websocket-server.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Server } from 'uws';
import uuid from 'uuid';
import url from 'url';
import { logger } from '../logger';
import { messageHandler } from '../messages';
import {
makeWsReplyMessage,
sendWsMessageWithLogger
} from '../utils/websocket-utils';
import {
roomDataActions,
roomStateConstants
} from '../rooms';
import {
dispatchStoreAction,
getStoreState
} from '../store';
import config from '../config';
import messageConstants from '../messages/message-constants';
import { perClientRateLimiter } from '../messages/message-rate-limiter';
import { urlUtils } from '../utils';
import serverActions from './server-actions';
import serverConstants from './server-constants';
const CUSTOM_PROXY_HEADERS = serverConstants.CUSTOM_PROXY_HEADERS;
const outgoingMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;
const startWebsocketServer = ( http_server ) => {
// create websocket server object
let wssConf = { server: http_server };
let wss = new Server( wssConf );
// predefined 'invalid url' message
const badUrlMsg = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.INVALID_URL
);
// predefined 'no rooms available' message
const noRoomsAvailableMsg = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.NO_ROOMS_AVAILABLE
);
// predefined 'room full' message
const roomFullMsg = makeWsReplyMessage(
config.serverId,
messageConstants.ERROR_TYPES.ROOM_FULL
);
// set up an incoming websocket connection
wss.on( 'connection', ( ws ) => {
let headers = ws.upgradeReq.headers;
// if the proxy flagged no rooms available, close the connection
if( typeof headers[ CUSTOM_PROXY_HEADERS.X_NO_ROOMS_AVAILABLE ] !== 'undefined' ) {
sendWsMessageWithLogger( ws, noRoomsAvailableMsg, logger );
ws.close();
return;
}
// if the proxy marked the request to be closed, close the connection
if( typeof headers[ CUSTOM_PROXY_HEADERS.X_PLEASE_CLOSE_THIS_CONNECTION ] !== 'undefined' ) {
sendWsMessageWithLogger( ws, badUrlMsg, logger );
ws.close();
return;
}
// if some kind of problem stopped a name being specified, close the connection
if( typeof headers[ CUSTOM_PROXY_HEADERS.X_REQUESTED_ROOM_NAME ] === 'undefined' ) {
sendWsMessageWithLogger( ws, badUrlMsg, logger );
ws.close();
return;
}
// if this server's running in production ...
if( config.projectId === config.productionEnvironmentProjectId ) {
// ... and the client didn't provide the correct Origin header
let parsedUrl = url.parse( headers.origin );
if( parsedUrl.hostname !== config.productionEnvironmentRequiredOrigin ) {
sendWsMessageWithLogger( ws, badUrlMsg, logger );
ws.close();
return;
}
}
// generate UUID to identify connection
ws.id = uuid();
ws.lastAction = Date.now();
// get the headset type & optional requested room from custom headers set by proxy
ws.headsetType = ws.upgradeReq.headers[ CUSTOM_PROXY_HEADERS.X_CLIENT_HEADSET_TYPE ];
ws.requestedRoomName = ws.upgradeReq.headers[ CUSTOM_PROXY_HEADERS.X_REQUESTED_ROOM_NAME ];
// if the client requested the room directly
if( typeof headers[ CUSTOM_PROXY_HEADERS.X_CLIENT_SPECIFIED_ROOM_NAME ] !== 'undefined' ) {
let specifiedRoomName = headers[ CUSTOM_PROXY_HEADERS.X_REQUESTED_ROOM_NAME ];
let roomState = getStoreState().roomStateReducer;
// belt & braces
if( typeof roomState.rooms[ specifiedRoomName ] === undefined ) {
sendWsMessageWithLogger( ws, badUrlMsg, logger );
ws.close();
return;
}
// only MAX_CLIENTS_PER_ROOM allowed into client-specified room names
if( roomState.rooms[ specifiedRoomName ].status === roomStateConstants.STATES.ROOM_FULL ) {
sendWsMessageWithLogger( ws, roomFullMsg, logger );
ws.close();
return;
}
// TODO: clients only allowed into rooms with availability for their headset type
// check against roomDataReducer.avails[ clientHeadsetType ]
// -- empty rooms also allowed ( roomStateReducer.roomsByState )
}
// CLIENT: tell the client its id and the id of the server it's connected to
let clientMsgData = {
[ outgoingMsgComponents.CONNECTION_INFO.CLIENT_ID ]: ws.id,
[ outgoingMsgComponents.CONNECTION_INFO.SERVER_ID ]: config.serverId
};
let clientMsg = makeWsReplyMessage(
config.serverId,
messageConstants.OUTGOING_MESSAGE_TYPES.CONNECTION_INFO,
clientMsgData
);
try{
sendWsMessageWithLogger( ws, clientMsg, logger );
}
catch( error ) {
logger.trace( `error sending error message to client ${ws.id} while sending room info:`, error.message );
}
// add to the server's state, to make accessible via id
dispatchStoreAction(
serverActions.addWebsocketToStateRequestAction( ws )
);
// route messages from the connection via message-handler/validator/sagas
ws.on( 'message', ( message, flags ) => {
// drop all viewer messages in production?
if( config.dropViewerMessagesInProduction === true ) {
if( config.projectId === config.productionEnvironmentProjectId ) {
if( ws.headsetType === messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER ) {
return;
}
}
}
// keep track of last time client did anything for inactivity-timeout
ws.lastAction = Date.now();
if( config.rateLimitInfo.perClientRateLimit === true ) {
// rate-limit overall msg rate per client
perClientRateLimiter.consume( ws.id )
.then( () => {
messageHandler.handleWebsocketMessage( ws, message );
})
.catch( () => {} );
}
else {
messageHandler.handleWebsocketMessage( ws, message );
}
} );
// add a handler for when the connection closes
ws.on( 'close', () => {
if( ws.currentRoom ) {
logger.trace( `client ${ws.id} disconnected, leaving room ${ws.currentRoom}` );
dispatchStoreAction(
roomDataActions.removeLocalClientFromRoomRequestAction(
ws.id,
ws.currentRoom
)
);
}
else {
logger.trace( `client ${ws.id} disconnected, not from any room` );
}
// remove from the server's state, to make inaccessible via id
dispatchStoreAction(
serverActions.removeWebsocketFromStateRequestAction( ws )
);
} );
// belt & braces
ws.on( 'error', ( error ) => {
logger.warn( `websocket error on connection ${ws.id}: ${error.message}` );
} );
} );
return wss;
};
export default {
startWebsocketServer
};
================================================
FILE: backend/src/spheres/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import sphereConstants from './sphere-constants';
import messageConstants from '../messages/message-constants';
const incomingMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const toneLabel = incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE;
const positionLabel = incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION;
const connectionsLabel = incomingMsgComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS;
const makeSphereOfToneAtPosition = ( tone, position ) => {
return {
[ toneLabel ]: tone,
[ positionLabel ]: position,
[ connectionsLabel ]: []
};
};
const trees = [
require('./trees/0'),
require('./trees/1'),
require('./trees/2'),
require('./trees/3'),
require('./trees/4'),
require('./trees/5'),
]
export {
makeSphereOfToneAtPosition,
sphereConstants,
trees
};
================================================
FILE: backend/src/spheres/sphere-constants.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const SPHERE_INFO = {
DEFAULT_NUMBER_OF_SPHERES_PER_ROOM: 5,
MAX_NUMBER_OF_SPHERES_PER_ROOM: 50,
MAX_NUMBER_OF_CONNECTIONS_PER_SPHERE: 10,
SPHERE_HOLD_TIMEOUT_IN_MS: 5000,
MINIMUM_STRIKE_VELOCITY: 0,
MAXIMUM_STRIKE_VELOCITY: 127,
LOWEST_TONE: 0,
HIGHEST_TONE: 17
};
export default {
SPHERE_INFO
};
================================================
FILE: backend/src/spheres/trees/0.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": 0.3,
"z": 0,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"1": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -0.14999999999999994,
"z": 0.2598076211353316,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"2": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.15000000000000013,
"z": -0.2598076211353315,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"3": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": 3.061616997868383e-17,
"z": 0.5,
"y": 1.6
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"4": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": -0.5,
"z": 6.123233995736766e-17,
"y": 1.6
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"5": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": -9.184850993605148e-17,
"z": -0.5,
"y": 1.6
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"6": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": 0.5,
"z": -1.2246467991473532e-16,
"y": 1.6
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
},
"7": {
[ toneLabel ]: 0,
[ positionLabel ]: {
"x": 0,
"z": 0,
"y": 2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
}
}
================================================
FILE: backend/src/spheres/trees/1.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 21,
[ positionLabel ]: {
"x": 0,
"z": 0,
"y": 2
},
[ meristemLabel ]: false,
[ connectionsLabel ]: []
},
"1": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": 0.21,
"z": 0,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"2": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": -0.10499999999999995,
"z": 0.18186533479473213,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
1,
3
]
},
"3": {
[ toneLabel ]: 18,
[ positionLabel ]: {
"x": -0.1050000000000001,
"z": -0.18186533479473205,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"4": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": 0.42,
"z": 0,
"y": 1.4
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
5
]
},
"5": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.2099999999999999,
"z": 0.36373066958946426,
"y": 1.4
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4,
6
]
},
"6": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": -0.2100000000000002,
"z": -0.3637306695894641,
"y": 1.4
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
5
]
},
"7": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": 0.63,
"z": 0,
"y": 1.1
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
8
]
},
"8": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.31499999999999984,
"z": 0.5455960043841964,
"y": 1.1
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
7,
9
]
},
"9": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.3150000000000003,
"z": -0.5455960043841962,
"y": 1.1
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
8
]
},
"10": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": 0.84,
"z": 0,
"y": 0.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
11
]
},
"11": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -0.4199999999999998,
"z": 0.7274613391789285,
"y": 0.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
10,
12
]
},
"12": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.4200000000000004,
"z": -0.7274613391789282,
"y": 0.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
11
]
}
}
================================================
FILE: backend/src/spheres/trees/2.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 0,
[ positionLabel ]: {
"x": 0.3,
"z": 0,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
1,
2
]
},
"1": {
[ toneLabel ]: 2,
[ positionLabel ]: {
"x": 0.6687355423879241,
"z": -0.20686414466293768,
"y": 1.3
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
0
]
},
"2": {
[ toneLabel ]: 4,
[ positionLabel ]: {
"x": 0.48296291314453416,
"z": 0.12940952255126037,
"y": 1.45
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
0,
3,
4
]
},
"3": {
[ toneLabel ]: 6,
[ positionLabel ]: {
"x": 0.7938667524116582,
"z": 0.42399950402726544,
"y": 1.55
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"4": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": 0.6062177826491071,
"z": 0.3499999999999999,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2,
5,
6
]
},
"5": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": 1.027104089199748,
"z": 0.3937730183102396,
"y": 1.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4
]
},
"6": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": 0.636396103067893,
"z": 0.6363961030678928,
"y": 1.95
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4
]
},
"7": {
[ toneLabel ]: 3,
[ positionLabel ]: {
"x": -0.14999999999999994,
"z": 0.2598076211353316,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
8,
9
]
},
"8": {
[ toneLabel ]: 5,
[ positionLabel ]: {
"x": -0.15521816678371875,
"z": 0.6825740404529765,
"y": 1.3
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
7
]
},
"9": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": -0.35355339059327373,
"z": 0.3535533905932738,
"y": 1.45
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
7,
10,
11
]
},
"10": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -0.7641277178854433,
"z": 0.4755090227947147,
"y": 1.55
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
9
]
},
"11": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.6062177826491069,
"z": 0.3500000000000002,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
9,
12,
13
]
},
"12": {
[ toneLabel ]: 13,
[ positionLabel ]: {
"x": -0.854569481781416,
"z": 0.6926117244227405,
"y": 1.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
11
]
},
"13": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": -0.8693332436601615,
"z": 0.23293714059226894,
"y": 1.95
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
11
]
},
"14": {
[ toneLabel ]: 4,
[ positionLabel ]: {
"x": -0.15000000000000013,
"z": -0.2598076211353315,
"y": 1.2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
15,
16
]
},
"15": {
[ toneLabel ]: 6,
[ positionLabel ]: {
"x": -0.5135173756042053,
"z": -0.4757098957900386,
"y": 1.3
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
14
]
},
"16": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": -0.12940952255126076,
"z": -0.48296291314453405,
"y": 1.45
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
14,
17,
18
]
},
"17": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": -0.0297390345262156,
"z": -0.8995085268219799,
"y": 1.55
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
16
]
},
"18": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": -1.2858791391047207e-16,
"z": -0.7,
"y": 1.7
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
16,
19,
20
]
},
"19": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.17253460741833146,
"z": -1.0863847427329798,
"y": 1.8
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
18
]
},
"20": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": 0.2329371405922683,
"z": -0.8693332436601617,
"y": 1.95
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
18
]
}
}
================================================
FILE: backend/src/spheres/trees/3.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 19,
[ positionLabel ]: {
"x": -0.3801701800236835,
"z": 0.22714110856547276,
"y": 0.9711673841367696
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
1
]
},
"1": {
[ toneLabel ]: 21,
[ positionLabel ]: {
"x": -0.6427852083066042,
"z": 0.009616761652669642,
"y": 1.1711673841367696
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
0
]
},
"2": {
[ toneLabel ]: 18,
[ positionLabel ]: {
"x": -0.3717230728255157,
"z": -0.35540364805038743,
"y": 1.0697267415202358
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
3
]
},
"3": {
[ toneLabel ]: 20,
[ positionLabel ]: {
"x": -0.20030527807571613,
"z": -0.6856251725306349,
"y": 1.2697267415202358
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"4": {
[ toneLabel ]: 17,
[ positionLabel ]: {
"x": 0.2775516451627562,
"z": -0.5157773829446608,
"y": 1.3804880182088852
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
5
]
},
"5": {
[ toneLabel ]: 19,
[ positionLabel ]: {
"x": 0.6683914801829096,
"z": -0.4130372477082535,
"y": 1.5804880182088852
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4
]
},
"6": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": 0.6406669137194841,
"z": 0.14622804231414932,
"y": 1.5521284395792219
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
7
]
},
"7": {
[ toneLabel ]: 18,
[ positionLabel ]: {
"x": 0.6283301758541369,
"z": 0.5830052038036452,
"y": 1.7521284395792218
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
6
]
},
"8": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": 0.03268723354108966,
"z": 0.7278378056229577,
"y": 1.4375443946415316
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
9
]
},
"9": {
[ toneLabel ]: 17,
[ positionLabel ]: {
"x": -0.42773941827558637,
"z": 0.8241868040756578,
"y": 1.6375443946415316
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
8
]
},
"10": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.7608452130361227,
"z": 0.24721359549995825,
"y": 1.3097886967409693
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
11
]
},
"11": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": -0.9781476007338058,
"z": -0.2079116908177584,
"y": 1.5097886967409693
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
10
]
},
"12": {
[ toneLabel ]: 13,
[ positionLabel ]: {
"x": -0.48006736955111823,
"z": -0.7272709782428493,
"y": 1.461249175138151
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
13
]
},
"13": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": -0.06407587922682341,
"z": -1.0695108533225732,
"y": 1.6612491751381508
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
12
]
},
"14": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": 0.6203422273145859,
"z": -0.7100388108034049,
"y": 1.7744448880450852
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
15
]
},
"15": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": 1.0815170100561873,
"z": -0.36938246566224114,
"y": 1.9744448880450851
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
14
]
},
"16": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": 0.9138398517295967,
"z": 0.4400820782478085,
"y": 1.8944794878661984
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
17
]
},
"17": {
[ toneLabel ]: 13,
[ positionLabel ]: {
"x": 0.6840314990772564,
"z": 1.0032899402408502,
"y": 2.0944794878661983
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
16
]
},
"18": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": -0.19386177149566014,
"z": 1.0682664104785127,
"y": 1.7500029067545588
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
19
]
},
"19": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": -0.8313423444203109,
"z": 0.9807808781086307,
"y": 1.9500029067545588
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
18
]
},
"20": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -1.1524845401944908,
"z": 0.10372548601683089,
"y": 1.6579479983438095
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
21
]
},
"21": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -1.2314153711992457,
"z": -0.5704848098486944,
"y": 1.8579479983438094
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
20
]
},
"22": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": -0.4316890695856524,
"z": -1.1502314125002475,
"y": 1.8582964637551596
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
23
]
},
"23": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": 0.2340255877359067,
"z": -1.4092722770336028,
"y": 2.0582964637551595
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
22
]
},
"24": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": 1.051722092687431,
"z": -0.7641208279802159,
"y": 2.1618033988749894
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
25
]
},
"25": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": 1.4917828430524098,
"z": -0.15679269490148218,
"y": 2.3618033988749896
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
24
]
}
}
================================================
FILE: backend/src/spheres/trees/4.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": 0.75,
"z": 0,
"y": 1
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
1
]
},
"1": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": 0.9310632489491795,
"z": 0.18873586425530814,
"y": 1.25
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
0
]
},
"2": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": 0.21822143065055674,
"z": 0.2736410188638104,
"y": 1.1428571428571428
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
3
]
},
"3": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": 0.384135321897057,
"z": 0.3936242554404447,
"y": 1.4928571428571429
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"4": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -0.16689070046723575,
"z": 0.7311959341363677,
"y": 1.2857142857142856
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
5
]
},
"5": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.3911849258208314,
"z": 0.8657218686221058,
"y": 1.5357142857142856
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4
]
},
"6": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.6757266509268144,
"z": -0.3254128043381685,
"y": 1.5714285714285714
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
7
]
},
"7": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": -0.8927946788297243,
"z": -0.32468086092858855,
"y": 1.9214285714285713
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
6
]
},
"8": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": -0.0778823268847101,
"z": -0.34122476926363826,
"y": 1.7142857142857144
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
9
]
},
"9": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.01341837989470709,
"z": -0.5498362911640168,
"y": 1.9642857142857144
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
8
]
},
"10": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": 0.35,
"z": -8.572527594031472e-17,
"y": 2
},
[ meristemLabel ]: true,
[ connectionsLabel ]: []
}
}
================================================
FILE: backend/src/spheres/trees/5.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import serverMessageConstants from '../../messages/message-constants';
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO;
const toneLabel = serverMsgComponents.TONE;
const positionLabel = serverMsgComponents.POSITION;
const meristemLabel = serverMsgComponents.MERISTEM;
const connectionsLabel = serverMsgComponents.CONNECTIONS;
module.exports = {
"0": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": 0.5,
"z": 0,
"y": 1.3
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
1
]
},
"1": {
[ toneLabel ]: 5,
[ positionLabel ]: {
"x": 0.8955037487502232,
"z": 0.08985007498214534,
"y": 1.4000000000000001
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
0
]
},
"2": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": 0.11126046697815722,
"z": 0.4874639560909118,
"y": 1.3357142857142859
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
3
]
},
"3": {
[ toneLabel ]: 6,
[ positionLabel ]: {
"x": 0.1116709845215571,
"z": 0.8930451227211232,
"y": 1.435714285714286
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
2
]
},
"4": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": -0.4504844339512095,
"z": 0.21694186955877912,
"y": 1.3714285714285714
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
5
]
},
"5": {
[ toneLabel ]: 7,
[ positionLabel ]: {
"x": -0.8458054852071072,
"z": 0.3075923945639262,
"y": 1.4714285714285715
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
4
]
},
"6": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": -0.31174490092936685,
"z": -0.39091574123401485,
"y": 1.4071428571428573
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
7
]
},
"7": {
[ toneLabel ]: 8,
[ positionLabel ]: {
"x": -0.4880898375488758,
"z": -0.756153628888675,
"y": 1.5071428571428573
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
6
]
},
"8": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": 0.3117449009293667,
"z": -0.39091574123401496,
"y": 1.4428571428571428
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
9
]
},
"9": {
[ toneLabel ]: 9,
[ positionLabel ]: {
"x": 0.6285850721951838,
"z": -0.6441124179934553,
"y": 1.542857142857143
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
8
]
},
"10": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": 0.45048443395120963,
"z": 0.21694186955877895,
"y": 1.4785714285714286
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
11
]
},
"11": {
[ toneLabel ]: 10,
[ positionLabel ]: {
"x": 0.7678365122206151,
"z": 0.46949663523914764,
"y": 1.5785714285714287
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
10
]
},
"12": {
[ toneLabel ]: 13,
[ positionLabel ]: {
"x": -0.11126046697815704,
"z": 0.48746395609091187,
"y": 1.5142857142857142
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
13
]
},
"13": {
[ toneLabel ]: 11,
[ positionLabel ]: {
"x": -0.2868656765450031,
"z": 0.8530580775189798,
"y": 1.6142857142857143
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
12
]
},
"14": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": -0.5,
"z": 1.8369701987210297e-16,
"y": 1.55
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
15
]
},
"15": {
[ toneLabel ]: 12,
[ positionLabel ]: {
"x": -0.8955037487502232,
"z": -0.08985007498214469,
"y": 1.6500000000000001
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
14
]
},
"16": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": -0.1112604669781574,
"z": -0.48746395609091175,
"y": 1.5857142857142859
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
17
]
},
"17": {
[ toneLabel ]: 13,
[ positionLabel ]: {
"x": -0.11167098452155783,
"z": -0.8930451227211231,
"y": 1.685714285714286
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
16
]
},
"18": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": 0.45048443395120946,
"z": -0.21694186955877928,
"y": 1.6214285714285714
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
19
]
},
"19": {
[ toneLabel ]: 14,
[ positionLabel ]: {
"x": 0.8458054852071069,
"z": -0.3075923945639269,
"y": 1.7214285714285715
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
18
]
},
"20": {
[ toneLabel ]: 17,
[ positionLabel ]: {
"x": 0.31174490092936696,
"z": 0.39091574123401474,
"y": 1.6571428571428573
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
21
]
},
"21": {
[ toneLabel ]: 15,
[ positionLabel ]: {
"x": 0.4880898375488761,
"z": 0.7561536288886749,
"y": 1.7571428571428573
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
20
]
},
"22": {
[ toneLabel ]: 18,
[ positionLabel ]: {
"x": -0.31174490092936585,
"z": 0.3909157412340156,
"y": 1.6928571428571428
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
23
]
},
"23": {
[ toneLabel ]: 16,
[ positionLabel ]: {
"x": -0.6285850721951823,
"z": 0.6441124179934566,
"y": 1.792857142857143
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
22
]
},
"24": {
[ toneLabel ]: 19,
[ positionLabel ]: {
"x": -0.4504844339512097,
"z": -0.21694186955877878,
"y": 1.7285714285714286
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
25
]
},
"25": {
[ toneLabel ]: 17,
[ positionLabel ]: {
"x": -0.7678365122206144,
"z": -0.4694966352391487,
"y": 1.8285714285714287
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
24
]
},
"26": {
[ toneLabel ]: 20,
[ positionLabel ]: {
"x": 0.11126046697815686,
"z": -0.48746395609091187,
"y": 1.7642857142857142
},
[ meristemLabel ]: true,
[ connectionsLabel ]: [
27
]
},
"27": {
[ toneLabel ]: 18,
[ positionLabel ]: {
"x": 0.2868656765450043,
"z": -0.8530580775189793,
"y": 1.8642857142857143
},
[ meristemLabel ]: false,
[ connectionsLabel ]: [
26
]
}
}
================================================
FILE: backend/src/store.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createStore, applyMiddleware, combineReducers } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { logger } from './logger';
import { messageSagas } from './messages';
import { roomSagas } from './rooms';
import {
roomDataReducer,
roomStateReducer
} from './rooms';
import {
serverConstants,
serverReducer,
serverSagas
} from './server';
let store = ( () => {
let sagaErrorTrap = ( error ) => {
logger.error( `sagaMiddleware trapped uncaught error, exiting - error was '${error.message}'` );
process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
};
let sagaMiddleware = createSagaMiddleware({
onError: sagaErrorTrap
});
let reducers = {
roomDataReducer,
roomStateReducer,
serverReducer
};
let rootReducer = combineReducers( reducers );
let allMiddleware = applyMiddleware( sagaMiddleware );
let store = createStore( rootReducer, {}, allMiddleware );
// see https://github.com/yelouafi/redux-saga/blob/master/examples/real-world/store/configureStore.prod.js
store.runSaga = ( saga ) => { sagaMiddleware.run( saga ); };
// when two sagas listen for the same action, they're run in this order
let sagaListsToRun = [
messageSagas,
serverSagas,
roomSagas
];
sagaListsToRun.forEach(
( sagaList ) => {
// run each named saga exported from this saga list
Object.keys( sagaList ).forEach(
( sagaName ) => {
store.runSaga( sagaList[ sagaName ] );
}
);
}
);
return store;
})();
const getStoreState = store.getState;
const dispatchStoreAction = store.dispatch;
export {
dispatchStoreAction,
getStoreState
};
================================================
FILE: backend/src/utils/index.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import reducerUtils from './reducer-utils';
import sagaUtils from './saga-utils';
import stringUtils from './string-utils';
import urlUtils from './url-utils';
export {
reducerUtils,
sagaUtils,
stringUtils,
urlUtils
};
================================================
FILE: backend/src/utils/reducer-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import sizeof from 'object-sizeof';
import { handleActions } from 'redux-actions';
const initStateObject = ( roomNames, createFunction, objectLabel, logger ) => {
let obj = {};
let start = Date.now();
roomNames.forEach(
( name ) => {
obj[ name ] = createFunction();
}
);
let finish = Date.now();
let millis = finish - start;
let seconds = millis / 1000;
let sizeofBytesUsed = sizeof( obj );
let sizeofKbytesUsed = Math.round( sizeofBytesUsed / 1024 );
let sizeofMbytesUsed = sizeofBytesUsed / 1024 / 1024;
logger.trace(
`initialised ${Object.keys( obj ).length} ${objectLabel} `
+ `using ~${sizeofKbytesUsed}KiB `
+ `(${sizeofMbytesUsed.toFixed( 2 )}MiB) `
+ `in ${seconds} seconds`
);
return obj;
};
// redux-ignore higher order reducer
// adapted from https://github.com/omnidan/redux-ignore
const filterActions = ( () => {
return function ( reducer ) {
var actions = arguments.length <= 1 || arguments[ 1 ] === undefined ? [] : arguments[ 1 ];
var isInList = ( action ) => {
return actions.indexOf( action.type ) >= 0;
};
var initialState = reducer( undefined, {} );
return function () {
var state = arguments.length <= 0 || arguments[ 0 ] === undefined ? initialState : arguments[ 0 ];
var action = arguments[ 1 ];
if ( !isInList( action ) ) {
return state;
}
return reducer( state, action );
};
};
})();
const createFilteredActionHandler = ( actionHandlers, initialState ) => {
let actionHandlerNames = Object.keys( actionHandlers );
const actionHandler = filterActions(
handleActions( actionHandlers, initialState ),
actionHandlerNames
);
return actionHandler;
}
export {
initStateObject,
filterActions,
createFilteredActionHandler
};
================================================
FILE: backend/src/utils/saga-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { call, put, spawn } from 'redux-saga/effects';
import { logger } from '../logger';
/*
* https://github.com/redux-saga/redux-saga/pull/644#issuecomment-272236599
*
* "Use spawn to start sagas so that an uncaught exception doesn't terminate all of them.
* Restart a saga on an asynchronous exception. Terminate it on a sync exception."
*/
const spawnAutoRestartingSagas = function* ( sagas ) {
yield sagas.map( ( saga ) => {
let restarter = function* () {
let isSyncError = false;
let previouslyStarted = false;
while (!isSyncError) {
isSyncError = true;
try {
setTimeout( () => { isSyncError = false; } );
if( previouslyStarted ) {
logger.info( `restarting crashed saga '${saga.name}'` );
}
previouslyStarted = true;
yield call( saga );
break;
}
catch ( error ) {
if ( isSyncError ) {
throw new Error( `saga '${saga.name}' was terminated because it threw an exception on startup.` );
}
logger.error( `uncaught error in saga '${saga.name}': ${error.message}` );
}
}
}
return spawn( restarter );
})
};
export default {
spawnAutoRestartingSagas
};
================================================
FILE: backend/src/utils/string-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const formatStringAsGcpResourceName = ( str ) => {
let charPattern = /[^A-Za-z0-9-]/gi;
let dashPattern = /-{2,}/gi;
return str.replace(charPattern, "-").replace(dashPattern, "");
};
export { formatStringAsGcpResourceName };
================================================
FILE: backend/src/utils/url-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import messageConstants from '../messages/message-constants';
const headsetTypes = Object.values( messageConstants.HEADSET_TYPES );
const getClientInfoFromUrl = ( url ) => {
let info = url.split( '/' );
info.shift();
if( info.length === 1 && info[ 0 ] === '' ) {
throw new Error( `bad URL: ${url}` );
}
if( info.length > 2 ) {
throw new Error( `bad URL: ${url}` );
}
let clientHeadsetType = info[ 0 ];
if( headsetTypes.indexOf( clientHeadsetType ) < 0 ) {
throw new Error( `bad headset type: ${clientHeadsetType}` );
}
let roomName = info.length > 1 ? info[ 1 ] : undefined;
if( typeof roomName !== 'undefined' && roomName.length !== 4 ) {
throw new Error( `bad room name: ${roomName}` );
}
return {
clientHeadsetType,
roomName
};
};
export default {
getClientInfoFromUrl
};
================================================
FILE: backend/src/utils/websocket-utils.js
================================================
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import WebSocket from 'uws';
import { serialize } from '../s11n';
import messageConstants from '../messages/message-constants';
const incomingMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const outgoingMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;
const makeWsReplyMessage = ( from, type, data ) => {
return {
[ outgoingMsgComponents.ALL_MESSAGES.FROM ]: from,
[ outgoingMsgComponents.ALL_MESSAGES.MSG ]: {
[ incomingMsgComponents.ALL_MESSAGES.TYPE ]: type,
[ incomingMsgComponents.ALL_MESSAGES.DATA ]: data
}
};
};
const makeWsBroadcastMessage = ( from, type, data ) => {
return {
[ outgoingMsgComponents.ALL_MESSAGES.TYPE ]: type,
[ outgoingMsgComponents.ALL_MESSAGES.FROM ]: from,
[ outgoingMsgComponents.ALL_MESSAGES.MSG ]: {
[ incomingMsgComponents.ALL_MESSAGES.TYPE ]: type,
[ incomingMsgComponents.ALL_MESSAGES.DATA ]: data
}
};
};
const ackWsSendWithId = ( error, wsId ) => {
if( !error ) {
return;
}
};
const sendWsMessageWithLogger = ( ws, msgObj, logger ) => {
if( ws.readyState !== WebSocket.OPEN ) {
return;
}
let msg = serialize( msgObj );
// wrap the ack function so we can log the WS id if it errors
let ackWsSend = ( ackError ) => {
try {
ackWsSendWithId( ackError, ws.id );
}
// handle this error because otherwise it's stuck in the callback
catch( error ) {
logger.trace( error.message, msg );
}
};
try {
ws.send( msg, ackWsSend );
}
catch( error ) {
logger.trace( `websocket error trying to send message to client id ${ws.id}: ${error.message}` );
throw( error );
}
};
const sendWsErrorWithLogger = ( serverId, ws, errType, errMsg ) => {
let msgObj = {
from: serverId,
msg: {
type: 'error',
data: {
errType,
detail: errMsg
}
}
};
sendWsMessageWithLogger( ws, msgObj, logger );
};
export {
makeWsReplyMessage,
makeWsBroadcastMessage,
sendWsMessageWithLogger,
sendWsErrorWithLogger
};
================================================
FILE: config.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Note, this file is only used on local development.
// Running it under appengine, the config.template is used instead
var CONFIG = {};
CONFIG.DEFAULT_REGION= "us";
CONFIG.SERVERS = {"us": ""};
================================================
FILE: config.template
================================================
var CONFIG = {}
CONFIG.DEFAULT_REGION = "{{default_region}}";
CONFIG.SERVERS = {
{% for server in servers %}
"{{server.name}}":"{{server.hostname}}",
{% endfor %}
};
================================================
FILE: index.html
================================================
The Musical Forest
Headphones Recommended
LOADING
No VR? Take a peek in 360 mode
?
The Musical Forest
Join users from around the world in a musical forest. Tap or click a shape to play it. If you're using a headset like the HTC VIVE, you can add shapes too. This experiment demonstrates cross-device co-presence in VR, allowing anyone to join in, no matter what device they're using.
================================================
FILE: js/ascene.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
let aframeContent = `
`;
let div = document.createElement("div");
div.innerHTML = aframeContent;
document.body.appendChild(div);
================================================
FILE: js/components/background-objects.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { BgTreeColors, ShadowColor } from '../core/colors';
const DEG2RAD = Math.PI / 180;
const HALFPI = Math.PI / 2;
const VECTOR_ZERO = new THREE.Vector3( 0, 0, 0 );
const SHADOW_X_AXIS = new THREE.Vector3( 1, 0, 0 );
const SHADOW_Z_AXIS = new THREE.Vector3( 0, 1, 0 );
const SHADOW_LENGTH = 20;
const SHADOW_GEOMETRY = new THREE.PlaneGeometry( 0.25, 1 );
const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({
color: ShadowColor,
side: THREE.DoubleSide
});
AFRAME.registerComponent( 'background-objects', {
nLoaded: 0,
init: function() {
this.treeObjs = [ 'bg-tree-1', 'bg-tree-2', 'bg-tree-3' ];
this.treeGeometry = [];
let objLoader = new THREE.OBJLoader();
if ( !AFRAME.utils.device.isMobile() ) {
this.treeObjs.forEach( function( obj ) {
objLoader.load( '/static/models/' + obj + '.obj', this.checkLoadComplete.bind( this ) );
}, this );
}
},
checkLoadComplete: function( objModel ) {
this.treeGeometry.push( objModel.children[ 0 ].geometry );
if ( ++this.nLoaded >= this.treeObjs.length ) {
this.generateTrees();
}
},
generateTrees: function() {
let total = AFRAME.utils.device.isMobile() ? 20 : 200;
let theta, x, z, mesh, shadow;
let treeDistance, treeColor, treeIndex, randomSeed;
let perimeter = 8;
let materials = [
new THREE.MeshBasicMaterial( { color: new THREE.Color( BgTreeColors[ 0 ] ), side: THREE.DoubleSide } ),
new THREE.MeshBasicMaterial( { color: new THREE.Color( BgTreeColors[ 1 ] ), side: THREE.DoubleSide } ),
new THREE.MeshBasicMaterial( { color: new THREE.Color( BgTreeColors[ 2 ] ), side: THREE.DoubleSide } )
];
for ( let i = 0; i < total; i++ ) {
theta = DEG2RAD * (i / total) * 360;
randomSeed = Math.random();
treeDistance = randomSeed * 18 + perimeter;
treeColor = Math.floor( randomSeed * 3 );
treeIndex = Math.floor( i % 3 );
x = Math.cos( theta ) * treeDistance;
z = Math.sin( theta ) * treeDistance;
// Create and position tree
mesh = new THREE.Mesh( this.treeGeometry[ treeIndex ], materials[ treeColor ] );
mesh.position.set( x, 0, z );
mesh.scale.set( 1, 3 + Math.random(), 1 );
mesh.scale.multiplyScalar( 0.0002 );
mesh.lookAt( VECTOR_ZERO );
mesh.rotation.y += HALFPI;
mesh.rotation.y += ( Math.random() - 0.5 ) * 0.05;
mesh.position.setY( Math.random() * -2 );
// Create and position tree shadow
shadow = new THREE.Mesh( SHADOW_GEOMETRY, SHADOW_MATERIAL );
shadow.position.set( x, 0, z );
shadow.lookAt( VECTOR_ZERO );
shadow.rotation.order = 'ZYX';
shadow.rotation.x += HALFPI;
shadow.scale.setY( SHADOW_LENGTH );
shadow.translateOnAxis( SHADOW_Z_AXIS, -SHADOW_LENGTH / 2 );
this.el.object3D.add( mesh );
this.el.object3D.add( shadow );
}
}
});
================================================
FILE: js/components/ball.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Note } from '../notes/note';
AFRAME.registerComponent( 'ball', {
grow: null,
shrink: null,
has3dContext: false,
schema: {
radius: { default: 0.1 },
grabbed: { type: 'boolean', default: false },
hand: { default: 'rightHand' },
// the node that connects to the trunk where other balls grow from
meristem: {
default: false,
type: 'boolean'
},
meristemNode: {
default: null
},
},
init: function() {
this.el.addEventListener( 'componentchanged', event => {
this.update();
if ( event.detail.name === 'tone' ) {
this.updateTone();
}
}, false);
// the hit animiation
this.el.addEventListener( 'hit', event => this.hit( event ) );
this.el.addEventListener( 'highlight', event => this.highlight( event ) );
this.el.addEventListener( 'unHighlight', event => this.unHighlight( event ) );
},
play: function() {
this.createShape();
this.updateTone();
document.dispatchEvent( new CustomEvent("BALL_CREATED", {
detail: {
id: this.el.id,
el: this.el,
grab: true,
hand: this.data.hand
}
}));
// takes a refresh to register the 3d context
setTimeout( () => {
this.has3dContext = true;
}, 0 );
},
createShape: function() {
this.note = new Note( this.el.sceneEl.components.palette );
this.note.lightPosition = this.el.sceneEl.components["fake-light"].getLightPosition();
this.note.setTone( this.el.getAttribute( 'tone' ) );
/*
* can't replace root mesh of node
* due to tight coupling of gaze.js & raycaster
* added note as child instead.
*/
// this.el.setObject3D("mesh", this.note.group)
this.el.object3D.add(this.note.group);
},
tick: function( t, dt ) {
this.note.tick();
},
updateTone: function() {
this.note.setTone( this.el.getAttribute( 'tone' ) );
// this.animateTexture(0.5, 0, null);
},
hit: function( event ) {
this.note.hit( event );
},
highlight: function( event ) {
this.note.highlight( event );
},
unHighlight: function( event ) {
this.note.unHighlight( event );
}
});
================================================
FILE: js/components/bg-tree-ring-material.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { EnvColors, BgTreeColors } from '../core/colors';
AFRAME.registerComponent( 'bg-tree-ring-material', {
schema: {
type: 'int',
default: 0
},
init: function() {
let objLoader = new THREE.OBJLoader();
if ( AFRAME.utils.device.isMobile() ) {
objLoader.load( '/static/models/bg-tree-ring.obj', this.onLoaded.bind( this ) );
}
},
onLoaded: function( object ) {
let mesh = object.children[ 0 ];
let tex = new THREE.Texture( document.getElementById( 'bg-tree-tex' ) );
tex.needsUpdate = true;
tex.minFilter = tex.magFilter = THREE.NearestFilter;
let shader = THREE.BGTreeShader;
let uniforms = THREE.UniformsUtils.clone( shader.uniforms );
uniforms.map.value = tex;
uniforms.color.value = new THREE.Color( BgTreeColors[ this.data ] );
uniforms.fogColor.value = new THREE.Color ( EnvColors.fog );
uniforms.fogDensity.value = 0.065;
mesh.material = new THREE.ShaderMaterial( {
uniforms: uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader,
side: THREE.DoubleSide
} );
this.el.setObject3D( 'mesh', mesh );
}
});
================================================
FILE: js/components/clicker.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
AFRAME.registerComponent( 'clicker', {
raycaster: null,
mouse: null,
canvas: null,
camera: null,
intersected: null,
teleporter:null,
schema: {
// radius: { default: 0.1 },
},
init: function() {
document.addEventListener("INTRO_COMPLETED", () => {
// creates references
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.canvas = this.el.sceneEl.canvas;
this.camera = this.getCameraFromEntity(document.querySelector("a-entity[camera]"));
this.teleporter = document.querySelector("#gaze-floor").components.teleport;
// on click
// get all balls
// filter out the ones out of range
// do a check for mouse position
// unproject ray to mouse with vector z = -1
// get ray intersects
// if intersect length > 1 & if intersect is same as mousedown, hit
document.addEventListener("touchstart", (event) => {});
document.addEventListener("touchend", (event) => {});
document.addEventListener("mousedown", (event) => {
let entity = this.getIntersectObject({x:event.clientX, y:event.clientY});
if( !entity ) { return; }
this.intersected = entity;
});
document.addEventListener("mouseup", (event) => {
let entity = this.getIntersectObject({x:event.clientX, y:event.clientY});
if( !entity ) { return; }
if(this.intersected === entity){
entity.emit("controllerhit", {velocity:0.5});
}
this.intersected = null;
event.wasSphereHit = true;
});
});
},
play: function() {
},
pause: function() {
},
getIntersectObject: function(intersect) {
this.mouse.x = ( intersect.x / document.body.clientWidth ) * 2 - 1;
this.mouse.y = - ( intersect.y / document.body.clientHeight ) * 2 + 1;
this.raycaster.setFromCamera( this.mouse, this.camera );
let selectables = tree.getElementsByClassName("selectable");
let balls = [];
for(let i=0; i 0 ) {
return intersects[0].object.parent.parent.el;
} else {
return null;
}
},
getCameraFromEntity: function(camera) {
for(let i=0; i {
this.onUpdated();
}, false);
document.addEventListener("SPHERE_TEXTURE_LOADED", (event) => {
this.update();
});
},
onLoaded: function() {
let mesh = this.el.components["collada-model"].model.children[ 0 ].children[ 0 ];
mesh.material = new THREE.MultiMaterial([
new THREE.MeshBasicMaterial( { color: HeadsetColor } ),
new THREE.MeshBasicMaterial( { color: this.data.bandColor } )
]);
this.isLoaded = true;
this.onUpdated();
},
update: function( oldData ) {
if ( !this.isLoaded) { return; }
let mesh = this.el.components["collada-model"].model.children[ 0 ].children[ 0 ];
mesh.material = new THREE.MultiMaterial([
new THREE.MeshBasicMaterial( { color: HeadsetColor } ),
new THREE.MeshBasicMaterial( { color: this.data.bandColor } )
]);
},
getColor: function() {
return new THREE.Color( this.el.sceneEl.components.palette.controllerColors()[0] );
},
onUpdated: function() {
if(!this.isLoaded) { return; }
let mesh = this.el.components["collada-model"].model.children[ 0 ].children[ 0 ];
let transparent = (this.data.opacity<1) ? true : false;
mesh.material.materials[0].transparent = transparent;
mesh.material.materials[1].transparent = transparent;
mesh.material.materials[0].opacity = this.data.opacity;
mesh.material.materials[1].opacity = this.data.opacity;
},
});
================================================
FILE: js/components/controllers.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
AFRAME.registerSystem('controllers', {
controllerExists:false,
schema: {
},
init: function() {
document.addEventListener("INTRO_COMPLETED", (event) => {
let timeout;
let loopCount = 0;
let loopFunction = () => {
if (loopCount > 240 ) {
} else if(this.controllerExists){
} else {
timeout = setTimeout(() => {
loopCount++;
this.testForController();
loopFunction();
}, 1000);
}
};
loopFunction();
}, false);
},
getGamePad: function() {
let vrGamepad = null;
if (!navigator.getGamepads) {
return vrGamepad;
}
let gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; ++i) {
let gamepad = gamepads[i];
if (gamepad) {
return gamepad;
}
}
return null;
},
testForController: function() {
let vrGamepad = this.getGamePad();
if (!vrGamepad) {
return;
}
let player;
let controller;
let rightHand;
let leftHand;
switch(vrGamepad.id){
case "Daydream Controller":
player = document.querySelector('#player');
controller = this.createDayDreamCoprescenceController("rightHand", "right");
player.appendChild(controller);
this.createDayDreamManager();
break;
case "OpenVR Gamepad":
player = document.querySelector('#player');
rightHand = this.createViveController("rightHand", "right");
player.appendChild(rightHand);
leftHand = this.createViveController("leftHand", "left");
player.appendChild(leftHand);
break;
case "Oculus Touch (Left)":
case "Oculus Touch (Right)":
player = document.querySelector('#player');
rightHand = this.createOcculusController("rightHand", "right");
player.appendChild(rightHand);
leftHand = this.createOcculusController("leftHand", "left");
player.appendChild(leftHand);
break;
default:
player = document.querySelector('#player');
rightHand = this.createOcculusController("rightHand", "right");
player.appendChild(rightHand);
leftHand = this.createOcculusController("leftHand", "left");
player.appendChild(leftHand);
break;
}
let triggerEvent = new CustomEvent("CONTROLLER_CREATED", {
"detail": {
"id": vrGamepad.id
}
});
document.dispatchEvent(triggerEvent);
this.controllerExists = true;
},
createViveController: function(id, hand) {
let controller = document.createElement('a-entity');
controller.id = id;
if(this.sceneEl.components.palette && this.sceneEl.components.palette.controllerColors()) {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("vive-controls", "hand:" + hand + "; model:false");
this.load6DofControllerParams(controller, hand, color);
} else {
document.addEventListener("SPHERE_TEXTURE_LOADED", (event) => {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("vive-controls", "hand:" + hand + "; model:false");
this.load6DofControllerParams(controller, hand, color);
});
}
return controller;
},
load6DofControllerParams: (entity, hand, color) => {
entity.setAttribute("mixin", "avatar-hand");
entity.setAttribute("grab-move", "");
entity.setAttribute("tool-tips", "hand:"+hand+";");
entity.setAttribute("haptics", "hand:"+ hand );
entity.setAttribute("touch-color", "");
entity.setAttribute("controller-material", "bandColor:"+color+";");
entity.setAttribute("copresence", "components: mixin, position, rotation; decimals:3; playerpart:"+hand);
},
createOcculusController: function(id, hand) {
let controller = document.createElement('a-entity');
controller.id = id;
if(this.sceneEl.components.palette && this.sceneEl.components.palette.controllerColors()) {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("oculus-touch-controls", "hand:" + hand + "; model:false");
this.load6DofControllerParams(controller, hand, color);
} else {
document.addEventListener("SPHERE_TEXTURE_LOADED", (event) => {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("oculus-touch-controls", "hand:" + hand + "; model:false");
this.load6DofControllerParams(controller, hand, color);
});
}
return controller;
},
/*
* Proxy controller. used for copresence
*/
createDayDreamCoprescenceController: function(id, hand) {
let controller = document.createElement('a-entity');
controller.id = id;
controller.setAttribute("position", "0 0 0");
controller.setAttribute("copresence", "components: mixin, position, rotation; decimals:3; playerpart:"+hand);
return controller;
},
createDayDreamManager: function() {
let player = document.querySelector('#player');
let avatar = document.querySelector('#avatar');
let avatarPosition = getComponentProperty(avatar, "position");
let manager = document.createElement('a-entity');
manager.id = "daydream";
manager.setAttribute("daydream-manager", "headset: #avatar; proxy: #rightHand; controller: #controller;");
manager.setAttribute("position", avatarPosition.x + " 0.0 " + avatarPosition.z);
this.sceneEl.appendChild(manager);
let controller = document.createElement('a-entity');
controller.id = "controller";
controller.setAttribute("daydream-controller", "");
controller.setAttribute("position", "0 0.1 0");
controller.setAttribute("daydream-pointer", "");
controller.setAttribute("mixin", "avatar-hand");
if(this.sceneEl.components.palette && this.sceneEl.components.palette.controllerColors()) {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("controller-material", "bandColor:"+color+";");
} else {
document.addEventListener("SPHERE_TEXTURE_LOADED", (event) => {
let color = this.sceneEl.components.palette.controllerColors()[1];
controller.setAttribute("controller-material", "bandColor:"+color+";");
});
}
manager.appendChild(controller);
},
doesControllerExists: function() {
return this.controllerExists;
}
});
================================================
FILE: js/components/copresence-server-messages.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import messageConstants from '../../backend/src/messages/message-constants';
const clientMsgTypes = messageConstants.INCOMING_MESSAGE_TYPES;
const clientMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const serverMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;
class Message {
constructor(type){
this.data = {};
this.type = type;
}
serialize(){
let msg = JSON.stringify({
[ clientMsgComponents.ALL_MESSAGES.TYPE ]: this.type,
[ clientMsgComponents.ALL_MESSAGES.DATA ]: this.data
});
return msg;
}
}
export class ExitRoom extends Message {
constructor(){
super( clientMsgTypes.EXIT_ROOM );
}
}
export class RoomClientPositionUpdate extends Message {
constructor(playerData, spheresData){
super( clientMsgTypes.UPDATE_CLIENT_COORDS );
const clientParts = clientMsgComponents.UPDATE_CLIENT_COORDS;
const coordParts = clientMsgComponents.REF_COORDINATE_SET;
this.data[clientParts.HEAD] = {};
this.data[clientParts.LEFT] = {};
this.data[clientParts.RIGHT] = {};
this.data[clientParts.HEAD][coordParts.POSITION] = playerData['head']['position'];
this.data[clientParts.HEAD][coordParts.ROTATION] = playerData['head']['rotation'];
this.data[clientParts.LEFT][coordParts.POSITION] = playerData['left']['position'];
this.data[clientParts.LEFT][coordParts.ROTATION] = playerData['left']['rotation'];
this.data[clientParts.RIGHT][coordParts.POSITION] = playerData['right']['position'];
this.data[clientParts.RIGHT][coordParts.ROTATION] = playerData['right']['rotation'];
this.data[clientParts.SPHERES] = []
for(let sphere of spheresData){
let s = {};
s[clientMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_ID] = sphere.id;
s[clientMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION] = sphere.position;
this.data[clientParts.SPHERES].push(s)
}
}
}
export class SpherePositionUpdate extends Message {
constructor(uuid, position){
super( clientMsgTypes.UPDATE_SPHERE_POSITION );
this.data[ clientMsgComponents.UPDATE_SPHERE_POSITION.SPHERE_ID ] = uuid;
this.data[ clientMsgComponents.UPDATE_SPHERE_POSITION.POSITION ] = position;
}
}
export class SphereToneUpdate extends Message {
constructor(uuid, tone){
super( clientMsgTypes.SET_SPHERE_TONE );
this.data[ clientMsgComponents.SET_SPHERE_TONE.SPHERE_ID ] = uuid;
this.data[ clientMsgComponents.SET_SPHERE_TONE.TONE ] = tone;
}
}
export class SphereConnectionUpdate extends Message {
constructor(uuid, connections){
super( clientMsgTypes.SET_SPHERE_CONNECTIONS );
this.data[ clientMsgComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID ] = uuid;
this.data[ clientMsgComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS ] = connections
}
}
export class GrabSphere extends Message {
constructor(uuid){
super( clientMsgTypes.GRAB_SPHERE );
this.data[ clientMsgComponents.GRAB_SPHERE.SPHERE_ID ] = uuid;
}
}
export class ReleaseSphere extends Message {
constructor(uuid){
super( clientMsgTypes.RELEASE_SPHERE );
this.data[ clientMsgComponents.RELEASE_SPHERE.SPHERE_ID ] = uuid;
}
}
export class StrikeSphere extends Message {
constructor(uuid, velocity=1){
super( clientMsgTypes.STRIKE_SPHERE );
this.data[ clientMsgComponents.STRIKE_SPHERE.SPHERE_ID ] = uuid;
this.data[ clientMsgComponents.STRIKE_SPHERE.VELOCITY ] = velocity;
}
}
export class DeleteSphere extends Message {
constructor(uuid){
super( clientMsgTypes.DELETE_SPHERE );
this.data[ clientMsgComponents.DELETE_SPHERE.SPHERE_ID ] = uuid;
}
}
export class CreateSphereAtPosition extends Message {
constructor(position, tone=1){
super( clientMsgTypes.CREATE_SPHERE_OF_TONE_AT_POSITION );
this.data[ clientMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION ] = position;
this.data[ clientMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE ] = tone;
}
}
================================================
FILE: js/components/copresence-server.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import serverMessageConstants from '../../backend/src/messages/message-constants';
import { ExitRoom, RoomClientPositionUpdate, CreateSphereAtPosition, DeleteSphere, SpherePositionUpdate, SphereToneUpdate, SphereConnectionUpdate, GrabSphere, ReleaseSphere, StrikeSphere } from './copresence-server-messages';
import _ from 'underscore';
import { getParameterByName, getViewerType, showErrorMessage } from '../util';
const msgTypes = serverMessageConstants.OUTGOING_MESSAGE_TYPES;
const errorTypes = serverMessageConstants.ERROR_TYPES;
const clientMsgComponents = serverMessageConstants.INCOMING_MESSAGE_COMPONENTS;
const serverMsgComponents = serverMessageConstants.OUTGOING_MESSAGE_COMPONENTS;
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
const stringify = AFRAME.utils.coordinates.stringify;
const MESSAGE_THROTTLE_INTERVAL = 200;
///
/// ------------------------------- SYSTEM --------------------------------------
///
AFRAME.registerSystem('copresence-server', {
// Holds the local players data (head and arm location)
playerDataCache: {
head: { position: {x:0, y:1.6, z:0}, rotation: {x:0, y:0, z:0}},
left: { position: {x:0.25, y:-10001.3, z:0}, rotation: {x:0, y:0, z:0}},
right: { position: {x:-0.25, y:-10001.3, z:0}, rotation: {x:0, y:0, z:0}},
userdata: {}
},
playerDataDirty: true,
entities: {},
sphereCache: {},
connected: false,
throttledSendMsg: null,
schema: {
hostname: {type: 'string'},
port: {type: 'number'}
},
init: function(){
this.throttledServerUpdate = _.throttle(()=>{
this.sendClientUpdate();
}, MESSAGE_THROTTLE_INTERVAL, {leading:true, trailing:true});
// this.throttledSendMsg = _.throttle((msg)=>{
// this.sendMsg(msg);
// }, MESSAGE_THROTTLE_INTERVAL, {leading:true, trailing:true});
},
tick: function(){
// Check if player data has changed
if(this.playerDataDirty && this.connected){
this.throttledServerUpdate();
}
},
sendClientUpdate: function () {
this.playerDataDirty = false;
let spheres = [];
for(let sphereId in this.sphereCache){
if(this.sphereCache[sphereId].dirty){
this.sphereCache[sphereId].dirty = false;
spheres.push({
id: sphereId,
position: this.sphereCache[sphereId].position
});
}
}
let playerMsg = new RoomClientPositionUpdate(this.playerDataCache, spheres);
this.sendMsg(playerMsg);
},
//returns a promise which is resolved when the server connects
connectToServer: function(){
if(this.connected) {
return;
}
this.region = CONFIG.DEFAULT_REGION;
this.roomname = undefined;
if(window.location.hash){
var matches = window.location.hash.match(/#(\w+)-(\w{4})/);
if(matches.length >= 3){
this.region = matches[1];
this.roomname = matches[2];
}
}
// If server doesnt exist, pick another available server
if(!CONFIG.SERVERS[this.region]){
this.region = Object.keys(CONFIG.SERVERS)[0];
}
let ws_url = [
"wss://",CONFIG.SERVERS[this.region], ":", "443"
].join('');
if(getParameterByName("server")){
ws_url = getParameterByName("server");
}
// client type is asynchronous
let connectWithClientType = (clientType) => {
ws_url = ws_url + '/' + clientType;
if(this.roomname){
ws_url = ws_url + '/' + this.roomname;
}
return new Promise((connected, error) => {
// window.UI.connecting();
this.ws = new WebSocket( ws_url );
this.ws.onopen = connected;
this.ws.onmessage = (msg) => {
let msg_data;
try {
msg_data = JSON.parse(msg.data);
} catch (e) {
console.error(e);
showErrorMessage("error_shake.gif", "Hmm, something went wrong", "Home");
}
if(msg_data) {
this.parseWebsocketMessage(
msg_data[ clientMsgComponents.ALL_MESSAGES.MSG ],
msg_data[ clientMsgComponents.ALL_MESSAGES.FROM ]
);
}
};
this.ws.onclose = () => {
// window.UI.connectionError();
};
this.ws.onerror = (err) =>{
if(this.ws.readyState > 1){
// TODO: Error handling
error(err);
showErrorMessage("error_shake.gif", "Hmm, something went wrong", "Home");
}
};
});
};
getViewerType((clientType) => {
connectWithClientType(clientType);
});
},
parseWebsocketMessage: function(msg, from){
if(!msg) return;
let msgType = msg[ clientMsgComponents.ALL_MESSAGES.TYPE ];
let msgData = msg[ clientMsgComponents.ALL_MESSAGES.DATA ];
switch( msgType ){
// Room
case msgTypes.CONNECTION_INFO:
this.clientId = msgData[ serverMsgComponents.CONNECTION_INFO.CLIENT_ID ];
this.serverId = msgData[ serverMsgComponents.CONNECTION_INFO.SERVER_ID ];
break;
case msgTypes.ROOM_STATUS_INFO:
this.handleRoomStatusInfo(msgData);
break;
case msgTypes.ROOM_EXIT_SUCCESS:
break;
case msgTypes.ROOM_HEARTBEAT:
break;
case msgTypes.ROOM_CLIENT_JOIN:
if(!this.connected) break;
this.handleRemoteJoining(msgData);
break;
case msgTypes.ROOM_CLIENT_EXIT:
if(!this.connected) break;
this.handleRemoveEntity(msgData);
break;
// Remote Clients
case msgTypes.ROOM_CLIENT_COORDS_UPDATED:
if(!this.connected) break;
this.handleUpdatePlayerEntity(from, msgData);
if(msgData[ serverMsgComponents.ROOM_CLIENT_COORDS_UPDATED.SPHERES ]){
for(let sphere of msgData[ serverMsgComponents.ROOM_CLIENT_COORDS_UPDATED.SPHERES ]) {
this.handleSpherePositionUpdate(sphere);
}
}
break;
// Spheres
case msgTypes.ROOM_SPHERE_CREATED:
if(!this.connected) break;
this.handleSphereCreation(msgData, true, msgTypes.ROOM_SPHERE_CREATED);
break;
case msgTypes.ROOM_SPHERE_GRABBED:
break;
case msgTypes.ROOM_SPHERE_POSITION_UPDATED:
// Deprecated
if(!this.connected) break;
this.handleSpherePositionUpdate(msgData);
break;
case msgTypes.ROOM_SPHERE_TONE_SET:
if(!this.connected) break;
this.handleSphereToneUpdate(msgData);
break;
case msgTypes.ROOM_SPHERE_CONNECTIONS_SET:
if(!this.connected) break;
this.handleSphereConnectionUpdate(msgData);
break;
case msgTypes.ROOM_SPHERE_RELEASED:
break;
case msgTypes.ROOM_SPHERE_STRUCK:
if(!this.connected) break;
this.handleSphereStruck(msgData);
break;
case msgTypes.ROOM_SPHERE_DELETED:
if(!this.connected) break;
this.handleSphereDeletion(msgData, true);
break;
case msgTypes.CREATE_SPHERE_DENIED:
break;
case msgTypes.CREATE_SPHERE_SUCCESS:
if(!this.connected) break;
this.handleSphereCreation(msgData, false, msgTypes.CREATE_SPHERE_SUCCESS);
break;
case msgTypes.GRAB_SPHERE_DENIED:
break;
case msgTypes.GRAB_SPHERE_SUCCESS:
break;
case msgTypes.UPDATE_SPHERE_POSITION_INVALID:
break;
case msgTypes.UPDATE_SPHERE_POSITION_DENIED:
break;
case msgTypes.RELEASE_SPHERE_DENIED:
break;
case msgTypes.RELEASE_SPHERE_INVALID:
break;
case msgTypes.RELEASE_SPHERE_SUCCESS:
break;
case msgTypes.DELETE_SPHERE_DENIED:
break;
case msgTypes.DELETE_SPHERE_INVALID:
break;
case msgTypes.DELETE_SPHERE_SUCCESS:
break;
case msgTypes.SET_SPHERE_TONE_DENIED:
break;
case msgTypes.SET_SPHERE_TONE_INVALID:
break;
case msgTypes.SET_SPHERE_TONE_SUCCESS:
break;
case msgTypes.SET_SPHERE_CONNECTIONS_DENIED:
break;
case msgTypes.SET_SPHERE_CONNECTIONS_INVALID:
break;
case msgTypes.SET_SPHERE_CONNECTIONS_SUCCESS:
break;
case msgTypes.CONNECT_SPHERES_IDENTICAL:
break;
case msgTypes.CONNECT_SPHERES_INVALID:
break;
case msgTypes.CONNECT_SPHERES_MISSING:
break;
case errorTypes.INVALID_URL:
showErrorMessage("error_shake.gif", "Invalid URL", "Try Again");
break;
case errorTypes.NO_ROOMS_AVAILABLE:
showErrorMessage("error_full.gif", "All rooms are currently occupied. Try again later", "Home");
break;
case errorTypes.NO_SUCH_ROOM:
showErrorMessage("error_shake.gif", "Hmm, something went wrong", "Home");
break;
case errorTypes.ROOM_QUEUE_FULL:
showErrorMessage("error_full.gif", "There are too many players in this room", "Try Another");
break;
case errorTypes.ROOM_FULL:
showErrorMessage("error_full.gif", "There are too many players in this room", "Try Another");
break;
case errorTypes.ROOM_UNAVAILABLE:
showErrorMessage("error_knock.gif", "This room is temporarily unavailable", "Home");
break;
case errorTypes.BUSY_TRY_AGAIN:
showErrorMessage("error_hit.gif", "Hmm, something went wrong", "Try Again");
break;
case errorTypes.ROOM_NOT_READY:
showErrorMessage("error_knock.gif", "Hmm, something went wrong", "Try Again");
break;
case errorTypes.ROOM_JOIN_TIMEOUT:
showErrorMessage("error_knock.gif", "Hmm, something went wrong", "Try Again");
break;
case errorTypes.SPHERE_HOLD_TIMEOUT:
break;
case errorTypes.CLIENT_INACTIVITY_TIMEOUT:
break;
default:
}
},
sendMsg: function(msg){
if(this.ws && this.ws.readyState == 1) {
this.ws.send(msg.serialize());
}
},
handleRoomStatusInfo: function(data){
this.connected = true;
const roomName = data[ serverMsgComponents.ROOM_STATUS_INFO.ROOM_NAME ];
const roomSoundbank = data[ serverMsgComponents.ROOM_STATUS_INFO.SOUNDBANK ];
// list of clients already in the room arranged according to headsetType
const clientList = data[ serverMsgComponents.ROOM_STATUS_INFO.CLIENTS ];
Object.keys( clientList ).forEach(( headsetType ) => {
for(let i = 0; i {
document.removeEventListener("SPHERE_TEXTURE_LOADED", loadSpheres);
// Create spheres
// if empty room, trigger tree created event
if(Object.keys(data[serverMsgComponents.ROOM_STATUS_INFO.SPHERES]).length===0){
this.sceneEl.systems.tree.isTreeCreated = true;
document.dispatchEvent(new Event("TREE_CREATED"));
} else {
_.each(
data[serverMsgComponents.ROOM_STATUS_INFO.SPHERES],
(sphereData, sphereId) =>{
sphereData[ serverMsgComponents.ROOM_SPHERE_POSITION_UPDATED.SPHERE_ID ] = sphereId;
this.handleSphereCreation(sphereData, true,"PER_SPHERE");
}
);
}
};
// texture needs to be loaded before spheres are created
// window.UI.joinedRoom(roomName);
window.location.hash = this.region+"-"+roomName;
this.sceneEl.removeAttribute('palette');
this.sceneEl.setAttribute('palette', roomSoundbank);
document.addEventListener("SPHERE_TEXTURE_LOADED", loadSpheres);
this.sceneEl.components.palette.loadSphereTextures();
},
// ENTITY
handleRemoveEntity: function(data){
let clientId = data[ serverMsgComponents.ROOM_CLIENT_EXIT.CLIENT_ID ];
let entity = this.entities[clientId];
if(entity) {
entity.parentNode.removeChild(entity);
}
},
handleRemoveAllEntities: function(){
for (let key in this.entities) {
let entity = this.entities[key];
if(entity) {
entity.parentNode.removeChild(entity);
}
}
},
createAframeEntity: function(id){
let entity = document.createElement('a-entity');
entity.id = id;
this.entities[id] = entity;
return entity;
},
// Create new player entity representing remote player
createRemotePlayerEntity: function(id, headsetType="6dof"){
const playerEntity = this.createAframeEntity(id);
setComponentProperty(playerEntity, 'smooth-motion', 'amount:3');
this.sceneEl.appendChild(playerEntity);
switch (headsetType){
case "viewer":
this.addHeadPart(playerEntity, 'head_' + id, 'avatar-head');
break;
case "3dof":
this.addHeadPart(playerEntity, 'head_' + id, 'avatar-head');
this.add3dofPart(playerEntity, 'right_' + id, 'avatar-hand');
break;
case "6dof":
this.addHeadPart(playerEntity, 'head_' + id, 'avatar-head');
this.add6dofPart(playerEntity, 'right_' + id, 'avatar-hand');
this.add6dofPart(playerEntity, 'left_' + id, 'avatar-hand');
break;
default:
console.warn(clientHeadset);
}
let triggerEvent = new CustomEvent("PLAYER_ADDED", {
"detail": {
"entity": playerEntity
}
});
document.dispatchEvent(triggerEvent);
},
// a la carte remote player parts
addHeadPart: function(playerEntity, id, partType) {
const entity = this.createAframeEntity(id);
playerEntity.appendChild(entity);
setTimeout( () => {
setComponentProperty(entity, 'position', '0 1.6 0');
setComponentProperty(entity, 'smooth-motion', 'amount:3');
setComponentProperty(entity, 'mixin', partType);
});
},
add6dofPart: function(playerEntity, id, partType) {
const entity = this.createAframeEntity(id);
playerEntity.appendChild(entity);
setTimeout( () => {
setComponentProperty(entity, 'smooth-motion', 'amount:3');
setComponentProperty(entity, 'mixin', partType);
});
},
add3dofPart: function(playerEntity, id, partType, callback=null) {
const entity = this.createAframeEntity(id);
playerEntity.appendChild(entity);
setTimeout( () => {
setComponentProperty(entity, 'smooth-motion', 'amount:3');
setComponentProperty(entity, 'daydream-pointer', "");
setComponentProperty(entity, 'mixin', partType);
});
},
// Handle client position data from server
handleUpdatePlayerEntity: function(id, data){
if(!this.entities[id]) {
this.createRemotePlayerEntity(id);
}
// Update position and rotation of head and hands
['head','left', 'right'].forEach((part) => {
let dataPart;
if(part == 'head') dataPart = serverMsgComponents.ROOM_CLIENT_COORDS_UPDATED.HEAD;
else if(part == 'left') dataPart = serverMsgComponents.ROOM_CLIENT_COORDS_UPDATED.LEFT;
else if(part == 'right') dataPart = serverMsgComponents.ROOM_CLIENT_COORDS_UPDATED.RIGHT;
const part_id = part + "_" + id;
let positionData = data[dataPart][clientMsgComponents.REF_COORDINATE_SET.POSITION];
let rotationData = data[dataPart][clientMsgComponents.REF_COORDINATE_SET.ROTATION];
if(part === 'head') {
setComponentProperty(this.entities[id], 'position', {x:positionData.x, y:0, z:positionData.z});
setComponentProperty(this.entities[part_id], 'position',{x:0, y:positionData.y, z:0});
} else {
let playerPos = getComponentProperty(this.entities[id], 'position');
if(!playerPos) { return; }
if(!this.entities[part_id]) { return; }
setComponentProperty(this.entities[part_id], 'position', {
x:positionData.x-playerPos.x,
y:positionData.y-playerPos.y,
z:positionData.z-playerPos.z
});
}
setComponentProperty(this.entities[part_id], 'rotation', stringify(rotationData));
});
},
// Set client position data on server in next tick
setPlayerData: function(part, component, data){
if(!this.connected) return;
if(component === 'position' && part === 'head') {
let avatar = document.querySelector("#avatar");
let avatarPosition = avatar.object3D.getWorldPosition();
this.playerDataCache[part][component].x = avatarPosition.x;
this.playerDataCache[part][component].y = avatarPosition.y;
this.playerDataCache[part][component].z = avatarPosition.z;
} else if(component === 'position') {
let hand = document.querySelector("#"+part+"Hand");
let handPosition = hand.object3D.getWorldPosition();
this.playerDataCache[part][component].x = handPosition.x;
this.playerDataCache[part][component].y = handPosition.y;
this.playerDataCache[part][component].z = handPosition.z;
} else if(component == 'rotation') {
this.playerDataCache[part][component].x = data.x;
this.playerDataCache[part][component].y = data.y;
this.playerDataCache[part][component].z = data.z;
} else {
// this.playerData[part].userdata[component] = data;
}
this.playerDataDirty = true;
},
// SPHERES
createSphereOnServer: function(entity){
if(!this.connected) return;
this._addingSphereEntity = entity;
let msg = new CreateSphereAtPosition(entity.getAttribute('position'), entity.getAttribute('tone'));
this.sendMsg(msg);
},
setSphereData: function(uuid, component, data){
if(!this.connected) return;
if(!uuid) throw new Error("No uuid in setSphereData");
if(component == 'position'){
if(!_.isEqual(data, this.sphereCache[uuid].position)) {
this.sphereCache[uuid].dirty = true;
// let msg = new SpherePositionUpdate(uuid, data);
// this.throttledSendMsg(msg)
this.sphereCache[uuid].position = _.clone(data);
this.playerDataDirty = true;
}
} else if(component == 'ball'){
if(data.grabbed != this.sphereCache[uuid].grabbed) {
this.sphereCache[uuid].grabbed = data.grabbed;
if (data.grabbed) {
this.sendMsg(new GrabSphere(uuid));
} else {
this.sendMsg(new ReleaseSphere(uuid));
}
}
} else if(component == 'tone'){
if(!_.isEqual(data, this.sphereCache[uuid].tone)) {
let msg = new SphereToneUpdate(uuid, data);
this.sendMsg(msg);
this.sphereCache[uuid].tone = _.clone(data);
}
}
},
removeSphere: function(uuid){
if(!this.connected) return;
if(uuid){
this.sendMsg(new DeleteSphere(uuid));
}
},
sendSphereStrike: function(uuid, velocity){
if(!this.connected) return;
if(uuid) {
this.sendMsg(new StrikeSphere(uuid, velocity));
}
},
handleSphereStruck: function (data) {
const sphereId = data[ serverMsgComponents.ROOM_SPHERE_STRUCK.SPHERE_ID ];
const strikeVelocity = data[ serverMsgComponents.ROOM_SPHERE_STRUCK.VELOCITY ];
let entity = document.querySelector("#sphere_"+sphereId);
if(!entity) {
return;
}
entity.emit("controllerhit", {"velocity":strikeVelocity, "remote": true});
},
handleSphereCreation: function(data, remote, message){
const sphereId = data[ serverMsgComponents.ROOM_STATUS_INFO.SPHERE_ID ];
const spherePosition = data[ serverMsgComponents.ROOM_STATUS_INFO.POSITION ];
const sphereTone = data[ serverMsgComponents.ROOM_STATUS_INFO.TONE ];
const sphereMeristem = data[ serverMsgComponents.ROOM_STATUS_INFO.MERISTEM ];
this.sphereCache[sphereId] = {
grabbed: false,
connections: [],
tone: sphereTone
};
if(!remote && this._addingSphereEntity){
// Local client created sphere, getting server generated UUID
this._addingSphereEntity.id = "sphere_"+sphereId;
this._addingSphereEntity.setAttribute('copresence','uuid', sphereId);
delete this._addingSphereEntity;
} else if(spherePosition){
// Create entity in tree system
document.querySelector('a-scene').systems.tree.createSphere({
id: "sphere_"+sphereId,
uuid: sphereId,
position: spherePosition,
tone: sphereTone,
meristem : sphereMeristem || false
});
}
},
handleSphereDeletion: function(data, remote=true){
const sphereId = data[ serverMsgComponents.ROOM_SPHERE_DELETED.SPHERE_ID ];
let sphere = document.querySelector("#sphere_"+sphereId);
document.querySelector('a-scene').systems.tree.deleteSphere(sphere);
},
handleSpherePositionUpdate: function(data){
const sphereId = data[ serverMsgComponents.ROOM_SPHERE_POSITION_UPDATED.SPHERE_ID ];
const spherePosition = data[ serverMsgComponents.ROOM_STATUS_INFO.POSITION ];
this.sphereCache[sphereId].position = _.clone(spherePosition);
let entity = document.querySelector("#sphere_"+sphereId);
if(!entity) {
return;
}
let ballComponent = entity.getAttribute('ball');
if(!ballComponent.grabbed) {
entity.setAttribute('position', spherePosition);
}
},
handleSphereToneUpdate: function(data){
const sphereId = data[ serverMsgComponents.ROOM_STATUS_INFO.SPHERE_ID ];
const sphereTone = data[ serverMsgComponents.ROOM_STATUS_INFO.TONE ];
this.sphereCache[sphereId].tone = _.clone(sphereTone);
let entity = document.querySelector("#sphere_"+sphereId);
if(!entity) {
return;
}
entity.setAttribute('tone', sphereTone);
},
handleSphereConnectionUpdate: function (data) {
const sphereId = data[ serverMsgComponents.ROOM_STATUS_INFO.SPHERE_ID ];
const sphereConnections = data[ serverMsgComponents.ROOM_STATUS_INFO.CONNECTIONS ];
let connections = _.map(sphereConnections, (c)=> "#sphere_"+c );
this.sphereCache[sphereId].connections = connections.slice();
let entity = document.querySelector("#sphere_"+sphereId);
if(!entity) {
return;
}
let ballComponent = entity.getAttribute('ball');
if(ballComponent && !ballComponent.grabbed) {
entity.setAttribute('ball', 'connections', connections);
}
},
handleRemoteJoining: function(data){
let clientId = data[ serverMsgComponents.ROOM_CLIENT_JOIN.CLIENT_ID ];
let clientHeadset = data[ serverMsgComponents.ROOM_CLIENT_JOIN.CLIENT_HEADSET_TYPE ];
// checks if controllers exists, delete / append based on interaction type
let addPointer = () => {
let right = document.querySelector("#right_" + clientId);
let left = document.querySelector("#left_" + clientId);
if(right && left){
let person = right.parentNode;
switch (clientHeadset){
case "viewer":
person.removeChild(right);
person.removeChild(left);
break;
case "3dof":
right.setAttribute("daydream-pointer", "");
person.removeChild(left);
break;
case "6dof":
break;
default:
}
} else {
requestAnimationFrame(addPointer);
}
};
addPointer();
},
});
///
/// ------------------------------- COMPONENT --------------------------------------
///
AFRAME.registerComponent('copresence', {
uuidset: false,
schema: {
components: {default: ['position','ball', 'tone'], type: 'array'},
decimals: {default: 3},
playerpart: {default: undefined, type: 'string'},
uuid: {default: undefined, type: 'string'}
},
init: function(){
const system = this.el.sceneEl.systems["copresence-server"];
const player = document.querySelector("#player");
const avatar = document.querySelector("#avatar");
if (!this.data.components.length) return;
// sphere is created locally, need to create one for the server
if(!this.data.uuid && !this.data.playerpart){
system.createSphereOnServer(this.el);
} else if(this.data.uuid){
this.uuidset = true;
}
let updatePlayerData = (evt) => {
this.data.components.forEach( (component) => {
// Check the component is in the list of watched components
if (evt.detail.name === component) {
let d = evt.detail.newData;
// Round data
if (this.data.decimals !== false) {
if (_.isObject(d)) {
d = _.mapObject(d, (n) => {
if (_.isNumber(n)) {
return Math.round(n * Math.pow(10, this.data.decimals)) / Math.pow(10, this.data.decimals);
}
return n;
});
}
if (_.isNumber(d)) {
d = Math.round(d * Math.pow(10, this.data.decimals)) / Math.pow(10, this.data.decimals);
}
}
// Let the system know about the data change
if (this.data.playerpart) {
system.setPlayerData(this.data.playerpart, component, d);
} else if(this.data.uuid){
system.setSphereData(this.data.uuid, component, d);
}
}
});
};
// Listen for component changes (position, etc)
this.el.addEventListener("componentchanged", (evt)=>{
if(_.isEqual(evt.detail.newData, evt.detail.oldData)) { return; }
updatePlayerData(evt);
});
player.addEventListener("componentchanged", (evt)=>{
// if data has updated and if it's avatar
if(_.isEqual(evt.detail.newData, evt.detail.oldData)) { return; }
if(!_.isEqual(avatar, this.el)) { return; }
updatePlayerData(evt);
});
this.el.addEventListener('controllerhit', (evt)=>{
if(!evt.detail.remote) {
let system = this.el.sceneEl.systems["copresence-server"];
system.sendSphereStrike(this.data.uuid, evt.detail.velocity);
}
});
},
update: function(){
// Check if UUID is being set for the first time, if thats the case, send the sphere data
if(!this.uuidset && this.data.uuid){
this.uuidset = true;
let system = this.el.sceneEl.systems["copresence-server"];
system.setSphereData(this.data.uuid, 'ball', this.el.getAttribute("ball"));
}
},
remove: function(){
let system = this.el.sceneEl.systems["copresence-server"];
if(this.data.uuid) {
system.removeSphere(this.data.uuid);
}
}
});
================================================
FILE: js/components/daydream-manager.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
AFRAME.registerComponent('daydream-manager', {
controllerBall: null,
headset: null,
proxy: null,
controller: null,
isHitByRayCast:false,
schema: {
headset: {
default: "#avatar"
},
proxy: {
default: "#rightHand"
},
controller: {
default: "#controller"
},
},
init: function() {
},
play: function() {
this.headset = document.querySelector(this.data.headset);
this.proxy = document.querySelector(this.data.proxy);
this.controller = document.querySelector(this.data.controller);
let controller = document.querySelector("#controller");
controller.addEventListener('raycaster-intersection', (event) => {
if(this.isHitByRayCast) { return; }
let proxyPosition = this.proxy.object3D.getWorldPosition();
event.detail.intersections.forEach((intersection) => {
let entity = intersection.object.el;
if(!entity.classList.contains('ball')){ return;}
let distance = entity.object3D.getWorldPosition().distanceToSquared (proxyPosition);
if(distance > 1) { return; }
entity.emit("controllerhit", { velocity: 1.0 });
this.isHitByRayCast = true;
});
});
controller.addEventListener('raycaster-intersection-cleared', (event) => {
this.isHitByRayCast = false;
});
},
pause: function() {
},
tick: function() {
let controller = document.querySelector("#controller");
if(!controller) { return; }
let rotation = getComponentProperty(controller, "rotation");
setComponentProperty(this.proxy, "rotation", {x:rotation.x, y:rotation.y, z:rotation.z});
let avatar = document.querySelector('#avatar');
avatar = avatar.object3D;
let position = getComponentProperty(controller, "position");
setComponentProperty(this.proxy, "position", {
x:position.x + avatar.position.x,
y:position.y,
z:position.z + avatar.position.z});
},
remove: function() {
// let prevEntity = document.querySelector(prevId);
},
});
================================================
FILE: js/components/daydream-pointer.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
AFRAME.registerComponent('daydream-pointer', {
schema: {
},
init: function() {
},
play: function() {
let cone = document.createElement('a-cone');
cone.className = "ray";
cone.setAttribute("position", "0 0 -0.44");
cone.setAttribute("rotation", "-90 0 0");
cone.setAttribute("radius-bottom", "0.001");
cone.setAttribute("radius-top", "0.005");
cone.setAttribute("height", "1.0");
cone.setAttribute("color", "#FFF");
cone.setAttribute("shader", "flat");
this.el.appendChild(cone);
let ball = document.createElement('a-sphere');
ball.className = "ball";
ball.setAttribute("segments-width", "8");
ball.setAttribute("segments-height", "8");
ball.setAttribute("position", "0 0.50 0");
ball.setAttribute("radius", "0.01");
cone.setAttribute("color", "#FFF");
cone.setAttribute("shader", "flat");
cone.appendChild(ball);
},
pause: function() {
},
tick: function() {
},
});
================================================
FILE: js/components/fake-light.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
AFRAME.registerComponent( 'fake-light', {
schema: {
position: { type: 'vec3', default: new THREE.Vector3( 3, 10, 1 ) }
},
getLightPosition: function() {
return this.data.position;
}
});
================================================
FILE: js/components/ga.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
AFRAME.registerComponent( 'ga', {
init: function(){
this.el.addEventListener('controllerhit', ()=>{
ga('send', 'event', "interaction", "hit");
});
this.el.addEventListener('grab', ()=>{
ga('send', 'event', "interaction", "grab");
});
}
});
================================================
FILE: js/components/gaze.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
let BALL_RADIUS = 0.05;
AFRAME.registerComponent('gaze', {
isRayCasterAvailable: false,
hoveredEntity: null,
schema: {
},
// gaze should only work for android, ios, desktop
// if vive, occulous, and daydream, disable gaze;
init: function() {
document.addEventListener("INTRO_COMPLETED", (event) => {
let mode = this.el.sceneEl.components.splash.mode;
if( AFRAME.utils.device.isMobile() && mode !== "360" ){
this.addRaycaster();
}
document.addEventListener("CONTROLLER_CREATED", (event) => {
if(event.detail.id !== "Daydream Controller") { return; }
this.createRayTargets();
this.removeShadows();
let avatar = document.querySelector('#avatar');
let controller = document.querySelector('#controller');
// swaps out racaster
if(avatar.hasAttribute('raycaster')) { avatar.removeAttribute("raycaster"); }
let target = document.createElement('a-entity');
target.id = "target";
setComponentProperty(target, "position", "0 0 0.25");
controller.appendChild(target);
target.setAttribute("raycaster", "objects: .selectable; near:0; far:10; recursive: true; interval:200");
}, false);
}, false);
document.addEventListener("BALL_CREATED", (event) => {
if (AFRAME.utils.device.isMobile()) {
this.createRayTarget(event.detail.el);
}
}, false);
},
play: function() {
},
pause: function() {
},
tick: function() {
},
// required to redo hit states
addRaycaster: function() {
this.createRayTargets();
let avatar = document.querySelector('#avatar');
// deletes old rays and adds new one
if(avatar.hasAttribute('raycaster')) { avatar.removeAttribute("raycaster"); }
avatar.setAttribute("raycaster", "objects: .selectable; near:0; far:10; recursive: true; interval:200");
/*
* the raycaster intersection updates continuously on interval,
* returning an array ofhit objects
* Intersection clear not required
*/
// null state is gaze-floor
let currentEntity = document.querySelector('#gaze-floor');
avatar.addEventListener('raycaster-intersection', (event) => {
if(event.detail.intersections.length === 0 ) { return; }
let entity = event.detail.intersections[0].object.el;
let isBall = entity.classList.contains('ball');
if(isBall && (currentEntity.id !== entity.id) ){
currentEntity.emit("unHighlight");
currentEntity = entity;
this.hoveredEntity = entity;
this.hoveredEntity.emit("highlight");
// teleport related
event.wasSphereHit = true;
document.dispatchEvent(new Event("ON_SPHERE_IN"));
event.stopPropagation();
} else if(isBall && (currentEntity.id === entity.id) ){
} else if (this.hoveredEntity) {
currentEntity = document.querySelector('#gaze-floor');
this.hoveredEntity.emit("unHighlight");
this.hoveredEntity = null;
// teleport related
document.dispatchEvent(new Event("ON_SPHERE_OUT"));
event.stopPropagation();
}
});
avatar.addEventListener('raycaster-intersection-cleared', (event) => {
if(this.hoveredEntity){
currentEntity = document.querySelector('#gaze-floor');
this.hoveredEntity.emit("unHighlight");
this.hoveredEntity = null;
}
});
document.addEventListener("mouseup", (event) => {
if(this.hoveredEntity) {
this.hoveredEntity.emit("controllerhit", {velocity:1.0});
event.wasSphereHit = true;
}
});
},
/*
* create ray targets, remove shadows for performance
*/
createRayTargets: function() {
let tree = document.querySelector("#tree");
let balls = tree.getElementsByClassName("selectable");
for(let i=0; i {
// when ball is created, makes sure hand is passed to correct ball
if(this.data.grabbing && event.detail.grab && event.detail.hand===this.el.id) {
let closestSphere = event.detail.el;
this.selectBall(closestSphere);
this.grabbedSphere = closestSphere;
closestSphere.emit("controllerhit", {velocity:0.5});
this.hasJustPlayed = true;
}
}, false);
},
selectBall: function(entity){
if(this.selectedSphere && this.selectedSphere !== -1) {
this.selectedSphere.setAttribute('ball', 'grabbed', false);
}
if( entity === -1 ){
this.selectedSphere = -1;
} else {
this.selectedSphere = entity;
this.selectedSphere.setAttribute('ball', 'grabbed', true);
}
},
// 57, 48
onKeyUp: function (event) {
switch(event.keyCode){
// close bracket
case 219:
this.onTriggerPress();
break;
// open bracket
case 221:
this.onTriggerRelease();
break;
// close parenthisis
case 48:
let controllerPos = this.controllerBall.getWorldPosition();
let [ closestSphere, distanceSquared ] = this.getClosestEntity(tree, controllerPos);
this.grabbedSphere = closestSphere;
if(this.grabbedSphere){
this.delete();
}
break;
}
},
creatControllerBall: function() {
let material, geometry, ball;
geometry = new THREE.IcosahedronGeometry( 0.04, 1);
material = new THREE.MeshBasicMaterial( {
color: 0xffffff,
transparent:true,
opacity: 0.25 });
this.controllerBall = new THREE.Mesh(geometry,material);
// ball = new THREE.Object3D();
this.controllerBall.name = "ball";
this.el.object3D.add(this.controllerBall);
if(this.el.id === "avatar") {
this.controllerBall.position.z = -1.0;
} else {
this.controllerBall.position.z = -0.03;
}
this.el.object3D.add(this.controllerBall);
// delete from avatar
document.addEventListener("CONTROLLER_CREATED", (event) => {
if(this.el.id !== "avatar") { return; }
if(!this.controllerBall) { return; }
this.el.object3D.remove(this.controllerBall);
}, false);
},
play: function () {
this.creatControllerBall();
this.enableInteractions();
},
enableInteractions: function() {
if (this.el.id === "avatar" && getParameterByName('reticle')==='true') {
document.addEventListener('keyup', this.onKeyUp.bind(this));
}
this.el.addEventListener('triggerdown', this.onTriggerPress.bind(this));
this.el.addEventListener('triggerup', this.onTriggerRelease.bind(this));
},
pause: function () {
if (this.el.id === "avatar" && getParameterByName('reticle')==='true') {
document.removeEventListener('keyup', this.onKeyUp);
}
this.el.removeEventListener('triggerdown', this.onTriggerPress);
this.el.removeEventListener('triggerup', this.onTriggerRelease);
},
getClosestEntity: function(tree, pos, ignored) {
let minValue = 100000;
let closestId = -1;
for(let i=0; i DELETE_VELOCITY) {
this.delete();
}
},
delete: function () {
if(!this.grabbedSphere) { return; }
let controllerPos = this.controllerBall.getWorldPosition();
let triggerEvent = new CustomEvent("ON_DELETE",{ "detail": {
"closestEntity": this.grabbedSphere,
}});
document.dispatchEvent(triggerEvent);
},
getVelocity: function(controllerPos) {
let velocity;
// compute velocity
if (this.prevControllerPos){
let distance = this.prevControllerPos.distanceTo(controllerPos);
let elapsedTime = Date.now() - this.lastUpdate;
let instantVelocity = distance / elapsedTime;
this.lastUpdate = Date.now();
this.prevControllerPos.copy(controllerPos);
this.velocityHistory.push(distance);
} else {
this.prevControllerPos = controllerPos.clone();
this.velocityHistory.push(0);
}
//keep no more than 10
if (this.velocityHistory.length > VELOCITY_SAMPLES){
this.velocityHistory.shift();
}
//compute the velocity as an average over all of the velocity history
velocity = this.velocityHistory.reduce(function(a, b) { return a + b; }) / this.velocityHistory.length;
velocity = Math.min(velocity / 0.02, 1); // normalize
return velocity;
},
tick: function(){
let tree = document.querySelector("#tree");
if(!this.controllerBall || tree.children.length === 0) { return; }
let controllerPos = this.controllerBall.getWorldPosition();
let [ closestSphere , distanceSquared ] = this.getClosestEntity(tree, controllerPos);
if(!closestSphere) { return; }
if(closestSphere.id === "TEMP_NEW_SPHERE_ID") { return; }
let hitScalar = (this.getShape(closestSphere) === "sphere") ? 1 : 1.5;
let thresholdSquared = this.getScale(closestSphere) * BALL_RADIUS*hitScalar + CONTROLLER_BALL_RADIUS;
thresholdSquared = thresholdSquared*thresholdSquared;
this.data.isNearSphere = distanceSquared < thresholdSquared;
let velocity = this.getVelocity(controllerPos);
// hightlight controller if near a ball
if(this.data.isNearSphere) {
this.previousClosestSphere = this.data.closestSphere;
this.data.closestSphere = closestSphere;
this.controllerBall.material.opacity = 0.75;
} else {
this.controllerBall.material.opacity = 0.25;
this.hasJustPlayed = false;
}
// repositions ball when grabbing so ball rests just above controller.
if(this.data.grabbing && (this.selectedSphere !== -1)) {
let treePosition = tree.object3D.getWorldPosition();
// controllerPos = this.controllerBall.getWorldPosition();
let matrix = new THREE.Matrix4();
matrix.extractRotation( this.el.object3D.matrix );
// let tone = this.getTone(this.selectedSphere);
let tone = this.getTone(this.selectedSphere) % this.getTonesInScale();
let hitScalar = (this.getShape(this.selectedSphere) === "sphere") ? 1 : 1.5;
let scale = 1 - tone / (this.getTotalTones() - 1);
let ballSize = 0.1;
ballSize *= scale*hitScalar;
ballSize += 0.05;
let direction = new THREE.Vector3( 0, 0, 1 );
direction.applyMatrix4( matrix );
direction.normalize();
direction.multiplyScalar(ballSize-0.05);
controllerPos.sub(treePosition);
controllerPos.sub(direction);
// if ball position is too low
if (controllerPos.y < 0.25) {
controllerPos.y = 0.25;
}
setComponentProperty(this.selectedSphere, "position", AFRAME.utils.coordinates.stringify(controllerPos));
}
// touch hits
if(this.data.grabbing) { return; }
if(!this.data.isNearSphere) { return; }
if(!this.hasJustPlayed || this.previousClosestSphere !== closestSphere) {
closestSphere.emit("controllerhit", {velocity:velocity, controllerPosition: this.controllerBall.getWorldPosition()});
this.hasJustPlayed = true;
this.el.emit('vibrate');
}
},
getTone(el){
return el.getAttribute('tone');
},
getTotalTones(el){
return this.el.sceneEl.components.palette.totalNotes();
},
getTonesInScale() {
return this.el.sceneEl.components.palette.noteCount();
},
getScale(entity) {
return entity.components.ball.note.head.scale;
},
getShape(entity) {
return entity.components.ball.note.head.shape;
},
});
================================================
FILE: js/components/haptics.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
AFRAME.registerComponent('haptics', {
schema: {
hand: {type:'string'}
},
init: function(){
this.el.addEventListener('vibrate', ()=>{
try {
let gamepad = navigator.getGamepads()[this.el.getAttribute("tracked-controls").controller];
if ('hapticActuators' in gamepad && gamepad.hapticActuators.length > 0) {
gamepad.hapticActuators[0].pulse(0.7, 10);
}
} catch (e){}
});
}
});
================================================
FILE: js/components/headset-material.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { HeadsetColor, HeadsetShadow } from '../core/colors';
AFRAME.registerComponent( 'headset-material', {
isLoaded: false,
schema: {
opacity: {
default: 1.0
},
},
init: function() {
this.el.addEventListener( 'model-loaded', this.onLoaded.bind( this ) );
this.el.addEventListener("componentchanged", (event) => {
this.onUpdated();
}, false);
},
onLoaded: function() {
let mesh = this.el.components["collada-model"].model.children[ 0 ].children[ 0 ];
mesh.material = new THREE.MultiMaterial([
new THREE.MeshBasicMaterial( { color: HeadsetColor, transparent:true} ),
new THREE.MeshBasicMaterial( { color: HeadsetShadow, transparent:true} )
]);
this.isLoaded = true;
this.onUpdated();
},
onUpdated: function() {
if(!this.isLoaded) { return; }
let mesh = this.el.components["collada-model"].model.children[ 0 ].children[ 0 ];
let transparent = (this.data.opacity<1) ? true : false;
mesh.material.materials[0].transparent = transparent;
mesh.material.materials[1].transparent = transparent;
mesh.material.materials[0].opacity = this.data.opacity;
mesh.material.materials[1].opacity = this.data.opacity;
},
});
================================================
FILE: js/components/listener.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Tone from 'tone';
AFRAME.registerComponent('listener', {
init() {},
remove() {},
tick() {
// const pos = this.el.object3D.position
const object3d = this.el.object3D;
object3d.updateMatrixWorld();
const matrixWorld = object3d.matrixWorld;
const position = new THREE.Vector3().setFromMatrixPosition(matrixWorld);
Tone.Listener.setPosition(position.x, position.y, position.z);
const mOrientation = matrixWorld.clone();
mOrientation.setPosition({
x: 0,
y: 0,
z: 0
});
const vFront = new THREE.Vector3(0, 0, 1);
vFront.applyMatrix4(mOrientation);
vFront.normalize();
const vUp = new THREE.Vector3(0, -1, 0);
vUp.applyMatrix4(mOrientation);
vUp.normalize();
Tone.Listener.setOrientation(vFront.x, vFront.y, vFront.z, vUp.x, vUp.y, vUp.z);
}
});
================================================
FILE: js/components/palette.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Instruments, NoteCount, InstrumentCount, TotalNotes} from '../core/instruments';
import { BallColors, ControllerColors } from '../core/colors';
import { Shapes } from '../core/shapes';
const SPHERE_BASE_URL = './static/img/ball_';
const SPHERE_BASE_EXT = '.png';
AFRAME.registerComponent('palette', {
schema: {
type: 'int',
default: 0
},
update() {
// Dispose of the old instrument
if (this._currentInstrument) {
this._currentInstrument.dispose();
}
// Set the new instrument
this._currentInstrument = Instruments[this.data % Instruments.length];
},
trigger(time, tone, velocity, x, y, z) {
this._currentInstrument.trigger(time, tone, velocity, x, y, z);
},
totalNotes() {
return TotalNotes;
},
noteCount() {
return NoteCount;
},
colorPalette() {
return BallColors[ this._currentInstrument.color ];
},
controllerColors() {
return ControllerColors[ this._currentInstrument.color ];
},
shapePalette(id) {
return Shapes[ id ];
},
loadSphereTextures() {
let image134Circles = document.getElementById( 'ball_circles_134' );
let image567Circles = document.getElementById( 'ball_circles_567' );
let image134Triangles = document.getElementById( 'ball_triangles_134' );
let image567Triangles = document.getElementById( 'ball_triangles_567' );
let image134Squares = document.getElementById( 'ball_squares_134' );
let image567Squares = document.getElementById( 'ball_squares_567' );
this._textureSprite134Circles = new THREE.Texture( image134Circles );
this._textureSprite567Circles = new THREE.Texture( image567Circles );
this._textureSprite134Triangles = new THREE.Texture( image134Triangles );
this._textureSprite567Triangles = new THREE.Texture( image567Triangles );
this._textureSprite134Squares = new THREE.Texture( image134Squares );
this._textureSprite567Squares = new THREE.Texture( image567Squares );
this._textureSprite134Circles.wrapS = this._textureSprite134Circles.wrapT = THREE.RepeatWrapping;
this._textureSprite567Circles.wrapS = this._textureSprite567Circles.wrapT = THREE.RepeatWrapping;
this._textureSprite134Triangles.wrapS = this._textureSprite134Triangles.wrapT = THREE.RepeatWrapping;
this._textureSprite567Triangles.wrapS = this._textureSprite567Triangles.wrapT = THREE.RepeatWrapping;
this._textureSprite134Squares.wrapS = this._textureSprite134Squares.wrapT = THREE.RepeatWrapping;
this._textureSprite567Squares.wrapS = this._textureSprite567Squares.wrapT = THREE.RepeatWrapping;
setTimeout( () => {
document.dispatchEvent(new Event("SPHERE_TEXTURE_LOADED"));
},0);
},
textureSprite134(id) {
return [this._textureSprite134Circles, this._textureSprite134Squares, this._textureSprite134Triangles][id];
},
textureSprite567(id) {
return [this._textureSprite567Circles, this._textureSprite567Squares, this._textureSprite567Triangles][id];
},
});
================================================
FILE: js/components/proximity-check.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
import { getParameterByName } from '../util';
AFRAME.registerComponent('proximity-check', {
schema: {
objects: {
default: [],
type: 'array'
},
},
init: function() {
},
play: function() {
document.addEventListener('PLAYER_ADDED', (event) => {
// adds to list
this.data.objects.push(event.detail.entity);
// when remote heads move check distances
let head = document.querySelector("#head_"+event.detail.entity.id);
let person = head.parentNode;
person.addEventListener('componentchanged', (event) => {
this.checkDistances();
});
this.checkDistances();
});
if(getParameterByName("proximity")!=="false"){
// when player moves check distances
let player = document.querySelector("#player");
player.addEventListener('componentchanged', (event) => {
this.checkDistances();
});
this.checkDistances();
}
},
pause: function() {
},
checkDistances:function() {
let myPosition = this.el.object3D.getWorldPosition();
let entity;
for(let i=0; i 0){
let ray = right.childNodes[0];
ray.setAttribute("opacity",opacity);
let cone = right.childNodes[0].childNodes[0];
cone.setAttribute("opacity",opacity);
}
}
}
},
});
================================================
FILE: js/components/quaternion.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Quaternion.
*
* Represents orientation of object in three dimensions. Similar to `rotation`
* component, but avoids problems of gimbal lock.
*
* See: https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation
*/
AFRAME.registerComponent('quaternion', {
schema: {type: 'vec4'},
update: function () {
var data = this.data;
this.el.object3D.quaternion.set(data.x, data.y, data.z, data.w);
}
});
================================================
FILE: js/components/smooth-motion.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var degToRad = require('three').Math.degToRad;
function LowpassFilter(Fc) {
var Q = 0.707;
var K = Math.tan(Math.PI * Fc);
var norm = 1 / (1 + K / Q + K * K);
this.a0 = K * K * norm;
this.a1 = 2 * this.a0;
this.a2 = this.a0;
this.b1 = 2 * (K * K - 1) * norm;
this.b2 = (1 - K / Q + K * K) * norm;
this.z1 = this.z2 = 0;
this.value = 0;
this.tick = function(value) {
var out = value * this.a0 + this.z1;
this.z1 = value * this.a1 + this.z2 - this.b1 * out;
this.z2 = value * this.a2 - this.b2 * out;
return out;
};
}
/**
* Interpolate component for A-Frame.
*/
AFRAME.registerComponent('smooth-motion', {
schema: {
amount: {
default: 1
}
},
/**
* Called once when component is attached. Generally for initial setup.
*/
init: function() {
this.quaternion = new THREE.Quaternion();
},
/**
* Called when component is attached and when component data changes.
* Generally modifies the entity based on the data.
*/
update: function(oldData) {
if (!this.filterPosX) {
var amount = parseFloat(this.data.amount);
if (amount > 0) {
var freq = 0.1 / amount;
this.filterPosX = new LowpassFilter(freq, this);
this.filterPosY = new LowpassFilter(freq, this);
this.filterPosZ = new LowpassFilter(freq, this);
}
}
},
/**
* Called when a component is removed (e.g., via removeAttribute).
* Generally undoes all modifications to the entity.
*/
remove: function() {},
/**
* Called on each scene tick.
*/
tick: function(t) {
if (!this.filterPosX) { return; }
var p = AFRAME.utils.entity.getComponentProperty(this.el, 'position');
this.el.object3D.position.setX(this.filterPosX.tick(p.x));
this.el.object3D.position.setY(this.filterPosY.tick(p.y));
this.el.object3D.position.setZ(this.filterPosZ.tick(p.z));
var quaternion;
if (AFRAME.utils.entity.getComponentProperty(this.el, 'quaternion')) {
var q = AFRAME.utils.entity.getComponentProperty(this.el, 'quaternion');
quaternion = new THREE.Quaternion(q._x, q._y, q._z, q._w);
} else {
var r = AFRAME.utils.entity.getComponentProperty(this.el, 'rotation');
quaternion = new THREE.Quaternion();
quaternion.setFromEuler(new THREE.Euler(degToRad(r.x), degToRad(r.y), degToRad(r.z), 'YXZ'));
}
this.quaternion.slerp(quaternion, 1 / (3 * parseFloat(this.data.amount)));
this.el.object3D.quaternion.copy(this.quaternion);
}
});
================================================
FILE: js/components/teleport.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
import { getParameterByName } from '../util'
import TWEEN from 'tween.js';
AFRAME.registerComponent('teleport', {
isHitByRayCast: false,
isAnimating: false,
isOverSphere: false,
floorTarget: null,
doesControllerExist: false,
schema: {
},
init: function() {
this.floorTarget = document.querySelector('#gaze-hit');
this.floorTarget.setAttribute("visible", "false");
},
play: function() {
let onUp = (event) => {
// flag passed from clicker js
if (event.wasSphereHit) { return; }
if(this.isHitByRayCast){
this.teleport();
}
};
document.addEventListener('INTRO_COMPLETED', (event) => {
setTimeout(() => {
document.addEventListener("CONTROLLER_CREATED", (event) => {
this.doesControllerExist = true;
this.hideTeleport();
// disables teleport on click screen tap
if (event.detail.id === "Daydream Controller") {
document.removeEventListener('touchend', onUp);
}
// enables teleport on controller
document.addEventListener('trackpadup', onUp);
document.addEventListener('thumbstickup', onUp);
}, false);
let avatar = document.querySelector('#avatar');
if(getParameterByName("teleport")==="false"){
this.hideTeleport();
return;
}
document.addEventListener("touchstart", (event) => {});
document.addEventListener("touchend", (event) => {});
let isTablet = this.isTabletLikeDimensions() && this.isTouchDevice();
if (AFRAME.utils.device.isMobile() || isTablet) {
document.addEventListener('mouseup', onUp);
} else {
this.hideTeleport();
return;
}
this.el.addEventListener('raycaster-intersected', (event) => {
if(this.isOverSphere) { return; }
if(this.isAnimating) { return; }
if(this.isHitByRayCast) { return; }
this.showTeleport();
});
this.el.addEventListener('raycaster-intersected-cleared', (event) => {
if(this.isOverSphere) { return; }
if(this.isAnimating) { return; }
if(!this.isHitByRayCast) { return; }
this.hideTeleport();
});
document.addEventListener('keyup', (event) => {
switch (event.keyCode) {
case 32: // spacebar
this.onUp(event);
break;
}
});
document.addEventListener('ON_SPHERE_IN', (event) => {
this.isOverSphere = true;
this.hideTeleport();
});
document.addEventListener('ON_SPHERE_OUT', (event) => {
this.isOverSphere = false;
});
});
});
},
hideTeleport: function() {
this.floorTarget.setAttribute("visible", "false");
this.isHitByRayCast = false;
},
showTeleport: function() {
this.floorTarget.setAttribute("visible", "true");
this.isHitByRayCast = true;
},
teleport: function() {
if (!this.getTreeCreated()) { return; }
if (this.isNearSphere()) { return; }
if (!this.isHitByRayCast) { return; }
this.isAnimating = true;
const target = document.querySelector('#gaze-hit');
const speed = 100;
const player = document.querySelector("#player");
const avatar = document.querySelector('#avatar');
const daydream = document.querySelector('#daydream');
let targetPosition = target.object3D.position;
let position = getComponentProperty(player,"position");
let moveTo = (data) => {
setComponentProperty(player, "position", position);
if (!daydream) { return; }
setComponentProperty(daydream, "position", position);
};
if (this.flyTween) {
this.flyTween.stop();
}
this.flyTween = new TWEEN.Tween(position)
.to(targetPosition.clone(), speed)
.easing(TWEEN.Easing.Exponential.InOut)
.onUpdate(moveTo)
.onComplete(()=>{
this.isAnimating = false;
this.hideTeleport();
})
.start();
},
pause: function() {
},
tick: function() {
if(this.isAnimating) { return; }
let cam = document.querySelector("[raycaster]").object3D;
let orientation = cam.getWorldDirection();
orientation.y *= -1;
orientation.z *= -1;
orientation.x *= -1;
let ray = new THREE.Ray(cam.getWorldPosition(), orientation);
let point = ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0)));
if(point) {
point.y = 0.02;
this.floorTarget.setAttribute('position', point);
}
},
getTreeCreated: function() {
return this.el.sceneEl.systems.tree.getTreeCreated();
},
isNearSphere: function() {
let nearSphereCount = 0;
let elements = document.querySelectorAll('[grab-move]');
let entity;
for(let i=0; i h) ? w : h) >= 960);
},
isTouchDevice: function() {
return 'ontouchstart' in window || navigator.maxTouchPoints; // works on most browsers || works on IE10/11 and Surface
},
});
================================================
FILE: js/components/tone.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Tone from 'tone';
import {scale} from '../util';
import { Instruments, NoteCount, InstrumentCount, TotalNotes} from '../core/instruments';
//create the background track
const filetype = Tone.Buffer.supportsType('mp3') ? 'mp3' : 'ogg';
const ambient = new Tone.Player(`static/audio/bg.${filetype}`, () => {
ambient.start();
ambient.volume.rampTo(-14, 4);
}).toMaster();
ambient.volume.value = -Infinity;
ambient.loop = true;
window.addEventListener('blur', () => {
Tone.Master.mute = true;
});
window.addEventListener('focus', () => {
Tone.Master.mute = false;
});
document.addEventListener("ENTERED_FOREST", () => {
Tone.Master.mute = false;
});
document.addEventListener("EXITED_FOREST", () => {
Tone.Master.mute = true;
});
AFRAME.registerComponent('tone', {
schema: {
type: 'int',
default: 0
},
init() {
// listen for events
this.el.addEventListener('controllerhit', this.controllerHit.bind(this));
this._palette = this.el.sceneEl.components.palette;
this._lastTrigger = 0;
},
remove() {
},
update(oldData) {
if (this.data !== oldData && typeof oldData !== "undefined") {
this.trigger(Tone.now() + 0.1, 0.4);
}
},
getPosition() {
const object3d = this.el.object3D;
object3d.updateMatrixWorld();
const matrixWorld = object3d.matrixWorld;
const position = new THREE.Vector3().setFromMatrixPosition(matrixWorld);
return position;
},
trigger(time = Tone.now(), velocity = 1) {
// this.env.triggerAttack(time, velocity)
const pos = this.getPosition();
Instruments[this.getShape()].trigger(time, this.getNote(), velocity, pos.x, pos.y, pos.z);
},
hit(time, velocity, controllerPosition = null, sourceId = null) {
// hits.push('#' + this.el.id);
this.trigger(time, velocity);
this.el.emit('hit', {
from: sourceId,
velocity: velocity,
delay: time - Tone.now(),
controllerPosition: controllerPosition
});
},
updatePosition() {
const object3d = this.el.object3D;
object3d.updateMatrixWorld();
const matrixWorld = object3d.matrixWorld;
const position = new THREE.Vector3().setFromMatrixPosition(matrixWorld);
this.panner.setPosition(position.x, position.y, position.z);
},
controllerHit(data) {
if( !this.el.getAttribute("visible")) {return;}
const time = Tone.Time('+0.01').toSeconds();
if ((time - this._lastTrigger) > 0.1) {
this._lastTrigger = time;
this.hit(time, data.detail.velocity, data.detail.controllerPosition);
}
},
//return the shape number of the tone 0-2
getShape(){
return Math.floor(this.data / NoteCount);
},
//return the note number of the tone 0-5
getNote(){
return this.data % NoteCount;
},
//return the total number of tones
//all note/shape
getTotalTones(){
return TotalNotes;
},
});
================================================
FILE: js/components/tool-tips.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
AFRAME.registerComponent('tool-tips', {
tooltip: null,
sprite: null,
clock: null,
tilesIncX : 6,
tilesIncY : 8,
totalTiles : 48,
tileTime: 48*4,
currentTime:0,
currentTile:0,
schema: {
hand: {
default: "right",
type: "string"
},
timeout: {
default: 30000,
type: "int"
}
},
init: function() {
this.clock = new THREE.Clock();
},
play: function() {
// adds timeout for tool tip removal
let addToolTipTimeout = () => {
document.removeEventListener("TREE_CREATED", addToolTipTimeout);
setTimeout(() => {
removeToolTipsBind();
}, this.data.timeout);
};
let removeToolTipsBind = () => {
document.removeEventListener("TREE_CREATED", addToolTipTimeout);
this.el.removeEventListener('triggerup', removeToolTipsBind);
this.removeToolTips();
};
let addToolTips = () => {
let loader = new THREE.TextureLoader();
loader.load("./static/img/toot_tip_"+this.data.hand+".png", (texture) => {
this.sprite = texture;
this.sprite.wrapS = THREE.RepeatWrapping;
this.sprite.wrapT = THREE.RepeatWrapping;
this.sprite.repeat.set( 1/this.tilesIncX, 1/this.tilesIncY );
let material = new THREE.MeshBasicMaterial({
map: this.sprite,
side: THREE.DoubleSide
});
let geometry = new THREE.PlaneGeometry( 0.1, 0.05, 1 );
this.tooltip = new THREE.Mesh(geometry, material);
this.tooltip.rotation.x = THREE.Math.degToRad(-90);
this.tooltip.position.x = (this.data.hand==="right") ? -0.085 : 0.085;
this.tooltip.position.z = 0.075;
this.el.object3D.add(this.tooltip);
});
// timeout required due to triggerup firing prematurely
setTimeout(() => {
this.el.addEventListener('triggerup', removeToolTipsBind);
if(this.isTreeCreated()){
addToolTipTimeout();
} else {
document.addEventListener("TREE_CREATED", addToolTipTimeout);
}
});
};
// adds tooltips once object 3d is ready
let checkForToolTips = () => {
if(this.el.object3D) {
addToolTips();
return;
}
requestAnimationFrame(checkForToolTips);
};
checkForToolTips();
},
removeToolTips: function() {
if(!this.tooltip) { return; }
this.el.object3D.remove(this.tooltip);
this.el.removeAttribute("tool-tips");
},
pause: function() {
},
tick: function() {
if(!this.tooltip) { return; }
this.currentTime += (1000 * this.clock.getDelta());
while (this.currentTime > this.tileTime) {
this.currentTime -= this.tileTime;
this.currentTile++;
if (this.currentTile == this.totalTiles){
this.currentTile = 0;
}
this.sprite.offset.x = Math.floor( this.currentTile / this.tilesIncX ) / this.tilesIncX;
this.sprite.offset.y = ( this.currentTile % this.tilesIncX ) / this.tilesIncY;
}
},
isTreeCreated: function() {
return this.el.sceneEl.systems.tree.isTreeCreated;
}
});
================================================
FILE: js/components/touch-color.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { getViewerType, getParameterByName } from '../util'
import { ControllerColors } from '../core/colors';
import TWEEN from 'tween.js';
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
AFRAME.registerComponent('touch-color', {
dependencies: ['tracked-controls', 'brush'],
tickTween: null,
trackPosStart: null,
trackPos: null,
prevTrackPos: null,
disc: null,
rotationChange: 0,
totalTicks: 20,
ticks: null,
lastTone : 4,
schema: {
isClockwise: {
default: true
},
isTracking: {
default: false
},
trackPos: {
default: new THREE.Vector2()
},
isTrackPadTouched: {
default: false
},
degrees: {
default: 0
},
color: { type: 'string', default: 'red' },
},
init: function() {
this.prevTrackPos = new THREE.Vector2(1, 1);
setTimeout(() => {
let material, geometry, ball;
ball = this.el.object3D;
if (this.el.id === "avatar") {
} else {
geometry = new THREE.CylinderGeometry(0.02, 0.02, 0.01, 32);
material = new THREE.MeshBasicMaterial({
color: this.getColor()
});
this.disc = new THREE.Mesh(geometry, material);
this.disc.name = "disc";
// this.disc.rotation.x = THREE.Math.degToRad( 6 );
this.disc.position.x = 0;
this.disc.position.y = 0.001;
this.disc.position.z = 0.0728 - 0.025;
let tick, theta;
this.ticks = [];
for (let i = 0; i < this.totalTicks; i++) {
geometry = new THREE.CylinderGeometry(0.001, 0.001, 0.011, 12);
material = new THREE.MeshBasicMaterial({
color: 0xFFFFFF
});
tick = new THREE.Mesh(geometry, material);
theta = i / this.totalTicks * Math.PI * 2;
tick.position.x = Math.cos(theta) * 0.017;
tick.position.z = Math.sin(theta) * 0.017;
this.ticks.push(tick);
this.disc.add(tick);
}
ball.add(this.disc);
}
}, 1);
this.el.addEventListener("componentchanged", function(e) {
if (e.detail.name === "grab-move") {
//if the attribute changed
if (e.detail.oldData.grabbing !== e.detail.newData.grabbing) {
// reset the rotation detail
this.rotationChange = 0;
}
}
});
document.addEventListener("SPHERE_TEXTURE_LOADED", (event) => {
this.update();
});
},
play: function() {
let el = this.el;
if (this.el.id === "avatar" && getParameterByName('reticle')==='true') {
document.addEventListener('keyup', this.onKeyUp.bind(this));
document.addEventListener('keydown', this.onKeyDown.bind(this));
}
el.addEventListener('trackpaddown', this.onTrackPadTouchStart.bind(this));
el.addEventListener('trackpadup', this.onTrackPadTouchEnd.bind(this));
el.addEventListener('touchstart', this.onTrackPadTouchStart.bind(this));
el.addEventListener('touchend', this.onTrackPadTouchEnd.bind(this));
el.addEventListener('axismove', this.onTrackPadMove.bind(this));
},
pause: function() {
let el = this.el;
if (this.el.id === "avatar" && getParameterByName('reticle')==='true') {
document.removeEventListener('keyup', this.onKeyUp);
document.removeEventListener('keydown', this.onKeyDown);
}
el.removeEventListener('trackpaddown', this.onTrackPadTouchStart);
el.removeEventListener('trackpadup', this.onTrackPadTouchEnd);
el.removeEventListener('touchstart', this.onTrackPadTouchStart);
el.removeEventListener('touchend', this.onTrackPadTouchEnd);
el.removeEventListener('axismove', this.onTrackPadMove);
},
// 57, 48
onKeyUp: function(event) {
switch (event.keyCode) {
// close bracket
case 32:
// document.dispatchEvent(new Event("TRACKPAD_UP"));
break;
}
},
onKeyDown: function(event) {
switch (event.keyCode) {
// <
case 188:
this.selectSound(-0.03);
break;
// >
case 190:
// this.rotateSound(1);
this.selectSound(0.03);
break;
}
},
selectSound: function(increment) {
let grabComponent = getComponentProperty(this.el, "grab-move");
if (!grabComponent.isNearSphere) {
return;
}
this.rotationChange += increment;
const incrementAmount = 0.02;
if (Math.abs(this.rotationChange) > incrementAmount) {
this.rotationChange = this.rotationChange % incrementAmount;
grabComponent.closestSphere.components.ball.note.setRotationIncrement(this.rotationChange > 0);
// move the tone value
let totalNotes = this.el.sceneEl.components.palette.totalNotes();
let tone = grabComponent.closestSphere.getAttribute("tone");
tone = this.rotationChange > 0 ? tone + 1 : tone - 1;
tone = tone % (totalNotes);
tone = (tone<0) ? (totalNotes - 1) : tone;
this.lastTone = tone;
grabComponent.closestSphere.setAttribute("tone", tone);
}
},
onTrackPadTouchStart: function(event) {
this.trackPosStart = null;
this.data.isTrackPadTouched = true;
},
onTrackPadTouchEnd: function(event) {
this.trackPosStart = null;
this.data.isTrackPadTouched = false;
},
onTrackPadMove: function(event) {
// HACK - trackpad move is firing on trigger.x = 0 fixes this
if (this.data.isTrackPadTouched === false || event.detail.axis[0] === 0) {
return;
}
this.data.trackPos.fromArray(event.detail.axis, 0);
if (this.el.hasAttribute("vive-controls")) {
this.data.trackPos.y *= -1;
}
this.data.trackPos.normalize();
this.data.isTracking = (this.data.trackPos.distanceToSquared(this.prevTrackPos) > 0.001);
if (!this.trackPosStart) {
this.trackPosStart = this.data.trackPos.clone();
}
let radiansA = Math.atan2(this.data.trackPos.y, this.data.trackPos.x);
let radiansB = Math.atan2(this.trackPosStart.y, this.data.trackPos.x);
this.data.degrees = Math.abs((radiansA - radiansB) * 180 / Math.PI);
let cross = (this.prevTrackPos.x * this.data.trackPos.y) - (this.prevTrackPos.y * this.data.trackPos.x);
if (cross !== 0) {
this.data.isClockwise = (cross > 0);
}
this.prevTrackPos.copy(this.data.trackPos);
if (!this.data.isTracking ||
!this.data.isTrackPadTouched
) {
return;
}
this.selectSound(0.003 * (this.data.isClockwise ? 1 : -1));
this.data.trackPos.multiplyScalar(0.1);
if (this.data.trackPos.lengthSq() > 0.005) {
let angle = this.data.trackPos.angle() * (180 / Math.PI);
let tickSeed = Math.floor((angle / 360) * this.totalTicks);
let tick = this.ticks[tickSeed];
let scalar = {
scale: 2
};
this.tickTween = new TWEEN.Tween({
scale: 0
})
.to({
scale: 1
}, 250)
.onUpdate((percentage) => {
tick.scale.x = 1 - percentage + 1;
tick.scale.z = 1 - percentage + 1;
});
this.tickTween.start();
}
},
tick: function() {
},
update: function( oldData ) {
if ( !this.disc ) { return; }
this.disc.material.color = this.getColor();
},
getColor: function() {
return new THREE.Color( this.el.sceneEl.components.palette.controllerColors()[1] );
}
});
function tweenUpdate() {
requestAnimationFrame(tweenUpdate);
TWEEN.update();
}
tweenUpdate();
================================================
FILE: js/components/tree.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ShadowColor } from '../core/colors';
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
const stringify = AFRAME.utils.coordinates.stringify;
AFRAME.registerSystem('tree', {
tree: null,
treeTimeOut:null,
isTreeCreated:false,
schema: {
},
init: function() {
document.addEventListener("TREE_CREATED", (event) => {
setTimeout(() => {
let balls = tree.getElementsByClassName("selectable");
}, 100);
});
document.addEventListener("ON_TRIGGER_EMPTY_SPACE", (event) => {
let newBallData = {
id: "TEMP_NEW_SPHERE_ID",
position: stringify(event.detail.controllerPos),
hand: event.detail.hand,
tone: event.detail.lastTone
};
this.createSphere(newBallData);
ga('send', 'event', "interaction", "create");
}, false);
document.addEventListener("ON_DELETE", (event) => {
this.deleteSphere(event.detail.closestEntity);
ga('send', 'event', "interaction", "delete");
}, false);
this.createTree();
},
createTree: function() {
this.tree = document.createElement('a-entity');
this.tree.id = "tree";
setComponentProperty(this.tree, "position","0 0 0");
this.sceneEl.appendChild(this.tree);
},
createSphere: function(ballData) {
if (!ballData) throw new Error("No ball data");
let entity = document.createElement('a-entity');
entity.id = ballData.id;
entity.className = "ball";
entity.className += " selectable";
this.tree.appendChild(entity);
setComponentProperty(entity, "ball", {hand:ballData.hand});
setComponentProperty(entity, "tone", ballData.tone);
setComponentProperty(entity, "position", ballData.position);
setComponentProperty(entity, "visible", ballData.visible);
setComponentProperty(entity, 'smooth-motion', 'amount:3');
setComponentProperty(entity, 'ga', true);
// setComponentProperty(entity, "mixin", "obj-ball");
setComponentProperty(entity, "copresence", {uuid:ballData.uuid});
if(!this.isTreeCreated){
clearTimeout(this.treeTimeOut);
this.treeTimeOut = setTimeout(() => {
this.isTreeCreated = true;
document.dispatchEvent(new Event("TREE_CREATED"));
},0);
}
},
deleteSphere: function(ballData) {
let entity = document.querySelector("#" + ballData.id);
if (!entity) {
return;
}
let component = getComponentProperty(entity, "ball");
// remove ball
setTimeout(() => {
this.tree.removeChild(entity);
}, 0);
},
truncateDecimals: function(number, digits) {
let multiplier = Math.pow(10, digits),
adjustedNum = number * multiplier,
truncatedNum = Math[adjustedNum < 0 ? 'ceil' : 'floor'](adjustedNum);
return truncatedNum / multiplier;
},
switchRoom:function(){
this.sceneEl.systems["copresence-server"].switchRoom();
},
clear: function(){
let tree = document.querySelector("#tree");
let balls = tree.getElementsByClassName("selectable");
// delete balls on timer
let setDeleteTimer = (ball, timer) => {
setTimeout((event) => {
this.deleteSphere(ball);
},timer);
};
for(let i=0; i {
let radius = this.data.maxRadius;
this.el.addEventListener("componentchanged", event => {
if (event.detail.name === "position") {
let position = event.detail.newData;
if (position.z > radius) {
position.z = radius;
}
if (position.z < -radius) {
position.z = -radius;
}
if (position.x > radius) {
position.x = radius;
}
if (position.x < -radius) {
position.x = -radius;
}
setComponentProperty(this.el, "position", position);
}
});
});
},
play: function() {
},
pause: function() {
},
});
================================================
FILE: js/core/color-set.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export class ColorSet {
constructor( background, idle, active, stem ) {
this.background = new THREE.Color( background );
this.idle = new THREE.Color( idle );
this.active = new THREE.Color( active );
this.stem = new THREE.Color( stem );
}
setUniforms( material ) {
material.uniforms.bgColor.value = this.background;
material.uniforms.idleColor.value = this.idle;
material.uniforms.activeColor.value = this.active;
}
}
================================================
FILE: js/core/colors.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ColorSet } from './color-set';
const BallColors = {
blue: [
// bg idle active stem
//spheres - yellow to orange
new ColorSet( 0xFFC362, 0xFFA544, 0xFFE180, 0xFFA544 ),
new ColorSet( 0xFFB564, 0xFF9746, 0xFFD382, 0xFF9746 ),
new ColorSet( 0xFFA765, 0xFF8947, 0xFFC583, 0xFF8947 ),
new ColorSet( 0xFF9A67, 0xFF7C49, 0xFFB885, 0xFF7C49 ),
new ColorSet( 0xFF8C68, 0xFF6E4A, 0xFFAA86, 0xFF6E4A ),
new ColorSet( 0xFF7E6A, 0xFF604C, 0xFF9C88, 0xFF604C ),
//squares - blue to green
new ColorSet( 0x49A6E5, 0x308DCC, 0x6CC9FF, 0x308DCC ),
new ColorSet( 0x3AAED3, 0x2195BA, 0x5DD1F6, 0x2195BA ),
new ColorSet( 0x2CB7C1, 0x139EA8, 0x4FDAE4, 0x048F99 ),
new ColorSet( 0x1DBFAE, 0x04A695, 0x40E2D1, 0x04A695 ),
new ColorSet( 0x0FC89C, 0x00AF83, 0x32EBBF, 0x00AF83 ),
new ColorSet( 0x00D08A, 0x00B771, 0x23F3AD, 0x00B771 ),
//triangles - purple to light blue
new ColorSet( 0xAC44C8, 0x932AAF, 0xDE76FA, 0x982FB4 ),
new ColorSet( 0xA258D3, 0x893FBA, 0xD48AFF, 0x8E44BF ),
new ColorSet( 0x986DDE, 0x7F54C5, 0xCA9FFF, 0x8459CA ),
new ColorSet( 0x8D81E8, 0x7468CF, 0xBFB3FF, 0x796DD4 ),
new ColorSet( 0x8396F3, 0x6A7DDA, 0xB5C8FF, 0x6F82DF ),
new ColorSet( 0x79AAFE, 0x6091E5, 0xABDCFF, 0x6596EA ),
],
red: [
// bg idle active stem
//spheres - yellow to orange
new ColorSet( 0xFFC362, 0xFFA544, 0xFFE180, 0xFFA544 ),
new ColorSet( 0xFFB564, 0xFF9746, 0xFFD382, 0xFF9746 ),
new ColorSet( 0xFFA765, 0xFF8947, 0xFFC583, 0xFF8947 ),
new ColorSet( 0xFF9A67, 0xFF7C49, 0xFFB885, 0xFF7C49 ),
new ColorSet( 0xFF8C68, 0xFF6E4A, 0xFFAA86, 0xFF6E4A ),
new ColorSet( 0xFF7E6A, 0xFF604C, 0xFF9C88, 0xFF604C ),
//squares - blue to green
new ColorSet( 0x49A6E5, 0x308DCC, 0x6CC9FF, 0x308DCC ),
new ColorSet( 0x3AAED3, 0x2195BA, 0x5DD1F6, 0x2195BA ),
new ColorSet( 0x2CB7C1, 0x139EA8, 0x4FDAE4, 0x048F99 ),
new ColorSet( 0x1DBFAE, 0x04A695, 0x40E2D1, 0x04A695 ),
new ColorSet( 0x0FC89C, 0x00AF83, 0x32EBBF, 0x00AF83 ),
new ColorSet( 0x00D08A, 0x00B771, 0x23F3AD, 0x00B771 ),
//triangles - purple to light blue
new ColorSet( 0xAC44C8, 0x932AAF, 0xDE76FA, 0x982FB4 ),
new ColorSet( 0xA258D3, 0x893FBA, 0xD48AFF, 0x8E44BF ),
new ColorSet( 0x986DDE, 0x7F54C5, 0xCA9FFF, 0x8459CA ),
new ColorSet( 0x8D81E8, 0x7468CF, 0xBFB3FF, 0x796DD4 ),
new ColorSet( 0x8396F3, 0x6A7DDA, 0xB5C8FF, 0x6F82DF ),
new ColorSet( 0x79AAFE, 0x6091E5, 0xABDCFF, 0x6596EA ),
],
green: [
// bg idle active stem
//spheres - yellow to orange
new ColorSet( 0xFFC362, 0xFFA544, 0xFFE180, 0xFFA544 ),
new ColorSet( 0xFFB564, 0xFF9746, 0xFFD382, 0xFF9746 ),
new ColorSet( 0xFFA765, 0xFF8947, 0xFFC583, 0xFF8947 ),
new ColorSet( 0xFF9A67, 0xFF7C49, 0xFFB885, 0xFF7C49 ),
new ColorSet( 0xFF8C68, 0xFF6E4A, 0xFFAA86, 0xFF6E4A ),
new ColorSet( 0xFF7E6A, 0xFF604C, 0xFF9C88, 0xFF604C ),
//squares - blue to green
new ColorSet( 0x49A6E5, 0x308DCC, 0x6CC9FF, 0x308DCC ),
new ColorSet( 0x3AAED3, 0x2195BA, 0x5DD1F6, 0x2195BA ),
new ColorSet( 0x2CB7C1, 0x139EA8, 0x4FDAE4, 0x048F99 ),
new ColorSet( 0x1DBFAE, 0x04A695, 0x40E2D1, 0x04A695 ),
new ColorSet( 0x0FC89C, 0x00AF83, 0x32EBBF, 0x00AF83 ),
new ColorSet( 0x00D08A, 0x00B771, 0x23F3AD, 0x00B771 ),
//triangles - purple to light blue
new ColorSet( 0xAC44C8, 0x932AAF, 0xDE76FA, 0x982FB4 ),
new ColorSet( 0xA258D3, 0x893FBA, 0xD48AFF, 0x8E44BF ),
new ColorSet( 0x986DDE, 0x7F54C5, 0xCA9FFF, 0x8459CA ),
new ColorSet( 0x8D81E8, 0x7468CF, 0xBFB3FF, 0x796DD4 ),
new ColorSet( 0x8396F3, 0x6A7DDA, 0xB5C8FF, 0x6F82DF ),
new ColorSet( 0x79AAFE, 0x6091E5, 0xABDCFF, 0x6596EA ),
],
};
const ControllerColors = {
// bg grain
blue: [ 0x5396f2, 0x5261f2 ],
red: [ 0xec4c4e, 0xc14040 ],
green: [ 0x93cbc4, 0x58a868 ]
};
const BgTreeColors = [ 0x827193, 0xa08293, 0xc59498 ];
const EnvColors = {
floor: 0xF79F99,
fog: 0xe9a69a
};
const ShadowColor = 0xdf978b;
const HeadsetColor = 0xE8E8E8;
const HeadsetShadow = 0xAFAFAF;
export { BallColors, ControllerColors, BgTreeColors, EnvColors, ShadowColor, HeadsetColor, HeadsetShadow };
================================================
FILE: js/core/instrument.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Tone from 'tone';
export class Instrument {
constructor( data ) {
const folder = data.name;
const urls = {};
const filetype = Tone.Buffer.supportsType('mp3') ? 'mp3' : 'ogg';
for (let i = 0; i < data.noteCount; i++){
urls[i] = `static/audio/${folder}/${i}.${filetype}`;
}
this._buffers = new Tone.Buffers(urls);
this.color = data.color;
this.shape = data.shape;
this.output = new Tone.Gain().toMaster();
this.output.gain.value = 2;
}
trigger(time, tone, velocity, x, y, z){
if (this._buffers.loaded){
const source = this._createSource(time, tone, velocity);
const panner = this._createPanner(x, y, z);
source.connect(panner);
}
}
_createPanner(x, y, z){
const panner = Tone.context.createPanner();
panner.rolloffFactor = 2;
panner.setPosition(x, y, z);
panner.connect(this.output);
return panner;
}
_createSource(time, note, velocity){
const buffer = this._buffers.get(note);
const source = new Tone.BufferSource(buffer);
source.start(time, 0, undefined, velocity * 0.5, 0.01);
return source;
}
dispose(){
this.output.dispose();
}
}
================================================
FILE: js/core/instruments.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Instrument } from './instrument';
export const NoteCount = 6;
export const Instruments = [
new Instrument({
name: 'percussion',
color: 'blue',
shape: 'circles',
noteCount : NoteCount
}),
new Instrument({
name: 'marimba',
color: 'red',
shape: 'squares',
noteCount : NoteCount
}),
new Instrument({
name: 'voice',
color: 'green',
shape: 'triangles',
noteCount : NoteCount
}),
];
export const InstrumentCount = Instruments.length;
export const TotalNotes = NoteCount * InstrumentCount;
================================================
FILE: js/core/shape-data.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const TONE_CHANNEL_MAP = [
new THREE.Vector3( 1, 0, 0 ),
new THREE.Vector3( 1, 0, 0 ),
new THREE.Vector3( 0, 1, 0 ),
new THREE.Vector3( 0, 0, 1 ),
new THREE.Vector3( 1, 0, 0 ),
new THREE.Vector3( 0, 1, 0 ),
new THREE.Vector3( 0, 0, 1 )
];
export class ShapeData {
constructor( layout, repeats, tone ) {
this.layout = layout;
this.repeats = repeats;
this.tone = tone;
}
setUniforms( material ) {
material.uniforms.spriteLayout.value = this.layout;
material.uniforms.spriteRepeat.value = this.repeats;
material.uniforms.spriteChannel.value = TONE_CHANNEL_MAP[ this.tone ];
material.uniforms.use567.value = this.tone > 3 ? 1.0 : 0.0;
}
}
================================================
FILE: js/core/shapes.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ShapeData } from './shape-data';
export const Shapes = {
'circles': [
new ShapeData( new THREE.Vector2( 8, 4 ), new THREE.Vector2( 4, 1 ), 0 ),
new ShapeData( new THREE.Vector2( 8, 4 ), new THREE.Vector2( 4, 1 ), 1 ),
new ShapeData( new THREE.Vector2( 8, 4 ), new THREE.Vector2( 4, 1 ), 2 ),
new ShapeData( new THREE.Vector2( 8, 4 ), new THREE.Vector2( 3, 1 ), 3 ),
new ShapeData( new THREE.Vector2( 8, 4 ), new THREE.Vector2( 3, 1 ), 4 ),
new ShapeData( new THREE.Vector2( 6, 6 ), new THREE.Vector2( 2, 1 ), 5 ),
new ShapeData( new THREE.Vector2( 6, 6 ), new THREE.Vector2( 2, 1 ), 6 )
],
'triangles': [
new ShapeData( new THREE.Vector2( 9, 4 ), new THREE.Vector2( 4, 1 ), 0 ),
new ShapeData( new THREE.Vector2( 9, 4 ), new THREE.Vector2( 4, 1 ), 1 ),
new ShapeData( new THREE.Vector2( 9, 4 ), new THREE.Vector2( 4, 1 ), 2 ),
new ShapeData( new THREE.Vector2( 6, 6 ), new THREE.Vector2( 3, 1 ), 3 ),
new ShapeData( new THREE.Vector2( 7, 5 ), new THREE.Vector2( 3, 1 ), 4 ),
new ShapeData( new THREE.Vector2( 7, 5 ), new THREE.Vector2( 3, 1 ), 5 ),
new ShapeData( new THREE.Vector2( 6, 6 ), new THREE.Vector2( 3, 1 ), 6 )
],
'squares': [
new ShapeData( new THREE.Vector2( 2, 17 ), new THREE.Vector2( 1, 4 ), 0 ),
new ShapeData( new THREE.Vector2( 2, 17 ), new THREE.Vector2( 1, 4 ), 1 ),
new ShapeData( new THREE.Vector2( 3, 11 ), new THREE.Vector2( 1, 3 ), 2 ),
new ShapeData( new THREE.Vector2( 3, 11 ), new THREE.Vector2( 1, 3 ), 3 ),
new ShapeData( new THREE.Vector2( 3, 11 ), new THREE.Vector2( 1, 3 ), 4 ),
new ShapeData( new THREE.Vector2( 3, 11 ), new THREE.Vector2( 1, 2 ), 5 ),
new ShapeData( new THREE.Vector2( 3, 11 ), new THREE.Vector2( 1, 2 ), 6 )
]
};
================================================
FILE: js/index.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// debug
require('./util/trace.js');
require('./util/browserCheck.js');
// core
require('aframe');
require('./ascene.js');
require('webvr-ui');
require('./components/copresence-server.js');
require('./components/headset-material.js');
require('./components/bg-tree-ring-material.js');
require('../third_party/aframe-daydream-controller-component/daydream-controller.js');
require('./components/daydream-manager.js');
require('./components/daydream-pointer.js');
require('./components/fake-light.js');
require('./components/tool-tips.js');
require('./components/controller-material.js');
// utility
require('./components/quaternion.js');
require('./components/smooth-motion.js');
require('./components/controllers.js');
require('./components/haptics.js');
require('./components/ga.js');
// shaders
require( './shaders/bg-tree-shader' );
require( './shaders/circle-shader' );
require( './shaders/ball-shader' );
// tree
require('./components/tree.js');
require('./components/ball.js');
require('./components/background-objects.js');
// interaction
require('./components/gaze.js');
require('./components/grab-move.js');
require('./components/touch-color.js');
require('./components/teleport.js');
require('./components/proximity-check.js');
require('./components/clicker.js');
require('./components/wasd-boundaries.js');
//sound
require('./components/tone.js');
require('./components/palette.js');
require('./components/listener.js');
//the splash screen
require('./splash');
================================================
FILE: js/notes/note-head-cube.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteHead } from './note-head';
import { RandomRange3D } from '../util/random-range-3d';
const CUBE_GEOMETRY = new THREE.BoxGeometry( 0.1, 0.1, 0.1 );
const RANDOM_ROTATION = new RandomRange3D(
new THREE.Euler( -3.14, -3.14, -3.14 ),
new THREE.Euler( +3.14, +3.14, +3.14 )
);
export class NoteHeadCube extends NoteHead {
get geometry() {
return CUBE_GEOMETRY;
}
get rotation() {
return RANDOM_ROTATION.value;
}
}
================================================
FILE: js/notes/note-head-sphere.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteHead } from './note-head';
import { RandomRange3D } from '../util/random-range-3d';
const CUBE_GEOMETRY = new THREE.IcosahedronGeometry( 0.05, 2 );
const RANDOM_ROTATION = new RandomRange3D(
new THREE.Euler( -3.14, -3.14, -3.14 ),
new THREE.Euler( +3.14, +3.14, +3.14 )
);
export class NoteHeadSphere extends NoteHead {
get geometry() {
return CUBE_GEOMETRY;
}
get rotation() {
return RANDOM_ROTATION.value;
}
}
================================================
FILE: js/notes/note-head-tetra.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteHead } from './note-head';
import { RandomRange3D } from '../util/random-range-3d';
import { Tetrahedron } from '../util/tetrahedron';
const TETRA_GEOMETRY = new Tetrahedron().geometry;
const RANDOM_ROTATION = new RandomRange3D(
new THREE.Euler( -0.3, -0.3, -0.3 ),
new THREE.Euler( +0.3, +0.3, +0.3 )
);
export class NoteHeadTetra extends NoteHead {
constructor( palette, shape ) {
super( palette, shape );
// Enable flat shading
this.material.extensions = { derivatives: true };
this.material.defines = { FLAT_SHADED: true };
}
get geometry() {
return TETRA_GEOMETRY;
}
get rotation() {
return RANDOM_ROTATION.value;
}
}
================================================
FILE: js/notes/note-head.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const stringify = AFRAME.utils.coordinates.stringify;
import TWEEN from 'tween.js';
export class NoteHead {
constructor( palette, shape ) {
this.palette = palette;
this.shape = shape;
this.shader = THREE.BallShader;
this.uniforms = THREE.UniformsUtils.clone( this.shader.uniforms );
this.material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: this.shader.vertexShader,
fragmentShader: this.shader.fragmentShader
});
this.mesh = new THREE.Mesh( this.geometry, this.material );
}
setTone( value ) {
this.tone = value;
this.scale = ( 1 - this.tone / ( this.palette.totalNotes() - 1 ) ) * 2 + 1;
this.mesh.scale.set( this.scale, this.scale, this.scale );
setTimeout( () => {
let textureId = Math.floor( this.tone / this.palette.noteCount() );
let textureOrder = this.tone % this.palette.noteCount();
let textureData = ["circles","squares","triangles"][textureId];
this.palette.shapePalette( textureData )[ textureOrder ].setUniforms( this.material );
this.palette.colorPalette()[ this.tone ].setUniforms( this.material );
let texture134 = this.palette.textureSprite134( textureId );
let texture567 = this.palette.textureSprite567( textureId );
texture134.needsUpdate = true;
texture567.needsUpdate = true;
this.material.uniforms.map134.value = texture134;
this.material.uniforms.map567.value = texture567;
this.material.uniforms.lightPosition.value.copy( this.lightPosition );
},0);
}
highlight( event ) {
this.material.uniforms.brightnessAmount.value = 0.2;
}
unHighlight( event ) {
this.material.uniforms.brightnessAmount.value = 0.0;
}
hit( event, timeMs ) {
// Cancel tween if it already exists
if ( this.hitTween ) {
this.hitTween.stop();
}
this.hitPosition = event.detail.controllerPosition;
this.offsetPosition = this.mesh.getWorldPosition();
if(this.hitPosition) {
this.offsetPosition.sub(this.hitPosition);
}
this.hitVelocity = (event.detail.velocity===0) ? 1 : event.detail.velocity;
this.hitTween = new TWEEN.Tween( { t: this.hitVelocity } )
.to( { t: 0 }, timeMs )
.easing( TWEEN.Easing.Elastic.Out )
.delay( event.detail.delay * 1000 )
.onUpdate( (t) => {
this.updateHitTween( t );
})
.onComplete( () => {
this.updateHitTween( 1 );
});
this.hitTween.start();
this.hitFrameTween = new TWEEN.Tween( { t: 0 } )
.to( { t: 1 }, timeMs*1.25 )
.easing( TWEEN.Easing.Linear.None )
.delay( event.detail.delay * 1000 )
.onUpdate( (t) => {
this.material.uniforms.spriteIndex.value = Math.floor( t * 32 );
})
.onComplete( () => {
this.material.uniforms.spriteIndex.value = Math.floor( 1 * 32 );
});
this.hitFrameTween.start();
}
updateHitTween( t ) {
let s = ( 1 - t ) * this.hitVelocity + this.scale;
this.mesh.scale.set( s, s, s );
this.material.uniforms.activeAmount.value = 1 - t;
if(this.hitPosition) {
let p = ( 1 - t );
this.mesh.position.x = this.offsetPosition.x*p*this.hitVelocity;
this.mesh.position.y = this.offsetPosition.y*p*this.hitVelocity;
}
}
get geometry() { }
get rotation() { }
}
================================================
FILE: js/notes/note-shadow-cube.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteShadow } from './note-shadow';
import { ShadowColor } from '../core/colors';
const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({
color: ShadowColor
});
export class NoteShadowCube extends NoteShadow {
update() {
super.update();
this.mesh.scale.setY( Number.EPSILON );
}
get geometry() {
var cubeGeometry = new THREE.BoxGeometry( 0.1, 0.1, 0.1 );
cubeGeometry.applyMatrix( this.headMesh.matrix );
cubeGeometry.scale( 0.55, 0.55, 0.55 );
return cubeGeometry;
}
get material() {
return SHADOW_MATERIAL;
}
}
================================================
FILE: js/notes/note-shadow-sphere.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteShadow } from './note-shadow';
const SHADOW_GEOMETRY = new THREE.PlaneGeometry( 0.1, 0.1 );
const SHADOW_SHADER = THREE.CircleShader;
const SHADOW_UNIFORMS = THREE.UniformsUtils.clone( SHADOW_SHADER.uniforms );
const SHADOW_MATERIAL = new THREE.ShaderMaterial({
uniforms: SHADOW_UNIFORMS,
vertexShader: SHADOW_SHADER.vertexShader,
fragmentShader: SHADOW_SHADER.fragmentShader
});
export class NoteShadowSphere extends NoteShadow {
constructor( headMesh ) {
super( headMesh );
this.mesh.rotation.x -= Math.PI / 2;
}
get geometry() {
return SHADOW_GEOMETRY;
}
get material() {
return SHADOW_MATERIAL;
}
}
================================================
FILE: js/notes/note-shadow-tetra.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteShadow } from './note-shadow';
import { ShadowColor } from '../core/colors';
import { Tetrahedron } from '../util/tetrahedron';
const TETRA_GEOMETRY = new Tetrahedron().geometry;
const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({
color: ShadowColor
});
export class NoteShadowTetra extends NoteShadow {
update() {
super.update();
this.mesh.scale.setY( Number.EPSILON );
}
get geometry() {
var tetraGeometry = TETRA_GEOMETRY.clone();
tetraGeometry.applyMatrix( this.headMesh.matrix );
return tetraGeometry;
}
get material() {
return SHADOW_MATERIAL;
}
}
================================================
FILE: js/notes/note-shadow.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const SHADOW_SIZE = 1.05;
const SHADOW_GROUND_CLEARANCE = 0.01;
export class NoteShadow {
constructor( headMesh, rotation ) {
this.headMesh = headMesh;
this.headMesh.updateMatrix();
this.headMesh.updateMatrixWorld( true );
this.rotation = rotation;
this.mesh = new THREE.Mesh( this.geometry, this.material );
}
update() {
var worldZeroVector = new THREE.Vector3( 0, 0, 0 );
this.mesh.parent.worldToLocal( worldZeroVector );
this.mesh.position.setY( worldZeroVector.y + SHADOW_GROUND_CLEARANCE );
this.mesh.scale.copy( this.headMesh.scale );
this.mesh.scale.multiplyScalar( SHADOW_SIZE );
}
get geometry() { }
get material() { }
}
================================================
FILE: js/notes/note-stem.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const STEM_GEOMETRY = new THREE.CylinderGeometry( 0.005, 0.01, 1, 5 );
export class NoteStem {
constructor( palette ) {
this.palette = palette;
this.material = new THREE.MeshBasicMaterial();
var stemMesh = new THREE.Mesh( STEM_GEOMETRY, this.material );
stemMesh.position.y = -0.5;
this.mesh = new THREE.Group();
this.mesh.add( stemMesh );
}
setTone( value ) {
this.tone = value;
this.material.color = this.palette.colorPalette()[ this.tone ].idle;
this.material.update();
}
update() {
var worldZeroVector = new THREE.Vector3( 0, 0, 0 );
this.mesh.parent.worldToLocal( worldZeroVector );
this.mesh.position.setY( worldZeroVector.y );
this.mesh.scale.setY( worldZeroVector.y );
}
hit() {
}
}
================================================
FILE: js/notes/note.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NoteHeadSphere } from './note-head-sphere';
import { NoteHeadTetra } from './note-head-tetra';
import { NoteHeadCube } from './note-head-cube';
import { NoteShadowSphere } from './note-shadow-sphere';
import { NoteShadowTetra } from './note-shadow-tetra';
import { NoteShadowCube } from './note-shadow-cube';
const HIT_ANIM_MS = 750;
const SHAPE_NAMES = [ 'sphere', 'cube', 'tetra' ];
const HEAD_CONSTRUCTORS = {
sphere: NoteHeadSphere,
tetra: NoteHeadTetra,
cube: NoteHeadCube
};
const SHADOW_CONSTRUCTORS = {
sphere: NoteShadowSphere,
tetra: NoteShadowTetra,
cube: NoteShadowCube
};
export class Note {
constructor( palette ) {
this.group = new THREE.Group();
this.palette = palette;
}
setTone( value ) {
this.tone = value;
// Remove old note objects
if ( this.head ) this.group.remove( this.head.mesh );
if ( this.shadow ) this.group.remove( this.shadow.mesh );
// Create new head object
this.head = new ( HEAD_CONSTRUCTORS[ this.shape ] )( this.palette, this.shape );
this.head.name = "head";
this.head.lightPosition = this.lightPosition;
this.head.setTone( this.tone );
// Store first random rotation value
if ( !this.rotation ) {
this.rotation = new THREE.Euler();
this.rotation.copy( this.head.rotation );
}
// Reset head mesh rotation
this.head.mesh.rotation.copy( this.rotation );
// Create new shadow object
this.shadow = new ( SHADOW_CONSTRUCTORS[ this.shape ] )( this.head.mesh, this.rotation );
// Add 'em up
this.group.add( this.head.mesh );
this.group.add( this.shadow.mesh );
}
setRotationIncrement(isClockwise=false) {
let rotationAxis = new THREE.Vector3(0,1,0);
let rotationAmount = Math.PI * 2 * (isClockwise ? 0.03 : -0.03);
let deltaMatrix = new THREE.Matrix4();
deltaMatrix.makeRotationAxis(rotationAxis, rotationAmount );
let rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(this.rotation);
rotationMatrix.multiplyMatrices(deltaMatrix,rotationMatrix);
deltaMatrix.extractRotation(rotationMatrix);
this.rotation.setFromRotationMatrix(deltaMatrix);
}
removeShadow(){
if ( this.shadow ) {
this.group.remove( this.shadow.mesh );
delete this.shadow;
this.shadow = null;
}
}
tick() {
if(this.shadow) {
this.shadow.update();
}
}
highlight( event ) {
this.head.highlight( event );
}
unHighlight( event ) {
this.head.unHighlight( event );
}
hit( event ) {
this.head.hit( event, HIT_ANIM_MS );
}
get shape() {
return SHAPE_NAMES[ Math.floor( this.tone / this.palette.noteCount() ) ];
}
}
================================================
FILE: js/orientation-arm-model.js
================================================
/*
* Copyright 2016 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const HEAD_ELBOW_OFFSET = new THREE.Vector3(0.155, -0.465, -0.15);
const ELBOW_WRIST_OFFSET = new THREE.Vector3(0, 0, -0.25);
const WRIST_CONTROLLER_OFFSET = new THREE.Vector3(0, 0, 0.05);
const ARM_EXTENSION_OFFSET = new THREE.Vector3(-0.08, 0.14, 0.08);
const ELBOW_BEND_RATIO = 0.4; // 40% elbow, 60% wrist.
const EXTENSION_RATIO_WEIGHT = 0.4;
const MIN_ANGULAR_SPEED = 0.61; // 35 degrees per second (in radians).
/**
* Represents the arm model for the Daydream controller. Feed it a camera and
* the controller. Update it on a RAF.
*
* Get the model's pose using getPose().
*/
export default class OrientationArmModel {
constructor() {
this.isLeftHanded = false;
// Current and previous controller orientations.
this.controllerQ = new THREE.Quaternion();
this.lastControllerQ = new THREE.Quaternion();
// Current and previous head orientations.
this.headQ = new THREE.Quaternion();
// Current head position.
this.headPos = new THREE.Vector3();
// Positions of other joints (mostly for debugging).
this.elbowPos = new THREE.Vector3();
this.wristPos = new THREE.Vector3();
// Current and previous times the model was updated.
this.time = null;
this.lastTime = null;
// Root rotation.
this.rootQ = new THREE.Quaternion();
// Current pose that this arm model calculates.
this.pose = {
orientation: new THREE.Quaternion(),
position: new THREE.Vector3()
};
}
/**
* Methods to set controller and head pose (in world coordinates).
*/
setControllerOrientation(quaternion) {
this.lastControllerQ.copy(this.controllerQ);
this.controllerQ.copy(quaternion);
}
setHeadOrientation(quaternion) {
this.headQ.copy(quaternion);
}
setHeadPosition(position) {
this.headPos.copy(position);
}
setLeftHanded(isLeftHanded) {
// TODO(smus): Implement me!
this.isLeftHanded = isLeftHanded;
}
/**
* Called on a RAF.
*/
update() {
this.time = performance.now();
// If the controller's angular velocity is above a certain amount, we can
// assume torso rotation and move the elbow joint relative to the
// camera orientation.
let headYawQ = this.getHeadYawOrientation_();
let timeDelta = (this.time - this.lastTime) / 1000;
let angleDelta = this.quatAngle_(this.lastControllerQ, this.controllerQ);
let controllerAngularSpeed = angleDelta / timeDelta;
if (controllerAngularSpeed > MIN_ANGULAR_SPEED) {
// Attenuate the Root rotation slightly.
this.rootQ.slerp(headYawQ, angleDelta / 10);
} else {
this.rootQ.copy(headYawQ);
}
// We want to move the elbow up and to the center as the user points the
// controller upwards, so that they can easily see the controller and its
// tool tips.
let controllerEuler = new THREE.Euler().setFromQuaternion(this.controllerQ, 'YXZ');
let controllerXDeg = THREE.Math.radToDeg(controllerEuler.x);
let extensionRatio = this.clamp_((controllerXDeg - 11) / (50 - 11), 0, 1);
// Controller orientation in camera space.
let controllerCameraQ = this.rootQ.clone().inverse();
controllerCameraQ.multiply(this.controllerQ);
// Calculate elbow position.
let elbowPos = this.elbowPos;
elbowPos.copy(this.headPos).add(HEAD_ELBOW_OFFSET);
let elbowOffset = new THREE.Vector3().copy(ARM_EXTENSION_OFFSET);
elbowOffset.multiplyScalar(extensionRatio);
elbowPos.add(elbowOffset);
// Calculate joint angles. Generally 40% of rotation applied to elbow, 60%
// to wrist, but if controller is raised higher, more rotation comes from
// the wrist.
let totalAngle = this.quatAngle_(controllerCameraQ, new THREE.Quaternion());
let totalAngleDeg = THREE.Math.radToDeg(totalAngle);
let lerpSuppression = 1 - Math.pow(totalAngleDeg / 180, 4); // TODO(smus): ???
let elbowRatio = ELBOW_BEND_RATIO;
let wristRatio = 1 - ELBOW_BEND_RATIO;
let lerpValue = lerpSuppression *
(elbowRatio + wristRatio * extensionRatio * EXTENSION_RATIO_WEIGHT);
let wristQ = new THREE.Quaternion().slerp(controllerCameraQ, lerpValue);
let invWristQ = wristQ.inverse();
let elbowQ = controllerCameraQ.clone().multiply(invWristQ);
// Calculate our final controller position based on all our joint rotations
// and lengths.
/*
position_ =
root_rot_ * (
controller_root_offset_ +
2: (arm_extension_ * amt_extension) +
1: elbow_rot * (kControllerForearm + (wrist_rot * kControllerPosition))
);
*/
let wristPos = this.wristPos;
wristPos.copy(WRIST_CONTROLLER_OFFSET);
wristPos.applyQuaternion(wristQ);
wristPos.add(ELBOW_WRIST_OFFSET);
wristPos.applyQuaternion(elbowQ);
wristPos.add(this.elbowPos);
let offset = new THREE.Vector3().copy(ARM_EXTENSION_OFFSET);
offset.multiplyScalar(extensionRatio);
let position = new THREE.Vector3().copy(this.wristPos);
position.add(offset);
position.applyQuaternion(this.rootQ);
let orientation = new THREE.Quaternion().copy(this.controllerQ);
// Set the resulting pose orientation and position.
this.pose.orientation.copy(orientation);
this.pose.position.copy(position);
this.lastTime = this.time;
}
/**
* Returns the pose calculated by the model.
*/
getPose() {
return this.pose;
}
/**
* Debug methods for rendering the arm model.
*/
getForearmLength() {
return ELBOW_WRIST_OFFSET.length();
}
getElbowPosition() {
let out = this.elbowPos.clone();
return out.applyQuaternion(this.rootQ);
}
getWristPosition() {
let out = this.wristPos.clone();
return out.applyQuaternion(this.rootQ);
}
getHeadYawOrientation_() {
let headEuler = new THREE.Euler().setFromQuaternion(this.headQ, 'YXZ');
headEuler.x = 0;
headEuler.z = 0;
let destinationQ = new THREE.Quaternion().setFromEuler(headEuler);
return destinationQ;
}
clamp_(value, min, max) {
return Math.min(Math.max(value, min), max);
}
quatAngle_(q1, q2) {
let vec1 = new THREE.Vector3(0, 0, -1);
let vec2 = new THREE.Vector3(0, 0, -1);
vec1.applyQuaternion(q1);
vec2.applyQuaternion(q2);
return vec1.angleTo(vec2);
}
}
================================================
FILE: js/shaders/ball-shader.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CommonUniforms, ToonVertex, ToonFragUniforms, ToonFragCommon, ToonFragLighting } from './shader-chunks';
THREE.BallShader = {
uniforms: THREE.UniformsUtils.merge([ CommonUniforms,
{
map134: { type: 't' },
map567: { type: 't' },
use567: { value: 0.0 },
spriteLayout: { value: new THREE.Vector2( 8, 4 ) },
spriteRepeat: { value: new THREE.Vector2( 5, 1 ) },
spriteChannel: { value: new THREE.Vector3( 1, 0, 0 ) },
spriteIndex: { value: 0 },
bgColor: { value: new THREE.Color( 'rgb( 53, 101, 190 )' ) },
idleColor: { value: new THREE.Color( 'rgb( 0, 55, 182 )' ) },
activeColor: { value: new THREE.Color( 'rgb( 0, 130, 237 )' ) },
activeAmount: { value: 0 },
brightnessAmount: { value: 0.0 }
}
]),
vertexShader: ToonVertex,
fragmentShader: [
ToonFragUniforms,
'uniform sampler2D map134;',
'uniform sampler2D map567;',
'uniform float use567;',
'uniform vec2 spriteLayout;',
'uniform vec2 spriteRepeat;',
'uniform vec3 spriteChannel;',
'uniform float spriteIndex;',
'uniform vec3 bgColor;',
'uniform vec3 idleColor;',
'uniform vec3 activeColor;',
'uniform float activeAmount;',
'uniform float brightnessAmount;',
'void main() {',
ToonFragLighting,
'vec2 uvSpriteOffset = vec2( 0.0 );',
'uvSpriteOffset.x = floor( mod( spriteIndex, spriteLayout.x ) );',
'uvSpriteOffset.y = -floor( spriteIndex / spriteLayout.x ) - 1.0;',
'uvSpriteOffset /= spriteLayout;',
'vec2 uvSize = 1.0 / spriteLayout;',
'vec2 vUvSprite = vUv;',
'vUvSprite.y = vUvSprite.y + spriteLayout.y - 1.0;',
'vUvSprite = mod( vUvSprite * uvSize * spriteRepeat, uvSize ) + uvSpriteOffset;',
'vec3 tex134 = texture2D( map134, vUvSprite ).rgb;',
'vec3 tex567 = texture2D( map567, vUvSprite ).rgb;',
'vec3 a = mix( tex134, tex567, use567 );',
'vec3 color = mix( idleColor, activeColor, activeAmount );',
'diffuseColor = vec4( mix( bgColor, color, length( a * spriteChannel ) ) + brightnessAmount, 1.0 );',
ToonFragCommon,
'}'
].join( '\n' )
};
================================================
FILE: js/shaders/bg-tree-shader.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { BgTreeColors, ShadowColor } from '../core/colors';
THREE.BGTreeShader = {
uniforms: {
map: { type: 't' },
fogColor: { type: 'c' },
fogDensity: { value: 0 },
color: { type: 'c', value: new THREE.Color( BgTreeColors[ 0 ] ) },
shadowColor: { type: 'c', value: new THREE.Color( ShadowColor ) }
},
vertexShader: [
'varying vec2 vUV;',
'varying float fogDepth;',
'void main() {',
'vUV = uv;',
'vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );',
'fogDepth = -mvPosition.z;',
'gl_Position = projectionMatrix * mvPosition;',
'}'
].join( '\n' ),
fragmentShader: [
'#define LOG2 1.442695',
'uniform vec3 color;',
'uniform vec3 shadowColor;',
'uniform sampler2D map;',
'uniform float fogDensity;',
'uniform vec3 fogColor;',
'varying float fogDepth;',
'varying vec2 vUV;',
'void main() {',
'vec3 c = vUV.y > 0.5 ? color : shadowColor;',
'vec3 t = texture2D( map, vUV ).rgb;',
'if ( t.r < 0.5 ) discard;',
'float fogFactor = 1.0 - saturate( ( exp2( -fogDensity * fogDensity * fogDepth * fogDepth * LOG2 ) ) );',
'gl_FragColor = vec4( mix( t * c, fogColor, fogFactor ), 1.0 );',
'}'
].join( '\n' )
};
================================================
FILE: js/shaders/circle-shader.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ShadowColor } from '../core/colors';
import { BasicVertex } from './shader-chunks';
THREE.CircleShader = {
uniforms: {
radius: { type: 'f', value: 0.5 },
color: { type: 'c', value: new THREE.Color( ShadowColor ) }
},
vertexShader: BasicVertex,
fragmentShader: [
'uniform float radius;',
'uniform vec3 color;',
'varying vec2 vUV;',
'void main() {',
'if ( length( vUV - 0.5 ) > radius ) discard;',
'gl_FragColor = vec4( color, 1.0 );',
'}'
].join( '\n' )
};
================================================
FILE: js/shaders/shader-chunks.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const CommonUniforms = {
lightPosition: { value: new THREE.Vector3( 3, 10, 1 ) },
lightIntensity: { value: 1.15 },
shadeAmount: { value: 0.55 }
};
const BasicVertex = [
'varying vec2 vUV;',
'void main() {',
'vUV = uv;',
'gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );',
'}'
].join( '\n' );
const ToonVertex = [
'varying vec4 vWorldPosition;',
'varying vec3 vNormal;',
'varying vec2 vUv;',
'void main() {',
'vUv = uv;',
// World-space normal
'vNormal = normalize ( mat3( modelMatrix[0].xyz, modelMatrix[1].xyz, modelMatrix[2].xyz ) * normal );',
'vec3 transformed = vec3( position );',
'vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );',
'gl_Position = projectionMatrix * mvPosition;',
'vWorldPosition = modelMatrix * vec4( position, 1.0 );',
'}'
].join( '\n' );
const ToonFragUniforms = [
'uniform float lightIntensity;',
'uniform float shadeAmount;',
'uniform vec3 lightPosition;',
'uniform sampler2D gradientMap;',
'varying vec4 vWorldPosition;',
'varying vec3 vNormal;',
'varying vec2 vUv;',
].join( '\n' );
const ToonFragLighting = [
'#ifdef FLAT_SHADED',
// TODO: do this in the vertex shader for speeeeeed
'vec3 fdx = vec3( dFdx( vWorldPosition.x ), dFdx( vWorldPosition.y ), dFdx( vWorldPosition.z ) );',
'vec3 fdy = vec3( dFdy( vWorldPosition.x ), dFdy( vWorldPosition.y ), dFdy( vWorldPosition.z ) );',
'vec3 normal = normalize( cross( fdx, fdy ) );',
'#else',
'vec3 normal = normalize( vNormal );',
'#endif',
// The usual Lambertian stuff
'vec3 lightDirection = normalize( lightPosition - vWorldPosition.xyz );',
'float dotNL = max( dot( normal, lightDirection ), 0.0 ) * 0.5 + 0.5;',
// Clamp for a shaded toon look
'float toonIrradience = ( dotNL < shadeAmount ) ? 0.7 * lightIntensity : 1.0;',
'vec4 diffuseColor = vec4( 0.0 );',
].join( '\n' );
const ToonFragCommon = [
'gl_FragColor = vec4( diffuseColor.rgb * toonIrradience, diffuseColor.a );',
].join( '\n' );
export { CommonUniforms, BasicVertex, ToonVertex, ToonFragUniforms, ToonFragCommon, ToonFragLighting };
================================================
FILE: js/splash.js
================================================
// Copyright 2017 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the 'License');
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an 'AS IS' BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import TWEEN from 'tween.js';
import StartAudioContext from 'startaudiocontext';
import Tone from 'tone';
import { getParameterByName, getViewerType } from './util';
const getComponentProperty = AFRAME.utils.entity.getComponentProperty;
const setComponentProperty = AFRAME.utils.entity.setComponentProperty;
const stringify = AFRAME.utils.coordinates.stringify;
AFRAME.registerComponent('splash', {
flyTweenPosition:undefined,
flyTweenRotation:undefined,
isFirstTime:true,
viewer : false,
mode:undefined,
camPos:{ x:0, y:0, z:0 },
init(){
if(WebVRConfig){
WebVRConfig.CARDBOARD_UI_DISABLED = false;
WebVRConfig.ENABLE_DEPRECATED_API = true;
WebVRConfig.ROTATE_INSTRUCTIONS_DISABLED = false;
}
},
play(){
this._splashEl = document.querySelector('#splash');
this._splashEl.querySelector('#enterButton').appendChild(document.querySelector('.webvr-ui-button'));
// listen for enter vr
this.el.addEventListener('enter-vr', () => this._entered('vr'));
this.el.addEventListener('exit-vr', () => this._exited());
// start audio context
StartAudioContext(Tone.context, ['.webvr-ui-button', '#enter-360']);
// Connect to server on loaded
if( getParameterByName('autojoin') ){
setTimeout(() => this._entered('360'), 1000);
}
// add's editing capbaility on desktop
if( getParameterByName('reticle')==='true' ){
let avatar = document.querySelector('#avatar');
setComponentProperty(avatar,'grab-move', '');
}
let displayId;
//send analytics events
let enterVRButton = this.el.components['webvr-ui'].enterVR;
enterVRButton.on('error', (e)=>{
ga('send', 'event', 'device', 'error', e);
}).on('ready', ()=>{
enterVRButton.getVRDisplay().then((disp)=>{
displayId = disp.displayId;
ga('send', 'event', 'device', 'ready', disp.displayName);
});
});
window.addEventListener('gamepadconnected', (e)=>{
let gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; ++i) {
if(gamepads[i]){
// if(gamepads[i] && gamepads[i].displayId == displayId){
ga('send', 'event', 'gamepad', 'ready', gamepads[i].id);
}
}
});
this._progressBar();
},
_progressBar(){
const promises = [];
let asset;
for(let i=0; i {
asset.addEventListener('loaded', done);
}));
}
}
promises.push(new Promise(done => {
Tone.Buffer.on('load', done);
}));
this._allLoaded();
},
_allLoaded(){
// let avatar = document.querySelector('#avatar');
let camera = document.querySelector('a-entity[camera]');
let concater = function(obj) {
Object.keys(obj).forEach( key => {
obj[key] = ((obj[key]*100) | 0) / 100;
});
return JSON.stringify(obj);
};
/*
* HACK: position is stored to counteract height issues
* associated with Oculus's relative position data
* using componentchanged instead of EXIT VR
*/
camera.addEventListener('componentchanged', (event) => {
if ( event.detail.name === 'position' || event.detail.name === 'rotation' ) {
let pos = getComponentProperty(camera,'position');
if (pos.y !== 1.6 && pos.y !== 0){
this.camPos.x = pos.x;
this.camPos.y = pos.y;
this.camPos.z = pos.z;
}
}
});
this._splashEl.querySelector('#enter-container').classList.add('loaded');
this._splashEl.querySelector('#enter-360').addEventListener('click', () => this._entered('360'));
let treeCreatedEvent = (event) => {
document.removeEventListener('TREE_CREATED', treeCreatedEvent);
this._animateToTree();
};
document.addEventListener('TREE_CREATED', treeCreatedEvent);
},
_animateToTree(){
const player = document.querySelector('#player');
const avatar = document.querySelector('#avatar');
const camera = document.querySelector('a-entity[camera]');
const delay = 500;
const speed = 1000;
let position = getComponentProperty(player,'position');
let rotation = getComponentProperty(player,'rotation');
let isTabletWindow = this.isTabletLikeDimensions() && this.isTouchDevice() && this.mode === '360';
let isTabletVR = this.isTabletLikeDimensions() && this.isTouchDevice() && this.mode === 'vr';
let isDesktop = this.mode === '360' && !AFRAME.utils.device.isMobile();
let y = (isDesktop) ? 1.6: 0;
y = (isTabletWindow) ? 0: y;
const z = this.viewer ? 1.5 : 0;
this.flyTweenPosition = new TWEEN.Tween(position)
.to({ x:0, y:y, z:z }, speed)
.delay(delay)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => player.setAttribute('position', position));
this.flyTweenRotation = new TWEEN.Tween(rotation)
.to({ x:0, y: 0, z: 0 }, speed)
.delay(delay)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => player.setAttribute('rotation', rotation))
.onComplete(() => {
this.isFirstTime = false;
document.dispatchEvent(new Event('INTRO_COMPLETED'));
});
if (this.flyTweenPosition) {
this.flyTweenPosition.stop();
}
if (this.flyTweenRotation) {
this.flyTweenRotation.stop();
}
this.flyTweenPosition.start();
this.flyTweenRotation.start();
},
_exited(){
this._splashEl.classList.remove('invisible');
document.dispatchEvent(new Event('EXITED_FOREST'));
this.removeCardBoardInstructions();
this.mode = undefined;
},
_entered(mode){
this.mode = mode;
this.addCardBoardInstructions();
if(this.mode === '360') {
this.viewer = true;
ga('send', 'event', 'clickable_link', 'splash_page', 'enter_360');
} else {
ga('send', 'event', 'clickable_link', 'splash_page', 'enter_VR');
}
let player = document.querySelector('#player');
let avatar = document.querySelector('#avatar');
let camera = document.querySelector('a-entity[camera]');
if(!this.isFirstTime && !AFRAME.utils.device.isMobile()){
setComponentProperty(player,'position', '0 0 0');
setComponentProperty(avatar,'position', this.camPos);
setComponentProperty(camera,'position', this.camPos);
} else if (!this.isFirstTime){
setComponentProperty(player,'position', '0 0 0');
setComponentProperty(avatar,'position', '0 1.6 0');
setComponentProperty(camera,'position', '0 1.6 0');
}
this.el.removeEventListener('enter-vr', () => this._entered());
this._splashEl.querySelector('#enter-360').removeEventListener('click', () => this._entered());
this._splashEl.classList.add('invisible');
this.el.sceneEl.systems['copresence-server'].connectToServer();
// add ux based on device
getViewerType((clientType) => {
this._setSceneParameters({
clientType: clientType,
mode: this.mode,
isMobile: AFRAME.utils.device.isMobile(),
});
document.dispatchEvent(new Event('ENTERED_FOREST'));
});
},
_setSceneParameters(params){
let avatar = document.querySelector('#avatar');
let player = document.querySelector('#player');
let camera = document.querySelector('a-entity[camera]');
let floor = document.querySelector('#gaze-floor');
let floorStatic = document.querySelector('#floorStatic');
let skyStatic = document.querySelector('#skyStatic');
let isTabletWindow = this.isTabletLikeDimensions() && this.isTouchDevice() && this.mode === '360';
let isTabletVR = this.isTabletLikeDimensions() && this.isTouchDevice() && this.mode === 'vr';
let is6DOF = params.clientType === '6dof';
let isDesktop = params.clientType === 'viewer' && !params.isMobile;
let is3DOF = params.clientType === '3dof';
let isMagicWindow = params.isMobile && this.mode === '360';
let isCardBoard = params.isMobile && this.mode === 'vr';
if(isTabletVR){
setComponentProperty(avatar, 'position', '0 1.6 0');
setComponentProperty(floor,'teleport', '');
setComponentProperty(avatar,'look-controls', '');
setComponentProperty(avatar,'clicker', '');
} else if(isTabletWindow){
setComponentProperty(avatar, 'position', '0 1.6 0');
setComponentProperty(avatar, 'rotation', '-35 0 0');
setComponentProperty(floor,'teleport', '');
setComponentProperty(avatar,'look-controls', '');
setComponentProperty(avatar,'clicker', '');
} else if(is6DOF){
setComponentProperty(avatar,'look-controls', '');
setComponentProperty(avatar,'clicker', '');
setComponentProperty(floorStatic,'material', 'shader: flat; color: #e9a69a;');
} else if (isDesktop){
setComponentProperty(avatar, 'camera', 'userHeight:0.0');
setComponentProperty(player,'look-controls', '');
setComponentProperty(player,'wasd-controls', 'acceleration:35; easing:25;');
setComponentProperty(player,'wasd-boundaries', 'maxRadius:3.125;');
setComponentProperty(avatar,'clicker', '');
setComponentProperty(floorStatic,'material', 'shader: flat; color: #e9a69a;');
} else if (is3DOF) {
setComponentProperty(floor,'teleport', '');
setComponentProperty(avatar,'look-controls', '');
setComponentProperty(avatar,'clicker', '');
} else if (isMagicWindow){
this.removeCardBoardInstructions();
setComponentProperty(floor, 'teleport', '');
setComponentProperty(avatar,'look-controls', '');
setComponentProperty(avatar,'clicker', '');
} else if (isCardBoard){
setComponentProperty(floor, 'teleport', '');
setComponentProperty(avatar,'look-controls', '');
} else {
}
},
removeCardBoardInstructions(){
let cardboard = document.querySelector("#cardboard");
if(cardboard) {
cardboard.remove();
}
},
addCardBoardInstructions(){
if(AFRAME.utils.device.isMobile() && this.mode === 'vr'){
let cardboard = document.createElement('div');
cardboard.id = 'cardboard';
document.body.appendChild(cardboard);
// createElement
let container = document.createElement('div');
container.id = 'cardboardContainer';
cardboard.appendChild(container);
let img = document.createElement("img");
img.id = "img";
img.src = `/static/img/cardboardInstructions.gif`;
container.appendChild(img);
let p = document.createElement("p");
p.innerHTML = 'Place your phone into your default viewer