├── .gitignore ├── README.md ├── dolphin ├── index.js ├── package.json └── pnpm-lock.yaml ├── img ├── device.png └── presenter.png ├── overlay ├── App.jsx ├── app.js ├── controller.svg ├── index.html ├── package.json ├── pnpm-lock.yaml └── wii-overlay.svg ├── server ├── index.js ├── package.json └── pnpm-lock.yaml └── web ├── App.jsx ├── Pointer.jsx ├── app.css ├── app.js ├── icons.jsx ├── index.html ├── package.json ├── pnpm-lock.yaml └── wiimote.svg /.gitignore: -------------------------------------------------------------------------------- 1 | .zsign_cache 2 | .parcel-cache 3 | __pycache__ 4 | pnpm-debug.log 5 | dist 6 | .cache 7 | .tern-port 8 | config.json 9 | package-lock.json 10 | README.html 11 | *~ 12 | \#*\# 13 | node_modules 14 | yarn-error.log 15 | npm-error.log 16 | /.vscode 17 | /.idea 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webii 2 | A system allowing anyone to enjoy zero-contact Wii with their friends! 3 | 4 | 5 | 6 | ## Setup 7 | 8 | ### Build the web interface 9 | 10 | 1. Install dependencies: 11 | 12 | ``` 13 | cd web 14 | pnpm i --shamefully-hoist 15 | ``` 16 | 17 | 2. Create `config.json` with your server URL: 18 | 19 | ```json 20 | { 21 | "server": "ws://localhost:5454" 22 | } 23 | ``` 24 | 25 | 3. Build the project 26 | 27 | ```bash 28 | node_modules/.bin/parcel build index.html 29 | ``` 30 | 31 | ### Run the dispatch server 32 | 33 | 1. Install dependencies: 34 | 35 | ```bash 36 | cd server 37 | pnpm i 38 | ``` 39 | 40 | 2. Create `config.json` with the server port: 41 | 42 | ```json 43 | { 44 | "port": 5454 45 | } 46 | ``` 47 | 48 | 3. Run the server: 49 | 50 | ``` 51 | node index.js 52 | ``` 53 | 54 | ### Run the Dolphin server 55 | 56 | 1. Install dependencies: 57 | 58 | ```bash 59 | cd dolphin 60 | pnpm i 61 | ``` 62 | 63 | 2. Create `config.json` with your server URL: 64 | 65 | ```json 66 | { 67 | "server": "ws://localhost:5454" 68 | } 69 | ``` 70 | 71 | 3. Run the server: 72 | 73 | ```bash 74 | node index.js 75 | ``` 76 | 77 | ## Setting up Dolphin 78 | 1. With Dolphin, connect to the DSU server in Dolphin in `Controls` > `Alternate Input Sources`. Tick the `Enable` box and add the server (should be `127.0.0.1:26760`). 79 | 2. For every Wii remote in the list, select `Emulated Wii Remote` 80 | 3. Click `Configure` on `Wii Remote 1` 81 | 4. Select controller 0 of the DSU server in the dropdown in the top-left 82 | 5. Click every button in the list and bind it by pushing the corresponding button on your mobile device 83 | 6. Make sure you have `None` selected for `Extension` 84 | 7. Open the `Motion Input` tab 85 | 8. Right click every empty button and select the corresponding input from the list. This is a long manual process. 86 | 9. At the top right, type a name on the `Profile` dropdown and click `Save` 87 | 10. Close the configuration window 88 | 11. Open Wii Remotes 2-4, select your saved profile, click `Load`, and select the correct numbered DSUClient input device in the top-left 89 | 90 | ## Using 91 | Once you have the Dolphin server running, users can open the web interface and type the pin that was shown to gain control of a Wii remote. 92 | 93 | ## Future ideas 94 | At some point, I'd like to explore having the client use WebRTC for message passing instead of WebSockets for reduced latency, but the current WebSocket approach was chosen due to WebRTC sometimes being blocked by some corporate firewalls. 95 | 96 |  97 | -------------------------------------------------------------------------------- /dolphin/index.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws"); 2 | const fs = require("fs"); 3 | const crc32 = require("crc/lib/crc32"); 4 | const long = require("long"); 5 | const dgram = require("dgram"); 6 | 7 | const config = JSON.parse(fs.readFileSync("./config.json", "utf8")); 8 | 9 | const wss = new WebSocket.Server({ 10 | port: config.localPort, 11 | server: "127.0.0.1", 12 | }); 13 | const clients = new Set(); 14 | wss.on("connection", (client) => { 15 | if (pin) { 16 | clients.add(client); 17 | send( 18 | "hello", 19 | { 20 | pin, 21 | players: Object.keys(players), 22 | }, 23 | client 24 | ); 25 | client.on("close", () => { 26 | clients.delete(client); 27 | }); 28 | } else { 29 | console.log( 30 | "Got a connection from overlay, but dropping it since we aren't ready yet" 31 | ); 32 | client.close(); 33 | } 34 | }); 35 | const ws = new WebSocket(config.server); 36 | 37 | function broadcast(op, data) { 38 | for (const client of clients) { 39 | try { 40 | send(op, data, client); 41 | } catch (err) { 42 | console.warn("Issue broadcasting, termianting client"); 43 | client.terminate(); 44 | } 45 | } 46 | } 47 | function send(op, data, client) { 48 | (client || ws).send( 49 | JSON.stringify({ 50 | op, 51 | d: data, 52 | }) 53 | ); 54 | } 55 | 56 | ws.on("open", () => { 57 | send("hello", { 58 | type: "dolphin", 59 | }); 60 | }); 61 | ws.on("message", (frame) => { 62 | const data = JSON.parse(frame); 63 | HANDLERS[data.op](data.d); 64 | }); 65 | 66 | const players = {}; 67 | let seq = 0; 68 | 69 | const reporting = new Map(); 70 | 71 | function generate(playerId) { 72 | const connection = reporting.get(playerId); 73 | if (!connection) { 74 | console.log("No connection for " + playerId); 75 | return; 76 | } 77 | const packet = Buffer.alloc(80); 78 | packet[0] = playerId; 79 | packet[1] = 2; 80 | packet[2] = 2; 81 | packet[3] = 2; 82 | packet[10] = 0x05; 83 | // Outgoing packet 84 | packet[11] = 1; 85 | packet.writeUInt32LE(seq++, 12); 86 | const player = players[playerId]; 87 | if (!player) { 88 | // console.log("No player", playerId); 89 | return; 90 | } 91 | const buttons = player.buttons; 92 | const axes = player.axes; 93 | packet[16] = 94 | buttons.start | 95 | (buttons.l3 << 1) | 96 | (buttons.r3 << 2) | 97 | (buttons.select << 3) | 98 | (buttons.up << 4) | 99 | (buttons.right << 5) | 100 | (buttons.down << 6) | 101 | (buttons.left << 7); 102 | packet[17] = 103 | buttons.l2 | 104 | (buttons.r2 << 1) | 105 | (buttons.l1 << 2) | 106 | (buttons.r1 << 3) | 107 | (buttons.one << 4) | 108 | (buttons.a << 5) | 109 | (buttons.b << 6) | 110 | (buttons.two << 7); 111 | 112 | // dpad 113 | packet[24] = buttons.left ? 255 : 0; 114 | packet[25] = buttons.down ? 255 : 0; 115 | packet[26] = buttons.right ? 255 : 0; 116 | packet[27] = buttons.up ? 255 : 0; 117 | // buttons 118 | packet[28] = buttons.one ? 255 : 0; 119 | packet[29] = buttons.b ? 255 : 0; 120 | packet[30] = buttons.a ? 255 : 0; 121 | packet[31] = buttons.two ? 255 : 0; 122 | 123 | // IR 124 | packet[36] = player.point.hidden; 125 | packet[37] = 1; 126 | packet.writeUInt16LE(player.point.x, 38); 127 | packet.writeUInt16LE(player.point.x, 40); 128 | // Cursed, steals Left X / Left Y 129 | packet[20] = player.point.distance; 130 | packet[21] = player.point.hidden; 131 | // // IR 132 | // console.log(player.point); 133 | // packet.writeFloatLE(player.point.x, 80); 134 | // packet.writeFloatLE(player.point.y, 84); 135 | // packet[88] = player.point.hidden ? 255 : 0; 136 | // packet.writeFloatLE(player.point.distance, 89); 137 | 138 | // Motion timestamp 139 | if (player.timestamp) { 140 | packet.writeUInt32LE(player.timestamp.low, 48); 141 | packet.writeUInt32LE(player.timestamp.high, 52); 142 | } 143 | 144 | if (axes.accelerometer) { 145 | packet.writeFloatLE(axes.accelerometer.x, 56); 146 | packet.writeFloatLE(axes.accelerometer.y, 60); 147 | packet.writeFloatLE(axes.accelerometer.z, 64); 148 | } 149 | 150 | if (axes.gyroscope) { 151 | packet.writeFloatLE(axes.gyroscope.x, 68); // pitch 152 | packet.writeFloatLE(axes.gyroscope.y, 72); // yaw 153 | packet.writeFloatLE(axes.gyroscope.z, 76); // roll 154 | } 155 | 156 | const packed = pack(0x100002, packet); 157 | // console.log(" T:PadInfo", playerId); 158 | // Send twice for crc reasons? 159 | server.send(packed, 0, packed.length, connection.port, connection.address); 160 | server.send(packed, 0, packed.length, connection.port, connection.address); 161 | } 162 | 163 | setInterval(() => { 164 | for (const playerId of reporting.keys()) { 165 | if (players[playerId]) { 166 | generate(playerId); 167 | } 168 | } 169 | }, 250); 170 | 171 | let pin = null; 172 | const HANDLERS = { 173 | hello(data) { 174 | pin = data.pin; 175 | console.log("We have pin " + data.pin); 176 | }, 177 | connect(data) { 178 | players[data.player] = { 179 | buttons: {}, 180 | axes: {}, 181 | timestamp: null, 182 | point: {x: 0, y: 0, hidden: true, distance: 0}, 183 | }; 184 | broadcast("connect", {player: data.player}); 185 | console.log( 186 | `Controller ${data.player} is now connected by client ${data.clientId}` 187 | ); 188 | }, 189 | disconnect(data) { 190 | delete players[data.player]; 191 | broadcast("disconnect", {player: data.player}); 192 | console.log( 193 | `Controller ${data.player} has been removed by client ${data.clientId}` 194 | ); 195 | }, 196 | button(data) { 197 | players[data.player].buttons[data.button] = data.state; 198 | console.log( 199 | `Controller ${data.player}'s ${data.button} is now ${data.state}` 200 | ); 201 | generate(data.player); 202 | }, 203 | stick(data) { 204 | if (data.axis == "accelerometer") { 205 | players[data.player].timestamp = long.fromNumber(data.t, true); 206 | } 207 | players[data.player].axes[data.axis] = {x: data.x, y: data.y, z: data.z}; 208 | }, 209 | ir(data) { 210 | console.log(data, "ir"); 211 | if (data.x && data.y) { 212 | players[data.player].point = { 213 | x: data.x, 214 | y: data.y, 215 | distance: data.z, 216 | hidden: false, 217 | }; 218 | } else { 219 | players[data.player].point.hidden = true; 220 | } 221 | }, 222 | }; 223 | 224 | const serverId = Math.floor(Math.random() * Math.pow(2, 32)); 225 | 226 | function pack(type, content) { 227 | const length = content.length; 228 | // Header 229 | const buffer = Buffer.alloc(20); 230 | // magic 231 | buffer.write("DSUS", 0, 4); 232 | // protocol version 233 | buffer.writeUInt16LE(1001, 4); 234 | // Length (+4 is event type) 235 | buffer.writeUInt16LE(length + 4, 6); 236 | // CRC32 237 | // (must be left blank because of CRC) 238 | // Server ID 239 | buffer.writeUInt32LE(serverId, 12); 240 | // event type 241 | buffer.writeUInt32LE(type, 16); 242 | 243 | const result = Buffer.concat([buffer, content], length + 20); 244 | // CRC 245 | const crc = crc32(result); 246 | result.writeUInt32LE(crc, 8); 247 | return result; 248 | } 249 | 250 | const server = dgram.createSocket("udp4"); 251 | server.bind(26760, "0.0.0.0"); 252 | server.on("message", (msg, rinfo) => { 253 | const type = msg.readUInt32LE(16, 16); 254 | // console.log("Got a message:", type.toString(16)); 255 | switch (type) { 256 | case 0x100001: { 257 | // console.log("R :PortInfo"); 258 | const count = msg.readInt32LE(20); 259 | // console.log("Got a request for info on device count:", count); 260 | let last = null; 261 | for (let i = 0; i < count; ++i) { 262 | const controllerData = Buffer.alloc(12); 263 | const controller = msg[24 + i]; 264 | const player = players[controller]; 265 | controllerData[0] = controller; // pad id 266 | controllerData[1] = player ? 2 : 0; // State (connected) 267 | 268 | controllerData[2] = 2; // Full gyro 269 | controllerData[3] = player ? 2 : 0; // connection = bluetooth 270 | // Battery 271 | controllerData[10] = 0x05; 272 | const packed = pack(0x100001, controllerData); 273 | // console.log(" T:PortInfo", controller); 274 | server.send(packed, 0, packed.length, rinfo.port, rinfo.address); 275 | } 276 | break; 277 | } 278 | case 0x100002: { 279 | // console.log("R :PadInfo"); 280 | if (msg[20] == 0) { 281 | for (let i = 0; i < 4; ++i) { 282 | reporting.set(i, rinfo); 283 | } 284 | } else if (msg[20] == 1) { 285 | reporting.set(msg[21], rinfo); 286 | } 287 | 288 | for (const playerId of reporting.keys()) { 289 | generate(playerId); 290 | } 291 | break; 292 | } 293 | } 294 | }); 295 | 296 | async function fifoSend(player, data) { 297 | console.log("Sending", data, "along fifo"); 298 | await fs.promises.appendFile(config.socket + "-" + player, data + "\n"); 299 | } 300 | -------------------------------------------------------------------------------- /dolphin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webii-dolphin", 3 | "version": "1.0.0", 4 | "description": "Client which receives input events from devices and sends them to the Dolphin emulator", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/mstrodl/webii.git" 12 | }, 13 | "keywords": [ 14 | "dolphin-emu", 15 | "dolphin", 16 | "controller" 17 | ], 18 | "author": "Mary Strodl ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mstrodl/webii/issues" 22 | }, 23 | "homepage": "https://github.com/mstrodl/webii#readme", 24 | "dependencies": { 25 | "crc": "^3.8.0", 26 | "long": "^4.0.0", 27 | "ws": "^7.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /dolphin/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | crc: 3.8.0 3 | long: 4.0.0 4 | ws: 7.3.1 5 | lockfileVersion: 5.1 6 | packages: 7 | /base64-js/1.3.1: 8 | dev: false 9 | resolution: 10 | integrity: sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== 11 | /buffer/5.6.0: 12 | dependencies: 13 | base64-js: 1.3.1 14 | ieee754: 1.1.13 15 | dev: false 16 | resolution: 17 | integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== 18 | /crc/3.8.0: 19 | dependencies: 20 | buffer: 5.6.0 21 | dev: false 22 | resolution: 23 | integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== 24 | /ieee754/1.1.13: 25 | dev: false 26 | resolution: 27 | integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== 28 | /long/4.0.0: 29 | dev: false 30 | resolution: 31 | integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== 32 | /ws/7.3.1: 33 | dev: false 34 | engines: 35 | node: '>=8.3.0' 36 | peerDependencies: 37 | bufferutil: ^4.0.1 38 | utf-8-validate: ^5.0.2 39 | peerDependenciesMeta: 40 | bufferutil: 41 | optional: true 42 | utf-8-validate: 43 | optional: true 44 | resolution: 45 | integrity: sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== 46 | specifiers: 47 | crc: ^3.8.0 48 | long: ^4.0.0 49 | ws: ^7.3.1 50 | -------------------------------------------------------------------------------- /img/device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mstrodl/webii/6cb2bfa531b69a67ed625f93387ba43a18c0ef9f/img/device.png -------------------------------------------------------------------------------- /img/presenter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mstrodl/webii/6cb2bfa531b69a67ed625f93387ba43a18c0ef9f/img/presenter.png -------------------------------------------------------------------------------- /overlay/App.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import wiimoteSvg from "url:./controller.svg"; 3 | import QRCode from "qrcode.react"; 4 | 5 | export function App() { 6 | const [players, setPlayers] = useState({}); 7 | const [pin, setPin] = useState(null); 8 | 9 | useEffect(() => { 10 | let ws; 11 | function connect() { 12 | console.log("Connecting..."); 13 | setPlayers({}); 14 | setPin(null); 15 | ws = new WebSocket("ws://localhost:9999"); 16 | ws.addEventListener("message", (frame) => { 17 | const msg = JSON.parse(frame.data); 18 | if (msg.op == "hello") { 19 | const players = {}; 20 | for (const key of msg.d.players) { 21 | players[key] = true; 22 | } 23 | setPlayers(players); 24 | setPin(msg.d.pin); 25 | } else if (msg.op == "connect") { 26 | setPlayers((players) => 27 | Object.assign({}, players, {[msg.d.player]: true}) 28 | ); 29 | } else if (msg.op == "disconnect") { 30 | setPlayers((players) => 31 | Object.assign({}, players, {[msg.d.player]: false}) 32 | ); 33 | } 34 | }); 35 | 36 | ws.addEventListener("close", () => { 37 | if (ws) { 38 | ws = null; 39 | setTimeout(() => { 40 | console.log("Timeout hit"); 41 | connect(); 42 | }, 500); 43 | } 44 | }); 45 | ws.addEventListener("error", () => { 46 | if (ws) { 47 | ws = null; 48 | setTimeout(() => { 49 | console.log("Timeout hit on error"); 50 | connect(); 51 | }, 500); 52 | } 53 | }); 54 | } 55 | connect(); 56 | 57 | return () => { 58 | if (ws) { 59 | ws = null; 60 | const old = ws; 61 | old.close(); 62 | } 63 | }; 64 | }, []); 65 | 66 | return ( 67 | 68 | 75 | 76 | {/**/} 77 | 86 | 95 | 104 | 113 | 126 | 127 | 128 | The Contactless Wii 129 | 130 | 131 | 132 | 140 | {pin && ( 141 | 148 | )} 149 | 150 | 163 | 164 | 170 | Join Game: 171 | 172 | 173 | 174 | 175 | 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /overlay/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import {App} from "./App.jsx"; 4 | 5 | ReactDOM.render(, document.getElementById("mount")); 6 | -------------------------------------------------------------------------------- /overlay/controller.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 63 | 74 | 86 | 92 | 99 | 103 | 104 | 111 | 115 | 116 | 123 | 127 | 128 | 135 | 139 | 140 | 151 | 162 | 168 | 177 | 178 | 184 | 193 | 194 | 205 | 210 | 217 | 224 | 231 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /overlay/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /overlay/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webii-overlay", 3 | "version": "1.0.0", 4 | "description": "Webpage to show the connected controllers and game pin of the current Dolphin server for OBS overlays", 5 | "main": "dist/index.html", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/mstrodl/webii.git" 12 | }, 13 | "keywords": [ 14 | "dolphin-emu", 15 | "dolphin", 16 | "controller" 17 | ], 18 | "author": "Mary Strodl ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mstrodl/webii/issues" 22 | }, 23 | "homepage": "https://github.com/mstrodl/webii#readme", 24 | "dependencies": { 25 | "parcel": "^2.0.0-beta.1", 26 | "qrcode.react": "^1.0.0", 27 | "react": "^16.13.1", 28 | "react-dom": "^16.13.1" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.11.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /overlay/wii-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | The Contactless Wii 9 | 10 | Join Game: 11 | 12 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const WebSocket = require("ws"); 3 | 4 | const config = JSON.parse(fs.readFileSync("./config.json", "utf8")); 5 | 6 | const wss = new WebSocket.Server({port: config.port, host: "0.0.0.0"}); 7 | const sessions = new Map(); 8 | 9 | const CLOSE_CODES = { 10 | BAD_PIN: 4001, 11 | KICKED: 4002, 12 | ROOM_FULL: 4003, 13 | BAD_FORMAT: 4004, 14 | DOLPHIN_DIED: 4005, 15 | }; 16 | 17 | wss.on("connection", (ws) => { 18 | console.log("Client"); 19 | ws.once("message", (frame) => { 20 | const data = JSON.parse(frame); 21 | if (data.op != "hello") { 22 | ws.close(CLOSE_CODES.BAD_OP); 23 | } else { 24 | if (!data.d) { 25 | console.log(data, "Bad format"); 26 | return ws.close(CLOSE_CODES.BAD_FORMAT); 27 | } 28 | const type = data.d.type; 29 | if (type == "dolphin") { 30 | const dolphin = new Dolphin(ws); 31 | sessions.set(dolphin.pin, dolphin); 32 | console.log("Created session", dolphin.pin); 33 | } else if (type == "client") { 34 | console.log("Connection with", data.d.pin); 35 | const dolphin = sessions.get(data.d.pin); 36 | if (!dolphin) { 37 | return ws.close(CLOSE_CODES.BAD_PIN); 38 | } 39 | const client = new Client(dolphin, ws); 40 | console.log("Created client"); 41 | } 42 | } 43 | }); 44 | }); 45 | 46 | const CHARACTERS = "abcdefghijklmnopqrstuvwxyz"; 47 | function generatePin() { 48 | while (true) { 49 | let pin = ""; 50 | for (let i = 0; i < 5; ++i) { 51 | pin += CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)]; 52 | } 53 | if (!sessions.has(pin)) { 54 | return pin; 55 | } 56 | } 57 | } 58 | 59 | class Dolphin { 60 | constructor(ws) { 61 | this.ws = ws; 62 | this.ws.on("message", (frame) => { 63 | const data = JSON.parse(frame); 64 | this["on" + data.op[0].toUpperCase() + data.op.substring(1)](data.d); 65 | }); 66 | 67 | this.ws.on("pong", () => { 68 | this.ws.isAlive = true; 69 | }); 70 | this.interval = setInterval(() => { 71 | if (this.ws.isAlive === false) { 72 | this.ws.terminate(); 73 | } 74 | this.ws.isAlive = false; 75 | this.ws.ping(() => {}); 76 | }, 30000); 77 | 78 | this.players = new Array(4); 79 | this.pin = generatePin(); 80 | this.CLIENT_COUNT = 0; 81 | this.send("hello", { 82 | pin: this.pin, 83 | }); 84 | this.ws.on("close", () => { 85 | sessions.delete(this.pin); 86 | clearInterval(this.interval); 87 | }); 88 | } 89 | 90 | send(op, data) { 91 | this.ws.send( 92 | JSON.stringify({ 93 | op, 94 | d: data, 95 | }) 96 | ); 97 | } 98 | 99 | attachPlayer(client) { 100 | for (let index = 0; index < this.players.length; ++index) { 101 | console.log("Checking", index, this.players[index]); 102 | if (!this.players[index]) { 103 | this.players[index] = client; 104 | console.log("All good"); 105 | return index; 106 | } 107 | } 108 | return null; 109 | } 110 | 111 | onDisconnect(data) { 112 | for (const index in this.players) { 113 | if ( 114 | this.players[index] && 115 | this.players[index].clientId == data.clientId 116 | ) { 117 | this.players[index].close(CLOSE_CODES.KICKED); 118 | this.players[index] = null; 119 | } 120 | } 121 | } 122 | } 123 | 124 | class Client { 125 | constructor(server, ws) { 126 | this.server = server; 127 | this.ws = ws; 128 | this.ws.on("pong", () => { 129 | this.ws.isAlive = true; 130 | }); 131 | this.interval = setInterval(() => { 132 | if (this.ws.isAlive === false) { 133 | this.ws.terminate(); 134 | } 135 | this.ws.isAlive = false; 136 | this.ws.ping(() => {}); 137 | }, 30000); 138 | this.clientId = ++this.server.CLIENT_COUNT; 139 | this.ws.on("message", (frame) => { 140 | // console.log(this.server.pin, "<-", frame); 141 | const data = JSON.parse(frame); 142 | this["on" + data.op[0].toUpperCase() + data.op.substring(1)](data.d); 143 | }); 144 | this.ws.once("close", (code) => { 145 | clearInterval(this.interval); 146 | delete this.server.players[this.player]; 147 | this.server.send("disconnect", { 148 | player: this.player, 149 | }); 150 | }); 151 | this.server.ws.once("close", () => { 152 | this.close(CLOSE_CODES.DOLPHIN_DIED); 153 | }); 154 | 155 | this.player = this.server.attachPlayer(this); 156 | if (this.player === null) { 157 | this.close(CLOSE_CODES.ROOM_FULL); 158 | } else { 159 | this.send("hello", { 160 | player: this.player, 161 | clientId: this.clientId, 162 | }); 163 | this.server.send("connect", { 164 | player: this.player, 165 | clientId: this.clientId, 166 | }); 167 | } 168 | } 169 | 170 | send(op, data) { 171 | this.ws.send( 172 | JSON.stringify({ 173 | op, 174 | d: data, 175 | }) 176 | ); 177 | } 178 | 179 | close(code) { 180 | this.ws.close(code); 181 | } 182 | 183 | onButton(data) { 184 | if (this.player !== null) { 185 | this.server.send("button", { 186 | player: this.player, 187 | button: data.button, 188 | state: data.state, 189 | }); 190 | } 191 | } 192 | 193 | onIr(data) { 194 | if (this.player !== null) { 195 | this.server.send("ir", { 196 | player: this.player, 197 | x: data.x, 198 | y: data.y, 199 | z: data.z, 200 | }); 201 | } 202 | } 203 | 204 | onStick(data) { 205 | if (this.player !== null) { 206 | if (data.axis == "accelerometer") { 207 | //console.log(data); 208 | } 209 | this.server.send("stick", { 210 | player: this.player, 211 | axis: data.axis, 212 | x: data.x, 213 | y: data.y, 214 | z: data.z, 215 | t: data.t, 216 | }); 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webii-server", 3 | "version": "1.0.0", 4 | "description": "Server for routing wii remote inputs from devices to the emulator", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/mstrodl/webii.git" 12 | }, 13 | "keywords": [ 14 | "dolphin-emu", 15 | "dolphin", 16 | "controller" 17 | ], 18 | "author": "Mary Strodl ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mstrodl/webii/issues" 22 | }, 23 | "homepage": "https://github.com/mstrodl/webii#readme", 24 | "dependencies": { 25 | "ws": "^7.3.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | ws: 7.3.1 3 | lockfileVersion: 5.1 4 | packages: 5 | /ws/7.3.1: 6 | dev: false 7 | engines: 8 | node: '>=8.3.0' 9 | peerDependencies: 10 | bufferutil: ^4.0.1 11 | utf-8-validate: ^5.0.2 12 | peerDependenciesMeta: 13 | bufferutil: 14 | optional: true 15 | utf-8-validate: 16 | optional: true 17 | resolution: 18 | integrity: sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== 19 | specifiers: 20 | ws: ^7.3.1 21 | -------------------------------------------------------------------------------- /web/App.jsx: -------------------------------------------------------------------------------- 1 | import * as icons from "./icons.jsx"; 2 | import config from "./config.json"; 3 | import React, {useState, useCallback, useEffect} from "react"; 4 | import GyroNorm from "gyronorm/dist/gyronorm.complete.min.js"; 5 | import {Pointer} from "./Pointer.jsx"; 6 | 7 | export function App() { 8 | const [pin, setPin] = useState(null); 9 | const [error, setError] = useState(null); 10 | 11 | useEffect(() => { 12 | // Pin can be included in link for QR codes 13 | if (location.hash.length > 1) { 14 | setPin(location.hash.substring(1)); 15 | } 16 | 17 | function handler(event) { 18 | event.preventDefault(); 19 | const elem = document.documentElement; 20 | if (elem.requestFullscreen) { 21 | elem.requestFullscreen(); 22 | } else if (elem.webkitRequestFullscreen) { 23 | elem.webkitRequestFullscreen(); 24 | } 25 | } 26 | 27 | window.addEventListener("touchstart", handler); 28 | return () => window.removeEventListener("touchstart", handler); 29 | }, []); 30 | 31 | let child = null; 32 | if (!pin) { 33 | return setPin(pin)} error={error} />; 34 | } else { 35 | return ( 36 | <> 37 | { 40 | setError(error); 41 | setPin(null); 42 | }} 43 | /> 44 | 45 | > 46 | ); 47 | } 48 | 49 | return {child}; 50 | } 51 | 52 | function DarkToggle() { 53 | const [dark, setDark] = useState( 54 | window.matchMedia("(prefers-color-scheme: dark)").matches 55 | ); 56 | useEffect(() => { 57 | const match = window.matchMedia("(prefers-color-scheme: dark)"); 58 | console.log("Matched?", match); 59 | const listener = (match) => { 60 | console.log("Updated"); 61 | setDark(match.matches); 62 | }; 63 | listener(match); 64 | match.addEventListener("change", listener); 65 | return () => { 66 | match.removeEventListener("change", listener); 67 | }; 68 | }, []); 69 | useEffect(() => { 70 | if (dark) { 71 | document.documentElement.classList.add("dark"); 72 | document.documentElement.classList.remove("light"); 73 | } else { 74 | document.documentElement.classList.remove("dark"); 75 | document.documentElement.classList.add("light"); 76 | } 77 | }, [dark]); 78 | const onClick = useCallback(() => setDark((dark) => !dark), []); 79 | return ( 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | function PinPicker({setPin, error}) { 87 | const [tempPin, setTempPin] = useState(""); 88 | return ( 89 | 90 | {error && {error}} 91 | setTempPin(event.currentTarget.value)} 95 | value={tempPin} 96 | placeholder="Game Pin" 97 | /> 98 | setPin(tempPin.toLowerCase())} 102 | > 103 | Join Game 104 | 105 | 106 | ); 107 | } 108 | 109 | class Client { 110 | constructor(pin, ready) { 111 | this.ws = new WebSocket(config.server); 112 | this.ws.addEventListener("open", () => { 113 | this.send("hello", { 114 | type: "client", 115 | pin: pin, 116 | }); 117 | }); 118 | this.ws.addEventListener("message", (event) => { 119 | const msg = JSON.parse(event.data); 120 | console.log(msg, this); 121 | if (msg.op == "hello") { 122 | this.player = msg.d.player; 123 | ready(); 124 | } 125 | }); 126 | } 127 | 128 | send(op, data) { 129 | this.ws.send(JSON.stringify({op, d: data})); 130 | } 131 | 132 | button(button, state) { 133 | this.send("button", { 134 | button, 135 | state, 136 | }); 137 | } 138 | 139 | ir(x, y, z) { 140 | this.send("ir", {x, y, z}); 141 | } 142 | 143 | axis(axis, {x, y, z}) { 144 | this.send("stick", { 145 | axis, 146 | x, 147 | y, 148 | z, 149 | }); 150 | } 151 | } 152 | 153 | function Button({type, client, children}) { 154 | const Icon = icons[type]; 155 | /* onTouchMove={(event) => { 156 | * event.preventDefault(); 157 | * client.button(type, true); 158 | * }} */ 159 | return ( 160 | { 163 | event.preventDefault(); 164 | client.button(type, true); 165 | }} 166 | onMouseUp={(event) => { 167 | event.preventDefault(); 168 | client.button(type, false); 169 | }} 170 | onTouchStart={(event) => { 171 | event.preventDefault(); 172 | client.button(type, true); 173 | }} 174 | onTouchEnd={() => { 175 | event.preventDefault(); 176 | client.button(type, false); 177 | }} 178 | > 179 | {children} 180 | 181 | ); 182 | } 183 | 184 | const errors = { 185 | 4001: "Bad Pin Provided", 186 | 4002: "You were kicked", 187 | 4003: "Room is full", 188 | 4004: "Internal error: bad format", 189 | 4005: "Session was closed", 190 | }; 191 | 192 | function Controller({setError, pin}) { 193 | const [connected, setConnected] = useState(false); 194 | const [client, setClient] = useState(null); 195 | const [granted, setGranted] = useState( 196 | typeof DeviceOrientationEvent == "undefined" || 197 | typeof DeviceOrientationEvent.requestPermission != "function" 198 | ); 199 | const [pointing, setPointing] = useState(false); // false 200 | const [prompting, setPrompting] = useState(false); 201 | useEffect(() => { 202 | const client = new Client(pin, () => { 203 | setConnected(true); 204 | }); 205 | setClient(client); 206 | const closeListener = (event) => { 207 | setError( 208 | errors[event.code] || 209 | "You may have had a network error. Try reconnecting. Error code: " + 210 | event.code 211 | ); 212 | }; 213 | client.ws.addEventListener("close", closeListener); 214 | return () => { 215 | client.ws.removeEventListener("close", closeListener); 216 | client.ws.close(); 217 | }; 218 | }, [pin]); 219 | useEffect(() => { 220 | if (connected && granted) { 221 | let running = true; 222 | const gn = new GyroNorm(); 223 | gn.init({ 224 | frequency: 10, 225 | }) 226 | .then(() => { 227 | gn.start((data) => { 228 | if (running) { 229 | client.axis("accelerometer", { 230 | // 1g = 9.8m/s² 231 | x: data.dm.gx / 9.8, 232 | y: data.dm.gz / 9.8, 233 | z: -data.dm.gy / 9.8, 234 | t: Date.now(), 235 | }); 236 | client.axis("gyroscope", { 237 | // (Degrees / sec) 238 | x: data.dm.alpha, // Pitch 239 | y: data.dm.beta, // Yaw 240 | z: data.dm.gamma, // Roll 241 | }); 242 | } 243 | }); 244 | }) 245 | .catch((err) => { 246 | alert( 247 | "It seems like your hardware might not have an accelerometer! You can still play, but won't have tilt controls. Error: " + 248 | (err.message || err) 249 | ); 250 | console.error("No gyro support?", err); 251 | }); 252 | return () => { 253 | running = false; 254 | gn.stop(); 255 | }; 256 | } else if (!granted && connected) { 257 | setPrompting(true); 258 | } 259 | }, [connected, granted]); 260 | 261 | if (!connected) { 262 | return "Connecting..."; 263 | } 264 | console.log(client.player); 265 | return ( 266 | 267 | {prompting && ( 268 | 269 | 270 | 271 | Before we can get you playing, we need permission to get motion 272 | data from your device so we can tell the game how the controller 273 | is moving! 274 | 275 | 276 | 279 | DeviceOrientationEvent.requestPermission().then((state) => { 280 | setPrompting(false); 281 | setGranted(state == "granted"); 282 | }) 283 | } 284 | > 285 | Okay! 286 | 287 | 288 | 289 | 290 | )} 291 | {pointing && } 292 | 299 | 300 | 313 | 314 | 315 | 325 | 338 | 339 | 1 340 | 341 | 342 | 343 | 344 | 345 | 346 | 357 | 358 | 371 | 372 | B 373 | 374 | 375 | 376 | 377 | 378 | 385 | 386 | 387 | 394 | 398 | 399 | 400 | 401 | 402 | 409 | 413 | 414 | 415 | 416 | 417 | 424 | 428 | 429 | 430 | 431 | 432 | 439 | 443 | 444 | 445 | 446 | 447 | 448 | 458 | 471 | 472 | 2 473 | 474 | 475 | 476 | 477 | 478 | 479 | 489 | 502 | 503 | A 504 | 505 | 506 | 507 | 508 | 509 | 515 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 539 | 540 | 541 | 542 | 543 | 554 | 555 | 556 | = 0 ? LED_ON : LED_OFF} 562 | /> 563 | = 1 ? LED_ON : LED_OFF} 569 | /> 570 | = 2 ? LED_ON : LED_OFF} 576 | /> 577 | = 3 ? LED_ON : LED_OFF} 583 | /> 584 | 585 | 586 | 587 | 588 | ); 589 | } 590 | 591 | const LED_ON = "#37abc8"; 592 | const LED_OFF = "#ccc"; 593 | /* 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | */ 628 | -------------------------------------------------------------------------------- /web/Pointer.jsx: -------------------------------------------------------------------------------- 1 | import {AR} from "js-aruco"; 2 | import React, {useState, useRef, useCallback, useEffect} from "react"; 3 | 4 | export function Pointer({client}) { 5 | const [detector] = useState(() => new AR.Detector()); 6 | const [context, setContext] = useState(null); 7 | const [stream, setStream] = useState(null); 8 | const [dimensions, setDimensions] = useState(null); 9 | const canvas = useRef(null); 10 | const video = useRef(null); 11 | 12 | const setVideo = useCallback( 13 | (element) => { 14 | video.current = element; 15 | attachVideo(stream); 16 | console.log("Stream", stream, video.current); 17 | }, 18 | [stream] 19 | ); 20 | 21 | const attachVideo = useCallback( 22 | (stream) => { 23 | console.log("Attach video", video.current, stream); 24 | if (video.current && stream) { 25 | console.log("setting", stream); 26 | if ("srcObject" in video.current) { 27 | video.current.srcObject = stream; 28 | } else { 29 | url = URL.createObjectURL(stream); 30 | video.current.src = url; 31 | } 32 | 33 | console.log("context", context); 34 | requestAnimationFrame(tick); 35 | } 36 | }, 37 | [context] 38 | ); 39 | 40 | const tick = useCallback(() => { 41 | /* console.log("Tick", context); */ 42 | if (context) { 43 | context.drawImage( 44 | video.current, 45 | 0, 46 | 0, 47 | canvas.current.width, 48 | canvas.current.height 49 | ); 50 | const imageData = context.getImageData( 51 | 0, 52 | 0, 53 | canvas.current.width, 54 | canvas.current.height 55 | ); 56 | const markers = detector.detect(imageData); 57 | console.log(markers); 58 | let located = false; 59 | for (const marker of markers) { 60 | console.log("Marker", marker); 61 | if (marker.id == 1001) { 62 | console.log("We did it!"); 63 | located = true; 64 | const avgX = 65 | marker.corners.map((corner) => corner.x).reduce((a, b) => a + b) / 66 | marker.corners.length; 67 | const x = (avgX / canvas.current.width - 0.5) * -2; 68 | const avgY = 69 | marker.corners.map((corner) => corner.y).reduce((a, b) => a + b) / 70 | marker.corners.length; 71 | const y = (avgY / canvas.current.height - 0.5) * 2; 72 | // Average "radius" 73 | const radius = 74 | marker.corners 75 | .map((corner) => { 76 | return Math.sqrt( 77 | Math.pow(corner.x - avgX, 2), 78 | Math.pow(corner.y - avgY, 2) 79 | ); 80 | }) 81 | .reduce((a, b) => a + b) / 82 | marker.corners.length / 83 | Math.max(canvas.current.width, canvas.current.height); 84 | client.ir(x, y, radius); 85 | } 86 | } 87 | if (!located) { 88 | client.ir(); 89 | } 90 | } 91 | setTimeout(tick, 15); 92 | }, [detector, context]); 93 | 94 | useEffect(() => { 95 | navigator.mediaDevices 96 | .getUserMedia({ 97 | video: { 98 | facingMode: "environment", 99 | }, 100 | }) 101 | .then((stream) => { 102 | setStream(stream); 103 | attachVideo(stream); 104 | }) 105 | .catch((err) => { 106 | console.error(err); 107 | }); 108 | }, []); 109 | 110 | return ( 111 | 112 | 116 | setDimensions({ 117 | width: video.current.videoWidth, 118 | height: video.current.videoHeight, 119 | }) 120 | } 121 | /> 122 | { 126 | if (element) { 127 | console.log("Set context"); 128 | setContext(element.getContext("2d")); 129 | } 130 | canvas.current = element; 131 | }} 132 | /> 133 | 134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /web/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | } 4 | 5 | .controller svg { 6 | height: 100vh; 7 | width: auto; 8 | } 9 | 10 | .pin-picker { 11 | margin: 1em; 12 | } 13 | 14 | .button { 15 | cursor: pointer; 16 | } 17 | 18 | .modal button { 19 | background: #34beec; 20 | color: #fff; 21 | border: none; 22 | padding: 0.5em 1em; 23 | margin: 0 auto; 24 | position: absolute; 25 | left: 0; 26 | right: 0; 27 | } 28 | 29 | .modal .contain { 30 | position: relative; 31 | height: 2em; 32 | margin: 0.5em 0 0 0; 33 | } 34 | 35 | .modal { 36 | position: absolute; 37 | left: 0; 38 | right: 0; 39 | width: 80vw; 40 | margin: auto; 41 | background: #eee; 42 | top: 50%; 43 | transform: translateY(-50%); 44 | padding: 1em 2em; 45 | } 46 | 47 | .modal-contain { 48 | background: #000a; 49 | height: 100%; 50 | width: 100%; 51 | position: absolute; 52 | } 53 | 54 | 55 | .controller, .controller text { 56 | -webkit-user-select: none !important; 57 | user-select: none !important; 58 | -webkit-touch-callout: none !important; 59 | -webkit-user-select: none !important; 60 | -webkit-tap-highlight-color: transparent !important; 61 | -moz-user-select: none !important; 62 | } 63 | 64 | /* Cheese it! */ 65 | html.dark .controller *[fill="#f2f2f4"] { 66 | fill: #000 !important; 67 | } 68 | html.dark .controller *[fill="#eee"] { 69 | fill: #000 !important; 70 | } 71 | html.dark .controller *[stroke="#000000"] { 72 | stroke: #f2f2f4 !important; 73 | } 74 | html.dark .controller *[fill="#000000"] { 75 | fill: #f2f2f4 !important; 76 | } 77 | html.dark, html.dark body { 78 | background: #000; 79 | } 80 | 81 | .dark-toggle { 82 | position: absolute; 83 | bottom: 0; 84 | right: 0; 85 | height: 3rem; 86 | width: 3rem; 87 | margin: 1rem; 88 | } 89 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import {App} from "./App.jsx"; 4 | 5 | ReactDOM.render(, document.getElementById("mount")); 6 | -------------------------------------------------------------------------------- /web/icons.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function a() { 4 | return "A"; 5 | } 6 | export function b() { 7 | return "B"; 8 | } 9 | export function one() { 10 | return "1"; 11 | } 12 | export function two() { 13 | return "2"; 14 | } 15 | export function start() { 16 | return "+"; 17 | } 18 | export function select() { 19 | return "-"; 20 | } 21 | export function power() { 22 | return "Power"; 23 | } 24 | export function home() { 25 | return "Home"; 26 | } 27 | 28 | export function up() { 29 | return "|"; 30 | } 31 | export function down() { 32 | return "|"; 33 | } 34 | export function left() { 35 | return "-"; 36 | } 37 | export function right() { 38 | return "-"; 39 | } 40 | 41 | export function Moon() { 42 | return ( 43 | 44 | 45 | 57 | 65 | 69 | 73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Wii 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webii-web", 3 | "version": "1.0.0", 4 | "description": "Client to send wii remote inputs to the emulator", 5 | "main": "dist/index.html", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/mstrodl/webii.git" 12 | }, 13 | "keywords": [ 14 | "dolphin-emu", 15 | "dolphin", 16 | "controller" 17 | ], 18 | "author": "Mary Strodl ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mstrodl/webii/issues" 22 | }, 23 | "homepage": "https://github.com/mstrodl/webii#readme", 24 | "dependencies": { 25 | "gyronorm": "^2.0.6", 26 | "js-aruco": "^0.1.0", 27 | "parcel": "^2.0.0-beta.1", 28 | "react": "^16.13.1", 29 | "react-dom": "^16.13.1" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.11.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/wiimote.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 1 8 | 9 | 10 | B 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 2 33 | 34 | 35 | 36 | A 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | --------------------------------------------------------------------------------