-
-
-
请稍候...
-
-
-
-
资源即将开始加载
-
HTML5魔塔游戏平台,享受更多魔塔游戏:
https://h5mota.com/
-
-
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
diff --git a/mota.config.ts b/mota.config.ts
deleted file mode 100644
index fdfa1a1..0000000
--- a/mota.config.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-interface MotaConfig {
- name: string;
- /** 资源分组打包信息 */
- resourceZip?: string[][];
- resourceName?: string;
-}
-
-function defineConfig(config: MotaConfig): MotaConfig {
- return config;
-}
-
-export default defineConfig({
- // 这里修改塔的name,请保持与全塔属性的完全相同,否则发布之后可能无法进行游玩
- name: 'HumanBreak',
- resourceName: 'HumanBreakRes'
-});
diff --git a/package.json b/package.json
index 000963d..ba976b3 100644
--- a/package.json
+++ b/package.json
@@ -1,63 +1,94 @@
{
- "name": "mota-ts",
- "private": true,
- "version": "1.0.0",
- "type": "module",
- "scripts": {
- "dev": "ts-node-esm script/dev.ts",
- "build": "vue-tsc && vite build && ts-node-esm script/build.ts 0 1 1",
- "build-gh": "vue-tsc && vite build --base=/HumanBreak/ && ts-node-esm script/build.ts 1",
- "build-local": "vue-tsc && vite build --base=/ && ts-node-esm script/build.ts 1",
- "preview": "vite preview",
- "update": "ts-node-esm script/update.ts",
- "declare": "ts-node-esm script/declare.ts",
- "type": "vue-tsc --noEmit",
- "lines": "ts-node-esm script/lines.ts"
- },
- "dependencies": {
- "@ant-design/icons-vue": "^6.1.0",
- "ant-design-vue": "^3.2.20",
- "axios": "^1.4.0",
- "chart.js": "^4.3.0",
- "jszip": "^3.10.1",
- "lodash-es": "^4.17.21",
- "lz-string": "^1.5.0",
- "mutate-animate": "^1.1.1",
- "three": "^0.149.0",
- "vue": "^3.3.4"
- },
- "devDependencies": {
- "@babel/cli": "^7.21.5",
- "@babel/core": "^7.21.8",
- "@babel/preset-env": "^7.21.5",
- "@rollup/plugin-babel": "^6.0.3",
- "@rollup/plugin-commonjs": "^25.0.0",
- "@rollup/plugin-node-resolve": "^15.0.2",
- "@rollup/plugin-replace": "^5.0.2",
- "@rollup/plugin-terser": "^0.4.3",
- "@rollup/plugin-typescript": "^11.1.1",
- "@types/babel__core": "^7.20.0",
- "@types/fontmin": "^0.9.0",
- "@types/fs-extra": "^9.0.13",
- "@types/lodash-es": "^4.17.7",
- "@types/node": "^18.16.14",
- "@types/ws": "^8.5.4",
- "@vitejs/plugin-legacy": "^4.0.3",
- "@vitejs/plugin-vue": "^4.2.3",
- "@vitejs/plugin-vue-jsx": "^3.0.1",
- "chokidar": "^3.5.3",
- "compressing": "^1.9.0",
- "fontmin": "^0.9.9",
- "form-data": "^4.0.0",
- "fs-extra": "^10.1.0",
- "less": "^4.1.3",
- "rollup": "^3.23.0",
- "terser": "^5.17.6",
- "ts-node": "^10.9.1",
- "typescript": "^4.9.5",
- "unplugin-vue-components": "^0.22.12",
- "vite": "^4.3.8",
- "vue-tsc": "^1.6.5",
- "ws": "^8.13.0"
- }
-}
\ No newline at end of file
+ "name": "mota-ts",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx script/dev.ts",
+ "preview": "vite preview",
+ "declare": "tsx script/declare.ts",
+ "type": "vue-tsc --noEmit",
+ "lines": "tsx script/lines.ts packages packages-user",
+ "build:packages": "vue-tsc --noEmit && tsx script/build-packages.ts",
+ "build:game": "tsx script/declare.ts && vue-tsc --noEmit && tsx script/build-game.ts",
+ "build:lib": "vue-tsc --noEmit && tsx script/build-lib.ts",
+ "docs:dev": "concurrently -k -n SIDEBAR,VITEPRESS -c blue,green \"tsx docs/.vitepress/api.ts\" \"vitepress dev docs\"",
+ "docs:build": "vitepress build docs",
+ "docs:preview": "vitepress preview docs"
+ },
+ "dependencies": {
+ "@ant-design/icons-vue": "^6.1.0",
+ "@wasm-audio-decoders/ogg-vorbis": "^0.1.16",
+ "anon-tokyo": "0.0.0-alpha.0",
+ "ant-design-vue": "^3.2.20",
+ "axios": "^1.8.4",
+ "chart.js": "^4.4.8",
+ "codec-parser": "^2.5.0",
+ "eventemitter3": "^5.0.1",
+ "gl-matrix": "^3.4.3",
+ "jszip": "^3.10.1",
+ "lodash-es": "^4.17.21",
+ "lz-string": "^1.5.0",
+ "maxrects-packer": "^2.7.3",
+ "mutate-animate": "^1.4.2",
+ "ogg-opus-decoder": "^1.6.14",
+ "opus-decoder": "^0.7.7",
+ "vue": "^3.5.20"
+ },
+ "devDependencies": {
+ "@babel/cli": "^7.26.4",
+ "@babel/core": "^7.26.10",
+ "@babel/preset-env": "^7.26.9",
+ "@eslint/js": "^9.24.0",
+ "@rollup/plugin-babel": "^6.0.4",
+ "@rollup/plugin-commonjs": "^25.0.8",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^15.3.1",
+ "@rollup/plugin-replace": "^5.0.7",
+ "@rollup/plugin-terser": "^0.4.4",
+ "@rollup/plugin-typescript": "^11.1.6",
+ "@types/archiver": "^6.0.3",
+ "@types/babel__core": "^7.20.5",
+ "@types/express": "^5.0.3",
+ "@types/fontmin": "^0.9.5",
+ "@types/fs-extra": "^11.0.4",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^22.18.0",
+ "@types/ws": "^8.18.0",
+ "@vitejs/plugin-legacy": "^7.2.1",
+ "@vitejs/plugin-vue": "^6.0.1",
+ "@vitejs/plugin-vue-jsx": "^5.1.1",
+ "archiver": "^7.0.1",
+ "chokidar": "^3.6.0",
+ "compressing": "^1.10.1",
+ "concurrently": "^9.1.2",
+ "eslint": "^9.22.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-vue": "^9.33.0",
+ "express": "^5.1.0",
+ "fontmin": "^2.0.3",
+ "form-data": "^4.0.2",
+ "fs-extra": "^11.3.1",
+ "glob": "^11.0.1",
+ "globals": "^15.15.0",
+ "less": "^4.2.2",
+ "madge": "^8.0.0",
+ "markdown-it-mathjax3": "^4.3.2",
+ "mermaid": "^11.5.0",
+ "postcss-preset-env": "^9.6.0",
+ "prettier": "^3.6.2",
+ "rollup": "^4.49.0",
+ "terser": "^5.39.0",
+ "tsx": "^4.20.5",
+ "typescript": "^5.9.2",
+ "typescript-eslint": "^8.27.0",
+ "vite": "^7.0.0",
+ "vite-plugin-dts": "^4.5.4",
+ "vitepress": "^1.6.3",
+ "vitepress-plugin-mermaid": "^2.0.17",
+ "vue-tsc": "^2.2.8",
+ "ws": "^8.18.1"
+ }
+}
diff --git a/packages-user/client-modules/package.json b/packages-user/client-modules/package.json
new file mode 100644
index 0000000..ffd1f88
--- /dev/null
+++ b/packages-user/client-modules/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@user/client-modules",
+ "dependencies": {
+ "@motajs/client-base": "workspace:*",
+ "@motajs/common": "workspace:*",
+ "@motajs/render": "workspace:*",
+ "@motajs/render-assets": "workspace:*",
+ "@motajs/render-core": "workspace:*",
+ "@motajs/legacy-common": "workspace:*",
+ "@motajs/legacy-ui": "workspace:*",
+ "@motajs/types": "workspace:*",
+ "@motajs/system-action": "workspace:*",
+ "@motajs/system-ui": "workspace:*",
+ "@user/data-base": "workspace:*",
+ "@user/data-state": "workspace:*",
+ "@user/legacy-plugin-data": "workspace:*"
+ }
+}
diff --git a/packages-user/client-modules/src/action/hotkey.ts b/packages-user/client-modules/src/action/hotkey.ts
new file mode 100644
index 0000000..a52844d
--- /dev/null
+++ b/packages-user/client-modules/src/action/hotkey.ts
@@ -0,0 +1,645 @@
+import { KeyCode } from '@motajs/client-base';
+import { gameKey, HotkeyJSON } from '@motajs/system-action';
+import { hovered, mainUi, tip, openDanmakuPoster } from '@motajs/legacy-ui';
+import { GameStorage } from '@motajs/legacy-system';
+
+export const mainScope = Symbol.for('@key_main');
+
+// todo: 读取上一个手动存档,存档至下一个存档栏
+// ----- Register
+gameKey
+ //#region 游戏按键
+ .group('game', '游戏按键')
+ .register({
+ id: 'moveUp',
+ name: '上移',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: 'moveDown',
+ name: '下移',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: 'moveLeft',
+ name: '左移',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: 'moveRight',
+ name: '右移',
+ defaults: KeyCode.RightArrow
+ })
+ //#region ui界面
+ .group('ui', 'ui界面')
+ .register({
+ id: 'book',
+ name: '怪物手册',
+ defaults: KeyCode.KeyX
+ })
+ .register({
+ id: 'save',
+ name: '存档界面',
+ defaults: KeyCode.KeyS
+ })
+ .register({
+ id: 'load',
+ name: '读档界面',
+ defaults: KeyCode.KeyD
+ })
+ .register({
+ id: 'toolbox',
+ name: '道具栏',
+ defaults: KeyCode.KeyT
+ })
+ .register({
+ id: 'equipbox',
+ name: '装备栏',
+ defaults: KeyCode.KeyQ
+ })
+ .register({
+ id: 'fly',
+ name: '楼层传送',
+ defaults: KeyCode.KeyG
+ })
+ .register({
+ id: 'menu',
+ name: '菜单',
+ defaults: KeyCode.Escape
+ })
+ .register({
+ id: 'replay',
+ name: '录像回放',
+ defaults: KeyCode.KeyR
+ })
+ .register({
+ id: 'shop',
+ name: '快捷商店',
+ defaults: KeyCode.KeyV
+ })
+ .register({
+ id: 'statistics',
+ name: '数据统计',
+ defaults: KeyCode.KeyB
+ })
+ .register({
+ id: 'viewMap_1',
+ name: '浏览地图_1',
+ defaults: KeyCode.PageUp
+ })
+ .register({
+ id: 'viewMap_2',
+ name: '浏览地图_2',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: 'skillTree',
+ name: '技能树',
+ defaults: KeyCode.KeyJ
+ })
+ .register({
+ id: 'desc',
+ name: '百科全书',
+ defaults: KeyCode.KeyH
+ })
+ //#region 功能按键
+ .group('function', '功能按键')
+ .register({
+ id: 'undo_1',
+ name: '回退_1',
+ defaults: KeyCode.KeyA
+ })
+ .register({
+ id: 'undo_2',
+ name: '回退_2',
+ defaults: KeyCode.Digit5
+ })
+ .register({
+ id: 'redo_1',
+ name: '恢复_1',
+ defaults: KeyCode.KeyW
+ })
+ .register({
+ id: 'redo_2',
+ name: '恢复_2',
+ defaults: KeyCode.Digit6
+ })
+ .register({
+ id: 'turn',
+ name: '勇士转向',
+ defaults: KeyCode.KeyZ
+ })
+ .register({
+ id: 'getNext_1',
+ name: '轻按_1',
+ defaults: KeyCode.Space
+ })
+ .register({
+ id: 'getNext_2',
+ name: '轻按_2',
+ defaults: KeyCode.Digit7
+ })
+ .register({
+ id: 'mark',
+ name: '标记怪物',
+ defaults: KeyCode.KeyM
+ })
+ .register({
+ id: 'special',
+ name: '鼠标位置怪物属性',
+ defaults: KeyCode.KeyE
+ })
+ .register({
+ id: 'critical',
+ name: '鼠标位置怪物临界',
+ defaults: KeyCode.KeyC
+ })
+ .register({
+ id: 'danmaku',
+ name: '发送弹幕',
+ defaults: KeyCode.KeyA,
+ ctrl: true
+ })
+ .register({
+ id: 'quickEquip_1',
+ name: '切换/保存套装_1',
+ defaults: KeyCode.Digit1,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_2',
+ name: '切换/保存套装_2',
+ defaults: KeyCode.Digit2,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_3',
+ name: '切换/保存套装_3',
+ defaults: KeyCode.Digit3,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_4',
+ name: '切换/保存套装_4',
+ defaults: KeyCode.Digit4,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_5',
+ name: '切换/保存套装_5',
+ defaults: KeyCode.Digit5,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_6',
+ name: '切换/保存套装_6',
+ defaults: KeyCode.Digit6,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_7',
+ name: '切换/保存套装_7',
+ defaults: KeyCode.Digit7,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_8',
+ name: '切换/保存套装_8',
+ defaults: KeyCode.Digit8,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_9',
+ name: '切换/保存套装_9',
+ defaults: KeyCode.Digit9,
+ alt: true
+ })
+ .register({
+ id: 'quickEquip_0',
+ name: '切换/保存套装_0',
+ defaults: KeyCode.Digit0,
+ alt: true
+ })
+ //#region 技能按键
+ .group('skill', '技能按键')
+ .register({
+ id: 'skill1',
+ name: '断灭之刃',
+ defaults: KeyCode.Digit1
+ })
+ .register({
+ id: 'skill2',
+ name: '跳跃',
+ defaults: KeyCode.Digit2
+ })
+ .register({
+ id: 'skill3',
+ name: '铸剑为盾',
+ defaults: KeyCode.Digit3
+ })
+ //#region 系统按键
+ .group('system', '系统按键')
+ .register({
+ id: 'restart',
+ name: '回到开始界面',
+ defaults: KeyCode.KeyN
+ })
+ .register({
+ id: 'comment',
+ name: '评论区',
+ defaults: KeyCode.KeyP
+ })
+ .register({
+ id: 'debug',
+ name: '调试模式',
+ defaults: KeyCode.F8
+ })
+ //#region 通用按键
+ .group('general', '通用按键')
+ .register({
+ id: 'exit_1',
+ name: '退出ui界面_1',
+ defaults: KeyCode.KeyX
+ })
+ .register({
+ id: 'exit_2',
+ name: '退出ui界面_2',
+ defaults: KeyCode.Escape
+ })
+ .register({
+ id: 'confirm_1',
+ name: '确认_1',
+ defaults: KeyCode.Enter
+ })
+ .register({
+ id: 'confirm_2',
+ name: '确认_2',
+ defaults: KeyCode.Space
+ })
+ .register({
+ id: 'confirm_3',
+ name: '确认_3',
+ defaults: KeyCode.KeyC
+ })
+ //#region 开始界面
+ .group('@ui_start', '开始界面')
+ .register({
+ id: '@start_up',
+ name: '上移光标',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@start_down',
+ name: '下移光标',
+ defaults: KeyCode.DownArrow
+ })
+ //#region 怪物手册
+ .group('@ui_book', '怪物手册')
+ .register({
+ id: '@book_up',
+ name: '上移光标',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@book_down',
+ name: '下移光标',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@book_pageDown_1',
+ name: '下移5个怪物_1',
+ defaults: KeyCode.RightArrow
+ })
+ .register({
+ id: '@book_pageDown_2',
+ name: '下移5个怪物_2',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: '@book_pageUp_1',
+ name: '上移5个怪物_1',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: '@book_pageUp_2',
+ name: '上移5个怪物_2',
+ defaults: KeyCode.PageUp
+ })
+ //#region 道具栏
+ .group('@ui_toolbox', '道具栏')
+ .register({
+ id: '@toolbox_right',
+ name: '光标右移',
+ defaults: KeyCode.RightArrow
+ })
+ .register({
+ id: '@toolbox_left',
+ name: '光标左移',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: '@toolbox_up',
+ name: '光标上移',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@toolbox_down',
+ name: '光标下移',
+ defaults: KeyCode.DownArrow
+ })
+ //#region 商店
+ .group('@ui_shop', '商店')
+ .register({
+ id: '@shop_up',
+ name: '上移光标',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@shop_down',
+ name: '下移光标',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@shop_add',
+ name: '增加购买量',
+ defaults: KeyCode.RightArrow
+ })
+ .register({
+ id: '@shop_min',
+ name: '减少购买量',
+ defaults: KeyCode.LeftArrow
+ })
+ //#region 楼层传送
+ .group('@ui_fly', '楼层传送')
+ .register({
+ id: '@fly_left',
+ name: '左移地图',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: '@fly_right',
+ name: '右移地图',
+ defaults: KeyCode.RightArrow
+ })
+ .register({
+ id: '@fly_up',
+ name: '上移地图',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@fly_down',
+ name: '下移地图',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@fly_last',
+ name: '上一张地图',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: '@fly_next',
+ name: '下一张地图',
+ defaults: KeyCode.PageUp
+ })
+ //#region 传统楼传
+ .group('@ui_fly_tradition', '楼层传送-传统按键')
+ .register({
+ id: '@fly_down_t',
+ name: '上一张地图',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@fly_up_t',
+ name: '下一张地图',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@fly_left_t_1',
+ name: '前10张地图_1',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: '@fly_left_t_2',
+ name: '前10张地图_2',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: '@fly_right_t_1',
+ name: '后10张地图_1',
+ defaults: KeyCode.RightArrow
+ })
+ .register({
+ id: '@fly_right_t_2',
+ name: '后10张地图_2',
+ defaults: KeyCode.PageUp
+ })
+ // #region 存档界面
+ .group('@ui_save', '存档界面')
+ .register({
+ id: '@save_pageUp',
+ name: '向后翻页',
+ defaults: KeyCode.PageUp
+ })
+ .register({
+ id: '@save_pageDown',
+ name: '向前翻页',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: '@save_up',
+ name: '选择框向上',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@save_down',
+ name: '选择框向下',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@save_left',
+ name: '选择框向左',
+ defaults: KeyCode.LeftArrow
+ })
+ .register({
+ id: '@save_right',
+ name: '选择框向右',
+ defaults: KeyCode.RightArrow
+ })
+ //#region 浏览地图
+ .group('@ui_viewMap', '浏览地图')
+ .register({
+ id: '@viewMap_up_1',
+ name: '下一层地图_1',
+ defaults: KeyCode.UpArrow
+ })
+ .register({
+ id: '@viewMap_up_2',
+ name: '下一层地图_2',
+ defaults: KeyCode.PageUp
+ })
+ .register({
+ id: '@viewMap_down_1',
+ name: '上一层地图_1',
+ defaults: KeyCode.DownArrow
+ })
+ .register({
+ id: '@viewMap_down_2',
+ name: '上一层地图_2',
+ defaults: KeyCode.PageDown
+ })
+ .register({
+ id: '@viewMap_up_ten',
+ name: '下十层地图',
+ defaults: KeyCode.UpArrow,
+ ctrl: true
+ })
+ .register({
+ id: '@viewMap_down_ten',
+ name: '上十层地图',
+ defaults: KeyCode.DownArrow,
+ ctrl: true
+ })
+ .register({
+ id: '@viewMap_book',
+ name: '怪物手册',
+ defaults: KeyCode.KeyX
+ })
+ .register({
+ id: '@viewMap_fly',
+ name: '传送至',
+ defaults: KeyCode.KeyG
+ })
+ .register({
+ id: '@viewMap_reset',
+ name: '重置视角',
+ defaults: KeyCode.KeyR
+ });
+// #endregion
+
+gameKey.enable();
+gameKey.use(mainScope);
+
+//#region 按键实现
+
+gameKey
+ .when(
+ () =>
+ !core.status.lockControl && !core.isMoving() && !core.isReplaying()
+ )
+ .realize('book', () => {
+ core.openBook(true);
+ })
+ .realize('toolbox', () => {
+ core.openToolbox(true);
+ })
+ .realize('equipbox', () => {
+ core.openEquipbox(true);
+ })
+ .realize('fly', () => {
+ core.useFly(true);
+ })
+ .realize('shop', () => {
+ core.openQuickShop(true);
+ })
+ .realize('skillTree', () => {
+ core.useItem('skill1', true);
+ })
+ .realize('desc', () => {
+ core.useItem('I560', true);
+ })
+ .realize('undo', () => {
+ core.doSL('autoSave', 'load');
+ })
+ .realize('redo', () => {
+ core.doSL('autoSave', 'reload');
+ })
+ .realize('turn', () => {
+ core.turnHero();
+ })
+ .realize('getNext', () => {
+ core.getNextItem();
+ })
+ .realize('mark', () => {
+ const cls = hovered?.event.cls;
+ if (cls === 'enemys' || cls === 'enemy48') {
+ // const id = hovered!.event.id as EnemyIds;
+ // if (hasMarkedEnemy(id)) unmarkEnemy(id);
+ // else markEnemy(id);
+ }
+ })
+ .realize('special', () => {
+ if (hovered) {
+ const { x, y } = hovered;
+ const enemy = core.status.thisMap.enemy.get(x, y);
+ if (enemy) mainUi.open('fixedDetail', { panel: 'special' });
+ }
+ })
+ .realize('critical', () => {
+ if (hovered) {
+ const { x, y } = hovered;
+ const enemy = core.status.thisMap.enemy.get(x, y);
+ if (enemy) mainUi.open('fixedDetail', { panel: 'critical' });
+ }
+ })
+ .realize('danmaku', () => {
+ openDanmakuPoster();
+ })
+ .realize('restart', () => {
+ core.confirmRestart();
+ })
+ .realize('comment', () => {
+ core.actions._clickGameInfo_openComments();
+ })
+ .realize('skill1', () => {
+ const HeroSkill = Mota.require('@user/data-state').HeroSkill;
+ if (!HeroSkill.learnedSkill(HeroSkill.Blade)) return;
+ if (HeroSkill.getAutoSkill()) {
+ tip('error', '已开启自动切换技能!');
+ return;
+ }
+ core.playSound('光标移动');
+ HeroSkill.toggleSkill(HeroSkill.Blade);
+ core.status.route.push('useSkill:Blade');
+ core.updateStatusBar();
+ })
+ .realize('skill2', () => {
+ const HeroSkill = Mota.require('@user/data-state').HeroSkill;
+ if (
+ !flags.onChase &&
+ !core.status.floorId.startsWith('tower') &&
+ HeroSkill.learnedSkill(HeroSkill.Jump)
+ ) {
+ Mota.require('@user/legacy-plugin-data').jumpSkill();
+ core.status.route.push('useSkill:Jump');
+ } else {
+ if (core.hasItem('pickaxe')) {
+ core.useItem('pickaxe');
+ }
+ }
+ })
+ .realize('skill3', () => {
+ const HeroSkill = Mota.require('@user/data-state').HeroSkill;
+ if (!HeroSkill.learnedSkill(HeroSkill.Shield)) return;
+ if (HeroSkill.getAutoSkill()) {
+ tip('error', '已开启自动切换技能!');
+ return;
+ }
+ core.playSound('光标移动');
+ HeroSkill.toggleSkill(HeroSkill.Shield);
+ core.status.route.push('useSkill:Shield');
+ core.updateStatusBar();
+ })
+ .realize('debug', () => {
+ core.debug();
+ });
+
+// ----- Storage
+const keyStorage = new GameStorage
>(
+ GameStorage.fromAuthor('AncTe', 'gameKey')
+);
+keyStorage.data = {};
+keyStorage.read();
+gameKey.on('set', (id, key, assist) => {
+ keyStorage.setValue(id, { key, assist });
+});
+gameKey.fromJSON(keyStorage.toJSON());
diff --git a/packages-user/client-modules/src/action/index.ts b/packages-user/client-modules/src/action/index.ts
new file mode 100644
index 0000000..0837d98
--- /dev/null
+++ b/packages-user/client-modules/src/action/index.ts
@@ -0,0 +1,2 @@
+export * from './move';
+export * from './hotkey';
diff --git a/packages-user/client-modules/src/action/move.ts b/packages-user/client-modules/src/action/move.ts
new file mode 100644
index 0000000..49372b4
--- /dev/null
+++ b/packages-user/client-modules/src/action/move.ts
@@ -0,0 +1,182 @@
+import { KeyCode } from '@motajs/client-base';
+import { Hotkey, HotkeyData } from '@motajs/system-action';
+import { HeroMover, IMoveController } from '@user/data-state';
+import { Ticker } from 'mutate-animate';
+import { mainScope } from './hotkey';
+
+type MoveKey = Record;
+type MoveKeyConfig = Record;
+
+export class HeroKeyMover {
+ /** 当前按下的键 */
+ private pressedKey: Set = new Set();
+ /** 当前的移动方向 */
+ private moveDir: Dir = 'down';
+ /** 当前是否正在使用按键移动 */
+ private moving: boolean = false;
+ /** 当前移动的控制器 */
+ private controller?: IMoveController;
+
+ /** 按键接续ticker */
+ private ticker = new Ticker();
+
+ /** 当前移动实例绑定的热键 */
+ hotkey: Hotkey;
+ /** 当前热键的移动按键信息 */
+ hotkeyData: MoveKey;
+ /** 移动实例 */
+ mover: HeroMover;
+ /** 移动可触发的作用域 */
+ scope: symbol = mainScope;
+
+ constructor(hotkey: Hotkey, mover: HeroMover, config?: MoveKeyConfig) {
+ this.hotkey = hotkey;
+ this.mover = mover;
+ hotkey.on('press', this.onPressKey);
+ hotkey.on('release', this.onReleaseKey);
+
+ const data = hotkey.data;
+
+ this.hotkeyData = {
+ left: data[config?.left ?? 'moveLeft'],
+ right: data[config?.right ?? 'moveRight'],
+ up: data[config?.up ?? 'moveUp'],
+ down: data[config?.down ?? 'moveDown']
+ };
+
+ // 静止时尝试启动移动
+ this.ticker.add(() => {
+ if (!this.moving) {
+ if (this.pressedKey.size > 0) {
+ const dir = [...this.pressedKey].at(-1);
+ if (!dir) return;
+ this.moveDir = dir;
+ this.tryStartMove();
+ }
+ }
+ });
+ }
+
+ /**
+ * 按键移动
+ * @param code 按键码
+ */
+ private onPressKey = (code: KeyCode) => {
+ if (core.isReplaying() || !core.isPlaying()) return;
+ core.waitHeroToStop();
+ if (code === this.hotkeyData.left.key) this.press('left');
+ else if (code === this.hotkeyData.right.key) this.press('right');
+ else if (code === this.hotkeyData.up.key) this.press('up');
+ else if (code === this.hotkeyData.down.key) this.press('down');
+ };
+
+ /**
+ * 释放按键
+ * @param code 按键码
+ */
+ private onReleaseKey = (code: KeyCode) => {
+ if (code === this.hotkeyData.left.key) this.release('left');
+ else if (code === this.hotkeyData.right.key) this.release('right');
+ else if (code === this.hotkeyData.up.key) this.release('up');
+ else if (code === this.hotkeyData.down.key) this.release('down');
+ };
+
+ /**
+ * 设置按键触发作用域
+ */
+ setScope(scope: symbol) {
+ this.scope = scope;
+ }
+
+ /**
+ * 按下某个方向键
+ * @param dir 移动方向
+ */
+ press(dir: Dir) {
+ if (this.hotkey.scope !== this.scope || core.status.lockControl) return;
+ this.pressedKey.add(dir);
+ this.moveDir = dir;
+ if (!this.moving) {
+ this.tryStartMove();
+ }
+ }
+
+ /**
+ * 松开方向键
+ * @param dir 移动方向
+ */
+ release(dir: Dir) {
+ this.pressedKey.delete(dir);
+ if (this.pressedKey.size > 0) {
+ this.moveDir = [...this.pressedKey][0];
+ } else {
+ this.endMove();
+ }
+ }
+
+ /**
+ * 尝试开始移动
+ * @returns 是否成功开始移动
+ */
+ tryStartMove() {
+ if (this.moving || core.status.lockControl) return false;
+
+ this.mover.oneStep(this.moveDir);
+ const controller = this.mover.startMove(false, false, false, true);
+ if (!controller) return false;
+
+ this.controller = controller;
+ controller.onEnd.then(() => {
+ this.moving = false;
+ this.controller = void 0;
+ this.mover.off('stepEnd', this.onStepEnd);
+ });
+ this.moving = true;
+
+ this.mover.on('stepEnd', this.onStepEnd);
+ return true;
+ }
+
+ /**
+ * 停止本次按键移动
+ */
+ endMove() {
+ this.controller?.stop();
+ }
+
+ /**
+ * 移动结束
+ */
+ private onStepEnd = () => {
+ const con = this.controller;
+ if (!con) return;
+
+ // 被禁止操作时
+ if (core.status.lockControl) {
+ con.stop();
+ return;
+ }
+
+ // 未移动时
+ if (!this.moving) {
+ con.stop();
+ return;
+ }
+
+ // 尝试移动
+ if (this.pressedKey.size > 0) {
+ if (con.queue.length === 0) {
+ con.push({ type: 'dir', value: this.moveDir });
+ }
+ } else {
+ con.stop();
+ }
+ };
+
+ destroy() {
+ this.hotkey.off('press', this.onPressKey);
+ this.hotkey.off('release', this.onReleaseKey);
+ this.mover.off('stepEnd', this.onStepEnd);
+ this.ticker.destroy();
+ }
+}
diff --git a/packages-user/client-modules/src/audio/bgm.ts b/packages-user/client-modules/src/audio/bgm.ts
new file mode 100644
index 0000000..f983414
--- /dev/null
+++ b/packages-user/client-modules/src/audio/bgm.ts
@@ -0,0 +1,268 @@
+import EventEmitter from 'eventemitter3';
+import { audioPlayer, AudioPlayer, AudioRoute, AudioStatus } from './player';
+import { guessTypeByExt, isAudioSupport } from './support';
+import { logger } from '@motajs/common';
+import { StreamLoader } from '../loader';
+import { linear, sleep, Transition } from 'mutate-animate';
+import { VolumeEffect } from './effect';
+
+interface BgmVolume {
+ effect: VolumeEffect;
+ transition: Transition;
+}
+
+interface BgmControllerEvent {
+ play: [];
+ pause: [];
+ resume: [];
+ stop: [];
+}
+
+export class BgmController<
+ T extends string = BgmIds
+> extends EventEmitter {
+ /** bgm音频名称的前缀 */
+ prefix: string = 'bgms.';
+ /** 每个 bgm 的音量控制器 */
+ readonly gain: Map = new Map();
+
+ /** 正在播放的 bgm */
+ playingBgm?: T;
+ /** 是否正在播放 */
+ playing: boolean = false;
+
+ /** 是否已经启用 */
+ enabled: boolean = true;
+ /** 主音量控制器 */
+ private readonly mainGain: VolumeEffect;
+ /** 是否屏蔽所有的音乐切换 */
+ private blocking: boolean = false;
+ /** 渐变时长 */
+ private transitionTime: number = 2000;
+
+ constructor(public readonly player: AudioPlayer) {
+ super();
+ this.mainGain = player.createVolumeEffect();
+ }
+
+ /**
+ * 设置音频渐变时长
+ * @param time 渐变时长
+ */
+ setTransitionTime(time: number) {
+ this.transitionTime = time;
+ for (const [, value] of this.gain) {
+ value.transition.time(time);
+ }
+ }
+
+ /**
+ * 屏蔽音乐切换
+ */
+ blockChange() {
+ this.blocking = true;
+ }
+
+ /**
+ * 取消屏蔽音乐切换
+ */
+ unblockChange() {
+ this.blocking = false;
+ }
+
+ /**
+ * 设置总音量大小
+ * @param volume 音量大小
+ */
+ setVolume(volume: number) {
+ this.mainGain.setVolume(volume);
+ }
+
+ /**
+ * 获取总音量大小
+ */
+ getVolume() {
+ return this.mainGain.getVolume();
+ }
+
+ /**
+ * 设置是否启用
+ * @param enabled 是否启用
+ */
+ setEnabled(enabled: boolean) {
+ if (enabled) this.resume();
+ else this.stop();
+ this.enabled = enabled;
+ }
+
+ /**
+ * 设置 bgm 音频名称的前缀
+ */
+ setPrefix(prefix: string) {
+ this.prefix = prefix;
+ }
+
+ private getId(name: T) {
+ return `${this.prefix}${name}`;
+ }
+
+ /**
+ * 根据 bgm 名称获取其 AudioRoute 实例
+ * @param id 音频名称
+ */
+ get(id: T) {
+ return this.player.getRoute(this.getId(id));
+ }
+
+ /**
+ * 添加一个 bgm
+ * @param id 要添加的 bgm 的名称
+ * @param url 指定 bgm 的加载地址
+ */
+ addBgm(id: T, url: string = `project/bgms/${id}`) {
+ const type = guessTypeByExt(id);
+ if (!type) {
+ logger.warn(50, id.split('.').slice(0, -1).join('.'));
+ return;
+ }
+ const gain = this.player.createVolumeEffect();
+ if (isAudioSupport(type)) {
+ const source = audioPlayer.createElementSource();
+ source.setSource(url);
+ source.setLoop(true);
+ const route = new AudioRoute(source, audioPlayer);
+ route.addEffect([gain, this.mainGain]);
+ audioPlayer.addRoute(this.getId(id), route);
+ this.setTransition(id, route, gain);
+ } else {
+ const source = audioPlayer.createStreamSource();
+ const stream = new StreamLoader(url);
+ stream.pipe(source);
+ source.setLoop(true);
+ const route = new AudioRoute(source, audioPlayer);
+ route.addEffect([gain, this.mainGain]);
+ audioPlayer.addRoute(this.getId(id), route);
+ this.setTransition(id, route, gain);
+ }
+ }
+
+ /**
+ * 移除一个 bgm
+ * @param id 要移除的 bgm 的名称
+ */
+ removeBgm(id: T) {
+ this.player.removeRoute(this.getId(id));
+ const gain = this.gain.get(id);
+ gain?.transition.ticker.destroy();
+ this.gain.delete(id);
+ }
+
+ private setTransition(id: T, route: AudioRoute, gain: VolumeEffect) {
+ const transition = new Transition();
+ transition
+ .time(this.transitionTime)
+ .mode(linear())
+ .transition('volume', 0);
+
+ const tick = () => {
+ gain.setVolume(transition.value.volume);
+ };
+
+ /**
+ * @param expect 在结束时应该是正在播放还是停止
+ */
+ const setTick = async (expect: AudioStatus) => {
+ transition.ticker.remove(tick);
+ transition.ticker.add(tick);
+ const identifier = route.stopIdentifier;
+ await sleep(this.transitionTime + 500);
+ if (
+ route.status === expect &&
+ identifier === route.stopIdentifier
+ ) {
+ transition.ticker.remove(tick);
+ if (route.status === AudioStatus.Playing) {
+ gain.setVolume(1);
+ } else {
+ gain.setVolume(0);
+ }
+ }
+ };
+
+ route.onStart(async () => {
+ transition.transition('volume', 1);
+ setTick(AudioStatus.Playing);
+ });
+ route.onEnd(() => {
+ transition.transition('volume', 0);
+ setTick(AudioStatus.Paused);
+ });
+ route.setEndTime(this.transitionTime);
+
+ this.gain.set(id, { effect: gain, transition });
+ }
+
+ /**
+ * 播放一个 bgm
+ * @param id 要播放的 bgm 名称
+ */
+ play(id: T, when?: number) {
+ if (this.blocking) return;
+ if (id !== this.playingBgm && this.playingBgm) {
+ this.player.pause(this.getId(this.playingBgm));
+ }
+ this.playingBgm = id;
+ if (!this.enabled) return;
+ this.player.play(this.getId(id), when);
+ this.playing = true;
+ this.emit('play');
+ }
+
+ /**
+ * 继续当前的 bgm
+ */
+ resume() {
+ if (this.blocking || !this.enabled || this.playing) return;
+ if (this.playingBgm) {
+ this.player.resume(this.getId(this.playingBgm));
+ }
+ this.playing = true;
+ this.emit('resume');
+ }
+
+ /**
+ * 暂停当前的 bgm
+ */
+ pause() {
+ if (this.blocking || !this.enabled) return;
+ if (this.playingBgm) {
+ this.player.pause(this.getId(this.playingBgm));
+ }
+ this.playing = false;
+ this.emit('pause');
+ }
+
+ /**
+ * 停止当前的 bgm
+ */
+ stop() {
+ if (this.blocking || !this.enabled) return;
+ if (this.playingBgm) {
+ this.player.stop(this.getId(this.playingBgm));
+ }
+ this.playing = false;
+ this.emit('stop');
+ }
+}
+
+export const bgmController = new BgmController(audioPlayer);
+
+export function loadAllBgm() {
+ const { loading } = Mota.require('@user/data-base');
+ loading.once('coreInit', () => {
+ const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
+ for (const bgm of data.main.bgms) {
+ bgmController.addBgm(bgm);
+ }
+ });
+}
diff --git a/packages-user/client-modules/src/audio/decoder.ts b/packages-user/client-modules/src/audio/decoder.ts
new file mode 100644
index 0000000..104dfa0
--- /dev/null
+++ b/packages-user/client-modules/src/audio/decoder.ts
@@ -0,0 +1,203 @@
+import { logger } from '@motajs/common';
+import { OggVorbisDecoderWebWorker } from '@wasm-audio-decoders/ogg-vorbis';
+import { OggOpusDecoderWebWorker } from 'ogg-opus-decoder';
+import { AudioType, isAudioSupport } from './support';
+import type { AudioPlayer } from './player';
+
+const fileSignatures: [AudioType, number[]][] = [
+ [AudioType.Mp3, [0x49, 0x44, 0x33]],
+ [AudioType.Ogg, [0x4f, 0x67, 0x67, 0x53]],
+ [AudioType.Wav, [0x52, 0x49, 0x46, 0x46]],
+ [AudioType.Flac, [0x66, 0x4c, 0x61, 0x43]],
+ [AudioType.Aac, [0xff, 0xf1]],
+ [AudioType.Aac, [0xff, 0xf9]]
+];
+const oggHeaders: [AudioType, number[]][] = [
+ [AudioType.Opus, [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]]
+];
+
+export function checkAudioType(data: Uint8Array) {
+ let audioType: AudioType | '' = '';
+ // 检查头文件获取音频类型,仅检查前256个字节
+ const toCheck = data.slice(0, 256);
+ for (const [type, value] of fileSignatures) {
+ if (value.every((v, i) => toCheck[i] === v)) {
+ audioType = type;
+ break;
+ }
+ }
+ if (audioType === AudioType.Ogg) {
+ // 如果是ogg的话,进一步判断是不是opus
+ for (const [key, value] of oggHeaders) {
+ const has = toCheck.some((_, i) => {
+ return value.every((v, ii) => toCheck[i + ii] === v);
+ });
+ if (has) {
+ audioType = key;
+ break;
+ }
+ }
+ }
+ return audioType;
+}
+
+export interface IAudioDecodeError {
+ /** 错误信息 */
+ message: string;
+}
+
+export interface IAudioDecodeData {
+ /** 每个声道的音频信息 */
+ channelData: Float32Array[];
+ /** 已经被解码的 PCM 采样数 */
+ samplesDecoded: number;
+ /** 音频采样率 */
+ sampleRate: number;
+ /** 解码错误信息 */
+ errors: IAudioDecodeError[];
+}
+
+export abstract class AudioDecoder {
+ static readonly decoderMap: Map AudioDecoder> =
+ new Map();
+
+ /**
+ * 注册一个解码器
+ * @param type 要注册的解码器允许解码的类型
+ * @param decoder 解码器对象
+ */
+ static registerDecoder(type: AudioType, decoder: new () => AudioDecoder) {
+ if (this.decoderMap.has(type)) {
+ logger.warn(47, type);
+ return;
+ }
+ this.decoderMap.set(type, decoder);
+ }
+
+ /**
+ * 解码音频数据
+ * @param data 音频文件数据
+ * @param player AudioPlayer实例
+ */
+ static async decodeAudioData(data: Uint8Array, player: AudioPlayer) {
+ // 检查头文件获取音频类型,仅检查前256个字节
+ const toCheck = data.slice(0, 256);
+ const type = checkAudioType(data);
+ if (type === '') {
+ logger.error(
+ 25,
+ [...toCheck]
+ .map(v => v.toString(16).padStart(2, '0'))
+ .join(' ')
+ .toUpperCase()
+ );
+ return null;
+ }
+ if (isAudioSupport(type)) {
+ if (data.buffer instanceof ArrayBuffer) {
+ return player.ac.decodeAudioData(data.buffer);
+ } else {
+ return null;
+ }
+ } else {
+ const Decoder = this.decoderMap.get(type);
+ if (!Decoder) {
+ return null;
+ } else {
+ const decoder = new Decoder();
+ await decoder.create();
+ const decodedData = await decoder.decodeAll(data);
+ if (!decodedData) return null;
+ const buffer = player.ac.createBuffer(
+ decodedData.channelData.length,
+ decodedData.channelData[0].length,
+ decodedData.sampleRate
+ );
+ decodedData.channelData.forEach((v, i) => {
+ buffer.copyToChannel(v, i);
+ });
+ decoder.destroy();
+ return buffer;
+ }
+ }
+ }
+
+ /**
+ * 创建音频解码器
+ */
+ abstract create(): Promise;
+
+ /**
+ * 摧毁这个解码器
+ */
+ abstract destroy(): void;
+
+ /**
+ * 解码流数据
+ * @param data 流数据
+ */
+ abstract decode(data: Uint8Array): Promise;
+
+ /**
+ * 解码整个文件
+ * @param data 文件数据
+ */
+ abstract decodeAll(data: Uint8Array): Promise;
+
+ /**
+ * 当音频解码完成后,会调用此函数,需要返回之前还未解析或未返回的音频数据。调用后,该解码器将不会被再次使用
+ */
+ abstract flush(): Promise;
+}
+
+export class VorbisDecoder extends AudioDecoder {
+ decoder?: OggVorbisDecoderWebWorker;
+
+ async create(): Promise {
+ this.decoder = new OggVorbisDecoderWebWorker();
+ await this.decoder.ready;
+ }
+
+ destroy(): void {
+ this.decoder?.free();
+ }
+
+ async decode(data: Uint8Array): Promise {
+ return this.decoder?.decode(data) as Promise;
+ }
+
+ async decodeAll(data: Uint8Array): Promise {
+ return this.decoder?.decodeFile(data) as Promise;
+ }
+
+ async flush(): Promise {
+ return this.decoder?.flush() as Promise;
+ }
+}
+
+export class OpusDecoder extends AudioDecoder {
+ decoder?: OggOpusDecoderWebWorker;
+
+ async create(): Promise {
+ this.decoder = new OggOpusDecoderWebWorker({
+ speechQualityEnhancement: 'none'
+ });
+ await this.decoder.ready;
+ }
+
+ destroy(): void {
+ this.decoder?.free();
+ }
+
+ async decode(data: Uint8Array): Promise {
+ return this.decoder?.decode(data) as Promise;
+ }
+
+ async decodeAll(data: Uint8Array): Promise {
+ return this.decoder?.decodeFile(data) as Promise;
+ }
+
+ async flush(): Promise {
+ return this.decoder?.flush() as Promise;
+ }
+}
diff --git a/packages-user/client-modules/src/audio/effect.ts b/packages-user/client-modules/src/audio/effect.ts
new file mode 100644
index 0000000..1471058
--- /dev/null
+++ b/packages-user/client-modules/src/audio/effect.ts
@@ -0,0 +1,288 @@
+import { isNil } from 'lodash-es';
+import { sleep } from 'mutate-animate';
+
+export interface IAudioInput {
+ /** 输入节点 */
+ input: AudioNode;
+}
+
+export interface IAudioOutput {
+ /** 输出节点 */
+ output: AudioNode;
+}
+
+export abstract class AudioEffect implements IAudioInput, IAudioOutput {
+ /** 输出节点 */
+ abstract output: AudioNode;
+ /** 输入节点 */
+ abstract input: AudioNode;
+
+ constructor(public readonly ac: AudioContext) {}
+
+ /**
+ * 当音频播放结束时触发,可以用于节点结束后处理
+ */
+ abstract end(): void;
+
+ /**
+ * 当音频开始播放时触发,可以用于节点初始化
+ */
+ abstract start(): void;
+
+ /**
+ * 连接至其他效果器
+ * @param target 目标输入
+ * @param output 当前效果器输出通道
+ * @param input 目标效果器的输入通道
+ */
+ connect(target: IAudioInput, output?: number, input?: number) {
+ this.output.connect(target.input, output, input);
+ }
+
+ /**
+ * 与其他效果器取消连接
+ * @param target 目标输入
+ * @param output 当前效果器输出通道
+ * @param input 目标效果器的输入通道
+ */
+ disconnect(target?: IAudioInput, output?: number, input?: number) {
+ if (!target) {
+ if (!isNil(output)) {
+ this.output.disconnect(output);
+ } else {
+ this.output.disconnect();
+ }
+ } else {
+ if (!isNil(output)) {
+ if (!isNil(input)) {
+ this.output.disconnect(target.input, output, input);
+ } else {
+ this.output.disconnect(target.input, output);
+ }
+ } else {
+ this.output.disconnect(target.input);
+ }
+ }
+ }
+}
+
+export class StereoEffect extends AudioEffect {
+ output: PannerNode;
+ input: PannerNode;
+
+ constructor(ac: AudioContext) {
+ super(ac);
+ const panner = ac.createPanner();
+ this.input = panner;
+ this.output = panner;
+ }
+
+ /**
+ * 设置音频朝向,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
+ * @param x 朝向x坐标
+ * @param y 朝向y坐标
+ * @param z 朝向z坐标
+ */
+ setOrientation(x: number, y: number, z: number) {
+ this.output.orientationX.value = x;
+ this.output.orientationY.value = y;
+ this.output.orientationZ.value = z;
+ }
+
+ /**
+ * 设置音频位置,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
+ * @param x 位置x坐标
+ * @param y 位置y坐标
+ * @param z 位置z坐标
+ */
+ setPosition(x: number, y: number, z: number) {
+ this.output.positionX.value = x;
+ this.output.positionY.value = y;
+ this.output.positionZ.value = z;
+ }
+
+ end(): void {}
+
+ start(): void {}
+}
+
+export class VolumeEffect extends AudioEffect {
+ output: GainNode;
+ input: GainNode;
+
+ constructor(ac: AudioContext) {
+ super(ac);
+ const gain = ac.createGain();
+ this.input = gain;
+ this.output = gain;
+ }
+
+ /**
+ * 设置音量大小
+ * @param volume 音量大小
+ */
+ setVolume(volume: number) {
+ this.output.gain.value = volume;
+ }
+
+ /**
+ * 获取音量大小
+ */
+ getVolume(): number {
+ return this.output.gain.value;
+ }
+
+ end(): void {}
+
+ start(): void {}
+}
+
+export class ChannelVolumeEffect extends AudioEffect {
+ output: ChannelMergerNode;
+ input: ChannelSplitterNode;
+
+ /** 所有的音量控制节点 */
+ private readonly gain: GainNode[] = [];
+
+ constructor(ac: AudioContext) {
+ super(ac);
+ const splitter = ac.createChannelSplitter();
+ const merger = ac.createChannelMerger();
+ this.output = merger;
+ this.input = splitter;
+ for (let i = 0; i < 6; i++) {
+ const gain = ac.createGain();
+ splitter.connect(gain, i);
+ gain.connect(merger, 0, i);
+ this.gain.push(gain);
+ }
+ }
+
+ /**
+ * 设置某个声道的音量大小
+ * @param channel 要设置的声道,可填0-5
+ * @param volume 这个声道的音量大小
+ */
+ setVolume(channel: number, volume: number) {
+ if (!this.gain[channel]) return;
+ this.gain[channel].gain.value = volume;
+ }
+
+ /**
+ * 获取某个声道的音量大小,可填0-5
+ * @param channel 要获取的声道
+ */
+ getVolume(channel: number): number {
+ if (!this.gain[channel]) return 0;
+ return this.gain[channel].gain.value;
+ }
+
+ end(): void {}
+
+ start(): void {}
+}
+
+export class DelayEffect extends AudioEffect {
+ output: DelayNode;
+ input: DelayNode;
+
+ constructor(ac: AudioContext) {
+ super(ac);
+ const delay = ac.createDelay();
+ this.input = delay;
+ this.output = delay;
+ }
+
+ /**
+ * 设置延迟时长
+ * @param delay 延迟时长,单位秒
+ */
+ setDelay(delay: number) {
+ this.output.delayTime.value = delay;
+ }
+
+ /**
+ * 获取延迟时长
+ */
+ getDelay() {
+ return this.output.delayTime.value;
+ }
+
+ end(): void {}
+
+ start(): void {}
+}
+
+export class EchoEffect extends AudioEffect {
+ output: GainNode;
+ input: GainNode;
+
+ /** 延迟节点 */
+ private readonly delay: DelayNode;
+ /** 反馈增益节点 */
+ private readonly gainNode: GainNode;
+ /** 当前增益 */
+ private gain: number = 0.5;
+ /** 是否正在播放 */
+ private playing: boolean = false;
+
+ constructor(ac: AudioContext) {
+ super(ac);
+ const delay = ac.createDelay();
+ const gain = ac.createGain();
+ gain.gain.value = 0.5;
+ delay.delayTime.value = 0.05;
+ delay.connect(gain);
+ gain.connect(delay);
+ this.delay = delay;
+ this.gainNode = gain;
+ this.input = gain;
+ this.output = gain;
+ }
+
+ /**
+ * 设置回声反馈增益大小
+ * @param gain 增益大小,范围 0-1,大于等于1的视为0.5,小于0的视为0
+ */
+ setFeedbackGain(gain: number) {
+ const resolved = gain >= 1 ? 0.5 : gain < 0 ? 0 : gain;
+ this.gain = resolved;
+ if (this.playing) this.gainNode.gain.value = resolved;
+ }
+
+ /**
+ * 设置回声间隔时长
+ * @param delay 回声时长,范围 0.01-Infinity,小于0.01的视为0.01
+ */
+ setEchoDelay(delay: number) {
+ const resolved = delay < 0.01 ? 0.01 : delay;
+ this.delay.delayTime.value = resolved;
+ }
+
+ /**
+ * 获取反馈节点增益
+ */
+ getFeedbackGain() {
+ return this.gain;
+ }
+
+ /**
+ * 获取回声间隔时长
+ */
+ getEchoDelay() {
+ return this.delay.delayTime.value;
+ }
+
+ end(): void {
+ this.playing = false;
+ const echoTime = Math.ceil(Math.log(0.001) / Math.log(this.gain)) + 10;
+ sleep(this.delay.delayTime.value * echoTime).then(() => {
+ if (!this.playing) this.gainNode.gain.value = 0;
+ });
+ }
+
+ start(): void {
+ this.playing = true;
+ this.gainNode.gain.value = this.gain;
+ }
+}
diff --git a/packages-user/client-modules/src/audio/index.ts b/packages-user/client-modules/src/audio/index.ts
new file mode 100644
index 0000000..c09a229
--- /dev/null
+++ b/packages-user/client-modules/src/audio/index.ts
@@ -0,0 +1,18 @@
+import { loadAllBgm } from './bgm';
+import { OpusDecoder, VorbisDecoder } from './decoder';
+import { AudioType } from './support';
+import { AudioDecoder } from './decoder';
+
+export function createAudio() {
+ loadAllBgm();
+ AudioDecoder.registerDecoder(AudioType.Ogg, VorbisDecoder);
+ AudioDecoder.registerDecoder(AudioType.Opus, OpusDecoder);
+}
+
+export * from './support';
+export * from './effect';
+export * from './player';
+export * from './source';
+export * from './bgm';
+export * from './decoder';
+export * from './sound';
diff --git a/packages-user/client-modules/src/audio/player.ts b/packages-user/client-modules/src/audio/player.ts
new file mode 100644
index 0000000..fc7100f
--- /dev/null
+++ b/packages-user/client-modules/src/audio/player.ts
@@ -0,0 +1,605 @@
+import EventEmitter from 'eventemitter3';
+import {
+ AudioBufferSource,
+ AudioElementSource,
+ AudioSource,
+ AudioStreamSource
+} from './source';
+import {
+ AudioEffect,
+ ChannelVolumeEffect,
+ DelayEffect,
+ EchoEffect,
+ IAudioOutput,
+ StereoEffect,
+ VolumeEffect
+} from './effect';
+import { isNil } from 'lodash-es';
+import { logger } from '@motajs/common';
+import { sleep } from 'mutate-animate';
+import { AudioDecoder } from './decoder';
+
+interface AudioPlayerEvent {}
+
+export class AudioPlayer extends EventEmitter {
+ /** 音频播放上下文 */
+ readonly ac: AudioContext;
+
+ /** 所有的音频播放路由 */
+ readonly audioRoutes: Map = new Map();
+ /** 音量节点 */
+ readonly gain: GainNode;
+
+ constructor() {
+ super();
+ this.ac = new AudioContext();
+ this.gain = this.ac.createGain();
+ this.gain.connect(this.ac.destination);
+ }
+
+ /**
+ * 解码音频数据
+ * @param data 音频数据
+ */
+ decodeAudioData(data: Uint8Array) {
+ return AudioDecoder.decodeAudioData(data, this);
+ }
+
+ /**
+ * 设置音量
+ * @param volume 音量
+ */
+ setVolume(volume: number) {
+ this.gain.gain.value = volume;
+ }
+
+ /**
+ * 获取音量
+ */
+ getVolume() {
+ return this.gain.gain.value;
+ }
+
+ /**
+ * 创建一个音频源
+ * @param Source 音频源类
+ */
+ createSource(
+ Source: new (ac: AudioContext) => T
+ ): T {
+ return new Source(this.ac);
+ }
+
+ /**
+ * 创建一个兼容流式音频源,可以与流式加载相结合,主要用于处理 opus ogg 不兼容的情况
+ */
+ createStreamSource() {
+ return new AudioStreamSource(this.ac);
+ }
+
+ /**
+ * 创建一个通过 audio 元素播放的音频源
+ */
+ createElementSource() {
+ return new AudioElementSource(this.ac);
+ }
+
+ /**
+ * 创建一个通过 AudioBuffer 播放的音频源
+ */
+ createBufferSource() {
+ return new AudioBufferSource(this.ac);
+ }
+
+ /**
+ * 获取音频目的地
+ */
+ getDestination() {
+ return this.gain;
+ }
+
+ /**
+ * 创建一个音频效果器
+ * @param Effect 效果器类
+ */
+ createEffect(
+ Effect: new (ac: AudioContext) => T
+ ): T {
+ return new Effect(this.ac);
+ }
+
+ /**
+ * 创建一个修改音量的效果器
+ * ```txt
+ * |----------|
+ * Input ----> | GainNode | ----> Output
+ * |----------|
+ * ```
+ */
+ createVolumeEffect() {
+ return new VolumeEffect(this.ac);
+ }
+
+ /**
+ * 创建一个立体声效果器
+ * ```txt
+ * |------------|
+ * Input ----> | PannerNode | ----> Output
+ * |------------|
+ * ```
+ */
+ createStereoEffect() {
+ return new StereoEffect(this.ac);
+ }
+
+ /**
+ * 创建一个修改单个声道音量的效果器
+ * ```txt
+ * |----------|
+ * -> | GainNode | \
+ * |--------------| / |----------| -> |------------|
+ * Input ----> | SplitterNode | ...... | MergerNode | ----> Output
+ * |--------------| \ |----------| -> |------------|
+ * -> | GainNode | /
+ * |----------|
+ * ```
+ */
+ createChannelVolumeEffect() {
+ return new ChannelVolumeEffect(this.ac);
+ }
+
+ /**
+ * 创建一个延迟效果器
+ * ```txt
+ * |-----------|
+ * Input ----> | DelayNode | ----> Output
+ * |-----------|
+ * ```
+ */
+ createDelayEffect() {
+ return new DelayEffect(this.ac);
+ }
+
+ /**
+ * 创建一个回声效果器
+ * ```txt
+ * |----------|
+ * Input ----> | GainNode | ----> Output
+ * ^ |----------| |
+ * | |
+ * | |------------| ↓
+ * |-- | Delay Node | <--
+ * |------------|
+ * ```
+ */
+ createEchoEffect() {
+ return new EchoEffect(this.ac);
+ }
+
+ /**
+ * 创建一个音频播放路由
+ * @param source 音频源
+ */
+ createRoute(source: AudioSource) {
+ return new AudioRoute(source, this);
+ }
+
+ /**
+ * 添加一个音频播放路由,可以直接被播放
+ * @param id 这个音频播放路由的名称
+ * @param route 音频播放路由对象
+ */
+ addRoute(id: string, route: AudioRoute) {
+ if (this.audioRoutes.has(id)) {
+ logger.warn(45, id);
+ }
+ this.audioRoutes.set(id, route);
+ }
+
+ /**
+ * 根据名称获取音频播放路由对象
+ * @param id 音频播放路由的名称
+ */
+ getRoute(id: string) {
+ return this.audioRoutes.get(id);
+ }
+
+ /**
+ * 移除一个音频播放路由
+ * @param id 要移除的播放路由的名称
+ */
+ removeRoute(id: string) {
+ const route = this.audioRoutes.get(id);
+ if (route) {
+ route.destroy();
+ }
+ this.audioRoutes.delete(id);
+ }
+
+ /**
+ * 播放音频
+ * @param id 音频名称
+ * @param when 从音频的哪个位置开始播放,单位秒
+ */
+ play(id: string, when: number = 0) {
+ const route = this.getRoute(id);
+ if (!route) {
+ logger.warn(53, 'play', id);
+ return;
+ }
+ route.play(when);
+ }
+
+ /**
+ * 暂停音频播放
+ * @param id 音频名称
+ * @returns 当音乐真正停止时兑现
+ */
+ pause(id: string) {
+ const route = this.getRoute(id);
+ if (!route) {
+ logger.warn(53, 'pause', id);
+ return;
+ }
+ return route.pause();
+ }
+
+ /**
+ * 停止音频播放
+ * @param id 音频名称
+ * @returns 当音乐真正停止时兑现
+ */
+ stop(id: string) {
+ const route = this.getRoute(id);
+ if (!route) {
+ logger.warn(53, 'stop', id);
+ return;
+ }
+ return route.stop();
+ }
+
+ /**
+ * 继续音频播放
+ * @param id 音频名称
+ */
+ resume(id: string) {
+ const route = this.getRoute(id);
+ if (!route) {
+ logger.warn(53, 'resume', id);
+ return;
+ }
+ route.resume();
+ }
+
+ /**
+ * 设置听者位置,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
+ * @param x 位置x坐标
+ * @param y 位置y坐标
+ * @param z 位置z坐标
+ */
+ setListenerPosition(x: number, y: number, z: number) {
+ const listener = this.ac.listener;
+ listener.positionX.value = x;
+ listener.positionY.value = y;
+ listener.positionZ.value = z;
+ }
+
+ /**
+ * 设置听者朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
+ * @param x 朝向x坐标
+ * @param y 朝向y坐标
+ * @param z 朝向z坐标
+ */
+ setListenerOrientation(x: number, y: number, z: number) {
+ const listener = this.ac.listener;
+ listener.forwardX.value = x;
+ listener.forwardY.value = y;
+ listener.forwardZ.value = z;
+ }
+
+ /**
+ * 设置听者头顶朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
+ * @param x 头顶朝向x坐标
+ * @param y 头顶朝向y坐标
+ * @param z 头顶朝向z坐标
+ */
+ setListenerUp(x: number, y: number, z: number) {
+ const listener = this.ac.listener;
+ listener.upX.value = x;
+ listener.upY.value = y;
+ listener.upZ.value = z;
+ }
+}
+
+export const enum AudioStatus {
+ Playing,
+ Pausing,
+ Paused,
+ Stoping,
+ Stoped
+}
+
+type AudioStartHook = (route: AudioRoute) => void;
+type AudioEndHook = (time: number, route: AudioRoute) => void;
+
+interface AudioRouteEvent {
+ updateEffect: [];
+ play: [];
+ stop: [];
+ pause: [];
+ resume: [];
+}
+
+export class AudioRoute
+ extends EventEmitter
+ implements IAudioOutput
+{
+ output: AudioNode;
+
+ /** 效果器路由图 */
+ readonly effectRoute: AudioEffect[] = [];
+
+ /** 结束时长,当音频暂停或停止时,会经过这么长时间之后才真正终止播放,期间可以做音频淡入淡出等效果 */
+ endTime: number = 0;
+
+ /** 当前播放状态 */
+ status: AudioStatus = AudioStatus.Stoped;
+ /** 暂停时刻 */
+ private pauseTime: number = 0;
+ /** 暂停时播放了多长时间 */
+ private pauseCurrentTime: number = 0;
+
+ /** 音频时长,单位秒 */
+ get duration() {
+ return this.source.duration;
+ }
+ /** 当前播放了多长时间,单位秒 */
+ get currentTime() {
+ if (this.status === AudioStatus.Paused) {
+ return this.pauseCurrentTime;
+ } else {
+ return this.source.currentTime;
+ }
+ }
+ set currentTime(time: number) {
+ this.source.stop();
+ this.source.play(time);
+ }
+
+ private shouldStop: boolean = false;
+ /**
+ * 每次暂停或停止时自增,用于判断当前正在处理的情况。
+ * 假如暂停后很快播放,然后很快暂停,那么需要根据这个来判断实际是否应该执行暂停后操作
+ */
+ stopIdentifier: number = 0;
+
+ private audioStartHook?: AudioStartHook;
+ private audioEndHook?: AudioEndHook;
+
+ constructor(
+ public readonly source: AudioSource,
+ public readonly player: AudioPlayer
+ ) {
+ super();
+ this.output = source.output;
+ source.on('end', () => {
+ if (this.status === AudioStatus.Playing) {
+ this.status = AudioStatus.Stoped;
+ }
+ });
+ source.on('play', () => {
+ if (this.status !== AudioStatus.Playing) {
+ this.status = AudioStatus.Playing;
+ }
+ });
+ }
+
+ /**
+ * 设置结束时间,暂停或停止时,会经过这么长时间才终止音频的播放,这期间可以做一下音频淡出的效果。
+ * @param time 暂停或停止时,经过多长时间之后才会结束音频的播放
+ */
+ setEndTime(time: number) {
+ this.endTime = time;
+ }
+
+ /**
+ * 当音频播放时执行的函数,可以用于音频淡入效果
+ * @param fn 音频开始播放时执行的函数
+ */
+ onStart(fn?: AudioStartHook) {
+ this.audioStartHook = fn;
+ }
+
+ /**
+ * 当音频暂停或停止时执行的函数,可以用于音频淡出效果
+ * @param fn 音频在暂停或停止时执行的函数,不填时表示取消这个钩子。
+ * 包含两个参数,第一个参数是结束时长,第二个参数是当前音频播放路由对象
+ */
+ onEnd(fn?: AudioEndHook) {
+ this.audioEndHook = fn;
+ }
+
+ /**
+ * 开始播放这个音频
+ * @param when 从音频的什么时候开始播放,单位秒
+ */
+ async play(when: number = 0) {
+ if (this.status === AudioStatus.Playing) return;
+ this.link();
+ await this.player.ac.resume();
+ if (this.effectRoute.length > 0) {
+ const first = this.effectRoute[0];
+ this.source.connect(first);
+ const last = this.effectRoute.at(-1)!;
+ last.connect({ input: this.player.getDestination() });
+ } else {
+ this.source.connect({ input: this.player.getDestination() });
+ }
+ this.source.play(when);
+ this.status = AudioStatus.Playing;
+ this.pauseTime = 0;
+ this.audioStartHook?.(this);
+ this.startAllEffect();
+ this.emit('play');
+ }
+
+ /**
+ * 暂停音频播放
+ */
+ async pause() {
+ if (this.status !== AudioStatus.Playing) return;
+ this.status = AudioStatus.Pausing;
+ this.stopIdentifier++;
+ const identifier = this.stopIdentifier;
+ if (this.audioEndHook) {
+ this.audioEndHook(this.endTime, this);
+ await sleep(this.endTime);
+ }
+ if (
+ this.status !== AudioStatus.Pausing ||
+ this.stopIdentifier !== identifier
+ ) {
+ return;
+ }
+ this.pauseCurrentTime = this.source.currentTime;
+ const time = this.source.stop();
+ this.pauseTime = time;
+ if (this.shouldStop) {
+ this.status = AudioStatus.Stoped;
+ this.endAllEffect();
+ this.emit('stop');
+ this.shouldStop = false;
+ } else {
+ this.status = AudioStatus.Paused;
+ this.endAllEffect();
+ this.emit('pause');
+ }
+ }
+
+ /**
+ * 继续音频播放
+ */
+ resume() {
+ if (this.status === AudioStatus.Playing) return;
+ if (
+ this.status === AudioStatus.Pausing ||
+ this.status === AudioStatus.Stoping
+ ) {
+ this.audioStartHook?.(this);
+ this.emit('resume');
+ return;
+ }
+ if (this.status === AudioStatus.Paused) {
+ this.play(this.pauseTime);
+ } else {
+ this.play(0);
+ }
+ this.status = AudioStatus.Playing;
+ this.pauseTime = 0;
+ this.audioStartHook?.(this);
+ this.startAllEffect();
+ this.emit('resume');
+ }
+
+ /**
+ * 停止音频播放
+ */
+ async stop() {
+ if (this.status !== AudioStatus.Playing) {
+ if (this.status === AudioStatus.Pausing) {
+ this.shouldStop = true;
+ }
+ return;
+ }
+ this.status = AudioStatus.Stoping;
+ this.stopIdentifier++;
+ const identifier = this.stopIdentifier;
+ if (this.audioEndHook) {
+ this.audioEndHook(this.endTime, this);
+ await sleep(this.endTime);
+ }
+ if (
+ this.status !== AudioStatus.Stoping ||
+ this.stopIdentifier !== identifier
+ ) {
+ return;
+ }
+ this.source.stop();
+ this.status = AudioStatus.Stoped;
+ this.pauseTime = 0;
+ this.endAllEffect();
+ this.emit('stop');
+ }
+
+ /**
+ * 添加效果器
+ * @param effect 要添加的效果,可以是数组,表示一次添加多个
+ * @param index 从哪个位置开始添加,如果大于数组长度,那么加到末尾,如果小于0,那么将会从后面往前数。默认添加到末尾
+ */
+ addEffect(effect: AudioEffect | AudioEffect[], index?: number) {
+ if (isNil(index)) {
+ if (effect instanceof Array) {
+ this.effectRoute.push(...effect);
+ } else {
+ this.effectRoute.push(effect);
+ }
+ } else {
+ if (effect instanceof Array) {
+ this.effectRoute.splice(index, 0, ...effect);
+ } else {
+ this.effectRoute.splice(index, 0, effect);
+ }
+ }
+ this.setOutput();
+ if (this.source.playing) this.link();
+ this.emit('updateEffect');
+ }
+
+ /**
+ * 移除一个效果器
+ * @param effect 要移除的效果
+ */
+ removeEffect(effect: AudioEffect) {
+ const index = this.effectRoute.indexOf(effect);
+ if (index === -1) return;
+ this.effectRoute.splice(index, 1);
+ effect.disconnect();
+ this.setOutput();
+ if (this.source.playing) this.link();
+ this.emit('updateEffect');
+ }
+
+ destroy() {
+ this.effectRoute.forEach(v => v.disconnect());
+ }
+
+ private setOutput() {
+ const effect = this.effectRoute.at(-1);
+ if (!effect) this.output = this.source.output;
+ else this.output = effect.output;
+ }
+
+ /**
+ * 连接音频路由图
+ */
+ private link() {
+ this.effectRoute.forEach(v => v.disconnect());
+ this.effectRoute.forEach((v, i) => {
+ const next = this.effectRoute[i + 1];
+ if (next) {
+ v.connect(next);
+ }
+ });
+ }
+
+ private startAllEffect() {
+ this.effectRoute.forEach(v => v.start());
+ }
+
+ private endAllEffect() {
+ this.effectRoute.forEach(v => v.end());
+ }
+}
+
+export const audioPlayer = new AudioPlayer();
+// window.audioPlayer = audioPlayer;
diff --git a/packages-user/client-modules/src/audio/sound.ts b/packages-user/client-modules/src/audio/sound.ts
new file mode 100644
index 0000000..0033554
--- /dev/null
+++ b/packages-user/client-modules/src/audio/sound.ts
@@ -0,0 +1,135 @@
+import EventEmitter from 'eventemitter3';
+import { audioPlayer, AudioPlayer } from './player';
+import { logger } from '@motajs/common';
+import { VolumeEffect } from './effect';
+
+type LocationArray = [number, number, number];
+
+interface SoundPlayerEvent {}
+
+export class SoundPlayer<
+ T extends string = SoundIds
+> extends EventEmitter {
+ /** 每个音效的唯一标识符 */
+ private num: number = 0;
+
+ /** 每个音效的数据 */
+ readonly buffer: Map = new Map();
+ /** 所有正在播放的音乐 */
+ readonly playing: Set = new Set();
+ /** 音量节点 */
+ readonly gain: VolumeEffect;
+
+ /** 是否已经启用 */
+ enabled: boolean = true;
+
+ constructor(public readonly player: AudioPlayer) {
+ super();
+ this.gain = player.createVolumeEffect();
+ }
+
+ /**
+ * 设置是否启用音效
+ * @param enabled 是否启用音效
+ */
+ setEnabled(enabled: boolean) {
+ if (!enabled) this.stopAllSounds();
+ this.enabled = enabled;
+ }
+
+ /**
+ * 设置音量大小
+ * @param volume 音量大小
+ */
+ setVolume(volume: number) {
+ this.gain.setVolume(volume);
+ }
+
+ /**
+ * 获取音量大小
+ */
+ getVolume() {
+ return this.gain.getVolume();
+ }
+
+ /**
+ * 添加一个音效
+ * @param id 音效名称
+ * @param data 音效的Uint8Array数据
+ */
+ async add(id: T, data: Uint8Array) {
+ const buffer = await this.player.decodeAudioData(data);
+ if (!buffer) {
+ logger.warn(51, id);
+ return;
+ }
+ this.buffer.set(id, buffer);
+ }
+
+ /**
+ * 播放一个音效
+ * @param id 音效名称
+ * @param position 音频位置,[0, 0, 0]表示正中心,x轴指向水平向右,y轴指向水平向上,z轴指向竖直向上
+ * @param orientation 音频朝向,[0, 1, 0]表示朝向前方
+ */
+ play(
+ id: T,
+ position: LocationArray = [0, 0, 0],
+ orientation: LocationArray = [1, 0, 0]
+ ) {
+ if (!this.enabled) return -1;
+ const buffer = this.buffer.get(id);
+ if (!buffer) {
+ logger.warn(52, id);
+ return -1;
+ }
+ const soundNum = this.num++;
+ const source = this.player.createBufferSource();
+ source.setBuffer(buffer);
+ const route = this.player.createRoute(source);
+ const stereo = this.player.createStereoEffect();
+ stereo.setPosition(position[0], position[1], position[2]);
+ stereo.setOrientation(orientation[0], orientation[1], orientation[2]);
+ route.addEffect([stereo, this.gain]);
+ this.player.addRoute(`sounds.${soundNum}`, route);
+ route.play();
+ // 清理垃圾
+ source.output.addEventListener('ended', () => {
+ this.playing.delete(soundNum);
+ this.player.removeRoute(`sounds.${soundNum}`);
+ });
+ this.playing.add(soundNum);
+ return soundNum;
+ }
+
+ /**
+ * 停止一个音效
+ * @param num 音效的唯一 id
+ */
+ stop(num: number) {
+ const id = `sounds.${num}`;
+ const route = this.player.getRoute(id);
+ if (route) {
+ route.stop();
+ this.player.removeRoute(id);
+ this.playing.delete(num);
+ }
+ }
+
+ /**
+ * 停止播放所有音效
+ */
+ stopAllSounds() {
+ this.playing.forEach(v => {
+ const id = `sounds.${v}`;
+ const route = this.player.getRoute(id);
+ if (route) {
+ route.stop();
+ this.player.removeRoute(id);
+ }
+ });
+ this.playing.clear();
+ }
+}
+
+export const soundPlayer = new SoundPlayer