initial commit
This commit is contained in:
commit
2a8d0269b8
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,135 @@
|
||||||
|
# @ngshack/input
|
||||||
|
|
||||||
|
Biblioteka komponentów input i nawigacji dla aplikacji Webland z obsługą gamepadów.
|
||||||
|
|
||||||
|
## Instalacja
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @ngshack/input
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- Angular CLI 17+
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Components
|
||||||
|
```bash
|
||||||
|
# Generate new component
|
||||||
|
ng generate component components/my-component --project=input
|
||||||
|
|
||||||
|
# Generate service
|
||||||
|
ng generate service services/my-service --project=input
|
||||||
|
|
||||||
|
# Generate module
|
||||||
|
ng generate module modules/my-module --project=input
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Library
|
||||||
|
```bash
|
||||||
|
ng build input
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test
|
||||||
|
```bash
|
||||||
|
ng test input
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publish
|
||||||
|
```bash
|
||||||
|
npm run build:libs
|
||||||
|
npm publish dist/input
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Navigation Module
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NavigationModule } from '@ngshack/input';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [NavigationModule],
|
||||||
|
template: `
|
||||||
|
<div inputActionBinding="confirm" (action)="onConfirm()">
|
||||||
|
<button>Confirm</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class MyComponent {
|
||||||
|
onConfirm() {
|
||||||
|
console.log('Confirmed!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Binding Directive
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ActionBindingDirective } from '@ngshack/input';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [ActionBindingDirective],
|
||||||
|
template: `
|
||||||
|
<div inputActionBinding="back" (action)="goBack()">
|
||||||
|
Back Button
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class MyComponent {
|
||||||
|
goBack() {
|
||||||
|
// Handle back action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎮 Gamepad support
|
||||||
|
- ⌨️ Keyboard navigation
|
||||||
|
- 🖱️ Mouse/touch support
|
||||||
|
- 🎯 Action binding system
|
||||||
|
- 📱 Responsive design
|
||||||
|
- 🎨 Gaming console theme
|
||||||
|
|
||||||
|
## Publishing the Library
|
||||||
|
|
||||||
|
Once the project is built, you can publish your library by following these steps:
|
||||||
|
|
||||||
|
1. Navigate to the `dist` directory:
|
||||||
|
```bash
|
||||||
|
cd dist/input
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the `npm publish` command to publish your library to the npm registry:
|
||||||
|
```bash
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"$schema": "../../node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"input": {
|
||||||
|
"projectType": "library",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"flat": false,
|
||||||
|
"inlineTemplate": false,
|
||||||
|
"inlineStyle": false,
|
||||||
|
"type": "Component"
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"flat": false,
|
||||||
|
"type": "Directive"
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"flat": false,
|
||||||
|
"type": "Pipe"
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"flat": false,
|
||||||
|
"type": "Service"
|
||||||
|
},
|
||||||
|
"@schematics/angular:module": {
|
||||||
|
"flat": false
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"flat": false,
|
||||||
|
"type": "Guard"
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"flat": false,
|
||||||
|
"type": "Interceptor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "input",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:ng-packagr",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"tsConfig": "tsconfig.lib.prod.json"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"tsConfig": "tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:karma",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||||
|
"dest": "../../dist/input",
|
||||||
|
"lib": {
|
||||||
|
"entryFile": "src/public-api.ts"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "@ngshack/input",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Webland input components library",
|
||||||
|
"author": "Webland Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["angular", "input", "forms", "components", "webland"],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/webland/webland.git",
|
||||||
|
"directory": "app/packages/input"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/webland/webland#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/webland/webland/issues"
|
||||||
|
},
|
||||||
|
"main": "bundles/webland-input.umd.js",
|
||||||
|
"module": "fesm2022/webland-input.mjs",
|
||||||
|
"typings": "index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": {
|
||||||
|
"default": "./package.json"
|
||||||
|
},
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"esm": "./fesm2022/webland-input.mjs",
|
||||||
|
"default": "./fesm2022/webland-input.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^20.1.0",
|
||||||
|
"@angular/core": "^20.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class InputModule { }
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { EventEmitter } from "@angular/core";
|
||||||
|
import { InputDevice } from "../input-device";
|
||||||
|
|
||||||
|
interface GamepadButtonState {
|
||||||
|
value: number;
|
||||||
|
angle?: number;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InputGamepad extends InputDevice {
|
||||||
|
|
||||||
|
static listenerInited = false;
|
||||||
|
public static DeviceConnected = new EventEmitter<InputGamepad>();
|
||||||
|
public static DeviceDisconnected = new EventEmitter<InputGamepad>();
|
||||||
|
public static DeviceListChanged = new EventEmitter<InputGamepad[]>();
|
||||||
|
private static gamepads: InputGamepad[] = [];
|
||||||
|
private stateUpdateAgent?: number;
|
||||||
|
private buttonState: GamepadButtonState[] = [];
|
||||||
|
public axesState: GamepadButtonState[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private gamepad: Gamepad
|
||||||
|
) {
|
||||||
|
super(InputGamepad.GetName(gamepad), 'gamepad');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetName(gamepad: Gamepad) {
|
||||||
|
return `${gamepad.id} #${gamepad.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMapping() {
|
||||||
|
return this.gamepad.mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static attachEvents() {
|
||||||
|
if (InputGamepad.listenerInited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('gamepadconnected', e => this.updateGamepadStates(e));
|
||||||
|
window.addEventListener('gamepaddisconnected', e => this.updateGamepadStates(e));
|
||||||
|
|
||||||
|
InputGamepad.listenerInited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect() {
|
||||||
|
this.gamepad.buttons.forEach((button, index) => {
|
||||||
|
this.buttonState.push({
|
||||||
|
value: button.value,
|
||||||
|
angle: 0,
|
||||||
|
id: index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let axisIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.gamepad.axes.length; i+=2) {
|
||||||
|
const axis = {
|
||||||
|
value: 0,
|
||||||
|
angle: 0,
|
||||||
|
id: axisIndex++,
|
||||||
|
};
|
||||||
|
this.axesState.push(axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stateUpdateAgent = setInterval(() => {
|
||||||
|
this.gamepad.buttons.forEach((button, index) => {
|
||||||
|
const state = this.buttonState.find(s => s.id === index);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasPressed = state.value > 0;
|
||||||
|
const isPressed = button.value > 0;
|
||||||
|
state.value = button.value;
|
||||||
|
if (isPressed !== wasPressed) {
|
||||||
|
this.setKey(`gamepad.button.${index}`, button.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
axisIndex = 0;
|
||||||
|
for (let i = 0; i < this.gamepad.axes.length; i+=2) {
|
||||||
|
const state = this.axesState.find(s => s.id === axisIndex);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = Math.abs(Math.max(0, this.gamepad.axes[i]));
|
||||||
|
const right = Math.abs(0 - Math.min(0, this.gamepad.axes[i]));
|
||||||
|
const up = Math.abs(Math.max(0, this.gamepad.axes[i + 1]));
|
||||||
|
const down = Math.abs(0 - Math.min(0, this.gamepad.axes[i + 1]));
|
||||||
|
|
||||||
|
const x = right - left;
|
||||||
|
const y = up - down;
|
||||||
|
|
||||||
|
const value = Math.sqrt(x * x + y * y);
|
||||||
|
const angle = Math.atan2(x, y);
|
||||||
|
|
||||||
|
if (value !== state.value || angle !== state.angle) {
|
||||||
|
this.setKey(`gamepad.axis.${state.id}`, value, angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = value;
|
||||||
|
state.angle = angle;
|
||||||
|
axisIndex++;
|
||||||
|
}
|
||||||
|
}, 50) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnect() {
|
||||||
|
this.scheme?.unassignDevice(this);
|
||||||
|
this.scheme = undefined;
|
||||||
|
clearInterval(this.stateUpdateAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static updateGamepadStates(event: GamepadEvent) {
|
||||||
|
|
||||||
|
console.log({event});
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'gamepadconnected':
|
||||||
|
const newGamepad = new InputGamepad(event.gamepad);
|
||||||
|
this.DeviceConnected.emit(newGamepad);
|
||||||
|
this.gamepads.push(newGamepad);
|
||||||
|
newGamepad.connect();
|
||||||
|
this.DeviceListChanged.emit(this.gamepads);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gamepaddisconnected':
|
||||||
|
const gamepad = this.gamepads.find(g => g.name === event.gamepad.id);
|
||||||
|
if (gamepad) {
|
||||||
|
gamepad.disconnect();
|
||||||
|
this.DeviceDisconnected.emit(gamepad);
|
||||||
|
this.gamepads.splice(this.gamepads.indexOf(gamepad), 1);
|
||||||
|
this.DeviceListChanged.emit(this.gamepads);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Init() {
|
||||||
|
this.attachEvents();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { InputDevice } from "../input-device";
|
||||||
|
|
||||||
|
export class InputKeyboard extends InputDevice {
|
||||||
|
|
||||||
|
static listenerInited = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
super(name, 'keyboard');
|
||||||
|
this.attachEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachEvents() {
|
||||||
|
if (InputKeyboard.listenerInited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', e => this.catchKey(e, 1));
|
||||||
|
window.addEventListener('keyup', e => this.catchKey(e, 0));
|
||||||
|
InputKeyboard.listenerInited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private catchKey(event: KeyboardEvent, value: number = 1, angle: number = 0): boolean {
|
||||||
|
const key = event.key;
|
||||||
|
super.setKey(key, value, angle);
|
||||||
|
event.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { InputScheme } from "./input-scheme";
|
||||||
|
|
||||||
|
export type ActionCallback = (value: number, data: unknown, angle?:number, timePressed?: number) => void;
|
||||||
|
|
||||||
|
export class InputAction {
|
||||||
|
|
||||||
|
private currentValue: number = 0;
|
||||||
|
private downActions: ActionCallback[] = [];
|
||||||
|
private upActions: ActionCallback[] = [];
|
||||||
|
private changeActions: ActionCallback[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapKeysToScheme(scheme: InputScheme, keys: (string | number)[]): this {
|
||||||
|
scheme.mapKey(this, keys);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public fire(value: number, data: unknown = undefined, angle?: number, timePressed?: number) {
|
||||||
|
const isPressed = value > 0;
|
||||||
|
const wasPressed = this.currentValue > 0;
|
||||||
|
if (isPressed !== wasPressed) {
|
||||||
|
if (isPressed) {
|
||||||
|
this.downActions.forEach(action => action(value, data, angle, timePressed));
|
||||||
|
} else {
|
||||||
|
this.upActions.forEach(action => action(value, data, angle, timePressed));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.changeActions.forEach(action => action(value, data, angle, timePressed));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDown(action: ActionCallback) {
|
||||||
|
this.downActions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUp(action: ActionCallback) {
|
||||||
|
this.upActions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onChange(action: ActionCallback) {
|
||||||
|
this.changeActions.push(action);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { EventEmitter } from "@angular/core";
|
||||||
|
import { InputScheme } from "./input-scheme";
|
||||||
|
import { InputAction } from "./input-action";
|
||||||
|
|
||||||
|
export interface InputKeyState {
|
||||||
|
key: string | number;
|
||||||
|
value: number;
|
||||||
|
angle?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class InputDevice {
|
||||||
|
|
||||||
|
protected scheme?: InputScheme;
|
||||||
|
protected keyStates: InputKeyState[] = [];
|
||||||
|
public keyStateChanged = new EventEmitter<InputKeyState>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly type = 'unknown',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapKey(action: InputAction, keys: (string | number)[]): this {
|
||||||
|
this.scheme?.mapKey(action, keys);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getScheme(): InputScheme | undefined {
|
||||||
|
return this.scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasScheme(): boolean {
|
||||||
|
return !!this.scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setKey(key: string | number, value: number = 1, angle: number = 0): this {
|
||||||
|
let exists = this.keyStates.find(k => k.key === key);
|
||||||
|
if (!exists) {
|
||||||
|
exists = {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
angle,
|
||||||
|
};
|
||||||
|
this.keyStates.push(exists);
|
||||||
|
} else {
|
||||||
|
exists.value = value;
|
||||||
|
exists.angle = angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keyStateChanged.emit(exists);
|
||||||
|
this.scheme?.setKeyState(key, value, angle);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public assignScheme(scheme: InputScheme): this {
|
||||||
|
this.scheme = scheme;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { InputAction } from "./input-action";
|
||||||
|
import { InputDevice } from "./input-device";
|
||||||
|
|
||||||
|
interface InputActionBind {
|
||||||
|
action: InputAction;
|
||||||
|
keys: (string | number)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InputScheme {
|
||||||
|
|
||||||
|
private data: unknown;
|
||||||
|
private actions: InputActionBind[] = [];
|
||||||
|
private devices: InputDevice[] = [];
|
||||||
|
public deviceListChanged = new BehaviorSubject<InputDevice[]>([]);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
) {
|
||||||
|
this.devices = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearKeyBindings() {
|
||||||
|
this.actions = [];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearDeviceAssignments() {
|
||||||
|
this.devices = [];
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unassignDevice(device: InputDevice) {
|
||||||
|
this.devices.splice(this.devices.indexOf(device), 1);
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDevices() {
|
||||||
|
return this.devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKeyForAction(action: string): (string | number)[] {
|
||||||
|
return this.actions.find(a => a.action.name === action)?.keys || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public setData(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapKey(action: InputAction, keys: (string | number)[]) {
|
||||||
|
this.actions.push({ action, keys });
|
||||||
|
}
|
||||||
|
|
||||||
|
public setKeyState(key: string | number, value: number, angle: number = 0) {
|
||||||
|
const binds = this.actions.filter(a => a.keys.includes(key));
|
||||||
|
if (binds.length) {
|
||||||
|
binds.forEach(bind => bind.action.fire(value, this.data, angle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public assignDevice(device: InputDevice): void {
|
||||||
|
device.assignScheme(this);
|
||||||
|
this.devices.push(device);
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { InputScheme } from "../models/input-scheme";
|
||||||
|
import { InputAction } from "../models/input-action";
|
||||||
|
import { InputDevice } from "../models/input-device";
|
||||||
|
import { InputKeyboard } from "../models/devices/input-keyboard";
|
||||||
|
import { InputGamepad } from "../models/devices/input-gamepad";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InputService {
|
||||||
|
|
||||||
|
public actions: InputAction[] = [];
|
||||||
|
public schemes: InputScheme[] = [];
|
||||||
|
public devices: InputDevice[] = [];
|
||||||
|
|
||||||
|
public deviceListChanged = new BehaviorSubject<InputDevice[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.defineKeyboard();
|
||||||
|
this.defineGamepad();
|
||||||
|
}
|
||||||
|
|
||||||
|
private defineKeyboard() {
|
||||||
|
const keyboard = new InputKeyboard('Keyboard');
|
||||||
|
this.devices.push(keyboard);
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
private defineGamepad() {
|
||||||
|
InputGamepad.DeviceConnected.subscribe(gamepad => {
|
||||||
|
this.devices.push(gamepad);
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
});
|
||||||
|
InputGamepad.DeviceDisconnected.subscribe(gamepad => {
|
||||||
|
this.devices = this.devices.filter(d => d.name !== gamepad.name);
|
||||||
|
this.deviceListChanged.next(this.devices);
|
||||||
|
});
|
||||||
|
InputGamepad.Init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public defineAction(name: string): InputAction {
|
||||||
|
const action = new InputAction(name);
|
||||||
|
this.actions.push(action);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public defineScheme(name: string): InputScheme {
|
||||||
|
const scheme = new InputScheme(name);
|
||||||
|
this.schemes.push(scheme);
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDevices(): InputDevice[] {
|
||||||
|
return this.devices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Directive, ElementRef, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[inputActionBinding]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class ActionBindingDirective {
|
||||||
|
|
||||||
|
@Input() inputActionBinding: string = '';
|
||||||
|
|
||||||
|
private element: HTMLElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
elementRef: ElementRef,
|
||||||
|
) {
|
||||||
|
this.element = elementRef.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.element.setAttribute('action', this.inputActionBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Directive } from '@angular/core';
|
||||||
|
import { KeyItemDirective, KeyItemEntry } from './key-item.directive';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[inputKeyItemGroup]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class KeyItemGroupDirective {
|
||||||
|
|
||||||
|
static groups: KeyItemGroupDirective[] = [];
|
||||||
|
public items: KeyItemEntry[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
KeyItemDirective.SetCurrentItem();
|
||||||
|
KeyItemGroupDirective.groups.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
const index = KeyItemGroupDirective.groups.findIndex(g => g === this);
|
||||||
|
if (index > -1) {
|
||||||
|
KeyItemGroupDirective.groups.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit, Optional, Host } from '@angular/core';
|
||||||
|
import { KeyItemGroupDirective } from './key-item-group.directive';
|
||||||
|
|
||||||
|
export interface ActionBindings {
|
||||||
|
[action: string]: () => undefined | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyItemEntry {
|
||||||
|
element: HTMLKeyItemEntryElement;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HTMLKeyItemEntryElement extends HTMLElement {
|
||||||
|
keyId: number;
|
||||||
|
active: string;
|
||||||
|
actionBindings: ActionBindings;
|
||||||
|
activate: () => void;
|
||||||
|
deactivate: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[inputKeyItem]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class KeyItemDirective implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
@Input() active = 'active';
|
||||||
|
@Input() activate: () => any = () => {};
|
||||||
|
@Input() deactivate: () => any = () => {};
|
||||||
|
@Input() focus: () => any = () => {};
|
||||||
|
@Input() blur: () => any = () => {};
|
||||||
|
@Input() actionBindings: ActionBindings = {};
|
||||||
|
private static allItems: KeyItemEntry[] = [];
|
||||||
|
private static current?: KeyItemEntry;
|
||||||
|
private element: HTMLKeyItemEntryElement;
|
||||||
|
private static NextId = 0;
|
||||||
|
private id = KeyItemDirective.NextId++;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional() private group: KeyItemGroupDirective,
|
||||||
|
elementRef: ElementRef,
|
||||||
|
) {
|
||||||
|
this.element = elementRef.nativeElement as HTMLKeyItemEntryElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FireAction(action: string): boolean {
|
||||||
|
const item = KeyItemDirective.current;
|
||||||
|
if (item) {
|
||||||
|
return item.element.actionBindings?.[action]?.() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.element.active = this.active;
|
||||||
|
this.element.activate = this.activate;
|
||||||
|
this.element.deactivate = this.deactivate;
|
||||||
|
this.element.keyId = this.id;
|
||||||
|
this.element.actionBindings = this.actionBindings;
|
||||||
|
this.element.focus = this.focus;
|
||||||
|
this.element.blur = this.blur;
|
||||||
|
const position = this.element.getBoundingClientRect();
|
||||||
|
const item = {
|
||||||
|
element: this.element,
|
||||||
|
top: position.top + position.height / 2,
|
||||||
|
left: position.left + position.width / 2,
|
||||||
|
};
|
||||||
|
KeyItemDirective.allItems.push(item);
|
||||||
|
this.group?.items.push(item);
|
||||||
|
if (this.group) {
|
||||||
|
this.group.items = this.group.items.sort((a, b) => {
|
||||||
|
const tabIndexA = a.element.tabIndex;
|
||||||
|
const tabIndexB = b.element.tabIndex;
|
||||||
|
return tabIndexA - tabIndexB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
const index = KeyItemDirective.allItems.findIndex(item => item.element.keyId === this.id);
|
||||||
|
if (index > -1) {
|
||||||
|
KeyItemDirective.allItems.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.group) {
|
||||||
|
const index = this.group.items.findIndex(item => item.element.keyId === this.id);
|
||||||
|
if (index > -1) {
|
||||||
|
this.group.items.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseenter')
|
||||||
|
onHover() {
|
||||||
|
if (this.active && !this.element.classList.contains(this.active)) {
|
||||||
|
this.element.classList.add(this.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focus();
|
||||||
|
|
||||||
|
const item = KeyItemDirective.allItems.find(item => item.element.keyId === this.id);
|
||||||
|
KeyItemDirective.SetCurrentItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseleave')
|
||||||
|
onLeave() {
|
||||||
|
if (this.active && this.element.classList.contains(this.active)) {
|
||||||
|
this.element.classList.remove(this.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ActivateCurrentItem() {
|
||||||
|
this.current?.element.activate.bind(this.current?.element)();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCurrentGroup() {
|
||||||
|
if (KeyItemGroupDirective.groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return KeyItemGroupDirective.groups[KeyItemGroupDirective.groups.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static SelectNexItem(fromAngleDeg: number, toAngleDeg: number) {
|
||||||
|
this.updateItems();
|
||||||
|
let current = this.current;
|
||||||
|
let allItems = this.allItems;
|
||||||
|
const currentGroup = this.getCurrentGroup();
|
||||||
|
if (currentGroup) {
|
||||||
|
const isCurrentInCurrentGroup = this.current && currentGroup.items.some(i => i.element.keyId === this.current!.element.keyId);
|
||||||
|
allItems = currentGroup.items;
|
||||||
|
if (!isCurrentInCurrentGroup) {
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
const list = allItems.map(item => {
|
||||||
|
const distance = Math.sqrt(Math.pow(item.top - this.current!.top, 2) + Math.pow(item.left - this.current!.left, 2));
|
||||||
|
const angle = 90 + Math.atan2(item.top - this.current!.top, item.left - this.current!.left) * 180 / Math.PI;
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
distance,
|
||||||
|
angle,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(item => {
|
||||||
|
const angle = item.angle;
|
||||||
|
const normalizedAngle = angle < 0 ? angle + 360 : angle;
|
||||||
|
return normalizedAngle >= fromAngleDeg && normalizedAngle <= toAngleDeg && item.distance > 0;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
if (list.length > 0) {
|
||||||
|
this.SetCurrentItem(list[0].item);
|
||||||
|
} else {
|
||||||
|
let index = allItems.findIndex(i => i.element.keyId === this.current!.element.keyId);
|
||||||
|
index = (index + 1) % allItems.length;
|
||||||
|
this.SetCurrentItem(allItems[index]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.SetCurrentItem(allItems[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static SetCurrentItem(item?: KeyItemEntry) {
|
||||||
|
if (this.current?.element.active && this.current.element.classList.contains(this.current.element.active)) {
|
||||||
|
this.current.element.classList.remove(this.current.element.active);
|
||||||
|
}
|
||||||
|
this.current?.element.blur();
|
||||||
|
this.current = item;
|
||||||
|
this.current?.element.focus();
|
||||||
|
if (this.current?.element.active && !this.current.element.classList.contains(this.current.element.active)) {
|
||||||
|
this.current.element.classList.add(this.current.element.active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateItems() {
|
||||||
|
this.allItems.forEach(item => {
|
||||||
|
const position = item.element.getBoundingClientRect();
|
||||||
|
item.top = position.top + position.height / 2;
|
||||||
|
item.left = position.left + position.width / 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActionBindingDirective } from './directives/action-binding.directive';
|
||||||
|
import { KeyItemDirective } from './directives/key-item.directive';
|
||||||
|
import { KeyItemGroupDirective } from './directives/key-item-group.directive';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ActionBindingDirective,
|
||||||
|
KeyItemDirective,
|
||||||
|
KeyItemGroupDirective,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ActionBindingDirective,
|
||||||
|
KeyItemDirective,
|
||||||
|
KeyItemGroupDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NavigationModule { }
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Public API Surface of input
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './lib/modules/input/input-module';
|
||||||
|
|
||||||
|
export * from './lib/modules/input/services/input.service';
|
||||||
|
export * from './lib/modules/input/models/input-action';
|
||||||
|
export * from './lib/modules/input/models/input-device';
|
||||||
|
export * from './lib/modules/input/models/input-scheme';
|
||||||
|
export * from './lib/modules/input/models/devices/input-gamepad';
|
||||||
|
export * from './lib/modules/input/models/devices/input-keyboard';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export * from './lib/modules/navigation/navigation-module';
|
||||||
|
|
||||||
|
export * from './lib/modules/navigation/directives/action-binding.directive';
|
||||||
|
export * from './lib/modules/navigation/directives/key-item-group.directive';
|
||||||
|
export * from './lib/modules/navigation/directives/key-item.directive';
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../out-tsc/lib",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.lib.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"compilationMode": "partial"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue