diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da69d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# Distribution directory +dist/ +lib/ + +### Vim ### +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fea218 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# shared-views + +[![Build Status](https://travis-ci.org/anyWareSculpture/shared-views.svg?branch=master)](https://travis-ci.org/anyWareSculpture/shared-views) + +[![codecov.io](http://codecov.io/github/anyWareSculpture/shared-views/coverage.svg?branch=master)](http://codecov.io/github/anyWareSculpture/shared-views?branch=master) + +![codecov.io](http://codecov.io/github/anyWareSculpture/shared-views/branch.svg?branch=master) + +## Usage + +This repository contains shared views used by multiple anyWare runtimes. + +When installed (or built), modules are stored in a `lib/` directory. Thus when requiring files, make sure that you are adding that before the path of the file you are requiring. In addition, ensure that you are requiring each individual file. `require('@anyware/shared-views')` alone will not work. + +Example of correct usage: + + const AudioView = require('@anyware/shared-views/lib/audio-view); + +This was implemented this way for a variety of reasons: + +1. Requiring individual files only gets those files and their dependencies. That way it isn't necessary to include the entire library if you only need a few parts. +2. This means that we don't have to keep any `index.js` or something up to date all the time. You can access whatever you want using the `lib/` directory. + diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..69396e3 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,41 @@ +var gulp = require('gulp'); + +var runSequence = require('run-sequence'); + +var gulpUtils = require('@anyware/gulp-utils'); + +MINIMUM_CODE_COVERAGE = 90; + +// Create shared tasks +require('@anyware/gulp-utils/tasks/test-task')( + gulp, + 'test', // taskName + ['src/**/*.js', '!src/index.js'], // filesToCover + 'test/**/*-test.js', // testFiles + process.env.TRAVIS ? 'spec' : 'nyan', // reporter + MINIMUM_CODE_COVERAGE // minimumCodeCoverage +); +require('@anyware/gulp-utils/tasks/submit-coverage-task')( + gulp, + 'submit-coverage' // taskName +); +require('@anyware/gulp-utils/tasks/lint-task')( + gulp, + 'lint', // taskName + ["src/**/*.js", "test/**/*.js"] // files +); +require('@anyware/gulp-utils/tasks/transpile-task')( + gulp, + 'build', // taskName + 'src/**/*.js', // targetFiles + 'lib' // destinationDirectory +); + +gulp.task('default', function(callback) { + return runSequence('lint', 'test', 'build', callback); +}); + +gulp.task('watch', ['build'], function watch() { + gulp.watch('src/**/*.js', ['build']); +}); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..57ed1c1 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@anyware/shared-views", + "version": "1.0.0", + "description": "", + "main": "", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/anyWareSculpture/shared-views.git" + }, + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/anyWareSculpture/shared-views/issues" + }, + "homepage": "https://github.com/anyWareSculpture/shared-views#readme", + "dependencies": { + "@anyware/game-logic": "^11.1.0", + "promise-decode-audio-data": "^0.2.0" + }, + "devDependencies": { + "@anyware/gulp-utils": "^1.3.3", + "babel": "^5.4.7", + "chai": "^2.3.0", + "eslint": "^0.24.0", + "eslint-plugin-react": "^2.6.2", + "gulp": "^3.9.0", + "rewire": "^2.3.3", + "run-sequence": "^1.1.0", + "sinon": "^1.14.1" + } +} diff --git a/src/audio-api.js b/src/audio-api.js new file mode 100644 index 0000000..2c1fcac --- /dev/null +++ b/src/audio-api.js @@ -0,0 +1,139 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +require('promise-decode-audio-data'); + +// FIXME: Defer this to window.onload() ? +const context = initContext(); + +let isNode = false; + +export class Sound { + constructor({ url, loop = false, fadeIn = 0, fadeOut = fadeIn, rate = 1, gain = 1, name = path.basename(url, '.wav') } = {}) { + + assert(url); + + this.url = url; + this.params = { + loop, + fadeIn, + fadeOut, + rate, + gain + }; + this.name = name; + this.gain = context.createGain(); + if (!isNode) this.gain.connect(context.destination); + this.head = this.gain; + } + + /** + * Returns a promise to fully load all needed assets for this sound + */ + load() { + console.log('loading ' + this.url); + // FIXME: Node support: + // if (isNode) fetch = promisify(fs.readFile)(__dirname + '/../' + this.url).then(buffer => buffer); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', this.url, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = e => { + if (xhr.status == 200) resolve(xhr.response); + else reject(xhr.response); + } + xhr.onerror = e => reject(e); + xhr.send(); + }) + .then(buffer => { + console.log(`loaded ${this.url} - ${buffer.byteLength} bytes`); + if (!buffer) console.log(`Buffer error: ${this.url}`); + return context.decodeAudioData(buffer); + }) + .then(soundBuffer => { + console.log(`decoded ${this.url}`); + this.buffer = soundBuffer; + return this; + }); + } + + play() { + if (this.params.fadeIn > 0) { + this.gain.gain.setValueAtTime(0, context.currentTime); + this.gain.gain.linearRampToValueAtTime(this.params.gain, context.currentTime + this.params.fadeIn); + } + + this.source = context.createBufferSource(); + this.source.buffer = this.buffer; + this.source.loop = this.params.loop; + if (this.params.rate != 1) this.source.playbackRate.value = this.params.rate; + if (this.params.gain != 1) this.gain.gain.value = this.params.gain; + this.source.connect(this.head); + if (isNode) this.gain.connect(context.destination); + this.source.start(context.currentTime); + } + + stop() { + if (this.params.fadeOut > 0) { + var volume = this.gain.gain.value; + this.gain.gain.cancelScheduledValues(context.currentTime); + this.gain.gain.setValueAtTime(volume, context.currentTime); + this.gain.gain.linearRampToValueAtTime(0,context.currentTime + volume*this.params.fadeOut); + this.gain.gain.setValueAtTime(1, context.currentTime + volume*this.params.fadeOut); + if (this.source) this.source.stop(context.currentTime + volume*this.params.fadeOut); + } + else { + if (this.source) this.source.stop(); + } + } +} + +/** + * Sound with a VCF (Voltage Controlled Filter). The VCF is currently hardcoded since we only use it once + */ +export class VCFSound extends Sound { + constructor({ url, fadeIn = 0, fadeOut = fadeIn, name = path.basename(url, '.wav') } = {}) { + super({url, loop: true, fadeIn, fadeOut, name}); + } + + play() { + // FIXME: If running on node.js + if (!context.createBiquadFilter) return super.play(); + + const lowpass = context.createBiquadFilter(); + lowpass.Q.value = 2; + lowpass.frequency.value = 2200; + lowpass.type = 'lowpass'; + lowpass.connect(this.head); + this.head = lowpass; + + var lfogain = context.createGain(); + lfogain.gain.value = 2000; + + var lfo = context.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 0.333; + lfogain.connect(lowpass.frequency); + lfo.connect(lfogain); + lfo.start(context.currentTime); + + super.play(); + } + + stop() { + super.stop(); + } +} + +function initContext() { + if (typeof AudioContext !== "undefined") { + return new AudioContext(); + } else if (typeof NodeAudioContext !== "undefined") { + isNode = true; + return new NodeAudioContext(); + } + else { + throw new Error('AudioContext not supported. :('); + } +} diff --git a/src/audio-view.js b/src/audio-view.js new file mode 100644 index 0000000..6d72f7a --- /dev/null +++ b/src/audio-view.js @@ -0,0 +1,115 @@ +/* + Game event Sound Logic + + Enter alone mode Alone_Mode/Pulse_amb_loop FIXME + Handshake Alone_Mode/Hand_Shake_01 changes.handshakes (Set of bool) + +Mole: + Panel activated Game_01/G01_LED_XX + Active panel touched Game_01/G01_Success_01 + Non-active panel touched Game_01/G01_Negative_01 + +Disk: + +Simon: + Panel activated during pattern animation + Game_03/G03_LED_XX + Correct panel touched + Game_03/G03_LED_XX + Wrong panel touched + Game_03/G03_Negative_01 + Won level (after short delay) + Game_03/G03_Success_01 + Won all levels (after short delay) + Game_03/G03_Light_Show_01 + +*/ + +const _ = require('lodash'); + +const SculptureStore = require('@anyware/game-logic/lib/sculpture-store'); +const GAMES = require('@anyware/game-logic/lib/constants/games'); +import {Sound, VCFSound} from './audio-api'; + +export default class AudioView { + constructor(store, config, dispatcher) { + this.store = store; + this.config = config; + } + + /** + * Loads all sounds, calls callback([err]) when done + */ + load(callback) { + // Maps logical sound identifiers to filenames. We'll load these sounds next. + this.sounds = { + alone: { + ambient: new VCFSound({url: 'sounds/Alone_Mode/Pulse_Amb_Loop.wav', fadeIn: 3}), + handshake: 'sounds/Alone_Mode/Hand_Shake_01.wav' + }, + mole: { + success: 'sounds/Game_01/G01_Success_01.wav', + failure: 'sounds/Game_01/G01_Negative_01.wav', + panels: [0,1,2].map(stripId => _.range(10).map(panelId => `sounds/Game_01/G01_LED_${("0"+(stripId*10+panelId+1)).slice(-2)}.wav`)) + }, + disk: { + ambient: new Sound({url: 'sounds/Game_02/G02_Amb_Breath_Loop_01.wav', loop: true}), + loop: new Sound({url: 'sounds/Game_02/G02_Disk_Loop_Ref_01.wav', loop: true, rate: 2, gain: 0.3, fadeIn: 10}), + distance: new Sound({url: 'sounds/Game_02/G02_Disk_Loop_01.wav', loop: true, rate: 2, gain: 0.5, fadeIn: 10}), + lighteffect: 'sounds/Game_02/G02_Lights_01.wav', + success: 'sounds/Game_02/G02_Success_01.wav', + show: 'sounds/Game_02/G02_Success_final_01.wav' + }, + simon: { + panels: [0,1,2].map(stripId => _.range(10).map(panelId => `sounds/Game_03/G03_LED_${("0"+(stripId*10+panelId+1)).slice(-2)}.wav`)), + success: 'sounds/Game_03/G03_Success_01.wav', + failure: 'sounds/Game_03/G03_Negative_01.wav', + show: 'sounds/Game_03/G03_Light_Show_01.wav' + } + }; + + // Traverse this.sounds and replace the filenames with valid sound objects. + this._promises = []; + this._traverse(this.sounds, this._promises); + + // _traverse() will create promises. We call the callback once all promises resolve + console.log(`${this._promises.length} promises`); + Promise.all(this._promises) + // Don't listen to events until we've loaded all sounds + .then(() => this.store.on(SculptureStore.EVENT_CHANGE, this._handleChanges.bind(this))) + .then(() => callback(null)) + .catch(callback.bind(null)); + } + + /** + * Traverses sound config objects and replaces nodes with valid, loaded, sounds + * populates the given promises array with promises of loaded sounds + */ + _traverse(node, promises) { + for (let key in node) { + const value = node[key]; + let sound; + if (typeof value === 'string') sound = node[key] = new Sound({url: value}); + else if (value instanceof Sound) sound = value; + if (sound) promises.push(sound.load()); + else this._traverse(value, promises); + } + } + + _handleChanges(changes) { + if (this.store.isPlayingHandshakeGame) this._handleHandshakeGame(changes); + } + + _handleHandshakeGame(changes) { + // On startup, or when Start State becomes active, play ambient sound + if (changes.currentGame === GAMES.HANDSHAKE) this.sounds.alone.ambient.play(); + + if (changes.handshakes) { + // Did someone shake my hand? + if (changes.handshakes[this.config.username]) { + this.sounds.alone.ambient.stop(); + this.sounds.alone.handshake.play(); + } + } + } +}