18 Commits
Author SHA1 Message Date
zhangyi ac4d8dfb38 Merge branch 'test' into feat-websoc 2026-07-09 16:26:31 +08:00
chenlimao ad8bc1f152 fix: 把 OTA 图片本地引用改成了同名 CDN 地址 2026-07-09 16:25:28 +08:00
zhangyi 725a3ddcb9 update:优化比赛 2026-07-09 16:15:27 +08:00
zhangyi 33187e0cc3 update;优化比赛服 2026-07-09 15:48:28 +08:00
zhangyi bf6b87b59c update:优化跳转 2026-07-09 14:43:25 +08:00
zhangyi 12729a1cef update:修复日志和防抖 2026-07-09 14:35:48 +08:00
zhangyi f8efe4c4a4 update:优化比赛 2026-07-09 11:43:06 +08:00
zhangyi af07d09ab6 update:优化比赛队列 2026-07-08 18:18:27 +08:00
zhangyi e0d976fcce update:更新比赛服逻辑 2026-07-08 16:03:34 +08:00
zhangyi 01ac4f9c03 update:新增比赛服链接 2026-07-08 14:51:35 +08:00
zhangyi 348d490de8 update:优化获取手机号 2026-07-06 11:15:38 +08:00
zhangyi d3e0307854 update:优化vip套餐 2026-07-06 09:37:08 +08:00
zhangyi 0a678cb387 update:修复大乱斗特效飘移 2026-07-03 17:46:00 +08:00
zhangyi 3c7467fbdf Merge branch 'test' into feat-vip 2026-07-03 17:04:09 +08:00
zhangyi 6aa3c6fd6c update:补充乱斗语音 2026-07-03 17:03:51 +08:00
zhangyi 8b11e41957 Merge branch 'feat-vip' into test 2026-07-03 13:56:06 +08:00
zhangyi 8c6374b2c5 update:对接虚拟支付 2026-07-03 13:55:24 +08:00
zhangyi 3e99bb8ad9 update:新手教程新增vip特效 2026-07-01 11:52:03 +08:00
47 changed files with 4519 additions and 568 deletions
+628
View File
@@ -26,6 +26,8 @@
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
"@dcloudio/uni-ui": "^1.5.11",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"protobufjs": "^8.7.0",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
@@ -2422,6 +2424,70 @@
"vite": "^5.2.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
@@ -2438,6 +2504,294 @@
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@intlify/core-base": {
"version": "9.1.9",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz",
@@ -3424,6 +3778,32 @@
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
"integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz",
"integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
@@ -3437,6 +3817,227 @@
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz",
"integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz",
"integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz",
"integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz",
"integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz",
"integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz",
"integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz",
"integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz",
"integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz",
"integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz",
"integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz",
"integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz",
"integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz",
"integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz",
"integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz",
"integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz",
"integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz",
"integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@sinonjs/commons": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
@@ -7535,6 +8136,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8222,6 +8829,15 @@
}
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz",
"integrity": "sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==",
"license": "MIT",
"peerDependencies": {
"pinia": "^2.0.0"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -8530,6 +9146,18 @@
"node": ">= 6"
}
},
"node_modules/protobufjs": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.0.tgz",
"integrity": "sha512-uu52JNxLh3vsL7tXU/h0gDaywufvuUCTbGSi0NKQKBZ2ZopkmrWQJSQO/EFqzu/5YhiwgVM8rq/a/iVpx4eZ0g==",
"license": "BSD-3-Clause",
"dependencies": {
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+1
View File
@@ -26,6 +26,7 @@
"@dcloudio/uni-ui": "^1.5.11",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"protobufjs": "^8.7.0",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
+26
View File
@@ -7,9 +7,13 @@
onHide
} from "@dcloudio/uni-app";
import websocket from "@/websocket";
import matchWebsocket from "@/matchWebsocket";
import {
getDeviceBatteryAPI
} from "@/apis";
import {
MESSAGETYPES
} from "@/constants";
import useStore from "@/store";
import {
storeToRefs
@@ -36,6 +40,9 @@
websocket.createWebSocket(token, onShootWsMsg);
}
if (!newVal) {
matchWebsocket.closeMatchWebSocket({
reason: "logout"
});
websocket.closeWebSocket();
}
}, {
@@ -73,10 +80,26 @@
// audioManager.play("射箭声音")
}
function connectMatchServerFromMessage(content) {
const messages = Array.isArray(content) ? content : [content];
messages.forEach((message) => {
if (
message?.constructor ===
MESSAGETYPES.ShootSyncBattleCreateMatchLinkID
) {
matchWebsocket.connectMatchWebSocketFromNotice(
message,
user.value.id
);
}
});
}
function onShootWsMsg(content) {
if(content.type === 'shoot-trigger'){
onDeviceShoot()
}
connectMatchServerFromMessage(content);
uni.$emit("socket-inbox", content);
}
@@ -99,6 +122,9 @@
uni.$off("update-online", emitUpdateOnline);
uni.$off("session-kicked-out", onSessionKickedOut);
uni.$off("device-bind-invalid", onDeviceBindInvalid);
matchWebsocket.closeMatchWebSocket({
reason: "app-hide"
});
websocket.closeWebSocket();
});
</script>
+20 -2
View File
@@ -1,3 +1,5 @@
import { normalizeBattleApiResult } from "@/utils/matchAdapter";
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
try {
@@ -6,7 +8,7 @@ try {
switch (envVersion) {
case "develop": // 开发版
// BASE_URL = "http://192.168.1.30:8000/api/shoot";
// BASE_URL = "http://192.168.1.2:8000/api/shoot";
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
break;
case "trial": // 体验版
@@ -366,6 +368,17 @@ export const createOrderAPI = (vipId) => {
});
};
export const virtualPayOrderAPI = (vipId = 0, code = "") => {
return request("POST", "/user/virtualPay/createOrder", {
vipId,
code,
});
};
export const getOrderDetailAPI = (orderId) => {
return request("GET", `/user/order/detail?orderId=${encodeURIComponent(orderId)}`);
};
export const payOrderAPI = (id) => {
return request("POST", "/user/order/pay", {
id,
@@ -510,6 +523,10 @@ export const getPhoneNumberAPI = (data) => {
return request("POST", "/index/getPhone", data);
};
export const getPhoneNumberAPIv2 = (data) => {
return request("POST", "/index/getPhone/v2", data);
};
export const getPointBookRankListAPI = (page = 1) => {
return request(
"GET",
@@ -538,9 +555,10 @@ export const getReadyAPI = (roomId) => {
};
export const getBattleAPI = async (battleId) => {
return request("POST", "/user/match/info", {
const result = await request("POST", "/user/match/info", {
id: battleId,
});
return normalizeBattleApiResult(result);
};
export const kickPlayerAPI = (number, userId) => {
+2
View File
@@ -40,6 +40,8 @@ export const audioFils = {
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya59b6pu0ur4um.mp3",
比赛开始:
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcuu5z3a3lumkutske.mp3",
下半场开始:
"https://static.shelingxingqiu.com/shootmini/static/audio/%E4%B8%8B%E5%8D%8A%E5%9C%BA%E5%BC%80%E5%A7%8B.mp3",
请开始射击:
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
射击无效:
+2 -5
View File
@@ -4,6 +4,7 @@ import { onShow } from "@dcloudio/uni-app";
import { getBattleAPI, getUserGameState } from "@/apis";
import { debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -67,11 +68,7 @@ const onClick = debounce(async () => {
const result = await getBattleAPI();
if (result && result.matchId) {
await uni.$checkAudio();
if (result.mode <= 3) {
await navigateOnce(`/pages/team-battle/index?battleId=${result.matchId}`);
} else {
await navigateOnce(`/pages/melee-battle?battleId=${result.matchId}`);
}
await returnToBattle(result, navigateOnce);
return;
}
if (game.value.roomID) {
+1 -1
View File
@@ -207,7 +207,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
justify-content: center;
color: #fff9;
font-size: 12px;
padding-top: 7px;
/* padding-top: 7px; */
flex: 0 0 auto;
}
.player-name {
+16 -3
View File
@@ -34,6 +34,7 @@ const phase = ref("idle");
const activePlayKey = ref("");
const animationKey = ref("");
const impactEmitted = ref(false);
const activeShot = ref(null);
let timers = [];
const isActive = computed(() => phase.value !== "idle");
@@ -54,9 +55,17 @@ const safeTargetSize = computed(() => {
};
});
function hasShotPoint(shot) {
const x = Number(shot?.x);
const y = Number(shot?.y);
return Number.isFinite(x) && Number.isFinite(y);
}
const effectiveShot = computed(() => activeShot.value || props.shot);
const shotPoint = computed(() => {
const x = Number(props.shot?.x);
const y = Number(props.shot?.y);
const x = Number(effectiveShot.value?.x);
const y = Number(effectiveShot.value?.y);
return {
x: Number.isFinite(x) ? x : 0,
y: Number.isFinite(y) ? y : 0,
@@ -179,16 +188,20 @@ function finish(playKey) {
clearTimers();
phase.value = "idle";
activePlayKey.value = "";
activeShot.value = null;
emit("complete", playKey);
}
function play() {
if (!props.playKey || !props.shot || !props.shot.ring) return;
if (!props.playKey || !props.shot || !props.shot.ring || !hasShotPoint(props.shot)) {
return;
}
clearTimers();
activePlayKey.value = props.playKey;
animationKey.value = `${props.playKey}`;
impactEmitted.value = false;
activeShot.value = { ...props.shot };
phase.value = "playing";
queueTimer(() => {
+65 -24
View File
@@ -1,5 +1,13 @@
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
import {
ref,
watch,
onMounted,
onBeforeUnmount,
computed,
nextTick,
getCurrentInstance,
} from "vue";
import PointSwitcher from "@/components/PointSwitcher.vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
@@ -56,8 +64,6 @@ const props = defineProps({
const pMode = ref(true);
const latestOne = ref(null);
const bluelatestOne = ref(null);
const prevScores = ref([]);
const prevBlueScores = ref([]);
const timer = ref(null);
const dirTimer = ref(null);
const angle = ref(null);
@@ -66,7 +72,9 @@ const shotEffect = ref(null);
const hiddenRedLatestKey = ref("");
const hiddenBlueLatestKey = ref("");
const targetShaking = ref(false);
const targetSize = ref({ width: 0, height: 0 });
const shakeTimer = ref(null);
const instance = getCurrentInstance();
const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68;
@@ -82,8 +90,14 @@ function buildShotEffectKey(team, shot, index) {
].join("-");
}
function hasShotPoint(shot) {
const x = Number(shot?.x);
const y = Number(shot?.y);
return Number.isFinite(x) && Number.isFinite(y);
}
function shouldPlayShotEffect(shot) {
return props.isSvip && !!shot && Number(shot.ring) > 0;
return props.isSvip && !!shot && Number(shot.ring) > 0 && hasShotPoint(shot);
}
function clearTipTimer() {
@@ -154,6 +168,29 @@ function shakeTarget() {
});
}
function updateTargetSize() {
nextTick(() => {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery();
query
.select(".target")
.boundingClientRect((rect) => {
const width = Number(rect?.width);
const height = Number(rect?.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
if (width <= 0 || height <= 0) return;
targetSize.value = { width, height };
})
.exec();
});
}
function handleWindowResize() {
updateTargetSize();
}
function shouldHideRedHit(index) {
return !!hiddenRedLatestKey.value && index === props.scores.length - 1;
}
@@ -163,46 +200,44 @@ function shouldHideBlueHit(index) {
}
watch(
() => props.scores,
(newVal) => {
if (newVal.length - prevScores.value.length === 1) {
const latestShot = newVal[newVal.length - 1];
() => props.scores.length,
(newLen, oldLen) => {
if (newLen === oldLen + 1) {
const latestShot = props.scores[newLen - 1];
if (shouldPlayShotEffect(latestShot)) {
triggerShotEffect("red", latestShot, newVal.length - 1);
triggerShotEffect("red", latestShot, newLen - 1);
} else {
showShotTip("red", latestShot);
}
} else if (newVal.length <= prevScores.value.length) {
return;
}
if (newLen < oldLen) {
latestOne.value = null;
hiddenRedLatestKey.value = "";
if (shotEffect.value?.team === "red") shotEffect.value = null;
}
prevScores.value = [...newVal];
},
{
deep: true,
}
);
watch(
() => props.blueScores,
(newVal) => {
if (newVal.length - prevBlueScores.value.length === 1) {
const latestShot = newVal[newVal.length - 1];
() => props.blueScores.length,
(newLen, oldLen) => {
if (newLen === oldLen + 1) {
const latestShot = props.blueScores[newLen - 1];
if (shouldPlayShotEffect(latestShot)) {
triggerShotEffect("blue", latestShot, newVal.length - 1);
triggerShotEffect("blue", latestShot, newLen - 1);
} else {
showShotTip("blue", latestShot);
}
} else if (newVal.length <= prevBlueScores.value.length) {
return;
}
if (newLen < oldLen) {
bluelatestOne.value = null;
hiddenBlueLatestKey.value = "";
if (shotEffect.value?.team === "blue") shotEffect.value = null;
}
prevBlueScores.value = [...newVal];
},
{
deep: true,
}
);
@@ -349,6 +384,8 @@ async function onReceiveMessage(message) {
onMounted(() => {
uni.$on("socket-inbox", onReceiveMessage);
updateTargetSize();
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
});
onBeforeUnmount(() => {
@@ -365,6 +402,7 @@ onBeforeUnmount(() => {
shakeTimer.value = null;
}
uni.$off("socket-inbox", onReceiveMessage);
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
});
</script>
@@ -461,6 +499,9 @@ onBeforeUnmount(() => {
:shot="shotEffect && shotEffect.shot"
:playKey="shotEffect ? shotEffect.key : ''"
:targetRadius="safeTargetRadius"
:targetWidth="targetSize.width"
:targetHeight="targetSize.height"
:hitOffsetPx="currentHitRadiusPx"
@impact="shakeTarget"
@complete="completeShotEffect"
/>
+11 -9
View File
@@ -7,6 +7,7 @@ import ScreenHint from "@/components/ScreenHint.vue";
import BackToGame from "@/components/BackToGame.vue";
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
import { capsuleHeight, debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn";
import AudioManager from "@/audioManager";
const props = defineProps({
title: {
@@ -110,6 +111,15 @@ onShow(() => {
showHint.value = false;
});
const navigateTo = (url) =>
new Promise((resolve, reject) => {
uni.navigateTo({
url,
success: resolve,
fail: reject,
});
});
const backToGame = debounce(async () => {
if (isLoading.value) return; // 防止重复点击
@@ -118,15 +128,7 @@ const backToGame = debounce(async () => {
const result = await getBattleAPI();
if (result && result.matchId) {
await checkAudioProgress();
if (result.mode <= 3) {
uni.navigateTo({
url: `/pages/team-battle/index?battleId=${result.matchId}`,
});
} else {
uni.navigateTo({
url: `/pages/melee-battle?battleId=${result.matchId}`,
});
}
await returnToBattle(result, navigateTo);
}
} catch (error) {
console.error("获取当前游戏失败:", error);
+7 -2
View File
@@ -36,6 +36,7 @@ const createRoom = debounce(async () => {
}
if (loading.value === true) return;
loading.value = true;
let keepLoading = false;
let size = 2;
if (battleMode.value === 2) size = 10;
if (battleMode.value === 3) size = 4;
@@ -49,14 +50,18 @@ const createRoom = debounce(async () => {
if (result.number) {
props.onConfirm();
await joinRoomAPI(result.number);
keepLoading = true;
uni.navigateTo({
url: "/pages/battle-room?roomNumber=" + result.number + "&target=" + targetMode.value,
fail: () => {
loading.value = false;
},
});
}
} catch (error) {
console.log(error);
} finally {
loading.value = false;
if (!keepLoading) loading.value = false;
}
});
</script>
@@ -112,7 +117,7 @@ const createRoom = debounce(async () => {
<text>40厘米全环靶</text>
</view>
</view>
<SButton :onClick="() => $clickSound(createRoom)">创建房间</SButton>
<SButton :disabled="loading" :onClick="() => { $clickSound(); return createRoom(); }">创建房间</SButton>
</view>
</template>
+10 -10
View File
@@ -85,25 +85,25 @@ const handleUpdateClick = async () => {
<!-- 悬浮图标溢出卡片顶部 -->
<image
v-if="isNewVersion"
src="../static/ota/ota-mascot.png"
src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-mascot.png"
mode="aspectFit"
class="float-icon float-mascot"
/>
<image
v-else-if="isSuccess"
src="../static/ota/check-char.png"
src="https://static.shelingxingqiu.com/shootmini/static/ota/check-char.png"
mode="aspectFit"
class="float-icon float-check"
/>
<image
v-else-if="isFailure"
src="../static/ota/close-char.png"
src="https://static.shelingxingqiu.com/shootmini/static/ota/close-char.png"
mode="aspectFit"
class="float-icon float-close"
/>
<image
v-else-if="isProgress"
src="../static/ota/target-char.png"
src="https://static.shelingxingqiu.com/shootmini/static/ota/target-char.png"
mode="aspectFit"
class="float-icon float-target"
/>
@@ -111,7 +111,7 @@ const handleUpdateClick = async () => {
<!-- 弹窗卡片overflow:visible 允许按钮溢出底部背景图通过 ota-bg-clip 独立裁剪保持圆角 -->
<view class="ota-dialog">
<view class="ota-bg-clip">
<image src="../static/ota/ota-bg.png" mode="aspectFill" class="ota-bg" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="ota-bg" />
</view>
<view
class="ota-content"
@@ -120,9 +120,9 @@ const handleUpdateClick = async () => {
<!-- 发现新版本new-ver.png 已包含标题图不再重复文字版本号使用 ota-ver.png 胶囊背景 -->
<block v-if="isNewVersion">
<image src="../static/ota/new-ver.png" mode="aspectFit" class="new-ver-img" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/new-ver.png" mode="aspectFit" class="new-ver-img" />
<view v-if="version" class="version-tag-wrap">
<image src="../static/ota/ota-ver.png" mode="aspectFit" class="version-tag-bg-img" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-ver.png" mode="aspectFit" class="version-tag-bg-img" />
<text class="version-tag">{{ version }}</text>
</view>
<!-- 副标题新版本将优化智能弓体验离下方详情 12rpx -->
@@ -139,7 +139,7 @@ const handleUpdateClick = async () => {
<!-- 更新成功图片左边距 34rpx文案左边距 44rpx按钮浮动底部居中 -->
<block v-else-if="isSuccess">
<image src="../static/ota/update-ok.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-ok.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<text class="dialog-desc">请关机并重启智能弓</text>
<view class="btn-group-result">
<view class="primary-btn" @click="emit('done')">
@@ -150,7 +150,7 @@ const handleUpdateClick = async () => {
<!-- 更新中复用成功标题图正文区域展示进度条无底部按钮 -->
<block v-else-if="isProgress">
<image src="../static/ota/update_progress.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update_progress.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
<view class="progress-wrap">
<view class="progress-track">
<view class="progress-fill" :style="{ width: `${progressValue}%` }"></view>
@@ -160,7 +160,7 @@ const handleUpdateClick = async () => {
<!-- 更新失败图片左边距 34rpx文案左对齐 44rpx按钮浮动底部居中 -->
<block v-else-if="isFailure">
<image src="../static/ota/update-fail.png" mode="aspectFit" class="result-title-img" style="width: 222rpx; height: 62rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-fail.png" mode="aspectFit" class="result-title-img" style="width: 222rpx; height: 62rpx;" />
<text class="dialog-desc">请确保</text>
<text class="dialog-desc">1智能弓已开启</text>
<text class="dialog-desc">2网路连接稳定</text>
+1 -1
View File
@@ -143,7 +143,7 @@ const openCoachComment = () => {
}}</text
>环的成绩所有箭支上靶后的平均点间距为<text
:style="{ color: '#fed847' }"
>{{ Number((result.average_distance || 0).toFixed(2)) }}</text
>{{ Number((result?.interpretation?.spreadStability || 0).toFixed(2)) }}</text
>{{
result.spreadEvaluation === "Dispersed"
? "还需要持续改进哦~"
+6 -1
View File
@@ -38,6 +38,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
halfRest: {
type: Boolean,
default: false,
},
onStop: {
type: Function,
default: () => {},
@@ -136,8 +140,9 @@ const updateSound = () => {
async function onReceiveMessage(msg) {
if (Array.isArray(msg)) return;
if (msg.type === MESSAGETYPESV2.BattleStart) {
const audioKey = props.melee && (halfTime.value || props.halfRest) ? "下半场开始" : "比赛开始";
halfTime.value = false;
audioManager.play("比赛开始");
audioManager.play(audioKey);
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play("比赛结束", false);
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
+6 -5
View File
@@ -11,6 +11,7 @@ import {
loginAPI,
getHomeData,
getPhoneNumberAPI,
getPhoneNumberAPIv2,
getDeviceBatteryAPI,
} from "@/apis";
@@ -43,12 +44,12 @@ const handleAgree = () => {
async function getphonenumber(e) {
if (e.detail.code) {
const wxResult = await wxLogin();
const result = await getPhoneNumberAPI({
...e.detail,
code: wxResult.code,
// const wxResult = await wxLogin();
const result = await getPhoneNumberAPIv2({
// ...e.detail,
code: e.detail.code,
});
if (result.phone) phone.value = result.phone;
if (result.purePhoneNumber) phone.value = result.purePhoneNumber;
}
}
+1
View File
@@ -1,6 +1,7 @@
export const MESSAGETYPES = {
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
ShootSyncBattleCreateMatchLinkID: parseInt("0xF86FF466"), // 2538869862
WaitForAllReady: parseInt("0x615C13BE"), // 1633424318
AllReady: parseInt("0x1CCB49FD"), // 483084797
MeleeAllReady: parseInt("0x37132BD5"), // 924003285
+607
View File
@@ -0,0 +1,607 @@
import {
ClientMessageType,
ServerMessageType,
createAckMessage,
createHeartbeatAckMessage,
createLeaveMessage,
decodeServerMessage,
getServerMessageTypeName,
} from "@/utils/matchProtocol";
import { MESSAGETYPESV2 } from "@/constants";
import { getDirectionText } from "@/util";
import {
normalizeId,
normalizeMatchInfo,
normalizePlainObject,
pickField,
} from "@/utils/matchAdapter";
// 比赛服 websocket 独立管理器:
// 负责连接、解码、ACK/LEAVE,并把比赛服消息适配成项目现有 socket-inbox 业务事件。
let socket = null;
let currentContext = null;
let isConnecting = false;
let manualClose = false;
let audioAckListenerReady = false;
let lastReadyRouteKey = "";
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
const pendingAcks = [];
const ACK_AUDIO_TIMEOUT_MS = 9000;
const READY_ROUTE_FALLBACK_DELAY_MS = 300;
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
// 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。
const BUSINESS_TYPE_BY_SERVER_TYPE = {
[ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart,
[ServerMessageType.SERVER_MSG_MATCH_START]: MESSAGETYPESV2.BattleStart,
[ServerMessageType.SERVER_MSG_NOW_YOU]: MESSAGETYPESV2.ToSomeoneShoot,
[ServerMessageType.SERVER_MSG_SHOT]: MESSAGETYPESV2.ShootResult,
[ServerMessageType.SERVER_MSG_NEW_ROUND]: MESSAGETYPESV2.NewRound,
[ServerMessageType.SERVER_MSG_MATCH_END]: MESSAGETYPESV2.BattleEnd,
[ServerMessageType.SERVER_MSG_TIMEOUT]: MESSAGETYPESV2.BattleEnd,
[ServerMessageType.SERVER_MSG_CHECK]: MESSAGETYPESV2.TestDistance,
[ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot,
};
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
function normalizeShootData(shootData) {
if (!shootData || typeof shootData !== "object") return shootData;
const normalized = { ...shootData };
if (normalized.distance === undefined && normalized.dst !== undefined) {
normalized.distance = normalized.dst;
}
return normalized;
}
// 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。
function buildBusinessMessage(message) {
const businessType = BUSINESS_TYPE_BY_SERVER_TYPE[message.type];
if (!businessType) return null;
const matchInfo = normalizeMatchInfo(message.match_info);
const matchId = normalizeId(
pickField(message, "matchId", "match_id") ||
matchInfo.matchId ||
currentContext?.matchId
);
const shootData = normalizeShootData(
matchInfo.shootData ||
(message.shoot_data ? normalizePlainObject(message.shoot_data) : undefined)
);
return {
...matchInfo,
type: businessType,
id: matchId,
matchId,
shootData,
sequence: message.sequence,
timestamp: message.timestamp,
matchWsType: message.type,
matchWsTypeName: getServerMessageTypeName(message.type),
};
}
function getReadySnapshotKey(matchId) {
return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(matchId)}`;
}
function persistReadyTeams(message) {
const teams = message?.teams || {};
if (Number(message?.mode) > 3) return;
const bluePlayers = teams[1]?.players || teams["1"]?.players;
const redPlayers = teams[2]?.players || teams["2"]?.players;
if (Array.isArray(bluePlayers)) uni.setStorageSync("blue-team", bluePlayers);
if (Array.isArray(redPlayers)) uni.setStorageSync("red-team", redPlayers);
}
function persistReadySnapshot(message) {
const matchId = normalizeId(message?.matchId || message?.id);
if (!matchId) return;
uni.setStorageSync(getReadySnapshotKey(matchId), {
...message,
savedAt: Date.now(),
});
}
function getBattlePageUrl(message) {
const matchId = normalizeId(message?.matchId || message?.id);
const mode = Number(message?.mode);
if (!matchId) return "";
if (!Number.isFinite(mode)) return "";
return mode <= 3
? `/pages/team-battle/index?battleId=${matchId}`
: `/pages/melee-battle?battleId=${matchId}`;
}
function getCurrentPageInfo() {
if (typeof getCurrentPages !== "function") return null;
const pages = getCurrentPages();
return pages[pages.length - 1] || null;
}
function normalizeRoute(route) {
return String(route || "").replace(/^\//, "");
}
function isCurrentBattlePage(matchId) {
const page = getCurrentPageInfo();
const route = normalizeRoute(page?.route);
if (route !== "pages/team-battle/index" && route !== "pages/melee-battle") {
return false;
}
const options = page?.options || page?.$page?.options || {};
return !matchId || normalizeId(options.battleId) === normalizeId(matchId);
}
function scheduleReadyRouteFallback(message) {
const url = getBattlePageUrl(message);
const matchId = normalizeId(message?.matchId || message?.id);
const routeKey = `${matchId}:${url}`;
if (!url || lastReadyRouteKey === routeKey) return;
lastReadyRouteKey = routeKey;
setTimeout(() => {
if (isCurrentBattlePage(matchId)) return;
const currentRoute = normalizeRoute(getCurrentPageInfo()?.route);
// battle-room 和 match-page 本身会消费 AboutToStart;这里不绕过页面内状态,避免好友房误触退房逻辑。
if (currentRoute === "pages/battle-room" || currentRoute === "pages/match-page") return;
uni.redirectTo({
url,
fail: (err) => {
console.log("[match-ws] ready route fallback failed", err, { url });
},
});
}, READY_ROUTE_FALLBACK_DELAY_MS);
}
function emitBusinessMessage(message) {
if (!message) return;
if (message.type === MESSAGETYPESV2.AboutToStart) {
persistReadyTeams(message);
persistReadySnapshot(message);
scheduleReadyRouteFallback(message);
}
uni.$emit("socket-inbox", message);
}
function resolvePlayerTeam(message, playerId) {
const teams = message?.teams || {};
const redPlayers = teams[2]?.players || teams["2"]?.players || [];
return redPlayers.some((item) => String(item.id) === String(playerId))
? "red"
: "blue";
}
function getNowYouAudioKeys(message) {
const current = message?.current || {};
const keys = [];
if (current.goldRound) {
keys.push("决金箭轮");
} else if (current.round) {
const roundIndex = Math.max(0, Number(current.round) - 1);
const roundName = ROUND_AUDIO_NAMES[roundIndex] || current.round;
keys.push(`${roundName}`);
}
if (String(current.playerId) === String(currentContext?.userId)) {
keys.push("轮到你了");
return keys;
}
const team = resolvePlayerTeam(message, current.playerId);
keys.push(team === "red" ? "请红方射箭" : "请蓝方射箭");
return keys;
}
function getShootResultAudioKeys(shootData) {
if (!shootData) return [];
const keys = [
shootData.ring ? `${shootData.ringX ? "X" : shootData.ring}` : "未上靶",
];
if (shootData.angle !== null && shootData.angle !== undefined) {
keys.push(`${getDirectionText(shootData.angle)}调整`);
}
return keys;
}
function getTestDistanceAudioKeys(shootData) {
const distance = Number(shootData?.distance ?? shootData?.dst);
if (Number.isNaN(distance)) return [];
return [distance / 100 >= 5 ? "\u8ddd\u79bb\u5408\u683c" : "\u8ddd\u79bb\u4e0d\u8db3"];
}
function getAckAudioKeys(message, businessMessage) {
switch (message.type) {
case ServerMessageType.SERVER_MSG_MATCH_START:
return ["比赛开始"];
case ServerMessageType.SERVER_MSG_NOW_YOU:
return getNowYouAudioKeys(businessMessage);
case ServerMessageType.SERVER_MSG_SHOT:
return getShootResultAudioKeys(businessMessage?.shootData);
case ServerMessageType.SERVER_MSG_MATCH_END:
case ServerMessageType.SERVER_MSG_TIMEOUT:
return ["比赛结束"];
case ServerMessageType.SERVER_MSG_CHECK:
return getTestDistanceAudioKeys(businessMessage?.shootData);
case ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE:
return ["射击无效"];
default:
return [];
}
}
function shouldCloseAfterAck(message) {
return (
message.type === ServerMessageType.SERVER_MSG_MATCH_END ||
message.type === ServerMessageType.SERVER_MSG_TIMEOUT
);
}
// 追加 token 查询参数;如果后端地址已经带 token,则保持原样。
function appendQuery(url, key, value) {
if (!value || new RegExp(`[?&]${key}=`).test(url)) return url;
return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(
value
)}`;
}
// 后端可能下发完整 ws 地址,也可能只给 host:port,这里统一转成可连接地址。
function normalizeServerUrl(serverAddr, token) {
if (!serverAddr || typeof serverAddr !== "string") return "";
let url = serverAddr.trim();
if (!url) return "";
if (!/^wss?:\/\//i.test(url)) {
const [address, query = ""] = url.split("?");
const hasPath = address.includes("/");
const host = hasPath
? address
: `${address}${address.includes(":") ? "" : ":8011"}/ws`;
url = `ws://${host}${query ? `?${query}` : ""}`;
}
return appendQuery(url, "token", token);
}
// 统一发送二进制 protobuf 消息,并在日志里保留发送类型。
function sendBuffer(buffer, label, clientMessage) {
if (!socket || !buffer) return;
const currentSocket = socket;
currentSocket.send({
data: buffer,
success: () => {
console.log("发送比赛服 WebSocket 消息", label, clientMessage);
},
fail: (err) => {
if (socket !== currentSocket) return;
console.log("比赛服 WebSocket 消息发送失败", label, clientMessage, err);
},
});
}
function sendHeartbeatAck() {
// 心跳 ACK 不进入 pendingAcks,收到后立即回复。
const clientMessage = {
type: ClientMessageType.CLIENT_MSG_HEARTBEAT_ACK,
};
sendBuffer(
createHeartbeatAckMessage(),
"CLIENT_MSG_HEARTBEAT_ACK",
clientMessage
);
}
function sendAck({ matchId, sequence }) {
// sequence 由后端处理,前端只原样带回,不做重排和断线补发。
if (sequence === undefined || sequence === null || sequence === "") return;
const clientMessage = {
type: ClientMessageType.CLIENT_MSG_ACK,
match_id: matchId,
sequence,
};
sendBuffer(
createAckMessage({ matchId, sequence }),
"CLIENT_MSG_ACK",
clientMessage
);
}
function completeAckTask(task) {
if (task?.timer) clearTimeout(task.timer);
sendAck(task);
if (task.leaveAfterAck) {
setTimeout(() => {
closeMatchWebSocket({ reason: "match-end" });
}, 0);
}
}
function isSameAckTask(task, payload = {}) {
if (String(task?.sequence) !== String(payload.sequence)) return false;
if (!payload.matchId) return true;
return String(task?.matchId) === String(payload.matchId);
}
function completePendingAckByMessage(payload = {}) {
if (payload.sequence === undefined || payload.sequence === null || payload.sequence === "") {
return;
}
const index = pendingAcks.findIndex((task) => isSameAckTask(task, payload));
if (index === -1) return;
const [task] = pendingAcks.splice(index, 1);
console.log("[match-ws] ack after page audio", task.sequence, payload);
completeAckTask(task);
}
function flushPendingAcks() {
// 每次语音播报完成,只确认队首那条等待相同语音 key 的服务端消息。
if (!pendingAcks.length) return;
const endedKey = arguments[0];
const task = pendingAcks[0];
if (task.expectedAudioKey && endedKey && task.expectedAudioKey !== endedKey) {
return;
}
completeAckTask(pendingAcks.shift());
}
function ensureAudioAckListener() {
// 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。
if (audioAckListenerReady) return;
uni.$on("audioEnded", flushPendingAcks);
uni.$on(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
audioAckListenerReady = true;
}
function removeAudioAckListener() {
if (!audioAckListenerReady) return;
uni.$off("audioEnded", flushPendingAcks);
uni.$off(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
audioAckListenerReady = false;
}
function queueAckAfterAudio(message, businessMessage) {
// 除心跳外,只要服务端带了 sequence,都需要走 ACK;没有语音的消息立即 ACK。
if (
message.sequence === undefined ||
message.sequence === null ||
message.sequence === ""
) {
return;
}
const task = {
matchId: normalizeId(
pickField(message, "matchId", "match_id") || currentContext?.matchId
),
sequence: message.sequence,
leaveAfterAck: shouldCloseAfterAck(message),
};
const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
if (!audioKeys.length) {
console.log(
"[match-ws] ack immediately without audio",
getServerMessageTypeName(message.type),
message.sequence
);
completeAckTask(task);
return;
}
task.expectedAudioKey = audioKeys[audioKeys.length - 1];
task.timer = setTimeout(() => {
const index = pendingAcks.indexOf(task);
if (index === -1) return;
pendingAcks.splice(index, 1);
console.log(
"[match-ws] ack audio wait timeout",
getServerMessageTypeName(message.type),
message.sequence,
task.expectedAudioKey
);
completeAckTask(task);
}, ACK_AUDIO_TIMEOUT_MS);
pendingAcks.push(task);
console.log(
"[match-ws] ack queued until audioEnded",
getServerMessageTypeName(message.type),
message.sequence,
task.expectedAudioKey
);
}
function handleMessage(data) {
// 比赛服下发 raw protobuf frame,先解码,再根据消息类型决定是否 ACK。
let message;
try {
message = decodeServerMessage(data);
} catch (err) {
console.log("[match-ws] decode failed", err, data);
return;
}
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
if (decodedMatchId && currentContext) {
currentContext.matchId = decodedMatchId;
}
if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
sendHeartbeatAck();
return;
}
const typeName = getServerMessageTypeName(message.type);
console.log("收到比赛服 WebSocket 消息", typeName, message);
const businessMessage = buildBusinessMessage(message);
if (businessMessage?.matchId && currentContext) {
currentContext.matchId = businessMessage.matchId;
}
queueAckAfterAudio(message, businessMessage);
emitBusinessMessage(businessMessage);
}
function sendLeave() {
// 主动关闭、比赛结束、页面离开时发 CLIENT_MSG_LEAVE。
if (!socket || !currentContext?.matchId) return;
const clientMessage = {
type: ClientMessageType.CLIENT_MSG_LEAVE,
match_id: currentContext.matchId,
user_id: currentContext.userId,
};
sendBuffer(
createLeaveMessage({
matchId: clientMessage.match_id,
userId: clientMessage.user_id,
}),
"CLIENT_MSG_LEAVE",
clientMessage
);
}
export function connectMatchWebSocket(options = {}) {
// 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。
const { serverAddr, matchId, userId, token } = options;
const url = normalizeServerUrl(serverAddr, token);
const normalizedMatchId = normalizeId(matchId);
const normalizedUserId = normalizeId(userId);
if (!url || !normalizedMatchId) {
console.log("[match-ws] missing serverAddr or matchId", options);
return;
}
if (
socket &&
currentContext?.url === url &&
currentContext?.matchId === normalizedMatchId
) {
// 同一场比赛同一地址重复通知时不重复建连。
console.log("[match-ws] already connected or connecting", {
matchId: normalizedMatchId,
url,
});
return;
}
// 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。
closeMatchWebSocket({ sendLeave: false, reason: "switch" });
manualClose = false;
isConnecting = true;
currentContext = {
serverAddr,
matchId: normalizedMatchId,
userId: normalizedUserId,
url,
};
ensureAudioAckListener();
const socketTask = uni.connectSocket({
url,
success: () => {
console.log("[match-ws] connect requested", {
matchId: normalizedMatchId,
url,
});
},
fail: (err) => {
if (socket !== socketTask) return;
socket = null;
isConnecting = false;
console.log("[match-ws] connect failed", err);
},
});
socket = socketTask;
socketTask.onOpen(() => {
if (socket !== socketTask) return;
isConnecting = false;
console.log("[match-ws] connected", {
matchId: normalizedMatchId,
url,
});
});
socketTask.onMessage((res) => {
if (socket !== socketTask) return;
handleMessage(res.data);
});
socketTask.onError((err) => {
if (socket !== socketTask) return;
console.log("[match-ws] socket error", err);
});
socketTask.onClose((result) => {
if (socket !== socketTask) return;
socket = null;
isConnecting = false;
console.log("[match-ws] closed", {
manualClose,
result,
context: currentContext,
});
if (!manualClose) removeAudioAckListener();
});
}
export function connectMatchWebSocketFromNotice(notice, fallbackUserId) {
// 原 websocket 下发的比赛服通知统一从这里转成连接参数。
if (!notice) return;
connectMatchWebSocket({
serverAddr: pickField(notice, "serverAddr", "server_addr"),
matchId: pickField(notice, "matchId", "match_id"),
userId: pickField(notice, "userId", "user_id") || fallbackUserId,
token: notice.token || notice.wsToken || notice.ws_token,
});
}
export function closeMatchWebSocket(options = {}) {
// 默认关闭时会发送 LEAVE;内部切换连接可通过 sendLeave:false 跳过。
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
manualClose = true;
pendingAcks.forEach((task) => {
if (task?.timer) clearTimeout(task.timer);
});
pendingAcks.length = 0;
if (socket) {
const currentSocket = socket;
if (shouldSendLeave) sendLeave();
socket = null;
try {
currentSocket.close();
} catch (err) {
console.log("[match-ws] close failed", err);
}
}
isConnecting = false;
removeAudioAckListener();
console.log("[match-ws] close requested", { reason, currentContext });
currentContext = null;
}
export default {
ClientMessageType,
ServerMessageType,
connectMatchWebSocket,
connectMatchWebSocketFromNotice,
closeMatchWebSocket,
};
+13 -4
View File
@@ -15,7 +15,7 @@ import {
getReadyAPI,
kickPlayerAPI,
} from "@/apis";
import { isLimitError } from "@/util";
import { debounce, isLimitError } from "@/util";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -59,6 +59,7 @@ const allReady = ref(false);
const timer = ref(null);
const goBattle = ref(false);
const showLimitModal = ref(false);
const readySubmitting = ref(false);
/** 从结算页返回时为 true,跳过进场靶纸语音 */
const skipTargetAudio = ref(false);
@@ -139,17 +140,22 @@ async function refreshRoomData() {
// timer.value = setTimeout(refreshRoomData, 2000);
}
const getReady = async () => {
const getReady = debounce(async () => {
if (readySubmitting.value) return;
readySubmitting.value = true;
try {
await getReadyAPI(roomNumber.value);
ready.value = true;
} catch (error) {
if (isLimitError(error)) {
showLimitModal.value = true;
return;
}
console.log("room ready error", error);
} finally {
readySubmitting.value = false;
}
};
});
const closeLimitModal = () => {
showLimitModal.value = false;
@@ -465,7 +471,10 @@ onBeforeUnmount(() => {
<PlayerSeats v-if="room.battleType === 2" :total="room.count || 10" :players="players"
:removePlayer="removePlayer" :isOwner="owner.id === user.id" />
<view>
<SButton :disabled="!canClick" :onClick="() => $clickSound(getReady)">
<SButton
:disabled="!canClick || readySubmitting"
:onClick="() => { $clickSound(); return getReady(); }"
>
{{
allReady.value
? "即将进入对局..."
+5
View File
@@ -29,6 +29,7 @@ import { storeToRefs } from "pinia";
const store = useStore();
const { user } = storeToRefs(store);
const scores = ref([]);
const isSvip = ref(false);
const step = ref(0);
const total = 12;
const stepButtonTexts = [
@@ -114,6 +115,7 @@ const onOver = async () => {
async function onReceiveMessage(msg) {
if (msg.type === MESSAGETYPESV2.ShootResult) {
isSvip.value = msg.sVip === true;
scores.value = msg.details;
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setTimeout(onOver, 1500);
@@ -204,6 +206,7 @@ const nextStep = async () => {
title.value = "小试牛刀";
await startPractiseAPI();
scores.value = [];
isSvip.value = false;
step.value = 5;
start.value = true;
setTimeout(() => {
@@ -230,6 +233,7 @@ const onClose = async () => {
practiseResult.value = {};
start.value = false;
scores.value = [];
isSvip.value = false;
step.value = 4;
const result = await createPractiseAPI(total, 120);
if (result) practiseId.value = result.id;
@@ -343,6 +347,7 @@ const onClose = async () => {
:currentRound="step === 5 ? scores.length : 0"
:totalRound="step === 5 ? total : 0"
:scores="scores"
:isSvip="isSvip"
/>
<ScorePanel
v-if="step === 5"
+29 -11
View File
@@ -27,6 +27,7 @@ const data = ref({});
const roomID = ref("");
const loading = ref(false);
const showLimitModal = ref(false);
const createRoomLoading = ref(false);
const isSVip = computed(() => user.value.sVip === true);
const isVip = computed(() => user.value.vip === true && !isSVip.value);
const challengeLimitText = computed(() =>
@@ -45,6 +46,8 @@ const enterRoom = debounce(async (number) => {
showModal.value = true;
return;
}
loading.value = true;
let keepLoading = false;
try {
const room = await getRoomAPI(number);
if (!room.number) {
@@ -63,24 +66,33 @@ const enterRoom = debounce(async (number) => {
return;
}
}
loading.value = true;
keepLoading = true;
uni.navigateTo({
url: "/pages/battle-room?roomNumber=" + number,
fail: () => {
loading.value = false;
},
});
} finally {
loading.value = false;
if (!keepLoading) loading.value = false;
}
});
const onCreateRoom = async () => {
const onCreateRoom = debounce(async () => {
if (createRoomLoading.value) return;
if (!canEenter(user.value, device.value, online.value)) return;
const countData = await loadDailyCount();
if (isLimitReached(countData.challenge)) {
showLimitModal.value = true;
return;
createRoomLoading.value = true;
try {
const countData = await loadDailyCount();
if (isLimitReached(countData.challenge)) {
showLimitModal.value = true;
return;
}
warnning.value = "";
showModal.value = true;
} finally {
createRoomLoading.value = false;
}
warnning.value = "";
showModal.value = true;
};
});
const closeLimitModal = () => {
showLimitModal.value = false;
@@ -116,6 +128,7 @@ const goMyRecord = () => {
});
};
onShow(async () => {
loading.value = false;
if (user.value.id) {
const [result] = await Promise.all([
getBattleDataAPI(),
@@ -208,7 +221,12 @@ onLoad(async (options) => {
<view v-if="challengeLimitText" class="pp-text">
{{ challengeLimitText }}
</view>
<SButton width="80%" :rounded="30" :onClick="() => $clickSound(onCreateRoom)">
<SButton
width="80%"
:rounded="30"
:disabled="createRoomLoading"
:onClick="() => { $clickSound(); return onCreateRoom(); }"
>
创建约战房
</SButton>
</view>
+146 -7
View File
@@ -13,7 +13,13 @@ import TestDistance from "@/components/TestDistance.vue";
import SModal from "@/components/SModal.vue";
import audioManager from "@/audioManager";
import { getBattleAPI, laserCloseAPI } from "@/apis";
import {
closeMatchWebSocket,
connectMatchWebSocket,
MATCH_WS_AUDIO_ACK_EVENT,
} from "@/matchWebsocket";
import { MESSAGETYPESV2 } from "@/constants";
import { takeMatchReturnSnapshot } from "@/utils/matchReturn";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
@@ -31,12 +37,22 @@ const playersScores = ref([]);
const halfTimeTip = ref(false);
const halfRest = ref(false);
const HALF_REST_SECONDS = 20;
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
const MATCH_STATUS_BY_TEXT = {
MATCH_STATUS_READY: 0,
MATCH_STATUS_STARTED: 1,
MATCH_STATUS_END: 2,
MATCH_STATUS_TIMEOUT: 3,
MATCH_STATUS_UNEXPECTEDLY: 4,
};
const halfRestRemain = ref(HALF_REST_SECONDS);
let halfRestTimer = null;
/** 控制设备离线提示弹窗的显示状态 */
const showOfflineModal = ref(false);
/** 记录每位玩家当前半场连续 10 环及以上次数,key 为 playerId,用于触发 tententen 音效 */
const xRingStreaks = ref({});
let battleEnded = false;
let skipNextRestoreOnShow = false;
function clearHalfRestCountdown() {
if (halfRestTimer) {
@@ -93,6 +109,90 @@ const currentPlayer = computed(() =>
);
const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true);
function getReadySnapshotKey(matchId) {
return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`;
}
function normalizeBattleInfo(battleInfo) {
if (!battleInfo || typeof battleInfo !== "object") return battleInfo;
const statusText = battleInfo.statusText || battleInfo.status_text;
if (
(battleInfo.status === undefined ||
battleInfo.status === null ||
battleInfo.status === "") &&
MATCH_STATUS_BY_TEXT[statusText] !== undefined
) {
return {
...battleInfo,
status: MATCH_STATUS_BY_TEXT[statusText],
};
}
return battleInfo;
}
function hasKnownStatus(battleInfo) {
return !(
battleInfo?.status === undefined ||
battleInfo?.status === null ||
battleInfo?.status === ""
);
}
function hasStartedSnapshot(battleInfo) {
if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0;
const current = battleInfo?.current || {};
return !!(
current.playerId ||
current.startTime ||
current.round ||
(Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length)
);
}
function takeReadySnapshot(matchId) {
const key = getReadySnapshotKey(matchId);
const snapshot = uni.getStorageSync(key);
if (snapshot) uni.removeStorageSync(key);
return normalizeBattleInfo(snapshot);
}
function reconnectMatchServer(battleInfo) {
const status = Number(battleInfo?.status);
if ([2, 3, 4].includes(status)) return;
if (!battleInfo?.serverAddr) return;
connectMatchWebSocket({
serverAddr: battleInfo.serverAddr,
matchId: battleInfo.matchId || battleId.value,
userId: user.value.id,
});
}
function closeBattleServer(reason) {
if (battleEnded) return;
closeMatchWebSocket({ reason });
}
function notifyMatchAudioAck(message) {
if (
message?.sequence === undefined ||
message?.sequence === null ||
message?.sequence === ""
) {
return;
}
uni.$emit(MATCH_WS_AUDIO_ACK_EVENT, {
matchId: message.matchId || battleId.value,
sequence: message.sequence,
matchWsType: message.matchWsType,
matchWsTypeName: message.matchWsTypeName,
});
}
/**
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
*/
@@ -103,7 +203,11 @@ watch(online, (newVal, oldVal) => {
});
function recoverData(battleInfo, { force = false } = {}) {
battleInfo = normalizeBattleInfo(battleInfo);
if (!battleInfo) return;
if (battleInfo.status !== undefined) {
battleEnded = [2, 4].includes(Number(battleInfo.status));
}
try {
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
if (battleInfo.way === 2) title.value = "排位赛 - 大乱斗";
@@ -121,19 +225,27 @@ function recoverData(battleInfo, { force = false } = {}) {
players.value = [];
}
start.value = battleInfo.status !== 0;
if (hasKnownStatus(battleInfo)) {
start.value = battleInfo.status !== 0;
} else if (!hasStartedSnapshot(battleInfo)) {
if (start.value !== true) start.value = false;
return;
} else {
start.value = true;
}
if (battleInfo.status === 0) {
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
const readyRemain = (Date.now() - (battleInfo.serverTime || Date.now())) / 1000;
if (readyRemain > 0 && readyRemain < 15) {
setTimeout(() => uni.$emit("update-timer", 15 - readyRemain - 0.2), 200);
}
return;
}
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
tips.value =
(battleInfo.rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
playersScores.value = battleInfo.rounds.map((r) => ({ ...r.shoots }));
(rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
playersScores.value = rounds.map((r) => ({ ...r.shoots }));
const totals = {};
players.value.forEach((p) => {
const total = playersScores.value.reduce((acc, round) => {
@@ -170,7 +282,18 @@ function recoverData(battleInfo, { force = false } = {}) {
}
onLoad(async (options) => {
if (options.battleId) battleId.value = options.battleId;
const returnSnapshot = options.fromReturn ? takeMatchReturnSnapshot() : null;
skipNextRestoreOnShow = false;
if (returnSnapshot?.matchId) battleId.value = returnSnapshot.matchId;
else if (options.battleId) battleId.value = options.battleId;
if (returnSnapshot) {
skipNextRestoreOnShow = true;
reconnectMatchServer(returnSnapshot);
recoverData(returnSnapshot, { force: true });
return;
}
const readySnapshot = takeReadySnapshot(battleId.value);
if (readySnapshot?.status === 0) recoverData(readySnapshot);
// uni.enableAlertBeforeUnload({
// message: "离开比赛可能导致比赛失败,是否继续?",
// success: (res) => {
@@ -207,7 +330,9 @@ function checkAndPlayTententen(playerId, isTenPlusRingShot) {
async function onReceiveMessage(msg) {
if (Array.isArray(msg)) return;
if (msg.type === MESSAGETYPESV2.BattleStart) {
if (msg.type === MESSAGETYPESV2.AboutToStart) {
recoverData(msg);
} else if (msg.type === MESSAGETYPESV2.BattleStart) {
clearHalfRestCountdown();
halfTimeTip.value = false;
halfRest.value = false;
@@ -241,6 +366,8 @@ async function onReceiveMessage(msg) {
tips.value = "准备下半场";
startHalfRestCountdown();
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
battleEnded = true;
notifyMatchAudioAck(msg);
setTimeout(() => {
// 全部跳转到新结算页
uni.redirectTo({
@@ -262,14 +389,24 @@ onBeforeUnmount(() => {
});
clearHalfRestCountdown();
uni.$off("socket-inbox", onReceiveMessage);
closeBattleServer("melee-battle-unmount");
audioManager.stopAll();
});
onHide(() => {
closeBattleServer("melee-battle-hide");
});
onShow(async () => {
if (skipNextRestoreOnShow) {
skipNextRestoreOnShow = false;
return;
}
if (battleId.value) {
const result = await getBattleAPI(battleId.value);
const result = normalizeBattleInfo(await getBattleAPI(battleId.value));
if (!result) return;
if (result.status === 2) {
battleEnded = true;
uni.showToast({
title: "比赛已结束",
icon: "none",
@@ -278,6 +415,7 @@ onShow(async () => {
delta: 2,
});
} else {
reconnectMatchServer(result);
recoverData(result, { force: true });
}
}
@@ -295,6 +433,7 @@ onShow(async () => {
:tips="tips"
:total="90"
:melee="true"
:halfRest="halfRest"
:battleId="battleId"
/>
<view v-if="start" class="user-row">
+135 -126
View File
@@ -1,10 +1,10 @@
<script setup>
import { computed, ref, onBeforeUnmount } from "vue";
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import Signin from "@/components/Signin.vue";
import { createOrderAPI, getAppConfig, getHomeData } from "@/apis";
import { capsuleHeight } from "@/util";
import { virtualPayOrderAPI, getAppConfig, getHomeData, getOrderDetailAPI } from "@/apis";
import { capsuleHeight, wxLogin } from "@/util";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -18,11 +18,8 @@ const showModal = ref(false);
const loadingConfig = ref(false);
const paying = ref(false);
const refreshing = ref(false);
const timer = ref(null);
const lastDate = ref(user.value.expiredAt || 0);
const maxRefreshTimes = 12;
// 会员页核心展示数据:视觉、权益、套餐均按蓝湖当前两张设计稿拆分。
// 会员页核心展示数据:视觉、权益按蓝湖当前两张设计稿拆分,套餐完全使用接口数据
const memberTypes = [
{
key: "normal",
@@ -44,11 +41,6 @@ const memberTypes = [
{ label: "排位赛\n每日+20次", icon: "../../static/vip/vip-rank.png" },
{ label: "约战\n每日+20次", icon: "../../static/vip/vip-battle.png" },
],
packages: [
{ name: "连续包月", price: "20", original: "35" },
{ name: "12个月", price: "300", original: "420" },
{ name: "3个月", price: "84", original: "105" },
],
},
{
key: "super",
@@ -73,11 +65,6 @@ const memberTypes = [
{ label: "排位赛无限制", icon: "../../static/vip/svip-rank.png" },
{ label: "专享SVIP客服", icon: "../../static/vip/svip-service.png" },
],
packages: [
{ name: "连续包月", price: "20", original: "35" },
{ name: "12个月", price: "300", original: "420" },
{ name: "3个月", price: "84", original: "105" },
],
},
];
@@ -100,16 +87,6 @@ const getVipExpiredValue = (type, source = user.value) => {
return type.key === "super" ? source.superVipExpiredAt : source.normalVipExpiredAt;
};
// 支付后轮询需要比较最新会员到期时间,兼容旧字段 expiredAt。
const getLatestVipExpiredTime = (source = user.value) => {
if (!source) return 0;
return Math.max(
toTimestamp(source.normalVipExpiredAt),
toTimestamp(source.superVipExpiredAt),
toTimestamp(source.expiredAt)
);
};
// 未过期才展示“会员生效中”样式,已过期或无值继续展示未开通样式。
const isVipActive = (type) => {
return toTimestamp(getVipExpiredValue(type)) > Date.now();
@@ -153,9 +130,34 @@ const getMenuName = (item) => {
return item.name || item.vipName || item.title || "";
};
const formatPrice = (value) => {
if (value === undefined || value === null || value === "") return "";
return String(value).replace("¥", "").replace("¥", "");
};
const getMenuPrice = (item) => {
const value = item.price || item.total || item.amount || item.money;
return value ? String(value).replace("¥", "").replace("¥", "") : "";
return formatPrice(item && item.price);
};
const getMenuOriginalPrice = (item) => {
return formatPrice(item && item.originalPrice);
};
const getMenuSortValue = (item) => {
const value = item && item.sort;
if (value === undefined || value === null || value === "") return Number.MAX_SAFE_INTEGER;
const sort = Number(value);
return Number.isFinite(sort) ? sort : Number.MAX_SAFE_INTEGER;
};
const sortMenusBySort = (menus = []) => {
return menus
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const sortDiff = getMenuSortValue(a.item) - getMenuSortValue(b.item);
return sortDiff || a.index - b.index;
})
.map(({ item }) => item);
};
const getMenuType = (item) => {
@@ -173,41 +175,19 @@ const getMenuType = (item) => {
return "";
};
const getPackageMonths = (name) => {
if (/连续包月/.test(name)) return 1;
const match = String(name || "").match(/(\d+)\s*个?月/);
return match ? Number(match[1]) : 0;
};
const matchPackageSource = (type, pack, index) => {
const menus = configMenus.value;
const typedMenus = menus.filter((item) => {
const menuType = getMenuType(item);
if (type.key === "super") return menuType === "super";
return menuType === "normal";
});
const pool = typedMenus.length ? typedMenus : menus;
const packMonths = getPackageMonths(pack.name);
return (
pool.find((item) => getMenuName(item).indexOf(pack.name) !== -1) ||
pool.find((item) => packMonths && getPackageMonths(getMenuName(item)) === packMonths) ||
pool.find((item) => getMenuPrice(item) === pack.price) ||
pool[index]
);
};
const getPackages = (type) => {
return type.packages.map((item, index) => {
const source = matchPackageSource(type, item, index);
return {
...item,
source,
name: source ? getMenuName(source) || item.name : item.name,
price: source ? getMenuPrice(source) || item.price : item.price,
icon: source && source.icon,
id: source && source.id,
};
});
return sortMenusBySort(configMenus.value)
.filter((item) => getMenuType(item) === type.key)
.map((item) => {
return {
source: item,
id: item.id,
name: getMenuName(item),
price: getMenuPrice(item),
originalPrice: getMenuOriginalPrice(item),
icon: item.icon,
};
});
};
const currentPackages = computed(() => {
@@ -263,41 +243,38 @@ const loadVipConfig = async () => {
}
};
const clearRefreshTimer = () => {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
const refreshUserAfterPay = async () => {
refreshing.value = true;
try {
const result = await getHomeData();
if (result.user) {
updateUser(result.user);
}
} catch (error) {
console.log("refresh user after pay error", error);
} finally {
refreshing.value = false;
}
};
const refreshUserAfterPay = () => {
clearRefreshTimer();
let refreshTimes = 0;
// 先记录支付前的最大到期时间,轮询到更大的值就认为会员状态已刷新。
lastDate.value = getLatestVipExpiredTime();
refreshing.value = true;
timer.value = setInterval(async () => {
refreshTimes += 1;
try {
const result = await getHomeData();
const latestExpiredAt = getLatestVipExpiredTime(result.user);
if (result.user && latestExpiredAt > lastDate.value) {
lastDate.value = latestExpiredAt;
updateUser(result.user);
clearRefreshTimer();
refreshing.value = false;
} else if (refreshTimes >= maxRefreshTimes) {
clearRefreshTimer();
refreshing.value = false;
}
} catch (error) {
console.log("refresh user after pay error", error);
if (refreshTimes >= maxRefreshTimes) {
clearRefreshTimer();
refreshing.value = false;
}
}
}, 1000);
const getVirtualPaySignData = (result) => {
// 后端可能返回字符串或对象,这里统一转成微信虚拟支付需要的 JSON 字符串。
const signDataObj = typeof result?.signData === "string" ? JSON.parse(result.signData) : result?.signData;
if (!signDataObj) return "";
// 微信虚拟支付短剧/虚拟商品模式需要购买数量;后端未返回时使用订单数量兜底。
if (!signDataObj.buyQuantity) {
signDataObj.buyQuantity = result.quantity || 1;
}
return JSON.stringify(signDataObj);
};
const isVirtualPayCancel = (res = {}) => {
const message = String(res.errMsg || res.message || "").toLowerCase();
const errCode = Number(res.errCode);
const errno = Number(res.errno);
return message.includes("cancel") || errCode === -23 || errno === -2;
};
const onPay = async () => {
@@ -317,12 +294,22 @@ const onPay = async () => {
});
return;
}
if (typeof wx === "undefined" || !wx.requestVirtualPayment) {
uni.showToast({
title: "当前环境不支持微信虚拟支付",
icon: "none",
});
return;
}
paying.value = true;
let waitingPayment = false;
let payToast = null;
try {
const result = await createOrderAPI(vipId);
const params = result?.pay?.order?.jsApi?.params;
if (!params?.timeStamp || !params?.nonceStr || !params?.package || !params?.paySign) {
// 微信虚拟支付创建订单前需要登录 code,服务端用它换取本次支付签名参数。
const wxResult = await wxLogin();
const result = await virtualPayOrderAPI(vipId, wxResult.code);
const finalSignData = getVirtualPaySignData(result);
if (!finalSignData || !result?.paySig || !result?.signature) {
uni.showToast({
title: "支付参数生成失败",
icon: "none",
@@ -330,34 +317,57 @@ const onPay = async () => {
refreshing.value = false;
return;
}
waitingPayment = true;
wx.requestPayment({
timeStamp: params.timeStamp, // 微信支付时间戳
nonceStr: params.nonceStr, // 微信支付随机串
package: params.package, // 预支付交易会话标识
paySign: params.paySign, // 微信支付签名
signType: params.signType || "RSA",
success() {
uni.showToast({
wx.requestVirtualPayment({
signData: finalSignData,
paySig: result.paySig,
signature: result.signature,
mode: "short_series_goods",
async success() {
payToast = {
title: "支付成功",
icon: "none",
});
icon: "success",
};
if (result?.outTradeNo) {
try {
const orderDetail = await getOrderDetailAPI(result.outTradeNo);
console.log("virtual pay order detail", orderDetail);
} catch (error) {
console.log("virtual pay order detail error", error);
}
}
// 客户端支付成功后,会员是否真正生效仍以服务端用户信息刷新结果为准。
refreshUserAfterPay();
},
fail(res) {
console.log("pay error", res);
if (res.errMsg && res.errMsg.indexOf("cancel") !== -1) return;
uni.showToast({
title: "支付失败,请稍后重试",
console.log("virtual pay error", res);
if (isVirtualPayCancel(res)) {
payToast = {
title: "支付已取消",
icon: "none",
};
return;
}
payToast = {
title: res.message || "支付失败,请稍后重试",
icon: "none",
});
};
},
complete() {
paying.value = false;
if (payToast) {
setTimeout(() => {
uni.showToast(payToast);
}, 200);
}
},
});
waitingPayment = true;
} catch (error) {
console.log("create vip order error", error);
console.log("create virtual pay order error", error);
uni.showToast({
title: error.message || "下单失败",
icon: "none",
});
} finally {
if (!waitingPayment) paying.value = false;
}
@@ -365,9 +375,6 @@ const onPay = async () => {
onShow(loadVipConfig);
onBeforeUnmount(() => {
clearRefreshTimer();
});
</script>
<template>
@@ -449,7 +456,7 @@ onBeforeUnmount(() => {
<view class="package-list">
<view
v-for="(pack, index) in getPackages(type)"
:key="`${type.key}-${pack.name}`"
:key="`${type.key}-${pack.id || pack.name || index}`"
class="package-card"
:class="{ 'package-card--active': selectedPackageIndex === index }"
@click="selectPackage(index)"
@@ -460,8 +467,8 @@ onBeforeUnmount(() => {
<text class="package-price__symbol">¥</text>
<text class="package-price__value">{{ pack.price }}</text>
</view>
<view class="package-origin">
<text>¥{{ pack.original }}</text>
<view v-if="pack.originalPrice" class="package-origin">
<text>¥{{ pack.originalPrice }}</text>
<view class="package-origin__line" />
</view>
</view>
@@ -473,13 +480,14 @@ onBeforeUnmount(() => {
hover-class="none"
class="activate-btn"
:class="type.buttonClass"
:disabled="loadingConfig || paying || refreshing"
:disabled="loadingConfig || paying || refreshing || !selectedPackage"
@click="onPay"
>
<text v-if="loadingConfig">加载套餐中</text>
<text v-else-if="paying">创建订单中</text>
<text v-else-if="!refreshing">¥ {{ selectedPackage.price }} 一键激活</text>
<text v-else>刷新会员状态中</text>
<text v-else-if="refreshing">刷新会员状态中</text>
<text v-else-if="selectedPackage">¥ {{ selectedPackage.price }} 一键激活</text>
<text v-else>套餐暂不可购买</text>
</button>
<view class="agreement">
@@ -746,8 +754,8 @@ onBeforeUnmount(() => {
}
.package-list {
display: flex;
width: 984rpx;
display: inline-flex;
min-width: 100%;
padding: 6rpx 84rpx 6rpx 6rpx;
box-sizing: border-box;
}
@@ -755,6 +763,7 @@ onBeforeUnmount(() => {
.package-card {
position: relative;
width: 264rpx;
flex: 0 0 264rpx;
height: 224rpx;
border-radius: 16rpx;
border: 2rpx solid #999999;
+3 -5
View File
@@ -97,12 +97,10 @@ const cancelOrder = async () => {
>复制</text
>
</view>
<text>下单时间{{ data.vipCreateAt }}</text>
<text
>支付时间{{
<text>创建时间{{ data.orderCreateAt }}</text>
<text v-if="data.orderStatus === 4">支付时间{{
data.orderStatus === 4 ? data.paymentTime : ""
}}</text
>
}}</text>
<text>金额{{ data.total }} </text>
<text>支付方式微信</text>
</view>
+5 -5
View File
@@ -632,7 +632,7 @@ onUnmounted(() => {
>
<!-- Hero: wifi1.png 单图已内含吉祥物 -->
<view class="hero-area">
<image src="../static/ota/wifi1.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/wifi1.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
</view>
<text class="page-title" :class="{ 'connected-page-title': currentState === 'CONNECTED' }">
@@ -721,7 +721,7 @@ onUnmounted(() => {
<!-- UPDATING -->
<view v-else-if="currentState === 'UPDATING'" class="center-page">
<image src="../static/ota/target-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/target-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新中,请稍等片刻...</text>
<view class="progress-wrap">
<view class="progress-track">
@@ -733,7 +733,7 @@ onUnmounted(() => {
<!-- DONE -->
<view v-else-if="currentState === 'DONE'" class="center-page">
<image src="../static/ota/check-char.png" mode="aspectFit" style="width: 194rpx; height: 166rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/check-char.png" mode="aspectFit" style="width: 194rpx; height: 166rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新完成</text>
<text class="page-desc-white">请关机并重启智能弓</text>
<view class="primary-btn done-btn" style="margin-top:20px" @click="handleDone">
@@ -743,7 +743,7 @@ onUnmounted(() => {
<!-- FAILED -->
<view v-else-if="currentState === 'FAILED'" class="center-page">
<image src="../static/ota/close-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/close-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title fail-title" style="margin-top: 24rpx;">更新失败</text>
<text class="page-desc-white">请确保</text>
<text class="page-desc-white">1智能弓已开启</text>
@@ -756,7 +756,7 @@ onUnmounted(() => {
<!-- CONNECTING 底部弹窗 -->
<view v-if="currentState === 'CONNECTING'" class="sheet-mask">
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'px' }" @click.stop="">
<image src="../static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
<view class="sheet-inner">
<!-- 有密码网络 -->
+2 -2
View File
@@ -13,7 +13,7 @@ import Container from "@/components/Container.vue";
<view class="section">
<view class="title">段位体系概述</view>
<view class="text">
我们的段位体系分为多个等级从低到高依次为倔强青铜秩序白银黄金王者永恒钻石最强王者非凡王者无双王者绝世王者至圣王者荣耀王者和传奇王者每个大段位下又分为若干小段位玩家需要通过积累积分来提升段位
我们的段位体系分为多个等级从低到高依次为倔强青铜秩序白银荣耀黄金永恒钻石最强王者非凡王者无双王者绝世王者至圣王者荣耀王者和传奇王者每个大段位下又分为若干小段位玩家需要通过积累积分来提升段位
</view>
</view>
@@ -79,7 +79,7 @@ import Container from "@/components/Container.vue";
<text>每个小段位需要满 3颗星才能晋升到下一个段位共9颗星</text>
</view>
<view class="table-row">
<text>黄金王者</text>
<text>荣耀黄金</text>
<view>
<text>黄金1*</text>
<text>黄金2*</text>
+1 -1
View File
@@ -86,7 +86,7 @@ const recoverData = (battleInfo, {force = false, arrowOnly = false} = {}) => {
start.value = battleInfo.status !== 0;
if (battleInfo.status === 0) {
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
const readyRemain = (Date.now() - (battleInfo.serverTime || Date.now())) / 1000;
if (readyRemain > 0 && readyRemain < 15) {
setTimeout(() => {
uni.$emit("update-timer", 15 - readyRemain - 0.2);
@@ -4,6 +4,7 @@ import { onShow } from "@dcloudio/uni-app";
import { getBattleAPI, getUserGameState } from "@/apis";
import { debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -67,11 +68,7 @@ const onClick = debounce(async () => {
const result = await getBattleAPI();
if (result && result.matchId) {
await uni.$checkAudio();
if (result.mode <= 3) {
await navigateOnce(`/pages/team-battle/index?battleId=${result.matchId}`);
} else {
await navigateOnce(`/pages/melee-battle?battleId=${result.matchId}`);
}
await returnToBattle(result, navigateOnce);
return;
}
if (game.value.roomID) {
+11 -9
View File
@@ -7,6 +7,7 @@ import ScreenHint from "./ScreenHint.vue";
import BackToGame from "./BackToGame.vue";
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
import { capsuleHeight, debounce } from "@/util";
import { returnToBattle } from "@/utils/matchReturn";
import AudioManager from "@/audioManager";
const props = defineProps({
title: {
@@ -120,6 +121,15 @@ onShow(() => {
showHint.value = false;
});
const navigateTo = (url) =>
new Promise((resolve, reject) => {
uni.navigateTo({
url,
success: resolve,
fail: reject,
});
});
const backToGame = debounce(async () => {
if (isLoading.value) return; // 防止重复点击
@@ -128,15 +138,7 @@ const backToGame = debounce(async () => {
const result = await getBattleAPI();
if (result && result.matchId) {
await checkAudioProgress();
if (result.mode <= 3) {
uni.navigateTo({
url: `/pages/team-battle/index?battleId=${result.matchId}`,
});
} else {
uni.navigateTo({
url: `/pages/melee-battle?battleId=${result.matchId}`,
});
}
await returnToBattle(result, navigateTo);
}
} catch (error) {
console.error("获取当前游戏失败:", error);
+154 -7
View File
@@ -13,8 +13,14 @@ import TeamAvatars from "./components/TeamAvatars.vue";
import ShootProgress2 from "./components/ShootProgress2.vue";
import SModal from "./components/SModal.vue";
import { laserCloseAPI, getBattleAPI } from "@/apis";
import {
closeMatchWebSocket,
connectMatchWebSocket,
MATCH_WS_AUDIO_ACK_EVENT,
} from "@/matchWebsocket";
import { MESSAGETYPESV2 } from "@/constants";
import { getDirectionText } from "@/util";
import { takeMatchReturnSnapshot } from "@/utils/matchReturn";
import audioManager, {
AUDIO_INTERRUPTION_BEGIN_EVENT,
AUDIO_INTERRUPTION_END_EVENT,
@@ -42,6 +48,14 @@ const AUDIO_TIMEOUT_MAX = 12000;
const BATTLE_CANCEL_RETURN_DELAY = 2000;
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks";
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
const MATCH_STATUS_BY_TEXT = {
MATCH_STATUS_READY: 0,
MATCH_STATUS_STARTED: 1,
MATCH_STATUS_END: 2,
MATCH_STATUS_TIMEOUT: 3,
MATCH_STATUS_UNEXPECTEDLY: 4,
};
const PROGRESS_ZERO_EVENT = "team-battle-progress-zero";
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
@@ -89,6 +103,7 @@ let restoreLoadingTimer = null;
let pendingRoundAudio = false;
// 一旦收到 BattleEnd,后续普通消息就不再进入队列。
let battleEnded = false;
let skipNextRestoreOnShow = false;
const handledMessageKeys = new Set();
const handledMessageKeyOrder = [];
const queuedMessageKeys = new Set();
@@ -129,6 +144,68 @@ function normalizeTimestamp(value) {
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
}
function getReadySnapshotKey(matchId) {
return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`;
}
function normalizeBattleInfo(battleInfo) {
if (!battleInfo || typeof battleInfo !== "object") return battleInfo;
const statusText = battleInfo.statusText || battleInfo.status_text;
if (
(battleInfo.status === undefined ||
battleInfo.status === null ||
battleInfo.status === "") &&
MATCH_STATUS_BY_TEXT[statusText] !== undefined
) {
return {
...battleInfo,
status: MATCH_STATUS_BY_TEXT[statusText],
};
}
return battleInfo;
}
function hasKnownStatus(battleInfo) {
return !(
battleInfo?.status === undefined ||
battleInfo?.status === null ||
battleInfo?.status === ""
);
}
function hasStartedSnapshot(battleInfo) {
if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0;
const current = battleInfo?.current || {};
return !!(
current.playerId ||
current.startTime ||
current.round ||
(Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length)
);
}
function takeReadySnapshot(matchId) {
const key = getReadySnapshotKey(matchId);
const snapshot = uni.getStorageSync(key);
if (snapshot) uni.removeStorageSync(key);
return normalizeBattleInfo(snapshot);
}
function reconnectMatchServer(battleInfo) {
const status = Number(battleInfo?.status);
if ([2, 3, 4].includes(status)) return;
if (!battleInfo?.serverAddr) return;
connectMatchWebSocket({
serverAddr: battleInfo.serverAddr,
matchId: battleInfo.matchId || battleId.value,
userId: user.value.id,
});
}
// 从不同消息结构中提取服务端时间,作为恢复和去重的时间基准。
function getServerTime(message) {
return normalizeTimestamp(
@@ -289,6 +366,10 @@ function hideRestoreLoading() {
}
function showRestoreLoading() {
if (start.value === false) {
hideRestoreLoading();
return;
}
clearRestoreLoadingTimer();
restoreLoading.value = true;
restoreLoadingTimer = setTimeout(() => {
@@ -391,7 +472,9 @@ async function executeBattleTask(task, runId) {
if (!task || !isQueueAlive(runId)) return;
const type = task.type;
if (type === MESSAGETYPESV2.BattleStart) {
if (type === MESSAGETYPESV2.AboutToStart) {
applyBattleSnapshot(task.message, { restore: true });
} else if (type === MESSAGETYPESV2.BattleStart) {
await runBattleStartTask(task, runId);
} else if (type === MESSAGETYPESV2.ToSomeoneShoot) {
await runToSomeoneShootTask(task, runId);
@@ -402,7 +485,7 @@ async function executeBattleTask(task, runId) {
} else if (type === MESSAGETYPESV2.BattleEnd) {
await runBattleEndTask(task, runId);
} else if (type === MESSAGETYPESV2.InvalidShot) {
await runInvalidShotTask(runId);
await runInvalidShotTask(task, runId);
}
}
@@ -448,6 +531,24 @@ function onAudioEnded(key) {
});
}
function notifyMatchAudioAck(task) {
const message = task?.message;
if (
message?.sequence === undefined ||
message?.sequence === null ||
message?.sequence === ""
) {
return;
}
uni.$emit(MATCH_WS_AUDIO_ACK_EVENT, {
matchId: message.matchId || battleId.value,
sequence: message.sequence,
matchWsType: message.matchWsType,
matchWsTypeName: message.matchWsTypeName,
});
}
function handleBattleCovered() {
if (pendingRestoreTimer) {
clearTimeout(pendingRestoreTimer);
@@ -462,6 +563,11 @@ function handleBattleRecovered() {
scheduleRestoreLatestBattle();
}
function closeBattleServer(reason) {
if (battleEnded) return;
closeMatchWebSocket({ reason });
}
// 队伍信息优先用接口返回值;接口缺失时使用本地缓存,避免重进页面时头像为空。
function loadTeamPlayers(teamInfo, storageKey) {
if (Array.isArray(teamInfo?.players)) return [...teamInfo.players];
@@ -526,6 +632,7 @@ function applyRestoreNewRoundSnapshot(battleInfo) {
// 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。
function applyBattleBase(battleInfo) {
battleInfo = normalizeBattleInfo(battleInfo);
if (!battleInfo) return;
if (battleInfo.matchId) battleId.value = battleInfo.matchId;
if (battleInfo.status !== undefined) {
@@ -734,8 +841,8 @@ function applyReadyState(battleInfo) {
clearProgressZeroWaiters();
cancelRoundTipDisplay();
const createTime = normalizeTimestamp(battleInfo?.createTime || Date.now());
const readyElapsed = (Date.now() - createTime) / 1000;
const serverTime = normalizeTimestamp(battleInfo?.serverTime || Date.now());
const readyElapsed = (Date.now() - serverTime) / 1000;
if (readyElapsed > 0 && readyElapsed < READY_SECONDS) {
setTimeout(() => {
uni.$emit("update-timer", READY_SECONDS - readyElapsed - 0.2);
@@ -745,6 +852,8 @@ function applyReadyState(battleInfo) {
// 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。
function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) {
battleInfo = normalizeBattleInfo(battleInfo);
if (!battleInfo) return;
// 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。
applyBattleBase(battleInfo);
if (battleInfo.status === 0) {
@@ -752,6 +861,12 @@ function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0
return;
}
if (!hasStartedSnapshot(battleInfo)) {
hideRestoreLoading();
if (start.value === false) applyReadyState(battleInfo);
return;
}
start.value = true;
showRoundTip.value = false;
@@ -793,6 +908,7 @@ async function runBattleStartTask(task, runId) {
pendingRoundAudio = true;
updateShotInfo(task.message);
await playAudioKeys("比赛开始", { interrupt: false });
notifyMatchAudioAck(task);
if (!isQueueAlive(runId)) return;
}
@@ -846,6 +962,7 @@ async function runToSomeoneShootTask(task, runId) {
});
await audioPromise;
notifyMatchAudioAck(task);
if (!isQueueAlive(runId)) return;
const remainingSeconds = getRemainingSeconds(battleInfo, task, {
@@ -918,6 +1035,7 @@ async function runShootResultTask(task) {
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
if (isTententen) audioKeys.push("tententen");
await playAudioKeys(audioKeys, { interrupt: false });
notifyMatchAudioAck(task);
}
// 新回合任务:展示上一回合结算弹窗,弹窗关闭后再允许下一轮继续。
@@ -977,6 +1095,7 @@ async function runBattleEndTask(task, runId) {
// 终局语音必须完整播完,再决定跳转或返回。
await playAudioKeys("比赛结束", { interrupt: false, timeout: AUDIO_TIMEOUT_MAX });
notifyMatchAudioAck(task);
if (!isQueueAlive(runId)) return;
if (matchStatus.value === 2) {
@@ -984,20 +1103,28 @@ async function runBattleEndTask(task, runId) {
url: `/pages/friend-battle-result?battleId=${battleId.value}`,
});
} else if (matchStatus.value === 4) {
const roomNumber = battleInfo.roomId || store.game.roomNumber || store.game.roomID;
setTimeout(() => {
uni.navigateBack();
if (roomNumber) {
uni.redirectTo({
url: `/pages/battle-room?roomNumber=${encodeURIComponent(roomNumber)}&fromResult=1`,
});
} else {
uni.navigateBack();
}
}, BATTLE_CANCEL_RETURN_DELAY);
}
}
// 无效射击任务:弹 toast 并播报无效射击,不修改比赛主状态。
async function runInvalidShotTask(runId) {
async function runInvalidShotTask(task, runId) {
if (!isQueueAlive(runId)) return;
uni.showToast({
title: "距离不足,无效",
icon: "none",
});
await playAudioKeys("射击无效", { interrupt: false });
notifyMatchAudioAck(task);
}
// 回前台恢复入口:拉取服务端快照,处理结束态,然后继续消费增量队列。
@@ -1016,6 +1143,7 @@ async function restoreLatestBattle() {
console.log("restore latest battle failed:", err);
}
if (currentRestoreId !== restoreGeneration) return;
result = normalizeBattleInfo(result);
if (!result) {
hideRestoreLoading();
runBattleQueue();
@@ -1041,6 +1169,7 @@ async function restoreLatestBattle() {
if (result.status === 4) {
clearXRingStreaks();
}
reconnectMatchServer(result);
if (restoreEventType === MESSAGETYPESV2.NewRound) {
const prevRound = getRestorePrevRound(result);
@@ -1094,10 +1223,12 @@ function onReceiveMessage(message) {
// 新对局入口:彻底清空上一局残留的状态、队列和缓存。
onLoad((options) => {
console.log('重新进入了')
const returnSnapshot = options.fromReturn ? takeMatchReturnSnapshot() : null;
skipNextRestoreOnShow = false;
// 新对局入口:把所有会串场的状态、队列、时间戳和缓存一次性清空。
start.value = null;
tips.value = "";
battleId.value = options.battleId || "";
battleId.value = returnSnapshot?.matchId || options.battleId || "";
currentRound.value = 0;
roundTipRound.value = 0;
goldenRound.value = 0;
@@ -1135,6 +1266,16 @@ onLoad((options) => {
store.updateShotInfo(0, 0);
store.updateTips("");
latestShotFlash.value = null;
if (returnSnapshot) {
skipNextRestoreOnShow = true;
applyBattleSnapshot(returnSnapshot, { restore: true });
reconnectMatchServer(returnSnapshot);
return;
}
const readySnapshot = takeReadySnapshot(battleId.value);
if (readySnapshot?.status === 0) {
applyBattleSnapshot(readySnapshot, { restore: true });
}
scheduleRestoreLatestBattle();
});
@@ -1167,6 +1308,7 @@ onBeforeUnmount(() => {
}
hideRestoreLoading();
invalidateBattleQueue({ stopAudio: true, stopProgress: true });
closeBattleServer("team-battle-unmount");
console.log('onBeforeUnmount', '页面卸载前')
audioManager.stopAll();
uni.$off(AUDIO_INTERRUPTION_BEGIN_EVENT, handleBattleCovered);
@@ -1176,11 +1318,16 @@ onBeforeUnmount(() => {
onHide(()=>{
console.log('onHide', '页面大退')
handleBattleCovered();
closeBattleServer("team-battle-hide");
})
// 每次回到前台都重新拉最新比赛快照,确保画面与后端一致。
onShow(() => {
console.log('onshow')
if (skipNextRestoreOnShow) {
skipNextRestoreOnShow = false;
return;
}
scheduleRestoreLatestBattle();
});
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

+4
View File
@@ -0,0 +1,4 @@
/* eslint-disable */
import * as $protobuf from "protobufjs";
const $root=$protobuf.Root.create({nested:{rpc:{ServerMessageType:{SERVER_MSG_UNKNOWN:0,SERVER_MSG_MATCH_READY:1,SERVER_MSG_MATCH_START:2,SERVER_MSG_NOW_YOU:3,SERVER_MSG_SHOT:4,SERVER_MSG_NEW_ROUND:5,SERVER_MSG_MATCH_END:6,SERVER_MSG_TIMEOUT:7,SERVER_MSG_CHECK:8,SERVER_MSG_RAND:9,SERVER_MSG_NOT_ENOUGH_DISTANCE:10,SERVER_MSG_PLAYER_LEFT:11,SERVER_MSG_HEARTBEAT:12,SERVER_MSG_PRACTICE_END:13},ClientMessageType:{CLIENT_MSG_UNKNOWN:0,CLIENT_MSG_HEARTBEAT_ACK:1,CLIENT_MSG_SHOOT_DATA:2,CLIENT_MSG_ACK:3,CLIENT_MSG_LEAVE:4},MatchStatus:{MATCH_STATUS_READY:0,MATCH_STATUS_STARTED:1,MATCH_STATUS_END:2,MATCH_STATUS_TIMEOUT:3,MATCH_STATUS_UNEXPECTEDLY:4},MatchPlayerStatus:{MATCH_PLAYER_STATUS_READY:0,MATCH_PLAYER_STATUS_STARTED:1,MATCH_PLAYER_STATUS_END:2},MatchShoot:{fields:{player_id:{type:"int64",id:1},status:{type:"int32",id:2},x:{type:"float",id:3},y:{type:"float",id:4},ring:{type:"int32",id:5},ring_x:{type:"bool",id:6},angle:{type:"float",id:7},distance:{type:"float",id:8}}},MatchShootList:{fields:{items:{rule:"repeated",type:"MatchShoot",id:1}}},RoundScore:{fields:{total_ring:{type:"int32",id:1},score:{type:"int32",id:2},if_win:{type:"bool",id:3}}},MatchRound:{fields:{shoots:{rule:"map",type:"MatchShootList",id:1,keytype:"int64"},scores:{rule:"map",type:"RoundScore",id:2,keytype:"int32"},round:{type:"int32",id:3},if_gold:{type:"bool",id:4},status:{type:"int32",id:5},gold_round:{type:"int32",id:6}}},PlayerMatchResult:{fields:{total_ring:{type:"int32",id:1},user_id:{type:"int64",id:2},ten_ring_count:{type:"int32",id:3},average_ring:{type:"float",id:4}}},PlayerFull:{fields:{id:{type:"int64",id:1},name:{type:"string",id:2},avatar:{type:"string",id:3},exp:{type:"int32",id:4},score:{type:"int32",id:5},level:{type:"int32",id:6},before_level:{type:"int32",id:7},before_exp:{type:"int32",id:8},current_exp:{type:"int32",id:9},upgrade_exp:{type:"int32",id:10},active:{type:"int32",id:11},device_id:{type:"string",id:12},s_vip:{type:"bool",id:13},vip:{type:"bool",id:14},player_match_result:{type:"PlayerMatchResult",id:15}}},TeamInfo:{fields:{players:{rule:"repeated",type:"PlayerFull",id:1},id:{type:"int32",id:2},name:{type:"string",id:3},score:{type:"int32",id:4}}},CurrentShoot:{fields:{round:{type:"int32",id:1},round_id:{type:"int64",id:2},index:{type:"int32",id:3},start_time:{type:"int64",id:4},player_id:{type:"int64",id:5},gold_round:{type:"bool",id:6},start_time_text:{type:"string",id:7},my_index:{type:"int32",id:8},index_map:{rule:"map",type:"int32",id:9,keytype:"int64"}}},ShootData:{fields:{x:{type:"float",id:1},y:{type:"float",id:2},r:{type:"float",id:3},dst:{type:"float",id:4},m:{type:"string",id:5},adc:{type:"float",id:6},device_id:{type:"string",id:7},shoot_id:{type:"string",id:8}}},PracticeInfo:{fields:{id:{type:"int64",id:1},user_id:{type:"int64",id:2},status:{type:"int32",id:3},status_text:{type:"string",id:4},start_time:{type:"int64",id:5},target_type:{type:"int32",id:6},vip:{type:"bool",id:7},s_vip:{type:"bool",id:8},device_id:{type:"string",id:9},shoot_data:{type:"MatchShoot",id:10},details:{rule:"repeated",type:"MatchShoot",id:11}}},MatchInfo:{fields:{match_id:{type:"int64",id:1},create_time:{type:"int64",id:2},start_time:{type:"int64",id:3},server_time:{type:"int64",id:4},shoot_time:{type:"int32",id:5},shoot_number:{type:"int32",id:6},ready_time:{type:"int32",id:7},way:{type:"int32",id:8},mode:{type:"int32",id:9},status:{type:"MatchStatus",id:10},status_text:{type:"string",id:11},rounds:{rule:"repeated",type:"MatchRound",id:12},teams:{rule:"map",type:"TeamInfo",id:13,keytype:"int32"},current:{type:"CurrentShoot",id:14},next:{type:"CurrentShoot",id:15},shoot_data:{type:"MatchShoot",id:16},win_team:{type:"int32",id:17},mvp:{type:"PlayerFull",id:18},room_id:{type:"string",id:19},result_list:{rule:"repeated",type:"PlayerMatchResult",id:20},timeout_time:{type:"int64",id:21},target_type:{type:"int32",id:22},event_type:{type:"int32",id:23},timeout:{type:"int32",id:24},server_addr:{type:"string",id:25}}},ServerMessage:{fields:{type:{type:"ServerMessageType",id:1},match_id:{type:"int64",id:2},timestamp:{type:"int64",id:3},match_info:{type:"MatchInfo",id:4,oneof:"payload"},shoot_data:{type:"ShootData",id:5,oneof:"payload"},practice_info:{type:"PracticeInfo",id:6,oneof:"payload"},sequence:{type:"int64",id:7}}},ClientMessage:{fields:{type:{type:"ClientMessageType",id:1},match_id:{type:"int64",id:2},user_id:{type:"int64",id:3},sequence:{type:"int64",id:4},data:{type:"bytes",id:5}}}}}});
export default $root;
+121
View File
@@ -0,0 +1,121 @@
// 比赛数据适配器:统一接口返回和比赛服 websocket 的字段结构。
// 页面继续使用原来的 camelCase 数据,后端可以返回 snake_case 或新的 matchInfo 包装结构。
export const MATCH_STATUS_BY_TEXT = {
MATCH_STATUS_READY: 0,
MATCH_STATUS_STARTED: 1,
MATCH_STATUS_END: 2,
MATCH_STATUS_TIMEOUT: 3,
MATCH_STATUS_UNEXPECTEDLY: 4,
};
export function pickField(source, camelKey, snakeKey) {
return source?.[camelKey] ?? source?.[snakeKey];
}
export function normalizeId(value) {
if (value === undefined || value === null || value === "") return "";
return String(value);
}
function toCamelKey(key) {
return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export function normalizePlainObject(value) {
if (Array.isArray(value)) return value.map(normalizePlainObject);
if (!value || typeof value !== "object") return value;
return Object.keys(value).reduce((result, key) => {
result[toCamelKey(key)] = normalizePlainObject(value[key]);
return result;
}, {});
}
function normalizeMapValues(map = {}) {
return Object.keys(map || {}).reduce((result, key) => {
result[key] = normalizePlainObject(map[key]);
return result;
}, {});
}
function normalizeShootListMap(map = {}) {
return Object.keys(map || {}).reduce((result, key) => {
const value = map[key];
const items = Array.isArray(value)
? value
: Array.isArray(value?.items)
? value.items
: [];
result[key] = items.map(normalizePlainObject);
return result;
}, {});
}
function normalizeRound(round = {}) {
const normalized = normalizePlainObject(round);
normalized.shoots = normalizeShootListMap(round.shoots || normalized.shoots);
normalized.scores = normalizeMapValues(round.scores || normalized.scores);
return normalized;
}
export function normalizeMatchInfo(matchInfo = {}) {
if (!matchInfo || typeof matchInfo !== "object") return matchInfo;
const normalized = normalizePlainObject(matchInfo);
const statusText = normalized.statusText || matchInfo.status_text;
if (
(normalized.status === undefined ||
normalized.status === null ||
normalized.status === "") &&
MATCH_STATUS_BY_TEXT[statusText] !== undefined
) {
normalized.status = MATCH_STATUS_BY_TEXT[statusText];
}
if (matchInfo.teams || normalized.teams) {
normalized.teams = normalizeMapValues(matchInfo.teams || normalized.teams);
}
if (Array.isArray(matchInfo.rounds || normalized.rounds)) {
normalized.rounds = (matchInfo.rounds || normalized.rounds).map(normalizeRound);
}
if (matchInfo.current || normalized.current) {
normalized.current = normalizePlainObject(matchInfo.current || normalized.current);
}
if (matchInfo.next || normalized.next) {
normalized.next = normalizePlainObject(matchInfo.next || normalized.next);
}
if (matchInfo.shoot_data || normalized.shootData) {
normalized.shootData = normalizePlainObject(matchInfo.shoot_data || normalized.shootData);
}
if (Array.isArray(matchInfo.result_list || normalized.resultList)) {
normalized.resultList = (matchInfo.result_list || normalized.resultList).map(
normalizePlainObject
);
}
return normalized;
}
export function normalizeBattleApiResult(result) {
if (!result || typeof result !== "object") return result;
// request 通常已经解出顶层 data;这里额外兼容未解包和旧版扁平结构。
const resultData = result.data && typeof result.data === "object" ? result.data : null;
const payload =
resultData && (resultData.matchInfo || resultData.match_info)
? resultData
: result;
const matchInfo = payload.matchInfo || payload.match_info || payload;
const normalized = normalizeMatchInfo(matchInfo);
const serverAddr =
payload.serverAddr ||
payload.server_addr ||
matchInfo.serverAddr ||
matchInfo.server_addr ||
normalized.serverAddr;
if (serverAddr) normalized.serverAddr = serverAddr;
return normalized;
}
+368
View File
@@ -0,0 +1,368 @@
import protobuf from "protobufjs/minimal.js";
const { Reader, Writer } = protobuf;
// 比赛服 protobuf 协议适配层:
// 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
export const ServerMessageType = {
SERVER_MSG_UNKNOWN: 0,
SERVER_MSG_MATCH_READY: 1,
SERVER_MSG_MATCH_START: 2,
SERVER_MSG_NOW_YOU: 3,
SERVER_MSG_SHOT: 4,
SERVER_MSG_NEW_ROUND: 5,
SERVER_MSG_MATCH_END: 6,
SERVER_MSG_TIMEOUT: 7,
SERVER_MSG_CHECK: 8,
SERVER_MSG_RAND: 9,
SERVER_MSG_NOT_ENOUGH_DISTANCE: 10,
SERVER_MSG_PLAYER_LEFT: 11,
SERVER_MSG_HEARTBEAT: 12,
SERVER_MSG_PRACTICE_END: 13,
};
export const ClientMessageType = {
CLIENT_MSG_UNKNOWN: 0,
CLIENT_MSG_HEARTBEAT_ACK: 1,
CLIENT_MSG_SHOOT_DATA: 2,
CLIENT_MSG_ACK: 3,
CLIENT_MSG_LEAVE: 4,
};
// protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。
const ServerMessageTypeNameByValue = Object.keys(ServerMessageType).reduce(
(result, name) => {
result[ServerMessageType[name]] = name;
return result;
},
{}
);
// 当前只需要解码比赛服下发的字段并打印,字段表按后端 proto 定义维护。
const SCHEMAS = {
MatchShoot: {
1: { name: "player_id", kind: "int64" },
2: { name: "status", kind: "int32" },
3: { name: "x", kind: "float" },
4: { name: "y", kind: "float" },
5: { name: "ring", kind: "int32" },
6: { name: "ring_x", kind: "bool" },
7: { name: "angle", kind: "float" },
8: { name: "distance", kind: "float" },
},
MatchShootList: {
1: { name: "items", kind: "message", type: "MatchShoot", repeated: true },
},
RoundScore: {
1: { name: "total_ring", kind: "int32" },
2: { name: "score", kind: "int32" },
3: { name: "if_win", kind: "bool" },
},
MatchRound: {
1: {
name: "shoots",
kind: "map",
keyKind: "int64",
valueKind: "message",
valueType: "MatchShootList",
},
2: {
name: "scores",
kind: "map",
keyKind: "int32",
valueKind: "message",
valueType: "RoundScore",
},
3: { name: "round", kind: "int32" },
4: { name: "if_gold", kind: "bool" },
5: { name: "status", kind: "int32" },
6: { name: "gold_round", kind: "int32" },
},
PlayerMatchResult: {
1: { name: "total_ring", kind: "int32" },
2: { name: "user_id", kind: "int64" },
3: { name: "ten_ring_count", kind: "int32" },
4: { name: "average_ring", kind: "float" },
},
PlayerFull: {
1: { name: "id", kind: "int64" },
2: { name: "name", kind: "string" },
3: { name: "avatar", kind: "string" },
4: { name: "exp", kind: "int32" },
5: { name: "score", kind: "int32" },
6: { name: "level", kind: "int32" },
7: { name: "before_level", kind: "int32" },
8: { name: "before_exp", kind: "int32" },
9: { name: "current_exp", kind: "int32" },
10: { name: "upgrade_exp", kind: "int32" },
11: { name: "active", kind: "int32" },
12: { name: "device_id", kind: "string" },
13: { name: "s_vip", kind: "bool" },
14: { name: "vip", kind: "bool" },
15: { name: "player_match_result", kind: "message", type: "PlayerMatchResult" },
},
TeamInfo: {
1: { name: "players", kind: "message", type: "PlayerFull", repeated: true },
2: { name: "id", kind: "int32" },
3: { name: "name", kind: "string" },
4: { name: "score", kind: "int32" },
},
CurrentShoot: {
1: { name: "round", kind: "int32" },
2: { name: "round_id", kind: "int64" },
3: { name: "index", kind: "int32" },
4: { name: "start_time", kind: "int64" },
5: { name: "player_id", kind: "int64" },
6: { name: "gold_round", kind: "bool" },
7: { name: "start_time_text", kind: "string" },
8: { name: "my_index", kind: "int32" },
9: {
name: "index_map",
kind: "map",
keyKind: "int64",
valueKind: "int32",
},
},
ShootData: {
1: { name: "x", kind: "float" },
2: { name: "y", kind: "float" },
3: { name: "r", kind: "float" },
4: { name: "dst", kind: "float" },
5: { name: "m", kind: "string" },
6: { name: "adc", kind: "float" },
7: { name: "device_id", kind: "string" },
8: { name: "shoot_id", kind: "string" },
},
PracticeInfo: {
1: { name: "id", kind: "int64" },
2: { name: "user_id", kind: "int64" },
3: { name: "status", kind: "int32" },
4: { name: "status_text", kind: "string" },
5: { name: "start_time", kind: "int64" },
6: { name: "target_type", kind: "int32" },
7: { name: "vip", kind: "bool" },
8: { name: "s_vip", kind: "bool" },
9: { name: "device_id", kind: "string" },
10: { name: "shoot_data", kind: "message", type: "MatchShoot" },
11: { name: "details", kind: "message", type: "MatchShoot", repeated: true },
},
MatchInfo: {
1: { name: "match_id", kind: "int64" },
2: { name: "create_time", kind: "int64" },
3: { name: "start_time", kind: "int64" },
4: { name: "server_time", kind: "int64" },
5: { name: "shoot_time", kind: "int32" },
6: { name: "shoot_number", kind: "int32" },
7: { name: "ready_time", kind: "int32" },
8: { name: "way", kind: "int32" },
9: { name: "mode", kind: "int32" },
10: { name: "status", kind: "int32" },
11: { name: "status_text", kind: "string" },
12: { name: "rounds", kind: "message", type: "MatchRound", repeated: true },
13: {
name: "teams",
kind: "map",
keyKind: "int32",
valueKind: "message",
valueType: "TeamInfo",
},
14: { name: "current", kind: "message", type: "CurrentShoot" },
15: { name: "next", kind: "message", type: "CurrentShoot" },
16: { name: "shoot_data", kind: "message", type: "MatchShoot" },
17: { name: "win_team", kind: "int32" },
18: { name: "mvp", kind: "message", type: "PlayerFull" },
19: { name: "room_id", kind: "string" },
20: {
name: "result_list",
kind: "message",
type: "PlayerMatchResult",
repeated: true,
},
21: { name: "timeout_time", kind: "int64" },
22: { name: "target_type", kind: "int32" },
23: { name: "event_type", kind: "int32" },
24: { name: "timeout", kind: "int32" },
25: { name: "server_addr", kind: "string" },
},
ServerMessage: {
1: { name: "type", kind: "int32" },
2: { name: "match_id", kind: "int64" },
3: { name: "timestamp", kind: "int64" },
4: { name: "match_info", kind: "message", type: "MatchInfo", oneof: "payload" },
5: { name: "shoot_data", kind: "message", type: "ShootData", oneof: "payload" },
6: { name: "practice_info", kind: "message", type: "PracticeInfo", oneof: "payload" },
7: { name: "sequence", kind: "int64" },
},
};
// 小程序 websocket 收到的 data 可能是 ArrayBuffer、TypedArray 或字符串,这里统一成 Uint8Array。
function toUint8Array(data) {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof data === "string") {
const bytes = new Uint8Array(data.length);
for (let i = 0; i < data.length; i += 1) {
bytes[i] = data.charCodeAt(i) & 0xff;
}
return bytes;
}
throw new TypeError("Unsupported match websocket message payload");
}
// uni.sendSocketMessage 发送二进制时使用 ArrayBuffer。
function toArrayBuffer(bytes) {
const output = new Uint8Array(bytes.length);
output.set(bytes);
return output.buffer;
}
function readScalar(reader, kind) {
switch (kind) {
case "int32":
return reader.int32();
case "int64":
// 统一把 int64 转成字符串,避免小程序环境下大整数精度丢失。
return reader.int64().toString();
case "float":
return reader.float();
case "bool":
return reader.bool();
case "string":
return reader.string();
case "bytes":
return Array.from(reader.bytes());
default:
throw new Error(`Unsupported protobuf scalar kind: ${kind}`);
}
}
function readMapEntry(reader, field) {
const end = reader.uint32() + reader.pos;
let key = "";
let value;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
key = readScalar(reader, field.keyKind);
break;
case 2:
value =
field.valueKind === "message"
? decodeMessage(field.valueType, reader, reader.uint32())
: readScalar(reader, field.valueKind);
break;
default:
reader.skipType(tag & 7);
break;
}
}
return { key: String(key), value };
}
function readValue(reader, field) {
if (field.kind === "message") {
return decodeMessage(field.type, reader, reader.uint32());
}
return readScalar(reader, field.kind);
}
function decodeMessage(schemaName, readerOrData, length) {
const schema = SCHEMAS[schemaName];
const reader =
readerOrData instanceof Reader ? readerOrData : Reader.create(readerOrData);
const end = length === undefined ? reader.len : reader.pos + length;
const message = {};
while (reader.pos < end) {
const tag = reader.uint32();
const field = schema[tag >>> 3];
if (!field) {
reader.skipType(tag & 7);
continue;
}
if (field.kind === "map") {
const entry = readMapEntry(reader, field);
const map = message[field.name] || {};
map[entry.key] = entry.value;
message[field.name] = map;
continue;
}
const value = readValue(reader, field);
if (field.repeated) {
if (!message[field.name]) message[field.name] = [];
message[field.name].push(value);
} else {
message[field.name] = value;
}
if (field.oneof) {
message[field.oneof] = field.name;
}
}
return message;
}
export function decodeServerMessage(data) {
// 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接解 ServerMessage。
return decodeMessage("ServerMessage", toUint8Array(data));
}
export function encodeClientMessage(payload) {
// ClientMessage 字段名按 proto 定义写入,例如 match_id、user_id。
const writer = Writer.create();
const matchId = payload.match_id ?? payload.matchId;
const userId = payload.user_id ?? payload.userId;
if (payload.type !== undefined) writer.uint32(8).int32(payload.type);
if (matchId !== undefined && matchId !== null) writer.uint32(16).int64(matchId);
if (userId !== undefined && userId !== null) writer.uint32(24).int64(userId);
if (payload.sequence !== undefined && payload.sequence !== null) {
writer.uint32(32).int64(payload.sequence);
}
if (payload.data !== undefined && payload.data !== null) {
writer.uint32(42).bytes(toUint8Array(payload.data));
}
return toArrayBuffer(writer.finish());
}
export function getServerMessageTypeName(type) {
return ServerMessageTypeNameByValue[type] || String(type);
}
export function createHeartbeatAckMessage() {
// 心跳 ACK 按文档立即回,不等待语音播报。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_HEARTBEAT_ACK,
});
}
export function createAckMessage({ matchId, sequence }) {
// 普通消息 ACK 仍携带 sequence,但前端不做 sequence 补发或排序。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_ACK,
match_id: matchId,
sequence,
});
}
export function createLeaveMessage({ matchId, userId }) {
// 比赛结束或页面离开时通知比赛服释放当前玩家连接。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_LEAVE,
match_id: matchId,
user_id: userId,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { connectMatchWebSocket } from "@/matchWebsocket";
import useStore from "@/store";
export const MATCH_RETURN_SNAPSHOT_KEY = "match-return-snapshot";
function getMatchId(battleInfo) {
return battleInfo?.matchId || battleInfo?.id || "";
}
function getBattlePageUrl(battleInfo) {
return Number(battleInfo?.mode) <= 3
? "/pages/team-battle/index?fromReturn=1"
: "/pages/melee-battle?fromReturn=1";
}
export function takeMatchReturnSnapshot() {
const snapshot = uni.getStorageSync(MATCH_RETURN_SNAPSHOT_KEY);
if (snapshot) uni.removeStorageSync(MATCH_RETURN_SNAPSHOT_KEY);
return snapshot && typeof snapshot === "object" ? snapshot : null;
}
export async function returnToBattle(battleInfo, navigate) {
const matchId = getMatchId(battleInfo);
if (!battleInfo || !matchId || !battleInfo.serverAddr) {
uni.showToast({
title: "比赛连接信息异常",
icon: "none",
});
return false;
}
const store = useStore();
uni.setStorageSync(MATCH_RETURN_SNAPSHOT_KEY, battleInfo);
connectMatchWebSocket({
serverAddr: battleInfo.serverAddr,
matchId,
userId: store.user?.id,
});
await navigate(getBattlePageUrl(battleInfo));
return true;
}
+1 -1
View File
@@ -24,7 +24,7 @@ function createWebSocket(token, onMessage) {
switch (envVersion) {
case "develop": // 开发版
// url = "ws://192.168.1.30:8000/socket";
// url = "ws://192.168.1.2:8000/socket";
url = "wss://apitest.shelingxingqiu.com/socket";
break;
case "trial": // 体验版
+2067 -322
View File
File diff suppressed because it is too large Load Diff