├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .luarc.json ├── LICENSE ├── README.md ├── decode_pulse_qrcode.py ├── docs ├── api.md └── images │ └── screenshot-widget.png ├── frontend ├── .gitignore ├── .vscode │ └── extensions.json ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── lib │ │ └── zxing_full.wasm │ └── vite.svg ├── src │ ├── App.vue │ ├── ViewerApp.vue │ ├── apis │ │ ├── dgLabSocketApi.ts │ │ ├── socketApi.ts │ │ └── webApi.ts │ ├── assets │ │ ├── battery.svg │ │ ├── bluetooth.svg │ │ ├── fire-white.svg │ │ └── vue.svg │ ├── charts │ │ ├── Bar1.vue │ │ ├── Battery1.vue │ │ ├── Circle1.vue │ │ ├── HealthBar1.vue │ │ ├── bar1 │ │ │ └── pills-mask.svg │ │ ├── chartRoutes.ts │ │ ├── healthBar1 │ │ │ ├── Heart.vue │ │ │ ├── heart-mask.svg │ │ │ └── joystix.monospace-regular.otf │ │ ├── types.d.ts │ │ ├── types │ │ │ └── ChartParamDef.ts │ │ └── utils │ │ │ └── transitionRef.ts │ ├── components │ │ ├── card │ │ │ └── PulseCard.vue │ │ ├── dialogs │ │ │ ├── ClientInfoDialog.vue │ │ │ ├── ConfigSavePrompt.vue │ │ │ ├── ConnectToClientDialog.vue │ │ │ ├── ConnectToSavedClientsDialog.vue │ │ │ ├── GetLiveCompDialog.vue │ │ │ ├── ImportPulseDialog.vue │ │ │ ├── PromptDialog.vue │ │ │ └── SortPulseDialog.vue │ │ ├── partials │ │ │ ├── ConnectToSavedClientsList.vue │ │ │ ├── CoyoteBluetoothPanel.vue │ │ │ ├── CoyoteBluetoothService.vue │ │ │ └── CustomToastContent.vue │ │ └── transitions │ │ │ └── FadeAndSlideTransitionGroup.vue │ ├── lib │ │ └── dg-pulse-helper │ │ │ ├── DGLabPulseHelper.ts │ │ │ ├── DGLabPulseQRHelper.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ ├── main.ts │ ├── pages │ │ ├── Controller.vue │ │ └── controller │ │ │ ├── GameConnection.vue │ │ │ ├── PulseSettings.vue │ │ │ └── StrengthSettings.vue │ ├── stores │ │ ├── ChartValueStore.ts │ │ ├── ClientsStore.ts │ │ ├── CoyoteBTStore.ts │ │ └── RemoteNotificationStore.ts │ ├── style.scss │ ├── type │ │ ├── common.ts │ │ ├── dg.ts │ │ └── pulse.ts │ ├── utils │ │ ├── CoyoteBluetoothController.ts │ │ ├── coyotePulse.ts │ │ ├── event.ts │ │ ├── request.ts │ │ ├── response.ts │ │ └── utils.ts │ ├── viewer.ts │ ├── vite-env.d.ts │ └── workers │ │ ├── AbstractBluetoothConnector.ts │ │ ├── SocketToCoyote2.ts │ │ └── SocketToCoyote3.ts ├── tsconfig.json ├── tsconfig.node.json ├── viewer.html └── vite.config.ts ├── package-dist.json ├── package-lock.json ├── package.json ├── sdk ├── auto_hot_key.ahk └── cheat_engine_sdk.lua ├── server ├── cli │ └── build-schema.js ├── config.example-server.yaml ├── config.example.yaml ├── data │ └── pulse.json5 ├── package-lock.json ├── package.json ├── public │ └── .gitkeep ├── src │ ├── config.ts │ ├── controllers │ │ ├── game │ │ │ ├── CoyoteGameController.ts │ │ │ └── actions │ │ │ │ ├── AbstractGameAction.ts │ │ │ │ └── GameFireAction.ts │ │ ├── http │ │ │ ├── GameApi.ts │ │ │ └── Web.ts │ │ └── ws │ │ │ ├── DGLabWS.ts │ │ │ └── WebWS.ts │ ├── index.ts │ ├── managers │ │ ├── CoyoteGameManager.ts │ │ ├── DGLabWSManager.ts │ │ └── WebWSManager.ts │ ├── model │ │ └── config │ │ │ ├── CustomPulseConfigUpdater.ts │ │ │ ├── GamePlayConfigUpdater.ts │ │ │ ├── GamePlayUserConfigUpdater.ts │ │ │ └── MainGameConfigUpdater.ts │ ├── modules.d.ts │ ├── router.ts │ ├── schemas │ │ ├── CustomSkinManifest.json │ │ ├── GameCustomPulseConfig.json │ │ ├── GameStrengthConfig.json │ │ ├── MainConfigType.json │ │ ├── MainGameConfig.json │ │ └── schemas.json │ ├── services │ │ ├── CoyoteGameConfigService.ts │ │ ├── CustomSkinService.ts │ │ ├── DGLabPulse.ts │ │ └── SiteNotificationService.ts │ ├── types.d.ts │ ├── types │ │ ├── config.ts │ │ ├── customSkin.ts │ │ ├── dg.ts │ │ ├── game.ts │ │ ├── gamePlay.ts │ │ └── server.ts │ └── utils │ │ ├── EventStore.ts │ │ ├── ExEventEmitter.ts │ │ ├── MultipleLinkedMap.ts │ │ ├── ObjectUpdater.ts │ │ ├── PulsePlayList.ts │ │ ├── WebSocketAsync.ts │ │ ├── WebSocketRouter.ts │ │ ├── checkUpdate.ts │ │ ├── latencyLogger.ts │ │ ├── onExit.ts │ │ ├── task.ts │ │ ├── utils.ts │ │ ├── validator.ts │ │ └── websocket.ts └── tsconfig.json └── version.json /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | paths-ignore: 10 | - 'docs/**' 11 | - 'sdk/**' 12 | - 'README.md' 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | node-version: [20.x] 24 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | - name: Install frontend packages 34 | run: npm ci 35 | working-directory: ./frontend 36 | - name: Build frontend 37 | run: npm run build --if-present 38 | working-directory: ./frontend 39 | - name: Install server packages 40 | run: npm ci 41 | working-directory: ./server 42 | - name: Build server 43 | run: npm run build --if-present 44 | working-directory: ./server 45 | - name: Install root packages 46 | run: npm ci 47 | - name: Migrate files 48 | run: npm run build:migrate 49 | - name: Build nodejs release 50 | run: npm run build:pkg:clean && npm run build:pkg:assets && npm run build:pkg:nodejs 51 | - name: Move Files to artifact dir 52 | run: mkdir -p artifacts && mv build artifacts/coyote-game-hub 53 | - name: Archive nodejs artifact 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: coyote-game-hub-nodejs-server 57 | path: | 58 | artifacts 59 | - name: Clean up 60 | run: rm -rf artifacts 61 | - name: Build Windows executable 62 | run: npm run build:pkg:clean && npm run build:pkg:assets && npm run build:pkg:win 63 | - name: Move Files to artifact dir 64 | run: mkdir -p artifacts && mv build artifacts/coyote-game-hub 65 | - name: Archive Windows artifact 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: coyote-game-hub-windows-amd64-dist 69 | path: | 70 | artifacts 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.VSCodeCounter 2 | /.idea 3 | /.vs 4 | .DS_Store 5 | 6 | node_modules/ 7 | 8 | /server/config.yaml 9 | /server/dist 10 | /server/public 11 | !/server/public/.gitkeep 12 | 13 | /frontend/dist 14 | /frontend/src/auto-imports.d.ts 15 | /frontend/src/components.d.ts 16 | 17 | /build 18 | 19 | game-config/ -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.library": [], 3 | "diagnostics.disable": [] 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

战败惩罚——郊狼游戏控制器

2 |
3 | 4 | 5 | 6 |
7 |

8 |
9 | 下载 10 | | 11 | 预览 12 | | 13 | 插件API 14 |
15 |

16 |
17 | 小组件截图 18 |
19 | 20 | ## 注意事项 21 | 22 | 请遵守直播平台的相关规定,不要违规使用本组件,如果使用本组件造成直播间封禁等后果与本组件作者无关。 23 | 24 | ## 使用方法(二进制发行版) 25 | 26 | 1. 从[Github Actions](https://github.com/hyperzlib/DG-Lab-Coyote-Game-Hub/actions)下载编译后的文件:[点击跳转](https://github.com/hyperzlib/DG-Lab-Coyote-Game-Hub/actions) 27 | 2. 解压后运行```coyote-game-hub-server.exe```启动服务器 28 | 29 | ## 使用方法(命令行) 30 | 31 | (以下样例中使用了```pnpm```安装依赖,你也可以使用```npm```或者```yarn```) 32 | 33 | 1. 进入```server```目录,运行```pnpm install```安装依赖 34 | 35 | 2. 进入```frontend```目录,运行```pnpm install```安装依赖 36 | 37 | 3. 在项目根目录运行```pnpm install```安装依赖,运行```npm run build```编译项目 38 | 39 | 4. 在项目根目录运行```npm start```启动服务器 40 | 41 | 5. 浏览器打开```http://localhost:8920```,即可看到控制面板 42 | 43 | ## 项目结构 44 | 45 | - ```server```:服务器端代码 46 | - ```frontend```:前端代码 47 | 48 | ## 构建 49 | 50 | ### 环境准备 51 | 在全局环境安装下面的包: 52 | ``` 53 | npm install -g nexe 54 | npm install -g vite 55 | npm install -g pkg 56 | ``` 57 | 58 | ### 构建工程 59 | 按顺序运行下面的指令 60 | ``` 61 | npm run build 62 | npm run build:pkg 63 | npm run build:pkg:assets 64 | npm run build:pkg:nodejs 65 | npm run build:pkg:linux 66 | npm run build:pkg 67 | ``` 68 | 69 | ### 产物位置 70 | 之后可以在 `build/` 目录下发现构建的产物 -------------------------------------------------------------------------------- /decode_pulse_qrcode.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import base64 4 | import deflate 5 | import zxing 6 | 7 | def decode_pulse_str(pulse_str: str) -> str: 8 | binary = bytes.fromhex(pulse_str) 9 | # Inflate 10 | data = deflate.gzip_decompress(binary) 11 | # Decode base64 12 | data = base64.b64decode(data) 13 | return data 14 | 15 | def read_pulse_str_from_qrcode(file_path: str) -> str: 16 | reader = zxing.BarCodeReader() 17 | barcode = reader.decode(file_path) 18 | url: str = barcode.parsed 19 | pulse_str = url.split("#DGLAB-PULSE#")[1] 20 | 21 | return pulse_str 22 | 23 | if __name__ == "__main__": 24 | if len(sys.argv) != 2: 25 | print("Usage: python decode_pulse_qrcode.py ") 26 | sys.exit(1) 27 | 28 | file_path = sys.argv[1] 29 | pulse_str = read_pulse_str_from_qrcode(file_path) 30 | data = decode_pulse_str(pulse_str) 31 | print(data) -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # 第三方插件接口 2 | 3 | ## 获取游戏信息 4 | 5 | ```sh 6 | GET /api/v2/game/{clientId} 7 | ``` 8 | 9 | ### 请求参数 10 | 11 | 无 12 | 13 | ### 响应 14 | 15 | ```json5 16 | { 17 | "status": 1, 18 | "code": "OK", 19 | "strengthConfig": { 20 | "strength": 5, // 基础强度 21 | "randomStrength": 5 // 随机强度,(强度范围:[strength, strength+randomStrength]) 22 | }, 23 | "gameConfig": { 24 | "strengthChangeInterval": [15, 30], // 随机强度变化间隔,单位:秒 25 | "enableBChannel": false, // 是否启用B通道 26 | "bChannelStrengthMultiplier": 1, // B通道强度倍数 27 | "pulseId": "d6f83af0", // 当前波形列表,可能是string或者string[] 28 | "pulseMode": "single", // 波形播放模式,single: 单个波形, sequence: 列表顺序播放, random: 随机播放 29 | "pulseChangeInterval": 60 30 | }, 31 | "clientStrength": { 32 | "strength": 0, // 客户端当前强度 33 | "limit": 20 // 客户端强度上限 34 | }, 35 | "currentPulseId": "d6f83af0" // 当前正在播放的波形ID 36 | } 37 | ``` 38 | 39 | ## 获取波形列表 40 | 41 | ```sh 42 | GET /api/v2/pulse_list # 获取服务器配置的波形列表 43 | GET /api/v2/game/{clientId}/pulse_list # 获取完整的波形列表(包括客户端自定义波形) 44 | ``` 45 | 46 | ### 请求参数 47 | 48 | 无 49 | 50 | ### 响应 51 | 52 | ```json5 53 | { 54 | "status": 1, 55 | "code": "OK", 56 | "pulseList": [ 57 | { 58 | "id": "d6f83af0", // 波形ID 59 | "name": "呼吸" // 波形名称 60 | }, 61 | // ... 62 | ] 63 | } 64 | ``` 65 | 66 | ## 获取游戏强度信息 67 | 68 | ```sh 69 | GET /api/v2/game/{clientId}/strength 70 | ``` 71 | 72 | ### 请求参数 73 | 74 | 无 75 | 76 | ### 响应 77 | 78 | ```json5 79 | { 80 | "status": 1, 81 | "code": "OK", 82 | "strengthConfig": { 83 | "strength": 5, // 基础强度 84 | "randomStrength": 5 // 随机强度,(强度范围:[strength, strength+randomStrength]) 85 | } 86 | } 87 | ``` 88 | 89 | ## 设置游戏强度配置 90 | 91 | ```sh 92 | POST /api/v2/game/{clientId}/strength 93 | ``` 94 | 95 | ### 请求参数 96 | 97 | 如果服务器配置```allowBroadcastToClients: true```,可以将请求地址中的```{clientId}```设置为```all```,将设置到所有客户端。 98 | 99 | 100 | 以下是请求参数的类型定义: 101 | 102 | ```typescript 103 | type SetStrengthConfigRequest = { 104 | strength?: { 105 | add?: number; // 增加基础强度 106 | sub?: number; // 减少强度 107 | set?: number; // 设置强度 108 | }, 109 | randomStrength?: { 110 | add?: number; // 增加随机强度 111 | sub?: number; // 减少强度 112 | set?: number; // 设置强度 113 | } 114 | } 115 | ``` 116 | 117 | 使用JSON POST格式发送请求的Post Body: 118 | 119 | ```json5 120 | { 121 | "strength": { 122 | "add": 1 123 | } 124 | } 125 | ``` 126 | 127 | 使用x-www-form-urlencoded格式发送请求的Post Body: 128 | 129 | ```html 130 | strength.add=1 131 | ``` 132 | 133 | 强度配置在服务端已做限制,不会超出范围。插件可以随意发送请求,不需要担心超出范围。 134 | 135 | ### 响应 136 | 137 | ```json5 138 | { 139 | "status": 1, 140 | "code": "OK", 141 | "message": "成功设置了 1 个游戏的强度配置", 142 | "successClientIds": [ 143 | "3ab0773d-69d0-41af-b74b-9c6ce6507f65" 144 | ] 145 | } 146 | ``` 147 | 148 | ## 获取游戏当前波形ID 149 | 150 | ```sh 151 | GET /api/v2/game/{clientId}/pulse 152 | ``` 153 | 154 | ### 请求参数 155 | 156 | 无 157 | 158 | ### 响应 159 | 160 | ```json5 161 | { 162 | "status": 1, 163 | "code": "OK", 164 | "pulseId": "d6f83af0" 165 | } 166 | ``` 167 | 168 | 或 169 | 170 | ```json5 171 | { 172 | "status": 1, 173 | "code": "OK", 174 | "pulseId": [ 175 | "d6f83af0", 176 | "7eae1e5f", 177 | "eea0e4ce", 178 | "2cbd592e" 179 | ] 180 | } 181 | ``` 182 | 183 | ## 设置游戏当前波形ID 184 | 185 | ```sh 186 | POST /api/v2/game/{clientId}/pulse 187 | ``` 188 | 189 | ### 请求参数 190 | 191 | 如果服务器配置```allowBroadcastToClients: true```,可以将请求地址中的```{clientId}```设置为```all```,将设置到所有客户端。 192 | 193 | 使用JSON POST格式发送请求的Post Body: 194 | 195 | ```json5 196 | { 197 | "pulseId": "d6f83af0" // 波形ID 198 | } 199 | ``` 200 | 201 | 或 202 | 203 | ```json5 204 | { 205 | "pulseId": [ 206 | "d6f83af0", 207 | "7eae1e5f", 208 | "eea0e4ce", 209 | "2cbd592e" 210 | ] // 波形ID列表 211 | } 212 | ``` 213 | 214 | 使用x-www-form-urlencoded格式发送请求的Post Body: 215 | 216 | ```html 217 | pulseId=d6f83af0 218 | ``` 219 | 220 | 或 221 | 222 | ```html 223 | pulseId[]=d6f83af0&pulseId[]=7eae1e5f&pulseId[]=eea0e4ce&pulseId[]=2cbd592e 224 | ``` 225 | 226 | ### 响应 227 | 228 | ```json5 229 | { 230 | "status": 1, 231 | "code": "OK", 232 | "message": "成功设置了 1 个游戏的波形ID", 233 | "successClientIds": [ 234 | "3ab0773d-69d0-41af-b74b-9c6ce6507f65" 235 | ] 236 | } 237 | ``` 238 | 239 | ## 请求错误响应 240 | 241 | ```json5 242 | { 243 | "status": 0, 244 | "code": "ERR::INVALID_REQUEST", 245 | "message": "请求参数不正确" 246 | } 247 | ``` 248 | 249 | 250 | ## 一键开火 251 | 252 | ```sh 253 | POST /api/v2/game/{clientId}/action/fire 254 | ``` 255 | 256 | ### 请求参数 257 | 258 | 如果服务器配置```allowBroadcastToClients: true```,可以将请求地址中的```{clientId}```设置为```all```,将设置到所有客户端。 259 | 260 | 261 | 以下是请求参数的类型定义: 262 | 263 | ```json5 264 | { 265 | "strength": 20, // 一键开火强度,最高40 266 | "time": 5000, // (可选)一键开火时间,单位:毫秒,默认为5000,最高30000(30秒) 267 | "override": false, // (可选)多次一键开火时,是否重置时间,true为重置时间,false为叠加时间,默认为false 268 | "pulseId": "d6f83af0" // (可选)一键开火的波形ID 269 | } 270 | ``` 271 | 272 | 使用JSON POST格式发送请求的Post Body: 273 | 274 | ```json5 275 | { 276 | "strength": 20, 277 | "time": 5000 278 | } 279 | ``` 280 | 281 | 使用x-www-form-urlencoded格式发送请求的Post Body: 282 | 283 | ```html 284 | strength=20&time=5000 285 | ``` 286 | 287 | 强度配置在服务端已做限制,不会超出范围。 288 | 289 | ### 响应 290 | 291 | ```json5 292 | { 293 | "status": 1, 294 | "code": "OK", 295 | "message": "成功向 1 个游戏发送了一键开火指令", 296 | "successClientIds": [ 297 | "3ab0773d-69d0-41af-b74b-9c6ce6507f65" 298 | ] 299 | } 300 | ``` 301 | -------------------------------------------------------------------------------- /docs/images/screenshot-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperzlib/DG-Lab-Coyote-Game-Hub/79e1cb0650fd32cc1f1bf5bbe71e3a1635d63e55/docs/images/screenshot-widget.png -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build && vue-tsc --noEmit", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@primevue/themes": "4.0.0-rc.2", 13 | "@types/web-bluetooth": "^0.0.20", 14 | "crypto-js": "^4.2.0", 15 | "eventemitter3": "^5.0.1", 16 | "js-base64": "^3.7.7", 17 | "js-md5": "^0.8.3", 18 | "pako": "^2.1.0", 19 | "pinia": "^2.2.2", 20 | "pinia-plugin-persistedstate": "^4.1.1", 21 | "primeicons": "^7.0.0", 22 | "primevue": "4.0.0-rc.2", 23 | "unplugin-auto-import": "^0.17.6", 24 | "unplugin-vue-components": "^0.27.0", 25 | "uuid": "^10.0.0", 26 | "vite-svg-loader": "^5.1.0", 27 | "vue": "^3.4.21", 28 | "vue-qrcode": "^2.2.2", 29 | "vue-router": "^4.3.3", 30 | "zxing-wasm": "^1.2.12" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^20.14.8", 34 | "@types/pako": "^2.0.3", 35 | "@types/uuid": "^10.0.0", 36 | "@vitejs/plugin-vue": "^5.0.4", 37 | "sass": "^1.77.5", 38 | "scss": "^0.2.4", 39 | "typescript": "^5.2.2", 40 | "vite": "^5.2.0", 41 | "vite-plugin-windicss": "^1.9.3", 42 | "vue-tsc": "^2.0.6", 43 | "windicss": "^3.5.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/public/lib/zxing_full.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperzlib/DG-Lab-Coyote-Game-Hub/79e1cb0650fd32cc1f1bf5bbe71e3a1635d63e55/frontend/public/lib/zxing_full.wasm -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /frontend/src/ViewerApp.vue: -------------------------------------------------------------------------------- 1 | 110 | 111 | 130 | 131 | -------------------------------------------------------------------------------- /frontend/src/apis/webApi.ts: -------------------------------------------------------------------------------- 1 | import { ChartParamDef } from "../charts/types/ChartParamDef"; 2 | 3 | export type ServerInfoResData = { 4 | server: { 5 | wsUrl: string, 6 | clientWsUrls: ClientConnectUrlInfo[], 7 | apiBaseHttpUrl: string, 8 | }, 9 | }; 10 | 11 | export type ClientConnectUrlInfo = { 12 | domain: string; 13 | connectUrl: string; 14 | }; 15 | 16 | export type ClientConnectInfoResData = { 17 | clientId: string, 18 | }; 19 | 20 | export type CustomSkinInfo = { 21 | name: string; 22 | url: string; 23 | help?: string; 24 | params?: ChartParamDef[]; 25 | }; 26 | 27 | export type CustomSkinsResData = { 28 | customSkins: CustomSkinInfo[], 29 | }; 30 | 31 | export type ApiResponse = { 32 | status: number, 33 | message?: string, 34 | } & T; 35 | 36 | export const webApi = { 37 | getServerInfo: async (): Promise | null> => { 38 | try { 39 | const response = await fetch('/api/server_info'); 40 | return response.json(); 41 | } 42 | catch (error) { 43 | console.error('Failed to get server info:', error); 44 | return null; 45 | } 46 | }, 47 | getClientConnectInfo: async (): Promise | null> => { 48 | try { 49 | const response = await fetch('/api/client/connect'); 50 | return response.json(); 51 | } 52 | catch (error) { 53 | console.error('Failed to get client connect info:', error); 54 | return null; 55 | } 56 | }, 57 | }; -------------------------------------------------------------------------------- /frontend/src/assets/battery.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/src/assets/bluetooth.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/src/assets/fire-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 14 | 17 | 20 | 23 | -------------------------------------------------------------------------------- /frontend/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/charts/Bar1.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 87 | 88 | -------------------------------------------------------------------------------- /frontend/src/charts/HealthBar1.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 70 | 71 | -------------------------------------------------------------------------------- /frontend/src/charts/bar1/pills-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/charts/chartRoutes.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw } from 'vue-router'; 2 | 3 | export const chartRoutes: RouteRecordRaw[] = [ 4 | { 5 | path: '/', 6 | component: () => import('./Circle1.vue'), 7 | name: '经典圆盘进度条', 8 | meta: { 9 | params: [ 10 | { 11 | prop: 'darkMode', 12 | type: 'boolean', 13 | name: '深色模式', 14 | }, 15 | ], 16 | }, 17 | }, 18 | { 19 | path: '/bar1', 20 | component: () => import('./Bar1.vue'), 21 | name: '横向胶囊进度条', 22 | }, 23 | { 24 | path: '/health-bar1', 25 | component: () => import('./HealthBar1.vue'), 26 | name: '像素心心HP条', 27 | meta: { 28 | params: [ 29 | { 30 | prop: 'textInverted', 31 | type: 'boolean', 32 | name: '白色文字', 33 | }, 34 | { 35 | prop: 'hideText', 36 | type: 'boolean', 37 | name: '隐藏文字', 38 | }, 39 | ], 40 | }, 41 | }, 42 | { 43 | path: '/battery1', 44 | component: () => import('./Battery1.vue'), name: '拟物电池 (仅当前电量)', 45 | meta: { 46 | params: [ 47 | { 48 | prop: 'yellowBar', 49 | type: 'boolean', 50 | name: '黄色电量条', 51 | }, 52 | { 53 | prop: 'hideText', 54 | type: 'boolean', 55 | name: '隐藏文字', 56 | }, 57 | ], 58 | }, 59 | }, 60 | ]; -------------------------------------------------------------------------------- /frontend/src/charts/healthBar1/Heart.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/charts/healthBar1/heart-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/charts/healthBar1/joystix.monospace-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperzlib/DG-Lab-Coyote-Game-Hub/79e1cb0650fd32cc1f1bf5bbe71e3a1635d63e55/frontend/src/charts/healthBar1/joystix.monospace-regular.otf -------------------------------------------------------------------------------- /frontend/src/charts/types.d.ts: -------------------------------------------------------------------------------- 1 | import { ChartParamDef } from './types/ChartParamDef'; 2 | 3 | declare module 'vue-router' { 4 | interface RouteMeta { 5 | params: ChartParamDef[]; 6 | } 7 | } -------------------------------------------------------------------------------- /frontend/src/charts/types/ChartParamDef.ts: -------------------------------------------------------------------------------- 1 | export type ChartParamDef = { 2 | prop: string; 3 | type: 'boolean' | 'int' | 'float' | 'string' | 'select'; 4 | name: string; 5 | help?: string; 6 | options?: { value: string, label: string }[]; 7 | }; -------------------------------------------------------------------------------- /frontend/src/charts/utils/transitionRef.ts: -------------------------------------------------------------------------------- 1 | export function transitionRef(value: Ref, valuePerSec: number): Ref { 2 | const refWithTransition = ref(value.value); 3 | 4 | let transitionTimer: NodeJS.Timeout | null = null; 5 | let transitionTargetValue = value.value; 6 | 7 | const update = () => { 8 | if (transitionTargetValue === refWithTransition.value && transitionTimer) { 9 | clearInterval(transitionTimer); 10 | transitionTimer = null; 11 | return; 12 | } else if (transitionTargetValue > refWithTransition.value) { 13 | refWithTransition.value += 1; 14 | } else { 15 | refWithTransition.value -= 1; 16 | } 17 | } 18 | 19 | const startTransition = () => { 20 | if (transitionTimer) { 21 | return; 22 | } 23 | 24 | transitionTimer = setInterval(update, 1000 / valuePerSec); 25 | }; 26 | 27 | watch(value, (newValue) => { 28 | transitionTargetValue = newValue; 29 | startTransition(); 30 | }); 31 | 32 | return refWithTransition; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/ClientInfoDialog.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/ConfigSavePrompt.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/ConnectToSavedClientsDialog.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 36 | 37 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/GetLiveCompDialog.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 148 | 149 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/ImportPulseDialog.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/PromptDialog.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | -------------------------------------------------------------------------------- /frontend/src/components/dialogs/SortPulseDialog.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 65 | 66 | -------------------------------------------------------------------------------- /frontend/src/components/partials/ConnectToSavedClientsList.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 57 | 58 | -------------------------------------------------------------------------------- /frontend/src/components/partials/CoyoteBluetoothPanel.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 100 | 101 | -------------------------------------------------------------------------------- /frontend/src/components/partials/CoyoteBluetoothService.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | -------------------------------------------------------------------------------- /frontend/src/components/partials/CustomToastContent.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 93 | 94 | -------------------------------------------------------------------------------- /frontend/src/components/transitions/FadeAndSlideTransitionGroup.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 63 | 64 | -------------------------------------------------------------------------------- /frontend/src/lib/dg-pulse-helper/DGLabPulseHelper.ts: -------------------------------------------------------------------------------- 1 | import { DGLabPulseInfo, DGLabRawPulseData } from './types'; 2 | 3 | export const PULSE_WINDOW = 100; // 100ms 4 | export const PULSE_POINT_TIME = 25; // 25ms 5 | 6 | export function hexToBuffer(hex: string): Uint8Array { 7 | return new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))); 8 | } 9 | 10 | export function bufferToHex(buffer: Uint8Array): string { 11 | return Array.from(buffer).map(byte => byte.toString(16).padStart(2, '0')).join(''); 12 | }; 13 | 14 | function interp(x: number[], dstLen: number): number[] { 15 | const srcLen = x.length - 1; 16 | const step = srcLen / (dstLen - 1); 17 | const y = new Array(dstLen).fill(0); 18 | 19 | for (let i = 0; i < dstLen; i++) { 20 | const srcIndex = i * step; 21 | const srcIndexFloor = Math.floor(srcIndex); 22 | const srcIndexCeil = Math.ceil(srcIndex); 23 | if (srcIndexFloor === srcIndexCeil) { 24 | y[i] = x[srcIndexFloor]; 25 | } else { 26 | y[i] = x[srcIndexFloor] + (x[srcIndexCeil] - x[srcIndexFloor]) * (srcIndex - srcIndexFloor); 27 | } 28 | } 29 | 30 | return y; 31 | }; 32 | 33 | export function dgLabFreqToUint8(freq: number): number { 34 | if (freq === 0) { 35 | return 0; 36 | } else if (freq < 10) { 37 | return 10; 38 | } else if (freq >= 10 && freq <= 100) { 39 | return freq; 40 | } else if (freq > 100 && freq <= 600) { 41 | return Math.floor((freq - 100) / 5) + 100; 42 | } else if (freq > 600 && freq <= 1000) { 43 | return Math.floor((freq - 600) / 10) + 200; 44 | } else { 45 | return 0; 46 | } 47 | } 48 | 49 | export function dgLabFreqFromUint8(value: number): number { 50 | if (value === 0) { 51 | return 0; 52 | } else if (value < 10) { 53 | return 10; 54 | } else if (value <= 100) { 55 | return value; 56 | } else if (value <= 200) { 57 | return (value - 100) * 5 + 100; 58 | } else if (value <= 240) { 59 | return (value - 200) * 10 + 600; 60 | } else { 61 | return 0; 62 | } 63 | } 64 | 65 | export function encodeRawPulseData(rawData: DGLabRawPulseData): string { 66 | let outputArr = new Uint8Array(8); 67 | for (let i = 0; i < 4; i++) { 68 | outputArr[i] = dgLabFreqToUint8(rawData.freq[i]); 69 | outputArr[i + 4] = rawData.value[i]; 70 | } 71 | return bufferToHex(outputArr).toUpperCase(); 72 | } 73 | 74 | export function decodeRawPulseData(data: string): DGLabRawPulseData { 75 | if (data.length !== 16) { 76 | throw new Error('Invalid data length'); 77 | } 78 | const buffer = hexToBuffer(data); 79 | return { 80 | freq: Array.from(new Uint8Array(buffer.subarray(0, 4))).map(dgLabFreqFromUint8), 81 | value: Array.from(new Uint8Array(buffer.subarray(4, 8))), 82 | }; 83 | } 84 | 85 | export function generateDGLabHexPulse(pulse: DGLabPulseInfo) { 86 | const sections = pulse.sections; 87 | 88 | let speedFactor = pulse.speedFactor ?? 1; 89 | 90 | let freqList: number[] = []; 91 | let valueList: number[] = []; 92 | 93 | for (const section of sections) { // 按小节生成 94 | let sectionRealTime = section.pulse.length * PULSE_WINDOW; 95 | /** 重复次数 */ 96 | let repeat = Math.max(1, Math.ceil(section.sectionTime * 1000 / sectionRealTime)); 97 | /** 总脉冲数 */ 98 | let totalPulseNum = section.pulse.length * repeat; 99 | 100 | let sectionValueList: number[] = []; 101 | let sectionFreqList: number[] = []; 102 | 103 | let freqConf: [number, number] = typeof section.freq === 'number' ? [section.freq, section.freq] : section.freq; 104 | switch (section.freqMode) { 105 | case 'inSection': 106 | // 节内渐变 107 | sectionFreqList = interp(freqConf, totalPulseNum); 108 | break; 109 | case 'inPulse': { 110 | // 每个脉冲内渐变(元内渐变) 111 | let subFreqList = interp(freqConf, section.pulse.length); 112 | for (let i = 0; i < repeat; i++) { 113 | sectionFreqList.push(...subFreqList); 114 | } 115 | break; 116 | } 117 | case 'perPulse': { 118 | // 节内每组脉冲渐变(元间渐变) 119 | let subFreqList = interp(freqConf, repeat); 120 | for (let freq of subFreqList) { 121 | for (let j = 0; j < section.pulse.length; j++) { 122 | sectionFreqList.push(freq); 123 | } 124 | } 125 | break; 126 | } 127 | case false: 128 | default: 129 | // 固定频率 130 | sectionFreqList = new Array(totalPulseNum).fill(freqConf[0]); 131 | break; 132 | } 133 | 134 | for (let i = 0; i < repeat; i++) { 135 | sectionValueList.push(...section.pulse); 136 | } 137 | 138 | freqList.push(...sectionFreqList); 139 | valueList.push(...sectionValueList); 140 | } 141 | 142 | let pointRepeatNum = 4; 143 | if (speedFactor === 2) { 144 | pointRepeatNum = 2; 145 | } else if (speedFactor === 4) { 146 | pointRepeatNum = 1; 147 | } 148 | 149 | let finalFreqList: number[] = []; 150 | let finalValueList: number[] = []; 151 | 152 | for (let i = 0; i < freqList.length; i++) { 153 | for (let j = 0; j < pointRepeatNum; j++) { 154 | finalFreqList.push(freqList[i]); 155 | finalValueList.push(valueList[i]); 156 | } 157 | } 158 | 159 | // 通过生成空白脉冲来实现休息时间 160 | if (pulse.sleepTime) { 161 | let sleepPulseNum = Math.ceil(pulse.sleepTime * 1000 / PULSE_WINDOW) * 4; 162 | for (let i = 0; i < sleepPulseNum; i++) { 163 | finalFreqList.push(10); 164 | finalValueList.push(0); 165 | } 166 | } 167 | 168 | // 补齐为4的倍数 169 | if (finalFreqList.length % 4 !== 0) { 170 | let padLen = 4 - finalFreqList.length % 4; 171 | for (let i = 0; i < padLen; i++) { 172 | finalFreqList.push(10); 173 | finalValueList.push(0); 174 | } 175 | } 176 | 177 | // 生成脉冲数据 178 | let pulseHexList: string[] = []; 179 | for (let startIndex = 0; startIndex < finalFreqList.length; startIndex += 4) { 180 | let freqSlice = finalFreqList.slice(startIndex, startIndex + 4); 181 | let valueSlice = finalValueList.slice(startIndex, startIndex + 4); 182 | pulseHexList.push(encodeRawPulseData({ freq: freqSlice, value: valueSlice })); 183 | } 184 | 185 | return pulseHexList; 186 | } -------------------------------------------------------------------------------- /frontend/src/lib/dg-pulse-helper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './DGLabPulseQRHelper'; 3 | export * from './DGLabPulseHelper'; -------------------------------------------------------------------------------- /frontend/src/lib/dg-pulse-helper/types.ts: -------------------------------------------------------------------------------- 1 | export interface DGLabPulseSectionInfo { 2 | /** 波形形状 */ 3 | pulse: number[]; 4 | /** 小节时长(秒,用于计算小节重复次数) */ 5 | sectionTime: number; 6 | /** 频率 */ 7 | freq: number | [number, number]; 8 | /** 频率模式 */ 9 | freqMode?: false | 'inSection' | 'inPulse' | 'perPulse'; 10 | } 11 | 12 | export type SinglePulse = [frequency: number, value: number]; 13 | 14 | export interface DGLabPulseInfo { 15 | sections: DGLabPulseSectionInfo[]; 16 | /** 休息时长(秒) */ 17 | sleepTime?: number; 18 | /** 播放速率 */ 19 | speedFactor?: number; 20 | } 21 | 22 | export interface DGLabRawPulseData { 23 | freq: number[]; 24 | value: number[]; 25 | } -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createWebHashHistory, createRouter, RouteRecordRaw } from 'vue-router' 3 | 4 | import PrimeVue from 'primevue/config'; 5 | import { definePreset } from '@primevue/themes'; 6 | import Aura from '@primevue/themes/aura'; 7 | 8 | import DialogService from 'primevue/dialogservice'; 9 | import ConfirmationService from 'primevue/confirmationservice'; 10 | import ToastService from 'primevue/toastservice'; 11 | 12 | import { createPinia } from 'pinia' 13 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 14 | 15 | import 'virtual:windi.css' 16 | import 'primeicons/primeicons.css' 17 | import './style.scss' 18 | import App from './App.vue' 19 | 20 | const appName = '战败惩罚'; 21 | 22 | const routes: RouteRecordRaw[] = [ 23 | { 24 | path: '/', component: () => import('./pages/Controller.vue'), name: '控制器', 25 | children: [ 26 | { path: '', redirect: 'strength' }, 27 | { path: 'strength', component: () => import('./pages/controller/StrengthSettings.vue'), name: '控制器 - 强度设置' }, 28 | { path: 'pulse', component: () => import('./pages/controller/PulseSettings.vue'), name: '控制器 - 波形设置' }, 29 | { path: 'game', component: () => import('./pages/controller/GameConnection.vue'), name: '控制器 - 游戏连接' }, 30 | ], 31 | }, 32 | ]; 33 | 34 | const router = createRouter({ 35 | history: createWebHashHistory(), 36 | routes, 37 | }); 38 | 39 | router.beforeEach((to, _, next) => { 40 | document.title = to.name?.toString() + ' - ' + appName; 41 | next(); 42 | }); 43 | 44 | 45 | const SeitaPreset = definePreset(Aura, { 46 | semantic: { 47 | primary: { 48 | 50: '{zinc.50}', 49 | 100: '{zinc.100}', 50 | 200: '{zinc.200}', 51 | 300: '{zinc.300}', 52 | 400: '{zinc.400}', 53 | 500: '{zinc.500}', 54 | 600: '{zinc.600}', 55 | 700: '{zinc.700}', 56 | 800: '{zinc.800}', 57 | 900: '{zinc.900}', 58 | 950: '{zinc.950}' 59 | }, 60 | colorScheme: { 61 | light: { 62 | primary: { 63 | color: '{zinc.950}', 64 | inverseColor: '#ffffff', 65 | hoverColor: '{zinc.900}', 66 | activeColor: '{zinc.800}' 67 | }, 68 | highlight: { 69 | background: '{zinc.950}', 70 | focusBackground: '{zinc.700}', 71 | color: '#ffffff', 72 | focusColor: '#ffffff' 73 | } 74 | }, 75 | dark: { 76 | primary: { 77 | color: '{zinc.50}', 78 | inverseColor: '{zinc.950}', 79 | hoverColor: '{zinc.100}', 80 | activeColor: '{zinc.200}' 81 | }, 82 | highlight: { 83 | background: 'rgba(250, 250, 250, .16)', 84 | focusBackground: 'rgba(250, 250, 250, .24)', 85 | color: 'rgba(255,255,255,.87)', 86 | focusColor: 'rgba(255,255,255,.87)' 87 | } 88 | } 89 | } 90 | } 91 | }); 92 | 93 | const pinia = createPinia() 94 | pinia.use(piniaPluginPersistedstate) 95 | 96 | createApp(App) 97 | .use(router) 98 | .use(pinia) 99 | .use(PrimeVue, { 100 | theme: { 101 | preset: SeitaPreset, 102 | }, 103 | }) 104 | .use(DialogService) 105 | .use(ToastService) 106 | .use(ConfirmationService) 107 | .directive('ripple', {}) // Bypass PrimeVue's ripple directive 108 | .mount('#app'); -------------------------------------------------------------------------------- /frontend/src/pages/controller/GameConnection.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/pages/controller/StrengthSettings.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/stores/ChartValueStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useChartValueStore = defineStore('chartValue', { 4 | state: () => ({ 5 | strength: 0, 6 | randomStrength: 0, 7 | temporaryStrength: 0, 8 | strengthLimit: 0, 9 | gameStarted: false, 10 | }), 11 | getters: { 12 | valLow: (state) => Math.min(state.strength + state.temporaryStrength, state.strengthLimit), 13 | valHigh: (state) => Math.min(state.strength + state.temporaryStrength + state.randomStrength, state.strengthLimit), 14 | valLimit: (state) => state.strengthLimit, 15 | }, 16 | }); -------------------------------------------------------------------------------- /frontend/src/stores/ClientsStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export interface ClientInfo { 4 | id: string; 5 | name: string; 6 | lastConnectTime: number; 7 | } 8 | 9 | export const useClientsStore = defineStore('clients', { 10 | state: () => ({ 11 | clientList: [] as ClientInfo[] 12 | }), 13 | actions: { 14 | addClient(id: string, name: string) { 15 | this.clientList.push({ id, name, lastConnectTime: Date.now() }); 16 | }, 17 | getClientInfo(id: string) { 18 | return this.clientList.find(c => c.id === id); 19 | }, 20 | updateClientName(id: string, name: string) { 21 | const client = this.clientList.find(c => c.id === id); 22 | if (client) { 23 | client.name = name; 24 | } 25 | }, 26 | updateClientConnectTime(id: string) { 27 | const client = this.clientList.find(c => c.id === id); 28 | if (client) { 29 | client.lastConnectTime = Date.now(); 30 | } 31 | }, 32 | }, 33 | persist: { 34 | key: 'CGH_Clients' 35 | } 36 | }); -------------------------------------------------------------------------------- /frontend/src/stores/CoyoteBTStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { CoyoteBluetoothController } from '../utils/CoyoteBluetoothController'; 3 | import { CoyoteDeviceVersion } from '../type/common'; 4 | 5 | export const useCoyoteBTStore = defineStore('coyoteBTStore', { 6 | state: () => ({ 7 | connected: false, 8 | 9 | deviceVersion: CoyoteDeviceVersion.V3, 10 | 11 | deviceBattery: 100, 12 | deviceStrengthA: 0, 13 | deviceStrengthB: 0, 14 | 15 | freqBalance: 150, 16 | inputLimitA: 20, 17 | inputLimitB: 20, 18 | 19 | controller: null as null | CoyoteBluetoothController, 20 | }), 21 | actions: { 22 | initConnection() { 23 | if (!this.controller) { 24 | return; 25 | } 26 | 27 | this.deviceVersion = this.controller.deviceVersion; 28 | 29 | this.connected = true; 30 | window.onbeforeunload = (event) => { 31 | event.preventDefault(); 32 | return '确定要断开蓝牙连接吗?'; 33 | }; 34 | 35 | this.controller.on('workerInit', () => { 36 | // 恢复设置 37 | this.controller?.setStrengthLimit(this.inputLimitA, this.inputLimitB); 38 | this.controller?.setFreqBalance(this.freqBalance); 39 | // 启动蓝牙 40 | this.controller?.startWs(); 41 | }); 42 | 43 | this.controller.on('batteryLevelChange', (battery) => { 44 | this.deviceBattery = battery; 45 | }); 46 | 47 | this.controller.on('strengthChange', (strengthA, strengthB) => { 48 | this.deviceStrengthA = strengthA; 49 | this.deviceStrengthB = strengthB; 50 | }); 51 | 52 | this.controller.on('disconnect', () => { 53 | this.connected = false; 54 | window.onbeforeunload = null; 55 | }); 56 | }, 57 | disconnect() { 58 | this.controller?.cleanup(); 59 | this.controller = null; 60 | this.connected = false; 61 | 62 | window.onbeforeunload = null; 63 | }, 64 | }, 65 | persist: { 66 | key: 'CoyoteBTStore', 67 | pick: ['freqBalance', 'inputLimitA', 'inputLimitB'], 68 | } 69 | }); -------------------------------------------------------------------------------- /frontend/src/stores/RemoteNotificationStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useRemoteNotificationStore = defineStore('remoteNotification', { 4 | state: () => ({ 5 | ignoredIds: [] as string[] 6 | }), 7 | actions: { 8 | isIgnored(id: string) { 9 | return this.ignoredIds.includes(id); 10 | }, 11 | ignore(id: string) { 12 | this.ignoredIds.push(id); 13 | }, 14 | }, 15 | persist: { 16 | key: 'CGH_RemoteNotification' 17 | } 18 | }); -------------------------------------------------------------------------------- /frontend/src/style.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: transparent; 3 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 4 | margin: 0; 5 | --progress-font-size: 1.8rem; 6 | } 7 | 8 | .pi > img, 9 | .pi > svg { 10 | width: var(--p-icon-size); 11 | height: auto; 12 | } 13 | 14 | a { 15 | color: var(--link-color); 16 | font-weight: 600; 17 | text-decoration: underline; 18 | } 19 | 20 | .d-none { 21 | display: none !important; 22 | } 23 | 24 | @keyframes pulse { 25 | 0% { 26 | opacity: 1; 27 | } 28 | 29 | 50% { 30 | opacity: 0.5; 31 | } 32 | 33 | 100% { 34 | opacity: 1; 35 | } 36 | } 37 | 38 | .animation-pulse { 39 | animation: pulse 1.5s infinite; 40 | } -------------------------------------------------------------------------------- /frontend/src/type/common.ts: -------------------------------------------------------------------------------- 1 | export enum CoyoteDeviceVersion { 2 | V2 = 2, 3 | V3 = 3, 4 | } 5 | 6 | export enum ConnectorType { 7 | DGLAB = 'DGLab', 8 | COYOTE_BLE_V2 = 'CoyoteBLEV2', 9 | COYOTE_BLE_V3 = 'CoyoteBLEV3', 10 | } -------------------------------------------------------------------------------- /frontend/src/type/dg.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This section defines some constant enums. 3 | */ 4 | export enum MessageType { 5 | HEARTBEAT = "heartbeat", 6 | BIND = "bind", 7 | MSG = "msg", 8 | BREAK = "break", 9 | ERROR = "error", 10 | } 11 | 12 | export enum RetCode { 13 | SUCCESS = "200", 14 | CLIENT_DISCONNECTED = "209", 15 | INVALID_CLIENT_ID = "210", 16 | SERVER_DELAY = "211", 17 | ID_ALREADY_BOUND = "400", 18 | TARGET_CLIENT_NOT_FOUND = "401", 19 | INCOMPATIBLE_RELATIONSHIP = "402", 20 | NON_JSON_CONTENT = "403", 21 | RECIPIENT_NOT_FOUND = "404", 22 | MESSAGE_TOO_LONG = "405", 23 | SERVER_INTERNAL_ERROR = "500", 24 | } 25 | 26 | export enum MessageDataHead { 27 | TARGET_ID = "targetId", 28 | DG_LAB = "DGLAB", 29 | STRENGTH = "strength", 30 | PULSE = "pulse", 31 | CLEAR = "clear", 32 | FEEDBACK = "feedback", 33 | } 34 | 35 | export enum StrengthOperationType { 36 | DECREASE = 0, 37 | INCREASE = 1, 38 | SET_TO = 2, 39 | } 40 | 41 | export enum FeedbackButton { 42 | A1 = 0, 43 | A2 = 1, 44 | A3 = 2, 45 | A4 = 3, 46 | A5 = 4, 47 | B1 = 5, 48 | B2 = 6, 49 | B3 = 7, 50 | B4 = 8, 51 | B5 = 9, 52 | } 53 | 54 | export enum Channel { 55 | A = 1, 56 | B = 2, 57 | } 58 | 59 | export type DGLabMessage = { 60 | type: MessageType | string, 61 | clientId: string, 62 | targetId: string, 63 | message: string, 64 | } -------------------------------------------------------------------------------- /frontend/src/type/pulse.ts: -------------------------------------------------------------------------------- 1 | export type PulseItemInfo = { 2 | id: string; 3 | name: string; 4 | pulseData: string[]; 5 | isCustom?: boolean; 6 | }; -------------------------------------------------------------------------------- /frontend/src/utils/coyotePulse.ts: -------------------------------------------------------------------------------- 1 | export type CoyoteRawPulseData = { 2 | frequency: number[]; 3 | strength: number[]; 4 | }; 5 | 6 | export function dgLabFreqFromUint8(value: number): number { 7 | if (value === 0) { 8 | return 0; 9 | } else if (value < 10) { 10 | return 10; 11 | } else if (value <= 100) { 12 | return value; 13 | } else if (value <= 200) { 14 | return (value - 100) * 5 + 100; 15 | } else if (value <= 240) { 16 | return (value - 200) * 10 + 600; 17 | } else { 18 | return 0; 19 | } 20 | } 21 | 22 | export function parseCoyotePulseHex(pulseHex: string[]): CoyoteRawPulseData { 23 | let freqData: number[] = []; 24 | let strengthData: number[] = []; 25 | 26 | for (let singlePulseHex of pulseHex) { 27 | if (singlePulseHex.length !== 16) { 28 | throw new Error('Invalid pulse hex string.'); 29 | } 30 | 31 | // split the hex string into 2 bytes 32 | let pulseData = new Uint8Array(8); 33 | for (let i = 0; i < 8; i ++) { 34 | const offset = i * 2; 35 | let byte = singlePulseHex.slice(offset, offset + 2); 36 | pulseData[i] = parseInt(byte, 16); 37 | } 38 | 39 | // { frequency: number[4], strength: number[4] } 40 | for (let i = 0; i < 4; i ++) { 41 | freqData.push(dgLabFreqFromUint8(pulseData[i])); 42 | strengthData.push(pulseData[i + 4]); 43 | } 44 | } 45 | 46 | return { frequency: freqData, strength: strengthData }; 47 | } 48 | 49 | export function generatePulseSVG(pulseData: CoyoteRawPulseData): string { 50 | const linesPerPulse = 5; 51 | const pulseSegmentWidth = 10; 52 | const pulseSegmentHeight = 260; 53 | 54 | let svgData: string[] = []; 55 | 56 | for (let i = 0; i < pulseData.frequency.length; i ++) { 57 | let freq = pulseData.frequency[i]; 58 | let strength = pulseData.strength[i]; 59 | 60 | let height = strength / 100 * pulseSegmentHeight; 61 | 62 | let x = i * pulseSegmentWidth; 63 | let y = pulseSegmentHeight - height; 64 | 65 | if (freq === 10) { 66 | // 输出整个矩形 67 | svgData.push(``); 68 | } else { 69 | // 每个segment分成4个pulse 70 | let pulseNumPerSegment = Math.ceil(100 / freq * 2); 71 | 72 | let currentPulseNum = 0; 73 | if (pulseNumPerSegment < 4) { 74 | if (i % pulseNumPerSegment === 0) { 75 | currentPulseNum = 1; 76 | } else { 77 | currentPulseNum = 0; 78 | } 79 | } else { 80 | // 银行家舍入法 81 | if (i % 2 === 0) { 82 | currentPulseNum = Math.floor(pulseNumPerSegment / 2); 83 | } else { 84 | currentPulseNum = Math.ceil(pulseNumPerSegment / 2); 85 | } 86 | } 87 | 88 | let gap = Math.floor(linesPerPulse / currentPulseNum); 89 | for (let j = 0; j < currentPulseNum; j ++) { 90 | let currentX = x + j * gap; 91 | svgData.push(``); 92 | } 93 | } 94 | } 95 | 96 | const svgWidth = pulseData.frequency.length * pulseSegmentWidth; 97 | const svgHeight = pulseSegmentHeight; 98 | 99 | return `${svgData.join('')}`; 100 | } -------------------------------------------------------------------------------- /frontend/src/utils/event.ts: -------------------------------------------------------------------------------- 1 | export type EventDef = { 2 | [eventName: string]: any[]; 3 | }; 4 | 5 | export type EventAddListenerFunc = { 6 | (eventName: K, listener: (...args: T[K]) => void): any; 7 | (eventName: string, listener: (...args: any[]) => void): any; 8 | }; 9 | 10 | export type EventRemoveListenerFunc = { 11 | (eventName: K, listener?: (...args: T[K]) => void): any; 12 | (eventName: string, listener?: (...args: any[]) => void): any; 13 | }; -------------------------------------------------------------------------------- /frontend/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { RouteLocationNormalizedLoaded } from "vue-router"; 2 | 3 | export function parseChartParams(route: RouteLocationNormalizedLoaded): Record { 4 | const result: Record = {}; 5 | 6 | for (let paramDef of route.meta?.params ?? []) { 7 | const value = route.query[paramDef.prop]; 8 | if (value !== undefined && typeof value === 'string') { 9 | switch (paramDef.type) { 10 | case 'boolean': 11 | result[paramDef.prop] = value === 'true' || value === '1'; 12 | break; 13 | case 'int': 14 | result[paramDef.prop] = parseInt(value); 15 | break; 16 | case 'float': 17 | result[paramDef.prop] = parseFloat(value); 18 | break; 19 | default: 20 | result[paramDef.prop] = value; 21 | break; 22 | } 23 | } 24 | } 25 | 26 | return result; 27 | } -------------------------------------------------------------------------------- /frontend/src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../apis/webApi"; 2 | 3 | export class ApiError extends Error { 4 | public response: any; 5 | 6 | constructor(message: string, response: any) { 7 | super(message); 8 | this.response = response; 9 | } 10 | 11 | public get status(): number { 12 | return this.response?.status ?? -1; 13 | } 14 | 15 | public toString(): string { 16 | return `API request failed: ${this.message}`; 17 | } 18 | }; 19 | 20 | export function handleApiResponse(response: ApiResponse) { 21 | if (response.status !== 1) { 22 | throw new ApiError(response.message ?? 'Unknown error', response); 23 | } 24 | } -------------------------------------------------------------------------------- /frontend/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const simpleObjDiff = (obj1: any, obj2: any) => { 2 | if (!obj1 || !obj2) { 3 | return []; 4 | } 5 | 6 | let differentKeys: string[] = []; 7 | for (let key in obj1) { 8 | const a = obj1[key]; 9 | const b = obj2[key]; 10 | 11 | if (typeof a !== typeof b) { 12 | differentKeys.push(key); 13 | } else if (Array.isArray(a) && Array.isArray(b)) { 14 | if (a.length !== b.length) { 15 | differentKeys.push(key); 16 | } else { 17 | for (let i = 0; i < a.length; i++) { 18 | if (a[i] !== b[i]) { 19 | differentKeys.push(key); 20 | break; 21 | } 22 | } 23 | } 24 | } else if (typeof a === 'object' && typeof b === 'object') { 25 | if (JSON.stringify(a) !== JSON.stringify(b)) { 26 | differentKeys.push(key); 27 | } 28 | } else if (obj1[key] !== obj2[key]) { 29 | differentKeys.push(key); 30 | } 31 | } 32 | 33 | if (differentKeys.length > 0) { 34 | return differentKeys; 35 | } else { 36 | return false; 37 | } 38 | } 39 | 40 | export function hexStringToUint8Array(hexString: string): Uint8Array { 41 | if (hexString.length % 2 !== 0) { 42 | throw new Error('Hex string length must be even'); 43 | } 44 | 45 | const array = new Uint8Array(hexString.length / 2); 46 | for (let i = 0; i < hexString.length; i += 2) { 47 | array[i / 2] = parseInt(hexString.substring(i, i + 2), 16); 48 | } 49 | return array; 50 | } 51 | 52 | export function uint8ArrayToHexString(array: Uint8Array): string { 53 | return Array.from(array).map((byte) => byte.toString(16).padStart(2, '0')).join(''); 54 | } 55 | 56 | export function asleep(ms: number) { 57 | return new Promise((resolve) => { 58 | setTimeout(resolve, ms); 59 | }); 60 | } 61 | 62 | export function inputToClipboard(input: HTMLInputElement | HTMLTextAreaElement) { 63 | input.select(); 64 | if (document.execCommand) { 65 | document.execCommand('copy'); 66 | } else if (navigator.clipboard) { 67 | navigator.clipboard.writeText(input.value); 68 | } 69 | } -------------------------------------------------------------------------------- /frontend/src/viewer.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createWebHashHistory, createRouter } from 'vue-router' 3 | 4 | import 'virtual:windi.css' 5 | import './style.scss' 6 | import ViewerApp from './ViewerApp.vue'; 7 | import { chartRoutes } from './charts/chartRoutes'; 8 | 9 | const router = createRouter({ 10 | history: createWebHashHistory(), 11 | routes: chartRoutes, 12 | }); 13 | 14 | createApp(ViewerApp) 15 | .use(router) 16 | .mount('#app'); -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/workers/AbstractBluetoothConnector.ts: -------------------------------------------------------------------------------- 1 | export interface AbstractBluetoothWorker { 2 | 3 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true 24 | }, 25 | "include": [ 26 | "src/**/*.ts", 27 | "src/**/*.tsx", 28 | "src/**/*.vue" 29 | ], 30 | "references": [ 31 | { 32 | "path": "./tsconfig.node.json" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 战败惩罚 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import svgLoader from 'vite-svg-loader' 4 | import AutoImport from 'unplugin-auto-import/vite' 5 | import Components from 'unplugin-vue-components/vite' 6 | import WindiCSS from 'vite-plugin-windicss' 7 | import { resolve } from 'path' 8 | import { PrimeVueResolver } from 'unplugin-vue-components/resolvers' 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | build: { 13 | rollupOptions: { 14 | input: { 15 | index: resolve('./index.html'), 16 | viewer: resolve('./viewer.html'), 17 | }, 18 | output: { 19 | manualChunks: { 20 | 'chartRoutes': ['./src/charts/chartRoutes.ts'], 21 | 'dg-pulse-helper': ['./src/lib/dg-pulse-helper/index.ts'], 22 | } 23 | } 24 | }, 25 | }, 26 | server: { 27 | proxy: { 28 | '/api': { 29 | target: 'http://127.0.0.1:8920', 30 | changeOrigin: true, 31 | }, 32 | '/ws': { 33 | target: 'http://127.0.0.1:8920', 34 | ws: true, 35 | }, 36 | '/dglab_ws': { 37 | target: 'http://127.0.0.1:8920', 38 | ws: true, 39 | }, 40 | } 41 | }, 42 | plugins: [ 43 | vue(), 44 | svgLoader(), 45 | AutoImport({ 46 | imports: [ 47 | 'vue', 48 | 'vue-router', 49 | ], 50 | dts: 'src/auto-imports.d.ts', 51 | }), 52 | Components({ 53 | dts: 'src/components.d.ts', 54 | resolvers: [ 55 | PrimeVueResolver(), 56 | ], 57 | }), 58 | WindiCSS(), 59 | ], 60 | }) 61 | -------------------------------------------------------------------------------- /package-dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coyote-game-hub", 3 | "version": "1.0.0", 4 | "description": "战败惩罚——郊狼游戏控制器", 5 | "main": "server/index.js", 6 | "scripts": { 7 | "start": "node server/index.js" 8 | }, 9 | "dependencies": { 10 | "@hyperzlib/node-reactive-config": "^1.1.1", 11 | "@koa/bodyparser": "^5.1.1", 12 | "ajv": "^8.16.0", 13 | "es-get-iterator": "^1.1.3", 14 | "got": "^11.8.6", 15 | "js-yaml": "^4.1.0", 16 | "json5": "^2.2.3", 17 | "koa": "^2.15.3", 18 | "koa-router": "^12.0.1", 19 | "koa-static": "^5.0.0", 20 | "lru-cache": "^10.2.2", 21 | "path-to-regexp": "^7.0.0", 22 | "uuid": "^10.0.0", 23 | "ws": "^8.17.1" 24 | }, 25 | "keywords": [ 26 | "DG-Lab", 27 | "obs" 28 | ], 29 | "author": "Hyperzlib", 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coyote-game-hub", 3 | "version": "1.0.0", 4 | "description": "战败惩罚——郊狼游戏控制器", 5 | "main": "index.js", 6 | "scripts": { 7 | "build:frontend": "cd frontend && npm run build", 8 | "build:server": "cd server && npm run build", 9 | "build:migrate": "shx cp -r frontend/dist/* server/public", 10 | "build": "npm run build:frontend && npm run build:server && npm run build:migrate", 11 | "build:pkg:assets": "shx mkdir -p build && shx cp -r server/data build && shx cp -r server/public build && shx cp server/config.example.yaml build && shx cp version.json build", 12 | "build:pkg:nodejs": "shx cp -r server/dist build/server && shx cp package-dist.json build/package.json", 13 | "build:pkg:win": "cd server && pkg . -t node18-win-x64 --out-path=../build", 14 | "build:pkg:linux": "cd server && pkg . -t node18-linux-x64 --out-path=../build", 15 | "build:pkg:clean": "shx rm -rf build", 16 | "build:pkg": "nexe -i dist/index.js -o dist/index", 17 | "start": "cd server && node dist/index.js" 18 | }, 19 | "keywords": [ 20 | "DG-Lab", 21 | "Coyote", 22 | "obs" 23 | ], 24 | "author": "Hyperzlib", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "pkg": "^5.8.1", 28 | "shx": "^0.3.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sdk/auto_hot_key.ahk: -------------------------------------------------------------------------------- 1 | ; ============================================================================= 2 | ; AutoHotKey functions for Coyote Game Hub 3 | ; ============================================================================= 4 | 5 | global CoyoteControllerURL := "http://127.0.0.1:8920" 6 | global CoyoteTargetClientId := "all" 7 | 8 | HttpPost(url, body) { 9 | ; https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttprequest 10 | web := ComObject('WinHttp.WinHttpRequest.5.1') 11 | web.Open("POST", url, false) 12 | web.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded") 13 | web.Send(body) 14 | return web.ResponseText 15 | } 16 | 17 | CoyoteUpdateGameConfig(paramStr) 18 | { 19 | global CoyoteControllerURL, CoyoteTargetClientId 20 | 21 | url := CoyoteControllerURL . "/api/game/" . CoyoteTargetClientId . "/strength_config" 22 | return HttpPost(url, paramStr) 23 | } 24 | 25 | CoyoteAddStrength(value) 26 | { 27 | return CoyoteUpdateGameConfig("strength.add=" . value) 28 | } 29 | 30 | CoyoteSubStrength(value) 31 | { 32 | return CoyoteUpdateGameConfig("strength.sub=" . value) 33 | } 34 | 35 | CoyoteSetStrength(value) 36 | { 37 | return CoyoteUpdateGameConfig("strength.set=" . value) 38 | } 39 | 40 | CoyoteAddRandomStrength(value) 41 | { 42 | return CoyoteUpdateGameConfig("randomStrength.add=" . value) 43 | } 44 | 45 | CoyoteSubRandomStrength(value) 46 | { 47 | return CoyoteUpdateGameConfig("randomStrength.sub=" . value) 48 | } 49 | 50 | CoyoteSetRandomStrength(value) 51 | { 52 | return CoyoteUpdateGameConfig("randomStrength.set=" . value) 53 | } 54 | 55 | CoyoteFire(strength, time, overrideTime := false) 56 | { 57 | global CoyoteControllerURL, CoyoteTargetClientId 58 | 59 | timeMs := time * 1000 60 | url := CoyoteControllerURL . "/api/game/" . CoyoteTargetClientId . "/fire" 61 | params := "strength=" . strength . "&time=" . timeMs 62 | if (overrideTime) 63 | { 64 | params .= "&override=1" 65 | } 66 | return HttpPost(url, params) 67 | } 68 | 69 | ; Example usage: 70 | ; F1:: 71 | ; { 72 | ; CoyoteAddStrength(1) 73 | ; return 74 | ; } -------------------------------------------------------------------------------- /sdk/cheat_engine_sdk.lua: -------------------------------------------------------------------------------- 1 | local coyote_connect_code = "all@http://127.0.0.1:8920/" -- 控制器链接码,本地使用时无需修改 2 | 3 | ------------------------------------------------------------------------------- 4 | -- 以下为 Coyote Game Hub SDK 5 | -- 本SDK提供了一些常用的接口,用于与Coyote Game Hub进行交互 6 | ------------------------------------------------------------------------------- 7 | 8 | -- 获取controller_url和target_client_id 9 | ---@param coyote_connect_code string 10 | ---@return string, string 11 | local function coyote_get_connect_info(coyote_connect_code) 12 | local coyote_target_client_id, coyote_controller_url = coyote_connect_code:split("@") 13 | if not coyote_controller_url then 14 | coyote_controller_url = coyote_target_client_id 15 | coyote_target_client_id = "all" 16 | end 17 | return coyote_target_client_id, coyote_controller_url 18 | end 19 | 20 | -- 获取controller_url和target_client_id 21 | coyote_target_client_id, coyote_controller_url = coyote_get_connect_info(coyote_connect_code) 22 | 23 | -- 更新当前强度,参考api.md中的“设置游戏强度配置” 24 | ---@param param_str string query格式的参数字符串 25 | ---@return unknown 26 | function coyote_api_update_strength(param_str) 27 | local http = getInternet() 28 | 29 | local api_url = coyote_controller_url .. "api/v2/game/" .. coyote_target_client_id .. "/strength" 30 | local response = http.postURL(api_url, param_str) 31 | 32 | return response 33 | end 34 | 35 | -- 增加强度 36 | ---@param value number 强度值 37 | function coyote_add_strength(value) 38 | local param_str = "strength.add=" .. value 39 | return coyote_api_update_strength(param_str) 40 | end 41 | 42 | -- 减少强度 43 | ---@param value number 强度值 44 | function coyote_sub_strength(value) 45 | local param_str = "strength.sub=" .. value 46 | return coyote_api_update_strength(param_str) 47 | end 48 | 49 | -- 设置强度 50 | ---@param value number 强度值 51 | function coyote_set_strength(value) 52 | local param_str = "strength.set=" .. value 53 | return coyote_api_update_strength(param_str) 54 | end 55 | 56 | -- 增加随机强度 57 | ---@param value number 强度值 58 | function coyote_add_random_strength(value) 59 | local param_str = "randomStrength.add=" .. value 60 | return coyote_api_update_strength(param_str) 61 | end 62 | 63 | -- 减少随机强度 64 | ---@param value number 强度值 65 | function coyote_sub_random_strength(value) 66 | local param_str = "randomStrength.sub=" .. value 67 | return coyote_api_update_strength(param_str) 68 | end 69 | 70 | -- 设置随机强度 71 | ---@param value number 强度值 72 | function coyote_set_random_strength(value) 73 | local param_str = "randomStrength.set=" .. value 74 | return coyote_api_update_strength(param_str) 75 | end 76 | 77 | -- 一键开火 78 | ---@param strength number 强度值 79 | function coyote_api_action_fire(strength, time, overrideTime, pulseId) 80 | overrideTime = overrideTime or false 81 | pulseId = pulseId or nil 82 | 83 | time = time or 5 84 | time = time * 1000 85 | local http = getInternet() 86 | 87 | local param_str = "strength=" .. strength .. "&time=" .. time 88 | 89 | if overrideTime then 90 | param_str = param_str .. "&override=true" 91 | end 92 | if pulseId then 93 | param_str = param_str .. "&pulseId=" .. pulseId 94 | end 95 | 96 | local api_url = coyote_controller_url .. "api/v2/game/" .. coyote_target_client_id .. "/action/fire" 97 | local response = http.postURL(api_url, param_str) 98 | return response 99 | end -------------------------------------------------------------------------------- /server/cli/build-schema.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const { createHash } = require('crypto'); 3 | const fs = require('fs'); 4 | const tsj = require('ts-json-schema-generator'); 5 | 6 | async function md5File(file) { 7 | const fileContent = await fs.promises.readFile(file); 8 | return createHash('md5').update(fileContent).digest('hex'); 9 | } 10 | 11 | const typesList = [ 12 | { 13 | file: './src/types/game.ts', 14 | types: ['GameStrengthConfig', 'GameCustomPulseConfig', 'MainGameConfig'] 15 | }, 16 | { 17 | file: './src/types/config.ts', 18 | types: ['MainConfigType'] 19 | }, 20 | { 21 | file: './src/types/customSkin.ts', 22 | types: ['CustomSkinManifest'] 23 | } 24 | ]; 25 | 26 | const outputDir = './src/schemas'; 27 | const schemaMetaFile = `${outputDir}/schemas.json`; 28 | 29 | async function buildSchemas() { 30 | // 读取meta文件 31 | let schemaMeta = {}; 32 | if (fs.existsSync(schemaMetaFile)) { 33 | schemaMeta = JSON.parse(await fs.promises.readFile(schemaMetaFile)); 34 | } 35 | 36 | for (let {file, types} of typesList) { 37 | for (let typeName of types) { 38 | // 如果文件已存在,则检测缓存 39 | const schemaFile = `${outputDir}/${typeName}.json`; 40 | if (fs.existsSync(schemaFile) && schemaMeta[schemaFile]) { 41 | const schemaMetaData = schemaMeta[schemaFile]; 42 | const fileMd5 = await md5File(file); 43 | 44 | if (schemaMetaData.fileMd5 === fileMd5) { 45 | console.log(`Schema for ${typeName} is up to date`); 46 | continue; 47 | } 48 | } 49 | 50 | // 生成schema 51 | let schemaConfig = { 52 | path: file, 53 | type: typeName, 54 | }; 55 | let schema = tsj.createGenerator(schemaConfig).createSchema(schemaConfig.type); 56 | let schemaJson = JSON.stringify(schema, null, 4); 57 | await fs.promises.writeFile(schemaFile, schemaJson); 58 | 59 | console.log(`Generated schema for ${typeName}`); 60 | 61 | // 更新meta 62 | const fileMd5 = await md5File(file); 63 | schemaMeta[schemaFile] = { fileMd5 }; 64 | }; 65 | 66 | await fs.promises.writeFile(schemaMetaFile, JSON.stringify(schemaMeta, null, 4)); 67 | } 68 | } 69 | 70 | buildSchemas(); -------------------------------------------------------------------------------- /server/config.example-server.yaml: -------------------------------------------------------------------------------- 1 | # 使用服务器搭建服务时的样例配置文件 2 | port: 8920 # 服务端口 3 | host: "0.0.0.0" # 监听IP地址 4 | reverseProxy: true # 是否使用反向代理,开启后会使用反向代理给出的客户端IP地址 5 | webBaseUrl: "https://www.example.com" # 作为服务部署时,配置控制台的Base URL,格式:http://www.example.com:1234或https://www.example.com 6 | webWsBaseUrl: "wss://ws.example.com" # 网页控制台的WebSocket Base URL,需要包含协议类型 7 | clientWsBaseUrl: "wss://ws.example.com" # 客户端连接的WebSocket Base URL,需要包含协议类型 8 | pulseConfigPath: "pulse.yaml" # 波形配置文件路径 9 | allowBroadcastToClients: false # 允许向所有已连接的客户端广播消息(搭建公开服务时必须关闭) 10 | hideWebUpdateNotification: true # 隐藏网页控制台的更新提示 11 | siteNotifications: # 站点通知 12 | - title: "欢迎使用CoyoteGameHub" 13 | message: "这是一个示例站点通知,你可以在配置文件中添加自己的通知,或者删除这个通知。" 14 | ignoreId: 'site-welcome-1' # 忽略ID,用户可以点击“忽略”按钮来忽略这个通知,忽略的通知将不再显示。建议每次更新通知时更改这个ID。 -------------------------------------------------------------------------------- /server/config.example.yaml: -------------------------------------------------------------------------------- 1 | # 本机使用时的样例配置文件 2 | port: 8920 # 服务器端口 3 | host: "0.0.0.0" # 监听地址 4 | pulseConfigPath: "pulse.yaml" # 波形配置文件路径 5 | openBrowser: true # 启动完成后自动打开浏览器 6 | allowBroadcastToClients: true # 允许向所有已连接的客户端广播消息 -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coyote-game-hub-server", 3 | "version": "1.0.0", 4 | "description": "战败惩罚——郊狼游戏控制器", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "dev": "tsx src/index.ts", 8 | "build:schema": "node cli/build-schema.js", 9 | "build:ts": "tsc", 10 | "build": "npm run build:schema && npm run build:ts" 11 | }, 12 | "bin": "dist/index.js", 13 | "pkg": { 14 | "scripts": [ 15 | "dist/**/*" 16 | ], 17 | "assets": [] 18 | }, 19 | "keywords": [ 20 | "DG-Lab", 21 | "obs" 22 | ], 23 | "author": "Hyperzlib", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@types/blocked-at": "^1.0.4", 27 | "@types/js-yaml": "^4.0.9", 28 | "@types/koa": "^2.15.0", 29 | "@types/koa__cors": "^5.0.0", 30 | "@types/koa-router": "^7.4.8", 31 | "@types/koa-static": "^4.0.4", 32 | "@types/uuid": "^10.0.0", 33 | "@types/ws": "^8.5.10", 34 | "ts-json-schema-generator": "^2.3.0", 35 | "tsx": "^4.15.7", 36 | "typescript": "^5.4.5" 37 | }, 38 | "dependencies": { 39 | "@hyperzlib/node-reactive-config": "^1.1.1", 40 | "@koa/bodyparser": "^5.1.1", 41 | "@koa/cors": "^5.0.0", 42 | "ajv": "^8.16.0", 43 | "blocked-at": "^1.2.0", 44 | "es-get-iterator": "^1.1.3", 45 | "got": "^11.8.6", 46 | "js-yaml": "^4.1.0", 47 | "json5": "^2.2.3", 48 | "koa": "^2.15.3", 49 | "koa-router": "^12.0.1", 50 | "koa-static": "^5.0.0", 51 | "lru-cache": "^10.2.2", 52 | "path-to-regexp": "^7.0.0", 53 | "uuid": "^10.0.0", 54 | "ws": "^8.17.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperzlib/DG-Lab-Coyote-Game-Hub/79e1cb0650fd32cc1f1bf5bbe71e3a1635d63e55/server/public/.gitkeep -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | import * as fs from 'fs'; 3 | import { MainConfigType } from './types/config'; 4 | import { validator } from './utils/validator'; 5 | 6 | export class Config { 7 | public value: ConfigType | null = null; 8 | public filePath: string; 9 | 10 | constructor(filePath: string) { 11 | this.filePath = filePath; 12 | } 13 | 14 | public async load() { 15 | try { 16 | const fileContent = await fs.promises.readFile(this.filePath, { encoding: 'utf-8' }); 17 | this.value = yaml.load(fileContent) as ConfigType; 18 | } catch (error: any) { 19 | console.error('Failed to read config:', error); 20 | } 21 | } 22 | 23 | public async save() { 24 | if (this.value) { 25 | try { 26 | const yamlStr = yaml.dump(this.value); 27 | await fs.promises.writeFile(this.filePath, yamlStr, { encoding: 'utf-8' }); 28 | } catch (error: any) { 29 | console.error('Failed to save config:', error); 30 | } 31 | } 32 | } 33 | } 34 | 35 | export class MainConfig { 36 | public static instance: Config; 37 | 38 | public static async initialize() { 39 | if (!fs.existsSync('config.yaml') && fs.existsSync('config.example.yaml')) { 40 | // 如果配置文件不存在,但存在示例配置文件,则复制示例配置文件 41 | fs.copyFileSync('config.example.yaml', 'config.yaml'); 42 | } 43 | 44 | MainConfig.instance = new Config('config.yaml'); 45 | await MainConfig.instance.load(); 46 | 47 | if (!validator.validateMainConfigType(MainConfig.value)) { 48 | console.error('MainConfig validation failed.'); 49 | console.error(validator.validateMainConfigType.errors); 50 | throw new Error(`MainConfig validation failed.`); 51 | } 52 | } 53 | 54 | public static get value() { 55 | return MainConfig.instance.value!; 56 | } 57 | 58 | public static load(): Promise { 59 | return MainConfig.instance.load(); 60 | } 61 | 62 | public static save(): Promise { 63 | return MainConfig.instance.save(); 64 | } 65 | } -------------------------------------------------------------------------------- /server/src/controllers/game/actions/AbstractGameAction.ts: -------------------------------------------------------------------------------- 1 | import { CoyoteGameController } from "../CoyoteGameController"; 2 | 3 | export abstract class AbstractGameAction { 4 | /** 游戏动作的默认权重 */ 5 | static readonly defaultPriority = 0; 6 | 7 | public game!: CoyoteGameController; 8 | abortController: AbortController = new AbortController(); 9 | 10 | constructor( 11 | /** 游戏动作的配置 */ 12 | public config: ActionConfig, 13 | /** 游戏动作的权重 */ 14 | public priority: number = AbstractGameAction.defaultPriority, 15 | ) {} 16 | 17 | _initialize(game: CoyoteGameController) { 18 | this.game = game; 19 | this.initialize(); 20 | } 21 | 22 | initialize() { 23 | // Subclass could override this method 24 | } 25 | 26 | /** 执行游戏动作 */ 27 | public abstract execute(ab: AbortController, harvest: () => void, done: () => void): Promise; 28 | 29 | /** 更新游戏动作的配置 */ 30 | public abstract updateConfig(config: ActionConfig): void; 31 | } -------------------------------------------------------------------------------- /server/src/controllers/game/actions/GameFireAction.ts: -------------------------------------------------------------------------------- 1 | import { AbstractGameAction } from "./AbstractGameAction"; 2 | 3 | export type GameFireActionConfig = { 4 | /** 一键开火的强度 */ 5 | strength: number; 6 | /** 一键开火的持续时间(毫秒) */ 7 | time: number; 8 | /** 指定波形ID */ 9 | pulseId?: string; 10 | /** 重复操作的模式 */ 11 | updateMode: "replace" | "append"; 12 | }; 13 | 14 | export const FIRE_MAX_STRENGTH = 40; 15 | export const FIRE_MAX_DURATION = 30000; 16 | 17 | export class GameFireAction extends AbstractGameAction { 18 | /** 一键开火强度 */ 19 | public fireStrength: number = 0; 20 | 21 | /** 一键开火结束时间 */ 22 | public fireEndTimestamp: number = 0; 23 | 24 | /** 一键开火波形(可能是临时的) */ 25 | public firePulseId: string = ''; 26 | 27 | initialize() { 28 | this.fireStrength = Math.min(this.config.strength, FIRE_MAX_STRENGTH); 29 | this.fireEndTimestamp = Date.now() + Math.min(this.config.time, FIRE_MAX_DURATION); 30 | this.firePulseId = this.config.pulseId || this.game.gameConfig.firePulseId || this.game.pulsePlayList.getCurrentPulseId(); 31 | 32 | this.game.tempStrength = this.fireStrength; 33 | } 34 | 35 | async execute(ab: AbortController, harvest: () => void, done: () => void): Promise { 36 | let strength = Math.min(this.game.strengthConfig.strength + this.fireStrength, this.game.gameStrength.limit); 37 | let outputTime = Math.min(this.fireEndTimestamp - Date.now(), 30000); // 单次最多输出30秒 38 | 39 | await this.game.setClientStrength(strength); 40 | 41 | await this.game.client?.outputPulse(this.firePulseId, outputTime, { 42 | abortController: ab, 43 | bChannel: this.game.gameConfig.enableBChannel, 44 | onTimeEnd: () => { 45 | if (this.fireStrength && Date.now() > this.fireEndTimestamp) { // 一键开火结束 46 | // 提前降低强度 47 | this.game.setClientStrength(this.game.strengthConfig.strength).catch((error) => { 48 | console.error('Failed to set strength:', error); 49 | }); 50 | } 51 | } 52 | }); 53 | 54 | if (this.fireStrength && Date.now() > this.fireEndTimestamp) { // 一键开火结束 55 | this.game.tempStrength = 0; 56 | done(); 57 | } 58 | } 59 | 60 | updateConfig(config: GameFireActionConfig): void { 61 | this.config = config; 62 | 63 | if (config.strength) { 64 | this.fireStrength = Math.min(config.strength, FIRE_MAX_STRENGTH); 65 | const strength = Math.min(this.game.strengthConfig.strength + this.fireStrength, this.game.clientStrength.limit); 66 | this.game.setClientStrength(strength).catch((error) => { 67 | console.error('Failed to set strength:', error); 68 | }); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /server/src/controllers/http/Web.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { Context } from 'koa'; 3 | import { DGLabWSManager } from '../../managers/DGLabWSManager'; 4 | import { MainConfig } from '../../config'; 5 | import { LocalIPAddress } from '../../utils/utils'; 6 | import { CustomSkinService } from '../../services/CustomSkinService'; 7 | 8 | const DGLAB_WS_PREFIX = 'https://www.dungeon-lab.com/app-download.php#DGLAB-SOCKET#'; 9 | 10 | export class WebController { 11 | public static async index(ctx: Context): Promise { 12 | ctx.body = { 13 | status: 1, 14 | code: 'OK', 15 | message: 'Welcome to DG-Lab Live Game Server', 16 | }; 17 | } 18 | 19 | public static async getServerInfo(ctx: Context): Promise { 20 | const config = MainConfig.value; 21 | 22 | let wsUrl = ''; 23 | if (config.webWsBaseUrl) { 24 | wsUrl = `${config.webWsBaseUrl}/ws/`; 25 | } else { 26 | wsUrl = '/ws/'; 27 | } 28 | 29 | let clientWsDomain = config.clientWsBaseUrl || config.webWsBaseUrl; 30 | let wsUrlList: Record[] = []; 31 | if (clientWsDomain) { // 配置文件中指定了客户端连接时的WebSocket地址 32 | let url = new URL(clientWsDomain); 33 | let domain = url.hostname; 34 | wsUrlList.push({ 35 | domain, 36 | connectUrl: `${DGLAB_WS_PREFIX}${clientWsDomain}/dglab_ws/{clientId}`, 37 | }); 38 | } else { // 未指定客户端连接时的WebSocket地址,使用本机IP地址 39 | let ipList = LocalIPAddress.getIPAddrList(); 40 | 41 | wsUrlList = ipList.map((ip) => { 42 | return { 43 | domain: ip, 44 | connectUrl: `${DGLAB_WS_PREFIX}ws://${ip}:${config.port}/dglab_ws/{clientId}`, 45 | }; 46 | }); 47 | } 48 | 49 | const apiBaseHttpUrl = config.apiBaseHttpUrl ?? config.webBaseUrl ?? `http://127.0.0.1:${config.port}`; 50 | 51 | ctx.body = { 52 | status: 1, 53 | code: 'OK', 54 | server: { 55 | wsUrl: wsUrl, 56 | clientWsUrls: wsUrlList, 57 | apiBaseHttpUrl, 58 | }, 59 | }; 60 | }; 61 | 62 | public static async getCustomSkinList(ctx: Context): Promise { 63 | ctx.body = { 64 | status: 1, 65 | code: 'OK', 66 | customSkins: CustomSkinService.instance.skins, 67 | }; 68 | } 69 | 70 | public static async getClientConnectInfo(ctx: Context): Promise { 71 | let clientId: string = ''; 72 | for (let i = 0; i < 10; i++) { 73 | clientId = uuidv4(); 74 | if (!DGLabWSManager.instance.getClient(clientId)) { 75 | break; 76 | } else { 77 | clientId = ''; 78 | } 79 | } 80 | 81 | if (clientId === '') { 82 | ctx.body = { 83 | status: 0, 84 | code: 'ERR::CREATE_CLIENT_ID_FAILED', 85 | message: '无法创建唯一的客户端ID,请稍后重试', 86 | }; 87 | return; 88 | } 89 | 90 | ctx.body = { 91 | status: 1, 92 | code: 'OK', 93 | clientId, 94 | }; 95 | } 96 | 97 | public static async notImplemented(ctx: Context): Promise { 98 | ctx.body = { 99 | status: 0, 100 | code: 'ERR::NOT_IMPLEMENTED', 101 | message: '此功能尚未实现', 102 | }; 103 | } 104 | } -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import Koa from 'koa'; 3 | import WebSocket from 'ws'; 4 | import KoaRouter from 'koa-router'; 5 | import cors from '@koa/cors'; 6 | import { WebSocketRouter } from './utils/WebSocketRouter'; 7 | import { setupWebSocketServer } from './utils/websocket'; 8 | import { setupRouter as initRouter } from './router'; 9 | import { MainConfig } from './config'; 10 | import serveStatic from "koa-static"; 11 | import bodyParser from '@koa/bodyparser'; 12 | import blocked from 'blocked-at'; 13 | 14 | // 加载Managers 15 | import './managers/DGLabWSManager'; 16 | import './managers/CoyoteGameManager'; 17 | import { DGLabPulseService } from './services/DGLabPulse'; 18 | import { LocalIPAddress, openBrowser } from './utils/utils'; 19 | import { validator } from './utils/validator'; 20 | import { CoyoteGameConfigService } from './services/CoyoteGameConfigService'; 21 | import { SiteNotificationService } from './services/SiteNotificationService'; 22 | import { checkUpdate } from './utils/checkUpdate'; 23 | import { CustomSkinService } from './services/CustomSkinService'; 24 | 25 | async function main() { 26 | // blocked((time, stack) => { 27 | // console.log(`Blocked for ${time}ms, operation started here:`, stack) 28 | // }); 29 | 30 | await validator.initialize(); 31 | await MainConfig.initialize(); 32 | 33 | await DGLabPulseService.instance.initialize(); 34 | await CoyoteGameConfigService.instance.initialize(); 35 | await SiteNotificationService.instance.initialize(); 36 | await CustomSkinService.instance.initialize(); 37 | 38 | const app = new Koa(); 39 | app.use(cors()); 40 | const httpServer = http.createServer(app.callback()); 41 | 42 | // 在HTTP服务器上创建WebSocket服务器 43 | const wsServer = new WebSocket.Server({ 44 | server: httpServer 45 | }); 46 | 47 | // 静态资源 48 | app.use(serveStatic('public')); 49 | 50 | // 中间件 51 | app.use(bodyParser()); 52 | 53 | const router = new KoaRouter(); 54 | const wsRouter = new WebSocketRouter(); 55 | 56 | // 初始化WebSocket路由拦截器 57 | setupWebSocketServer(wsServer, wsRouter); 58 | 59 | // 加载路由 60 | initRouter(router, wsRouter); 61 | 62 | app.use(router.routes()); 63 | 64 | httpServer.listen({ 65 | port: MainConfig.value?.port ?? 8920, 66 | host: MainConfig.value?.host ?? 'localhost', 67 | }, () => { 68 | const serverAddr = httpServer.address(); 69 | let serverAddrStr = ''; 70 | const ipAddrList = LocalIPAddress.getIPAddrList(); 71 | 72 | if (serverAddr && typeof serverAddr === 'object') { 73 | if (serverAddr.family.toLocaleLowerCase() === 'ipv4') { 74 | serverAddrStr = `http://${serverAddr.address}:${serverAddr.port}`; 75 | } else { 76 | serverAddrStr = `http://[${serverAddr.address}]:${serverAddr.port}`; 77 | } 78 | } else if (serverAddr && typeof serverAddr === 'string') { 79 | serverAddrStr = serverAddr; 80 | } 81 | 82 | console.log(`Server is running at ${serverAddrStr}`); 83 | if (serverAddr && typeof serverAddr === 'object') { 84 | console.log(`You can access the console via: http://127.0.0.1:${serverAddr.port}`); 85 | 86 | // 自动打开浏览器 87 | if (MainConfig.value.openBrowser) { 88 | try { 89 | openBrowser(`http://127.0.0.1:${serverAddr.port}`); 90 | } catch (err) { 91 | console.error('Cannot open browser:', err); 92 | } 93 | } 94 | } 95 | 96 | console.log('Local IP Address List:'); 97 | ipAddrList.forEach((ipAddr) => { 98 | console.log(` - ${ipAddr}`); 99 | }); 100 | }); 101 | 102 | // 检测更新 103 | checkUpdate().then((updateInfo) => { 104 | if (!updateInfo) return; 105 | 106 | if (MainConfig.value.hideWebUpdateNotification) return; // 不在控制台显示更新通知 107 | 108 | SiteNotificationService.instance.addNotification({ 109 | severity: 'secondary', 110 | icon: 'pi pi-download', 111 | title: `发现新版本 ${updateInfo.version}`, 112 | message: updateInfo.description ?? '请前往GitHub查看更新内容。', 113 | url: updateInfo.downloadUrl, 114 | urlLabel: '下载', 115 | sticky: true, 116 | ignoreId: 'update-notification-' + updateInfo.version, 117 | }); 118 | }); 119 | } 120 | 121 | main().catch((err) => { 122 | console.error(err); 123 | }); -------------------------------------------------------------------------------- /server/src/managers/CoyoteGameManager.ts: -------------------------------------------------------------------------------- 1 | import { CoyoteGameController } from "../controllers/game/CoyoteGameController"; 2 | import { DGLabWSClient } from "../controllers/ws/DGLabWS"; 3 | import { DGLabWSManager } from "./DGLabWSManager"; 4 | import { LRUCache } from "lru-cache"; 5 | import { ExEventEmitter } from "../utils/ExEventEmitter"; 6 | import { MultipleLinkedMap } from "../utils/MultipleLinkedMap"; 7 | 8 | export interface CoyoteGameManagerEvents { 9 | 10 | } 11 | 12 | export class CoyoteGameManager { 13 | private static _instance: CoyoteGameManager; 14 | 15 | private games: Map; 16 | private gameIdentifiers: MultipleLinkedMap = new MultipleLinkedMap(); 17 | 18 | private events = new ExEventEmitter(); 19 | 20 | /** 21 | * 缓存游戏配置信息,用于在断线重连时恢复游戏状态 22 | */ 23 | public configCache: LRUCache = new LRUCache({ 24 | max: 1000, 25 | ttl: 1000 * 60 * 30, // 30 minutes 26 | }); 27 | 28 | constructor() { 29 | this.games = new Map(); 30 | 31 | DGLabWSManager.instance.on('clientConnected', (client) => this.handleCoyoteClientConnected(client)); 32 | } 33 | 34 | public static createInstance() { 35 | if (!this._instance) { 36 | this._instance = new CoyoteGameManager(); 37 | } 38 | } 39 | 40 | public static get instance() { 41 | this.createInstance(); 42 | return this._instance; 43 | } 44 | 45 | public async handleCoyoteClientConnected(client: DGLabWSClient) { 46 | try { 47 | const game = await this.getOrCreateGame(client.clientId); 48 | await game.bindClient(client); 49 | } catch (error) { 50 | console.error('Failed to create game:', error); 51 | } 52 | } 53 | 54 | public async createGame(clientId: string) { 55 | const game = new CoyoteGameController(clientId); 56 | await game.initialize(); 57 | 58 | game.once('close', () => { 59 | this.games.delete(clientId); 60 | this.gameIdentifiers.removeField(clientId); 61 | }); 62 | 63 | game.on('identifiersUpdated', (newIdentifiers) => { 64 | this.gameIdentifiers.setFieldValues(clientId, newIdentifiers); 65 | }); 66 | 67 | this.games.set(clientId, game); 68 | 69 | return game; 70 | } 71 | 72 | public getGame(id: string, identifyType: 'id' | 'readonly' | 'gameplay' = 'id') { 73 | if (identifyType === 'id') { 74 | return this.games.get(id); 75 | } 76 | 77 | // 根据不同的标识类型查找游戏ID 78 | const gameId = this.gameIdentifiers.getFieldKey(`${identifyType}:${id}`); 79 | if (!gameId) { 80 | return undefined; 81 | } 82 | 83 | return this.games.get(gameId); 84 | } 85 | 86 | public async getOrCreateGame(clientId: string) { 87 | let game = this.getGame(clientId); 88 | if (!game) { 89 | game = await this.createGame(clientId); 90 | } 91 | 92 | return game; 93 | } 94 | 95 | public getGameList(): IterableIterator { 96 | return this.games.values(); 97 | } 98 | 99 | public on = this.events.on.bind(this.events); 100 | public once = this.events.once.bind(this.events); 101 | public off = this.events.off.bind(this.events); 102 | public removeAllListeners = this.events.removeAllListeners.bind(this.events); 103 | } 104 | 105 | CoyoteGameManager.createInstance(); -------------------------------------------------------------------------------- /server/src/managers/DGLabWSManager.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { EventEmitter } from 'events'; 3 | import { IncomingMessage } from 'http'; 4 | import { wrapAsyncWebSocket } from '../utils/WebSocketAsync'; 5 | import { RetCode } from '../types/dg'; 6 | import { DGLabWSClient } from '../controllers/ws/DGLabWS'; 7 | import { OnExit } from '../utils/onExit'; 8 | import { Config, MainConfig } from '../config'; 9 | 10 | export interface DGLabWSManagerEventsListener { 11 | clientConnected: [client: DGLabWSClient]; 12 | }; 13 | 14 | export class DGLabWSManager { 15 | private static _instance: DGLabWSManager; 16 | 17 | private clientIdToClient: Map = new Map(); 18 | 19 | private events = new EventEmitter(); 20 | 21 | static createInstance() { 22 | if (!this._instance) { 23 | this._instance = new DGLabWSManager(); 24 | 25 | // Add on exit handler 26 | OnExit.register(async () => { 27 | console.log('Exiting DGLabWSManager instance'); 28 | await this._instance.destory(); 29 | }); 30 | } 31 | } 32 | 33 | static get instance(): DGLabWSManager { 34 | this.createInstance(); 35 | return this._instance; 36 | } 37 | 38 | async handleWebSocket(rawWs: WebSocket, req: IncomingMessage, routeParams: Record): Promise { 39 | const clientId = routeParams.id; 40 | 41 | const ws = wrapAsyncWebSocket(rawWs); 42 | 43 | if (!clientId) { 44 | await ws.sendAsync(JSON.stringify({ 45 | type: 'error', 46 | clientId: '', 47 | targetId: '', 48 | message: RetCode.INVALID_CLIENT_ID, 49 | })); 50 | ws.close(); 51 | return; 52 | } 53 | 54 | if (this.clientIdToClient.has(clientId)) { 55 | await ws.sendAsync(JSON.stringify({ 56 | type: 'error', 57 | clientId: clientId, 58 | targetId: '', 59 | message: RetCode.ID_ALREADY_BOUND, 60 | })); 61 | ws.close(); 62 | return; 63 | } 64 | 65 | if (MainConfig.value.allowBroadcastToClients && this.clientIdToClient.size > 10) { 66 | // 单机模式下,只允许连接10个客户端 67 | await ws.sendAsync(JSON.stringify({ 68 | type: 'error', 69 | clientId: clientId, 70 | targetId: '', 71 | message: RetCode.ID_ALREADY_BOUND, 72 | })); 73 | console.log('Too many clients connected, reject client:', clientId); 74 | console.log('If you are running this program as a public server, please set allowBroadcastToClients to false in config.yaml'); 75 | } 76 | 77 | const client = new DGLabWSClient(ws, clientId); 78 | await client.initialize(); 79 | 80 | this.events.emit('clientConnected', client); 81 | 82 | // Add bindings 83 | this.clientIdToClient.set(clientId, client); 84 | 85 | client.once('close', async () => { 86 | this.clientIdToClient.delete(client.clientId); 87 | }); 88 | } 89 | 90 | getClient(clientId: string): DGLabWSClient | undefined { 91 | return this.clientIdToClient.get(clientId); 92 | } 93 | 94 | async destory(): Promise { 95 | let promises: Promise[] = []; 96 | for (const client of this.clientIdToClient.values()) { 97 | promises.push(client.close()); 98 | } 99 | 100 | try { 101 | await Promise.all(promises); 102 | } catch (error: any) { 103 | console.error('Failed to close all clients:', error); 104 | } 105 | 106 | this.clientIdToClient.clear(); 107 | 108 | this.events.removeAllListeners(); 109 | } 110 | 111 | public on = this.events.on.bind(this.events); 112 | public once = this.events.once.bind(this.events); 113 | public off = this.events.off.bind(this.events); 114 | public removeAllListeners = this.events.removeAllListeners.bind(this.events); 115 | } 116 | 117 | DGLabWSManager.createInstance(); -------------------------------------------------------------------------------- /server/src/managers/WebWSManager.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { IncomingMessage } from 'http'; 3 | import { WebSocket } from 'ws'; 4 | 5 | import { OnExit } from '../utils/onExit'; 6 | import { WebWSClient } from '../controllers/ws/WebWS'; 7 | import { wrapAsyncWebSocket } from '../utils/WebSocketAsync'; 8 | 9 | export interface WebWSManagerEventsListener { 10 | clientConnected: [client: WebWSClient]; 11 | } 12 | 13 | export class WebWSManager { 14 | private static _instance: WebWSManager; 15 | 16 | private clientList: WebWSClient[] = []; 17 | 18 | private events = new EventEmitter(); 19 | 20 | static createInstance() { 21 | if (!this._instance) { 22 | this._instance = new WebWSManager(); 23 | 24 | // Add on exit handler 25 | OnExit.register(async () => { 26 | console.log('Exiting WebWSManager instance'); 27 | await this._instance.destory(); 28 | }); 29 | } 30 | } 31 | 32 | static get instance(): WebWSManager { 33 | this.createInstance(); 34 | return this._instance; 35 | } 36 | 37 | public async handleWebSocket(rawWs: WebSocket, req: IncomingMessage): Promise { 38 | const ws = wrapAsyncWebSocket(rawWs); 39 | 40 | const client = new WebWSClient(ws); 41 | await client.initialize(); 42 | 43 | this.clientList.push(client); 44 | 45 | this.events.emit('clientConnected', client); 46 | 47 | client.once('close', () => { 48 | this.clientList = this.clientList.filter((c) => c !== client); 49 | }); 50 | } 51 | 52 | public async destory() { 53 | let promises: Promise[] = []; 54 | 55 | for (const client of this.clientList) { 56 | promises.push(client.close()); 57 | } 58 | 59 | try { 60 | await Promise.all(promises); 61 | } catch (error: any) { 62 | console.error('Failed to close all clients:', error); 63 | } 64 | 65 | this.clientList = []; 66 | 67 | this.events.removeAllListeners(); 68 | } 69 | 70 | public on = this.events.on.bind(this.events); 71 | public once = this.events.once.bind(this.events); 72 | public off = this.events.off.bind(this.events); 73 | public removeAllListener = this.events.removeAllListeners.bind(this.events); 74 | } 75 | -------------------------------------------------------------------------------- /server/src/model/config/CustomPulseConfigUpdater.ts: -------------------------------------------------------------------------------- 1 | import { ObjectUpdater } from "../../utils/ObjectUpdater"; 2 | import { GameCustomPulseConfig } from "../../types/game"; 3 | 4 | export class CustomPulseConfigUpdater extends ObjectUpdater { 5 | protected registerSchemas(): void { 6 | this.addSchema(0, (obj) => obj, () => { 7 | return { 8 | customPulseList: [], 9 | } as GameCustomPulseConfig; 10 | }); 11 | } 12 | } -------------------------------------------------------------------------------- /server/src/model/config/GamePlayConfigUpdater.ts: -------------------------------------------------------------------------------- 1 | import { ObjectUpdater } from "../../utils/ObjectUpdater"; 2 | import { CoyoteGamePlayConfig } from "../../types/gamePlay"; 3 | 4 | export class GamePlayConfigUpdater extends ObjectUpdater { 5 | protected registerSchemas(): void { 6 | this.addSchema(0, (obj) => obj, () => { 7 | return { 8 | gamePlayList: [], 9 | } as CoyoteGamePlayConfig; 10 | }); 11 | } 12 | } -------------------------------------------------------------------------------- /server/src/model/config/GamePlayUserConfigUpdater.ts: -------------------------------------------------------------------------------- 1 | import { ObjectUpdater } from "../../utils/ObjectUpdater"; 2 | import { CoyoteGamePlayUserConfig } from "../../types/gamePlay"; 3 | 4 | export class GamePlayUserConfigUpdater extends ObjectUpdater { 5 | protected registerSchemas(): void { 6 | this.addSchema(0, (obj) => obj, () => { 7 | return { 8 | configList: {}, 9 | } as CoyoteGamePlayUserConfig; 10 | }); 11 | } 12 | } -------------------------------------------------------------------------------- /server/src/model/config/MainGameConfigUpdater.ts: -------------------------------------------------------------------------------- 1 | import { DGLabPulseService } from "../../services/DGLabPulse"; 2 | import { MainGameConfig } from "../../types/game"; 3 | import { ObjectUpdater } from "../../utils/ObjectUpdater"; 4 | 5 | export class MainGameConfigUpdater extends ObjectUpdater { 6 | protected registerSchemas(): void { 7 | this.addSchema(0, (obj) => obj, () => { 8 | return { 9 | strengthChangeInterval: [15, 30], 10 | enableBChannel: false, 11 | bChannelStrengthMultiplier: 1, 12 | pulseId: DGLabPulseService.instance.getDefaultPulse().id, 13 | pulseMode: 'single', 14 | pulseChangeInterval: 60, 15 | } as MainGameConfig; 16 | }); 17 | } 18 | } -------------------------------------------------------------------------------- /server/src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'qrcode-reader'; -------------------------------------------------------------------------------- /server/src/router.ts: -------------------------------------------------------------------------------- 1 | import KoaRouter from 'koa-router'; 2 | import { WebSocketRouter } from './utils/WebSocketRouter'; 3 | import { DGLabWSManager } from './managers/DGLabWSManager'; 4 | import { WebController } from './controllers/http/Web'; 5 | import { WebWSManager } from './managers/WebWSManager'; 6 | import { GameApiController } from './controllers/http/GameApi'; 7 | 8 | export const setupRouter = (router: KoaRouter, wsRouter: WebSocketRouter) => { 9 | router.get('/', WebController.index); 10 | router.get('/api/server_info', WebController.getServerInfo); 11 | router.get('/api/client/connect', WebController.getClientConnectInfo); 12 | router.get('/api/custom_skins', WebController.getCustomSkinList); 13 | 14 | router.get('/api/game', GameApiController.gameApiInfo); 15 | 16 | // v1 17 | router.get('/api/game/:id', GameApiController.gameInfo); 18 | router.get('/api/game/:id/strength_config', GameApiController.getGameStrength); 19 | router.post('/api/game/:id/strength_config', GameApiController.setGameStrength); 20 | router.get('/api/game/:id/pulse_id', GameApiController.getPulseId); 21 | router.post('/api/game/:id/pulse_id', GameApiController.setPulseId); 22 | router.get('/api/game/:id/pulse_list', GameApiController.getPulseList); 23 | 24 | router.post('/api/game/:id/fire', GameApiController.startActionFire); 25 | 26 | // v2 27 | router.get('/api/v2/pulse_list', GameApiController.getPulseList); 28 | router.post('/api/v2/pair_game', WebController.notImplemented); 29 | 30 | router.get('/api/v2/game/:id', GameApiController.gameInfo); 31 | router.get('/api/v2/game/:id/strength', GameApiController.getGameStrength); 32 | router.post('/api/v2/game/:id/strength', GameApiController.setGameStrength); 33 | router.get('/api/v2/game/:id/pulse', GameApiController.getPulseId); 34 | router.post('/api/v2/game/:id/pulse', GameApiController.setPulseId); 35 | router.get('/api/v2/game/:id/pulse_list', GameApiController.getPulseList); 36 | 37 | router.post('/api/v2/game/:id/action/fire', GameApiController.startActionFire); 38 | 39 | router.post('/api/v2/game/:id/gameplay/init', WebController.notImplemented); 40 | router.post('/api/v2/game/:id/gameplay/:gameplayid/event/emit', WebController.notImplemented); 41 | 42 | wsRouter.get('/ws', async (ws, req) => { 43 | WebWSManager.instance.handleWebSocket(ws, req); 44 | }); 45 | 46 | wsRouter.get('/dglab_ws/:id', async (ws, req, routeParams) => { 47 | DGLabWSManager.instance.handleWebSocket(ws, req, routeParams); 48 | }); 49 | }; -------------------------------------------------------------------------------- /server/src/schemas/CustomSkinManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/CustomSkinManifest", 4 | "definitions": { 5 | "CustomSkinManifest": { 6 | "type": "object", 7 | "properties": { 8 | "name": { 9 | "type": "string" 10 | }, 11 | "main": { 12 | "type": "string" 13 | }, 14 | "help": { 15 | "type": "string" 16 | }, 17 | "params": { 18 | "type": "array", 19 | "items": { 20 | "$ref": "#/definitions/CustomSkinParamDef" 21 | } 22 | } 23 | }, 24 | "required": [ 25 | "name", 26 | "main" 27 | ], 28 | "additionalProperties": false 29 | }, 30 | "CustomSkinParamDef": { 31 | "type": "object", 32 | "properties": { 33 | "prop": { 34 | "type": "string" 35 | }, 36 | "type": { 37 | "type": "string", 38 | "enum": [ 39 | "string", 40 | "int", 41 | "boolean", 42 | "select" 43 | ] 44 | }, 45 | "options": { 46 | "type": "object", 47 | "properties": { 48 | "label": { 49 | "type": "string" 50 | }, 51 | "value": { 52 | "type": "string" 53 | } 54 | }, 55 | "required": [ 56 | "label", 57 | "value" 58 | ], 59 | "additionalProperties": false 60 | } 61 | }, 62 | "required": [ 63 | "prop", 64 | "type" 65 | ], 66 | "additionalProperties": false 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /server/src/schemas/GameCustomPulseConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/GameCustomPulseConfig", 4 | "definitions": { 5 | "GameCustomPulseConfig": { 6 | "type": "object", 7 | "properties": { 8 | "customPulseList": { 9 | "type": "array", 10 | "items": {} 11 | } 12 | }, 13 | "required": [ 14 | "customPulseList" 15 | ], 16 | "additionalProperties": false 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /server/src/schemas/GameStrengthConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/GameStrengthConfig", 4 | "definitions": { 5 | "GameStrengthConfig": { 6 | "type": "object", 7 | "properties": { 8 | "strength": { 9 | "type": "number" 10 | }, 11 | "randomStrength": { 12 | "type": "number" 13 | } 14 | }, 15 | "required": [ 16 | "strength", 17 | "randomStrength" 18 | ], 19 | "additionalProperties": false 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /server/src/schemas/MainConfigType.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/MainConfigType", 4 | "definitions": { 5 | "MainConfigType": { 6 | "type": "object", 7 | "properties": { 8 | "port": { 9 | "type": "number" 10 | }, 11 | "host": { 12 | "type": "string" 13 | }, 14 | "reverseProxy": { 15 | "type": "boolean", 16 | "description": "是否使用反向代理,开启后会使用反向代理的配置" 17 | }, 18 | "webBaseUrl": { 19 | "type": "string", 20 | "description": "作为服务部署时,配置控制台的Base URL,格式:http://www.example.com:1234或https://www.example.com" 21 | }, 22 | "webWsBaseUrl": { 23 | "type": [ 24 | "string", 25 | "null" 26 | ], 27 | "description": "网页控制台的WebSocket Base URL,需要包含协议类型" 28 | }, 29 | "clientWsBaseUrl": { 30 | "type": [ 31 | "string", 32 | "null" 33 | ], 34 | "description": "DG-Lab客户端连接时的WebSocket URL" 35 | }, 36 | "apiBaseHttpUrl": { 37 | "type": "string", 38 | "description": "API的基础URL,不需要“api”,末尾需要“/”,某些游戏插件可能不支持HTTPS,需要配置HTTP接口" 39 | }, 40 | "pulseConfigPath": { 41 | "type": "string", 42 | "description": "波形配置文件路径" 43 | }, 44 | "openBrowser": { 45 | "type": "boolean", 46 | "description": "服务器启动后自动打开浏览器" 47 | }, 48 | "allowBroadcastToClients": { 49 | "type": "boolean", 50 | "description": "允许插件API向所有客户端发送指令" 51 | }, 52 | "hideWebUpdateNotification": { 53 | "type": "boolean", 54 | "description": "网页不显示更新通知" 55 | }, 56 | "siteNotifications": { 57 | "type": "array", 58 | "items": { 59 | "$ref": "#/definitions/RemoteNotificationInfo" 60 | }, 61 | "description": "站点通知" 62 | } 63 | }, 64 | "required": [ 65 | "host", 66 | "port", 67 | "pulseConfigPath" 68 | ] 69 | }, 70 | "RemoteNotificationInfo": { 71 | "type": "object", 72 | "properties": { 73 | "title": { 74 | "type": "string", 75 | "description": "通知标题" 76 | }, 77 | "message": { 78 | "type": "string", 79 | "description": "通知内容" 80 | }, 81 | "icon": { 82 | "type": "string", 83 | "description": "通知图标,需要是PrimeVue图标列表里的className" 84 | }, 85 | "severity": { 86 | "type": "string", 87 | "enum": [ 88 | "success", 89 | "info", 90 | "warn", 91 | "error", 92 | "secondary", 93 | "contrast" 94 | ], 95 | "description": "通知类型" 96 | }, 97 | "ignoreId": { 98 | "type": "string", 99 | "description": "通知的ID,如果存在则此通知可以忽略" 100 | }, 101 | "sticky": { 102 | "type": "boolean", 103 | "description": "阻止通知自动关闭" 104 | }, 105 | "url": { 106 | "type": "string", 107 | "description": "点击通知后打开的URL" 108 | }, 109 | "urlLabel": { 110 | "type": "string", 111 | "description": "打开URL的按钮文本" 112 | } 113 | }, 114 | "required": [ 115 | "message" 116 | ], 117 | "additionalProperties": false 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /server/src/schemas/MainGameConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/MainGameConfig", 4 | "definitions": { 5 | "MainGameConfig": { 6 | "type": "object", 7 | "properties": { 8 | "strengthChangeInterval": { 9 | "type": "array", 10 | "items": { 11 | "type": "number" 12 | }, 13 | "minItems": 2, 14 | "maxItems": 2 15 | }, 16 | "enableBChannel": { 17 | "type": "boolean" 18 | }, 19 | "bChannelStrengthMultiplier": { 20 | "type": "number", 21 | "description": "B通道强度倍率" 22 | }, 23 | "pulseId": { 24 | "anyOf": [ 25 | { 26 | "type": "string" 27 | }, 28 | { 29 | "type": "array", 30 | "items": { 31 | "type": "string" 32 | } 33 | } 34 | ] 35 | }, 36 | "firePulseId": { 37 | "type": [ 38 | "string", 39 | "null" 40 | ] 41 | }, 42 | "pulseMode": { 43 | "$ref": "#/definitions/PulsePlayMode" 44 | }, 45 | "pulseChangeInterval": { 46 | "type": "number" 47 | } 48 | }, 49 | "required": [ 50 | "strengthChangeInterval", 51 | "enableBChannel", 52 | "bChannelStrengthMultiplier", 53 | "pulseId", 54 | "pulseMode", 55 | "pulseChangeInterval" 56 | ], 57 | "additionalProperties": false 58 | }, 59 | "PulsePlayMode": { 60 | "type": "string", 61 | "enum": [ 62 | "single", 63 | "sequence", 64 | "random" 65 | ] 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /server/src/schemas/schemas.json: -------------------------------------------------------------------------------- 1 | { 2 | "./src/schemas/GameStrengthConfig.json": { 3 | "fileMd5": "767957c4f5c0bedfadc8b1389d22ba33" 4 | }, 5 | "./src/schemas/GameCustomPulseConfig.json": { 6 | "fileMd5": "767957c4f5c0bedfadc8b1389d22ba33" 7 | }, 8 | "./src/schemas/MainGameConfig.json": { 9 | "fileMd5": "767957c4f5c0bedfadc8b1389d22ba33" 10 | }, 11 | "./src/schemas/MainConfigType.json": { 12 | "fileMd5": "0af6fae41e565e20eebfee8c0cd0dec7" 13 | }, 14 | "./src/schemas/CustomSkinManifest.json": { 15 | "fileMd5": "d470a5cbc371ad4458f1fb9b4829a923" 16 | } 17 | } -------------------------------------------------------------------------------- /server/src/services/CustomSkinService.ts: -------------------------------------------------------------------------------- 1 | import { CustomSkinManifest } from "../types/customSkin"; 2 | import * as fs from 'fs'; 3 | import { validator } from "../utils/validator"; 4 | 5 | export type CustomSkinInfo = CustomSkinManifest & { 6 | url: string; 7 | } 8 | 9 | export class CustomSkinService { 10 | private static _instance: CustomSkinService; 11 | 12 | private skinsPath = 'public/skins'; 13 | private skinsBaseUrl = '/skins'; 14 | 15 | public skins: CustomSkinInfo[] = []; 16 | 17 | public static createInstance() { 18 | if (!this._instance) { 19 | this._instance = new CustomSkinService(); 20 | } 21 | } 22 | 23 | public static get instance() { 24 | this.createInstance(); 25 | return this._instance; 26 | } 27 | 28 | public async initialize() { 29 | await this.scanSkins(); 30 | } 31 | 32 | public async scanSkins() { 33 | if (!fs.existsSync(this.skinsPath)) { 34 | console.log('CustomSkins path not found, ignore.'); 35 | return; 36 | } 37 | 38 | const files = await fs.promises.readdir(this.skinsPath); 39 | const skins: CustomSkinInfo[] = []; 40 | for (const skinDir of files) { 41 | if (!fs.statSync(`${this.skinsPath}/${skinDir}`).isDirectory()) { 42 | continue; 43 | } 44 | 45 | const manifestPath = `${this.skinsPath}/${skinDir}/skin.json`; 46 | if (!fs.existsSync(manifestPath)) { 47 | continue; 48 | } 49 | 50 | const manifestContent = await fs.promises.readFile(manifestPath, { encoding: 'utf-8' }); 51 | const manifest: CustomSkinManifest = JSON.parse(manifestContent); 52 | 53 | if (!validator.validateCustomSkinManifest(manifest)) { 54 | console.error(`Invalid manifest for skin ${skinDir}`); 55 | console.error(validator.validateCustomSkinManifest.errors); 56 | continue; 57 | } 58 | 59 | let skinIndexUrl = `${this.skinsBaseUrl}/${skinDir}/${manifest.main}`; 60 | skins.push({ 61 | ...manifest, 62 | url: skinIndexUrl, 63 | }); 64 | } 65 | 66 | this.skins = skins; 67 | } 68 | } -------------------------------------------------------------------------------- /server/src/services/DGLabPulse.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import JSON5 from 'json5'; 4 | import { v4 as uuid4 } from 'uuid'; 5 | import { EventEmitter } from 'events'; 6 | import { ReactiveConfig } from '@hyperzlib/node-reactive-config'; 7 | 8 | ReactiveConfig.addParser('json5', { 9 | parse: JSON5.parse, 10 | stringify: (value) => JSON5.stringify(value, null, 4), 11 | }); 12 | 13 | export interface DGLabPulseBaseInfo { 14 | id: string; 15 | name: string; 16 | } 17 | 18 | export interface DGLabPulseInfo extends DGLabPulseBaseInfo { 19 | pulseData: string[]; 20 | } 21 | 22 | export interface DGLabPulseServiceEvents { 23 | pulseListUpdated: [pulseList: DGLabPulseBaseInfo[]]; 24 | } 25 | 26 | export const PULSE_WINDOW = 100; 27 | 28 | export class DGLabPulseService { 29 | public pulseList: DGLabPulseInfo[] = []; 30 | private pulseConfig: ReactiveConfig; 31 | 32 | private pulseConfigPath = 'data/pulse.json5'; 33 | 34 | private events = new EventEmitter(); 35 | 36 | private static _instance: DGLabPulseService; 37 | 38 | constructor() { 39 | this.pulseConfig = new ReactiveConfig(this.pulseConfigPath, [], { 40 | autoInit: false, 41 | }); 42 | } 43 | 44 | public static createInstance() { 45 | if (!this._instance) { 46 | this._instance = new DGLabPulseService(); 47 | } 48 | } 49 | 50 | public static get instance(): DGLabPulseService { 51 | this.createInstance(); 52 | return this._instance; 53 | } 54 | 55 | public async initialize(): Promise { 56 | this.pulseConfig.on('data', (value) => { 57 | console.log('Pulse list updated.'); 58 | this.pulseList = value; 59 | this.events.emit('pulseListUpdated', this.getPulseInfoList()); 60 | }); 61 | 62 | await this.pulseConfig.initialize(); 63 | } 64 | 65 | public async destroy(): Promise { 66 | this.pulseConfig.destroy(); 67 | 68 | this.events.removeAllListeners(); 69 | } 70 | 71 | public getDefaultPulse(): DGLabPulseInfo { 72 | return this.pulseList[0] ?? { 73 | id: 'empty', 74 | name: 'Empty', 75 | pulseData: ['0A0A0A0A00000000'], 76 | }; 77 | } 78 | 79 | public getPulseInfoList(): DGLabPulseBaseInfo[] { 80 | return this.pulseList; 81 | } 82 | 83 | public getPulse(pulseId: string, customPulseList?: DGLabPulseInfo[]): DGLabPulseInfo | null { 84 | if (customPulseList) { 85 | const customPulse = customPulseList.find(pulse => pulse.id === pulseId); 86 | if (customPulse) { 87 | return customPulse; 88 | } 89 | } 90 | 91 | return this.pulseList.find(pulse => pulse.id === pulseId) ?? null; 92 | } 93 | 94 | public getPulseHexData(pulse: DGLabPulseInfo): [string[], number] { 95 | let totalDuration = pulse.pulseData.length * PULSE_WINDOW; 96 | return [pulse.pulseData, totalDuration]; 97 | } 98 | 99 | public on = this.events.on.bind(this.events); 100 | public once = this.events.once.bind(this.events); 101 | public off = this.events.off.bind(this.events); 102 | public removeAllListeners = this.events.removeAllListeners.bind(this.events); 103 | } 104 | -------------------------------------------------------------------------------- /server/src/services/SiteNotificationService.ts: -------------------------------------------------------------------------------- 1 | import { ExEventEmitter } from "../utils/ExEventEmitter"; 2 | import { RemoteNotificationInfo } from "../types/server"; 3 | import { MainConfig } from "../config"; 4 | 5 | export interface SiteNotificationManagerService { 6 | newNotification: [notification: RemoteNotificationInfo]; 7 | } 8 | 9 | export class SiteNotificationService { 10 | private static _instance: SiteNotificationService; 11 | 12 | private events = new ExEventEmitter(); 13 | 14 | private notifications: RemoteNotificationInfo[] = []; 15 | 16 | constructor() { 17 | 18 | } 19 | 20 | public static createInstance() { 21 | if (!this._instance) { 22 | this._instance = new SiteNotificationService(); 23 | } 24 | } 25 | 26 | public static get instance() { 27 | this.createInstance(); 28 | return this._instance; 29 | } 30 | 31 | public async initialize() { 32 | if (MainConfig.value.siteNotifications) { 33 | this.notifications = MainConfig.value.siteNotifications; 34 | } 35 | } 36 | 37 | public addNotification(notification: RemoteNotificationInfo) { 38 | this.notifications.push(notification); 39 | this.events.emit('newNotification', notification); 40 | } 41 | 42 | public getNotifications() { 43 | return this.notifications; 44 | } 45 | 46 | public on = this.events.on.bind(this.events); 47 | public once = this.events.once.bind(this.events); 48 | public off = this.events.off.bind(this.events); 49 | public removeAllListeners = this.events.removeAllListeners.bind(this.events); 50 | } -------------------------------------------------------------------------------- /server/src/types.d.ts: -------------------------------------------------------------------------------- 1 | import 'koa'; 2 | import 'http'; 3 | 4 | declare module "*.json"; // Allow importing JSON files 5 | 6 | declare module 'koa' { 7 | interface Request { 8 | body?: any; 9 | rawBody: string; 10 | } 11 | } 12 | declare module 'http' { 13 | interface IncomingMessage { 14 | body?: any; 15 | rawBody: string; 16 | } 17 | } -------------------------------------------------------------------------------- /server/src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { RemoteNotificationInfo } from "./server"; 2 | 3 | export type MainConfigType = { 4 | port: number; 5 | host: string; 6 | /** 是否使用反向代理,开启后会使用反向代理的配置 */ 7 | reverseProxy?: boolean; 8 | /** 作为服务部署时,配置控制台的Base URL,格式:http://www.example.com:1234或https://www.example.com */ 9 | webBaseUrl?: string; 10 | /** 网页控制台的WebSocket Base URL,需要包含协议类型 */ 11 | webWsBaseUrl?: string | null; 12 | /** DG-Lab客户端连接时的WebSocket URL */ 13 | clientWsBaseUrl?: string | null; 14 | /** API的基础URL,不需要“api”,末尾需要“/”,某些游戏插件可能不支持HTTPS,需要配置HTTP接口 */ 15 | apiBaseHttpUrl?: string; 16 | /** 波形配置文件路径 */ 17 | pulseConfigPath: string; 18 | /** 服务器启动后自动打开浏览器 */ 19 | openBrowser?: boolean; 20 | /** 允许插件API向所有客户端发送指令 */ 21 | allowBroadcastToClients?: boolean; 22 | /** 网页不显示更新通知 */ 23 | hideWebUpdateNotification?: boolean; 24 | /** 站点通知 */ 25 | siteNotifications?: RemoteNotificationInfo[]; 26 | } & Record; -------------------------------------------------------------------------------- /server/src/types/customSkin.ts: -------------------------------------------------------------------------------- 1 | export type CustomSkinParamDef = { 2 | prop: string; 3 | type: 'boolean' | 'int' | 'float' | 'string' | 'select'; 4 | name: string; 5 | help?: string; 6 | options?: { value: string, label: string }[]; 7 | }; 8 | 9 | export type CustomSkinManifest = { 10 | name: string; 11 | main: string; 12 | help?: string; 13 | params?: CustomSkinParamDef[]; 14 | }; -------------------------------------------------------------------------------- /server/src/types/dg.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This section defines some constant enums. 3 | */ 4 | export enum MessageType { 5 | HEARTBEAT = "heartbeat", 6 | BIND = "bind", 7 | MSG = "msg", 8 | BREAK = "break", 9 | ERROR = "error", 10 | } 11 | 12 | export enum RetCode { 13 | SUCCESS = "200", 14 | CLIENT_DISCONNECTED = "209", 15 | INVALID_CLIENT_ID = "210", 16 | SERVER_DELAY = "211", 17 | ID_ALREADY_BOUND = "400", 18 | TARGET_CLIENT_NOT_FOUND = "401", 19 | INCOMPATIBLE_RELATIONSHIP = "402", 20 | NON_JSON_CONTENT = "403", 21 | RECIPIENT_NOT_FOUND = "404", 22 | MESSAGE_TOO_LONG = "405", 23 | SERVER_INTERNAL_ERROR = "500", 24 | } 25 | 26 | export enum MessageDataHead { 27 | TARGET_ID = "targetId", 28 | DG_LAB = "DGLAB", 29 | STRENGTH = "strength", 30 | PULSE = "pulse", 31 | CLEAR = "clear", 32 | FEEDBACK = "feedback", 33 | } 34 | 35 | export enum StrengthOperationType { 36 | DECREASE = 0, 37 | INCREASE = 1, 38 | SET_TO = 2, 39 | } 40 | 41 | export enum FeedbackButton { 42 | A1 = 0, 43 | A2 = 1, 44 | A3 = 2, 45 | A4 = 3, 46 | A5 = 4, 47 | B1 = 5, 48 | B2 = 6, 49 | B3 = 7, 50 | B4 = 8, 51 | B5 = 9, 52 | } 53 | 54 | export enum Channel { 55 | A = 1, 56 | B = 2, 57 | } 58 | 59 | export type DGLabMessage = { 60 | type: MessageType | string, 61 | clientId: string, 62 | targetId: string, 63 | message: string, 64 | } -------------------------------------------------------------------------------- /server/src/types/game.ts: -------------------------------------------------------------------------------- 1 | export type PulsePlayMode = 'single' | 'sequence' | 'random'; 2 | 3 | export interface GameStrengthConfig { 4 | strength: number; 5 | randomStrength: number; 6 | } 7 | 8 | export interface MainGameConfig { 9 | strengthChangeInterval: [number, number]; 10 | 11 | enableBChannel: boolean; 12 | /** B通道强度倍率 */ 13 | bChannelStrengthMultiplier: number; 14 | 15 | pulseId: string | string[]; 16 | firePulseId?: string | null; 17 | 18 | pulseMode: PulsePlayMode; 19 | pulseChangeInterval: number; 20 | } 21 | 22 | export interface GameConnectionConfig { 23 | /** 游戏链接码 */ 24 | connectCodeList: string[]; 25 | } 26 | 27 | export interface GameCustomPulseConfig { 28 | customPulseList: any[]; 29 | } -------------------------------------------------------------------------------- /server/src/types/gamePlay.ts: -------------------------------------------------------------------------------- 1 | export type GamePlaySimpleEventDefinition = { 2 | type: 'simple'; 3 | default?: number; 4 | }; 5 | 6 | export type GamePlayNumericEventDefinition = { 7 | type: 'numeric'; 8 | /** 最小值和最大值 */ 9 | default?: [number, number]; 10 | }; 11 | 12 | export type GamePlayEventDefinition = { 13 | id: string; 14 | /** 事件名 */ 15 | name: string; 16 | /** 帮助信息 */ 17 | help?: string; 18 | } & (GamePlaySimpleEventDefinition | GamePlayNumericEventDefinition); 19 | 20 | export type GamePlayConfigSelectOption = { 21 | label: string; 22 | value: string; 23 | }; 24 | 25 | export type GamePlayConfigEntryDefinition = { 26 | id: string; 27 | name: string; 28 | type: 'text' | 'int' | 'float' | 'boolean' | 'select'; 29 | help?: string; 30 | options?: GamePlayConfigSelectOption[]; 31 | default?: number; 32 | }; 33 | 34 | /** 游戏玩法的定义信息 */ 35 | export type GamePlayDefinition = { 36 | /** 连接Token */ 37 | token: string; 38 | /** 备注名 */ 39 | remarkName?: string; 40 | /** 游戏标题 */ 41 | title?: string; 42 | /** 游戏标题(原语言) */ 43 | titleOriginal?: string; 44 | /** 游戏图标 */ 45 | iconUrl?: string; 46 | /** 游戏描述 */ 47 | description?: string; 48 | /** 事件定义 */ 49 | events?: GamePlayEventDefinition[]; 50 | /** 其他配置定义 */ 51 | configs?: GamePlayConfigEntryDefinition[]; 52 | }; 53 | 54 | /** 游戏玩法的用户设置 */ 55 | export type GamePlayUserConfig = { 56 | token: string; 57 | events: Record; 58 | configs: Record; 59 | } 60 | 61 | export type CoyoteGamePlayConfig = { 62 | gamePlayList: GamePlayDefinition[]; 63 | }; 64 | 65 | export type CoyoteGamePlayUserConfig = { 66 | configList: Record; 67 | }; -------------------------------------------------------------------------------- /server/src/types/server.ts: -------------------------------------------------------------------------------- 1 | export type RemoteNotificationInfo = { 2 | /** 通知标题 */ 3 | title?: string; 4 | /** 通知内容 */ 5 | message: string; 6 | /** 通知图标,需要是PrimeVue图标列表里的className */ 7 | icon?: string; 8 | /** 通知类型 */ 9 | severity?: 'success' | 'info' | 'warn' | 'error' | 'secondary' | 'contrast'; 10 | /** 通知的ID,如果存在则此通知可以忽略 */ 11 | ignoreId?: string; 12 | /** 阻止通知自动关闭 */ 13 | sticky?: boolean; 14 | /** 点击通知后打开的URL */ 15 | url?: string; 16 | /** 打开URL的按钮文本 */ 17 | urlLabel?: string; 18 | }; -------------------------------------------------------------------------------- /server/src/utils/EventStore.ts: -------------------------------------------------------------------------------- 1 | export interface IEventEmitter { 2 | on: Function; 3 | off: Function; 4 | } 5 | 6 | export interface EventStoreItem { 7 | source: IEventEmitter; 8 | eventName: string; 9 | callback: Function; 10 | } 11 | 12 | export interface wrappedEventSourceItem { 13 | source: IEventEmitter; 14 | wrapped: IEventEmitter; 15 | } 16 | 17 | export class EventStore { 18 | private listenedEvents: EventStoreItem[] = []; 19 | private wrappedEventSources: wrappedEventSourceItem[] = []; 20 | 21 | constructor() { 22 | 23 | } 24 | 25 | /** 26 | * Wrap the event listener to store the event source and event name 27 | * @param eventSource 28 | */ 29 | public wrap(eventSource: T): T { 30 | let cached = this.wrappedEventSources.find((item) => item.source === eventSource); 31 | if (cached) { 32 | return cached.wrapped as T; 33 | } 34 | 35 | let wrapped = new Proxy(eventSource, { 36 | get: (target, prop) => { 37 | switch (prop) { 38 | case 'on': 39 | case 'addListener': 40 | return this.wrapOn(eventSource); 41 | case 'off': 42 | case 'removeListener': 43 | return this.wrapOff(eventSource); 44 | case 'removeAllListeners': 45 | return this.wrapRemoveAllListeners(eventSource); 46 | default: 47 | return (target as any)[prop]; 48 | } 49 | } 50 | }); 51 | 52 | this.wrappedEventSources.push({ 53 | source: eventSource, 54 | wrapped 55 | }); 56 | 57 | return wrapped as T; 58 | } 59 | 60 | private wrapOn(eventSource: T) { 61 | return (eventName: string, arg1: string | Function, arg2?: Function) => { 62 | let callback: Function; 63 | if (typeof arg1 === 'string') { 64 | eventName = `${eventName}/${arg1}`; 65 | callback = arg2!; 66 | } else { 67 | callback = arg1; 68 | } 69 | 70 | this.listenedEvents.push({ 71 | source: eventSource, 72 | eventName, 73 | callback, 74 | }); 75 | 76 | return eventSource.on(eventName, callback as any); 77 | } 78 | } 79 | 80 | private wrapOff(eventSource: T) { 81 | return (eventName: string, arg1: string | Function, arg2?: Function) => { 82 | let callback: Function; 83 | if (typeof arg1 === 'string') { 84 | eventName = `${eventName}/${arg1}`; 85 | callback = arg2!; 86 | } else { 87 | callback = arg1; 88 | } 89 | 90 | let eventItem = this.listenedEvents.find((item) => item.source === eventSource && item.eventName === eventName && item.callback === callback); 91 | if (eventItem) { 92 | this.listenedEvents = this.listenedEvents.filter((item) => item !== eventItem); 93 | } 94 | 95 | return eventSource.off(eventName, callback as any); 96 | } 97 | } 98 | 99 | private wrapRemoveAllListeners(eventSource: T) { 100 | return (eventName?: string, arg1?: string) => { 101 | if (eventName) { 102 | // Remove all listeners of the specified event 103 | if (arg1) { 104 | eventName = `${eventName}/${arg1}`; 105 | } 106 | 107 | let shouldRemove = this.listenedEvents.find((item) => item.source === eventSource && item.eventName === eventName); 108 | if (shouldRemove) { 109 | eventSource.off(eventName, shouldRemove.callback as any); 110 | } 111 | 112 | this.listenedEvents = this.listenedEvents.filter((item) => item.source !== eventSource || item.eventName !== eventName); 113 | } else { 114 | let shouldRemove = this.listenedEvents.filter((item) => item.source === eventSource); 115 | for (let item of shouldRemove) { 116 | eventSource.off(item.eventName, item.callback as any); 117 | } 118 | 119 | this.listenedEvents = this.listenedEvents.filter((item) => item.source !== eventSource); 120 | } 121 | }; 122 | } 123 | 124 | /** 125 | * Remove all event listeners 126 | */ 127 | public removeAllListeners() { 128 | for (let eventItem of this.listenedEvents) { 129 | eventItem.source.off(eventItem.eventName, eventItem.callback as any); 130 | } 131 | 132 | this.listenedEvents = []; 133 | } 134 | } -------------------------------------------------------------------------------- /server/src/utils/MultipleLinkedMap.ts: -------------------------------------------------------------------------------- 1 | import { simpleArrayDiff } from "./utils"; 2 | 3 | export class MultipleLinkedMap { 4 | private _map = new Map(); 5 | private _reverseMap = new Map(); 6 | 7 | public get map() { 8 | return this._map; 9 | } 10 | 11 | public get reverseMap() { 12 | return this._reverseMap; 13 | } 14 | 15 | public get keysCount() { 16 | return this._map.size; 17 | } 18 | 19 | public get valuesCount() { 20 | return this._reverseMap.size; 21 | } 22 | 23 | public keys() { 24 | return this._map.keys(); 25 | } 26 | 27 | public values() { 28 | return this._reverseMap.keys(); 29 | } 30 | 31 | public getFieldValues(key: K): V[] { 32 | return this._map.get(key) || []; 33 | } 34 | 35 | public getFieldKey(value: V): K | undefined { 36 | return this._reverseMap.get(value); 37 | } 38 | 39 | public addFieldValue(key: K, value: V) { 40 | let values = this._map.get(key); 41 | if (!values) { 42 | values = []; 43 | this._map.set(key, values); 44 | } 45 | values.push(value); 46 | this._reverseMap.set(value, key); 47 | } 48 | 49 | public removeFieldValue(key: K, value: V) { 50 | let values = this._map.get(key); 51 | if (values) { 52 | let index = values.indexOf(value); 53 | if (index !== -1) { 54 | values.splice(index, 1); 55 | if (values.length === 0) { 56 | this._map.delete(key); 57 | } 58 | } 59 | } 60 | this._reverseMap.delete(value); 61 | } 62 | 63 | public setFieldValues(key: K, values: V[]) { 64 | let added = values; 65 | let removed: V[] = []; 66 | 67 | let oldValues = this._map.get(key); 68 | if (oldValues) { 69 | let diffResult = simpleArrayDiff(oldValues, values); 70 | added = diffResult.added; 71 | removed = diffResult.removed; 72 | } 73 | 74 | this._map.set(key, values); 75 | 76 | for (let value of removed) { 77 | this._reverseMap.delete(value); 78 | } 79 | for (let value of added) { 80 | this._reverseMap.set(value, key); 81 | } 82 | } 83 | 84 | public removeField(key: K) { 85 | let values = this._map.get(key); 86 | if (values) { 87 | for (let value of values) { 88 | this._reverseMap.delete(value); 89 | } 90 | } 91 | this._map.delete(key); 92 | } 93 | 94 | public clear() { 95 | this._map.clear(); 96 | this._reverseMap.clear(); 97 | } 98 | } -------------------------------------------------------------------------------- /server/src/utils/ObjectUpdater.ts: -------------------------------------------------------------------------------- 1 | export interface ObjectSchemaStore { 2 | version: number; 3 | defaultEmptyObject: any; 4 | upgrade: (oldObject: any) => any; 5 | } 6 | 7 | export type VersionedObject = T & { version: number }; 8 | 9 | export class ObjectUpdater { 10 | public objectSchemas: ObjectSchemaStore[] = []; 11 | 12 | constructor() { 13 | this.registerSchemas(); 14 | } 15 | 16 | /** 17 | * 注册schema 18 | * 继承的类可以重写这个方法,用于注册不同版本的schema 19 | */ 20 | protected registerSchemas(): void { 21 | 22 | } 23 | 24 | public addSchema(version: number, upgrade: (oldObject: any) => any, defaultEmptyObject: any = {}): void { 25 | let prevSchema = this.objectSchemas[this.objectSchemas.length - 1]; 26 | this.objectSchemas.push({ version, defaultEmptyObject, upgrade }); 27 | 28 | if (prevSchema.version > version) { // 如果添加的版本号比前一个版本号小,则重新排序 29 | this.objectSchemas.sort((a, b) => a.version - b.version); 30 | } 31 | } 32 | 33 | /** 34 | * 将对象升级到最新版本 35 | * @param object 36 | * @param version 37 | * @returns 38 | */ 39 | public updateObject(object: T): T { 40 | let currentObject = object; 41 | let version = (object as any).version || 0; 42 | for (let i = 0; i < this.objectSchemas.length; i++) { 43 | let schema = this.objectSchemas[i]; 44 | if (schema.version > version) { 45 | currentObject = schema.upgrade(currentObject); 46 | } 47 | } 48 | return currentObject; 49 | } 50 | 51 | /** 52 | * 获取默认的空对象 53 | * @returns 54 | */ 55 | public getDefaultEmptyObject(): any { 56 | const defaultEmptyObject = this.objectSchemas[this.objectSchemas.length - 1].defaultEmptyObject; 57 | if (typeof defaultEmptyObject === 'function') { 58 | return defaultEmptyObject(); 59 | } else { 60 | return defaultEmptyObject; 61 | } 62 | } 63 | 64 | public getCurrentVersion(): number { 65 | return this.objectSchemas[this.objectSchemas.length - 1].version; 66 | } 67 | } -------------------------------------------------------------------------------- /server/src/utils/PulsePlayList.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export class PulsePlayList { 4 | public mode: 'single' | 'sequence' | 'random' = 'single'; 5 | 6 | public pulseIds: string[] = []; 7 | public currentIndex = 0; 8 | 9 | public changeInterval = 0; 10 | 11 | public nextChangeTime = 0; 12 | 13 | public constructor(pulseIds: string[], mode: 'single' | 'sequence' | 'random' = 'single', interval?: number) { 14 | this.pulseIds = pulseIds; 15 | 16 | this.mode = mode; 17 | 18 | if (pulseIds.length <= 1) { 19 | mode = 'single'; 20 | } 21 | 22 | if (mode !== 'single') { 23 | this.changeInterval = interval || 60; 24 | this.nextChangeTime = Date.now() + this.changeInterval * 1000; 25 | } 26 | 27 | if (mode === 'random') { 28 | this.suffle(); 29 | } 30 | } 31 | 32 | public getCurrentPulseId(): string { 33 | if (this.mode === 'single') { 34 | return this.pulseIds[0]; 35 | } 36 | 37 | if (Date.now() > this.nextChangeTime) { 38 | this.nextChangeTime = Date.now() + this.changeInterval * 1000; 39 | this.currentIndex ++; 40 | 41 | if (this.currentIndex >= this.pulseIds.length) { // 播放完毕,重新开始 42 | this.currentIndex = 0; 43 | if (this.mode === 'random') { // 随机播放时重新洗牌 44 | this.suffle(); 45 | } 46 | } 47 | } 48 | 49 | return this.pulseIds[this.currentIndex]; 50 | } 51 | 52 | private suffle() { 53 | this.pulseIds = this.pulseIds.sort(() => Math.random() - 0.5); 54 | } 55 | } -------------------------------------------------------------------------------- /server/src/utils/WebSocketAsync.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | 3 | type BufferLike = 4 | | string 5 | | Buffer 6 | | DataView 7 | | number 8 | | ArrayBufferView 9 | | Uint8Array 10 | | ArrayBuffer 11 | | SharedArrayBuffer 12 | | readonly any[] 13 | | readonly number[] 14 | | { valueOf(): ArrayBuffer } 15 | | { valueOf(): SharedArrayBuffer } 16 | | { valueOf(): Uint8Array } 17 | | { valueOf(): readonly number[] } 18 | | { valueOf(): string } 19 | | { [Symbol.toPrimitive](hint: string): string }; 20 | 21 | export type WebSocketAsyncExtension = { 22 | sendAsync(data: BufferLike): Promise; 23 | sendAsync( 24 | data: BufferLike, 25 | options: { 26 | mask?: boolean | undefined; 27 | binary?: boolean | undefined; 28 | compress?: boolean | undefined; 29 | fin?: boolean | undefined; 30 | } 31 | ): void; 32 | pingAsync(data?: any, mask?: boolean): Promise; 33 | pongAsync(data?: any, mask?: boolean): Promise; 34 | } 35 | 36 | export type AsyncWebSocket = WebSocket & WebSocketAsyncExtension; 37 | 38 | export const wrapAsyncWebSocket = (ws: WebSocket): AsyncWebSocket => { 39 | const asyncWs = ws as WebSocket & WebSocketAsyncExtension; 40 | 41 | asyncWs.sendAsync = (...args: any[]) => { 42 | return new Promise((resolve, reject) => { 43 | const cb = (error?: Error) => { 44 | if (error) { 45 | reject(error); 46 | } else { 47 | resolve(); 48 | } 49 | }; 50 | 51 | if (args.length === 1) { 52 | ws.send(args[0], cb); 53 | } else if (args.length === 2) { 54 | ws.send(args[0], args[1], cb); 55 | } else { 56 | reject(new Error('Invalid arguments')); 57 | } 58 | }); 59 | }; 60 | 61 | asyncWs.pingAsync = (data?: any, mask?: boolean) => { 62 | return new Promise((resolve, reject) => { 63 | ws.ping(data, mask, (error) => { 64 | if (error) { 65 | reject(error); 66 | } else { 67 | resolve(); 68 | } 69 | }); 70 | }); 71 | }; 72 | 73 | asyncWs.pongAsync = (data?: any, mask?: boolean) => { 74 | return new Promise((resolve, reject) => { 75 | ws.pong(data, mask, (error) => { 76 | if (error) { 77 | reject(error); 78 | } else { 79 | resolve(); 80 | } 81 | }); 82 | }); 83 | }; 84 | 85 | return asyncWs; 86 | } -------------------------------------------------------------------------------- /server/src/utils/WebSocketRouter.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import WebSocket from 'ws'; 3 | import { pathToRegexp } from 'path-to-regexp'; 4 | 5 | export type WebSocketRouterCallback = (ws: WebSocket, req: IncomingMessage, routeParams: Record) => void; 6 | 7 | export type WebSocketRouteInfo = { 8 | url: string; 9 | regexp: RegExp & { keys: { name: string }[] }; 10 | callback: WebSocketRouterCallback; 11 | }; 12 | 13 | export type WebSocketRouteMatchResult = { 14 | route: WebSocketRouteInfo; 15 | params: Record; 16 | }; 17 | 18 | export class WebSocketRouter { 19 | public routes: WebSocketRouteInfo[]; // 保存路由信息 20 | 21 | constructor() { 22 | this.routes = []; 23 | } 24 | 25 | public get(url: string, callback: WebSocketRouterCallback) { 26 | const regexp = pathToRegexp(url); 27 | this.routes.push({ 28 | url, 29 | regexp, 30 | callback 31 | }); 32 | } 33 | 34 | public match(url: string): WebSocketRouteMatchResult | null { 35 | for (const route of this.routes) { 36 | if (route.regexp.test(url)) { 37 | // 提取路由参数 38 | let routeParams: Record = {}; 39 | const keys = route.regexp.keys; 40 | const matches = route.regexp.exec(url); 41 | if (matches) { 42 | for (let i = 1; i < matches.length; i++) { 43 | routeParams[keys[i - 1].name] = matches[i]; 44 | } 45 | } 46 | 47 | return { 48 | route, 49 | params: routeParams 50 | }; 51 | } 52 | } 53 | 54 | return null; 55 | } 56 | } -------------------------------------------------------------------------------- /server/src/utils/checkUpdate.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import got from 'got'; 3 | 4 | export type VersionInfo = { 5 | repo: string; 6 | version: string; 7 | description?: string; 8 | releaseFile: { 9 | [platform: string]: string; 10 | }, 11 | apiMirrors: { 12 | version: string; 13 | release: string; 14 | }[], 15 | } & Record; 16 | 17 | export function compareVersion(current: string, remote: string) { 18 | const v1s = current.split('.').map(Number); 19 | const v2s = remote.split('.').map(Number); 20 | 21 | let v1ns = ''; 22 | let v2ns = ''; 23 | 24 | for (let i = 0; i < Math.max(v1s.length, v2s.length); i++) { 25 | let chunk1 = v1s[i] === undefined ? '' : v1s[i].toString(); 26 | let chunk2 = v2s[i] === undefined ? '' : v2s[i].toString(); 27 | 28 | let maxLen = Math.max(chunk1.length, chunk2.length); 29 | chunk1 = chunk1.padStart(maxLen, '0'); 30 | chunk2 = chunk2.padStart(maxLen, '0'); 31 | 32 | v1ns += chunk1; 33 | v2ns += chunk2; 34 | } 35 | 36 | let v1n = parseInt(v1ns); 37 | let v2n = parseInt(v2ns); 38 | 39 | if (v2n > v1n) { 40 | return true; 41 | } else { 42 | return false; 43 | } 44 | } 45 | 46 | export type UpdateInfo = { 47 | downloadUrl: string; 48 | } & VersionInfo; 49 | 50 | export async function checkUpdate(): Promise { 51 | if (!fs.existsSync('version.json')) return false; 52 | try { 53 | const versionInfo: VersionInfo = JSON.parse(await fs.promises.readFile('version.json', 'utf8')); 54 | if (!versionInfo.repo || !versionInfo.version) return false; 55 | 56 | let apis = versionInfo.apiMirrors; 57 | 58 | for (const api of apis) { 59 | try { 60 | const res = await got(api.version.replace('{repo}', versionInfo.repo), { 61 | timeout: 5000, 62 | }).json(); 63 | 64 | if (res.version && compareVersion(versionInfo.version, res.version)) { 65 | let releaseFile = ''; 66 | if (process.platform.startsWith('win')) { 67 | releaseFile = res.releaseFile.windows; 68 | } else if (process.platform.startsWith('linux')) { 69 | releaseFile = res.releaseFile.linux; 70 | } else if (process.platform.startsWith('darwin')) { 71 | releaseFile = res.releaseFile.mac; 72 | } 73 | 74 | console.log(`检测到新版本:${res.version},更新内容:\n${res.description}\n`); 75 | 76 | let downloadUrl = 'https://github.com/' + res.repo + '/releases/'; 77 | if (releaseFile) { 78 | downloadUrl = api.release.replace('{repo}', res.repo).replace('{version}', res.version).replace('{file}', releaseFile); 79 | console.log(`下载地址:${api.release.replace('{repo}', res.repo).replace('{version}', res.version).replace('{file}', releaseFile)}`); 80 | } 81 | 82 | return { 83 | downloadUrl, 84 | ...versionInfo, 85 | }; 86 | } 87 | } catch (e: any) { 88 | 89 | } 90 | } 91 | } catch (e: any) { 92 | console.error('Failed to check update:', e); 93 | } 94 | 95 | return false; 96 | } -------------------------------------------------------------------------------- /server/src/utils/latencyLogger.ts: -------------------------------------------------------------------------------- 1 | export class LatencyLogger { 2 | private taskName: string | null = null; 3 | private startTime: number | null = null; 4 | private previousTime: number | null = null; 5 | 6 | constructor() { } 7 | 8 | public start(taskName: string) { 9 | this.taskName = taskName; 10 | this.startTime = Date.now(); 11 | } 12 | 13 | public finish() { 14 | this.taskName = null; 15 | this.startTime = null; 16 | } 17 | 18 | public log(method: string = '') { 19 | return; // Disable latency logger 20 | 21 | // if (!this.taskName || !this.startTime) { 22 | // return; 23 | // } 24 | 25 | // const endTime = Date.now(); 26 | // const fullLatency = endTime - this.startTime; 27 | // const latency = this.previousTime ? endTime - this.previousTime : 0; 28 | 29 | // this.previousTime = endTime; 30 | 31 | // console.log(`Latency trace: [${this.taskName}/${method}] - Latency: ${latency}ms (${fullLatency}ms)`); 32 | } 33 | } -------------------------------------------------------------------------------- /server/src/utils/onExit.ts: -------------------------------------------------------------------------------- 1 | export class OnExit { 2 | private static callbacks: Array<() => void | Promise> = []; 3 | 4 | public static init() { 5 | process.on('SIGINT', async () => { 6 | await OnExit.run(); 7 | }); 8 | 9 | process.on('SIGTERM', async () => { 10 | await OnExit.run(); 11 | }); 12 | 13 | process.on('uncaughtException', async (err) => { 14 | console.error('Uncaught exception:', err); 15 | }); 16 | } 17 | 18 | public static register(callback: () => void) { 19 | OnExit.callbacks.push(callback); 20 | } 21 | 22 | public static async run() { 23 | for (const callback of OnExit.callbacks) { 24 | await callback(); 25 | } 26 | 27 | process.exit(0); 28 | } 29 | } 30 | 31 | OnExit.init(); -------------------------------------------------------------------------------- /server/src/utils/task.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { asleep } from "./utils"; 3 | import { LatencyLogger } from "./latencyLogger"; 4 | 5 | export class TaskAbortedError extends Error { 6 | constructor() { 7 | super('Task aborted'); 8 | } 9 | } 10 | 11 | export interface TaskOptions { 12 | minInterval?: number; 13 | autoRestart?: boolean; 14 | } 15 | 16 | export type TaskEventsHandler = { 17 | (event: 'error', listener: (error: any) => void): void; 18 | }; 19 | 20 | export function createHarvest(abortController: AbortController): () => void { 21 | return () => { 22 | if (abortController.signal.aborted) { 23 | throw new TaskAbortedError(); 24 | } 25 | }; 26 | } 27 | 28 | /** 29 | * @param abortController 30 | * @param harvest This function will break the task if the task is aborted. 31 | */ 32 | export type TaskHandler = (abortController: AbortController, harvest: () => void, round: number) => Promise; 33 | 34 | export class Task { 35 | public minInterval: number; 36 | public events: EventEmitter = new EventEmitter(); 37 | public running: boolean = false; 38 | public autoRestart: boolean = true; 39 | 40 | private isRestarting: boolean = false; 41 | private latencyLogger = new LatencyLogger(); 42 | 43 | private handler: TaskHandler; 44 | 45 | private abortController: AbortController = new AbortController(); 46 | private waitForStop: Promise | null = null; 47 | private stopResolve: (() => void) | null = null; 48 | 49 | constructor(handler: TaskHandler, options?: TaskOptions) { 50 | this.handler = handler; 51 | 52 | this.minInterval = options?.minInterval ?? 100; 53 | this.autoRestart = options?.autoRestart ?? true; 54 | 55 | this.run().catch((error) => this.handleError(error)); 56 | } 57 | 58 | public async run(): Promise { 59 | if (this.running) { 60 | return; 61 | } 62 | 63 | let harvest = createHarvest(this.abortController); 64 | 65 | this.running = true; 66 | let round = 0; 67 | while (this.running) { 68 | let startTime = Date.now(); 69 | try { 70 | await this.handler(this.abortController, harvest, round); 71 | harvest(); // 确保触发TaskAborted 72 | } catch (error) { 73 | if (error instanceof TaskAbortedError) { // Task aborted 74 | if (!this.isRestarting) { // 如果不是正在重启则停止任务 75 | break; 76 | } else { 77 | this.latencyLogger.finish(); 78 | 79 | this.isRestarting = false; 80 | // 重设abortController 81 | this.abortController = new AbortController(); 82 | harvest = createHarvest(this.abortController); 83 | } 84 | } else { 85 | throw error; 86 | } 87 | } 88 | let endTime = Date.now(); 89 | 90 | const sleepTime = Math.max(0, this.minInterval - (endTime - startTime)); 91 | await asleep(sleepTime); 92 | 93 | round ++; 94 | } 95 | 96 | if (this.stopResolve) { 97 | this.stopResolve(); 98 | } 99 | } 100 | 101 | public handleError(error: Error) { 102 | this.events.emit('error', error); 103 | if (this.autoRestart) { 104 | this.run().catch((error) => this.handleError(error)); 105 | } else { 106 | this.running = false; 107 | } 108 | } 109 | 110 | public async stop(): Promise { 111 | this.waitForStop = new Promise((resolve) => { 112 | this.stopResolve = resolve; 113 | this.running = false; 114 | }); 115 | 116 | await this.waitForStop; 117 | } 118 | 119 | public restart(): void { 120 | if (!this.running) { 121 | return; 122 | } 123 | 124 | this.latencyLogger.start('restartTask'); 125 | this.isRestarting = true; 126 | this.abortController.abort(); 127 | } 128 | 129 | public async abort(): Promise { 130 | const stopPromise = this.stop(); 131 | this.abortController.abort(); 132 | await stopPromise; 133 | } 134 | 135 | public on: TaskEventsHandler = this.events.on.bind(this.events); 136 | public once: TaskEventsHandler = this.events.once.bind(this.events); 137 | public off = this.events.off.bind(this.events); 138 | } -------------------------------------------------------------------------------- /server/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { networkInterfaces } from "os"; 2 | import { exec } from "child_process"; 3 | 4 | export const asleep = (ms: number, abortController?: AbortController) => { 5 | if (abortController) { 6 | const promise = new Promise((resolve) => { 7 | let resolved = false; 8 | 9 | const onAbort = () => { 10 | if (!resolved) { 11 | clearTimeout(tid); 12 | resolve(false); 13 | } 14 | }; 15 | 16 | abortController.signal.addEventListener('abort', onAbort, { once: true }); 17 | 18 | const tid = setTimeout(() => { 19 | resolve(true); 20 | resolved = true; 21 | abortController.signal.removeEventListener('abort', onAbort); 22 | }, ms); 23 | }); 24 | 25 | return promise; 26 | } else { 27 | return new Promise((resolve) => setTimeout(() => resolve(true), ms)); 28 | } 29 | }; 30 | 31 | export const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; 32 | 33 | export const openBrowser = (url: string) => { 34 | const command = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open'; 35 | exec(`${command} ${url}`); 36 | } 37 | 38 | export const simpleObjDiff = (obj1: any, obj2: any) => { 39 | if (!obj1 || !obj2) { 40 | return []; 41 | } 42 | 43 | let differentKeys: string[] = []; 44 | for (let key in obj1) { 45 | const a = obj1[key]; 46 | const b = obj2[key]; 47 | 48 | if (typeof a !== typeof b) { 49 | differentKeys.push(key); 50 | } else if (Array.isArray(a) && Array.isArray(b)) { 51 | if (a.length !== b.length) { 52 | differentKeys.push(key); 53 | } else { 54 | for (let i = 0; i < a.length; i++) { 55 | if (a[i] !== b[i]) { 56 | differentKeys.push(key); 57 | break; 58 | } 59 | } 60 | } 61 | } else if (typeof a === 'object' && typeof b === 'object') { 62 | if (JSON.stringify(a) !== JSON.stringify(b)) { 63 | differentKeys.push(key); 64 | } 65 | } else if (obj1[key] !== obj2[key]) { 66 | differentKeys.push(key); 67 | } 68 | } 69 | 70 | if (differentKeys.length > 0) { 71 | return differentKeys; 72 | } else { 73 | return false; 74 | } 75 | } 76 | 77 | export function simpleArrayDiff(arr1: T[], arr2: T[]): { added: T[], removed: T[] } { 78 | const set1 = new Set(arr1); 79 | const set2 = new Set(arr2); 80 | 81 | const added = arr2.filter(item => !set1.has(item)); 82 | const removed = arr1.filter(item => !set2.has(item)); 83 | 84 | return { added, removed }; 85 | } 86 | 87 | export class LocalIPAddress { 88 | private static ipAddrList?: string[]; 89 | 90 | public static getIPAddrList(): string[] { 91 | if (!this.ipAddrList) { 92 | this.ipAddrList = []; 93 | 94 | const interfaces = networkInterfaces(); 95 | Object.keys(interfaces).forEach((name) => { 96 | if (name.startsWith('lo')) { // ignore loopback interface 97 | return; 98 | } 99 | 100 | interfaces[name]?.forEach((info) => { 101 | if (info.family === 'IPv4') { 102 | this.ipAddrList!.push(info.address); 103 | } 104 | }); 105 | }); 106 | } 107 | 108 | return this.ipAddrList!; 109 | } 110 | } -------------------------------------------------------------------------------- /server/src/utils/validator.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { ValidateFunction } from "ajv"; 2 | 3 | class TypeValidator { 4 | public ajv = new Ajv(); 5 | 6 | public validators: Map = new Map(); 7 | 8 | constructor() { } 9 | 10 | public async initialize() { 11 | this.validators.set('MainGameConfig', this.ajv.compile(await import('../schemas/MainGameConfig.json'))); 12 | this.validators.set('GameStrengthConfig', this.ajv.compile(await import('../schemas/GameStrengthConfig.json'))); 13 | this.validators.set('GameCustomPulseConfig', this.ajv.compile(await import('../schemas/GameCustomPulseConfig.json'))); 14 | this.validators.set('MainConfigType', this.ajv.compile(await import('../schemas/MainConfigType.json'))); 15 | this.validators.set('CustomSkinManifest', this.ajv.compile(await import('../schemas/CustomSkinManifest.json'))); 16 | } 17 | 18 | public validate(type: string, data: any): boolean { 19 | const validator = this.validators.get(type); 20 | if (!validator) { 21 | throw new Error(`Validator for type ${type} not found.`); 22 | } 23 | 24 | return validator(data); 25 | } 26 | 27 | public get validateMainGameConfig() { 28 | return this.validators.get('MainGameConfig')!; 29 | } 30 | 31 | public get validateGameStrengthConfig() { 32 | return this.validators.get('GameStrengthConfig')!; 33 | } 34 | 35 | public get validateGameCustomPulseConfig() { 36 | return this.validators.get('GameCustomPulseConfig')!; 37 | } 38 | 39 | public get validateMainConfigType() { 40 | return this.validators.get('MainConfigType')!; 41 | } 42 | 43 | public get validateCustomSkinManifest() { 44 | return this.validators.get('CustomSkinManifest')!; 45 | } 46 | } 47 | 48 | export const validator = new TypeValidator(); -------------------------------------------------------------------------------- /server/src/utils/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { WebSocketRouter } from './WebSocketRouter'; 3 | 4 | export const setupWebSocketServer = (wsServer: WebSocket.Server, router: WebSocketRouter) => { 5 | wsServer.on('connection', (ws, req) => { 6 | const url = req.url; 7 | if (url) { 8 | const result = router.match(url); 9 | if (result) { 10 | result.route.callback(ws, req, result.params); 11 | } else { 12 | ws.close(); 13 | } 14 | } 15 | }); 16 | 17 | return wsServer; 18 | }; -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "repo": "hyperzlib/DG-Lab-Coyote-Game-Hub", 3 | "version": "1.4.0", 4 | "description": "1. 增加蓝牙重连功能\n2. 增加链接码显示\n3. 优化部分文本", 5 | "releaseFile": { 6 | "windows": "coyote-game-hub-windows-amd64-dist.zip", 7 | "linux": "coyote-game-hub-nodejs-server.zip", 8 | "mac": "coyote-game-hub-nodejs-server.zip" 9 | }, 10 | "apiMirrors": [ 11 | { 12 | "version": "https://raw.githubusercontent.com/{repo}/master/version.json", 13 | "release": "https://github.com/{repo}/releases/download/v{version}/{file}" 14 | }, 15 | { 16 | "version": "https://mirror.ghproxy.com/https://raw.githubusercontent.com/{repo}/master/version.json", 17 | "release": "https://mirror.ghproxy.com/https://github.com/{repo}/releases/download/v{version}/{file}" 18 | } 19 | ] 20 | } 21 | --------------------------------------------------------------------------------