Finish input service, get tests running

This commit is contained in:
Connor Peet 2016-10-25 11:33:19 -07:00
Родитель e78974074f
Коммит a638d840cb
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CF8FD2EA0DBC61BD
12 изменённых файлов: 764 добавлений и 484 удалений

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

@ -1,12 +1,11 @@
# top-most EditorConfig file
# editorconfig.org
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
indent_size = 2

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

@ -1,70 +1,71 @@
import { ArcModule, InputService } from '../../../../src';
import { ArcModule, InputService, FocusService } from '../../../../src';
import { Component, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
@Component({
selector: 'demo-app',
styles: [`
:host {
font-family: monospace;
}
selector: 'demo-app',
styles: [`
:host {
font-family: monospace;
}
.area {
max-width: 960px;
border: 1px solid #000;
margin: 15px auto;
.area {
max-width: 960px;
border: 1px solid #000;
margin: 15px auto;
.arc--selected {
border-color: #f00;
}
}
.arc--selected {
border-color: #f00;
}
}
.box {
width: 100px;
float: left;
margin: 15px;
background: #000;
color: #fff;
.box {
width: 100px;
float: left;
margin: 15px;
background: #000;
color: #fff;
.arc--selected {
background: #f00;
}
}
`],
template: `
<div class="area">
<div class="box" *ngFor="let box of boxes">
{{ box }}
</div>
</div>
`,
.arc--selected {
background: #f00;
}
}
`],
template: `
<div class="area">
<div class="box" *ngFor="let box of boxes">
{{ box }}
</div>
</div>
`,
})
export class DemoComponent {
public boxes: string[] = [];
public boxes: string[] = [];
constructor(input: InputService) {
for (let i = 0; i < 100; i++) {
this.boxes.push(String(`Box ${i}`));
}
input.bootstrap();
constructor(input: InputService) {
for (let i = 0; i < 100; i++) {
this.boxes.push(String(`Box ${i}`));
}
input.bootstrap();
}
}
@NgModule({
imports: [
BrowserModule,
ArcModule,
],
providers: [
InputService,
],
declarations: [
DemoComponent,
],
bootstrap: [
DemoComponent,
],
imports: [
BrowserModule,
ArcModule,
],
providers: [
FocusService,
InputService,
],
declarations: [
DemoComponent,
],
bootstrap: [
DemoComponent,
],
})
export class AppModule {
}

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

@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcade Machine</title>
<link rel="stylesheet" href="normalize.css">
<base href="/">
</head>
<body>
<demo-app></demo-app>
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcade Machine</title>
<link rel="stylesheet" href="normalize.css">
<base href="/">
</head>
<body>
<demo-app></demo-app>
</body>
</html>

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

@ -12,62 +12,61 @@ paths.indexTemplate = path.resolve(__dirname, 'src/index.ejs');
paths.tsconfig = path.resolve(__dirname, '../tsconfig.json');
module.exports = {
devtool: 'source-map',
context: paths.root,
entry: {
main: path.resolve(__dirname, 'src/bundles/main.ts'),
polyfills: path.resolve(__dirname, 'src/bundles/polyfills.ts')
},
output: {
path: paths.dist,
filename: 'bundles/[name].bundle.js',
},
resolve: {
extensions: ['.js', '.ts'],
},
devServer: {
port: 8080,
historyApiFallback: true,
contentBase: 'demo/dist'
},
module: {
devtool: 'source-map',
context: paths.root,
entry: {
main: path.resolve(__dirname, 'src/bundles/main.ts'),
polyfills: path.resolve(__dirname, 'src/bundles/polyfills.ts')
},
output: {
path: paths.dist,
filename: 'bundles/[name].bundle.js',
},
resolve: {
extensions: ['.js', '.ts'],
},
devServer: {
port: 8080,
historyApiFallback: true,
contentBase: 'demo/dist'
},
module: {
loaders: [
{
test: /\.ts$/,
loaders: [
{
test: /\.ts$/,
loaders: [
{
loader: 'awesome-typescript-loader',
query: {
useForkChecker: true,
tsconfig: paths.tsconfig
}
},
],
{
loader: 'awesome-typescript-loader',
query: {
useForkChecker: true,
tsconfig: paths.tsconfig
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: paths.indexTemplate,
hash: true,
chunksSortMode: (a, b) => chunkOrder.indexOf(a.names[0]) > chunkOrder.indexOf(b.names[0]),
}),
},
],
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: paths.indexTemplate,
hash: true,
chunksSortMode: (a, b) => chunkOrder.indexOf(a.names[0]) > chunkOrder.indexOf(b.names[0]),
}),
new atl.ForkCheckerPlugin(),
new atl.ForkCheckerPlugin(),
// Fix for critical dependency warning due to System.import in angular.
// See https://github.com/angular/angular/issues/11580
new webpack.ContextReplacementPlugin(
/angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/,
paths.app
),
],
node: {
fs: 'empty',
crypto: 'empty',
module: false,
clearImmediate: false,
setImmediate: false
}
// Fix for critical dependency warning due to System.import in angular.
// See https://github.com/angular/angular/issues/11580
new webpack.ContextReplacementPlugin(
/angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/,
paths.app
),
],
node: {
fs: 'empty',
crypto: 'empty',
module: false,
clearImmediate: false,
setImmediate: false
}
};

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

@ -17,13 +17,13 @@ let isTestRun = false;
* Inline the templates and styles, and the compile to javascript.
*/
gulp.task(':build:app', () => {
const tsProject = tsc.createProject('tsconfig.json', {
module: isTestRun ? 'commonjs' : 'es2015',
});
const tsProject = tsc.createProject('tsconfig.json', {
module: isTestRun ? 'commonjs' : 'es2015',
});
gulp.src(['./src/**/*.ts'])
.pipe(tsProject())
.pipe(gulp.dest('./dist'));
gulp.src(['./src/**/*.ts'])
.pipe(tsProject())
.pipe(gulp.dest('./dist'));
});
/**
@ -35,75 +35,74 @@ gulp.task('clean', () => gulp.src('dist', { read: false }).pipe(gulpClean(null)
* Builds the main framework to the build folder
*/
gulp.task('build', (cb) => gulpRunSequence(
'clean',
[
':build:app',
],
cb
'clean',
[
':build:app',
],
cb
));
/**
* Bundles vendor files for test access
*/
gulp.task(':test:vendor', function () {
const npmVendorFiles = [
'@angular', 'core-js/client', 'systemjs/dist', 'zone.js/dist'
];
const npmVendorFiles = [
'@angular', 'core-js/client', 'rxjs', 'systemjs/dist', 'zone.js/dist'
];
return gulpMerge(
npmVendorFiles.map(function (root) {
const glob = path.join(root, '**/*.+(js|js.map)');
return gulpMerge(npmVendorFiles.map(function (root) {
const glob = path.join(root, '**/*.+(js|js.map)');
return gulp.src(path.join('node_modules', glob))
.pipe(gulp.dest(path.join('dist/vendor', root)));
}));
return gulp.src(path.join('node_modules', glob))
.pipe(gulp.dest(path.join('dist/vendor', root)));
}));
});
/**
* Bundles systemjs files
*/
gulp.task(':test:system', () => {
gulp.src('test/bin/**.*')
.pipe(tsc())
.pipe(gulp.dest('dist/bin'));
gulp.src('test/bin/**.*')
.pipe(tsc())
.pipe(gulp.dest('dist/bin'));
});
/**
* Pre-test setup task
*/
gulp.task(':test:deps', (cb) => {
isTestRun = true;
gulpRunSequence(
'clean',
[
':test:system',
':test:vendor',
':build:app',
],
cb
);
isTestRun = true;
gulpRunSequence(
'clean',
[
':test:system',
':test:vendor',
':build:app',
],
cb
);
});
/**
* Karma unit-testing
*/
gulp.task('test', [':test:deps'], (done) => {
new karma.Server({
configFile: path.join(process.cwd(), 'test/karma.confloader.js')
}, done).start();
new karma.Server({
configFile: path.join(process.cwd(), 'test/karma.confloader.js')
}, done).start();
});
gulp.task(':demo:clean', () => gulp.src('demo/dist', { read: false }).pipe(gulpClean(null)));
gulp.task(':demo:build:ts', (cb) => {
webpack(require('./demo/webpack.config.js'), cb);
webpack(require('./demo/webpack.config.js'), cb);
});
gulp.task('demo', (cb) => gulpRunSequence(
':demo:clean',
[
':demo:build:ts',
],
cb
':demo:clean',
[
':demo:build:ts',
],
cb
));

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

@ -8,7 +8,7 @@
"clean": "rimraf node_modules doc dist && npm cache clean",
"test:lint": "tslint 'src/**/*.ts' --project tsconfig.json",
"test:unit": "gulp test",
"test": "npm run lint && npm run test:unit",
"test": "npm-run-all --parallel test:lint test:unit",
"start": "npm run serve",
"serve": "webpack-dev-server --config demo/webpack.config.js"
},
@ -22,16 +22,15 @@
"@angular/platform-browser": "^2.0.1",
"@angular/platform-browser-dynamic": "^2.0.1",
"@types/core-js": "^0.9.34",
"@types/node": "^6.0.40",
"core-js": "^2.4.1",
"rxjs": "^5.0.0-rc.1",
"systemjs": "0.19.38",
"zone.js": "^0.6.23"
},
"devDependencies": {
"@types/jasmine": "^2.2.34",
"@types/karma": "^0.13.33",
"@types/jasmine": "^2.5.35",
"@types/lodash": "^4.14.36",
"@types/node": "^6.0.41",
"@types/node": "^6.0.45",
"autoprefixer": "^6.5.0",
"awesome-typescript-loader": "^2.2.4",
"clean-webpack-plugin": "^0.1.10",
@ -45,18 +44,18 @@
"gulp-typescript": "^3.0.1",
"html-webpack-plugin": "^2.22.0",
"istanbul-instrumenter-loader": "^0.2.0",
"jasmine-core": "^2.3.4",
"jasmine-spec-reporter": "^2.4.0",
"jasmine-core": "^2.5.2",
"jasmine-spec-reporter": "^2.7.0",
"karma": "^1.1.1",
"karma-browserstack-launcher": "^1.0.1",
"karma-chrome-launcher": "^1.0.1",
"karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.0.0",
"karma-jasmine": "^1.0.2",
"karma-mocha-reporter": "^2.2.0",
"karma-sauce-launcher": "^1.0.0",
"karma-sourcemap-loader": "^0.3.7",
"merge2": "^1.0.2",
"npm-run-all": "^3.1.1",
"phantomjs-prebuilt": "^2.1.4",
"reflect-metadata": "^0.1.8",
"rimraf": "^2.5.1",

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

@ -1,8 +1,22 @@
import { Injectable } from '@angular/core';
export enum Direction {
LEFT,
RIGHT,
UP,
DOWN,
SUBMIT,
BACK,
LEFT,
RIGHT,
UP,
DOWN,
SUBMIT,
BACK,
}
@Injectable()
export class FocusService {
/**
* Attempts to effect the focus command, returning a
* boolean if it was handled.
*/
public fire(direction: Direction): boolean {
return true;
}
}

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

@ -1,10 +1,11 @@
import { NgModule } from '@angular/core';
export { InputService } from './input.service';
export { FocusService } from './focus.service';
@NgModule({
imports: [],
exports: [],
imports: [],
exports: [],
})
export class ArcModule {
}

140
src/input.service.spec.ts Normal file
Просмотреть файл

@ -0,0 +1,140 @@
import { Direction } from './focus.service';
import { InputService } from './input.service';
describe('input service', () => {
let fire: jasmine.Spy;
let input: InputService;
beforeEach(() => {
fire = jasmine.createSpy('fire');
input = new InputService(<any> { fire });
});
afterEach(() => input.teardown());
// from http://stackoverflow.com/questions/18001169/how-do-i-trigger-a-keyup
// -keydown-event-in-an-angularjs-unit-test
const sendKeyDown = (target: HTMLElement, keyCode: number) => {
const e = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
shiftKey: true,
});
delete e.keyCode;
Object.defineProperty(e, 'keyCode', { value: keyCode });
target.dispatchEvent(e);
};
describe('keyboard events', () => {
beforeEach(() => input.bootstrap());
it('triggers key events', () => {
sendKeyDown(document.body, 40);
expect(fire).toHaveBeenCalledWith(Direction.DOWN);
});
it('does not trigger events when defaults have been prevented', () => {
const handler = (ev: KeyboardEvent) => ev.preventDefault();
document.body.addEventListener('keydown', handler);
sendKeyDown(document.body, 40);
expect(fire).not.toHaveBeenCalledWith(Direction.DOWN);
});
});
it('enables virtual keyboards when they\'re present', () => {
const nav: any = navigator;
nav.gamepadInputEmulation = 'mouse';
input.bootstrap();
expect(nav.gamepadInputEmulation).toEqual('keyboard');
input.teardown();
expect(nav.gamepadInputEmulation).toEqual('mouse');
delete nav.gamepadInputEmulation;
});
describe('gamepads', () => {
beforeEach(() => input.bootstrap());
const createFakeGamepad = () => {
const pad = {
id: 'xbox one controller',
connected: true,
axes: [0, 0],
buttons: <{ pressed: boolean }[]> [],
};
for (let i = 0; i < 15; i++) {
pad.buttons.push({ pressed: false });
}
return pad;
};
const afterTwoFrames = (fn: () => void) => {
requestAnimationFrame(() => requestAnimationFrame(fn));
};
it('polls gamepads when they are connected, and disconnects', done => {
const pad = createFakeGamepad();
input.gamepadSrc.next({ gamepad: <any> pad });
pad.axes[0] = -1;
pad.buttons[0].pressed = true;
afterTwoFrames(() => {
expect(fire).toHaveBeenCalledWith(Direction.LEFT);
expect(fire).toHaveBeenCalledWith(Direction.SUBMIT);
expect((<any> input).pollRaf).toBeTruthy();
pad.connected = false;
afterTwoFrames(() => {
expect((<any> input).pollRaf).toBeNull();
done();
})
});
});
it('starts triggering fast presses after an amount of time', done => {
const pad = createFakeGamepad();
input.gamepadSrc.next({ gamepad: <any> pad });
pad.axes[0] = -1;
setTimeout(() => expect(fire.calls.count()).toEqual(1), 400);
setTimeout(() => expect(fire.calls.count()).toEqual(2), 550);
setTimeout(() => {
expect(fire.calls.count()).toEqual(4);
done();
}, 850);
});
it('starts triggers when joysticks are newly moved', done => {
const pad = createFakeGamepad();
input.gamepadSrc.next({ gamepad: <any> pad });
pad.axes[0] = -1;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(1);
pad.axes[0] = 0;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(1);
pad.axes[0] = -1;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(2);
done();
});
});
});
});
it('starts triggers when buttons are newly pressed', done => {
const pad = createFakeGamepad();
input.gamepadSrc.next({ gamepad: <any> pad });
pad.buttons[0].pressed = true;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(1);
pad.buttons[0].pressed = false;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(1);
pad.buttons[0].pressed = true;
afterTwoFrames(() => {
expect(fire.calls.count()).toEqual(2);
done();
});
});
});
});
});
});

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

@ -1,122 +1,173 @@
import { Direction } from './focus.service';
import { Direction, FocusService } from './focus.service';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
interface GamepadWrapper {
// Directional returns from the gamepad. They debounce themselves and
// trigger again after debounce times.
left(now: number): boolean;
right(now: number): boolean;
up(now: number): boolean;
down(now: number): boolean;
import 'rxjs/add/observable/fromEvent';
import 'rxjs/add/observable/merge';
/**
* Returns if the user is pressing the "back" button.
*/
back(now: number): boolean;
interface IGamepadWrapper {
// Directional returns from the gamepad. They debounce themselves and
// trigger again after debounce times.
left(now: number): boolean;
right(now: number): boolean;
up(now: number): boolean;
down(now: number): boolean;
/**
* Returns if the user is pressing the "submit" button.
*/
submit(now: number): boolean;
/**
* Returns if the user is pressing the "back" button.
*/
back(now: number): boolean;
/**
* Returns whether the gamepad is still connected;
*/
isConnected(): boolean;
/**
* Returns if the user is pressing the "submit" button.
*/
submit(now: number): boolean;
/**
* Returns whether the gamepad is still connected;
*/
isConnected(): boolean;
}
enum DebouncerStage {
IDLE,
HELD,
FAST,
IDLE,
HELD,
FAST,
}
/**
* DirectionalDebouncer debounces directional navigation like arrow keys,
* handling "holding" states.
*/
class DirectionalDebouncer {
/**
* fn is a bound function that can be called to check if the key is held.
*/
public fn: (time: number) => boolean;
/**
* fn is a bound function that can be called to check if the key is held.
*/
public fn: (time: number) => boolean;
/**
* Initial debounce after a joystick is pressed before beginning shorter
* press debouncded.
*/
public static JoystickInitialDebounce = 500;
/**
* Initial debounce after a joystick is pressed before beginning shorter
* press debouncded.
*/
public static initialDebounce = 500;
/**
* Fast debounce time for joysticks when they're being held in a direction.
*/
public static JoystickFastDebounce = 200;
/**
* Fast debounce time for joysticks when they're being held in a direction.
*/
public static fastDebounce = 150;
private heldAt = 0;
private stage = DebouncerStage.IDLE;
private heldAt = 0;
private stage = DebouncerStage.IDLE;
constructor(private predicate: () => boolean) {}
constructor(private predicate: () => boolean) {}
/**
* Returns whether the key should be registered as pressed.
*/
public attempt(now: number): boolean {
const result = this.predicate();
if (!result) {
this.stage = DebouncerStage.IDLE;
return false;
}
switch (this.stage) {
case DebouncerStage.IDLE:
this.stage = DebouncerStage.HELD;
return true;
case DebouncerStage.HELD:
if (now - this.heldAt < DirectionalDebouncer.JoystickInitialDebounce) {
this.heldAt = now;
return false;
}
this.stage = DebouncerStage.FAST;
return true;
case DebouncerStage.FAST:
if (now - this.heldAt < DirectionalDebouncer.JoystickFastDebounce) {
this.heldAt = now;
return false;
}
return true;
default:
throw new Error(`Unknown debouncer stage ${this.stage}!`);
}
/**
* Returns whether the key should be registered as pressed.
*/
public attempt(now: number): boolean {
const result = this.predicate();
if (!result) {
this.stage = DebouncerStage.IDLE;
return false;
}
switch (this.stage) {
case DebouncerStage.IDLE:
this.stage = DebouncerStage.HELD;
this.heldAt = now;
return true;
case DebouncerStage.HELD:
if (now - this.heldAt < DirectionalDebouncer.initialDebounce) {
return false;
}
this.heldAt = now;
this.stage = DebouncerStage.FAST;
return true;
case DebouncerStage.FAST:
if (now - this.heldAt < DirectionalDebouncer.fastDebounce) {
return false;
}
this.heldAt = now;
return true;
default:
throw new Error(`Unknown debouncer stage ${this.stage}!`);
}
}
}
class XboxGamepadWrapper implements GamepadWrapper {
public left: (now: number) => boolean;
public right: (now: number) => boolean;
public up: (now: number) => boolean;
public down: (now: number) => boolean;
public back: (now: number) => boolean;
public submit: (now: number) => boolean;
/**
* FiredDebouncer handles single "fired" states that happen from button presses.
*/
class FiredDebouncer {
private fired = false;
constructor(private pad: Gamepad) {
const left = new DirectionalDebouncer(() => pad.axes[0] < -InputService.JoystickThreshold);
const right = new DirectionalDebouncer(() => pad.axes[0] > InputService.JoystickThreshold);
const up = new DirectionalDebouncer(() => pad.axes[1] < -InputService.JoystickThreshold);
const down = new DirectionalDebouncer(() => pad.axes[0] > InputService.JoystickThreshold);
const back = new DirectionalDebouncer(() => pad.buttons[1].pressed);
const submit = new DirectionalDebouncer(() => pad.buttons[0].pressed);
constructor(private predicate: () => boolean) {}
this.left = now => left.attempt(now);
this.right = now => right.attempt(now);
this.up = now => up.attempt(now);
this.down = now => down.attempt(now);
this.back = now => back.attempt(now);
this.submit = now => submit.attempt(now);
}
/**
* Returns whether the key should be registered as pressed.
*/
public attempt(): boolean {
const result = this.predicate();
const hadFired = this.fired;
this.fired = result;
public isConnected() {
return this.pad.connected;
}
return !hadFired && result;
}
}
class XboxGamepadWrapper implements IGamepadWrapper {
/**
* Mangitude that joysticks have to go in one direction to be translated
* into a direction key press.
*/
public static joystickThreshold = 0.5;
public left: (now: number) => boolean;
public right: (now: number) => boolean;
public up: (now: number) => boolean;
public down: (now: number) => boolean;
public back: (now: number) => boolean;
public submit: (now: number) => boolean;
constructor(private pad: Gamepad) {
const left = new DirectionalDebouncer(() => {
/* left joystick */ /* left dpad arrow */
return pad.axes[0] < -XboxGamepadWrapper.joystickThreshold || pad.buttons[13].pressed;
});
const right = new DirectionalDebouncer(() => {
/* right joystick */ /* right dpad arrow */
return pad.axes[0] > XboxGamepadWrapper.joystickThreshold || pad.buttons[14].pressed;
});
const up = new DirectionalDebouncer(() => {
/* up joystick */ /* up dpad arrow */
return pad.axes[1] < -XboxGamepadWrapper.joystickThreshold || pad.buttons[11].pressed;
});
const down = new DirectionalDebouncer(() => {
/* down joystick */ /* down dpad arrow */
return pad.axes[1] > XboxGamepadWrapper.joystickThreshold || pad.buttons[12].pressed;
});
const back = new FiredDebouncer(() => pad.buttons[1].pressed); // B button
const submit = new FiredDebouncer(() => pad.buttons[0].pressed); // A button
this.left = now => left.attempt(now);
this.right = now => right.attempt(now);
this.up = now => up.attempt(now);
this.down = now => down.attempt(now);
this.back = () => back.attempt();
this.submit = () => submit.attempt();
}
public isConnected() {
return this.pad.connected;
}
}
/**
@ -126,143 +177,223 @@ class XboxGamepadWrapper implements GamepadWrapper {
@Injectable()
export class InputService {
/**
* Mangitude that joysticks have to go in one direction to be translated
* into a direction key press.
*/
public static JoystickThreshold = 0.5;
/**
* DirectionCodes is a map of directions to key code names.
*/
public static directionCodes = new Map<Direction, number[]>([
[Direction.LEFT, [
37, // LeftArrow
214, // GamepadLeftThumbstickLeft
205, // GamepadDPadLeft
140, // NavigationLeft
]],
[Direction.RIGHT, [
39, // RightArrow
213, // GamepadLeftThumbstickRight
206, // GamepadDPadRight
141, // NavigationRight
]],
[Direction.UP, [
38, // UpArrow
211, // GamepadLeftThumbstickUp
203, // GamepadDPadUp
138, // NavigationUp
]],
[Direction.DOWN, [
40, // UpArrow
212, // GamepadLeftThumbstickDown
204, // GamepadDPadDown
139, // NavigationDown
]],
[Direction.SUBMIT, [
13, // Enter
142, // NavigationAccept
195, // GamepadA
]],
[Direction.BACK, [
8, // Backspace
196, // GamepadB
]],
]);
/**
* DirectionCodes is a map of directions to key code names.
*/
public static DirectionCodes = new Map<Direction, number[]>([
[Direction.DOWN, []]
]);
/**
* Mock source for gamepad connections. You can provide gamepads manually
* here, but this is mostly for testing purposes.
*/
public gamepadSrc = new Subject<{ gamepad: Gamepad }>();
private gamepads: GamepadWrapper[];
private pollRaf: number;
/**
* Mock source for keyboard events. You can provide events manually
* here, but this is mostly for testing purposes.
*/
public keyboardSrc = new Subject<{
defaultPrevented: boolean,
keyCode: number,
preventDefault: () => void,
}>();
/**
* Bootstrap attaches event listeners from the service to the DOM.
*/
public bootstrap() {
// The gamepadInputEmulation is a string property that exists in
// JavaScript UWAs and in WebViews in UWAs. It won't exist in
// Win8.1 style apps or browsers.
if (typeof (<any> navigator).gamepadInputEmulation === "string") {
// We want the gamepad to provide gamepad VK keyboard events rather than moving a
// mouse like cursor. Set to "keyboard", the gamepad will provide such keyboard events
// and provide input to the DOM navigator.getGamepads API.
(<any> navigator).gamepadInputEmulation = "keyboard";
} else if (typeof navigator.getGamepads === 'function') {
// Otherwise poll for connected gamepads and use that for input.
this.watchForGamepad();
private gamepads: IGamepadWrapper[] = [];
private subscriptions: Subscription[] = [];
private pollRaf: number = null;
constructor(private focus: FocusService) {}
/**
* Bootstrap attaches event listeners from the service to the DOM.
*/
public bootstrap() {
// The gamepadInputEmulation is a string property that exists in
// JavaScript UWAs and in WebViews in UWAs. It won't exist in
// Win8.1 style apps or browsers.
if ('gamepadInputEmulation' in navigator) {
// We want the gamepad to provide gamepad VK keyboard events rather than moving a
// mouse like cursor. Set to "keyboard", the gamepad will provide such keyboard events
// and provide input to the DOM navigator.getGamepads API.
(<any> navigator).gamepadInputEmulation = 'keyboard';
} else if (typeof navigator.getGamepads === 'function') {
// Otherwise poll for connected gamepads and use that for input.
this.watchForGamepad();
}
this.addKeyboardListeners();
}
/**
* Unregisters all listeners and frees resources associated with the service.
*/
public teardown() {
this.gamepads = [];
cancelAnimationFrame(this.pollRaf);
while (this.subscriptions.length) {
this.subscriptions.pop().unsubscribe();
}
if ('gamepadInputEmulation' in navigator) {
(<any> navigator).gamepadInputEmulation = 'mouse';
}
}
/**
* Detects any connected gamepads and watches for new ones to start
* polling them. This is the entry point for gamepad input handling.
*/
private watchForGamepad() {
const addGamepad = (pad: Gamepad) => {
let gamepad: IGamepadWrapper;
if (/xbox/i.test(pad.id)) {
gamepad = new XboxGamepadWrapper(pad);
}
if (!gamepad) {
// We can try, at least ¯\_(ツ)_/¯ and this should
// usually be OK due to remapping.
gamepad = new XboxGamepadWrapper(pad);
}
this.gamepads.push(gamepad);
};
Array.from(navigator.getGamepads())
.filter(pad => !!pad)
.forEach(addGamepad);
if (this.gamepads.length > 0) {
this.scheduleGamepadPoll();
}
this.subscriptions.push(
Observable.merge(
this.gamepadSrc,
Observable.fromEvent(window, 'gamepadconnected')
).subscribe(ev => {
addGamepad((<any> ev).gamepad);
cancelAnimationFrame(this.pollRaf);
this.scheduleGamepadPoll();
})
);
}
/**
* Schedules a new gamepad poll at the next animation frame.
*/
private scheduleGamepadPoll() {
this.pollRaf = requestAnimationFrame(now => this.pollGamepad(now));
}
/**
* Checks for input provided by the gamepad and fires off events as
* necessary. It schedules itself again provided that there's still
* a connected gamepad somewhere.
*/
private pollGamepad(now: number) {
navigator.getGamepads(); // refreshes all checked-out gamepads
for (let i = 0; i < this.gamepads.length; i++) {
const gamepad = this.gamepads[i];
if (!gamepad.isConnected()) {
this.gamepads.splice(i, 1);
i -= 1;
continue;
}
if (gamepad.left(now)) {
this.handleDirection(Direction.LEFT);
}
if (gamepad.right(now)) {
this.handleDirection(Direction.RIGHT);
}
if (gamepad.down(now)) {
this.handleDirection(Direction.DOWN);
}
if (gamepad.up(now)) {
this.handleDirection(Direction.UP);
}
if (gamepad.submit(now)) {
this.handleDirection(Direction.SUBMIT);
}
if (gamepad.back(now)) {
this.handleDirection(Direction.BACK);
}
}
if (this.gamepads.length > 0) {
this.scheduleGamepadPoll();
} else {
this.pollRaf = null;
}
}
private handleDirection(direction: Direction): boolean {
return this.focus.fire(direction);
}
/**
* Handles a key down event, returns whether the event has resulted
* in a navigation and should be cancelled.
*/
private handleKeyDown(keyCode: number): boolean {
let result: boolean;
InputService.directionCodes.forEach((codes, direction) => {
if (result === undefined && codes.indexOf(keyCode) !== -1) {
result = this.handleDirection(direction);
}
});
return result;
}
/**
* Adds listeners for keyboard events.
*/
private addKeyboardListeners() {
this.subscriptions.push(
Observable.merge(
this.keyboardSrc,
Observable.fromEvent<KeyboardEvent>(window, 'keydown')
).subscribe(ev => {
if (!ev.defaultPrevented && this.handleKeyDown(ev.keyCode)) {
ev.preventDefault();
}
this.addKeyboardListeners();
}
/**
* Detects any connected gamepads and watches for new ones to start
* polling them. This is the entry point for gamepad input handling.
*/
private watchForGamepad() {
const addGamepads = () => {
// it's not an array, originally, and contains undefined elements.
this.gamepads = Array.from(navigator.getGamepads())
.filter(pad => !!pad)
.map(pad => {
if (/xbox/i.test(pad.id)) {
return new XboxGamepadWrapper(pad);
}
// We can try, at least ¯\_(ツ)_/¯ and this should
// usually be OK due to remapping.
return new XboxGamepadWrapper(pad);
});
};
addGamepads();
if (this.gamepads.length > 0) {
this.scheduleGamepadPoll();
}
addEventListener('gamepadconnected', () => {
addGamepads();
cancelAnimationFrame(this.pollRaf);
this.scheduleGamepadPoll();
});
}
/**
* Schedules a new gamepad poll at the next animation frame.
*/
private scheduleGamepadPoll() {
this.pollRaf = requestAnimationFrame(now => this.pollGamepad(now));
}
/**
* Checks for input provided by the gamepad and fires off events as
* necessary. It schedules itself again provided that there's still
* a connected gamepad somewhere.
*/
private pollGamepad(now: number) {
for (let i = 0; i < this.gamepads.length; i++) {
const gamepad = this.gamepads[i];
if (!gamepad.isConnected()) {
this.gamepads.splice(i, 1);
i -= 1;
continue;
}
if (gamepad.left(now)) {
this.handleDirection(Direction.LEFT);
} else if (gamepad.right(now)) {
this.handleDirection(Direction.RIGHT);
} else if (gamepad.down(now)) {
this.handleDirection(Direction.DOWN);
} else if (gamepad.up(now)) {
this.handleDirection(Direction.UP);
} else if (gamepad.submit(now)) {
this.handleDirection(Direction.SUBMIT);
} else if (gamepad.back(now)) {
this.handleDirection(Direction.BACK);
}
}
if (this.gamepads.length > 0) {
this.scheduleGamepadPoll();
}
}
private handleDirection(direction: Direction): boolean {
console.log('dir', direction);
return true;
}
/**
* Handles a key down event, returns whether the event has resulted
* in a navigation and should be cancelled.
*/
private handleKeyDown(keyCode: number): boolean {
let result: boolean;
InputService.DirectionCodes.forEach((codes, direction) => {
if (result === undefined && codes.indexOf(keyCode) !== -1) {
result = this.handleDirection(direction);
}
});
return result;
}
/**
* Adds listeners for keyboard events.
*/
private addKeyboardListeners() {
addEventListener('keydown', ev => {
if (!ev.defaultPrevented && this.handleKeyDown(ev.keyCode)) {
ev.preventDefault();
}
});
}
})
);
}
}

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

@ -1,6 +1,5 @@
/*global jasmine, __karma__, window*/
/*global __karma__, window*/
Error.stackTraceLimit = Infinity;
jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000;
__karma__.loaded = function () {
};

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

@ -2,97 +2,95 @@ import path = require('path');
export function config(config: any) {
config.set({
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: path.join(__dirname, '..'),
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: path.join(__dirname, '..'),
failOnEmptyTestSuite: false,
failOnEmptyTestSuite: false,
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['jasmine'],
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-mocha-reporter'),
require('karma-coverage'),
require('karma-chrome-launcher'),
require('karma-firefox-launcher'),
],
plugins: [
require('karma-jasmine'),
require('karma-mocha-reporter'),
require('karma-coverage'),
require('karma-chrome-launcher'),
require('karma-firefox-launcher'),
],
// list of files / patterns to load in the browser
files: [
{ pattern: 'dist/vendor/core-js/client/core.js', included: true, watched: false },
{ pattern: 'dist/vendor/systemjs/dist/system-polyfills.js', included: true, watched: false },
{ pattern: 'dist/vendor/systemjs/dist/system.src.js', included: true, watched: false },
// list of files / patterns to load in the browser
files: [
{ pattern: 'dist/vendor/core-js/client/core.js', included: true, watched: false },
{ pattern: 'dist/vendor/systemjs/dist/system-polyfills.js', included: true, watched: false },
{ pattern: 'dist/vendor/systemjs/dist/system.src.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/zone.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/sync-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/async-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/proxy.js', include: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/fake-async-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/long-stack-trace-zone.js', include: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/jasmine-patch.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/zone.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/sync-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/async-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/proxy.js', include: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/fake-async-test.js', included: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/long-stack-trace-zone.js', include: true, watched: false },
{ pattern: 'dist/vendor/zone.js/dist/jasmine-patch.js', included: true, watched: false },
{ pattern: 'dist/vendor/hammerjs/hammer.min.js', included: true, watched: false },
{ pattern: 'test/karma-shim.js', included: true, watched: false },
{ pattern: 'test/karma-shim.js', included: true, watched: false },
{ pattern: 'dist/bin/system-config-spec.js', included: true, watched: false },
{ pattern: 'dist/bin/system-config-spec.js', included: true, watched: false },
// paths loaded via module imports
{ pattern: 'dist/**/*.js', included: false, watched: true },
],
// paths loaded via module imports
{ pattern: 'dist/**/*.js', included: false, watched: true },
],
proxies: {
// required for component assets fetched by Angular's compiler
'/components/': '/base/dist/components/',
'/core/': '/base/dist/core/',
},
proxies: {
// required for component assets fetched by Angular's compiler
'/components/': '/base/dist/components/',
'/core/': '/base/dist/core/',
},
// list of files to exclude
exclude: [],
// list of files to exclude
exclude: [],
coverageReporter: {
dir: 'coverage/',
reporters: [
{type: 'text-summary'},
{type: 'html'}
]
},
coverageReporter: {
dir: 'coverage/',
reporters: [
{type: 'text-summary'},
{type: 'html'}
]
},
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {},
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {},
// test results reporter to use
// possible values: 'dots', 'progress', 'mocha'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['mocha', 'coverage'],
// test results reporter to use
// possible values: 'dots', 'progress', 'mocha'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['mocha', 'coverage'],
port: 9876,
colors: true,
port: 9876,
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Chrome'],
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Chrome'],
browserDisconnectTimeout: 2000000,
browserNoActivityTimeout: 2400000,
captureTimeout: 12000000,
browserDisconnectTimeout: 2000000,
browserNoActivityTimeout: 2400000,
captureTimeout: 12000000,
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true
});
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true
});
};