diff --git a/packages/dualsense-bindings/.gitignore b/packages/dualsense-bindings/.gitignore new file mode 100644 index 0000000000..3e22129247 --- /dev/null +++ b/packages/dualsense-bindings/.gitignore @@ -0,0 +1 @@ +/dist \ No newline at end of file diff --git a/packages/dualsense-bindings/LICENSE b/packages/dualsense-bindings/LICENSE new file mode 100644 index 0000000000..6b0b1270ff --- /dev/null +++ b/packages/dualsense-bindings/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/dualsense-bindings/devEnv/api-extractor.json b/packages/dualsense-bindings/devEnv/api-extractor.json new file mode 100644 index 0000000000..f8015076a1 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/api-extractor.json @@ -0,0 +1,36 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + "extends": "../../../devEnv/api-extractor-base.json", + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + "projectFolder": ".." +} diff --git a/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json b/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json new file mode 100644 index 0000000000..bcc091f626 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/api-extractor.tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": {} + } +} diff --git a/packages/dualsense-bindings/devEnv/build.ts b/packages/dualsense-bindings/devEnv/build.ts new file mode 100644 index 0000000000..599d72096d --- /dev/null +++ b/packages/dualsense-bindings/devEnv/build.ts @@ -0,0 +1,55 @@ +import * as path from 'path' +import {build} from 'esbuild' +import type {Plugin} from 'esbuild' + +const externalPlugin = (patterns: RegExp[]): Plugin => { + return { + name: `external`, + + setup(build) { + build.onResolve({filter: /.*/}, (args) => { + const external = patterns.some((p) => { + return p.test(args.path) + }) + + if (external) { + return {path: args.path, external} + } + }) + }, + } +} + +const definedGlobals = { + global: 'window', +} + +function createBundles(watch: boolean) { + const pathToPackage = path.join(__dirname, '../') + const esbuildConfig: Parameters[0] = { + entryPoints: [path.join(pathToPackage, 'src/index.ts')], + bundle: true, + sourcemap: true, + define: definedGlobals, + watch, + platform: 'neutral', + mainFields: ['browser', 'module', 'main'], + target: ['firefox57', 'chrome58'], + conditions: ['browser', 'node'], + plugins: [externalPlugin([/^[\@a-zA-Z]+/])], + } + + build({ + ...esbuildConfig, + outfile: path.join(pathToPackage, 'dist/index.js'), + format: 'cjs', + }) + + // build({ + // ...esbuildConfig, + // outfile: path.join(pathToPackage, 'dist/index.mjs'), + // format: 'esm', + // }) +} + +createBundles(false) diff --git a/packages/dualsense-bindings/devEnv/tsconfig.json b/packages/dualsense-bindings/devEnv/tsconfig.json new file mode 100644 index 0000000000..077404aaa4 --- /dev/null +++ b/packages/dualsense-bindings/devEnv/tsconfig.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/packages/dualsense-bindings/package.json b/packages/dualsense-bindings/package.json new file mode 100644 index 0000000000..9805fd802c --- /dev/null +++ b/packages/dualsense-bindings/package.json @@ -0,0 +1,51 @@ +{ + "name": "@theatre/dualsense-bindings", + "version": "0.6.0-dev.3", + "license": "Apache-2.0", + "author": { + "name": "Andrew Prifer", + "email": "andrew.prifer@gmail.com", + "url": "https://github.com/AndrewPrifer" + }, + "repository": { + "type": "git", + "url": "https://github.com/theatre-js/theatre", + "directory": "packages/dualsense-bindings" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "prepack": "node ../../devEnv/ensurePublishing.js", + "typecheck": "yarn run build", + "build": "run-s build:ts build:js build:api-json", + "build:ts": "tsc --build ./tsconfig.json", + "build:js": "node -r esbuild-register ./devEnv/build.ts", + "build:api-json": "api-extractor run --local --config devEnv/api-extractor.json", + "prepublish": "node ../../devEnv/ensurePublishing.js", + "clean": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.18.11", + "@types/jest": "^26.0.23", + "@types/node": "^15.6.2", + "@types/w3c-web-hid": "^1.0.2", + "esbuild": "^0.12.15", + "esbuild-register": "^2.5.0", + "npm-run-all": "^4.1.5", + "typescript": "^4.4.2" + }, + "dependencies": { + "ahrs": "^1.3.0", + "buffer": "^6.0.3", + "crc": "^4.1.0", + "lodash-es": "4.17.21", + "threejs-math": "^0.147.0" + }, + "peerDependencies": { + "@theatre/core": "workspace:*", + "@theatre/studio": "workspace:*" + } +} diff --git a/packages/dualsense-bindings/src/bindingCreators.ts b/packages/dualsense-bindings/src/bindingCreators.ts new file mode 100644 index 0000000000..a8492c005b --- /dev/null +++ b/packages/dualsense-bindings/src/bindingCreators.ts @@ -0,0 +1,203 @@ +import type {IScrub} from '@theatre/studio' +// Using the slightly larger threejs-math library instead of @math-gl +// because math-gl by default uses ZYX euler order, and some methods, notable fromQuaternion, +// don't allow you to specify a different order, which we need, because threejs defaults to XYZ. +import {Euler, Quaternion, Vector3} from 'threejs-math' +import type {API} from '.' +import {get} from 'lodash-es' + +export function createRotationBinding({propName = 'rotation'} = {}) { + let scrub: IScrub | null = null + let initialRotation: Quaternion | null = null + + return { + cross: (pressed: boolean, {orientation, object, studio}: API) => { + const propValue = get(object.value, propName) + + if (!propValue) { + console.warn(`Bindings: object has no ${propName} prop.`) + return + } + if (pressed) { + // "applies" the current orientation, so that it is treated as the "zero" orientation + orientation.apply() + scrub = studio.scrub() + initialRotation = new Quaternion().setFromEuler( + new Euler(propValue.x, propValue.y, propValue.z), + ) + } else { + if (scrub) { + scrub.commit() + scrub = null + } + initialRotation = null + } + }, + orientation(x: number, y: number, z: number, w: number, {object}: API) { + if (!scrub || !initialRotation) return + + const propPointer = get(object.props, propName) + + scrub.capture((api) => { + const rotation = new Euler().setFromQuaternion( + new Quaternion(x, y, z, w).premultiply(initialRotation!), + ) + + api.set(propPointer, { + x: rotation.x, + y: rotation.y, + z: rotation.z, + }) + }) + }, + } +} + +export type MovementPlane = 'xz' | 'xy' | 'yz' + +export function createPositionBinding({ + propName = 'position', + onStart: onActive = () => {}, + onEnd = () => {}, +}: { + propName?: string + onStart?: ( + movementPlane: MovementPlane, + initialPosition: [number, number, number], + ) => void + onEnd?: () => void +} = {}) { + let initialPosition: Vector3 | null = null + let movementPlane: MovementPlane | null = null + let scrub: IScrub | null = null + + const createButtonHandler = + (plane: MovementPlane) => + (pressed: boolean, {object, orientation, studio}: API) => { + if (movementPlane && movementPlane !== plane) return // only one button can be pressed at a time + + const propValue = get(object.value, propName) + + if (!propValue) { + console.warn(`Bindings: object has no ${propName} prop.`) + return + } + + if (pressed) { + onActive(plane, [propValue.x, propValue.y, propValue.z]) + + movementPlane = plane + // "applies" the current orientation, so that it is treated as the "zero" orientation + orientation.apply() + scrub = studio.scrub() + initialPosition = new Vector3(propValue.x, propValue.y, propValue.z) + } else { + // if this button press was ignored, the release should be ignored too + if (movementPlane !== plane) return // only one button can be pressed at a time + + onEnd() + + if (scrub) { + scrub.commit() + scrub = null + } + initialPosition = null + movementPlane = null + } + } + + return { + square: createButtonHandler('xz'), + triangle: createButtonHandler('xy'), + circle: createButtonHandler('yz'), + orientation(x: number, y: number, z: number, w: number, {object}: API) { + if (!scrub || !initialPosition) return + + scrub.capture((api) => { + if (!object.value.rotation) return + + const propPointer = get(object.props, propName) + + /* + Calculating the position involves mapping the orientation to a point on a plane. This can be done using different + trigonometric methods. + + Things to consider: + - We need to balance the speed of movement with the precision of the movement. + - We need to balance the speed of movement with the sensor noise. Larger coefficients will make the + movement noisier. + + Here are some methods and their caveats: + - Take the cotangent of the orientation with respect to a plane + This method allows for precise, slower movements at small orientation changes, + while allowing for fast movements at the extremes. This method fits best the the laser pointer metaphor (see below). + A downside of this method is that since the mapping is not linear, the movement is harder to predict towards the extremes. + - Take the cosine of the orientation with respect to a plane + The movement slows down towards the extremes, which is less useful than the tangent method described above. + - Map two angles of the orientation linearly to the x and y axes of the plane + This method results in linear movement. While the movement is more predictable, the precision that can be achieved + with the same range of movement is lower than with the tangent method. + + const vector = new Vector3(0, 0, -1).applyQuaternion(new Quaternion(x, y, z, w)) + const xTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.x) + const yTranslationLinear = ((2 * a) / Math.PI) * Math.asin(vector.y) + + There are also different metaphors we can consider in the implementation: + - Laser pointer + The controller is a laser pointer, projecting a point onto a flat surface. In this metaphor, the roll + of the controller is ignored, since it doesn't change the direction of the laser pointer. Orientation maps + tangentially to movement. At the extremes, movement becomes faster per orientation change. + - Trackball + The controller is a trackball. Rotation around the global z axis results in movement along the x axis, Rotation around the + global x axis results in changes in the y axis. Rotation around the global y axis is ignored. Orientation maps lineraly to movement. + Implementing this is different, since with the trackball, the path of rotations matters, we can't map the orientation + directly to movement, we have to use the deltas. + + It could be tempting to combine the two metaphors, but this can result in a confusing experience, + since the roll or the yaw of the controller can easily be changed inadvertently, which makes it difficult to + determine the user's intent. + + This implementation uses the laser pointer metaphor with the tangent method. + */ + + // calculate the controller's forward vector + const forwardVector = new Vector3(0, 0, -1).applyQuaternion( + new Quaternion(x, y, z, w), + ) + + // Take the tangent of the angle between the forward vector and the orthogonal plane of the initial forward vector + // by dividing the x and y components by the z component. + // Limit the z component to at least 0.3 so that the position doesn't fly off the handle + // when we divide by z (and to avoid NaN). + // Below z = 0.3 the movement becomes too noisy anyway. + // We then multiply by 6 to make the movement a bit more sensitive. This value is chosen as a balance between sensitivity and noise. + const xTranslation = + (forwardVector.x / Math.max(Math.abs(forwardVector.z), 0.3)) * 6 + const yTranslation = + (forwardVector.y / Math.max(Math.abs(forwardVector.z), 0.3)) * 6 + + if (movementPlane === 'xz') { + api.set(propPointer, { + x: initialPosition!.x + xTranslation, + y: initialPosition!.y, + z: initialPosition!.z + yTranslation, + }) + } + if (movementPlane === 'xy') { + api.set(propPointer, { + x: initialPosition!.x + xTranslation, + y: initialPosition!.y + yTranslation, + z: initialPosition!.z, + }) + } + if (movementPlane === 'yz') { + api.set(propPointer, { + x: initialPosition!.x, + y: initialPosition!.y + yTranslation, + z: initialPosition!.z - xTranslation, + }) + } + }) + }, + } +} diff --git a/packages/dualsense-bindings/src/ds-hid.ts b/packages/dualsense-bindings/src/ds-hid.ts new file mode 100644 index 0000000000..54a689789e --- /dev/null +++ b/packages/dualsense-bindings/src/ds-hid.ts @@ -0,0 +1,758 @@ +/* +Based on https://github.com/equalent/DualSense.js by Andrey Tsurkan, with the +addition of gyro and accelerometer support, and a virtual "attitude" sensor that +fuses data from the gyro and accelerometer to give the controller's orientation. + +MIT License + +Copyright (c) 2022 Andrey Tsurkan + +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. + */ + +import AHRS from 'ahrs' +import crc32 from 'crc/crc32' +import {Buffer} from 'buffer/' + +const clamp = (num: number, min: number, max: number) => + Math.min(Math.max(num, min), max) + +if (typeof navigator.hid === 'undefined') { + console.error('DualSense.js requires WebHID support!') +} + +export enum DSControllerStatus { + CONNECTED = 'CONNECTED', + DISCONNECTED = 'DISCONNECTED', +} + +export enum DSControllerConnectionType { + USB = 'USB', + BLUETOOTH = 'BLUETOOTH', +} + +interface DSControllerStateChangeEvent { + state: DSControllerState +} + +type DSControllerEvent = DSControllerStateChangeEvent + +type DSControllerStateChangeListener = ( + event: DSControllerStateChangeEvent, +) => void +type DSControllerEventListener = DSControllerStateChangeListener +type DSControllerEventType = 'stateChange' + +export interface DSControllerLEDColor { + r: number + g: number + b: number +} + +export interface DSController { + get status(): DSControllerStatus + get connectionType(): DSControllerConnectionType + get productName(): string + set ledColor(color: DSControllerLEDColor) + set leftMotor(strength: number) + set rightMotor(strength: number) + disconnect(): Promise + addEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ): void + removeEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ): void + attitude: { + applyGyro: () => void + apply: () => void + reset: () => void + } +} + +export interface DSControllerTouchState { + active: boolean + id: number + x: number + y: number +} + +export interface DSControllerGyroState { + x: number + y: number + z: number +} + +export interface DSControllerAccelState { + x: number + y: number + z: number +} + +export interface DSControllerBatteryState { + percent: number + full: boolean + charging: boolean +} + +export interface DSControllerAttitude { + heading: number + pitch: number + roll: number +} + +export class DSControllerState { + leftStickX: number = 0 + leftStickY: number = 0 + rightStickX: number = 0 + rightStickY: number = 0 + leftTrigger: number = 0 + rightTrigger: number = 0 + + leftButton: boolean = false + rightButton: boolean = false + + square: boolean = false + cross: boolean = false + circle: boolean = false + triangle: boolean = false + + dpadUp: boolean = false + dpadDown: boolean = false + dpadLeft: boolean = false + dpadRight: boolean = false + + leftStickButton: boolean = false + rightStickButton: boolean = false + + optionsButton: boolean = false + createButton: boolean = false + psButton: boolean = false + touchButton: boolean = false + + touch0: DSControllerTouchState = {active: false, id: 0, x: 0, y: 0} + touch1: DSControllerTouchState = {active: false, id: 0, x: 0, y: 0} + + gyro: DSControllerGyroState = {x: 0, y: 0, z: 0} + + accel: DSControllerAccelState = {x: 0, y: 0, z: 0} + + battery: DSControllerBatteryState = { + percent: 0, + full: false, + charging: false, + } + + attitude: [number, number, number, number] = [0, 0, 0, 1] + + prevTimestamp: number = -1 + timestamp: number = -1 + + raw01: any = {} + raw31: any = {} +} + +export class DSControllerNotFoundError extends Error { + constructor() { + super( + 'No DualSense controller available (not connected or not selected by the user)', + ) + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export class DSControllerNotConnected extends Error { + constructor() { + super('DualSense is not connected') + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export class DSControllerIncompatible extends Error { + constructor() { + super('DualSense is not compatible') + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +const DS_VENDOR_ID = 0x054c +const DS_PRODUCT_ID = 0x0ce6 + +export async function connectController(): Promise { + let devices = await navigator.hid.requestDevice({ + filters: [ + { + vendorId: DS_VENDOR_ID, + productId: DS_PRODUCT_ID, + }, + ], + }) + + if (devices.length === 0) { + throw new DSControllerNotFoundError() + } else { + let device = devices[0] + await device.open() + return new DSControllerImpl(device) + } +} + +class DSControllerInternalState { + changed: boolean = true + + leftMotor: number = 0 + rightMotor: number = 0 + ledColor: DSControllerLEDColor = {r: 0, g: 0, b: 0} + attitudeBias = {x: 0, y: 0, z: 0, w: 1} +} + +function shiftedSnormToFloat(x: number) { + return (x - 127) / 128 +} + +function unormToFloat(x: number) { + return x / 255 +} + +function floatToUnorm(x: number) { + return Math.round(clamp(x, 0.0, 1.0) * 255) +} + +const ahrsConfig = { + // Ahrs won't use this because we provide timestamps instead at every update. Using this would break + // because the DS can reduce the refrehs rate based on things like battery level. + sampleInterval: 20, + algorithm: 'Madgwick', + beta: 0.1, + doInitialisation: true, +} as const + +class DSControllerImpl implements DSController { + private _status: DSControllerStatus = DSControllerStatus.CONNECTED + private _connectionType: DSControllerConnectionType + private _device: HIDDevice + private _inputReportLength: number = 0 + private _internalState: DSControllerInternalState = + new DSControllerInternalState() + private _state: DSControllerState = new DSControllerState() + private _stateChangeListeners: Set = + new Set() + private _ahrs: AHRS + private _intervalId: ReturnType + + constructor(device: HIDDevice) { + this._device = device + this._device.addEventListener('inputreport', (evt: HIDInputReportEvent) => { + this.processInputReport(evt) + }) + this._ahrs = new AHRS(ahrsConfig) + + let numFeatureReports = this._device.collections[0]?.featureReports?.length + if (numFeatureReports === 20) { + this._connectionType = DSControllerConnectionType.USB + } else if (numFeatureReports === 12) { + this._connectionType = DSControllerConnectionType.BLUETOOTH + } else { + throw new DSControllerIncompatible() + } + + this._intervalId = setInterval(async () => { + await this.backgroundLoop() + }, 10) + } + + addEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.add(listener) + break + } + } + + removeEventListener( + type: DSControllerEventType, + listener: DSControllerEventListener, + ) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.delete(listener) + break + } + } + + attitude = { + applyGyro: () => { + this._ahrs = new AHRS(ahrsConfig) + }, + apply: () => { + const quaterion = this._ahrs.getQuaternion() + this._internalState.attitudeBias = { + x: quaterion.x * -1, + y: quaterion.y * -1, + z: quaterion.z * -1, + w: quaterion.w, + } + }, + reset: () => { + this._internalState.attitudeBias = {x: 0, y: 0, z: 0, w: 1} + }, + } + + private dispatchEvent(type: DSControllerEventType, evt: DSControllerEvent) { + switch (type) { + case 'stateChange': + this._stateChangeListeners.forEach((l) => { + l(evt) + }) + break + } + } + + private processInputButtons( + buttons0: number, + buttons1: number, + buttons2: number, + ) { + this._state.square = !!(buttons0 & (1 << 4)) + this._state.cross = !!(buttons0 & (1 << 5)) + this._state.circle = !!(buttons0 & (1 << 6)) + this._state.triangle = !!(buttons0 & (1 << 7)) + + this._state.leftButton = !!(buttons1 & (1 << 0)) + this._state.rightButton = !!(buttons1 & (1 << 1)) + + let dpad = buttons0 & 0x0f + this._state.dpadUp = dpad == 0 || dpad == 1 || dpad == 7 + this._state.dpadDown = dpad == 3 || dpad == 4 || dpad == 5 + this._state.dpadLeft = dpad == 5 || dpad == 6 || dpad == 7 + this._state.dpadRight = dpad == 1 || dpad == 2 || dpad == 3 + + this._state.leftStickButton = !!(buttons1 & (1 << 6)) + this._state.rightStickButton = !!(buttons1 & (1 << 7)) + + this._state.optionsButton = !!(buttons1 & (1 << 5)) + this._state.createButton = !!(buttons1 & (1 << 4)) + this._state.psButton = !!(buttons2 & (1 << 0)) + this._state.touchButton = !!(buttons2 & (1 << 1)) + } + + private processInputTouch( + target: DSControllerTouchState, + touch0: number, + touch1: number, + touch2: number, + touch3: number, + ) { + target.active = !(touch0 & 0x80) + target.id = touch0 & 0x7f + target.x = ((touch2 & 0x0f) << 8) | touch1 + target.y = (touch3 << 4) | ((touch2 & 0xf0) >> 4) + } + + private processInputBattery(battery0: number, battery1: number) { + this._state.battery.percent = ((battery0 & 0x0f) * 100) / 800 + this._state.battery.full = !!(battery0 & 0x20) + this._state.battery.charging = !!(battery1 & 0x08) + } + + private processInputGyro( + target: DSControllerGyroState, + gyroX0: number, + gyroX1: number, + gyroY0: number, + gyroY1: number, + gyroZ0: number, + gyroZ1: number, + ) { + let gyrox = (gyroX1 << 8) | gyroX0 + if (gyrox > 0x7fff) gyrox -= 0x10000 + let gyroy = (gyroY1 << 8) | gyroY0 + if (gyroy > 0x7fff) gyroy -= 0x10000 + let gyroz = (gyroZ1 << 8) | gyroZ0 + if (gyroz > 0x7fff) gyroz -= 0x10000 + + // 1024 LSB/deg/s + gyrox = Math.abs(gyrox / 1024) > 0.01 ? gyrox / 1024 : 0 + gyroy = Math.abs(gyroy / 1024) > 0.01 ? gyroy / 1024 : 0 + gyroz = Math.abs(gyroz / 1024) > 0.01 ? gyroz / 1024 : 0 + + target.x = gyrox + target.y = gyroy + target.z = gyroz + } + + private processInputAccel( + target: DSControllerAccelState, + accelX0: number, + accelX1: number, + accelY0: number, + accelY1: number, + accelZ0: number, + accelZ1: number, + ) { + let accelx = (accelX1 << 8) | accelX0 + if (accelx > 0x7fff) accelx -= 0x10000 + let accely = (accelY1 << 8) | accelY0 + if (accely > 0x7fff) accely -= 0x10000 + let accelz = (accelZ1 << 8) | accelZ0 + if (accelz > 0x7fff) accelz -= 0x10000 + + // 8192 LSB/g + target.x = accelx / 8192 + target.y = accely / 8192 + target.z = accelz / 8192 + } + + private processInputReportUSB01(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(0)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(1)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(3)) + + this._state.leftTrigger = unormToFloat(data.getUint8(4)) + this._state.rightTrigger = unormToFloat(data.getUint8(5)) + + this.processInputButtons( + data.getUint8(7), + data.getUint8(8), + data.getUint8(9), + ) + + let touch00 = data.getUint8(32) + let touch01 = data.getUint8(33) + let touch02 = data.getUint8(34) + let touch03 = data.getUint8(35) + + let touch10 = data.getUint8(36) + let touch11 = data.getUint8(37) + let touch12 = data.getUint8(38) + let touch13 = data.getUint8(39) + + this.processInputTouch( + this._state.touch0, + touch00, + touch01, + touch02, + touch03, + ) + this.processInputTouch( + this._state.touch1, + touch10, + touch11, + touch12, + touch13, + ) + + let battery0 = data.getUint8(52) + let battery1 = data.getUint8(53) + + this.processInputBattery(battery0, battery1) + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw01[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReportBluetooth01(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(0)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(1)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(3)) + + this._state.leftTrigger = unormToFloat(data.getUint8(7)) + this._state.rightTrigger = unormToFloat(data.getUint8(8)) + + this.processInputButtons( + data.getUint8(4), + data.getUint8(5), + data.getUint8(6), + ) + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw01[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReportBluetooth31(evt: HIDInputReportEvent) { + let data = evt.data + + this._state.prevTimestamp = this._state.timestamp + this._state.timestamp = Date.now() + + this._state.leftStickX = shiftedSnormToFloat(data.getUint8(1)) + this._state.leftStickY = shiftedSnormToFloat(data.getUint8(2)) + this._state.rightStickX = shiftedSnormToFloat(data.getUint8(3)) + this._state.rightStickY = shiftedSnormToFloat(data.getUint8(4)) + + this._state.leftTrigger = unormToFloat(data.getUint8(5)) + this._state.rightTrigger = unormToFloat(data.getUint8(6)) + + this.processInputButtons( + data.getUint8(8), + data.getUint8(9), + data.getUint8(10), + ) + + let touch00 = data.getUint8(33) + let touch01 = data.getUint8(34) + let touch02 = data.getUint8(35) + let touch03 = data.getUint8(36) + + let touch10 = data.getUint8(37) + let touch11 = data.getUint8(38) + let touch12 = data.getUint8(39) + let touch13 = data.getUint8(40) + + this.processInputTouch( + this._state.touch0, + touch00, + touch01, + touch02, + touch03, + ) + this.processInputTouch( + this._state.touch1, + touch10, + touch11, + touch12, + touch13, + ) + + let battery0 = data.getUint8(53) + let battery1 = data.getUint8(54) + + this.processInputBattery(battery0, battery1) + + let gyroX0 = data.getUint8(16) + let gyroX1 = data.getUint8(17) + let gyroY0 = data.getUint8(18) + let gyroY1 = data.getUint8(19) + let gyroZ0 = data.getUint8(20) + let gyroZ1 = data.getUint8(21) + + this.processInputGyro( + this._state.gyro, + gyroX0, + gyroX1, + gyroY0, + gyroY1, + gyroZ0, + gyroZ1, + ) + + let accelX0 = data.getUint8(22) + let accelX1 = data.getUint8(23) + let accelY0 = data.getUint8(24) + let accelY1 = data.getUint8(25) + let accelZ0 = data.getUint8(26) + let accelZ1 = data.getUint8(27) + + this.processInputAccel( + this._state.accel, + accelX0, + accelX1, + accelY0, + accelY1, + accelZ0, + accelZ1, + ) + + // mapping the controller's Y-up coordinate system to the AHRS' Z-up coordinate system + this._ahrs.update( + this._state.gyro.z, + this._state.gyro.x, + this._state.gyro.y, + this._state.accel.z, + this._state.accel.x, + this._state.accel.y, + 0, + 0, + 0, + // Technically, since input reports can come in at a different rate than the sensor refresh rate, + // setting the delta from the report timestamp is not totally correct, but it's close enough I think. + // We could get the actual sensor timestamp later. + this._state.prevTimestamp !== -1 + ? (this._state.timestamp - this._state.prevTimestamp) / 1000 + : 20, + ) + + type Quaternion = ReturnType + + function mulQuat(a: Quaternion, b: Quaternion) { + var qax = a.x, + qay = a.y, + qaz = a.z, + qaw = a.w, + qbx = b.x, + qby = b.y, + qbz = b.z, + qbw = b.w + + const res = { + x: qax * qbw + qay * qbz - qaz * qby + qaw * qbx, + y: -qax * qbz + qay * qbw + qaz * qbx + qaw * qby, + z: qax * qby - qay * qbx + qaz * qbw + qaw * qbz, + w: -qax * qbx - qay * qby - qaz * qbz + qaw * qbw, + } + + return res + } + + const quaternion = mulQuat( + this._internalState.attitudeBias, + this._ahrs.getQuaternion(), + ) + + // mapping from AHRS' Z-up coordinate system to the controller's Y-up coordinate system + this._state.attitude = [ + quaternion.y, + quaternion.z, + quaternion.x, + quaternion.w, + ] + + for (let i = 0; i <= evt.data.byteLength - 1; i++) { + this._state.raw31[i.toString()] = evt.data.getUint8(i) + } + } + + private processInputReport(evt: HIDInputReportEvent) { + if (this._connectionType === DSControllerConnectionType.BLUETOOTH) { + if (evt.reportId == 0x01) { + console.log('01') + this.processInputReportBluetooth01(evt) + } else if (evt.reportId == 0x31) { + this.processInputReportBluetooth31(evt) + } else { + return + } + } else if (this._connectionType === DSControllerConnectionType.USB) { + if (evt.reportId == 0x01) { + this.processInputReportUSB01(evt) + } else { + return + } + } else { + return + } + + this.dispatchEvent('stateChange', { + state: this._state, + }) + } + + get status(): DSControllerStatus { + return this._status + } + + get connectionType(): DSControllerConnectionType { + return this._connectionType + } + + get productName(): string { + return this._device.productName + } + + set ledColor(color: DSControllerLEDColor) { + this._internalState.ledColor = { + r: floatToUnorm(color.r), + g: floatToUnorm(color.g), + b: floatToUnorm(color.b), + } + } + + set leftMotor(strength: number) { + this._internalState.leftMotor = floatToUnorm(strength) + } + + set rightMotor(strength: number) { + this._internalState.rightMotor = floatToUnorm(strength) + } + + async disconnect() { + if (this._status !== DSControllerStatus.CONNECTED) { + throw new DSControllerNotConnected() + } else { + clearInterval(this._intervalId) + await this._device.close() + this._status = DSControllerStatus.DISCONNECTED + } + } + + private prepareReport(): Uint8Array { + let data: Uint8Array + + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + data = new Uint8Array(78) // only bytes starting with 1 will be sent, byte 0 is for CRC (report ID) + data[0] = 0x31 // report ID + data[1] = 0x02 + } else { + data = new Uint8Array(47) + } + + let off = 0 // common report data offset (0 for USB report 0x02, 3 for BT report 0x31) + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + off = 2 + } + + data[off + 0] = 0xff + data[off + 1] = 0xf7 //0x1 | 0x2 | 0x4 | 0x10 | 0x40; + data[off + 2] = this._internalState.leftMotor + data[off + 3] = this._internalState.rightMotor + data[off + 39] = 0x01 + data[off + 43] = 0x02 + data[off + 44] = this._internalState.ledColor.r + data[off + 45] = this._internalState.ledColor.g + data[off + 46] = this._internalState.ledColor.b + + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + let crcDv = new DataView(data.buffer, 74, 4) + let crc = crc32(Buffer.from(data.slice(0, 74)), 0xeada2d49) + crcDv.setUint32(0, crc, true) + + return data.slice(1, 78) + } else { + return data + } + } + + private async sendReport() { + if (this._connectionType == DSControllerConnectionType.BLUETOOTH) { + await this._device.sendReport(0x31, this.prepareReport()) + } else { + await this._device.sendReport(0x02, this.prepareReport()) + } + } + + private async backgroundLoop() { + await this.sendReport() + } +} diff --git a/packages/dualsense-bindings/src/index.ts b/packages/dualsense-bindings/src/index.ts new file mode 100644 index 0000000000..538b61c6de --- /dev/null +++ b/packages/dualsense-bindings/src/index.ts @@ -0,0 +1,110 @@ +import type {ISheetObject} from '@theatre/core' +import type {IStudio} from '@theatre/studio' +import * as DS from './ds-hid' + +export type API = { + orientation: { + applyGyro: () => void + apply: () => void + reset: () => void + } + object: ISheetObject + studio: IStudio +} + +type DSBindings = { + orientation?: (x: number, y: number, z: number, w: number, api: API) => void + + cross?: (pressed: boolean, api: API) => void + square?: (pressed: boolean, api: API) => void + circle?: (pressed: boolean, api: API) => void + triangle?: (pressed: boolean, api: API) => void +} + +type Selector = (address: ISheetObject['address']) => boolean + +export default class DualSenseBindings { + private _controller: DS.DSController | null = null + private _studio + private _bindings: Set<{bindings: DSBindings; selector: Selector}> = new Set() + + private _buttons = { + cross: false, + square: false, + circle: false, + triangle: false, + } + + constructor(studio: IStudio) { + this._studio = studio + } + + async connect() { + this._controller = await DS.connectController() + + this._controller.addEventListener('stateChange', (evt) => { + const item = this._studio.selection.find( + (s): s is ISheetObject => s.type === 'Theatre_SheetObject_PublicAPI', + ) + + if (!item) return + + const api: API = { + orientation: this._controller!.attitude, + object: item, + studio: this._studio, + } + + this._bindings.forEach((bindings) => { + if (!bindings.selector(item.address)) return + + bindings.bindings.orientation?.( + ...(Object.values(evt.state.attitude) as [ + number, + number, + number, + number, + ]), + api, + ) + + if (evt.state.cross !== this._buttons.cross) { + bindings.bindings.cross?.(evt.state.cross, api) + } + if (evt.state.square !== this._buttons.square) { + bindings.bindings.square?.(evt.state.square, api) + } + if (evt.state.circle !== this._buttons.circle) { + bindings.bindings.circle?.(evt.state.circle, api) + } + if (evt.state.triangle !== this._buttons.triangle) { + bindings.bindings.triangle?.(evt.state.triangle, api) + } + }) + + this._buttons.cross = evt.state.cross + this._buttons.square = evt.state.square + this._buttons.circle = evt.state.circle + this._buttons.triangle = evt.state.triangle + }) + + return () => { + this._controller?.disconnect() + this._controller = null + } + } + + addBinding(bindings: DSBindings, selector: Selector = () => true) { + const item = { + bindings, + selector, + } + this._bindings.add(item) + + return () => { + this._bindings.delete(item) + } + } +} + +export * from './bindingCreators' diff --git a/packages/dualsense-bindings/tsconfig.json b/packages/dualsense-bindings/tsconfig.json new file mode 100644 index 0000000000..a03e340e28 --- /dev/null +++ b/packages/dualsense-bindings/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ESNext", "DOM"], + "rootDir": "src", + "types": ["jest", "node"], + "emitDeclarationOnly": true, + "target": "es6", + "composite": true + }, + "include": ["./src/**/*"], + "references": [{"path": "../../theatre"}] +} diff --git a/packages/playground/src/shared/dualsense-bindings/App.tsx b/packages/playground/src/shared/dualsense-bindings/App.tsx new file mode 100644 index 0000000000..d72329d9ff --- /dev/null +++ b/packages/playground/src/shared/dualsense-bindings/App.tsx @@ -0,0 +1,199 @@ +import {editable as e, SheetProvider} from '@theatre/r3f' +import {Plane} from '@react-three/drei' +import {getProject, notify} from '@theatre/core' +import React, {Suspense, useEffect} from 'react' +import {Canvas, useFrame} from '@react-three/fiber' +import {useGLTF, PerspectiveCamera} from '@react-three/drei' +import type {MovementPlane} from '@theatre/dualsense-bindings' +import DualSenseBindings, { + createPositionBinding, + createRotationBinding, +} from '@theatre/dualsense-bindings' +import studio from '@theatre/studio' +import type {GridHelper} from 'three' + +notify.info( + 'Click anywhere to connect a controller', + 'This playground demonstrates the dualsense-bindings package. Click anywhere to connect a DualSense controller.', +) + +const EditableCamera = e(PerspectiveCamera, 'perspectiveCamera') + +let originalPosition: [number, number, number] | null = null +let movementPlane: MovementPlane | null = null + +// All the bindings code you need (API names work in progress) ⬇️ + +// Mayyybe we should depend on studio in the bindings package, all the bindings will be together anyway (I think), so it's easy to exclude from the production bundle. +// This kind of injection would only make sense if we assume that bindings would be added all over the place. +const bindings = new DualSenseBindings(studio) +bindings.addBinding( + createRotationBinding(), + (address) => address.projectId === 'Space', +) +bindings.addBinding( + createPositionBinding({ + // this API literally only exists so that I can do the fancy grid helper thing :D + onStart: (plane, newOriginalPosition) => { + movementPlane = plane + originalPosition = newOriginalPosition + }, + onEnd: () => { + originalPosition = null + movementPlane = null + }, + }), +) + +// Done + +const planeSize = 20 + +const PositioningHelper = () => { + const gridHelperRef = React.useRef(null) + + useFrame(() => { + if (gridHelperRef.current) { + if (movementPlane) { + gridHelperRef.current.visible = true + gridHelperRef.current.position.set(...originalPosition!) + if (movementPlane === 'xz') { + gridHelperRef.current.rotation.x = 0 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = 0 + } + if (movementPlane === 'xy') { + gridHelperRef.current.rotation.x = Math.PI / 2 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = 0 + } + if (movementPlane === 'yz') { + gridHelperRef.current.rotation.x = 0 + gridHelperRef.current.rotation.y = 0 + gridHelperRef.current.rotation.z = Math.PI / 2 + } + } else { + gridHelperRef.current.visible = false + } + } + }) + return +} + +const Model = () => { + const {scene} = useGLTF( + 'https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/zombie-car/model.gltf', + ) + + useEffect(() => { + scene.traverse((mesh: any) => { + mesh.castShadow = true + }) + }, [scene]) + + return ( + + + + + + ) +} + +function App() { + return ( +
{ + bindings.connect() + }} + style={{ + height: '100vh', + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +export default App diff --git a/packages/playground/src/shared/dualsense-bindings/index.tsx b/packages/playground/src/shared/dualsense-bindings/index.tsx new file mode 100644 index 0000000000..5507263712 --- /dev/null +++ b/packages/playground/src/shared/dualsense-bindings/index.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import App from './App' +import studio from '@theatre/studio' +import extension from '@theatre/r3f/dist/extension' + +studio.extend(extension) +studio.initialize() + +ReactDOM.render(, document.getElementById('root')) diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index 39d0b70190..00d906312a 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -12,7 +12,8 @@ "references": [ {"path": "../../theatre"}, {"path": "../dataverse"}, - {"path": "../r3f"} + {"path": "../r3f"}, + {"path": "../dualsense-bindings"} ], "include": ["./src/**/*", "./src/**/*.json", "./devEnv/**/*"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index ab99a313ed..cd4ed770a0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,9 @@ "@theatre/r3f/dist/extension": ["./packages/r3f/src/extension/index.ts"], "@theatre/dataverse-experiments": [ "./packages/dataverse-experiments/src/index.ts" + ], + "@theatre/dualsense-bindings": [ + "./packages/dualsense-bindings/src/index.ts" ] }, "forceConsistentCasingInFileNames": true diff --git a/yarn.lock b/yarn.lock index 63086eb312..ee2f742aeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8178,6 +8178,29 @@ __metadata: languageName: unknown linkType: soft +"@theatre/dualsense-bindings@workspace:packages/dualsense-bindings": + version: 0.0.0-use.local + resolution: "@theatre/dualsense-bindings@workspace:packages/dualsense-bindings" + dependencies: + "@microsoft/api-extractor": ^7.18.11 + "@types/jest": ^26.0.23 + "@types/node": ^15.6.2 + "@types/w3c-web-hid": ^1.0.2 + ahrs: ^1.3.0 + buffer: ^6.0.3 + crc: ^4.1.0 + esbuild: ^0.12.15 + esbuild-register: ^2.5.0 + lodash-es: 4.17.21 + npm-run-all: ^4.1.5 + threejs-math: ^0.147.0 + typescript: ^4.4.2 + peerDependencies: + "@theatre/core": "workspace:*" + "@theatre/studio": "workspace:*" + languageName: unknown + linkType: soft + "@theatre/r3f@workspace:*, @theatre/r3f@workspace:packages/r3f": version: 0.0.0-use.local resolution: "@theatre/r3f@workspace:packages/r3f" @@ -9032,6 +9055,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-hid@npm:^1.0.2": + version: 1.0.3 + resolution: "@types/w3c-web-hid@npm:1.0.3" + checksum: 90ee1eeb2acf5d5ddf0b7acefd4f8aaa7d0175d991c3606a9ad62bdfa7a8de93665f5f6218dc4ecb34ea1d2f3e357813b315f46c1ea6b8aa1693e217e436c9b2 + languageName: node + linkType: hard + "@types/webpack-sources@npm:*": version: 2.1.1 resolution: "@types/webpack-sources@npm:2.1.1" @@ -10201,6 +10231,13 @@ __metadata: languageName: node linkType: hard +"ahrs@npm:^1.3.0": + version: 1.3.1 + resolution: "ahrs@npm:1.3.1" + checksum: 235d1515ca6005a711bed2ff724b353bf4e9758c9ae099d6dc92646126ebbea173a826b974fb33e15893f81ee0a6d485e925488218f99d1a841412a62133ebed + languageName: node + linkType: hard + "ajv-errors@npm:^1.0.0": version: 1.0.1 resolution: "ajv-errors@npm:1.0.1" @@ -11603,7 +11640,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.0.2": +"base64-js@npm:^1.0.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -12160,6 +12197,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + "builtin-modules@npm:^3.1.0": version: 3.2.0 resolution: "builtin-modules@npm:3.2.0" @@ -13376,6 +13423,15 @@ __metadata: languageName: node linkType: hard +"crc@npm:^4.1.0": + version: 4.2.0 + resolution: "crc@npm:4.2.0" + peerDependencies: + buffer: ">=6.0.3" + checksum: 29eabe6bcbee60b67348ba5e03742300c99617ad66d93d67e3bc62b665f0f2b1584157a35ade5b1037cef65b8a1fd8bedd6e0628c8449555fc9d16a866044318 + languageName: node + linkType: hard + "create-ecdh@npm:^4.0.0": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" @@ -19058,7 +19114,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"ieee754@npm:^1.1.4": +"ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e @@ -22451,7 +22507,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"lodash-es@npm:^4.17.21, lodash-es@npm:^4.2.1": +"lodash-es@npm:4.17.21, lodash-es@npm:^4.17.21, lodash-es@npm:^4.2.1": version: 4.17.21 resolution: "lodash-es@npm:4.17.21" checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 @@ -31407,6 +31463,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"threejs-math@npm:^0.147.0": + version: 0.147.0 + resolution: "threejs-math@npm:0.147.0" + checksum: 314c12491f919cbb5974a382ac2a0fbfe0a04de7b144bc64d8edad5c06efbe7337e6b536b59cae9ae7b03063f56b24852d606f8b85dfa6408ebeed18f0c05c1b + languageName: node + linkType: hard + "throat@npm:^5.0.0": version: 5.0.0 resolution: "throat@npm:5.0.0"