Repository: wowserhq/wowser Branch: master Commit: 5fcd3e607db7 Files: 126 Total size: 207.4 KB Directory structure: gitextract_5ysnh6kx/ ├── .babelrc ├── .codeclimate.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .istanbul.yml ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin/ │ └── serve ├── gulpfile.babel.js ├── package.json ├── src/ │ ├── bootstrapper.jsx │ ├── components/ │ │ ├── auth/ │ │ │ ├── index.jsx │ │ │ └── index.styl │ │ ├── characters/ │ │ │ └── index.jsx │ │ ├── game/ │ │ │ ├── chat/ │ │ │ │ ├── index.jsx │ │ │ │ └── index.styl │ │ │ ├── controls.jsx │ │ │ ├── hud/ │ │ │ │ ├── index.jsx │ │ │ │ └── index.styl │ │ │ ├── index.jsx │ │ │ ├── index.styl │ │ │ ├── portrait/ │ │ │ │ ├── index.jsx │ │ │ │ └── index.styl │ │ │ ├── quests/ │ │ │ │ ├── index.jsx │ │ │ │ └── index.styl │ │ │ └── stats/ │ │ │ ├── index.jsx │ │ │ └── index.styl │ │ ├── kit/ │ │ │ └── index.jsx │ │ ├── realms/ │ │ │ └── index.jsx │ │ └── wowser/ │ │ ├── index.jsx │ │ ├── index.styl │ │ ├── session.jsx │ │ └── ui/ │ │ ├── form/ │ │ │ └── index.styl │ │ ├── frame/ │ │ │ ├── dividers/ │ │ │ │ └── index.styl │ │ │ └── index.styl │ │ ├── index.styl │ │ ├── screen.styl │ │ ├── type.styl │ │ └── widgets/ │ │ ├── button.styl │ │ └── index.styl │ ├── index.html │ ├── lib/ │ │ ├── auth/ │ │ │ ├── challenge-opcode.js │ │ │ ├── handler.js │ │ │ ├── opcode.js │ │ │ └── packet.js │ │ ├── characters/ │ │ │ ├── character.js │ │ │ └── handler.js │ │ ├── config.js │ │ ├── crypto/ │ │ │ ├── big-num.js │ │ │ ├── crypt.js │ │ │ ├── hash/ │ │ │ │ └── sha1.js │ │ │ ├── hash.js │ │ │ └── srp.js │ │ ├── game/ │ │ │ ├── chat/ │ │ │ │ ├── handler.js │ │ │ │ └── message.js │ │ │ ├── entity.js │ │ │ ├── guid.js │ │ │ ├── handler.js │ │ │ ├── opcode.js │ │ │ ├── packet.js │ │ │ ├── player.js │ │ │ ├── unit.js │ │ │ └── world/ │ │ │ ├── content-queue.js │ │ │ ├── doodad-manager.js │ │ │ ├── handler.js │ │ │ ├── map.js │ │ │ ├── terrain-manager.js │ │ │ └── wmo-manager/ │ │ │ ├── index.js │ │ │ └── wmo-handler.js │ │ ├── index.js │ │ ├── net/ │ │ │ ├── loader.js │ │ │ ├── packet.js │ │ │ └── socket.js │ │ ├── pipeline/ │ │ │ ├── adt/ │ │ │ │ ├── chunk/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── material.js │ │ │ │ │ ├── shader.frag │ │ │ │ │ └── shader.vert │ │ │ │ ├── index.js │ │ │ │ └── loader.js │ │ │ ├── dbc/ │ │ │ │ ├── index.js │ │ │ │ └── loader.js │ │ │ ├── m2/ │ │ │ │ ├── animation-manager.js │ │ │ │ ├── batch-manager.js │ │ │ │ ├── blueprint.js │ │ │ │ ├── index.js │ │ │ │ ├── loader.js │ │ │ │ ├── material/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── shader.frag │ │ │ │ │ └── shader.vert │ │ │ │ └── submesh.js │ │ │ ├── material.js │ │ │ ├── texture-loader.js │ │ │ ├── wdt/ │ │ │ │ ├── index.js │ │ │ │ └── loader.js │ │ │ ├── wmo/ │ │ │ │ ├── blueprint.js │ │ │ │ ├── group/ │ │ │ │ │ ├── blueprint.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── loader.js │ │ │ │ ├── index.js │ │ │ │ ├── loader.js │ │ │ │ └── material/ │ │ │ │ ├── index.js │ │ │ │ ├── shader.frag │ │ │ │ └── shader.vert │ │ │ └── worker/ │ │ │ ├── index.js │ │ │ ├── pool.js │ │ │ ├── task.js │ │ │ └── thread.js │ │ ├── realms/ │ │ │ ├── handler.js │ │ │ └── realm.js │ │ ├── server/ │ │ │ ├── .babelrc │ │ │ ├── cluster.js │ │ │ ├── config/ │ │ │ │ ├── index.js │ │ │ │ └── setup-prompts.js │ │ │ ├── index.js │ │ │ └── pipeline/ │ │ │ ├── archive.js │ │ │ └── index.js │ │ └── utils/ │ │ ├── array-util.js │ │ └── object-util.js │ └── spec/ │ ├── .eslintrc │ ├── sample-spec.js │ └── spec-helper.js └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "plugins": [ "transform-class-properties", "transform-export-extensions", "transform-function-bind", "transform-es2015-block-scoping", "add-module-exports", "transform-es2015-modules-commonjs" ], "presets": [ "react" ] } ================================================ FILE: .codeclimate.yml ================================================ languages: JavaScript: true exclude_paths: - 'lib/*' ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .eslintrc ================================================ { "extends": "timkurvers/react", "rules": { // Allow vertically aligning values "no-multi-spaces": [0], // See: https://github.com/yannickcr/eslint-plugin-react/issues/128 "react/sort-comp": [0], // Disable newer additions originating from Airbnb "arrow-body-style": [0], "prefer-arrow-callback": [0], "space-before-function-paren": [0], "react/jsx-indent-props": [0], "react/jsx-closing-bracket-location": [0] } } ================================================ FILE: .gitignore ================================================ /coverage/ /lib/ /node_modules/ /public/scripts/ /public/styles/ /public/templates/ /spec/ ================================================ FILE: .istanbul.yml ================================================ instrumentation: excludes: ['public/**', 'src/**', 'bundle.js', 'gulpfile.babel.js'] include-all-sources: true ================================================ FILE: .travis.yml ================================================ sudo: false language: node_js node_js: - '4' - '5' - '6' matrix: fast_finish: true addons: apt: sources: - ubuntu-toolchain-r-test packages: - g++-4.8 before_script: - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - chmod +x ./cc-test-reporter - ./cc-test-reporter before-build env: - CXX=g++-4.8 script: npm test --coverage after_script: - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT ================================================ FILE: AUTHORS ================================================ fallenoak (https://github.com/fallenoak) timkurvers (https://github.com/timkurvers) ================================================ FILE: CHANGELOG.md ================================================ # Changelog ### v0.0.2 - March 20, 2020 - Ensure package on `npm` contains current project status. ### v0.0.1 - November 13, 2014 - Initial release. ### v0.0.0 - November 1, 2014 - Placeholder release. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2012-2018 Wowser Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Wowser [![Version](https://img.shields.io/npm/v/wowser.svg?style=flat)](https://www.npmjs.org/package/wowser) [![Join Community](https://img.shields.io/badge/discord-join_community-blue.svg?style=flat)](https://discord.gg/DeVVKVg) [![Build Status](https://img.shields.io/travis/wowserhq/wowser.svg?style=flat)](https://travis-ci.org/wowserhq/wowser) [![Known Vulnerabilities](https://snyk.io/test/github/wowserhq/wowser/badge.svg)](https://snyk.io/test/github/wowserhq/wowser) [![Maintainability](https://api.codeclimate.com/v1/badges/863393c7addcb1cd7be7/maintainability)](https://codeclimate.com/github/wowserhq/wowser/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/863393c7addcb1cd7be7/test_coverage)](https://codeclimate.com/github/wowserhq/wowser/test_coverage) World of Warcraft in the browser using JavaScript and WebGL. Licensed under the [**MIT** license](LICENSE). [![See Wowser tech demo](https://user-images.githubusercontent.com/378235/27762818-800fd91c-5e79-11e7-8301-733d736dd065.jpg)](https://www.youtube.com/watch?v=BrnbANSwC4I) ## Status Wowser is in the process of being split up into (at minimum) the following parts: - [Client](https://github.com/wowserhq/client/) (user interface loaded from XML/LUA) - [Pipeline](https://github.com/wowserhq/pipeline) server (serves up resources from the official client) This repository will in the future become an umbrella package. ## Background Wowser is a proof-of-concept of getting a triple-A game to run in a webbrowser, attempting to tackle a wide variety of challenges: data retrieval, socket connections, cryptography, 3d graphics, binary data handling, background workers and audio, to name a few. ## Features Wowser is aiming to be both a low-level API as well as a graphical client, interacting with a World of Warcraft server like an official client would. **Note:** Only Wrath of the Lich King (3.3.5a) is currently supported. A copy of the official client is required. **Warning:** Do not attempt to use this client on official/retail servers as your account may get banned. At present, Wowser is capable of: - Authenticating by username / password. - Listing available realms. - Connecting to a realm. - Listing characters available on a realm. - Joining the game world with a character. - Logging game world packets, such as when a creature moves in the vicinity. In addition, there's good progress on getting terrain and models rendered. ## Browser Support Wowser is presumed to be working on any browser supporting [JavaScript's typed arrays] and at the very least a binary version of the WebSocket protocol. ## Development Wowser is written in [ES2015], developed with [webpack] and [Gulp], compiled by [Babel] and [soon™] to be tested through [Mocha]. 1. Clone the repository: ```shell git clone git://github.com/wowserhq/wowser.git ``` 2. Download and install [Node.js] – including `npm` – for your platform. 3. Install dependencies: ```shell npm install ``` 4. Install [StormLib] and [BLPConverter], which are used to handle Blizzard's game files. ### Client [Webpack]'s development server monitors source files and builds: ```shell npm run web-dev ``` Wowser will be served on `http://localhost:8080`. ### Pipeline server To deliver game resources to its client, Wowser ships with a pipeline. Build the pipeline: ```shell npm run gulp ``` Keep this process running to monitor source files and automatically rebuild. After building, serve the pipeline as follows in a separate process: ```shell npm run serve ``` On first run you will be prompted to specify the following: - Path to client data folder (e.g. `C:/Program Files (x86)/World of Warcraft/Data`) - Server port (default is `3000`) - Number of cluster workers (default depends on amount of CPUs) Clear these settings by running `npm run reset` **Disclaimer:** Wowser serves up resources to the browser over HTTP. Depending on your network configuration these may be available to others. Respect laws and do not distribute game data you do not own. ### Socket proxies To utilize raw TCP connections a WebSocket proxy is required for JavaScript clients. [Websockify] can - among other things - act as a proxy for raw TCP sockets. For now, you will want to proxy both port 3724 (auth) and 8129 (world). Use a different set of ports if the game server is on the same machine as your client. ```shell npm run proxy 3724 host:3724 npm run proxy 8129 host:8129 ``` ## Contribution When contributing, please: - Fork the repository - Open a pull request (preferably on a separate branch) [Babel]: https://babeljs.io/ [BLPConverter]: https://github.com/wowserhq/blizzardry#blp [ES2015]: https://babeljs.io/docs/learn-es2015/ [Gulp]: http://gulpjs.com/ [JavaScript's typed arrays]: http://caniuse.com/#search=typed%20arrays [Mocha]: http://mochajs.org/ [Node.js]: http://nodejs.org/#download [StormLib]: https://github.com/wowserhq/blizzardry#mpq [Websockify]: https://github.com/kanaka/websockify/ [soon™]: http://www.wowwiki.com/Soon [webpack]: http://webpack.github.io/ ================================================ FILE: bin/serve ================================================ #!/usr/bin/env node const Cluster = require('../lib/server/cluster'); const ServerConfig = require('../lib/server/config'); ServerConfig.verify().then(function() { const cluster = new Cluster(); cluster.start(); }); ================================================ FILE: gulpfile.babel.js ================================================ import Config from 'configstore'; import babel from 'gulp-babel'; import cache from 'gulp-cached'; import del from 'del'; import gulp from 'gulp'; import mocha from 'gulp-mocha'; import pkg from './package.json'; import plumber from 'gulp-plumber'; const config = { db: new Config(pkg.name), scripts: 'src/**/*.js', specs: 'spec/**/*.js' }; gulp.task('reset', function(done) { config.db.clear(); process.stdout.write(`\n> Settings deleted from ${config.db.path}\n\n`); done(); }); gulp.task('clean', function(cb) { del([ 'lib/*', 'spec/*' ], cb); }); gulp.task('scripts', function() { return gulp.src(config.scripts) .pipe(cache('babel')) .pipe(plumber()) .pipe(babel()) .pipe(gulp.dest('.')); }); gulp.task('spec', function() { return gulp.src(config.specs, { read: false }) .pipe(plumber()) .pipe(mocha()); }); gulp.task('rebuild', gulp.series( 'clean', 'scripts' )); gulp.task('watch', function(done) { gulp.watch(config.scripts, gulp.series('scripts', 'spec')); done(); }); gulp.task('default', gulp.series( 'rebuild', 'spec', 'watch' )); ================================================ FILE: package.json ================================================ { "name": "wowser", "version": "0.0.2", "description": "World of Warcraft in the browser using JavaScript and WebGL", "author": "Wowser Contributors", "repository": "wowserhq/wowser", "license": "MIT", "main": "lib/client/index.js", "files": [ "AUTHORS", "CHANGELOG.md", "LICENSE", "README.md" ], "scripts": { "gulp": "gulp", "lint": "eslint src *.js --ext .js --ext .jsx; exit 0", "proxy": "websockify", "start": "node bin/serve", "serve": "node bin/serve", "serve-dev": "nodemon bin/serve -w lib/server", "pretest": "gulp rebuild", "reset": "gulp reset", "test": "istanbul test ./node_modules/mocha/bin/_mocha -- spec --recursive", "web-dev": "webpack-dev-server", "web-release": "webpack --optimize --progress" }, "keywords": [ "world of warcraft", "warcraft", "blizzard", "wow" ], "dependencies": { "array-find": "^0.1.1", "blizzardry": "^0.4.0", "bluebird": "^2.10.0", "byte-buffer": "^1.0.3", "classnames": "^2.2.0", "configstore": "^1.2.0", "deep-equal": "^1.0.0", "express": "^4.9.3", "globby": "^5.0.0", "inquirer": "^0.8.5", "jsbn": "timkurvers/jsbn.git#wowser", "keymaster": "^1.6.2", "morgan": "^1.3.2", "normalize.css": "^3.0.3", "pngjs": "^2.3.0", "react": "^0.14.3", "react-dom": "^0.14.3", "three": "^0.77.0", "websockify": "^0.7.1" }, "devDependencies": { "babel-core": "^6.10.0", "babel-eslint": "^6.1.0", "babel-loader": "^6.2.0", "babel-plugin-transform-class-properties": "^6.10.0", "babel-plugin-transform-export-extensions": "^6.8.0", "babel-plugin-transform-function-bind": "^6.8.0", "babel-plugin-transform-es2015-block-scoping": "^6.10.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.8.0", "babel-plugin-transform-es2015-parameters": "^6.9.0", "babel-plugin-add-module-exports": "^0.2.0", "babel-preset-react": "^6.5.0", "chai": "^3.5.0", "css-loader": "^0.23.0", "del": "^1.2.0", "eslint": "^2.13.0", "eslint-config-airbnb": "^6.2.0", "eslint-config-timkurvers": "^0.2.3", "eslint-loader": "^1.3.0", "eslint-plugin-react": "^4.3.0", "file-loader": "^0.9.0", "glslify-loader": "wowserhq/glslify-loader#query-opts", "glslify-import": "^3.0.0", "gulp": "gulpjs/gulp.git#4.0", "gulp-babel": "^6.1.0", "gulp-cached": "^1.1.0", "gulp-mocha": "2.2.0", "gulp-plumber": "^1.1.0", "gulp-remember": "^0.3.0", "gulp-stylus": "^2.5.0", "html-webpack-plugin": "^2.21.0", "istanbul": "^0.4.0", "json-loader": "^0.5.0", "mocha": "^2.5.0", "nodemon": "^1.9.0", "raw-loader": "^0.5.0", "sinon": "^1.17.0", "sinon-chai": "^2.8.0", "style-loader": "^0.13.0", "stylus-loader": "^1.6.0", "url-loader": "^0.5.0", "webpack": "^1.13.0", "webpack-dev-server": "^1.14.0", "worker-loader": "^0.7.0" } } ================================================ FILE: src/bootstrapper.jsx ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import Wowser from './components/wowser'; ReactDOM.render(, document.querySelector('app')); ================================================ FILE: src/components/auth/index.jsx ================================================ import React from 'react'; import session from '../wowser/session'; class AuthScreen extends React.Component { static id = 'auth'; static title = 'Authentication'; constructor() { super(); this.state = { host: window.location.hostname, port: session.auth.constructor.PORT, username: '', password: '' }; this._onAuthenticate = ::this._onAuthenticate; this._onChange = ::this._onChange; this._onSubmit = ::this._onSubmit; this._onConnect = ::this._onConnect; session.auth.on('connect', this._onConnect); session.auth.on('reject', session.auth.disconnect); session.auth.on('authenticate', this._onAuthenticate); } componentWillUnmount() { session.auth.removeListener('connect', this._onConnect); session.auth.removeListener('reject', session.auth.disconnect); session.auth.removeListener('authenticate', this._onAuthenticate); } connect(host, port) { session.auth.connect(host, port); } authenticate(username, password) { session.auth.authenticate(username, password); } _onAuthenticate() { session.screen = 'realms'; } _onChange(event) { this.setState({ [event.target.name]: event.target.value }); } _onConnect() { this.authenticate(this.state.username, this.state.password); } _onSubmit(event) { event.preventDefault(); this.connect(this.state.host, this.state.port); } render() { return (

Authentication

Note: Wowser requires a WebSocket proxy, see the README on GitHub.

); } } export default AuthScreen; ================================================ FILE: src/components/auth/index.styl ================================================ wowser .auth .panel max-width: 300px ================================================ FILE: src/components/characters/index.jsx ================================================ import React from 'react'; import session from '../wowser/session'; class CharactersScreen extends React.Component { static id = 'characters'; static title = 'Character Selection'; constructor() { super(); this.state = { character: null, characters: [] }; this._onCharacterSelect = ::this._onCharacterSelect; this._onJoin = ::this._onJoin; this._onRefresh = ::this._onRefresh; this._onSubmit = ::this._onSubmit; session.characters.on('refresh', this._onRefresh); session.game.on('join', this._onJoin); this.refresh(); } componentWillUnmount() { session.characters.removeListener('refresh', this._onRefresh); session.game.removeListener('join', this._onJoin); } join(character) { session.game.join(character); } refresh() { session.characters.refresh(); } _onCharacterSelect(event) { this.setState({ character: event.target.value }); } _onJoin() { session.screen = 'game'; } _onRefresh() { const characters = session.characters.list; this.setState({ character: characters[0], characters: characters }); } _onSubmit(event) { event.preventDefault(); this.join(this.state.character); } render() { return (

Character Selection

At some point this screen will allow managing characters. Soon™

); } } export default CharactersScreen; ================================================ FILE: src/components/game/chat/index.jsx ================================================ import React from 'react'; import classes from 'classnames'; import './index.styl'; import session from '../../wowser/session'; class ChatPanel extends React.Component { constructor() { super(); this.state = { text: '', messages: session.chat.messages }; this._onChange = ::this._onChange; this._onMessage = ::this._onMessage; this._onSubmit = ::this._onSubmit; session.chat.on('message', this._onMessage); } componentDidUpdate() { this.refs.messages.scrollTop = this.refs.messages.scrollHeight; } send(text) { const message = session.chat.create(); message.text = text; session.chat.messages.push(message); } _onChange(event) { this.setState({ text: event.target.value }); } _onMessage() { this.setState({ messages: session.chat.messages }); } _onSubmit(event) { event.preventDefault(); if (this.state.text) { this.send(this.state.text); this.setState({ text: '' }); } } render() { return (
); } } export default ChatPanel; ================================================ FILE: src/components/game/chat/index.styl ================================================ wowser .chat position: absolute bottom: 0 left: 0 width: 400px ul height: 182px padding: 0 margin: .4em list-style: none overflow: auto .message font-size: 14px &.system color: #FFCC00 &.info color: #26C9FF &.error color: #FF0000 &.channel color: #FFB872 &.whisper color: #FF72FF &.guild color: #2CB200 form margin: 2px 5px input width: 100% ================================================ FILE: src/components/game/controls.jsx ================================================ import React from 'react'; import THREE from 'three'; import key from 'keymaster'; class Controls extends React.Component { static propTypes = { camera: React.PropTypes.object.isRequired, for: React.PropTypes.object.isRequired }; constructor(props) { super(); this.element = document.body; this.unit = props.for; this.camera = props.camera; // Based on THREE's OrbitControls // See: http://threejs.org/examples/js/controls/OrbitControls.js this.clock = new THREE.Clock(); this.rotateStart = new THREE.Vector2(); this.rotateEnd = new THREE.Vector2(); this.rotateDelta = new THREE.Vector2(); this.rotating = false; this.rotateSpeed = 1.0; this.offset = new THREE.Vector3(-10, 0, 10); this.target = new THREE.Vector3(); this.phi = this.phiDelta = 0; this.theta = this.thetaDelta = 0; this.scale = 1; this.zoomSpeed = 1.0; this.zoomScale = Math.pow(0.95, this.zoomSpeed); // Zoom distance limits this.minDistance = 6; this.maxDistance = 500; // Vertical orbit limits this.minPhi = 0; this.maxPhi = Math.PI * 0.45; this.quat = new THREE.Quaternion().setFromUnitVectors( this.camera.up, new THREE.Vector3(0, 1, 0) ); this.quatInverse = this.quat.clone().inverse(); this.EPS = 0.000001; this._onMouseDown = ::this._onMouseDown; this._onMouseUp = ::this._onMouseUp; this._onMouseMove = ::this._onMouseMove; this._onMouseWheel = ::this._onMouseWheel; this.element.addEventListener('mousedown', this._onMouseDown); this.element.addEventListener('mouseup', this._onMouseUp); this.element.addEventListener('mousemove', this._onMouseMove); this.element.addEventListener('mousewheel', this._onMouseWheel); // Firefox scroll-wheel support this.element.addEventListener('DOMMouseScroll', this._onMouseWheel); this.update(); } componentWillUnmount() { this.element.removeEventListener('mousedown', this._onMouseDown); this.element.removeEventListener('mouseup', this._onMouseUp); this.element.removeEventListener('mousemove', this._onMouseMove); this.element.removeEventListener('mousewheel', this._onMouseWheel); this.element.removeEventListener('DOMMouseScroll', this._onMouseWheel); } update() { const unit = this.unit; // TODO: Get rid of this delta retrieval call const delta = this.clock.getDelta(); if (this.unit) { if (key.isPressed('up') || key.isPressed('w')) { unit.moveForward(delta); } if (key.isPressed('down') || key.isPressed('s')) { unit.moveBackward(delta); } if (key.isPressed('q')) { unit.strafeLeft(delta); } if (key.isPressed('e')) { unit.strafeRight(delta); } if (key.isPressed('space')) { unit.ascend(delta); } if (key.isPressed('x')) { unit.descend(delta); } if (key.isPressed('left') || key.isPressed('a')) { unit.rotateLeft(delta); } if (key.isPressed('right') || key.isPressed('d')) { unit.rotateRight(delta); } this.target = this.unit.position; } const position = this.camera.position; // Rotate offset to "y-axis-is-up" space this.offset.applyQuaternion(this.quat); // Angle from z-axis around y-axis let theta = Math.atan2(this.offset.x, this.offset.z); // Angle from y-axis let phi = Math.atan2( Math.sqrt(this.offset.x * this.offset.x + this.offset.z * this.offset.z), this.offset.y ); theta += this.thetaDelta; phi += this.phiDelta; // Limit vertical orbit phi = Math.max(this.minPhi, Math.min(this.maxPhi, phi)); phi = Math.max(this.EPS, Math.min(Math.PI - this.EPS, phi)); let radius = this.offset.length() * this.scale; // Limit zoom distance radius = Math.max(this.minDistance, Math.min(this.maxDistance, radius)); this.offset.x = radius * Math.sin(phi) * Math.sin(theta); this.offset.y = radius * Math.cos(phi); this.offset.z = radius * Math.sin(phi) * Math.cos(theta); // Rotate offset back to 'camera-up-vector-is-up' space this.offset.applyQuaternion(this.quatInverse); position.copy(this.target).add(this.offset); this.camera.lookAt(this.target); this.thetaDelta = 0; this.phiDelta = 0; this.scale = 1; } rotateHorizontally(angle) { this.thetaDelta -= angle; } rotateVertically(angle) { this.phiDelta -= angle; } zoomOut() { this.scale /= this.zoomScale; } zoomIn() { this.scale *= this.zoomScale; } _onMouseDown(event) { this.rotating = true; this.rotateStart.set(event.clientX, event.clientY); } _onMouseUp() { this.rotating = false; } _onMouseMove(event) { if (this.rotating) { event.preventDefault(); this.rotateEnd.set(event.clientX, event.clientY); this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart); this.rotateHorizontally( 2 * Math.PI * this.rotateDelta.x / this.element.clientWidth * this.rotateSpeed ); this.rotateVertically( 2 * Math.PI * this.rotateDelta.y / this.element.clientHeight * this.rotateSpeed ); this.rotateStart.copy(this.rotateEnd); this.update(); } } _onMouseWheel(event) { event.preventDefault(); event.stopPropagation(); const delta = event.wheelDelta || -event.detail; if (delta > 0) { this.zoomIn(); } else if (delta < 0) { this.zoomOut(); } this.update(); } render() { return null; } } export default Controls; ================================================ FILE: src/components/game/hud/index.jsx ================================================ import React from 'react'; import './index.styl'; // TODO: import Chat from '../chat'; import Portrait from '../portrait'; // TODO: import Quests from '../quests'; import session from '../../wowser/session'; class HUD extends React.Component { render() { const player = session.player; return ( { player.target && } ); } } export default HUD; ================================================ FILE: src/components/game/hud/index.styl ================================================ wowser .game .hud z-index: 2 display: flex align-items: center justify-content: center ================================================ FILE: src/components/game/index.jsx ================================================ import React from 'react'; import THREE from 'three'; import './index.styl'; import Controls from './controls'; import HUD from './hud'; import Stats from './stats'; import session from '../wowser/session'; class GameScreen extends React.Component { static id = 'game'; static title = 'Game'; constructor() { super(); this.animate = ::this.animate; this.resize = ::this.resize; this.camera = new THREE.PerspectiveCamera(60, this.aspectRatio, 1, 1000); this.camera.up.set(0, 0, 1); this.camera.position.set(15, 0, 7); this.prevCameraRotation = null; this.prevCameraPosition = null; this.renderer = null; this.requestID = null; // For some reason, we can't use the clock from controls here. this.clock = new THREE.Clock(); } componentDidMount() { this.renderer = new THREE.WebGLRenderer({ alpha: true, canvas: this.refs.canvas }); this.forceUpdate(); this.resize(); this.animate(); window.addEventListener('resize', this.resize); } componentWillUnmount() { if (this.renderer) { this.renderer.dispose(); this.renderer = null; } if (this.requestID) { this.requestID = null; cancelAnimationFrame(this.requestID); } window.removeEventListener('resize', this.resize); } get aspectRatio() { return window.innerWidth / window.innerHeight; } resize() { this.renderer.setSize(window.innerWidth, window.innerHeight); this.camera.aspect = this.aspectRatio; this.camera.updateProjectionMatrix(); } animate() { if (!this.renderer) { return; } this.refs.controls.update(); this.refs.stats.forceUpdate(); const cameraMoved = this.prevCameraRotation === null || this.prevCameraPosition === null || !this.prevCameraRotation.equals(this.camera.quaternion) || !this.prevCameraPosition.equals(this.camera.position); session.world.animate(this.clock.getDelta(), this.camera, cameraMoved); this.renderer.render(session.world.scene, this.camera); this.requestID = requestAnimationFrame(this.animate); this.prevCameraRotation = this.camera.quaternion.clone(); this.prevCameraPosition = this.camera.position.clone(); } render() { return ( ); } } export default GameScreen; ================================================ FILE: src/components/game/index.styl ================================================ wowser .game canvas position: absolute top: 0 left: 0 z-index: 1 width: 100% height: 100% ================================================ FILE: src/components/game/portrait/index.jsx ================================================ import React from 'react'; import classes from 'classnames'; import './index.styl'; class Portrait extends React.Component { static propTypes = { self: React.PropTypes.bool, unit: React.PropTypes.object.isRequired, target: React.PropTypes.bool }; render() { const unit = this.props.unit; const className = classes('portrait', { self: this.props.self, target: this.props.target }); return (
{ unit.name }
{ unit.hp } / { unit.maxHp }
{ unit.mp } / { unit.maxMp }
); } } export default Portrait; ================================================ FILE: src/components/game/portrait/index.styl ================================================ wowser .portrait display: block position: relative width: 178px height: 60px background: url('./images/portrait.png') no-repeat .icon position: absolute top: -4px left: -4px z-index: 1 .name position: absolute top: 4px left: 64px color: #FFCC00 font-size: 15px .level position: absolute top: 6px right: 12px font-size: 11px .divider position: absolute top: 20px right: 3px width: 113px .health, .mana width: 115px border-radius: 4px font-size: 12px line-height: 11px text-shadow: 1px 1px 0px #000000 text-align: center .health position: absolute bottom: 23px right: 6px background-image: linear-gradient(180deg, #330000 0%, #990000 100%) box-shadow: -1px -1px 0px #660000, 1px 1px 0px #E50202 .mana position: absolute bottom: 7px right: 20px background-image: linear-gradient(180deg, #021D39 0%, #0B4C93 100%) box-shadow: -1px -1px 0px #063467, 1px 1px 0px #146CD0 &.self position: absolute top: 12px left: 12px &.target position: absolute top: 12px left: 210px wowser .icon.portrait width: 66px height: 66px background: url('./images/icon-portrait.png') no-repeat ================================================ FILE: src/components/game/quests/index.jsx ================================================ import React from 'react'; import './index.styl'; class QuestsPanel extends React.Component { render() { return (

Quest Log

Soon™

); } } export default QuestsPanel; ================================================ FILE: src/components/game/quests/index.styl ================================================ wowser .quests position: absolute bottom: 0 right: 0 height: 30% width: 300px ================================================ FILE: src/components/game/stats/index.jsx ================================================ import React from 'react'; import './index.styl'; class Stats extends React.Component { static propTypes = { renderer: React.PropTypes.object, map: React.PropTypes.object }; mapStats() { const map = this.props.map; return (

Map Chunks

Loaded: { map ? map.chunks.size : 0 }

Map Doodads

Loading: { map ? map.doodadManager.entriesPendingLoad.size : 0 }

Loaded: { map ? map.doodadManager.doodads.size : 0 }

Animated: { map ? map.doodadManager.animatedDoodads.size : 0 }

WMOs

Loading Entries: { map ? map.wmoManager.counters.loadingEntries : 0 }

Loaded Entries: { map ? map.wmoManager.counters.loadedEntries : 0 }

Loading Groups: { map ? map.wmoManager.counters.loadingGroups : 0 }

Loaded Groups: { map ? map.wmoManager.counters.loadedGroups : 0 }

Loading Doodads: { map ? map.wmoManager.counters.loadingDoodads : 0 }

Loaded Doodads: { map ? map.wmoManager.counters.loadedDoodads : 0 }

Animated Doodads: { map ? map.wmoManager.counters.animatedDoodads : 0 }

); } render() { const renderer = this.props.renderer; if (!renderer) { return null; } const map = this.props.map; const { memory, programs, render } = renderer.info; return (

Memory

Geometries: { memory.geometries }

Textures: { memory.textures }

Programs: { programs.length }

Render

Calls: { render.calls }

Faces: { render.faces }

Points: { render.points }

Vertices: { render.vertices }

{ map && this.mapStats() }
); } } export default Stats; ================================================ FILE: src/components/game/stats/index.styl ================================================ wowser .stats position: absolute bottom: 0 right: 0 z-index: 3 width: 160px ================================================ FILE: src/components/kit/index.jsx ================================================ import React from 'react'; class KitScreen extends React.Component { static id = 'kit'; static title = 'UI Kit'; render() { return (

Thin frame

Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.

Thick frame

Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.

Regular panel

Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.

Headless panel

Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.

); } } export default KitScreen; ================================================ FILE: src/components/realms/index.jsx ================================================ import React from 'react'; import session from '../wowser/session'; class RealmsScreen extends React.Component { static id = 'realms'; static title = 'Realms Selection'; constructor() { super(); this.state = { realm: null, realms: [] }; this._onAuthenticate = ::this._onAuthenticate; this._onRealmSelect = ::this._onRealmSelect; this._onRefresh = ::this._onRefresh; this._onSubmit = ::this._onSubmit; session.realms.on('refresh', this._onRefresh); session.game.on('authenticate', this._onAuthenticate); this.refresh(); } componentWillUnmount() { session.realms.removeListener('refresh', this._onRefresh); session.game.removeListener('authenticate', this._onAuthenticate); } connect(realm) { session.game.connect('localhost', realm.port); } refresh() { session.realms.refresh(); } _onAuthenticate() { session.screen = 'characters'; } _onRealmSelect(event) { this.setState({ realm: event.target.value }); } _onRefresh() { const realms = session.realms.list; this.setState({ realm: realms[0], realms: realms }); } _onSubmit(event) { event.preventDefault(); this.connect(this.state.realm); } render() { return (

Realm Selection

); } } export default RealmsScreen; ================================================ FILE: src/components/wowser/index.jsx ================================================ import React from 'react'; import './index.styl'; import AuthScreen from '../auth'; import CharactersScreen from '../characters'; import GameScreen from '../game'; import RealmsScreen from '../realms'; import Kit from '../kit'; import session from './session'; class Wowser extends React.Component { static SCREENS = [ AuthScreen, RealmsScreen, CharactersScreen, GameScreen, Kit ]; constructor() { super(); this.state = { screen: session.screen }; this._onScreenChange = ::this._onScreenChange; this._onScreenSelect = ::this._onScreenSelect; session.on('screen:change', this._onScreenChange); } get currentScreen() { const Screen = this.constructor.SCREENS.find((screen) => { return screen.id === this.state.screen; }); return ; } _onScreenChange(_from, to) { this.setState({ screen: to }); } _onScreenSelect(event) { session.screen = event.target.value; } render() { const screens = this.constructor.SCREENS; return (
Wowser
World of Warcraft in the browser
{ this.currentScreen }
); } } export default Wowser; ================================================ FILE: src/components/wowser/index.styl ================================================ @import '~normalize.css' @import './ui'; html, body width: 100% height: 100% overflow: hidden * box-sizing: border-box wowser display: flex align-items: center justify-content: center width: 100% height: 100% background: #222222 font-family: Galdeano font-size: 13px color: #FFFFFF -webkit-font-smoothing: antialiased &:active cursor: none .branding position: absolute top: 10px right: 10px z-index: 4 header width: 204px height: 47px background: url('./images/logo.png') no-repeat text-indent: -99999px .divider margin: 5px 0 .slogan color: #FFCC00 letter-spacing: .075em select.screen-selector position: absolute top: 100px right: 10px z-index: 4 color: #000000 ================================================ FILE: src/components/wowser/session.jsx ================================================ import Client from '../../lib'; class Session extends Client { constructor() { super(); this._screen = 'auth'; } get screen() { return this._screen; } set screen(screen) { if (this._screen !== screen) { this.emit('screen:change', this._screen, screen); this._screen = screen; } } } export default new Session(); ================================================ FILE: src/components/wowser/ui/form/index.styl ================================================ wowser form fieldset display: block border: 0 padding: 0 margin: .5em border-top: 1px solid transparent label display: block color: #999999 font-size: 14px margin: .7em 0 .1em input, select, textarea padding: .2em .3em background-color: #111111 border-width: 1px border-style: solid border-color: #333333 #666666 #666666 #333333 border-radius: 6px color: #FFFFFF font-size: 14px -webkit-font-smoothing: antialiased outline: none margin-bottom: .3em ================================================ FILE: src/components/wowser/ui/frame/dividers/index.styl ================================================ wowser .divider border-style: solid &, &.horizontal border-width: 3px 5px 0 5px border-image: url('./images/horizontal.png') 3 5 0 5 repeat &.thick border-width: 11px 5px 0 5px border-image: url('./images/thick-horizontal.png') 11 5 0 5 repeat ================================================ FILE: src/components/wowser/ui/frame/index.styl ================================================ @import './dividers'; wowser .frame, .panel display: block position: relative margin: 10px &:before content: ' ' display: block position: absolute top: -3px bottom: -3px left: -3px right: -3px z-index: -1 background: rgba(0, 0, 0, .8) .frame border-width: 5px border-image: url('./images/thin.png') 5 5 5 5 repeat border-style: solid .divider &, &.horizontal margin-left: -3px margin-right: -3px &.thick border-width: 11px border-image: url('./images/thick.png') 11 11 11 11 repeat .divider &, &.horizontal margin-left: -5px margin-right: -5px .panel border-width: 23px border-image: url('./images/panel.png') 23 23 23 23 repeat border-style: solid .icon.portrait position: absolute top: -30px left: -46px & + h1, & + h2, & + h3 margin-left: 1.5em .divider &, &.horizontal margin-left: -6px margin-right: -6px &.headless border-width: 11px 23px 23px 23px border-image: url('./images/panel-headless.png') 11 23 23 23 repeat .icon.portrait top: -20px ================================================ FILE: src/components/wowser/ui/index.styl ================================================ @import './form'; @import './frame'; @import './screen'; @import './type'; @import './widgets'; ================================================ FILE: src/components/wowser/ui/screen.styl ================================================ wowser .screen position: absolute top: 0 left: 0 z-index: 3 width: 100% height: 100% display: flex align-items: center justify-content: center ================================================ FILE: src/components/wowser/ui/type.styl ================================================ wowser h1, h2, h3, h4 margin: .3em color: #FFCC00 font-weight: normal h1 font-size: 17px h2 font-size: 15px p margin: .5em ================================================ FILE: src/components/wowser/ui/widgets/button.styl ================================================ wowser input[type='submit'], input[type='button'], button margin: .4em 0 .3em .5em padding: .2em 1em border: none border-radius: 4px background-image: linear-gradient(180deg, #990000 0%, #660000 60%, #660000 100%) border-width: 1px border-style: solid border-color: #E50202 #990000 #770000 #E50202 color: #FFFFFF text-shadow: 1px 1px 0px #330000 font-size: 13px -webkit-font-smoothing: antialiased outline: none &:enabled:active background-image: linear-gradient(180deg, #660000 0%, #660000 26%, #990000 100%) border-color: #330000 transform: scale(.97) text-shadow: none box-shadow: -1px -1px 1px rgba(#000000, .2), 1px 1px 1px rgba(#000000, .2) &:enabled:hover color: #FFCC00 &:disabled background-image: linear-gradient(180deg, #4A0000 0%, #2A0000 100%) border-color: #8A0000 #400000 #400000 #8A0000 color: #990000 ================================================ FILE: src/components/wowser/ui/widgets/index.styl ================================================ @import './button'; ================================================ FILE: src/index.html ================================================ Wowser ================================================ FILE: src/lib/auth/challenge-opcode.js ================================================ class ChallengeOpcode { static SUCCESS = 0x00; static UNKNOWN0 = 0x01; static UNKNOWN1 = 0x02; static ACCOUNT_BANNED = 0x03; static ACCOUNT_INVALID = 0x04; static PASSWORD_INVALID = 0x05; static ALREADY_ONLINE = 0x06; static OUT_OF_CREDIT = 0x07; static BUSY = 0x08; static BUILD_INVALID = 0x09; static BUILD_UPDATE = 0x0A; static INVALID_SERVER = 0x0B; static ACCOUNT_SUSPENDED = 0x0C; static ACCESS_DENIED = 0x0D; static SURVEY = 0x0E; static PARENTAL_CONTROL = 0x0F; static LOCK_ENFORCED = 0x10; static TRIAL_EXPIRED = 0x11; static BATTLE_NET = 0x12; } export default ChallengeOpcode; ================================================ FILE: src/lib/auth/handler.js ================================================ import AuthChallengeOpcode from './challenge-opcode'; import AuthOpcode from './opcode'; import AuthPacket from './packet'; import Socket from '../net/socket'; import SRP from '../crypto/srp'; class AuthHandler extends Socket { // Default port for the auth-server static PORT = 3724; // Creates a new authentication handler constructor(session) { super(); // Holds session this.session = session; // Holds credentials for this session (if any) this.account = null; this.password = null; // Holds Secure Remote Password implementation this.srp = null; // Listen for incoming data this.on('data:receive', this.dataReceived); // Delegate packets this.on('packet:receive:LOGON_CHALLENGE', this.handleLogonChallenge); this.on('packet:receive:LOGON_PROOF', this.handleLogonProof); } // Retrieves the session key (if any) get key() { return this.srp && this.srp.K; } // Connects to given host through given port connect(host, port = NaN) { if (!this.connected) { super.connect(host, port || this.constructor.PORT); console.info('connecting to auth-server @', this.host, ':', this.port); } return this; } // Sends authentication request to connected host authenticate(account, password) { if (!this.connected) { return false; } this.account = account.toUpperCase(); this.password = password.toUpperCase(); console.info('authenticating', this.account); // Extract configuration data const { build, majorVersion, minorVersion, patchVersion, game, raw: { os, locale, platform }, timezone } = this.session.config; const ap = new AuthPacket(AuthOpcode.LOGON_CHALLENGE, 4 + 29 + 1 + this.account.length); ap.writeByte(0x00); ap.writeShort(30 + this.account.length); ap.writeString(game); // game string ap.writeByte(majorVersion); // v1 (major) ap.writeByte(minorVersion); // v2 (minor) ap.writeByte(patchVersion); // v3 (patch) ap.writeShort(build); // build ap.writeString(platform); // platform ap.writeString(os); // os ap.writeString(locale); // locale ap.writeUnsignedInt(timezone); // timezone ap.writeUnsignedInt(0); // ip ap.writeByte(this.account.length); // account length ap.writeString(this.account); // account this.send(ap); } // Data received handler dataReceived() { while (true) { if (!this.connected || this.buffer.available < AuthPacket.HEADER_SIZE) { return; } const ap = new AuthPacket(this.buffer.readByte(), this.buffer.seek(-AuthPacket.HEADER_SIZE).read(), false); console.log('⟹', ap.toString()); // console.debug ap.toHex() // console.debug ap.toASCII() this.emit('packet:receive', ap); if (ap.opcodeName) { this.emit(`packet:receive:${ap.opcodeName}`, ap); } } } // Logon challenge handler (LOGON_CHALLENGE) handleLogonChallenge(ap) { ap.readUnsignedByte(); const status = ap.readUnsignedByte(); switch (status) { case AuthChallengeOpcode.SUCCESS: console.info('received logon challenge'); const B = ap.read(32); // B const glen = ap.readUnsignedByte(); // g-length const g = ap.read(glen); // g const Nlen = ap.readUnsignedByte(); // n-length const N = ap.read(Nlen); // N const salt = ap.read(32); // salt ap.read(16); // unknown ap.readUnsignedByte(); // security flags this.srp = new SRP(N, g); this.srp.feed(salt, B, this.account, this.password); const lpp = new AuthPacket(AuthOpcode.LOGON_PROOF, 1 + 32 + 20 + 20 + 2); lpp.write(this.srp.A.toArray()); lpp.write(this.srp.M1.digest); lpp.write(new Array(20)); // CRC hash lpp.writeByte(0x00); // number of keys lpp.writeByte(0x00); // security flags this.send(lpp); break; case AuthChallengeOpcode.ACCOUNT_INVALID: console.warn('account invalid'); this.emit('reject'); break; case AuthChallengeOpcode.BUILD_INVALID: console.warn('build invalid'); this.emit('reject'); break; default: break; } } // Logon proof handler (LOGON_PROOF) handleLogonProof(ap) { ap.readByte(); console.info('received proof response'); const M2 = ap.read(20); if (this.srp.validate(M2)) { this.emit('authenticate'); } else { this.emit('reject'); } } } export default AuthHandler; ================================================ FILE: src/lib/auth/opcode.js ================================================ class Opcode { static LOGON_CHALLENGE = 0x00; static LOGON_PROOF = 0x01; static RECONNECT_CHALLENGE = 0x02; static RECONNECT_PROOF = 0x03; static REALM_LIST = 0x10; } export default Opcode; ================================================ FILE: src/lib/auth/packet.js ================================================ import AuthOpcode from './opcode'; import BasePacket from '../net/packet'; import ObjectUtil from '../utils/object-util'; class AuthPacket extends BasePacket { // Header size in bytes for both incoming and outgoing packets static HEADER_SIZE = 1; constructor(opcode, source, outgoing = true) { super(opcode, source || AuthPacket.HEADER_SIZE, outgoing); } // Retrieves the name of the opcode for this packet (if available) get opcodeName() { return ObjectUtil.keyByValue(AuthOpcode, this.opcode); } // Finalizes this packet finalize() { this.index = 0; this.writeByte(this.opcode); } } export default AuthPacket; ================================================ FILE: src/lib/characters/character.js ================================================ class Character { // Short string representation of this character toString() { return `[Character; GUID: ${this.guid}]`; } } export default Character; ================================================ FILE: src/lib/characters/handler.js ================================================ import EventEmitter from 'events'; import Character from './character'; import GamePacket from '../game/packet'; import GameOpcode from '../game/opcode'; class CharacterHandler extends EventEmitter { // Creates a new character handler constructor(session) { super(); // Holds session this.session = session; // Initially empty list of characters this.list = []; // Listen for character list this.session.game.on('packet:receive:SMSG_CHAR_ENUM', ::this.handleCharacterList); } // Requests a fresh list of characters refresh() { console.info('refreshing character list'); const gp = new GamePacket(GameOpcode.CMSG_CHAR_ENUM); return this.session.game.send(gp); } // Character list refresh handler (SMSG_CHAR_ENUM) handleCharacterList(gp) { const count = gp.readByte(); // number of characters this.list.length = 0; for (let i = 0; i < count; ++i) { const character = new Character(); character.guid = gp.readGUID(); character.name = gp.readCString(); character.race = gp.readUnsignedByte(); character.class = gp.readUnsignedByte(); character.gender = gp.readUnsignedByte(); character.bytes = gp.readUnsignedInt(); character.facial = gp.readUnsignedByte(); character.level = gp.readUnsignedByte(); character.zone = gp.readUnsignedInt(); character.map = gp.readUnsignedInt(); character.x = gp.readFloat(); character.y = gp.readFloat(); character.z = gp.readFloat(); character.guild = gp.readUnsignedInt(); character.flags = gp.readUnsignedInt(); gp.readUnsignedInt(); // character customization gp.readUnsignedByte(); // (?) const pet = { model: gp.readUnsignedInt(), level: gp.readUnsignedInt(), family: gp.readUnsignedInt() }; if (pet.model) { character.pet = pet; } character.equipment = []; for (let j = 0; j < 23; ++j) { const item = { model: gp.readUnsignedInt(), type: gp.readUnsignedByte(), enchantment: gp.readUnsignedInt() }; character.equipment.push(item); } this.list.push(character); } this.emit('refresh'); } } export default CharacterHandler; ================================================ FILE: src/lib/config.js ================================================ class Raw { constructor(config) { this.config = config; } raw(value) { return ('\u0000\u0000\u0000\u0000' + value.split('').reverse().join('')).slice(-4); } get locale() { return this.raw(this.config.locale); } get os() { return this.raw(this.config.os); } get platform() { return this.raw(this.config.platform); } } class Config { constructor() { this.game = 'Wow '; this.build = 12340; this.version = '3.3.5'; this.timezone = 0; this.locale = 'enUS'; this.os = 'Win'; this.platform = 'x86'; this.raw = new Raw(this); } set version(version) { [ this.majorVersion, this.minorVersion, this.patchVersion ] = version.split('.').map(function(bit) { return parseInt(bit, 10); }); } } export default Config; ================================================ FILE: src/lib/crypto/big-num.js ================================================ import BigInteger from 'jsbn/lib/big-integer'; // C-like BigNum decorator for JSBN's BigInteger class BigNum { // Convenience BigInteger.ZERO decorator static ZERO = new BigNum(BigInteger.ZERO); // Creates a new BigNum constructor(value, radix) { if (typeof value === 'number') { this._bi = BigInteger.fromInt(value); } else if (value.constructor === BigInteger) { this._bi = value; } else if (value.constructor === BigNum) { this._bi = value.bi; } else { this._bi = new BigInteger(value, radix); } } // Short string description of this BigNum toString() { return `[BigNum; Value: ${this._bi}; Hex: ${this._bi.toString(16).toUpperCase()}]`; } // Retrieves BigInteger instance being decorated get bi() { return this._bi; } // Performs a modulus operation mod(m) { return new BigNum(this._bi.mod(m.bi)); } // Performs an exponential+modulus operation modPow(e, m) { return new BigNum(this._bi.modPow(e.bi, m.bi)); } // Performs an addition add(o) { return new BigNum(this._bi.add(o.bi)); } // Performs a subtraction subtract(o) { return new BigNum(this._bi.subtract(o.bi)); } // Performs a multiplication multiply(o) { return new BigNum(this._bi.multiply(o.bi)); } // Performs a division divide(o) { return new BigNum(this._bi.divide(o.bi)); } // Whether the given BigNum is equal to this one equals(o) { return this._bi.equals(o.bi); } // Generates a byte-array from this BigNum (defaults to little-endian) toArray(littleEndian = true, unsigned = true) { const ba = this._bi.toByteArray(); if (unsigned && this._bi.s === 0 && ba[0] === 0) { ba.shift(); } if (littleEndian) { return ba.reverse(); } return ba; } // Creates a new BigNum from given byte-array static fromArray(bytes, littleEndian = true, unsigned = true) { if (typeof bytes.toArray !== 'undefined') { bytes = bytes.toArray(); } else { bytes = bytes.slice(0); } if (littleEndian) { bytes = bytes.reverse(); } if (unsigned && bytes[0] & 0x80) { bytes.unshift(0); } return new BigNum(bytes); } // Creates a new random BigNum of the given number of bytes static fromRand(length) { // TODO: This should use a properly seeded, secure RNG const bytes = []; for (let i = 0; i < length; ++i) { bytes.push(Math.floor(Math.random() * 128)); } return new BigNum(bytes); } } export default BigNum; ================================================ FILE: src/lib/crypto/crypt.js ================================================ import { HMAC } from 'jsbn/lib/sha1'; import RC4 from 'jsbn/lib/rc4'; import ArrayUtil from '../utils/array-util'; class Crypt { // Creates crypt constructor() { // RC4's for encryption and decryption this._encrypt = null; this._decrypt = null; } // Encrypts given data through RC4 encrypt(data) { if (this._encrypt) { this._encrypt.encrypt(data); } return this; } // Decrypts given data through RC4 decrypt(data) { if (this._decrypt) { this._decrypt.decrypt(data); } return this; } // Sets session key and initializes this crypt set key(key) { console.info('initializing crypt'); // Fresh RC4's this._encrypt = new RC4(); this._decrypt = new RC4(); // Calculate the encryption hash (through the server decryption key) const enckey = ArrayUtil.fromHex('C2B3723CC6AED9B5343C53EE2F4367CE'); const enchash = HMAC.fromArrays(enckey, key); // Calculate the decryption hash (through the client decryption key) const deckey = ArrayUtil.fromHex('CC98AE04E897EACA12DDC09342915357'); const dechash = HMAC.fromArrays(deckey, key); // Seed RC4's with the computed hashes this._encrypt.init(enchash); this._decrypt.init(dechash); // Ensure the buffer is synchronized for (let i = 0; i < 1024; ++i) { this._encrypt.next(); this._decrypt.next(); } } } export default Crypt; ================================================ FILE: src/lib/crypto/hash/sha1.js ================================================ import SHA1Base from 'jsbn/lib/sha1'; import Hash from '../hash'; // SHA-1 implementation class SHA1 extends Hash { // Finalizes this SHA-1 hash finalize() { this._digest = SHA1Base.fromArray(this._data.toArray()); } } export default SHA1; ================================================ FILE: src/lib/crypto/hash.js ================================================ import ByteBuffer from 'byte-buffer'; // Feedable hash implementation class Hash { // Creates a new hash constructor() { // Data fed to this hash this._data = null; // Resulting digest this._digest = null; this.reset(); } // Retrieves digest (finalizes this hash if needed) get digest() { if (!this._digest) { this.finalize(); } return this._digest; } // Resets this hash, voiding the digest and allowing new feeds reset() { this._data = new ByteBuffer(0, ByteBuffer.BIG_ENDIAN, true); this._digest = null; return this; } // Feeds hash given value feed(value) { if (this._digest) { return this; } if (value.constructor === String) { this._data.writeString(value); } else { this._data.write(value); } return this; } // Finalizes this hash, calculates the digest and blocks additional feeds finalize() { return this; } } export default Hash; ================================================ FILE: src/lib/crypto/srp.js ================================================ import equal from 'deep-equal'; import BigNum from './big-num'; import SHA1 from './hash/sha1'; // Secure Remote Password // http://tools.ietf.org/html/rfc2945 class SRP { // Creates new SRP instance with given constant prime and generator constructor(N, g) { // Constant prime (N) this._N = BigNum.fromArray(N); // Generator (g) this._g = BigNum.fromArray(g); // Client salt (provided by server) this._s = null; // Salted authentication hash this._x = null; // Random scrambling parameter this._u = null; // Derived key this._k = new BigNum(3); // Server's public ephemeral value (provided by server) this._B = null; // Password verifier this._v = null; // Client-side session key this._S = null; // Shared session key this._K = null; // Client proof hash this._M1 = null; // Expected server proof hash this._M2 = null; while (true) { // Client's private ephemeral value (random) this._a = BigNum.fromRand(19); // Client's public ephemeral value based on the above // A = g ^ a mod N this._A = this._g.modPow(this._a, this._N); if (!this._A.mod(this._N).equals(BigNum.ZERO)) { break; } } } // Retrieves client's public ephemeral value get A() { return this._A; } // Retrieves the session key get K() { return this._K; } // Retrieves the client proof hash get M1() { return this._M1; } // Feeds salt, server's public ephemeral value, account and password strings feed(s, B, I, P) { // Generated salt (s) and server's public ephemeral value (B) this._s = BigNum.fromArray(s); this._B = BigNum.fromArray(B); // Authentication hash consisting of user's account (I), a colon and user's password (P) // auth = H(I : P) const auth = new SHA1(); auth.feed(I); auth.feed(':'); auth.feed(P).finalize(); // Salted authentication hash consisting of the salt and the authentication hash // x = H(s | auth) const x = new SHA1(); x.feed(this._s.toArray()); x.feed(auth.digest); this._x = BigNum.fromArray(x.digest); // Password verifier // v = g ^ x mod N this._v = this._g.modPow(this._x, this._N); // Random scrambling parameter consisting of the public ephemeral values // u = H(A | B) const u = new SHA1(); u.feed(this._A.toArray()); u.feed(this._B.toArray()); this._u = BigNum.fromArray(u.digest); // Client-side session key // S = (B - (kg^x)) ^ (a + ux) const kgx = this._k.multiply(this._g.modPow(this._x, this._N)); const aux = this._a.add(this._u.multiply(this._x)); this._S = this._B.subtract(kgx).modPow(aux, this._N); // Store odd and even bytes in separate byte-arrays const S = this._S.toArray(); const S1 = []; const S2 = []; for (let i = 0; i < 16; ++i) { S1[i] = S[i * 2]; S2[i] = S[i * 2 + 1]; } // Hash these byte-arrays const S1h = new SHA1(); const S2h = new SHA1(); S1h.feed(S1).finalize(); S2h.feed(S2).finalize(); // Shared session key generation by interleaving the previously generated hashes this._K = []; for (let i = 0; i < 20; ++i) { this._K[i * 2] = S1h.digest[i]; this._K[i * 2 + 1] = S2h.digest[i]; } // Generate username hash const userh = new SHA1(); userh.feed(I).finalize(); // Hash both prime and generator const Nh = new SHA1(); const gh = new SHA1(); Nh.feed(this._N.toArray()).finalize(); gh.feed(this._g.toArray()).finalize(); // XOR N-prime and generator const Ngh = []; for (let i = 0; i < 20; ++i) { Ngh[i] = Nh.digest[i] ^ gh.digest[i]; } // Calculate M1 (client proof) // M1 = H( (H(N) ^ H(G)) | H(I) | s | A | B | K ) this._M1 = new SHA1(); this._M1.feed(Ngh); this._M1.feed(userh.digest); this._M1.feed(this._s.toArray()); this._M1.feed(this._A.toArray()); this._M1.feed(this._B.toArray()); this._M1.feed(this._K); this._M1.finalize(); // Pre-calculate M2 (expected server proof) // M2 = H( A | M1 | K ) this._M2 = new SHA1(); this._M2.feed(this._A.toArray()); this._M2.feed(this._M1.digest); this._M2.feed(this._K); this._M2.finalize(); } // Validates given M2 with expected M2 validate(M2) { if (!this._M2) { return false; } return equal(M2.toArray(), this._M2.digest); } } export default SRP; ================================================ FILE: src/lib/game/chat/handler.js ================================================ import EventEmitter from 'events'; import Message from './message'; class ChatHandler extends EventEmitter { // Creates a new chat handler constructor(session) { super(); // Holds session this.session = session; // Holds messages this.messages = [ new Message('system', 'Welcome to Wowser!'), new Message('system', 'This is a very alpha-ish build.'), new Message('info', 'This is an info message'), new Message('error', 'This is an error message'), new Message('area', 'Player: This is a message emitted nearby'), new Message('channel', '[Trade]: This is a channel message'), new Message('whisper outgoing', 'To Someone: This is an outgoing whisper'), new Message('whisper incoming', 'Someone: This is an incoming whisper'), new Message('guild', '[Guild] Someone: This is a guild message') ]; // Listen for messages this.session.game.on('packet:receive:SMSG_MESSAGE_CHAT', ::this.handleMessage); } // Creates chat message create() { return new Message(); } // Sends given message send(_message) { throw new Error('sending chat messages is not yet implemented'); } // Message handler (SMSG_MESSAGE_CHAT) handleMessage(gp) { gp.readUnsignedByte(); // type gp.readUnsignedInt(); // language const guid1 = gp.readGUID(); gp.readUnsignedInt(); gp.readGUID(); // guid2 const len = gp.readUnsignedInt(); const text = gp.readString(len); gp.readUnsignedByte(); // flags const message = new Message(); message.text = text; message.guid = guid1; this.messages.push(message); this.emit('message', message); } } export default ChatHandler; ================================================ FILE: src/lib/game/chat/message.js ================================================ class ChatMessage { // Creates a new message constructor(kind, text) { this.kind = kind; this.text = text; this.timestamp = new Date(); } // Short string representation of this message toString() { return `[Message; Text: ${this.text}; GUID: ${this.guid}]`; } } export default ChatMessage; ================================================ FILE: src/lib/game/entity.js ================================================ import EventEmitter from 'events'; class Entity extends EventEmitter { constructor() { super(); this.guid = Math.random() * 1000000 | 0; } } export default Entity; ================================================ FILE: src/lib/game/guid.js ================================================ class GUID { // GUID byte-length (64-bit) static LENGTH = 8; // Creates a new GUID constructor(buffer) { // Holds raw byte representation this.raw = buffer; // Holds low-part this.low = buffer.readUnsignedInt(); // Holds high-part this.high = buffer.readUnsignedInt(); } // Short string representation of this GUID toString() { const high = ('0000' + this.high.toString(16)).slice(-4); const low = ('0000' + this.low.toString(16)).slice(-4); return `[GUID; Hex: 0x${high}${low}]`; } } export default GUID; ================================================ FILE: src/lib/game/handler.js ================================================ import ByteBuffer from 'byte-buffer'; import BigNum from '../crypto/big-num'; import Crypt from '../crypto/crypt'; import GameOpcode from './opcode'; import GamePacket from './packet'; import GUID from '../game/guid'; import SHA1 from '../crypto/hash/sha1'; import Socket from '../net/socket'; class GameHandler extends Socket { // Creates a new game handler constructor(session) { super(); // Holds session this.session = session; // Listen for incoming data this.on('data:receive', ::this.dataReceived); // Delegate packets this.on('packet:receive:SMSG_AUTH_CHALLENGE', ::this.handleAuthChallenge); this.on('packet:receive:SMSG_AUTH_RESPONSE', ::this.handleAuthResponse); this.on('packet:receive:SMSG_LOGIN_VERIFY_WORLD', ::this.handleWorldLogin); } // Connects to given host through given port connect(host, port) { if (!this.connected) { super.connect(host, port); console.info('connecting to game-server @', this.host, ':', this.port); } return this; } // Finalizes and sends given packet send(packet) { const size = packet.bodySize + GamePacket.OPCODE_SIZE_OUTGOING; packet.front(); packet.writeShort(size, ByteBuffer.BIG_ENDIAN); packet.writeUnsignedInt(packet.opcode); // Encrypt header if needed if (this._crypt) { this._crypt.encrypt(new Uint8Array(packet.buffer, 0, GamePacket.HEADER_SIZE_OUTGOING)); } return super.send(packet); } // Attempts to join game with given character join(character) { if (character) { console.info('joining game with', character.toString()); const gp = new GamePacket(GameOpcode.CMSG_PLAYER_LOGIN, GamePacket.HEADER_SIZE_OUTGOING + GUID.LENGTH); gp.writeGUID(character.guid); return this.send(gp); } return false; } // Data received handler dataReceived(_socket) { while (true) { if (!this.connected) { return; } if (this.remaining === false) { if (this.buffer.available < GamePacket.HEADER_SIZE_INCOMING) { return; } // Decrypt header if needed if (this._crypt) { this._crypt.decrypt(new Uint8Array(this.buffer.buffer, this.buffer.index, GamePacket.HEADER_SIZE_INCOMING)); } this.remaining = this.buffer.readUnsignedShort(ByteBuffer.BIG_ENDIAN); } if (this.remaining > 0 && this.buffer.available >= this.remaining) { const size = GamePacket.OPCODE_SIZE_INCOMING + this.remaining; const gp = new GamePacket(this.buffer.readUnsignedShort(), this.buffer.seek(-GamePacket.HEADER_SIZE_INCOMING).read(size), false); this.remaining = false; console.log('⟹', gp.toString()); // console.debug gp.toHex() // console.debug gp.toASCII() this.emit('packet:receive', gp); if (gp.opcodeName) { this.emit(`packet:receive:${gp.opcodeName}`, gp); } } else if (this.remaining !== 0) { return; } } } // Auth challenge handler (SMSG_AUTH_CHALLENGE) handleAuthChallenge(gp) { console.info('handling auth challenge'); gp.readUnsignedInt(); // (0x01) const salt = gp.read(4); const seed = BigNum.fromRand(4); const hash = new SHA1(); hash.feed(this.session.auth.account); hash.feed([0, 0, 0, 0]); hash.feed(seed.toArray()); hash.feed(salt); hash.feed(this.session.auth.key); const build = this.session.config.build; const account = this.session.auth.account; const size = GamePacket.HEADER_SIZE_OUTGOING + 8 + this.session.auth.account.length + 1 + 4 + 4 + 20 + 20 + 4; const app = new GamePacket(GameOpcode.CMSG_AUTH_PROOF, size); app.writeUnsignedInt(build); // build app.writeUnsignedInt(0); // (?) app.writeCString(account); // account app.writeUnsignedInt(0); // (?) app.write(seed.toArray()); // client-seed app.writeUnsignedInt(0); // (?) app.writeUnsignedInt(0); // (?) app.writeUnsignedInt(0); // (?) app.writeUnsignedInt(0); // (?) app.writeUnsignedInt(0); // (?) app.write(hash.digest); // digest app.writeUnsignedInt(0); // addon-data this.send(app); this._crypt = new Crypt(); this._crypt.key = this.session.auth.key; } // Auth response handler (SMSG_AUTH_RESPONSE) handleAuthResponse(gp) { console.info('handling auth response'); // Handle result byte const result = gp.readUnsignedByte(); if (result === 0x0D) { console.warn('server-side auth/realm failure; try again'); this.emit('reject'); return; } if (result === 0x15) { console.warn('account in use/invalid; aborting'); this.emit('reject'); return; } // TODO: Ensure the account is flagged as WotLK (expansion //2) this.emit('authenticate'); } // World login handler (SMSG_LOGIN_VERIFY_WORLD) handleWorldLogin(_gp) { this.emit('join'); } } export default GameHandler; ================================================ FILE: src/lib/game/opcode.js ================================================ class GameOpcode { static CMSG_CHAR_ENUM = 0x0037; static SMSG_CHAR_ENUM = 0x003B; static CMSG_PLAYER_LOGIN = 0x003D; static SMSG_CHARACTER_LOGIN_FAILED = 0x0041; static SMSG_LOGIN_SETTIMESPEED = 0x0042; static SMSG_CONTACT_LIST = 0x0067; static CMSG_MESSAGE_CHAT = 0x0095; static SMSG_MESSAGE_CHAT = 0x0096; static SMSG_UPDATE_OBJECT = 0x00A9; static SMSG_MONSTER_MOVE = 0x00DD; static SMSG_TUTORIAL_FLAGS = 0x00FD; static SMSG_INITIALIZE_FACTIONS = 0x0122; static SMSG_SET_PROFICIENCY = 0x0127; static SMSG_ACTION_BUTTONS = 0x0129; static SMSG_INITIAL_SPELLS = 0x012A; static SMSG_SPELL_START = 0x0131; static SMSG_SPELL_GO = 0x0132; static SMSG_BINDPOINT_UPDATE = 0x0155; static SMSG_ITEM_TIME_UPDATE = 0x01EA; static SMSG_AUTH_CHALLENGE = 0x01EC; static CMSG_AUTH_PROOF = 0x01ED; static SMSG_AUTH_RESPONSE = 0x01EE; static SMSG_COMPRESSED_UPDATE_OBJECT = 0x01F6; static SMSG_ACCOUNT_DATA_TIMES = 0x0209; static SMSG_LOGIN_VERIFY_WORLD = 0x0236; static SMSG_SPELL_NON_MELEE_DAMAGE_LOG = 0x0250; static SMSG_INIT_WORLD_STATES = 0x02C2; static SMSG_UPDATE_WORLD_STATE = 0x02C3; static SMSG_WEATHER = 0x02F4; static MSG_SET_DUNGEON_DIFFICULTY = 0x0329; static SMSG_UPDATE_INSTANCE_OWNERSHIP = 0x032B; static SMSG_INSTANCE_DIFFICULTY = 0x033B; static SMSG_MOTD = 0x033D; static SMSG_TIME_SYNC_REQ = 0x0390; static SMSG_FEATURE_SYSTEM_STATUS = 0x03C9; static SMSG_SERVER_BUCK_DATA = 0x041D; static SMSG_SEND_UNLEARN_SPELLS = 0x041E; static SMSG_LEARNED_DANCE_MOVES = 0x0455; static SMSG_ALL_ACHIEVEMENT_DATA = 0x047D; static SMSG_POWER_UPDATE = 0x0480; static SMSG_AURA_UPDATE_ALL = 0x0495; static SMSG_AURA_UPDATE = 0x0496; static SMSG_EQUIPMENT_SET_LIST = 0x04BC; static SMSG_TALENTS_INFO = 0x04C0; static MSG_SET_RAID_DIFFICULTY = 0x04EB; } export default GameOpcode; ================================================ FILE: src/lib/game/packet.js ================================================ import BasePacket from '../net/packet'; import GameOpcode from './opcode'; import GUID from './guid'; import ObjectUtil from '../utils/object-util'; class GamePacket extends BasePacket { // Header sizes in bytes for both incoming and outgoing packets static HEADER_SIZE_INCOMING = 4; static HEADER_SIZE_OUTGOING = 6; // Opcode sizes in bytes for both incoming and outgoing packets static OPCODE_SIZE_INCOMING = 2; static OPCODE_SIZE_OUTGOING = 4; constructor(opcode, source, outgoing = true) { if (!source) { source = (outgoing) ? GamePacket.HEADER_SIZE_OUTGOING : GamePacket.HEADER_SIZE_INCOMING; } super(opcode, source, outgoing); } // Retrieves the name of the opcode for this packet (if available) get opcodeName() { return ObjectUtil.keyByValue(GameOpcode, this.opcode); } // Header size in bytes (dependent on packet origin) get headerSize() { if (this.outgoing) { return this.constructor.HEADER_SIZE_OUTGOING; } return this.constructor.HEADER_SIZE_INCOMING; } // Reads GUID from this packet readGUID() { return new GUID(this.read(GUID.LENGTH)); } // Writes given GUID to this packet writeGUID(guid) { this.write(guid.raw); return this; } // // Reads packed GUID from this packet // // TODO: Implementation // readPackedGUID: -> // return null // // Writes given GUID to this packet in packed form // // TODO: Implementation // writePackedGUID: (guid) -> // return this } export default GamePacket; ================================================ FILE: src/lib/game/player.js ================================================ import Unit from './unit'; class Player extends Unit { constructor() { super(); this.name = 'Player'; this.hp = this.hp; this.mp = this.mp; this.target = null; this.displayID = 24978; this.mapID = null; } worldport(mapID, x, y, z) { if (!this.mapID || this.mapID !== mapID) { this.mapID = mapID; this.emit('map:change', mapID); } this.position.set(x, y, z); this.emit('position:change', this); } } export default Player; ================================================ FILE: src/lib/game/unit.js ================================================ import THREE from 'three'; import DBC from '../pipeline/dbc'; import Entity from './entity'; import M2Blueprint from '../pipeline/m2/blueprint'; class Unit extends Entity { constructor() { super(); this.name = ''; this.level = '?'; this.target = null; this.maxHp = 0; this.hp = 0; this.maxMp = 0; this.mp = 0; this.rotateSpeed = 2; this.moveSpeed = 40; this._view = new THREE.Group(); this._displayID = 0; this._model = null; } get position() { return this._view.position; } get displayID() { return this._displayID; } set displayID(displayID) { if (!displayID) { return; } DBC.load('CreatureDisplayInfo', displayID).then((displayInfo) => { this._displayID = displayID; this.displayInfo = displayInfo; const { modelID } = displayInfo; DBC.load('CreatureModelData', modelID).then((modelData) => { this.modelData = modelData; this.modelData.path = this.modelData.file.match(/^(.+?)(?:[^\\]+)$/)[1]; this.displayInfo.modelData = this.modelData; M2Blueprint.load(this.modelData.file).then((m2) => { m2.displayInfo = this.displayInfo; this.model = m2; }); }); }); } get view() { return this._view; } get model() { return this._model; } set model(m2) { // TODO: Should this support multiple models? Mounts? if (this._model) { this.view.remove(this._model); } // TODO: Figure out whether this 180 degree rotation is correct m2.rotation.z = Math.PI; m2.updateMatrix(); this.view.add(m2); // Auto-play animation index 0 in unit model, if present // TODO: Properly manage unit animations if (m2.animated && m2.animations.length > 0) { m2.animations.playAnimation(0); m2.animations.playAllSequences(); } this.emit('model:change', this, this._model, m2); this._model = m2; } ascend(delta) { this.view.translateZ(this.moveSpeed * delta); this.emit('position:change', this); } descend(delta) { this.view.translateZ(-this.moveSpeed * delta); this.emit('position:change', this); } moveForward(delta) { this.view.translateX(this.moveSpeed * delta); this.emit('position:change', this); } moveBackward(delta) { this.view.translateX(-this.moveSpeed * delta); this.emit('position:change', this); } rotateLeft(delta) { this.view.rotateZ(this.rotateSpeed * delta); this.emit('position:change', this); } rotateRight(delta) { this.view.rotateZ(-this.rotateSpeed * delta); this.emit('position:change', this); } strafeLeft(delta) { this.view.translateY(this.moveSpeed * delta); this.emit('position:change', this); } strafeRight(delta) { this.view.translateY(-this.moveSpeed * delta); this.emit('position:change', this); } } export default Unit; ================================================ FILE: src/lib/game/world/content-queue.js ================================================ class ContentQueue { constructor(processor, interval = 1, workFactor = 1, minWork = 1) { this.processor = processor; this.interval = interval; this.workFactor = workFactor; this.minWork = minWork; this.queue = new Map(); this.schedule = ::this.schedule; this.run = ::this.run; this.schedule(); } has(key) { return this.queue.has(key); } add(key, job) { if (this.queue.has(key)) { return; } this.queue.set(key, job); } remove(key) { let count = 0; if (this.queue.has(key)) { this.queue.delete(key); count++; } return count; } schedule() { setTimeout(this.run, this.interval); } run() { let count = 0; const max = Math.min(this.queue.size * this.workFactor, this.minWork); for (const entry of this.queue) { const [key, job] = entry; this.processor(job); this.queue.delete(key); count++; if (count > max) { break; } } this.schedule(); } clear() { this.queue.clear(); } } export default ContentQueue; ================================================ FILE: src/lib/game/world/doodad-manager.js ================================================ import M2Blueprint from '../../pipeline/m2/blueprint'; class DoodadManager { // Proportion of pending doodads to load or unload in a given tick. static LOAD_FACTOR = 1 / 40; // Minimum number of pending doodads to load or unload in a given tick. static MINIMUM_LOAD_THRESHOLD = 2; // Number of milliseconds to wait before loading another portion of doodads. static LOAD_INTERVAL = 1; constructor(map) { this.map = map; this.chunkRefs = new Map(); this.doodads = new Map(); this.animatedDoodads = new Map(); this.entriesPendingLoad = new Map(); this.entriesPendingUnload = new Map(); this.loadChunk = ::this.loadChunk; this.unloadChunk = ::this.unloadChunk; this.loadDoodads = ::this.loadDoodads; this.unloadDoodads = ::this.unloadDoodads; // Kick off intervals. this.loadDoodads(); this.unloadDoodads(); } // Process a set of doodad entries for a given chunk index of the world map. loadChunk(index, entries) { for (let i = 0, len = entries.length; i < len; ++i) { const entry = entries[i]; let chunkRefs; // Fetch or create chunk references for entry. if (this.chunkRefs.has(entry.id)) { chunkRefs = this.chunkRefs.get(entry.id); } else { chunkRefs = new Set(); this.chunkRefs.set(entry.id, chunkRefs); } // Add chunk reference to entry. chunkRefs.add(index); // If the doodad is pending unload, remove the pending unload. if (this.entriesPendingUnload.has(entry.id)) { this.entriesPendingUnload.delete(entry.id); } // Add to pending loads. Actual loading is done by interval. this.entriesPendingLoad.set(entry.id, entry); } } unloadChunk(index, entries) { for (let i = 0, len = entries.length; i < len; ++i) { const entry = entries[i]; const chunkRefs = this.chunkRefs.get(entry.id); // Remove chunk reference for entry. chunkRefs.delete(index); // If at least one chunk reference remains for entry, leave loaded. Typically happens in // cases where a doodad is shared across multiple chunks. if (chunkRefs.size > 0) { continue; } // No chunk references remain, so we should remove from pending loads if necessary. if (this.entriesPendingLoad.has(entry.id)) { this.entriesPendingLoad.delete(entry.id); } // Add to pending unloads. Actual unloading is done by interval. this.entriesPendingUnload.set(entry.id, entry); } } // Every tick of the load interval, load a portion of any doodads pending load. loadDoodads() { let count = 0; for (const entry of this.entriesPendingLoad.values()) { if (this.doodads.has(entry.id)) { this.entriesPendingLoad.delete(entry.id); continue; } this.loadDoodad(entry); this.entriesPendingLoad.delete(entry.id); ++count; const shouldYield = count >= this.constructor.MINIMUM_LOAD_THRESHOLD && count > this.entriesPendingLoad.size * this.constructor.LOAD_FACTOR; if (shouldYield) { setTimeout(this.loadDoodads, this.constructor.LOAD_INTERVAL); return; } } setTimeout(this.loadDoodads, this.constructor.LOAD_INTERVAL); } loadDoodad(entry) { M2Blueprint.load(entry.filename).then((doodad) => { if (this.entriesPendingUnload.has(entry.id)) { return; } doodad.entryID = entry.id; this.doodads.set(entry.id, doodad); this.placeDoodad(doodad, entry.position, entry.rotation, entry.scale); if (doodad.animated) { this.enableDoodadAnimations(entry, doodad); } }); } enableDoodadAnimations(entry, doodad) { // Maintain separate entries for animated doodads to avoid excessive iterations on each // call to animate() during the render loop. this.animatedDoodads.set(entry.id, doodad); // Auto-play animation index 0 in doodad, if animations are present. // TODO: Properly manage doodad animations. if (doodad.animations.length > 0) { doodad.animations.playAnimation(0); doodad.animations.playAllSequences(); } } // Every tick of the load interval, unload a portion of any doodads pending unload. unloadDoodads() { let count = 0; for (const entry of this.entriesPendingUnload.values()) { // If the doodad was already unloaded, remove it from the pending unloads. if (!this.doodads.has(entry.id)) { this.entriesPendingUnload.delete(entry.id); continue; } this.unloadDoodad(entry); this.entriesPendingUnload.delete(entry.id); ++count; const shouldYield = count >= this.constructor.MINIMUM_LOAD_THRESHOLD && count > this.entriesPendingUnload.size * this.constructor.LOAD_FACTOR; if (shouldYield) { setTimeout(this.unloadDoodads, this.constructor.LOAD_INTERVAL); return; } } setTimeout(this.unloadDoodads, this.constructor.LOAD_INTERVAL); return; } unloadDoodad(entry) { const doodad = this.doodads.get(entry.id); this.doodads.delete(entry.id); this.animatedDoodads.delete(entry.id); this.map.remove(doodad); M2Blueprint.unload(doodad); } // Place a doodad on the world map, adhereing to a provided position, rotation, and scale. placeDoodad(doodad, position, rotation, scale) { doodad.position.set( -(position.z - this.map.constructor.ZEROPOINT), -(position.x - this.map.constructor.ZEROPOINT), position.y ); // Provided as (Z, X, -Y) doodad.rotation.set( rotation.z * Math.PI / 180, rotation.x * Math.PI / 180, -rotation.y * Math.PI / 180 ); // Adjust doodad rotation to match Wowser's axes. const quat = doodad.quaternion; quat.set(quat.x, quat.y, quat.z, -quat.w); if (scale !== 1024) { const scaleFloat = scale / 1024; doodad.scale.set(scaleFloat, scaleFloat, scaleFloat); } // Add doodad to world map. this.map.add(doodad); doodad.updateMatrix(); } animate(delta, camera, cameraMoved) { this.animatedDoodads.forEach((doodad) => { if (!doodad.visible) { return; } if (doodad.receivesAnimationUpdates && doodad.animations.length > 0) { doodad.animations.update(delta); } if (cameraMoved && doodad.billboards.length > 0) { doodad.applyBillboards(camera); } if (doodad.skeletonHelper) { doodad.skeletonHelper.update(); } }); } } export default DoodadManager; ================================================ FILE: src/lib/game/world/handler.js ================================================ import EventEmitter from 'events'; import THREE from 'three'; import M2Blueprint from '../../pipeline/m2/blueprint'; import WorldMap from './map'; class WorldHandler extends EventEmitter { constructor(session) { super(); this.session = session; this.player = this.session.player; this.scene = new THREE.Scene(); this.scene.matrixAutoUpdate = false; this.map = null; this.changeMap = ::this.changeMap; this.changeModel = ::this.changeModel; this.changePosition = ::this.changePosition; this.entities = new Set(); this.add(this.player); this.player.on('map:change', this.changeMap); this.player.on('position:change', this.changePosition); // Darkshire (Eastern Kingdoms) this.player.worldport(0, -10559, -1189, 28); // Booty Bay (Eastern Kingdoms) // this.player.worldport(0, -14354, 518, 22); // Stonewrought Dam (Eastern Kingdoms) // this.player.worldport(0, -4651, -3316, 296); // Ironforge (Eastern Kingdoms) // this.player.worldport(0, -4981.25, -881.542, 502.66); // Darnassus (Kalimdor) // this.player.worldport(1, 9947, 2557, 1316); // Astranaar (Kalimdor) // this.player.worldport(1, 2752, -348, 107); // Moonglade (Kalimdor) // this.player.worldport(1, 7827, -2425, 489); // Un'Goro Crater (Kalimdor) // this.player.worldport(1, -7183, -1394, -183); // Everlook (Kalimdor) // this.player.worldport(1, 6721.44, -4659.09, 721.893); // Stonetalon Mountains (Kalimdor) // this.player.worldport(1, 2506.3, 1470.14, 263.722); // Mulgore (Kalimdor) // this.player.worldport(1, -1828.913, -426.307, 6.299); // Thunderbluff (Kalimdor) // this.player.worldport(1, -1315.901, 138.6357, 302.008); // Auberdine (Kalimdor) // this.player.worldport(1, 6355.151, 508.831, 15.859); // The Exodar (Expansion 01) // this.player.worldport(530, -4013, -11894, -2); // Nagrand (Expansion 01) // this.player.worldport(530, -743.149, 8385.114, 33.435); // Eversong Woods (Expansion 01) // this.player.worldport(530, 9152.441, -7442.229, 68.144); // Daggercap Bay (Northrend) // this.player.worldport(571, 1031, -5192, 180); // Dalaran (Northrend) // this.player.worldport(571, 5797, 629, 647); } add(entity) { this.entities.add(entity); if (entity.view) { this.scene.add(entity.view); entity.on('model:change', this.changeModel); } } remove(entity) { this.entity.delete(entity); if (entity.view) { this.scene.remove(entity.view); entity.removeListener('model:change', this.changeModel); } } renderAtCoords(x, y) { if (!this.map) { return; } this.map.render(x, y); } changeMap(mapID) { WorldMap.load(mapID).then((map) => { if (this.map) { this.scene.remove(this.map); } this.map = map; this.scene.add(this.map); this.renderAtCoords(this.player.position.x, this.player.position.y); }); } changeModel(_unit, _oldModel, _newModel) { } changePosition(player) { this.renderAtCoords(player.position.x, player.position.y); } animate(delta, camera, cameraMoved) { this.animateEntities(delta, camera, cameraMoved); if (this.map !== null) { this.map.animate(delta, camera, cameraMoved); } // Send delta updates to instanced M2 animation managers. M2Blueprint.animate(delta); } animateEntities(delta, camera, cameraMoved) { this.entities.forEach((entity) => { const { model } = entity; if (model === null || !model.animated) { return; } if (model.receivesAnimationUpdates && model.animations.length > 0) { model.animations.update(delta); } if (cameraMoved && model.billboards.length > 0) { model.applyBillboards(camera); } if (model.skeletonHelper) { model.skeletonHelper.update(); } }); } } export default WorldHandler; ================================================ FILE: src/lib/game/world/map.js ================================================ import THREE from 'three'; import ADT from '../../pipeline/adt'; import Chunk from '../../pipeline/adt/chunk'; import DBC from '../../pipeline/dbc'; import WDT from '../../pipeline/wdt'; import DoodadManager from './doodad-manager'; import WMOManager from './wmo-manager'; import TerrainManager from './terrain-manager'; class WorldMap extends THREE.Group { static ZEROPOINT = ADT.SIZE * 32; static CHUNKS_PER_ROW = 64 * 16; // Controls when ADT chunks are loaded and unloaded from the map. static CHUNK_RENDER_RADIUS = 12; constructor(data, wdt) { super(); this.matrixAutoUpdate = false; this.terrainManager = new TerrainManager(this); this.doodadManager = new DoodadManager(this); this.wmoManager = new WMOManager(this); this.data = data; this.wdt = wdt; this.mapID = this.data.id; this.chunkX = null; this.chunkY = null; this.queuedChunks = new Map(); this.chunks = new Map(); } get internalName() { return this.data.internalName; } render(x, y) { const chunkX = Chunk.chunkFor(x); const chunkY = Chunk.chunkFor(y); if (this.chunkX === chunkX && this.chunkY === chunkY) { return; } this.chunkX = chunkX; this.chunkY = chunkY; const radius = this.constructor.CHUNK_RENDER_RADIUS; const indices = this.chunkIndicesAround(chunkX, chunkY, radius); indices.forEach((index) => { this.loadChunkByIndex(index); }); this.chunks.forEach((_chunk, index) => { if (indices.indexOf(index) === -1) { this.unloadChunkByIndex(index); } }); } chunkIndicesAround(chunkX, chunkY, radius) { const perRow = this.constructor.CHUNKS_PER_ROW; const base = this.indexFor(chunkX, chunkY); const indices = []; for (let y = -radius; y <= radius; ++y) { for (let x = -radius; x <= radius; ++x) { indices.push(base + y * perRow + x); } } return indices; } loadChunkByIndex(index) { if (this.queuedChunks.has(index)) { return; } const perRow = this.constructor.CHUNKS_PER_ROW; const chunkX = (index / perRow) | 0; const chunkY = index % perRow; this.queuedChunks.set(index, Chunk.load(this, chunkX, chunkY).then((chunk) => { this.chunks.set(index, chunk); this.terrainManager.loadChunk(index, chunk); this.doodadManager.loadChunk(index, chunk.doodadEntries); this.wmoManager.loadChunk(index, chunk.wmoEntries); })); } unloadChunkByIndex(index) { const chunk = this.chunks.get(index); if (!chunk) { return; } this.terrainManager.unloadChunk(index, chunk); this.doodadManager.unloadChunk(index, chunk.doodadEntries); this.wmoManager.unloadChunk(index, chunk.wmoEntries); this.queuedChunks.delete(index); this.chunks.delete(index); } indexFor(chunkX, chunkY) { return chunkX * 64 * 16 + chunkY; } animate(delta, camera, cameraMoved) { this.doodadManager.animate(delta, camera, cameraMoved); this.wmoManager.animate(delta, camera, cameraMoved); } static load(id) { return DBC.load('Map', id).then((data) => { const { internalName: name } = data; return WDT.load(`World\\Maps\\${name}\\${name}.wdt`).then((wdt) => { return new this(data, wdt); }); }); } } export default WorldMap; ================================================ FILE: src/lib/game/world/terrain-manager.js ================================================ class TerrainManager { constructor(map) { this.map = map; } loadChunk(_index, terrain) { this.map.add(terrain); terrain.updateMatrix(); } unloadChunk(_index, terrain) { this.map.remove(terrain); terrain.dispose(); } } export default TerrainManager; ================================================ FILE: src/lib/game/world/wmo-manager/index.js ================================================ import ContentQueue from '../content-queue'; import WMOHandler from './wmo-handler'; import WMOBlueprint from '../../../pipeline/wmo/blueprint'; class WMOManager { static LOAD_ENTRY_INTERVAL = 1; static LOAD_ENTRY_WORK_FACTOR = 1 / 10; static LOAD_ENTRY_WORK_MIN = 2; static UNLOAD_DELAY_INTERVAL = 30000; constructor(map) { this.map = map; this.chunkRefs = new Map(); this.counters = { loadingEntries: 0, loadedEntries: 0, loadingGroups: 0, loadedGroups: 0, loadingDoodads: 0, loadedDoodads: 0, animatedDoodads: 0 }; this.entries = new Map(); this.queues = { loadEntry: new ContentQueue( ::this.processLoadEntry, this.constructor.LOAD_ENTRY_INTERVAL, this.constructor.LOAD_ENTRY_WORK_FACTOR, this.constructor.LOAD_ENTRY_WORK_MIN ) }; } loadChunk(chunkIndex, wmoEntries) { for (let i = 0, len = wmoEntries.length; i < len; ++i) { const wmoEntry = wmoEntries[i]; this.addChunkRef(chunkIndex, wmoEntry); this.cancelUnloadEntry(wmoEntry); this.enqueueLoadEntry(wmoEntry); } } unloadChunk(chunkIndex, wmoEntries) { for (let i = 0, len = wmoEntries.length; i < len; ++i) { const wmoEntry = wmoEntries[i]; const refCount = this.removeChunkRef(chunkIndex, wmoEntry); // Still has a chunk reference; don't queue for unload. if (refCount > 0) { continue; } this.dequeueLoadEntry(wmoEntry); this.scheduleUnloadEntry(wmoEntry); } } addChunkRef(chunkIndex, wmoEntry) { let chunkRefs; // Fetch or create chunk references for entry. if (this.chunkRefs.has(wmoEntry.id)) { chunkRefs = this.chunkRefs.get(wmoEntry.id); } else { chunkRefs = new Set(); this.chunkRefs.set(wmoEntry.id, chunkRefs); } // Add chunk reference to entry. chunkRefs.add(chunkIndex); const refCount = chunkRefs.size; return refCount; } removeChunkRef(chunkIndex, wmoEntry) { const chunkRefs = this.chunkRefs.get(wmoEntry.id); // Remove chunk reference for entry. chunkRefs.delete(chunkIndex); const refCount = chunkRefs.size; if (chunkRefs.size === 0) { this.chunkRefs.delete(wmoEntry.id); } return refCount; } enqueueLoadEntry(wmoEntry) { const key = wmoEntry.id; // Already loading or loaded. if (this.queues.loadEntry.has(key) || this.entries.has(key)) { return; } this.queues.loadEntry.add(key, wmoEntry); this.counters.loadingEntries++; } dequeueLoadEntry(wmoEntry) { const key = wmoEntry.key; // Not loading. if (!this.queues.loadEntry.has(key)) { return; } this.queues.loadEntry.remove(key); this.counters.loadingEntries--; } scheduleUnloadEntry(wmoEntry) { const wmoHandler = this.entries.get(wmoEntry.id); if (!wmoHandler) { return; } wmoHandler.scheduleUnload(this.constructor.UNLOAD_DELAY_INTERVAL); } cancelUnloadEntry(wmoEntry) { const wmoHandler = this.entries.get(wmoEntry.id); if (!wmoHandler) { return; } wmoHandler.cancelUnload(); } processLoadEntry(wmoEntry) { const wmoHandler = new WMOHandler(this, wmoEntry); this.entries.set(wmoEntry.id, wmoHandler); WMOBlueprint.load(wmoEntry.filename).then((wmoRoot) => { wmoHandler.load(wmoRoot); this.counters.loadingEntries--; this.counters.loadedEntries++; }); } animate(delta, camera, cameraMoved) { this.entries.forEach((wmoHandler) => { wmoHandler.animate(delta, camera, cameraMoved); }); } } export default WMOManager; ================================================ FILE: src/lib/game/world/wmo-manager/wmo-handler.js ================================================ import ContentQueue from '../content-queue'; import WMOBlueprint from '../../../pipeline/wmo/blueprint'; import WMOGroupBlueprint from '../../../pipeline/wmo/group/blueprint'; import M2Blueprint from '../../../pipeline/m2/blueprint'; class WMOHandler { static LOAD_GROUP_INTERVAL = 1; static LOAD_GROUP_WORK_FACTOR = 1 / 10; static LOAD_GROUP_WORK_MIN = 2; static LOAD_DOODAD_INTERVAL = 1; static LOAD_DOODAD_WORK_FACTOR = 1 / 20; static LOAD_DOODAD_WORK_MIN = 2; constructor(manager, entry) { this.manager = manager; this.entry = entry; this.root = null; this.groups = new Map(); this.doodads = new Map(); this.animatedDoodads = new Map(); this.doodadSet = []; this.doodadRefs = new Map(); this.counters = { loadingGroups: 0, loadingDoodads: 0, loadedGroups: 0, loadedDoodads: 0, animatedDoodads: 0 }; this.queues = { loadGroup: new ContentQueue( ::this.processLoadGroup, this.constructor.LOAD_GROUP_INTERVAL, this.constructor.LOAD_GROUP_WORK_FACTOR, this.constructor.LOAD_GROUP_WORK_MIN ), loadDoodad: new ContentQueue( ::this.processLoadDoodad, this.constructor.LOAD_DOODAD_INTERVAL, this.constructor.LOAD_DOODAD_WORK_FACTOR, this.constructor.LOAD_DOODAD_WORK_MIN ) }; this.pendingUnload = null; this.unloading = false; } load(wmoRoot) { this.root = wmoRoot; this.doodadSet = this.root.doodadSet(this.entry.doodadSet); this.placeRoot(); this.enqueueLoadGroups(); } enqueueLoadGroups() { const outdoorGroupIDs = this.root.outdoorGroupIDs; const indoorGroupIDs = this.root.indoorGroupIDs; for (let ogi = 0, oglen = outdoorGroupIDs.length; ogi < oglen; ++ogi) { const wmoGroupID = outdoorGroupIDs[ogi]; this.enqueueLoadGroup(wmoGroupID); } for (let igi = 0, iglen = indoorGroupIDs.length; igi < iglen; ++igi) { const wmoGroupID = indoorGroupIDs[igi]; this.enqueueLoadGroup(wmoGroupID); } } enqueueLoadGroup(wmoGroupID) { // Already loaded. if (this.groups.has(wmoGroupID)) { return; } this.queues.loadGroup.add(wmoGroupID, wmoGroupID); this.manager.counters.loadingGroups++; this.counters.loadingGroups++; } processLoadGroup(wmoGroupID) { // Already loaded. if (this.groups.has(wmoGroupID)) { this.manager.counters.loadingGroups--; this.counters.loadingGroups--; return; } WMOGroupBlueprint.loadWithID(this.root, wmoGroupID).then((wmoGroup) => { if (this.unloading) { return; } this.loadGroup(wmoGroupID, wmoGroup); this.manager.counters.loadingGroups--; this.counters.loadingGroups--; this.manager.counters.loadedGroups++; this.counters.loadedGroups++; }); } loadGroup(wmoGroupID, wmoGroup) { this.placeGroup(wmoGroup); this.groups.set(wmoGroupID, wmoGroup); if (wmoGroup.data.MODR) { this.enqueueLoadGroupDoodads(wmoGroup); } } enqueueLoadGroupDoodads(wmoGroup) { wmoGroup.data.MODR.doodadIndices.forEach((doodadIndex) => { const wmoDoodadEntry = this.doodadSet[doodadIndex]; // Since the doodad set is filtered based on the requested set in the entry, not all // doodads referenced by a group will be present. if (!wmoDoodadEntry) { return; } // Assign the index as an id property on the entry. wmoDoodadEntry.id = doodadIndex; const refCount = this.addDoodadRef(wmoDoodadEntry, wmoGroup); // Only enqueue load on the first reference, since it'll already have been enqueued on // subsequent references. if (refCount === 1) { this.enqueueLoadDoodad(wmoDoodadEntry); } }); } enqueueLoadDoodad(wmoDoodadEntry) { // Already loading or loaded. if (this.queues.loadDoodad.has(wmoDoodadEntry.id) || this.doodads.has(wmoDoodadEntry.id)) { return; } this.queues.loadDoodad.add(wmoDoodadEntry.id, wmoDoodadEntry); this.manager.counters.loadingDoodads++; this.counters.loadingDoodads++; } processLoadDoodad(wmoDoodadEntry) { // Already loaded. if (this.doodads.has(wmoDoodadEntry.id)) { this.manager.counters.loadingDoodads--; this.counters.loadingDoodads--; return; } M2Blueprint.load(wmoDoodadEntry.filename).then((wmoDoodad) => { if (this.unloading) { return; } this.loadDoodad(wmoDoodadEntry, wmoDoodad); this.manager.counters.loadingDoodads--; this.counters.loadingDoodads--; this.manager.counters.loadedDoodads++; this.counters.loadedDoodads++; if (wmoDoodad.animated) { this.manager.counters.animatedDoodads++; this.counters.animatedDoodads++; } }); } loadDoodad(wmoDoodadEntry, wmoDoodad) { wmoDoodad.entryID = wmoDoodadEntry.id; this.placeDoodad(wmoDoodadEntry, wmoDoodad); if (wmoDoodad.animated) { this.animatedDoodads.set(wmoDoodadEntry.id, wmoDoodad); if (wmoDoodad.animations.length > 0) { // TODO: Do WMO doodads have more than one animation? If so, which one should play? wmoDoodad.animations.playAnimation(0); wmoDoodad.animations.playAllSequences(); } } this.doodads.set(wmoDoodadEntry.id, wmoDoodad); } scheduleUnload(unloadDelay = 0) { this.pendingUnload = setTimeout(::this.unload, unloadDelay); } cancelUnload() { if (this.pendingUnload) { clearTimeout(this.pendingUnload); } } unload() { this.unloading = true; this.manager.entries.delete(this.entry.id); this.manager.counters.loadedEntries--; this.queues.loadGroup.clear(); this.queues.loadDoodad.clear(); this.manager.counters.loadingGroups -= this.counters.loadingGroups; this.manager.counters.loadedGroups -= this.counters.loadedGroups; this.manager.counters.loadingDoodads -= this.counters.loadingDoodads; this.manager.counters.loadedDoodads -= this.counters.loadedDoodads; this.manager.counters.animatedDoodads -= this.counters.animatedDoodads; this.counters.loadingGroups = 0; this.counters.loadedGroups = 0; this.counters.loadingDoodads = 0; this.counters.loadedDoodads = 0; this.counters.animatedDoodads = 0; this.manager.map.remove(this.root); for (const wmoGroup of this.groups.values()) { this.root.remove(wmoGroup); WMOGroupBlueprint.unload(wmoGroup); } for (const wmoDoodad of this.doodads.values()) { this.root.remove(wmoDoodad); M2Blueprint.unload(wmoDoodad); } WMOBlueprint.unload(this.root); this.groups = new Map(); this.doodads = new Map(); this.animatedDoodads = new Map(); this.doodadRefs = new Map(); this.root = null; this.entry = null; } placeRoot() { const { position, rotation } = this.entry; this.root.position.set( -(position.z - this.manager.map.constructor.ZEROPOINT), -(position.x - this.manager.map.constructor.ZEROPOINT), position.y ); // Provided as (Z, X, -Y) this.root.rotation.set( rotation.z * Math.PI / 180, rotation.x * Math.PI / 180, -rotation.y * Math.PI / 180 ); // Adjust WMO rotation to match Wowser's axes. const quat = this.root.quaternion; quat.set(quat.x, quat.y, quat.z, -quat.w); this.manager.map.add(this.root); this.root.updateMatrix(); } placeGroup(wmoGroup) { this.root.add(wmoGroup); wmoGroup.updateMatrix(); } placeDoodad(wmoDoodadEntry, wmoDoodad) { const { position, rotation, scale } = wmoDoodadEntry; wmoDoodad.position.set(-position.x, -position.y, position.z); // Adjust doodad rotation to match Wowser's axes. const quat = wmoDoodad.quaternion; quat.set(rotation.x, rotation.y, -rotation.z, -rotation.w); wmoDoodad.scale.set(scale, scale, scale); this.root.add(wmoDoodad); wmoDoodad.updateMatrix(); } addDoodadRef(wmoDoodadEntry, wmoGroup) { const key = wmoDoodadEntry.id; let doodadRefs; // Fetch or create group references for doodad. if (this.doodadRefs.has(key)) { doodadRefs = this.doodadRefs.get(key); } else { doodadRefs = new Set(); this.doodadRefs.set(key, doodadRefs); } // Add group reference to doodad. doodadRefs.add(wmoGroup.groupID); const refCount = doodadRefs.size; return refCount; } removeDoodadRef(wmoDoodadEntry, wmoGroup) { const key = wmoDoodadEntry.id; const doodadRefs = this.doodadRefs.get(key); if (!doodadRefs) { return 0; } // Remove group reference for doodad. doodadRefs.delete(wmoGroup.groupID); const refCount = doodadRefs.size; if (doodadRefs.size === 0) { this.doodadRefs.delete(key); } return refCount; } groupsForDoodad(wmoDoodad) { const wmoGroupIDs = this.doodadRefs.get(wmoDoodad.entryID); const wmoGroups = []; for (const wmoGroupID of wmoGroupIDs) { const wmoGroup = this.groups.get(wmoGroupID); if (wmoGroup) { wmoGroups.push(wmoGroup); } } return wmoGroups; } doodadsForGroup(wmoGroup) { const wmoDoodads = []; for (const refs of this.doodadRefs) { const [wmoDoodadEntryID, wmoGroupIDs] = refs; if (wmoGroupIDs.has(wmoGroup.groupID)) { const wmoDoodad = this.doodads.get(wmoDoodadEntryID); if (wmoDoodad) { wmoDoodads.push(wmoDoodad); } } } return wmoDoodads; } animate(delta, camera, cameraMoved) { for (const wmoDoodad of this.animatedDoodads.values()) { if (!wmoDoodad.visible) { continue; } if (wmoDoodad.receivesAnimationUpdates && wmoDoodad.animations.length > 0) { wmoDoodad.animations.update(delta); } if (cameraMoved && wmoDoodad.billboards.length > 0) { wmoDoodad.applyBillboards(camera); } if (wmoDoodad.skeletonHelper) { wmoDoodad.skeletonHelper.update(); } } } } export default WMOHandler; ================================================ FILE: src/lib/index.js ================================================ import EventEmitter from 'events'; import AuthHandler from './auth/handler'; import CharactersHandler from './characters/handler'; import ChatHandler from './game/chat/handler'; import Config from './config'; import GameHandler from './game/handler'; import Player from './game/player'; import RealmsHandler from './realms/handler'; import WorldHandler from './game/world/handler'; class Client extends EventEmitter { constructor(config) { super(); this.config = config || new Config(); this.auth = new AuthHandler(this); this.realms = new RealmsHandler(this); this.game = new GameHandler(this); this.characters = new CharactersHandler(this); this.chat = new ChatHandler(this); this.player = new Player(); this.world = new WorldHandler(this); } } export default Client; ================================================ FILE: src/lib/net/loader.js ================================================ import Promise from 'bluebird'; class Loader { constructor() { this.prefix = this.prefix || '/pipeline/'; this.responseType = this.responseType || 'arraybuffer'; } load(path) { return new Promise((resolve, _reject) => { const uri = `${this.prefix}${path}`; const xhr = new XMLHttpRequest(); xhr.open('GET', encodeURI(uri), true); xhr.onload = function(_event) { // TODO: Handle failure if (this.status >= 200 && this.status < 400) { resolve(this.response); } }; xhr.responseType = this.responseType; xhr.send(); }); } } export default Loader; ================================================ FILE: src/lib/net/packet.js ================================================ import ByteBuffer from 'byte-buffer'; class Packet extends ByteBuffer { // Creates a new packet with given opcode from given source or length constructor(opcode, source, outgoing = true) { super(source, ByteBuffer.LITTLE_ENDIAN); // Holds the opcode for this packet this.opcode = opcode; // Whether this packet is outgoing or incoming this.outgoing = outgoing; // Seek past opcode to reserve space for it when finalizing this.index = this.headerSize; } // Header size in bytes get headerSize() { return this.constructor.HEADER_SIZE; } // Body size in bytes get bodySize() { return this.length - this.headerSize; } // Retrieves the name of the opcode for this packet (if available) get opcodeName() { return null; } // Short string representation of this packet toString() { const opcode = ('0000' + this.opcode.toString(16).toUpperCase()).slice(-4); return `[${this.constructor.name}; Opcode: ${this.opcodeName || 'UNKNOWN'} (0x${opcode}); Length: ${this.length}; Body: ${this.bodySize}; Index: ${this._index}]`; } // Finalizes this packet finalize() { return this; } } export default Packet; ================================================ FILE: src/lib/net/socket.js ================================================ import ByteBuffer from 'byte-buffer'; import EventEmitter from 'events'; // Base-class for any socket including signals and host/port management class Socket extends EventEmitter { // Maximum buffer capacity // TODO: Arbitrarily chosen, determine this cap properly static BUFFER_CAP = 2048; // Creates a new socket constructor() { super(); // Holds the host, port and uri currently connected to (if any) this.host = null; this.port = NaN; this.uri = null; // Holds the actual socket this.socket = null; // Holds buffered data this.buffer = null; // Holds incoming packet's remaining size in bytes (false if no packet is being handled) this.remaining = false; } // Whether this socket is currently connected get connected() { return this.socket && this.socket.readyState === WebSocket.OPEN; } // Connects to given host through given port (if any; default port is implementation specific) connect(host, port = NaN) { if (!this.connected) { this.host = host; this.port = port; this.uri = 'ws://' + this.host + ':' + this.port; this.buffer = new ByteBuffer(0, ByteBuffer.LITTLE_ENDIAN); this.remaining = false; this.socket = new WebSocket(this.uri, 'binary'); this.socket.binaryType = 'arraybuffer'; this.socket.onopen = (e) => { this.emit('connect', e); }; this.socket.onclose = (e) => { this.emit('disconnect', e); }; this.socket.onmessage = (e) => { const index = this.buffer.index; this.buffer.end().append(e.data.byteLength).write(e.data); this.buffer.index = index; this.emit('data:receive', this); if (this.buffer.available === 0 && this.buffer.length > this.constructor.BUFFER_CAP) { this.buffer.clip(); } }; this.socket.onerror = function(e) { console.error(e); }; } return this; } // Attempts to reconnect to cached host and port reconnect() { if (!this.connected && this.host && this.port) { this.connect(this.host, this.port); } return this; } // Disconnects this socket disconnect() { if (this.connected) { this.socket.close(); } return this; } // Finalizes and sends given packet send(packet) { if (this.connected) { packet.finalize(); console.log('⟸', packet.toString()); // console.debug packet.toHex() // console.debug packet.toASCII() this.socket.send(packet.buffer); this.emit('packet:send', packet); return true; } return false; } } export default Socket; ================================================ FILE: src/lib/pipeline/adt/chunk/index.js ================================================ import THREE from 'three'; import ADT from '../'; import Material from './material'; class Chunk extends THREE.Mesh { static SIZE = 33.33333; static UNIT_SIZE = 33.33333 / 8; constructor(adt, id) { super(); this.matrixAutoUpdate = false; const data = this.data = adt.data.MCNKs[id]; const textureNames = adt.textures; const size = this.constructor.SIZE; const unitSize = this.constructor.UNIT_SIZE; this.position.y = adt.y + -(data.indexX * size); this.position.x = adt.x + -(data.indexY * size); this.holes = data.holes; const vertexCount = data.MCVT.heights.length; const positions = new Float32Array(vertexCount * 3); const normals = new Float32Array(vertexCount * 3); const uvs = new Float32Array(vertexCount * 2); const uvsAlpha = new Float32Array(vertexCount * 2); // See: http://www.pxr.dk/wowdev/wiki/index.php?title=ADT#MCVT_sub-chunk data.MCVT.heights.forEach(function(height, index) { let y = Math.floor(index / 17); let x = index % 17; if (x > 8) { y += 0.5; x -= 8.5; } // Mirror geometry over X and Y axes positions[index * 3] = -(y * unitSize); positions[index * 3 + 1] = -(x * unitSize); positions[index * 3 + 2] = data.position.z + height; uvs[index * 2] = x; uvs[index * 2 + 1] = y; uvsAlpha[index * 2] = x / 8; uvsAlpha[index * 2 + 1] = y / 8; }); data.MCNR.normals.forEach(function(normal, index) { normals[index * 3] = normal.x; normals[index * 3 + 1] = normal.z; normals[index * 3 + 2] = normal.y; }); const indices = new Uint32Array(8 * 8 * 4 * 3); let faceIndex = 0; const addFace = (index1, index2, index3) => { indices[faceIndex * 3] = index1; indices[faceIndex * 3 + 1] = index2; indices[faceIndex * 3 + 2] = index3; faceIndex++; }; for (let y = 0; y < 8; ++y) { for (let x = 0; x < 8; ++x) { if (!this.isHole(y, x)) { const index = 9 + y * 17 + x; addFace(index, index - 9, index - 8); addFace(index, index - 8, index + 9); addFace(index, index + 9, index + 8); addFace(index, index + 8, index - 9); } } } const geometry = this.geometry = new THREE.BufferGeometry(); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); geometry.addAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.addAttribute('normal', new THREE.BufferAttribute(normals, 3)); geometry.addAttribute('uv', new THREE.BufferAttribute(uvs, 2)); geometry.addAttribute('uvAlpha', new THREE.BufferAttribute(uvsAlpha, 2)); this.material = new Material(data, textureNames); } get doodadEntries() { return this.data.MCRF.doodadEntries; } get wmoEntries() { return this.data.MCRF.wmoEntries; } isHole(y, x) { const column = Math.floor(y / 2); const row = Math.floor(x / 2); const bit = 1 << (column * 4 + row); return bit & this.holes; } dispose() { this.geometry.dispose(); this.material.dispose(); } static chunkFor(position) { return 32 * 16 - (position / this.SIZE) | 0; } static tileFor(chunk) { return (chunk / 16) | 0; } static load(map, chunkX, chunkY) { const tileX = this.tileFor(chunkX); const tileY = this.tileFor(chunkY); const offsetX = chunkX - tileX * 16; const offsetY = chunkY - tileY * 16; const id = offsetX * 16 + offsetY; return ADT.loadTile(map.internalName, tileX, tileY, map.wdt.data.flags).then((adt) => { return new this(adt, id); }); } } export default Chunk; ================================================ FILE: src/lib/pipeline/adt/chunk/material.js ================================================ import THREE from 'three'; import TextureLoader from '../../texture-loader'; import fragmentShader from './shader.frag'; import vertexShader from './shader.vert'; class Material extends THREE.ShaderMaterial { constructor(data, textureNames) { super(); this.layers = data.MCLY.layers; this.rawAlphaMaps = data.MCAL.alphaMaps; this.textureNames = textureNames; this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; this.side = THREE.BackSide; this.layerCount = 0; this.textures = []; this.alphaMaps = []; this.loadLayers(); this.uniforms = { layerCount: { type: 'i', value: this.layerCount }, alphaMaps: { type: 'tv', value: this.alphaMaps }, textures: { type: 'tv', value: this.textures }, // Managed by light manager lightModifier: { type: 'f', value: '1.0' }, ambientLight: { type: 'c', value: new THREE.Color(0.5, 0.5, 0.5) }, diffuseLight: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, // Managed by light manager fogModifier: { type: 'f', value: '1.0' }, fogColor: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, fogStart: { type: 'f', value: 5.0 }, fogEnd: { type: 'f', value: 400.0 } }; } loadLayers() { this.layerCount = this.layers.length; this.loadAlphaMaps(); this.loadTextures(); } loadAlphaMaps() { const alphaMaps = []; this.rawAlphaMaps.forEach((raw) => { const texture = new THREE.DataTexture(raw, 64, 64); texture.format = THREE.LuminanceFormat; texture.minFilter = texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; alphaMaps.push(texture); }); // Texture array uniforms must have at least one value present to be considered valid. if (alphaMaps.length === 0) { alphaMaps.push(new THREE.Texture()); } this.alphaMaps = alphaMaps; } loadTextures() { const textures = []; this.layers.forEach((layer) => { const filename = this.textureNames[layer.textureID]; const texture = TextureLoader.load(filename); textures.push(texture); }); this.textures = textures; } dispose() { super.dispose(); this.textures.forEach((texture) => { TextureLoader.unload(texture); }); this.alphaMaps.forEach((alphaMap) => { alphaMap.dispose(); }); } } export default Material; ================================================ FILE: src/lib/pipeline/adt/chunk/shader.frag ================================================ uniform int layerCount; uniform sampler2D alphaMaps[4]; uniform sampler2D textures[4]; varying vec2 vUv; varying vec2 vUvAlpha; varying vec3 vertexNormal; varying float cameraDistance; uniform float lightModifier; uniform vec3 ambientLight; uniform vec3 diffuseLight; uniform float fogModifier; uniform float fogStart; uniform float fogEnd; uniform vec3 fogColor; vec4 applyFog(vec4 color) { float fogFactor = (fogEnd - cameraDistance) / (fogEnd - fogStart); fogFactor = fogFactor * fogModifier; fogFactor = clamp(fogFactor, 0.0, 1.0); color.rgb = mix(fogColor.rgb, color.rgb, fogFactor); // Ensure alpha channel is gone once a sufficient distance into the fog is reached. if (cameraDistance > fogEnd * 1.5) { color.a = 1.0; } return color; } vec4 finalizeColor(vec4 color) { if (fogModifier > 0.0) { color = applyFog(color); } return color; } // Given a light direction and normal, return a directed diffuse light. vec3 getDirectedDiffuseLight(vec3 lightDirection, vec3 lightNormal, vec3 diffuseLight) { float light = dot(lightNormal, -lightDirection); if (light < 0.0) { light = 0.0; } else if (light > 0.5) { light = 0.5 + ((light - 0.5) * 0.65); } vec3 directedDiffuseLight = diffuseLight.rgb * light; return directedDiffuseLight; } // Given a layer, light it with diffuse and ambient light. vec4 lightLayer(vec4 color, vec3 diffuse, vec3 ambient) { if (lightModifier > 0.0) { color.rgb *= diffuse + ambient; color.rgb = saturate(color.rgb); } return color; } // Given a color, light it, and blend it with a layer. vec4 lightAndBlendLayer(vec4 color, vec4 layer, vec4 blend, vec3 diffuse, vec3 ambient) { layer = lightLayer(layer, diffuse, ambient); color = (layer * blend) + ((1.0 - blend) * color); return color; } void main() { vec3 lightDirection = normalize(vec3(-1, -1, -1)); vec3 lightNormal = normalize(vertexNormal); vec3 directedDiffuseLight = getDirectedDiffuseLight(lightDirection, lightNormal, diffuseLight); vec4 layer; vec4 blend; // Base layer vec4 color = texture2D(textures[0], vUv); color = lightLayer(color, directedDiffuseLight, ambientLight); // 2nd layer if (layerCount > 1) { layer = texture2D(textures[1], vUv); blend = texture2D(alphaMaps[0], vUvAlpha); color = lightAndBlendLayer(color, layer, blend, directedDiffuseLight, ambientLight); } // 3rd layer if (layerCount > 2) { layer = texture2D(textures[2], vUv); blend = texture2D(alphaMaps[1], vUvAlpha); color = lightAndBlendLayer(color, layer, blend, directedDiffuseLight, ambientLight); } // 4th layer if (layerCount > 3) { layer = texture2D(textures[3], vUv); blend = texture2D(alphaMaps[2], vUvAlpha); color = lightAndBlendLayer(color, layer, blend, directedDiffuseLight, ambientLight); } color = finalizeColor(color); gl_FragColor = color; } ================================================ FILE: src/lib/pipeline/adt/chunk/shader.vert ================================================ precision highp float; attribute vec2 uvAlpha; varying vec2 vUv; varying vec2 vUvAlpha; varying vec3 vertexNormal; varying float cameraDistance; void main() { vUv = uv; vUvAlpha = uvAlpha; // TODO: Potentially necessary for specular lighting vec3 vertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; cameraDistance = distance(cameraPosition, vertexWorldPosition); vertexNormal = vec3(normal); // TODO: Potentially unnecessary for ADT shading // vertexWorldNormal = (modelMatrix * vec4(normal, 0.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ================================================ FILE: src/lib/pipeline/adt/index.js ================================================ import WorkerPool from '../worker/pool'; class ADT { static SIZE = 533.33333; static cache = {}; constructor(path, data) { this.path = path; this.data = data; const tyx = this.path.match(/(\d+)_(\d+)\.adt$/); this.tileX = +tyx[2]; this.tileY = +tyx[1]; this.x = this.constructor.positionFor(this.tileX); this.y = this.constructor.positionFor(this.tileY); } get wmos() { return this.data.MODF.entries; } get doodads() { return this.data.MDDF.entries; } get textures() { return this.data.MTEX.filenames; } static positionFor(tile) { return (32 - tile) * this.SIZE; } static tileFor(position) { return 32 - (position / this.SIZE) | 0; } static loadTile(map, tileX, tileY, wdtFlags) { return ADT.load(`World\\Maps\\${map}\\${map}_${tileY}_${tileX}.adt`, wdtFlags); } static loadAtCoords(map, x, y, wdtFlags) { const tileX = this.tileFor(x); const tileY = this.tileFor(y); return this.loadTile(map, tileX, tileY, wdtFlags); } static load(path, wdtFlags) { if (!(path in this.cache)) { this.cache[path] = WorkerPool.enqueue('ADT', path, wdtFlags).then((args) => { const [data] = args; return new this(path, data); }); } return this.cache[path]; } } export default ADT; ================================================ FILE: src/lib/pipeline/adt/loader.js ================================================ import ADT from 'blizzardry/lib/adt'; import { DecodeStream } from 'blizzardry/lib/restructure'; import Loader from '../../net/loader'; const loader = new Loader(); export default function(path, wdtFlags) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); const data = ADT(wdtFlags).decode(stream); return data; }); } ================================================ FILE: src/lib/pipeline/dbc/index.js ================================================ import WorkerPool from '../worker/pool'; class DBC { static cache = {}; constructor(data) { this.data = data; this.records = data.records; this.index(); } index() { this.records.forEach(function(record) { if (record.id === undefined) { return; } this[record.id] = record; }.bind(this)); } static load(name, id) { if (!(name in this.cache)) { this.cache[name] = WorkerPool.enqueue('DBC', name).then((args) => { const [data] = args; return new this(data); }); } if (id !== undefined) { return this.cache[name].then(function(dbc) { return dbc[id]; }); } return this.cache[name]; } } export default DBC; ================================================ FILE: src/lib/pipeline/dbc/loader.js ================================================ import * as DBC from 'blizzardry/lib/dbc/entities'; import { DecodeStream } from 'blizzardry/lib/restructure'; import Loader from '../../net/loader'; const loader = new Loader(); export default function(name) { const path = `DBFilesClient\\${name}.dbc`; const entity = DBC[name]; return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); const data = entity.dbc.decode(stream); // TODO: This property breaks web worker communication for some reason! delete data.entity; return data; }); } ================================================ FILE: src/lib/pipeline/m2/animation-manager.js ================================================ import EventEmitter from 'events'; import THREE from 'three'; class AnimationManager extends EventEmitter { constructor(root, animationDefs, sequenceDefs) { super(); // Complicated M2s may have far more than 10 (default listener cap) M2Materials subscribed to // the same texture animations. this.setMaxListeners(150); this.animationDefs = animationDefs; this.sequenceDefs = sequenceDefs; this.animationClips = []; this.sequenceClips = []; this.loadedAnimations = {}; this.loadedSequences = {}; this.mixer = new THREE.AnimationMixer(root); // M2 animations are keyframed in milliseconds. this.mixer.timeScale = 1000.0; this.registerAnimationClips(this.animationDefs); this.registerSequenceClips(this.sequenceDefs); this.length = this.animationClips.length + this.sequenceClips.length; } update(delta) { this.mixer.update(delta); this.emit('update'); } loadAnimation(animationIndex) { // The animation is already loaded. if (typeof this.loadedAnimations[animationIndex] !== 'undefined') { return this.loadedAnimations[animationIndex]; } const clip = this.animationClips[animationIndex]; const action = this.mixer.clipAction(clip); this.loadedAnimations[animationIndex] = action; return action; } unloadAnimation(animationIndex) { // The animation isn't loaded. if (typeof this.loadedAnimations[animationIndex] === 'undefined') { return; } const clip = this.animationClips[animationIndex]; this.mixer.uncacheClip(clip); delete this.loadedAnimations[animationIndex]; return; } playAnimation(animationIndex) { const action = this.loadAnimation(animationIndex); action.play(); } stopAnimation(animationIndex) { // The animation isn't loaded. if (typeof this.loadedAnimations[animationIndex] === 'undefined') { return; } const action = this.loadAnimation(animationIndex); action.stop(); } loadSequence(sequenceIndex) { // The sequence is already loaded. if (typeof this.loadedSequences[sequenceIndex] !== 'undefined') { return this.loadedSequences[sequenceIndex]; } const clip = this.sequenceClips[sequenceIndex]; const action = this.mixer.clipAction(clip); this.loadedSequences[sequenceIndex] = action; return action; } unloadSequence(sequenceIndex) { // The sequence isn't loaded. if (typeof this.loadedSquences[sequenceIndex] === 'undefined') { return; } const clip = this.sequenceClips[sequenceIndex]; this.mixer.uncacheClip(clip); delete this.loadedSequences[sequenceIndex]; return; } playSequence(sequenceIndex) { const action = this.loadSequence(sequenceIndex); action.play(); } playAllSequences() { this.sequenceDefs.forEach((_sequenceDuration, index) => { this.playSequence(index); }); } stopSequence(sequenceIndex) { // The sequence isn't loaded. if (typeof this.loadedSequences[sequenceIndex] === 'undefined') { return; } const action = this.loadSequence(sequenceIndex); action.stop(); } registerAnimationClips(animationDefs) { animationDefs.forEach((animationDef, index) => { const clip = new THREE.AnimationClip('animation-' + index, animationDef.length, []); this.animationClips[index] = clip; }); } registerSequenceClips(sequenceDefs) { sequenceDefs.forEach((sequenceDuration, index) => { const clip = new THREE.AnimationClip('sequence-' + index, sequenceDuration, []); this.sequenceClips[index] = clip; }); } unregisterTrack(trackID) { this.animationClips.forEach((clip) => { clip.tracks = clip.tracks.filter((track) => { return track.name !== trackID; }); clip.trim(); clip.optimize(); }); this.sequenceClips.forEach((clip) => { clip.tracks = clip.tracks.filter((track) => { return track.name !== trackID; }); clip.trim(); clip.optimize(); }); } registerTrack(opts) { let trackID; if (opts.animationBlock.globalSequenceID > -1) { trackID = this.registerSequenceTrack(opts); } else { trackID = this.registerAnimationTrack(opts); } return trackID; } registerAnimationTrack(opts) { const trackName = opts.target.uuid + '.' + opts.property; const animationBlock = opts.animationBlock; const { valueTransform } = opts; animationBlock.tracks.forEach((trackDef, animationIndex) => { const animationDef = this.animationDefs[animationIndex]; // Avoid creating tracks for external .anim animations. if ((animationDef.flags & 0x130) === 0) { return; } // Avoid creating empty tracks. if (trackDef.timestamps.length === 0) { return; } const timestamps = trackDef.timestamps; const values = []; // Transform values before passing in to track. trackDef.values.forEach((rawValue) => { if (valueTransform) { values.push.apply(values, valueTransform(rawValue)); } else { values.push.apply(values, rawValue); } }); const clip = this.animationClips[animationIndex]; const track = new THREE[opts.trackType](trackName, timestamps, values); clip.tracks.push(track); clip.optimize(); }); return trackName; } registerSequenceTrack(opts) { const trackName = opts.target.uuid + '.' + opts.property; const animationBlock = opts.animationBlock; const { valueTransform } = opts; animationBlock.tracks.forEach((trackDef) => { // Avoid creating empty tracks. if (trackDef.timestamps.length === 0) { return; } const timestamps = trackDef.timestamps; const values = []; // Transform values before passing in to track. trackDef.values.forEach((rawValue) => { if (valueTransform) { values.push.apply(values, valueTransform(rawValue)); } else { values.push.apply(values, rawValue); } }); const track = new THREE[opts.trackType](trackName, timestamps, values); const clip = this.sequenceClips[animationBlock.globalSequenceID]; clip.tracks.push(track); clip.optimize(); }); return trackName; } } export default AnimationManager; ================================================ FILE: src/lib/pipeline/m2/batch-manager.js ================================================ class BatchManager { constructor() { } createDefs(data, skinData) { const defs = []; skinData.batches.forEach((batchData) => { const def = this.createDef(data, batchData); defs.push(def); }); return defs; } createDef(data, batchData) { const def = this.stubDef(); const { textures } = data; const { vertexColorAnimations, transparencyAnimations, uvAnimations } = data; if (!batchData.textureIndices) { this.resolveTextureIndices(data, batchData); } if (!batchData.uvAnimationIndices) { this.resolveUVAnimationIndices(data, batchData); } const { opCount } = batchData; const { textureMappingIndex, materialIndex } = batchData; const { vertexColorAnimationIndex, transparencyAnimationLookup } = batchData; const { textureIndices, uvAnimationIndices } = batchData; // Batch flags def.flags = batchData.flags; // Submesh index and batch layer def.submeshIndex = batchData.submeshIndex; def.layer = batchData.layer; // Op count and shader ID def.opCount = batchData.opCount; def.shaderID = batchData.shaderID; // Texture mapping // -1 => Env; 0 => T1; 1 => T2 if (textureMappingIndex >= 0) { const textureMapping = data.textureMappings[textureMappingIndex]; def.textureMapping = textureMapping; } // Material (render flags and blending mode) const material = data.materials[materialIndex]; def.renderFlags = material.renderFlags; def.blendingMode = material.blendingMode; // Vertex color animation block if (vertexColorAnimationIndex > -1 && vertexColorAnimations[vertexColorAnimationIndex]) { const vertexColorAnimation = vertexColorAnimations[vertexColorAnimationIndex]; def.vertexColorAnimation = vertexColorAnimation; def.vertexColorAnimationIndex = vertexColorAnimationIndex; } // Transparency animation block // TODO: Do we load multiple values based on opCount? const transparencyAnimationIndex = data.transparencyAnimationLookups[transparencyAnimationLookup]; if (transparencyAnimationIndex > -1 && transparencyAnimations[transparencyAnimationIndex]) { const transparencyAnimation = transparencyAnimations[transparencyAnimationIndex]; def.transparencyAnimation = transparencyAnimation; def.transparencyAnimationIndex = transparencyAnimationIndex; } for (let opIndex = 0; opIndex < def.opCount; ++opIndex) { // Texture const textureIndex = textureIndices[opIndex]; const texture = textures[textureIndex]; if (texture) { def.textures[opIndex] = texture; def.textureIndices[opIndex] = textureIndex; } // UV animation block const uvAnimationIndex = uvAnimationIndices[opIndex]; const uvAnimation = uvAnimations[uvAnimationIndex]; if (uvAnimation) { def.uvAnimations[opIndex] = uvAnimation; def.uvAnimationIndices[opIndex] = uvAnimationIndex; } } return def; } resolveTextureIndices(data, batchData) { batchData.textureIndices = []; for (let opIndex = 0; opIndex < batchData.opCount; opIndex++) { const textureIndex = data.textureLookups[batchData.textureLookup + opIndex]; batchData.textureIndices.push(textureIndex); } } resolveUVAnimationIndices(data, batchData) { batchData.uvAnimationIndices = []; for (let opIndex = 0; opIndex < batchData.opCount; opIndex++) { const uvAnimationIndex = data.uvAnimationLookups[batchData.uvAnimationLookup + opIndex]; batchData.uvAnimationIndices.push(uvAnimationIndex); } } stubDef() { const def = { flags: null, shaderID: null, opCount: null, textureMapping: null, renderFlags: null, blendingMode: null, textures: [], textureIndices: [], uvAnimations: [], uvAnimationIndices: [], transparencyAnimation: null, transparencyAnimationIndex: null, vertexColorAnimation: null, vertexColorAnimationIndex: null }; return def; } } export default BatchManager; ================================================ FILE: src/lib/pipeline/m2/blueprint.js ================================================ import WorkerPool from '../worker/pool'; import M2 from './'; class M2Blueprint { static cache = new Map(); static animationUpdateTargets = new Map(); static references = new Map(); static pendingUnload = new Set(); static unloaderRunning = false; static UNLOAD_INTERVAL = 15000; static load(rawPath) { const path = rawPath.replace(/\.md(x|l)/i, '.m2').toUpperCase(); // Prevent unintended unloading. if (this.pendingUnload.has(path)) { this.pendingUnload.delete(path); } // Background unloader might need to be started. if (!this.unloaderRunning) { this.unloaderRunning = true; this.backgroundUnload(); } // Keep track of references. let refCount = this.references.get(path) || 0; ++refCount; this.references.set(path, refCount); if (!this.cache.has(path)) { this.cache.set(path, WorkerPool.enqueue('M2', path).then((args) => { const [data, skinData] = args; const m2 = new M2(path, data, skinData); if (m2.receivesAnimationUpdates) { this.animationUpdateTargets.set(path, m2); } return m2; })); } return this.cache.get(path).then((m2) => { return m2.clone(); }); } static unload(m2) { const path = m2.path.replace(/\.md(x|l)/i, '.m2').toUpperCase(); // Immediately dispose any non-instanced M2s. if (!m2.canInstance) { m2.dispose(); } let refCount = this.references.get(path) || 1; --refCount; if (refCount === 0) { this.pendingUnload.add(path); } else { this.references.set(path, refCount); } } static backgroundUnload() { this.pendingUnload.forEach((path) => { // Handle disposal for instanced M2s. if (this.cache.has(path)) { this.cache.get(path).then((m2) => { m2.dispose(); }); } this.cache.delete(path); this.animationUpdateTargets.delete(path); this.references.delete(path); this.pendingUnload.delete(path); }); setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); } static animate(delta) { this.animationUpdateTargets.forEach((m2) => { // Handle delta updates for instanced M2s (which share animation managers). if (m2.animations.length > 0) { m2.animations.update(delta); } }); } } export default M2Blueprint; ================================================ FILE: src/lib/pipeline/m2/index.js ================================================ import THREE from 'three'; import Submesh from './submesh'; import M2Material from './material'; import AnimationManager from './animation-manager'; import BatchManager from './batch-manager'; class M2 extends THREE.Group { static cache = {}; constructor(path, data, skinData, instance = null) { super(); this.matrixAutoUpdate = false; this.eventListeners = []; this.name = path.split('\\').slice(-1).pop(); this.path = path; this.data = data; this.skinData = skinData; this.batchManager = new BatchManager(); // Instanceable M2s share geometry, texture units, and animations. this.canInstance = data.canInstance; this.animated = data.animated; this.billboards = []; // Keep track of whether or not to use skinning. If the M2 has bone animations, useSkinning is // set to true, and all meshes and materials used in the M2 will be skinning enabled. Otherwise, // skinning will not be enabled. Skinning has a very significant impact on the render loop in // three.js. this.useSkinning = false; this.mesh = null; this.submeshes = []; this.parts = new Map(); this.geometry = null; this.submeshGeometries = new Map(); this.skeleton = null; this.bones = []; this.rootBones = []; if (instance) { this.animations = instance.animations; // To prevent over-updating animation timelines, instanced M2s shouldn't receive animation // time deltas. Instead, only the original M2 should receive time deltas. this.receivesAnimationUpdates = false; } else { this.animations = new AnimationManager(this, data.animations, data.sequences); if (this.animated) { this.receivesAnimationUpdates = true; } else { this.receivesAnimationUpdates = false; } } this.createSkeleton(data.bones); // Instanced M2s can share geometries and texture units. if (instance) { this.batches = instance.batches; this.geometry = instance.geometry; this.submeshGeometries = instance.submeshGeometries; } else { this.createTextureAnimations(data); this.createBatches(data, skinData); this.createGeometry(data.vertices); } this.createMesh(this.geometry, this.skeleton, this.rootBones); this.createSubmeshes(data, skinData); } createSkeleton(boneDefs) { const rootBones = []; const bones = []; const billboards = []; for (let boneIndex = 0, len = boneDefs.length; boneIndex < len; ++boneIndex) { const boneDef = boneDefs[boneIndex]; const bone = new THREE.Bone(); bones.push(bone); // M2 bone positioning seems to be inverted on X and Y const { pivotPoint } = boneDef; const correctedPosition = new THREE.Vector3(-pivotPoint[0], -pivotPoint[1], pivotPoint[2]); bone.position.copy(correctedPosition); if (boneDef.parentID > -1) { const parent = bones[boneDef.parentID]; parent.add(bone); // Correct bone positioning relative to parent let up = bone; while (up = up.parent) { bone.position.sub(up.position); } } else { bone.userData.isRoot = true; rootBones.push(bone); } // Enable skinning support on this M2 if we have bone animations. if (boneDef.animated) { this.useSkinning = true; } // Flag billboarded bones if (boneDef.billboarded) { bone.userData.billboarded = true; bone.userData.billboardType = boneDef.billboardType; billboards.push(bone); } // Bone translation animation block if (boneDef.translation.animated) { this.animations.registerTrack({ target: bone, property: 'position', animationBlock: boneDef.translation, trackType: 'VectorKeyframeTrack', valueTransform: function(value) { return [ bone.position.x + -value[0], bone.position.y + -value[1], bone.position.z + value[2] ]; } }); } // Bone rotation animation block if (boneDef.rotation.animated) { this.animations.registerTrack({ target: bone, property: 'quaternion', animationBlock: boneDef.rotation, trackType: 'QuaternionKeyframeTrack', valueTransform: function(value) { return [value[0], value[1], -value[2], -value[3]]; } }); } // Bone scaling animation block if (boneDef.scaling.animated) { this.animations.registerTrack({ target: bone, property: 'scale', animationBlock: boneDef.scaling, trackType: 'VectorKeyframeTrack' }); } } // Preserve the bones this.bones = bones; this.rootBones = rootBones; this.billboards = billboards; // Assemble the skeleton this.skeleton = new THREE.Skeleton(bones); this.skeleton.matrixAutoUpdate = this.matrixAutoUpdate; } // Returns a map of M2Materials indexed by submesh. Each material represents a batch, // to be rendered in the order of appearance in the map's entry for the submesh index. createBatches(data, skinData) { const batches = new Map(); const batchDefs = this.batchManager.createDefs(data, skinData); const batchLen = batchDefs.length; for (let batchIndex = 0; batchIndex < batchLen; ++batchIndex) { const batchDef = batchDefs[batchIndex]; const { submeshIndex } = batchDef; if (!batches.has(submeshIndex)) { batches.set(submeshIndex, []); } // Array that will contain materials matching each batch. const submeshBatches = batches.get(submeshIndex); // Observe the M2's skinning flag in the M2Material. batchDef.useSkinning = this.useSkinning; const batchMaterial = new M2Material(this, batchDef); submeshBatches.unshift(batchMaterial); } this.batches = batches; } createGeometry(vertices) { const geometry = new THREE.Geometry(); for (let vertexIndex = 0, len = vertices.length; vertexIndex < len; ++vertexIndex) { const vertex = vertices[vertexIndex]; const { position } = vertex; geometry.vertices.push( // Provided as (X, Z, -Y) new THREE.Vector3(position[0], position[2], -position[1]) ); geometry.skinIndices.push( new THREE.Vector4(...vertex.boneIndices) ); geometry.skinWeights.push( new THREE.Vector4(...vertex.boneWeights) ); } // Mirror geometry over X and Y axes and rotate const matrix = new THREE.Matrix4(); matrix.makeScale(-1, -1, 1); geometry.applyMatrix(matrix); geometry.rotateX(-Math.PI / 2); // Preserve the geometry this.geometry = geometry; } createMesh(geometry, skeleton, rootBones) { let mesh; if (this.useSkinning) { mesh = new THREE.SkinnedMesh(geometry); // Assign root bones to mesh rootBones.forEach((bone) => { mesh.add(bone); bone.skin = mesh; }); // Bind mesh to skeleton mesh.bind(skeleton); } else { mesh = new THREE.Mesh(geometry); } mesh.matrixAutoUpdate = this.matrixAutoUpdate; // Never display the mesh // TODO: We shouldn't really even have this mesh in the first place, should we? mesh.visible = false; // Add mesh to the group this.add(mesh); // Assign as root mesh this.mesh = mesh; } createSubmeshes(data, skinData) { const { vertices } = data; const { submeshes, indices, triangles } = skinData; const subLen = submeshes.length; for (let submeshIndex = 0; submeshIndex < subLen; ++submeshIndex) { const submeshDef = submeshes[submeshIndex]; // Bring up relevant batches and geometry. const submeshBatches = this.batches.get(submeshIndex); const submeshGeometry = this.submeshGeometries.get(submeshIndex) || this.createSubmeshGeometry(submeshDef, indices, triangles, vertices); const submesh = this.createSubmesh(submeshDef, submeshGeometry, submeshBatches); this.parts.set(submesh.userData.partID, submesh); this.submeshes.push(submesh); this.submeshGeometries.set(submeshIndex, submeshGeometry); this.add(submesh); } } createSubmeshGeometry(submeshDef, indices, triangles, vertices) { const geometry = this.geometry.clone(); // TODO: Figure out why this isn't cloned by the line above geometry.skinIndices = Array.from(this.geometry.skinIndices); geometry.skinWeights = Array.from(this.geometry.skinWeights); const uvs = []; const { startTriangle: start, triangleCount: count } = submeshDef; for (let i = start, faceIndex = 0; i < start + count; i += 3, ++faceIndex) { const vindices = [ indices[triangles[i]], indices[triangles[i + 1]], indices[triangles[i + 2]] ]; const face = new THREE.Face3(vindices[0], vindices[1], vindices[2]); geometry.faces.push(face); uvs[faceIndex] = []; for (let vinIndex = 0, vinLen = vindices.length; vinIndex < vinLen; ++vinIndex) { const index = vindices[vinIndex]; const { textureCoords, normal } = vertices[index]; uvs[faceIndex].push(new THREE.Vector2(textureCoords[0][0], textureCoords[0][1])); face.vertexNormals.push(new THREE.Vector3(normal[0], normal[1], normal[2])); } } geometry.faceVertexUvs = [uvs]; const bufferGeometry = new THREE.BufferGeometry().fromGeometry(geometry); return bufferGeometry; } createSubmesh(submeshDef, geometry, batches) { const rootBone = this.bones[submeshDef.rootBone]; const opts = { skeleton: this.skeleton, geometry: geometry, rootBone: rootBone, useSkinning: this.useSkinning, matrixAutoUpdate: this.matrixAutoUpdate }; const submesh = new Submesh(opts); submesh.applyBatches(batches); submesh.userData.partID = submeshDef.partID; return submesh; } createTextureAnimations(data) { this.textureAnimations = new THREE.Object3D(); this.uvAnimationValues = []; this.transparencyAnimationValues = []; this.vertexColorAnimationValues = []; const { uvAnimations, transparencyAnimations, vertexColorAnimations } = data; this.createUVAnimations(uvAnimations); this.createTransparencyAnimations(transparencyAnimations); this.createVertexColorAnimations(vertexColorAnimations); } // TODO: Add support for rotation and scaling in UV animations. createUVAnimations(uvAnimationDefs) { if (uvAnimationDefs.length === 0) { return; } uvAnimationDefs.forEach((uvAnimationDef, index) => { // Default value this.uvAnimationValues[index] = { translation: [1.0, 1.0, 1.0], rotation: [0.0, 0.0, 0.0, 1.0], scaling: [1.0, 1.0, 1.0], matrix: new THREE.Matrix4() }; const { translation } = uvAnimationDef; this.animations.registerTrack({ target: this, property: 'uvAnimationValues[' + index + '].translation', animationBlock: translation, trackType: 'VectorKeyframeTrack' }); // Set up event subscription to produce matrix from translation, rotation, and scaling // values. const updater = () => { const animationValue = this.uvAnimationValues[index]; // Set up matrix for use in uv transform in vertex shader. animationValue.matrix = new THREE.Matrix4().compose( new THREE.Vector3(...animationValue.translation), new THREE.Quaternion(...animationValue.rotation), new THREE.Vector3(...animationValue.scaling) ); }; this.animations.on('update', updater); this.eventListeners.push([this.animations, 'update', updater]); }); } createTransparencyAnimations(transparencyAnimationDefs) { if (transparencyAnimationDefs.length === 0) { return; } transparencyAnimationDefs.forEach((transparencyAnimationDef, index) => { // Default value this.transparencyAnimationValues[index] = 1.0; this.animations.registerTrack({ target: this, property: 'transparencyAnimationValues[' + index + ']', animationBlock: transparencyAnimationDef, trackType: 'NumberKeyframeTrack', valueTransform: function(value) { return [value]; } }); }); } createVertexColorAnimations(vertexColorAnimationDefs) { if (vertexColorAnimationDefs.length === 0) { return; } vertexColorAnimationDefs.forEach((vertexColorAnimationDef, index) => { // Default value this.vertexColorAnimationValues[index] = { color: [1.0, 1.0, 1.0], alpha: 1.0 }; const { color, alpha } = vertexColorAnimationDef; this.animations.registerTrack({ target: this, property: 'vertexColorAnimationValues[' + index + '].color', animationBlock: color, trackType: 'VectorKeyframeTrack' }); this.animations.registerTrack({ target: this, property: 'vertexColorAnimationValues[' + index + '].alpha', animationBlock: alpha, trackType: 'NumberKeyframeTrack', valueTransform: function(value) { return [value]; } }); }); } applyBillboards(camera) { for (let i = 0, len = this.billboards.length; i < len; ++i) { const bone = this.billboards[i]; switch (bone.userData.billboardType) { case 0: this.applySphericalBillboard(camera, bone); break; case 3: this.applyCylindricalZBillboard(camera, bone); break; default: break; } } } applySphericalBillboard(camera, bone) { const boneRoot = bone.skin; if (!boneRoot) { return; } const camPos = this.worldToLocal(camera.position.clone()); const modelForward = new THREE.Vector3(camPos.x, camPos.y, camPos.z); modelForward.normalize(); const modelVmEl = boneRoot.modelViewMatrix.elements; const modelRight = new THREE.Vector3(modelVmEl[0], modelVmEl[4], modelVmEl[8]); modelRight.multiplyScalar(-1); const modelUp = new THREE.Vector3(); modelUp.crossVectors(modelForward, modelRight); modelUp.normalize(); const rotateMatrix = new THREE.Matrix4(); rotateMatrix.set( modelForward.x, modelRight.x, modelUp.x, 0, modelForward.y, modelRight.y, modelUp.y, 0, modelForward.z, modelRight.z, modelUp.z, 0, 0, 0, 0, 1 ); bone.rotation.setFromRotationMatrix(rotateMatrix); } applyCylindricalZBillboard(camera, bone) { const boneRoot = bone.skin; if (!boneRoot) { return; } const camPos = this.worldToLocal(camera.position.clone()); const modelForward = new THREE.Vector3(camPos.x, camPos.y, camPos.z); modelForward.normalize(); const modelVmEl = boneRoot.modelViewMatrix.elements; const modelRight = new THREE.Vector3(modelVmEl[0], modelVmEl[4], modelVmEl[8]); const modelUp = new THREE.Vector3(0, 0, 1); const rotateMatrix = new THREE.Matrix4(); rotateMatrix.set( modelForward.x, modelRight.x, modelUp.x, 0, modelForward.y, modelRight.y, modelUp.y, 0, modelForward.z, modelRight.z, modelUp.z, 0, 0, 0, 0, 1 ); bone.rotation.setFromRotationMatrix(rotateMatrix); } set displayInfo(displayInfo) { for (let i = 0, len = this.submeshes.length; i < len; ++i) { this.submeshes[i].displayInfo = displayInfo; } } detachEventListeners() { this.eventListeners.forEach((entry) => { const [target, event, listener] = entry; target.removeListener(event, listener); }); } dispose() { this.detachEventListeners(); this.eventListeners = []; this.geometry.dispose(); this.mesh.geometry.dispose(); this.submeshes.forEach((submesh) => { submesh.dispose(); }); } clone() { let instance = {}; if (this.canInstance) { instance.animations = this.animations; instance.geometry = this.geometry; instance.submeshGeometries = this.submeshGeometries; instance.batches = this.batches; } else { instance = null; } return new this.constructor(this.path, this.data, this.skinData, instance); } } export default M2; ================================================ FILE: src/lib/pipeline/m2/loader.js ================================================ import { DecodeStream } from 'blizzardry/lib/restructure'; import M2 from 'blizzardry/lib/m2'; import Skin from 'blizzardry/lib/m2/skin'; import Loader from '../../net/loader'; const loader = new Loader(); export default function(path) { return loader.load(path).then((raw) => { let buffer = new Buffer(new Uint8Array(raw)); let stream = new DecodeStream(buffer); const data = M2.decode(stream); // TODO: Allow configuring quality const quality = data.viewCount - 1; const skinPath = path.replace(/\.m2/i, `0${quality}.skin`); return loader.load(skinPath).then((rawSkin) => { buffer = new Buffer(new Uint8Array(rawSkin)); stream = new DecodeStream(buffer); const skinData = Skin.decode(stream); return [data, skinData]; }); }); } ================================================ FILE: src/lib/pipeline/m2/material/index.js ================================================ import THREE from 'three'; import TextureLoader from '../../texture-loader'; import vertexShader from './shader.vert'; import fragmentShader from './shader.frag'; class M2Material extends THREE.ShaderMaterial { constructor(m2, def) { if (def.useSkinning) { super({ skinning: true }); } else { super({ skinning: false }); } this.m2 = m2; this.eventListeners = []; const vertexShaderMode = this.vertexShaderModeFromID(def.shaderID, def.opCount); const fragmentShaderMode = this.fragmentShaderModeFromID(def.shaderID, def.opCount); this.uniforms = { textureCount: { type: 'i', value: 0 }, textures: { type: 'tv', value: [] }, blendingMode: { type: 'i', value: 0 }, vertexShaderMode: { type: 'i', value: vertexShaderMode }, fragmentShaderMode: { type: 'i', value: fragmentShaderMode }, billboarded: { type: 'f', value: 0.0 }, // Animated vertex colors animatedVertexColorRGB: { type: 'v3', value: new THREE.Vector3(1.0, 1.0, 1.0) }, animatedVertexColorAlpha: { type: 'f', value: 1.0 }, // Animated transparency animatedTransparency: { type: 'f', value: 1.0 }, // Animated texture coordinate transform matrices animatedUVs: { type: 'm4v', value: [ new THREE.Matrix4(), new THREE.Matrix4(), new THREE.Matrix4(), new THREE.Matrix4() ] }, // Managed by light manager lightModifier: { type: 'f', value: '1.0' }, ambientLight: { type: 'c', value: new THREE.Color(0.5, 0.5, 0.5) }, diffuseLight: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, // Managed by light manager fogModifier: { type: 'f', value: '1.0' }, fogColor: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, fogStart: { type: 'f', value: 5.0 }, fogEnd: { type: 'f', value: 400.0 } }; this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; this.applyRenderFlags(def.renderFlags); this.applyBlendingMode(def.blendingMode); // Shader ID is a masked int that determines mode for vertex and fragment shader. this.shaderID = def.shaderID; // Loaded by calling updateSkinTextures() this.skins = {}; this.skins.skin1 = null; this.skins.skin2 = null; this.skins.skin3 = null; this.textures = []; this.textureDefs = def.textures; this.loadTextures(); this.registerAnimations(def); } // TODO: Fully expand these lookups. vertexShaderModeFromID(shaderID, opCount) { if (opCount === 1) { return 0; } if (shaderID === 0) { return 1; } return -1; } // TODO: Fully expand these lookups. fragmentShaderModeFromID(shaderID, opCount) { if (opCount === 1) { // fragCombinersWrath1Pass return 0; } if (shaderID === 0) { // fragCombinersWrath2Pass return 1; } // Unknown / unhandled return -1; } enableBillboarding() { // TODO: Make billboarding happen in the vertex shader. this.uniforms.billboarded = { type: 'f', value: '1.0' }; // TODO: Shouldn't this be FrontSide? Billboarding logic currently seems to flips the mesh // backward. this.side = THREE.BackSide; } applyRenderFlags(renderFlags) { // Flag 0x01 (unlit) if (renderFlags & 0x01) { this.uniforms.lightModifier = { type: 'f', value: '0.0' }; } // Flag 0x02 (unfogged) if (renderFlags & 0x02) { this.uniforms.fogModifier = { type: 'f', value: '0.0' }; } // Flag 0x04 (no backface culling) if (renderFlags & 0x04) { this.side = THREE.DoubleSide; this.transparent = true; } // Flag 0x10 (no z-buffer write) if (renderFlags & 0x10) { this.depthWrite = false; } } applyBlendingMode(blendingMode) { this.uniforms.blendingMode.value = blendingMode; if (blendingMode === 1) { this.uniforms.alphaKey = { type: 'f', value: 1.0 }; } else { this.uniforms.alphaKey = { type: 'f', value: 0.0 }; } if (blendingMode >= 1) { this.transparent = true; this.blending = THREE.CustomBlending; } switch (blendingMode) { case 0: this.blending = THREE.NoBlending; this.blendSrc = THREE.OneFactor; this.blendDst = THREE.ZeroFactor; break; case 1: this.alphaTest = 0.5; this.side = THREE.DoubleSide; this.blendSrc = THREE.OneFactor; this.blendDst = THREE.ZeroFactor; this.blendSrcAlpha = THREE.OneFactor; this.blendDstAlpha = THREE.ZeroFactor; break; case 2: this.blendSrc = THREE.SrcAlphaFactor; this.blendDst = THREE.OneMinusSrcAlphaFactor; this.blendSrcAlpha = THREE.SrcAlphaFactor; this.blendDstAlpha = THREE.OneMinusSrcAlphaFactor; break; case 3: this.blendSrc = THREE.SrcColorFactor; this.blendDst = THREE.DstColorFactor; this.blendSrcAlpha = THREE.SrcAlphaFactor; this.blendDstAlpha = THREE.DstAlphaFactor; break; case 4: this.blendSrc = THREE.SrcAlphaFactor; this.blendDst = THREE.OneFactor; this.blendSrcAlpha = THREE.SrcAlphaFactor; this.blendDstAlpha = THREE.OneFactor; break; case 5: this.blendSrc = THREE.DstColorFactor; this.blendDst = THREE.ZeroFactor; this.blendSrcAlpha = THREE.DstAlphaFactor; this.blendDstAlpha = THREE.ZeroFactor; break; case 6: this.blendSrc = THREE.DstColorFactor; this.blendDst = THREE.SrcColorFactor; this.blendSrcAlpha = THREE.DstAlphaFactor; this.blendDstAlpha = THREE.SrcAlphaFactor; break; default: break; } } loadTextures() { const textureDefs = this.textureDefs; const textures = []; textureDefs.forEach((textureDef) => { textures.push(this.loadTexture(textureDef)); }); this.textures = textures; // Update shader uniforms to reflect loaded textures. this.uniforms.textures = { type: 'tv', value: textures }; this.uniforms.textureCount = { type: 'i', value: textures.length }; } loadTexture(textureDef) { const wrapS = THREE.RepeatWrapping; const wrapT = THREE.RepeatWrapping; const flipY = false; let path = null; switch (textureDef.type) { case 0: // Hardcoded texture path = textureDef.filename; break; case 11: if (this.skins.skin1) { path = this.skins.skin1; } break; case 12: if (this.skins.skin2) { path = this.skins.skin2; } break; case 13: if (this.skins.skin3) { path = this.skins.skin3; } break; default: break; } if (path) { return TextureLoader.load(path, wrapS, wrapT, flipY); } else { return null; } } registerAnimations(def) { const { uvAnimationIndices, transparencyAnimationIndex, vertexColorAnimationIndex } = def; this.registerUVAnimations(uvAnimationIndices); this.registerTransparencyAnimation(transparencyAnimationIndex); this.registerVertexColorAnimation(vertexColorAnimationIndex); } registerUVAnimations(uvAnimationIndices) { if (uvAnimationIndices.length === 0) { return; } const { animations, uvAnimationValues } = this.m2; const updater = () => { uvAnimationIndices.forEach((uvAnimationIndex, opIndex) => { const target = this.uniforms.animatedUVs; const source = uvAnimationValues[uvAnimationIndex]; target.value[opIndex] = source.matrix; }); }; animations.on('update', updater); this.eventListeners.push([animations, 'update', updater]); } registerTransparencyAnimation(transparencyAnimationIndex) { if (transparencyAnimationIndex === null || transparencyAnimationIndex === -1) { return; } const { animations, transparencyAnimationValues } = this.m2; const target = this.uniforms.animatedTransparency; const source = transparencyAnimationValues; const valueIndex = transparencyAnimationIndex; const updater = () => { target.value = source[valueIndex]; }; animations.on('update', updater); this.eventListeners.push([animations, 'update', updater]); } registerVertexColorAnimation(vertexColorAnimationIndex) { if (vertexColorAnimationIndex === null || vertexColorAnimationIndex === -1) { return; } const { animations, vertexColorAnimationValues } = this.m2; const targetRGB = this.uniforms.animatedVertexColorRGB; const targetAlpha = this.uniforms.animatedVertexColorAlpha; const source = vertexColorAnimationValues; const valueIndex = vertexColorAnimationIndex; const updater = () => { targetRGB.value = source[valueIndex].color; targetAlpha.value = source[valueIndex].alpha; }; animations.on('update', updater); this.eventListeners.push([animations, 'update', updater]); } detachEventListeners() { this.eventListeners.forEach((entry) => { const [target, event, listener] = entry; target.removeListener(event, listener); }); } updateSkinTextures(skin1, skin2, skin3) { this.skins.skin1 = skin1; this.skins.skin2 = skin2; this.skins.skin3 = skin3; this.loadTextures(); } dispose() { super.dispose(); this.detachEventListeners(); this.eventListeners = []; this.textures.forEach((texture) => { TextureLoader.unload(texture); }); } } export default M2Material; ================================================ FILE: src/lib/pipeline/m2/material/shader.frag ================================================ uniform int fragmentShaderMode; uniform int textureCount; uniform sampler2D textures[4]; varying vec2 uv1; varying vec2 uv2; varying float cameraDistance; varying vec3 vertexWorldNormal; varying vec4 animatedVertexColor; uniform float animatedTransparency; uniform float alphaKey; uniform float lightModifier; uniform vec3 ambientLight; uniform vec3 diffuseLight; uniform float fogModifier; uniform float fogStart; uniform float fogEnd; uniform vec3 fogColor; uniform int blendingMode; vec4 fragCombinersWrath1Pass(sampler2D texture1, vec2 uv1) { vec4 texture1Color = texture2D(texture1, uv1); if (alphaKey == 1.0 && texture1Color.a <= 0.5) { discard; } vec4 c1 = texture1Color; // Apply animated transparency (defaults to 1.0) c1.a *= animatedTransparency; // Blend with vertex color c1.rgb *= (animatedVertexColor.rgb * animatedVertexColor.a); // Restore full color intensity after blending with vertexColor c1.rgb *= 2.0; // Force transparent pixels to fully opaque if in opaque blending mode (0). Needed to prevent // transparent pixels from becoming inappropriately bright. if (blendingMode == 0) { c1.a = 1.0; } vec4 outputColor = c1; return outputColor; } vec4 fragCombinersWrath2Pass(sampler2D texture1, vec2 uv1, sampler2D texture2, vec2 uv2) { vec4 texture1Color = texture2D(texture1, uv1); vec4 texture2Color = texture2D(texture2, uv2); if (alphaKey == 1.0 && texture1Color.a <= 0.5) { discard; } vec4 c1 = texture1Color; vec4 c2 = texture2Color; // Apply animated transparency (defaults to 1.0) c1.a *= animatedTransparency; // Blend texture alphas c1.a *= c2.a; // Blend with vertex color c1.rgb *= (animatedVertexColor.rgb * animatedVertexColor.a); // Restore full color intensity after blending with vertexColor c1.rgb *= 2.0; vec4 outputColor = c1; return outputColor; } vec4 applyDiffuseLighting(vec4 color) { vec3 lightDirection = vec3(1, 1, -1); float light = saturate(dot(vertexWorldNormal, normalize(-lightDirection))); vec3 diffusion = diffuseLight.rgb * light; diffusion += ambientLight.rgb; diffusion = saturate(diffusion); color.rgb *= diffusion; return color; } vec4 applyFog(vec4 color) { float fogFactor = (fogEnd - cameraDistance) / (fogEnd - fogStart); fogFactor = 1.0 - clamp(fogFactor, 0.0, 1.0); float fogColorFactor = fogFactor * fogModifier; // Only mix fog color for simple blending modes. if (blendingMode <= 2) { color.rgb = mix(color.rgb, fogColor.rgb, fogColorFactor); } // Ensure certain blending mode pixels become fully opaque by fog end. if (cameraDistance >= fogEnd) { color.rgb = fogColor.rgb; color.a = 1.0; } // Ensure certain blending mode pixels fade out as fog increases. if (blendingMode >= 2 && blendingMode < 6) { color.a *= 1.0 - fogFactor; } return color; } vec4 finalizeColor(vec4 color) { if (lightModifier > 0.0) { color = applyDiffuseLighting(color); } color = applyFog(color); return color; } void main() { vec4 color; // -1 = unknown / unhandled // Stopgap until all shaders are implemented and verified if (fragmentShaderMode == -1) { color = texture2D(textures[0], uv1); } else if (fragmentShaderMode == 0) { color = fragCombinersWrath1Pass(textures[0], uv1); } else if (fragmentShaderMode == 1) { color = fragCombinersWrath2Pass(textures[0], uv1, textures[1], uv2); } // Apply lighting and fog. color = finalizeColor(color); gl_FragColor = color; } ================================================ FILE: src/lib/pipeline/m2/material/shader.vert ================================================ precision highp float; varying vec2 uv1; varying vec2 uv2; varying float cameraDistance; varying vec3 vertexWorldNormal; uniform vec3 animatedVertexColorRGB; uniform float animatedVertexColorAlpha; uniform float animatedTransparency; uniform mat4 animatedUVs[4]; varying vec4 animatedVertexColor; uniform float billboarded; #ifdef USE_SKINNING uniform mat4 bindMatrix; uniform mat4 bindMatrixInverse; #ifdef BONE_TEXTURE uniform sampler2D boneTexture; uniform int boneTextureWidth; uniform int boneTextureHeight; mat4 getBoneMatrix( const in float i ) { float j = i * 4.0; float x = mod( j, float( boneTextureWidth ) ); float y = floor( j / float( boneTextureWidth ) ); float dx = 1.0 / float( boneTextureWidth ); float dy = 1.0 / float( boneTextureHeight ); y = dy * ( y + 0.5 ); vec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) ); vec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) ); vec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) ); vec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) ); mat4 bone = mat4( v1, v2, v3, v4 ); return bone; } #else uniform mat4 boneGlobalMatrices[ MAX_BONES ]; mat4 getBoneMatrix( const in float i ) { mat4 bone = boneGlobalMatrices[ int(i) ]; return bone; } #endif #endif void main() { // TODO: Use vertexShaderMode to determine coordinates uv1 = vec2(uv[0], uv[1]); uv2 = vec2(uv[0], uv[1]); // Apply texture animations vec4 uv1a = animatedUVs[0] * vec4(uv1, 0, 1.0); uv1 = uv1a.xy / uv1a.w; vec4 uv2a = animatedUVs[1] * vec4(uv2, 0, 1.0); uv2 = uv2a.xy / uv2a.w; // TODO: Will this be needed in the fragment shader at some point? vec3 vertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; cameraDistance = distance(cameraPosition, vertexWorldPosition); // Account for adjustments (eg. model rotation) in world space // TODO: Do we need to account for skinning? vertexWorldNormal = (modelMatrix * vec4(normal, 0.0)).xyz; animatedVertexColor.rgb = animatedVertexColorRGB.xyz * 0.5; animatedVertexColor.a = animatedVertexColorAlpha; vec3 transformed = vec3(position); #ifdef USE_SKINNING mat4 boneMatX = getBoneMatrix(skinIndex.x); mat4 boneMatY = getBoneMatrix(skinIndex.y); mat4 boneMatZ = getBoneMatrix(skinIndex.z); mat4 boneMatW = getBoneMatrix(skinIndex.w); #endif #ifdef USE_SKINNING vec4 skinVertex = bindMatrix * vec4(transformed, 1.0); vec4 skinned = vec4( 0.0 ); skinned += boneMatX * skinVertex * skinWeight.x; skinned += boneMatY * skinVertex * skinWeight.y; skinned += boneMatZ * skinVertex * skinWeight.z; skinned += boneMatW * skinVertex * skinWeight.w; skinned = bindMatrixInverse * skinned; #endif #ifdef USE_SKINNING vec4 mvPosition = modelViewMatrix * skinned; #else vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0); #endif gl_Position = projectionMatrix * mvPosition; } ================================================ FILE: src/lib/pipeline/m2/submesh.js ================================================ import THREE from 'three'; class Submesh extends THREE.Group { constructor(opts) { super(); this.matrixAutoUpdate = opts.matrixAutoUpdate; this.useSkinning = opts.useSkinning; this.rootBone = null; this.billboarded = false; if (this.useSkinning) { // Preserve the rootBone for the submesh such that its skin property can be assigned to the // first child batch mesh. this.rootBone = opts.rootBone; this.billboarded = opts.rootBone.userData.billboarded; // Preserve the skeleton for use in applying batches. this.skeleton = opts.skeleton; } // Preserve the geometry for use in applying batches. this.geometry = opts.geometry; } // Submeshes get one mesh per batch, which allows them to effectively simulate multiple // render passes. Batch mesh rendering order should be handled properly by the three.js // renderer. applyBatches(batches) { this.clearBatches(); const batchLen = batches.length; for (let batchIndex = 0; batchIndex < batchLen; ++batchIndex) { const batchMaterial = batches[batchIndex]; // If the submesh is billboarded, flag the material as billboarded. if (this.billboarded) { batchMaterial.enableBillboarding(); } let batchMesh; // Only use a skinned mesh if the submesh uses skinning. if (this.useSkinning) { batchMesh = new THREE.SkinnedMesh(this.geometry, batchMaterial); batchMesh.bind(this.skeleton); } else { batchMesh = new THREE.Mesh(this.geometry, batchMaterial); } batchMesh.matrixAutoUpdate = this.matrixAutoUpdate; this.add(batchMesh); } if (this.useSkinning) { this.rootBone.skin = this.children[0]; } } // Remove any existing child batch meshes. clearBatches() { const childrenLength = this.children.length; for (let childIndex = 0; childIndex < childrenLength; ++childIndex) { const child = this.children[childIndex]; this.remove(child); } if (this.useSkinning) { // If all batch meshes are cleared, there is no longer a skin to associate with the // root bone. this.rootBone.skin = null; } } // Update all existing batch mesh materials to point to the new skins (textures). set displayInfo(displayInfo) { const { path } = displayInfo.modelData; const skin1 = `${path}${displayInfo.skin1}.blp`; const skin2 = `${path}${displayInfo.skin2}.blp`; const skin3 = `${path}${displayInfo.skin3}.blp`; const childrenLength = this.children.length; for (let childIndex = 0; childIndex < childrenLength; ++childIndex) { const child = this.children[childIndex]; child.material.updateSkinTextures(skin1, skin2, skin3); } } dispose() { this.geometry.dispose(); this.children.forEach((child) => { child.geometry.dispose(); child.material.dispose(); }); } } export default Submesh; ================================================ FILE: src/lib/pipeline/material.js ================================================ import THREE from 'three'; const loader = new THREE.TextureLoader(); class Material extends THREE.MeshBasicMaterial { constructor(params = {}) { params.wireframe = true; super(params); } set texture(path) { loader.load(encodeURI(`pipeline/${path}.png`), (texture) => { texture.flipY = false; texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; this.wireframe = false; this.map = texture; this.needsUpdate = true; }); } } export default Material; ================================================ FILE: src/lib/pipeline/texture-loader.js ================================================ import THREE from 'three'; const loader = new THREE.TextureLoader(); class TextureLoader { static cache = new Map(); static references = new Map(); static pendingUnload = new Set(); static unloaderRunning = false; static UNLOAD_INTERVAL = 15000; static load(rawPath, wrapS = THREE.RepeatWrapping, wrapT = THREE.RepeatWrapping, flipY = true) { const path = rawPath.toUpperCase(); // Ensure we cache based on texture settings. Some textures are reused with different settings. const textureKey = `${path};ws:${wrapS.toString()};wt:${wrapT.toString()};fy:${flipY}}`; // Prevent unintended unloading. if (this.pendingUnload.has(textureKey)) { this.pendingUnload.delete(textureKey); } // Background unloader might need to be started. if (!this.unloaderRunning) { this.unloaderRunning = true; this.backgroundUnload(); } // Keep track of references. let refCount = this.references.get(textureKey) || 0; ++refCount; this.references.set(textureKey, refCount); const encodedPath = encodeURI(`pipeline/${path}.png`); if (!this.cache.has(textureKey)) { // TODO: Promisify THREE's TextureLoader callbacks this.cache.set(textureKey, loader.load(encodedPath, function(texture) { texture.sourceFile = path; texture.textureKey = textureKey; texture.wrapS = wrapS; texture.wrapT = wrapT; texture.flipY = flipY; texture.needsUpdate = true; })); } return this.cache.get(textureKey); } static unload(texture) { const textureKey = texture.textureKey; let refCount = this.references.get(textureKey) || 1; --refCount; if (refCount === 0) { this.pendingUnload.add(textureKey); } else { this.references.set(textureKey, refCount); } } static backgroundUnload() { this.pendingUnload.forEach((textureKey) => { if (this.cache.has(textureKey)) { this.cache.get(textureKey).dispose(); } this.cache.delete(textureKey); this.references.delete(textureKey); this.pendingUnload.delete(textureKey); }); setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); } } export default TextureLoader; ================================================ FILE: src/lib/pipeline/wdt/index.js ================================================ import WorkerPool from '../worker/pool'; class WDT { static cache = {}; constructor(data) { this.data = data; } static load(path) { if (!(path in this.cache)) { this.cache[path] = WorkerPool.enqueue('WDT', path).then((args) => { const [data] = args; return new this(data); }); } return this.cache[path]; } } export default WDT; ================================================ FILE: src/lib/pipeline/wdt/loader.js ================================================ import { DecodeStream } from 'blizzardry/lib/restructure'; import WDT from 'blizzardry/lib/wdt'; import Loader from '../../net/loader'; const loader = new Loader(); export default function(path) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); const data = WDT.decode(stream); return data; }); } ================================================ FILE: src/lib/pipeline/wmo/blueprint.js ================================================ import WorkerPool from '../worker/pool'; import WMO from './'; class WMOBlueprint { static cache = new Map(); static references = new Map(); static pendingUnload = new Set(); static unloaderRunning = false; static UNLOAD_INTERVAL = 15000; static load(rawPath) { const path = rawPath.toUpperCase(); // Prevent unintended unloading. if (this.pendingUnload.has(path)) { this.pendingUnload.delete(path); } // Background unloader might need to be started. if (!this.unloaderRunning) { this.unloaderRunning = true; this.backgroundUnload(); } // Keep track of references. let refCount = this.references.get(path) || 0; ++refCount; this.references.set(path, refCount); if (!this.cache.has(path)) { this.cache.set(path, WorkerPool.enqueue('WMO', path).then((args) => { const [data] = args; return new WMO(path, data); })); } return this.cache.get(path).then((wmo) => { return wmo.clone(); }); } static unload(wmo) { const path = wmo.path.toUpperCase(); let refCount = this.references.get(path) || 1; --refCount; if (refCount === 0) { this.pendingUnload.add(path); } else { this.references.set(path, refCount); } } static backgroundUnload() { this.pendingUnload.forEach((path) => { this.cache.delete(path); this.references.delete(path); this.pendingUnload.delete(path); }); setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); } } export default WMOBlueprint; ================================================ FILE: src/lib/pipeline/wmo/group/blueprint.js ================================================ import WorkerPool from '../../worker/pool'; import WMOGroup from './'; class WMOGroupBlueprint { static cache = new Map(); static references = new Map(); static pendingUnload = new Set(); static unloaderRunning = false; static UNLOAD_INTERVAL = 15000; static load(wmo, id, rawPath) { const path = rawPath.toUpperCase(); // Prevent unintended unloading. if (this.pendingUnload.has(path)) { this.pendingUnload.delete(path); } // Background unloader might need to be started. if (!this.unloaderRunning) { this.unloaderRunning = true; this.backgroundUnload(); } // Keep track of references. let refCount = this.references.get(path) || 0; ++refCount; this.references.set(path, refCount); if (!this.cache.has(path)) { this.cache.set(path, WorkerPool.enqueue('WMOGroup', path).then((args) => { const [data] = args; return new WMOGroup(wmo, id, data, path); })); } return this.cache.get(path).then((wmoGroup) => { return wmoGroup.clone(); }); } static loadWithID(wmo, id) { const suffix = `000${id}`.slice(-3); const groupPath = wmo.path.replace(/\.wmo/i, `_${suffix}.wmo`); return this.load(wmo, id, groupPath); } static unload(wmoGroup) { wmoGroup.dispose(); const path = wmoGroup.path.toUpperCase(); let refCount = this.references.get(path) || 1; --refCount; if (refCount === 0) { this.pendingUnload.add(path); } else { this.references.set(path, refCount); } } static backgroundUnload() { this.pendingUnload.forEach((path) => { if (this.cache.has(path)) { this.cache.get(path).then((wmoGroup) => { wmoGroup.dispose(); }); } this.cache.delete(path); this.references.delete(path); this.pendingUnload.delete(path); }); setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); } } export default WMOGroupBlueprint; ================================================ FILE: src/lib/pipeline/wmo/group/index.js ================================================ import THREE from 'three'; import WMOMaterial from '../material'; class WMOGroup extends THREE.Mesh { static cache = {}; constructor(wmo, id, data, path) { super(); this.dispose = ::this.dispose; this.matrixAutoUpdate = false; this.wmo = wmo; this.groupID = id; this.data = data; this.path = path; this.indoor = data.indoor; this.animated = false; const vertexCount = data.MOVT.vertices.length; const textureCoords = data.MOTV.textureCoords; const positions = new Float32Array(vertexCount * 3); const normals = new Float32Array(vertexCount * 3); const uvs = new Float32Array(vertexCount * 2); const colors = new Float32Array(vertexCount * 3); const alphas = new Float32Array(vertexCount); data.MOVT.vertices.forEach(function(vertex, index) { // Provided as (X, Z, -Y) positions[index * 3] = vertex[0]; positions[index * 3 + 1] = vertex[2]; positions[index * 3 + 2] = -vertex[1]; uvs[index * 2] = textureCoords[index][0]; uvs[index * 2 + 1] = textureCoords[index][1]; }); data.MONR.normals.forEach(function(normal, index) { normals[index * 3] = normal[0]; normals[index * 3 + 1] = normal[2]; normals[index * 3 + 2] = -normal[1]; }); if ('MOCV' in data) { data.MOCV.colors.forEach(function(color, index) { colors[index * 3] = color.r / 255.0; colors[index * 3 + 1] = color.g / 255.0; colors[index * 3 + 2] = color.b / 255.0; alphas[index] = color.a / 255.0; }); } else if (this.indoor) { // Default indoor vertex color: rgba(0.5, 0.5, 0.5, 1.0) data.MOVT.vertices.forEach(function(_vertex, index) { colors[index * 3] = 127.0 / 255.0; colors[index * 3 + 1] = 127.0 / 255.0; colors[index * 3 + 2] = 127.0 / 255.0; alphas[index] = 1.0; }); } const indices = new Uint32Array(data.MOVI.triangles); const geometry = this.geometry = new THREE.BufferGeometry(); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); geometry.addAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.addAttribute('normal', new THREE.BufferAttribute(normals, 3)); geometry.addAttribute('uv', new THREE.BufferAttribute(uvs, 2)); // TODO: Perhaps it is possible to directly use a vec4 here? Currently, color + alpha is // combined into a vec4 in the material's vertex shader. For some reason, attempting to // directly use a BufferAttribute with a length of 4 resulted in incorrect ordering for the // values in the shader. geometry.addAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.addAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); // Mirror geometry over X and Y axes and rotate const matrix = new THREE.Matrix4(); matrix.makeScale(-1, -1, 1); geometry.applyMatrix(matrix); geometry.rotateX(-Math.PI / 2); const materialIDs = []; data.MOBA.batches.forEach(function(batch) { materialIDs.push(batch.materialID); geometry.addGroup(batch.firstIndex, batch.indexCount, batch.materialID); }); const materialDefs = this.wmo.data.MOMT.materials; const texturePaths = this.wmo.data.MOTX.filenames; this.material = this.createMultiMaterial(materialIDs, materialDefs, texturePaths); } createMultiMaterial(materialIDs, materialDefs, texturePaths) { const multiMaterial = new THREE.MultiMaterial(); materialIDs.forEach((materialID) => { const materialDef = materialDefs[materialID]; if (this.indoor) { materialDef.indoor = true; } else { materialDef.indoor = false; } if (!this.wmo.data.MOHD.skipBaseColor) { materialDef.useBaseColor = true; materialDef.baseColor = this.wmo.data.MOHD.baseColor; } else { materialDef.useBaseColor = false; } const material = this.createMaterial(materialDefs[materialID], texturePaths); multiMaterial.materials[materialID] = material; }); return multiMaterial; } createMaterial(materialDef, texturePaths) { const textureDefs = []; materialDef.textures.forEach((textureDef) => { const texturePath = texturePaths[textureDef.offset]; if (texturePath !== undefined) { textureDef.path = texturePath; textureDefs.push(textureDef); } else { textureDefs.push(null); } }); const material = new WMOMaterial(materialDef, textureDefs); return material; } clone() { return new this.constructor(this.wmo, this.groupID, this.data, this.path); } dispose() { this.geometry.dispose(); this.material.materials.forEach((material) => { material.dispose(); }); } } export default WMOGroup; ================================================ FILE: src/lib/pipeline/wmo/group/loader.js ================================================ import { DecodeStream } from 'blizzardry/lib/restructure'; import WMOGroup from 'blizzardry/lib/wmo/group'; import Loader from '../../../net/loader'; const loader = new Loader(); export default function(path) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); const data = WMOGroup.decode(stream); return data; }); } ================================================ FILE: src/lib/pipeline/wmo/index.js ================================================ import THREE from 'three'; class WMO extends THREE.Group { static cache = {}; constructor(path, data) { super(); this.matrixAutoUpdate = false; this.path = path; this.data = data; this.groupCount = data.MOHD.groupCount; this.groups = new Map(); this.indoorGroupIDs = []; this.outdoorGroupIDs = []; // Separate group IDs by indoor/outdoor flag. This allows us to queue outdoor groups to // load before indoor groups. for (let i = 0; i < this.groupCount; ++i) { const group = data.MOGI.groups[i]; if (group.indoor) { this.indoorGroupIDs.push(i); } else { this.outdoorGroupIDs.push(i); } } } doodadSet(doodadSet) { const set = this.data.MODS.sets[doodadSet]; const { startIndex: start, doodadCount: count } = set; const entries = this.data.MODD.doodads.slice(start, start + count); return entries; } clone() { return new this.constructor(this.path, this.data); } } export default WMO; ================================================ FILE: src/lib/pipeline/wmo/loader.js ================================================ import { DecodeStream } from 'blizzardry/lib/restructure'; import WMO from 'blizzardry/lib/wmo'; import Loader from '../../net/loader'; const loader = new Loader(); export default function(path) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); const data = WMO.decode(stream); return data; }); } ================================================ FILE: src/lib/pipeline/wmo/material/index.js ================================================ import THREE from 'three'; import TextureLoader from '../../texture-loader'; import vertexShader from './shader.vert'; import fragmentShader from './shader.frag'; class WMOMaterial extends THREE.ShaderMaterial { constructor(def, textureDefs) { super(); this.textures = []; this.uniforms = { textures: { type: 'tv', value: [] }, textureCount: { type: 'i', value: 0 }, blendingMode: { type: 'i', value: def.blendMode }, useBaseColor: { type: 'i', value: 0 }, baseColor: { type: 'c', value: new THREE.Color(0, 0, 0) }, baseAlpha: { type: 'f', value: 0.0 }, indoor: { type: 'i', value: 0 }, // Managed by light manager lightModifier: { type: 'f', value: 1.0 }, ambientLight: { type: 'c', value: new THREE.Color(0.5, 0.5, 0.5) }, diffuseLight: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, // Managed by light manager fogModifier: { type: 'f', value: 1.0 }, fogColor: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, fogStart: { type: 'f', value: 5.0 }, fogEnd: { type: 'f', value: 400.0 } }; if (def.useBaseColor) { const baseColor = new THREE.Color( def.baseColor.r / 255.0, def.baseColor.g / 255.0, def.baseColor.b / 255.0 ); const baseAlpha = def.baseColor.a / 255.0; this.uniforms.useBaseColor = { type: 'i', value: 1 }; this.uniforms.baseColor = { type: 'c', value: baseColor }; this.uniforms.baseAlpha = { type: 'f', value: baseAlpha }; } // Tag lighting mode (based on group flags) if (def.indoor) { this.uniforms.indoor = { type: 'i', value: 1 }; } // Flag 0x01 (unlit) // TODO: This is really only unlit at night. Needs to integrate with the light manager in // some fashion. if (def.flags & 0x10) { this.uniforms.lightModifier = { type: 'f', value: 0.0 }; } // Transparent blending if (def.blendMode === 1) { this.transparent = true; this.side = THREE.DoubleSide; } // Flag 0x04: no backface culling if (def.flags & 0x04) { this.side = THREE.DoubleSide; } // Flag 0x40: clamp to edge if (def.flags & 0x40) { this.wrapping = THREE.ClampToEdgeWrapping; } else { this.wrapping = THREE.RepeatWrapping; } this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; this.loadTextures(textureDefs); } // TODO: Handle texture flags and color. loadTextures(textureDefs) { const textures = []; textureDefs.forEach((textureDef) => { if (textureDef !== null) { const texture = TextureLoader.load(textureDef.path, this.wrapping, this.wrapping, false); textures.push(texture); } }); this.textures = textures; // Update shader uniforms to reflect loaded textures. this.uniforms.textures = { type: 'tv', value: textures }; this.uniforms.textureCount = { type: 'i', value: textures.length }; } dispose() { super.dispose(); this.textures.forEach((texture) => { TextureLoader.unload(texture); }); } } export default WMOMaterial; ================================================ FILE: src/lib/pipeline/wmo/material/shader.frag ================================================ varying vec2 vUv; varying vec4 vertexColor; varying vec3 vertexWorldNormal; varying float cameraDistance; uniform int textureCount; uniform sampler2D textures[4]; uniform int blendingMode; uniform float lightModifier; uniform vec3 ambientLight; uniform vec3 diffuseLight; uniform float fogModifier; uniform float fogStart; uniform float fogEnd; uniform vec3 fogColor; uniform int indoor; // Given a light direction and normal, return a directed diffuse light. vec3 createGlobalLight(vec3 lightDirection, vec3 lightNormal, vec3 diffuseLight, vec3 ambientLight) { float light = dot(lightNormal, -lightDirection); if (light < 0.0) { light = 0.0; } else if (light > 0.5) { light = 0.5 + ((light - 0.5) * 0.65); } vec3 directedDiffuseLight = diffuseLight.rgb * light; directedDiffuseLight.rgb += ambientLight.rgb; directedDiffuseLight = saturate(directedDiffuseLight); return directedDiffuseLight; } vec4 applyFog(vec4 color) { float fogFactor = (fogEnd - cameraDistance) / (fogEnd - fogStart); fogFactor = 1.0 - clamp(fogFactor, 0.0, 1.0); float fogColorFactor = fogFactor * fogModifier; color.rgb = mix(color.rgb, fogColor.rgb, fogColorFactor); // Ensure certain blending mode pixels become fully opaque by fog end. if (cameraDistance >= fogEnd) { color.rgb = fogColor.rgb; color.a = 1.0; } return color; } vec4 lightIndoor(vec4 color, vec4 vertexColor, vec3 light) { vec3 groupColor = vertexColor.rgb; vec3 indoorLight; indoorLight = (vertexColor.a * light.rgb) + ((1.0 - vertexColor.a) * groupColor); indoorLight.rgb = saturate(indoorLight.rgb); color.rgb *= indoorLight; return color; } vec4 lightOutdoor(vec4 color, vec4 vertexColor, vec3 light) { vec3 outdoorLight = light.rgb += (vertexColor.rgb * 2.0); outdoorLight.rgb = saturate(outdoorLight.rgb); color.rgb *= outdoorLight; return color; } void main() { vec3 lightDirection = normalize(vec3(-1, -1, -1)); vec3 lightNormal = normalize(vertexWorldNormal); vec3 globalLight = createGlobalLight(lightDirection, lightNormal, diffuseLight, ambientLight); // Base layer vec4 color = texture2D(textures[0], vUv); // Knock out transparent pixels in transparent blending mode (1). if (blendingMode == 1 && color.a < (10.0 / 255.0)) { discard; } // Force transparent pixels to fully opaque if in opaque blending mode (0). Needed to prevent // transparent pixels from becoming inappropriately bright. if (blendingMode == 0) { color.a = 1.0; } if (lightModifier > 0.0) { if (indoor == 1) { color = lightIndoor(color, vertexColor, globalLight); } else { color = lightOutdoor(color, vertexColor, globalLight); } } color = applyFog(color); gl_FragColor = color; } ================================================ FILE: src/lib/pipeline/wmo/material/shader.vert ================================================ precision highp float; varying vec2 vUv; varying vec3 vertexWorldNormal; varying float cameraDistance; attribute vec3 color; attribute float alpha; varying vec4 vertexColor; uniform int indoor; uniform int useBaseColor; uniform vec3 baseColor; uniform float baseAlpha; vec4 saturate(vec4 value) { vec4 result = clamp(value, 0.0, 1.0); return result; } vec3 saturate(vec3 value) { vec3 result = clamp(value, 0.0, 1.0); return result; } float saturate(float value) { float result = clamp(value, 0.0, 1.0); return result; } void main() { vUv = uv; vertexColor = vec4(color, alpha); if (indoor == 1 && useBaseColor == 1) { vertexColor.rgb = saturate(vertexColor.rgb + baseColor.rgb); vertexColor.a = saturate(mod(vertexColor.a, 1.0) + (1.0 - baseAlpha)); } vec3 vertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; cameraDistance = distance(cameraPosition, vertexWorldPosition); vertexWorldNormal = (modelMatrix * vec4(normal, 0.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ================================================ FILE: src/lib/pipeline/worker/index.js ================================================ import ADT from '../adt/loader'; import DBC from '../dbc/loader'; import M2 from '../m2/loader'; import WDT from '../wdt/loader'; import WMO from '../wmo/loader'; import WMOGroup from '../wmo/group/loader'; const worker = self; const loaders = { ADT, DBC, M2, WDT, WMO, WMOGroup }; const fulfill = function(type, result) { worker.postMessage([type].concat(result)); }; const resolve = function(value) { fulfill(true, value); }; const reject = function(error) { fulfill(false, error.toString()); }; worker.addEventListener('message', (event) => { const [loader, ...args] = event.data; if (loader in loaders) { loaders[loader](...args).then(function(result) { resolve(result); }).catch((error) => { reject(error); }); } else { reject(new Error(`Invalid loader: ${loader}`)); } }); ================================================ FILE: src/lib/pipeline/worker/pool.js ================================================ import Task from './task'; import Thread from './thread'; class WorkerPool { constructor(concurrency = this.defaultConcurrency) { this.concurrency = concurrency; this.queue = []; this.threads = []; this.next = ::this.next; } get defaultConcurrency() { return navigator.hardwareConcurrency || 4; } get thread() { let thread = this.threads.find(current => current.idle); if (thread) { return thread; } if (this.threads.length < this.concurrency) { thread = new Thread(); this.threads.push(thread); return thread; } } enqueue(...args) { const task = new Task(...args); this.queue.push(task); this.next(); return task.promise; } next() { if (this.queue.length) { const thread = this.thread; if (thread) { const task = this.queue.shift(); thread.execute(task).then(this.next).catch(this.next); } } } } export { WorkerPool }; export default new WorkerPool(); ================================================ FILE: src/lib/pipeline/worker/task.js ================================================ import Promise from 'bluebird'; class Task { constructor(...args) { this.args = args; this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); } } export default Task; ================================================ FILE: src/lib/pipeline/worker/thread.js ================================================ import Worker from 'worker!./'; class Thread { constructor() { this._onMessage = ::this._onMessage; this.worker = new Worker(); this.worker.addEventListener('message', this._onMessage); } get busy() { return !!this.task; } get idle() { return !this.busy; } execute(task) { this.task = task; this.worker.postMessage(task.args); return this.task.promise; } _onMessage(event) { const [success, ...args] = event.data; if (success) { this.task.resolve(args); } else { this.task.reject(args); } this.task = null; } } export default Thread; ================================================ FILE: src/lib/realms/handler.js ================================================ import EventEmitter from 'events'; import AuthOpcode from '../auth/opcode'; import AuthPacket from '../auth/packet'; import Realm from './realm'; class RealmsHandler extends EventEmitter { // Creates a new realm handler constructor(session) { super(); // Holds session this.session = session; // Initially empty list of realms this.list = []; // Listen for realm list this.session.auth.on('packet:receive:REALM_LIST', ::this.handleRealmList); } // Requests a fresh list of realms refresh() { console.info('refreshing realmlist'); const ap = new AuthPacket(AuthOpcode.REALM_LIST, 1 + 4); // Per WoWDev, the opcode is followed by an unknown uint32 ap.writeUnsignedInt(0x00); return this.session.auth.send(ap); } // Realm list refresh handler (REALM_LIST) handleRealmList(ap) { ap.readShort(); // packet-size ap.readUnsignedInt(); // (?) const count = ap.readShort(); // number of realms this.list.length = 0; for (let i = 0; i < count; ++i) { const realm = new Realm(); realm.icon = ap.readUnsignedByte(); realm.lock = ap.readUnsignedByte(); realm.flags = ap.readUnsignedByte(); realm.name = ap.readCString(); realm.address = ap.readCString(); realm.population = ap.readFloat(); realm.characters = ap.readUnsignedByte(); realm.timezone = ap.readUnsignedByte(); realm.id = ap.readUnsignedByte(); // TODO: Introduce magic constants such as REALM_FLAG_SPECIFYBUILD if (realm.flags & 0x04) { realm.majorVersion = ap.readUnsignedByte(); realm.minorVersion = ap.readUnsignedByte(); realm.patchVersion = ap.readUnsignedByte(); realm.build = ap.readUnsignedShort(); } this.list.push(realm); } this.emit('refresh'); } } export default RealmsHandler; ================================================ FILE: src/lib/realms/realm.js ================================================ class Realm { // Creates a new realm constructor() { // Holds host, port and address this._host = null; this._port = NaN; this._address = null; // Holds realm attributes this.name = null; this.id = null; this.icon = null; this.flags = null; this.timezone = null; this.population = 0.0; this.characters = 0; this.majorVersion = null; this.minorVersion = null; this.patchVersion = null; this.build = null; } // Short string representation of this realm toString() { return `[Realm; Name: ${this.name}; Address: ${this._address}; Characters: ${this.characters}]`; } // Retrieves host for this realm get host() { return this._host; } // Retrieves port for this realm get port() { return this._port; } // Retrieves address for this realm get address() { return this._address; } // Sets address for this realm set address(address) { this._address = address; const parts = this._address.split(':'); this._host = parts[0] || null; this._port = parts[1] || NaN; } } export default Realm; ================================================ FILE: src/lib/server/.babelrc ================================================ { "plugins": [ "transform-class-properties", "transform-export-extensions", "transform-function-bind", "transform-es2015-block-scoping", "transform-es2015-parameters", "add-module-exports", "transform-es2015-modules-commonjs" ] } ================================================ FILE: src/lib/server/cluster.js ================================================ import cluster from 'cluster'; import Server from './'; import ServerConfig from './config'; class Cluster { get clustered() { return this.workerCount > 1; } get workerCount() { return ServerConfig.db.get('clusterWorkerCount'); } get serverPort() { return ServerConfig.db.get('serverPort'); } start() { if (!this.clustered || cluster.isMaster) { console.log(`\n> Settings loaded from ${ServerConfig.db.path}`); console.log("> Use 'npm run reset' to clear settings\n"); console.log(`> Starting server at localhost:${this.serverPort}\n`); } if (this.clustered && cluster.isMaster) { for (let i = 0; i < this.workerCount; ++i) { cluster.fork(); } } else { this.spawn(); } } spawn() { if (this.clustered) { console.log(`> Spawning worker (#${cluster.worker.id})`); } (new Server(this.serverPort)).start(); } } export default Cluster; ================================================ FILE: src/lib/server/config/index.js ================================================ import Configstore from 'configstore'; import Promise from 'bluebird'; import inquirer from 'inquirer'; import pkg from '../../../package.json'; import prompts from './setup-prompts'; class ServerConfig { static DEFAULTS = { 'clientData': null, 'clusterWorkerCount': 1, 'isFirstRun': true, 'serverPort': '3000' }; constructor(defaults = this.constructor.DEFAULTS) { this.db = new Configstore(pkg.name, defaults); } get isFirstRun() { return this.db.get('isFirstRun'); } verify() { const promise = this.isFirstRun ? this.prompt() : Promise.resolve(); return promise.then(function() { // TODO: Verify the actual configuration and bail out when needed }); } prompt() { return new Promise((resolve, _reject) => { console.log('> Preparing initial setup\n'); inquirer.prompt(prompts, answers => { Object.keys(answers).map(key => { return this.db.set(key, answers[key]); }); this.db.set('isFirstRun', false); console.log('\n> Setup finished!'); resolve(); }); }); } } export default new ServerConfig(); ================================================ FILE: src/lib/server/config/setup-prompts.js ================================================ import fs from 'fs'; import os from 'os'; export default [ { type: 'input', name: 'clientData', message: 'Client data directory', default: function() { if (process.platform === 'win32') { return 'C:/Program Files (x86)/World of Warcraft/Data'; } return '/Applications/World of Warcraft/Data'; }, validate: function(value) { const done = this.async(); fs.lstat(value, function(err, stats) { if (err) { done('Invalid path'); } else if (stats.isDirectory()) { done(true); } else { done('Please provide path to a directory'); } }); } }, { type: 'input', name: 'serverPort', message: 'Server port', default: '3000' }, { type: 'input', name: 'clusterWorkerCount', message: 'Number of cluster workers', default: Math.ceil(os.cpus().length / 2) } ]; ================================================ FILE: src/lib/server/index.js ================================================ import express from 'express'; import logger from 'morgan'; import Pipeline from './pipeline'; class Server { constructor(port, root = process.pwd) { this.port = port; this.root = root; this.app = express(); this.app.set('root', this.root); this.app.use(logger('dev')); this.app.use(express.static('./public')); this.app.use('/pipeline', new Pipeline().router); } start() { this.app.listen(this.port); } } export default Server; ================================================ FILE: src/lib/server/pipeline/archive.js ================================================ import MPQ from 'blizzardry/lib/mpq'; import glob from 'globby'; class Archive { static CHAIN = [ 'common.MPQ', 'common-2.MPQ', 'expansion.MPQ', 'lichking.MPQ', '*/locale-*.MPQ', '*/speech-*.MPQ', '*/expansion-locale-*.MPQ', '*/lichking-locale-*.MPQ', '*/expansion-speech-*.MPQ', '*/lichking-speech-*.MPQ', '*/patch-*.MPQ', 'patch.MPQ', 'patch-*.MPQ' ]; static build(root) { const patterns = this.CHAIN.map(function(path) { return `${root}/${path}`; }); const archives = glob.sync(patterns); const base = MPQ.open(archives.shift(), MPQ.OPEN.READ_ONLY); archives.forEach(function(archive) { base.patch(archive, ''); }); return base; } } export default Archive; ================================================ FILE: src/lib/server/pipeline/index.js ================================================ import BLP from 'blizzardry/lib/blp'; import * as DBC from 'blizzardry/lib/dbc/entities'; import { DecodeStream } from 'blizzardry/lib/restructure'; import { PNG } from 'pngjs'; import express from 'express'; import find from 'array-find'; import Archive from './archive'; import ServerConfig from '../config'; class Pipeline { static get DATA_DIR() { return ServerConfig.db.get('clientData'); } constructor() { this.router = express(); this.router.param('resource', ::this.resource); this.router.get('/:resource(*.blp).png', ::this.blp); this.router.get('/:resource(*.dbc)/:id(*)?.json', ::this.dbc); this.router.get('/find/:query', ::this.find); this.router.get('/:resource', ::this.serve); } get archive() { this._archive = this._archive || Archive.build(this.constructor.DATA_DIR); return this._archive; } resource(req, _res, next, path) { req.resourcePath = path; req.resource = this.archive.files.get(path); if (req.resource) { next(); // Ensure file is closed in StormLib. req.resource.close(); } else { const err = new Error('resource not found'); err.status = 404; throw err; } } blp(req, res) { BLP.from(req.resource.data, function(blp) { const mipmap = blp.largest; const png = new PNG({ width: mipmap.width, height: mipmap.height }); png.data = mipmap.rgba; res.type('image/png'); png.pack().pipe(res); }); } dbc(req, res) { const name = req.resourcePath.match(/(\w+)\.dbc/)[1]; const definition = DBC[name]; if (definition) { const dbc = definition.dbc.decode(new DecodeStream(req.resource.data)); const id = req.params.id; if (id) { const match = find(dbc.records, function(entity) { return String(entity.id) === id; }); if (match) { res.send(match); } else { const err = new Error('entity not found'); err.status = 404; throw err; } } else { res.send(dbc.records); } } else { const err = new Error('entity definition not found'); err.status = 404; throw err; } } find(req, res) { const results = this.archive.files.find(req.params.query).map((result) => { const path = `${req.baseUrl}/${encodeURI(result.filename)}`; const link = `${req.protocol}://${req.headers.host}${path}`; return { filename: result.filename, name: result.name, size: result.fileSize, link: link }; }); res.send(results); } serve(req, res) { res.type(req.resource.name); res.send(req.resource.data); } } export default Pipeline; ================================================ FILE: src/lib/utils/array-util.js ================================================ class ArrayUtil { // Generates array from given hex string static fromHex(hex) { const array = []; for (let i = 0; i < hex.length; i += 2) { array.push(parseInt(hex.slice(i, i + 2), 16)); } return array; } } export default ArrayUtil; ================================================ FILE: src/lib/utils/object-util.js ================================================ class ObjectUtil { // Retrieves key for given value (if any) in object static keyByValue(object, target) { if (!('lookup' in object)) { const lookup = {}; for (const key in object) { if (object.hasOwnProperty(key)) { const value = object[key]; lookup[value] = key; } } object.lookup = lookup; } return object.lookup[target]; } } export default ObjectUtil; ================================================ FILE: src/spec/.eslintrc ================================================ { "env": { "mocha": true } } ================================================ FILE: src/spec/sample-spec.js ================================================ import {} from './spec-helper'; describe('Wowser', function() { xit('will have specs (hopefully)'); }); ================================================ FILE: src/spec/spec-helper.js ================================================ import bridge from 'sinon-chai'; import chai from 'chai'; import sinon from 'sinon'; chai.use(bridge); beforeEach(function() { this.sandbox = sinon.sandbox.create(); }); afterEach(function() { this.sandbox.restore(); }); export const expect = chai.expect; export sinon from 'sinon'; ================================================ FILE: webpack.config.js ================================================ const HtmlWebpackPlugin = require('html-webpack-plugin'); const path = require('path'); module.exports = { context: path.join(__dirname, 'src'), entry: './bootstrapper', output: { path: path.join(__dirname, 'public'), filename: 'wowser-[hash].js' }, resolve: { extensions: ['', '.js', '.jsx'] }, resolveLoader: { root: path.join(__dirname, 'node_modules') }, module: { loaders: [ { test: /\.json$/, loader: 'json-loader' }, { test: /\.(png|jpg)$/, loader: 'url-loader?limit=100000' }, { test: /\.styl$/, loader: 'style-loader!css-loader!stylus-loader?resolve url', exclude: /node_modules/ }, { test: /\.(frag|vert|glsl)$/, loader: 'raw-loader!glslify-loader?transform[]=glslify-import', exclude: /node_modules/ }, { test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules|blizzardry/ }, { test: /\.jsx?$/, loader: 'eslint-loader', exclude: /node_modules|blizzardry/ } ] }, plugins: [ new HtmlWebpackPlugin({ hash: true, inject: true, template: 'index.html' }) ], devServer: { contentBase: path.join(__dirname, 'public'), proxy: { '/pipeline/*': { target: 'http://localhost:3000', secure: false } } } };