diff --git a/.gitignore b/.gitignore
index 1923370..c91affc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,7 @@ node_modules
.github
openspec
CLAUDE.md
-dosc
+docs
.DS_Store
dist
*.local
diff --git a/package.json b/package.json
index 090bdd0..a50c6be 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
"@dcloudio/uni-ui": "^1.5.11",
"pinia": "2.0.36",
+ "pinia-plugin-persistedstate": "3.2.1",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
diff --git a/src/App.vue b/src/App.vue
index 751b9d6..c852548 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -21,7 +21,8 @@
} = storeToRefs(store);
const {
updateUser,
- updateOnline
+ updateOnline,
+ clearSessionState
} = store;
watch(
@@ -46,6 +47,17 @@
updateUser(value);
}
+ function onSessionKickedOut() {
+ const env = uni.getAccountInfoSync().miniProgram.envVersion;
+ uni.removeStorageSync(`${env}_token`);
+ clearSessionState();
+ uni.showModal({
+ title: "提示",
+ content: "账号已在其他设备登录",
+ showCancel: false,
+ });
+ }
+
async function emitUpdateOnline() {
const data = await getDeviceBatteryAPI();
updateOnline(data.online);
@@ -65,6 +77,7 @@
onShow(() => {
uni.$on("update-user", emitUpdateUser);
uni.$on("update-online", emitUpdateOnline);
+ uni.$on("session-kicked-out", onSessionKickedOut);
const token = uni.getStorageSync(
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
);
@@ -77,6 +90,7 @@
onHide(() => {
uni.$off("update-user", emitUpdateUser);
uni.$off("update-online", emitUpdateOnline);
+ uni.$off("session-kicked-out", onSessionKickedOut);
websocket.closeWebSocket();
});
@@ -289,4 +303,4 @@
font-style: normal;
font-display: swap;
}
-
\ No newline at end of file
+
diff --git a/src/apis.js b/src/apis.js
index 8431626..f980cc0 100644
--- a/src/apis.js
+++ b/src/apis.js
@@ -6,7 +6,7 @@ try {
switch (envVersion) {
case "develop": // 开发版
- // BASE_URL = "http://localhost:8000/api/shoot";
+ // BASE_URL = "http://192.168.1.30:8000/api/shoot";
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
break;
case "trial": // 体验版
@@ -42,10 +42,13 @@ function request(method, url, data = {}) {
if (code === 0) resolve(data);
else if (message) {
if (message.indexOf("登录身份已失效") !== -1) {
+ console.log('1111111111111111111,token失效')
uni.removeStorageSync(
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
);
uni.$emit("update-user");
+ reject({ type: "AUTH_INVALID", message });
+ return;
}
if (message === "ROOM_FULL") {
resolve({full: true});
diff --git a/src/audioManager.js b/src/audioManager.js
index 1e7239e..a0f55bb 100644
--- a/src/audioManager.js
+++ b/src/audioManager.js
@@ -1,4 +1,11 @@
+export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
+export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
+
export const audioFils = {
+ tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
+ 点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
+ "20CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/20CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
+ "40CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/40CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
// 激光已校准:
// "https://static.shelingxingqiu.com/attachment/2025-10-29/ddupaur1vdkyhzaqdc.mp3",
胜利: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuo9yjp0kt5msvmvd.mp3",
@@ -36,7 +43,7 @@ export const audioFils = {
请开始射击:
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
射击无效:
- "https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya55ufiiw8oo55.mp3",
+ "https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%E6%A3%80%E6%9F%A5%E8%B7%9D%E7%A6%BB%E5%92%8C%E9%9D%B6%E7%BA%B8.mp3",
未上靶:
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6n45o3tsm1v4unam.mp3",
"1环":
@@ -93,7 +100,7 @@ function debugLog(...args) {
const envVersion = accountInfo.miniProgram.envVersion;
// 只在体验版打印日志,正式版(release)和开发版(develop)不打印
- if (envVersion === "trial") {
+ if (envVersion === "trial" || envVersion === "develop") {
console.log(...args);
}
}
@@ -123,6 +130,7 @@ class AudioManager {
// 防重复播放保护
this.lastPlayKey = null;
this.lastPlayAt = 0;
+ this.isInterrupted = false;
// 静音开关
this.isMuted = false;
@@ -137,10 +145,41 @@ class AudioManager {
this.localFileCache = uni.getStorageSync("audio_local_files") || {};
// 启动时自动清理过期的缓存文件(URL 已不在 audioFils 中的文件)
this.cleanObsoleteCache();
+ this.bindAudioInterruptionEvents();
this.initAudios();
}
+ bindAudioInterruptionEvents() {
+ if (this._audioInterruptionBound) return;
+ this._audioInterruptionBound = true;
+
+ const begin = () => {
+ if (this.isInterrupted) return;
+ this.isInterrupted = true;
+ this.stopAll();
+ this.isSequenceRunning = false;
+ this.sequenceQueue = [];
+ this.sequenceIndex = 0;
+ this.pendingPlayKey = null;
+ uni.$emit(AUDIO_INTERRUPTION_BEGIN_EVENT);
+ };
+
+ const end = () => {
+ if (!this.isInterrupted) return;
+ this.isInterrupted = false;
+ uni.$emit(AUDIO_INTERRUPTION_END_EVENT);
+ void this.reloadAll();
+ };
+
+ if (typeof uni?.onAudioInterruptionBegin === "function") {
+ uni.onAudioInterruptionBegin(begin);
+ }
+ if (typeof uni?.onAudioInterruptionEnd === "function") {
+ uni.onAudioInterruptionEnd(end);
+ }
+ }
+
// 清理不再使用的缓存文件
cleanObsoleteCache() {
const activeUrls = new Set(Object.values(audioFils));
@@ -457,6 +496,10 @@ class AudioManager {
// 播放指定音频或音频数组(数组则按顺序连续播放)
play(input, interrupt = true) {
+ if (this.isInterrupted) {
+ debugLog("音频处理中断状态,忽略播放请求");
+ return;
+ }
// 统一规范化为队列
let queue = [];
if (Array.isArray(input)) {
@@ -510,6 +553,10 @@ class AudioManager {
// 内部方法:播放单个 key
_playSingle(key, forceStopAll = false) {
+ if (this.isInterrupted) {
+ debugLog(`音频处理中断状态,跳过播放: ${key}`);
+ return;
+ }
// 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音
const now = Date.now();
if (this.lastPlayKey === key && now - this.lastPlayAt < 250) {
@@ -553,7 +600,13 @@ class AudioManager {
// 显式授权播放并立即播放
this.allowPlayMap.set(key, true);
- audio.play();
+ try {
+ audio.play();
+ } catch (err) {
+ this.allowPlayMap.set(key, false);
+ debugLog(`音频 ${key} 播放调用失败`, err?.errMsg || err);
+ return;
+ }
this.currentPlayingKey = key;
this.lastPlayKey = key;
this.lastPlayAt = Date.now();
diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue
index 3a44260..05f2ebe 100644
--- a/src/components/AppFooter.vue
+++ b/src/components/AppFooter.vue
@@ -31,7 +31,7 @@ function handleTabClick(index) {
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
- @click="handleTabClick(index)"
+ @click="$clickSound(() => handleTabClick(index))"
:style="{
width: index === 1 ? '36%' : '20%',
}"
diff --git a/src/components/BackToGame.vue b/src/components/BackToGame.vue
index 9319c21..b5a509c 100644
--- a/src/components/BackToGame.vue
+++ b/src/components/BackToGame.vue
@@ -19,8 +19,12 @@ const props = defineProps({
});
const loading = ref(false);
+/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
+const getToken = () =>
+ uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
+
onShow(async () => {
- if (user.value.id) {
+ if (user.value.id && getToken()) {
setTimeout(async () => {
const state = await getUserGameState();
updateGame(state.gaming, state.roomId);
@@ -33,7 +37,8 @@ watch(
async (value) => {
if (!value.id) {
updateGame(false, "");
- } else {
+ } else if (getToken()) {
+ // 有有效 token 时才查询在局状态,避免 token 失效时反复发起无效请求
const state = await getUserGameState();
updateGame(state.gaming, state.roomId);
}
@@ -49,7 +54,7 @@ const onClick = debounce(async () => {
await uni.$checkAudio();
if (result.mode <= 3) {
uni.navigateTo({
- url: `/pages/team-battle?battleId=${result.matchId}`,
+ url: `/pages/team-battle/index?battleId=${result.matchId}`,
});
} else {
uni.navigateTo({
diff --git a/src/components/BattleHeader.vue b/src/components/BattleHeader.vue
index b7b3716..b12d84f 100644
--- a/src/components/BattleHeader.vue
+++ b/src/components/BattleHeader.vue
@@ -80,28 +80,35 @@ defineProps({
/>
-
+
+
+ players.length > 5 && e.stopPropagation()"
:style="{ paddingTop: showHeader ? '15px' : '0' }"
>
-
-
- {{ player.name }}
+
+
+
+ {{ player.name }}
+
-
+
@@ -144,17 +151,21 @@ defineProps({
justify-content: center;
}
.players-melee {
- display: flex;
height: 80px;
width: 100%;
- overflow-x: auto;
}
.players-melee::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
-.players-melee > view {
+/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
+.players-melee-inner {
+ display: flex;
+ height: 100%;
+ flex-wrap: nowrap;
+}
+.players-melee-inner > view {
display: flex;
flex-direction: column;
align-items: center;
diff --git a/src/components/BowTarget.vue b/src/components/BowTarget.vue
index b1e6c5c..79ee41d 100644
--- a/src/components/BowTarget.vue
+++ b/src/components/BowTarget.vue
@@ -34,6 +34,18 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ targetRadius: {
+ type: Number,
+ default: 20,
+ },
+ hitRadiusPx: {
+ type: Number,
+ default: 2,
+ },
+ zoomHitRadiusPx: {
+ type: Number,
+ default: 5,
+ },
});
const pMode = ref(true);
@@ -80,13 +92,79 @@ watch(
}
);
-function calcRealX(num, offset = 3.4) {
- const len = 20.4 + num;
- return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
+const safeTargetRadius = computed(() => {
+ const radius = Number(props.targetRadius);
+ return Number.isFinite(radius) && radius > 0 ? radius : 20;
+});
+
+const currentHitRadiusPx = computed(() => {
+ const radius = Number(
+ pMode.value ? props.zoomHitRadiusPx : props.hitRadiusPx
+ );
+ return Number.isFinite(radius) && radius >= 0 ? radius : 0;
+});
+
+function getShotPoint(shot, fallbackCenter = false) {
+ const x = Number(shot?.x);
+ const y = Number(shot?.y);
+ if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+ return fallbackCenter ? { x: 0, y: 0 } : null;
}
-function calcRealY(num, offset = 3.4) {
- const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num;
- return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
+
+function getPointDirection(point) {
+ if (!point) return null;
+ const distance = Math.sqrt(point.x * point.x + point.y * point.y);
+ if (distance === 0) return null;
+
+ return {
+ x: point.x / distance,
+ y: point.y / distance,
+ };
+}
+
+function formatPxOffset(value) {
+ if (!value) return "";
+ const operator = value > 0 ? "+" : "-";
+ return ` ${operator} ${Math.abs(value)}px`;
+}
+
+function formatTargetPosition(percent, offset) {
+ const pxOffset = formatPxOffset(offset);
+ return pxOffset ? `calc(${percent}%${pxOffset})` : `${percent}%`;
+}
+
+function getTargetPositionStyle(point, offsetPx = 0) {
+ if (!point) return { display: "none" };
+
+ const radius = safeTargetRadius.value;
+ const diameter = radius * 2;
+ const direction = getPointDirection(point);
+ const xOffset = direction ? direction.x * offsetPx : 0;
+ const yOffset = direction ? -direction.y * offsetPx : 0;
+ const leftPercent = ((point.x + radius) / diameter) * 100;
+ const topPercent = ((radius - point.y) / diameter) * 100;
+
+ return {
+ left: formatTargetPosition(leftPercent, xOffset),
+ top: formatTargetPosition(topPercent, yOffset),
+ transform: "translate(-50%, -50%)",
+ };
+}
+
+function getHitStyle(shot) {
+ const radius = currentHitRadiusPx.value;
+ const point = getShotPoint(shot);
+
+ return {
+ ...getTargetPositionStyle(point, radius),
+ width: `${radius * 2}px`,
+ height: `${radius * 2}px`,
+ };
+}
+
+function getTipStyle(shot) {
+ const point = getShotPoint(shot, true);
+ return getTargetPositionStyle(point, shot?.ring ? currentHitRadiusPx.value : 0);
}
const simulShoot = async () => {
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
@@ -169,20 +247,14 @@ onBeforeUnmount(() => {
经验 +1
{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
}}环
@@ -193,20 +265,14 @@ onBeforeUnmount(() => {
user.id === bluelatestOne.playerId
"
class="e-value fade-in-out"
- :style="{
- left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
- top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
- }"
+ :style="getTipStyle(bluelatestOne)"
>
经验 +1
{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}环
@@ -217,8 +283,7 @@ onBeforeUnmount(() => {
index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`"
:style="{
- left: calcRealX(bow.x, pMode ? '3.4' : '2'),
- top: calcRealY(bow.y, pMode ? '3.4' : '2'),
+ ...getHitStyle(bow),
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
}"
>{{ index + 1 }} {
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
}`"
:style="{
- left: calcRealX(bow.x, pMode ? '3.4' : '2'),
- top: calcRealY(bow.y, pMode ? '3.4' : '2'),
+ ...getHitStyle(bow),
backgroundColor: '#1840FF',
}"
>
@@ -302,21 +366,11 @@ onBeforeUnmount(() => {
z-index: 1;
color: #fff;
transition: all 0.3s ease;
-}
-.s-point {
- width: 4px;
- height: 4px;
- min-width: 4px;
- min-height: 4px;
+ box-sizing: border-box;
}
.b-point {
- width: 10px;
- height: 10px;
- min-width: 10px;
- min-height: 10px;
border: 1px solid #fff;
z-index: 1;
- box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
@@ -332,6 +386,19 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/
margin-top: 2rpx;
}
+@keyframes target-pump-in {
+ from {
+ transform: translate(-50%, -50%) scale(2);
+ }
+
+ to {
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+.hit.pump-in {
+ animation: target-pump-in 0.3s ease-out forwards;
+ transform-origin: center center;
+}
.header {
width: 100%;
display: flex;
diff --git a/src/components/Container.vue b/src/components/Container.vue
index 109606a..36840f2 100644
--- a/src/components/Container.vue
+++ b/src/components/Container.vue
@@ -116,7 +116,7 @@ const backToGame = debounce(async () => {
await checkAudioProgress();
if (result.mode <= 3) {
uni.navigateTo({
- url: `/pages/team-battle?battleId=${result.matchId}`,
+ url: `/pages/team-battle/index?battleId=${result.matchId}`,
});
} else {
uni.navigateTo({
@@ -206,7 +206,7 @@ const goCalibration = async () => {
-
+
diff --git a/src/components/CreateRoom.vue b/src/components/CreateRoom.vue
index e34d7d7..e66dd22 100644
--- a/src/components/CreateRoom.vue
+++ b/src/components/CreateRoom.vue
@@ -17,12 +17,19 @@ const props = defineProps({
},
});
-const battleMode = ref(1);
-const targetMode = ref(1);
+/** 对战模式:0=未选 1=1v1 2=乱斗 3=2v2 4=3v3 */
+const battleMode = ref(0);
+/** 靶纸尺寸:0=未选 1=20cm 2=40cm */
+const targetMode = ref(0);
const loading = ref(false);
const roomNumber = ref("");
const createRoom = debounce(async () => {
+ // 校验必填项:对战模式与靶纸均必须选择
+ if (!battleMode.value || !targetMode.value) {
+ uni.showToast({ title: '请完善创建信息', icon: 'none' });
+ return;
+ }
if (game.value.inBattle) {
uni.$showHint(1);
return;
@@ -105,7 +112,7 @@ const createRoom = debounce(async () => {
40厘米全环靶
- 创建房间
+ 创建房间
diff --git a/src/components/GuideTwo.vue b/src/components/GuideTwo.vue
index abe4c51..6d6cb63 100644
--- a/src/components/GuideTwo.vue
+++ b/src/components/GuideTwo.vue
@@ -9,12 +9,12 @@ defineProps({
-
+
@@ -26,20 +26,21 @@ defineProps({
.container {
display: flex;
align-items: center;
- padding: 0 15px;
+ padding: 0 26rpx 0 28rpx;
margin-bottom: 14rpx;
- width: clac(100% - 30px);
+ width: clac(100% - 54rpx);
}
.container .shooter2 {
- width: 150rpx;
- height: 162rpx;
+ display: block;
+ width: 133rpx;
+ height: 144rpx;
}
.container .bg-box {
color: #fff;
font-size: 28rpx;
position: relative;
flex: 1;
- min-height: 55px;
+ height: 128rpx;
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/components/Header.vue b/src/components/Header.vue
index fff26e6..7296572 100644
--- a/src/components/Header.vue
+++ b/src/components/Header.vue
@@ -57,8 +57,9 @@ const signin = () => {
const loading = ref(false);
const pointBook = ref(null);
-const showProgress = ref(false);
const heat = ref(0);
+/** 房间号按钮动态定位样式(position: fixed,根据胶囊真实位置计算,脱离 flex 流避免挤压标题) */
+const battleRoomBtnStyle = ref({});
const updateLoading = (value) => {
loading.value = value;
@@ -80,8 +81,22 @@ onMounted(() => {
pointBook.value = uni.getStorageSync("last-point-book");
}
}
- if (currentPage.route === "pages/team-battle") {
- showProgress.value = true;
+ // 仅在对战房间页获取胶囊位置,按钮用 fixed 定位精确贴靠胶囊左侧(脱离 flex 流,不挤压标题)
+ if (currentPage.route === "pages/battle-room") {
+ try {
+ const menuButtonRect = uni.getMenuButtonBoundingClientRect();
+ const { windowWidth } = uni.getSystemInfoSync();
+ battleRoomBtnStyle.value = {
+ // 按钮右边缘距视口右侧 = 屏幕宽 - 胶囊左边缘 + 4px 安全间隙
+ right: (windowWidth - menuButtonRect.left + 4) + "px",
+ // 垂直位置与胶囊顶部对齐
+ top: menuButtonRect.top + "px",
+ // 高度与胶囊一致,视觉融合
+ height: menuButtonRect.height + "px",
+ };
+ } catch (e) {
+ // 获取失败时使用 CSS 兜底定位(28vw + 4px 作为 right,8px 作为 top)
+ }
}
uni.$on("update-hot", updateHot);
});
@@ -127,8 +142,8 @@ onBeforeUnmount(() => {
{{ title }}
@@ -136,12 +151,12 @@ onBeforeUnmount(() => {
- 凹造型
+ 箭前准备
-
感知距离 {
}}
-
+
-
+
@@ -134,11 +156,13 @@ onBeforeUnmount(() => {
justify-content: center;
font-weight: 500;
}
-.container > button:last-child {
+
+.container>button:last-child {
width: 36px;
height: 36px;
}
-.container > button:last-child > image {
+
+.container>button:last-child>image {
width: 36px;
min-height: 36px;
}
diff --git a/src/components/Matching.vue b/src/components/Matching.vue
index c6c109a..f680c0f 100644
--- a/src/components/Matching.vue
+++ b/src/components/Matching.vue
@@ -123,7 +123,7 @@ onBeforeUnmount(() => {
- 取消匹配
+ 取消匹配
diff --git a/src/components/PlayerScore.vue b/src/components/PlayerScore.vue
index 08931a6..54fea32 100644
--- a/src/components/PlayerScore.vue
+++ b/src/components/PlayerScore.vue
@@ -43,7 +43,7 @@ const rowCount = new Array(6).fill(0);
{{
- scores[1] && scores[1][index] ? `${scores[0][index].ring}环` : "-"
+ scores[1] && scores[1][index] ? `${scores[1][index].ring}环` : "-"
}}
diff --git a/src/components/PlayerSeats.vue b/src/components/PlayerSeats.vue
index cb4c14f..5ce4f2a 100644
--- a/src/components/PlayerSeats.vue
+++ b/src/components/PlayerSeats.vue
@@ -14,6 +14,11 @@ const props = defineProps({
type: Function,
default: () => {},
},
+ /** 当前用户是否为房主;仅房主可见踢人按钮 */
+ isOwner: {
+ type: Boolean,
+ default: false,
+ },
});
const seats = new Array(props.total).fill(1);
@@ -45,8 +50,9 @@ const seats = new Array(props.total).fill(1);
mode="widthFix"
class="player-bg"
/> -->
+
removePlayer(players[index])"
diff --git a/src/components/ShootProgress2.vue b/src/components/ShootProgress2.vue
index 260837f..552aeb6 100644
--- a/src/components/ShootProgress2.vue
+++ b/src/components/ShootProgress2.vue
@@ -29,6 +29,33 @@ const updateRemain = (value) => {
if (timer.value) clearInterval(timer.value);
return
}
+ // zeroThenReset:ToSomeoneShoot 到达时,若进度条仍在倒计时则先瞬间清零(约 150ms 停留)再显示下一玩家满值
+ // 若进度条已到 0(loading 状态),直接切换满值
+ if (value.zeroThenReset) {
+ if (timer.value) clearInterval(timer.value);
+ const wasNonZero = remain.value > 0;
+ // 更新下一玩家颜色和方向(在清零和满值时均生效)
+ currentTeam.value = value.team;
+ if (value.team === 'red') barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
+ if (value.team === 'blue') barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
+ transitionStyle.value = "none";
+ if (wasNonZero) {
+ // 瞬间清零,停留约 150ms 后切换为满值
+ remain.value = 0;
+ loading.value = true;
+ setTimeout(() => {
+ remain.value = value.value;
+ loading.value = false;
+ setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
+ }, 150);
+ } else {
+ // 已在底部,直接切换满值
+ remain.value = value.value;
+ loading.value = false;
+ setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
+ }
+ return;
+ }
loading.value = false;
currentTeam.value = value.team
if (value.team === 'red')
@@ -36,7 +63,14 @@ const updateRemain = (value) => {
if (value.team === 'blue')
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
if (value.reset) {
+ // 重置前先清除旧计时器,防止超时未射箭时旧 interval 残留,导致进度条震荡
+ if (timer.value) clearInterval(timer.value);
+ // 重置时瞬间跳满格,禁用 CSS 过渡避免从旧值「涨到满」的动画
+ transitionStyle.value = "none";
remain.value = value.value;
+ setTimeout(() => {
+ transitionStyle.value = "all 1s linear";
+ }, 50);
return;
}
const newVal = Math.round(value.value);
@@ -51,6 +85,8 @@ const updateRemain = (value) => {
remain.value = newVal;
}
+ // 启动前先清除旧计时器,防止多次 {stop:false} 事件叠加多个 interval
+ if (timer.value) clearInterval(timer.value);
timer.value = setInterval(() => {
loading.value = remain.value === 0;
if (remain.value > 0) remain.value--;
diff --git a/src/components/Swiper.vue b/src/components/Swiper.vue
index 043a91b..849812a 100644
--- a/src/components/Swiper.vue
+++ b/src/components/Swiper.vue
@@ -1,5 +1,5 @@
@@ -70,7 +74,7 @@ watch(
/>
doSomething())"
+ * @param {Function} handler - 原始点击回调函数(可选,点击时直接调用)
+ * @param {string} [soundKey='点击按钮'] - audioManager 中的音效 key
+ */
+ app.config.globalProperties.$clickSound = (handler, soundKey = '点击按钮') => {
+ audioManager.play(soundKey);
+ if (typeof handler === 'function') handler();
+ };
+
return {
app
}
diff --git a/src/pages.json b/src/pages.json
index 4ba6bae..131cadb 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -31,7 +31,7 @@
}
},
{
- "path": "pages/team-battle"
+ "path": "pages/team-battle/index"
},
{
"path": "pages/melee-battle"
@@ -39,6 +39,9 @@
{
"path": "pages/battle-result"
},
+ {
+ "path": "pages/friend-battle-result"
+ },
{
"path": "pages/point-book-edit"
},
diff --git a/src/pages/battle-result.vue b/src/pages/battle-result.vue
index 03e05b7..87d5a30 100644
--- a/src/pages/battle-result.vue
+++ b/src/pages/battle-result.vue
@@ -117,7 +117,7 @@ const checkBowData = () => {
}deg)`,
}"
>
-
+
斩获 {
margin: '0 3px',
fontWeight: '600',
}"
- >{{ data.mvp[0].totalRings }}{{ data.mvp.totalRings }}环
diff --git a/src/pages/battle-room.vue b/src/pages/battle-room.vue
index e488ce5..0449663 100644
--- a/src/pages/battle-room.vue
+++ b/src/pages/battle-room.vue
@@ -9,9 +9,7 @@ import Avatar from "@/components/Avatar.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import {
getRoomAPI,
- destroyRoomAPI,
exitRoomAPI,
- startRoomAPI,
chooseTeamAPI,
getReadyAPI,
kickPlayerAPI,
@@ -19,6 +17,7 @@ import {
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
+import audioManager from "@/audioManager";
const store = useStore();
const { user } = storeToRefs(store);
@@ -42,10 +41,23 @@ const battleTitle = computed(() => {
return `${half}v${half}对抗赛`;
});
+/** 靶纸尺寸(cm),由 URL 参数或 API 返回的 targetType 字段填充 */
+const targetSize = ref(0);
+
+/**
+ * 根据 targetSize 动态生成靶纸尺寸文本,如"40cm"
+ * 数据未就绪时显示 "--";数据来源:创建者取 URL 参数 target,加入者取 API 返回的 targetType
+ */
+const targetSizeLabel = computed(() =>
+ targetSize.value ? `${targetSize.value}cm` : '--'
+);
+
const ready = ref(false);
const allReady = ref(false);
const timer = ref(null);
const goBattle = ref(false);
+/** 从结算页返回时为 true,跳过进场靶纸语音 */
+const skipTargetAudio = ref(false);
/**
* 从服务端刷新当前房间数据,更新成员列表、准备状态等信息
@@ -56,6 +68,15 @@ async function refreshRoomData() {
const result = await getRoomAPI(roomNumber.value);
if (result.started) return;
room.value = result;
+ // 加入者通过 API 返回的 targetType 字段同步靶纸尺寸,并持久化到本地缓存
+ if (result.targetType) {
+ targetSize.value = result.targetType;
+ uni.setStorageSync(`targetSize_${roomNumber.value}`, result.targetType);
+ } else if (targetSize.value === 0) {
+ // API 无该字段时,从本地缓存兜底(如"返回房间"场景)
+ const stored = uni.getStorageSync(`targetSize_${roomNumber.value}`);
+ if (stored) targetSize.value = stored;
+ }
owner.value = {};
opponent.value = {};
const members = result.members || [];
@@ -115,10 +136,6 @@ async function refreshRoomData() {
// timer.value = setTimeout(refreshRoomData, 2000);
}
-const startGame = async () => {
- const result = await startRoomAPI(room.value.number);
-};
-
const getReady = async () => {
await getReadyAPI(roomNumber.value);
};
@@ -178,7 +195,7 @@ async function onReceiveMessage(message) {
uni.setStorageSync("blue-team", message.teams[1].players || []);
uni.setStorageSync("red-team", message.teams[2].players || []);
uni.redirectTo({
- url: "/pages/team-battle?battleId=" + message.matchId,
+ url: "/pages/team-battle/index?battleId=" + message.matchId,
});
} else {
uni.redirectTo({
@@ -193,14 +210,6 @@ const chooseTeam = async (team) => {
refreshMembers(result.members);
};
-const destroyRoom = async () => {
- if (roomNumber.value) await destroyRoomAPI(roomNumber.value);
-};
-
-const exitRoom = async () => {
- uni.navigateBack();
-};
-
/** 待确认踢出的玩家信息 */
const playerToKick = ref(null);
/** 控制踢出确认弹窗的显示状态 */
@@ -241,11 +250,41 @@ const canClick = computed(() => {
return true;
});
+/**
+ * 根据对战类型和人数动态生成分享文案
+ * 1v1 / 默认 → "星球论箭,来一决高下敢否?"
+ * 2v2 → "2v2对抗赛,是兄弟来助我一把!"
+ * 3v3 → "3v3对抗赛,来了马上发车!"
+ * 乱斗 → "热血乱斗赛,敢来争锋?"
+ */
+const shareTitle = computed(() => {
+ const { battleType, count } = room.value;
+ if (battleType === 2) return '热血乱斗赛,敢来争锋?';
+ if (battleType === 1 && count === 4) return '2v2对抗赛,是兄弟来助我一把!';
+ if (battleType === 1 && count === 6) return '3v3对抗赛,来了马上发车!';
+ return '星球论箭,来一决高下敢否?';
+});
+
+/**
+ * 根据对战类型和靶纸尺寸动态返回分享封面图路径,共 4 张图:
+ * contest_share_20.png — 约战/对抗赛 + 20cm 靶
+ * contest_share_40.png — 约战/对抗赛 + 40cm 靶
+ * melee_share_20.png — 多人乱斗 + 20cm 靶
+ * melee_share_40.png — 多人乱斗 + 40cm 靶
+ *
+ * 当 targetSize 未知时默认取 20cm 图。
+ */
+const shareImage = computed(() => {
+ const type = room.value.battleType === 2 ? 'melee' : 'contest';
+ const size = targetSize.value === 40 ? '40' : '20';
+ return `https://static.shelingxingqiu.com/shootmini/static/share/${type}_share_${size}.png`;
+});
+
onShareAppMessage(() => {
return {
- title: "邀请您进入房间对战",
+ title: shareTitle.value,
path: "/pages/friend-battle?roomID=" + roomNumber.value,
- imageUrl: "",
+ imageUrl: shareImage.value,
};
});
@@ -265,10 +304,21 @@ onShow(() => {
* - 同步到 Pinia Store(供 Header 组件展示房号胶囊)
*/
onLoad(async (options) => {
+ // 从结算页跳回时携带 fromResult=1,标记跳过进场语音
+ if (options.fromResult) skipTargetAudio.value = true;
if (options.roomNumber) {
roomNumber.value = options.roomNumber;
store.updateRoomNumber(options.roomNumber);
}
+ // 创建者通过 URL 参数 target(1→20cm,2→40cm)初始化靶纸尺寸,并持久化到本地缓存
+ if (options.target) {
+ targetSize.value = parseInt(options.target) * 20;
+ uni.setStorageSync(`targetSize_${roomNumber.value}`, targetSize.value);
+ } else if (roomNumber.value) {
+ // "返回房间"等无 target 参数的场景:从本地缓存恢复(待 refreshRoomData 进一步覆盖)
+ const stored = uni.getStorageSync(`targetSize_${roomNumber.value}`);
+ if (stored) targetSize.value = stored;
+ }
});
/**
@@ -280,6 +330,13 @@ onMounted(() => {
keepScreenOn: true,
});
uni.$on("socket-inbox", onReceiveMessage);
+ // 页面加载完成 1 秒后根据靶纸尺寸播报对应语音;从结算页返回时跳过
+ setTimeout(() => {
+ if (!skipTargetAudio.value) {
+ const key = targetSize.value === 40 ? '40CM全环靶' : '20CM全环靶';
+ audioManager.play(key);
+ }
+ }, 1000);
});
onBeforeUnmount(() => {
@@ -299,11 +356,8 @@ onBeforeUnmount(() => {
- 弓箭手们,人都到齐了吗?
- {{
- `${room.count / 2}v${room.count / 2}比赛即将开始!`
- }}
- 大乱斗即将开始!
+ 请使用{{ targetSizeLabel }}全环靶
+ 如果实际靶纸与选择靶纸不同,将导致射箭无效
@@ -344,7 +398,8 @@ onBeforeUnmount(() => {
管理员
-
+ removePlayer(item)" :style="{ top: '-10rpx', right: '-10rpx' }">
@@ -384,10 +439,11 @@ onBeforeUnmount(() => {
+
+ :removePlayer="removePlayer" :isOwner="owner.id === user.id" />
-
+
{{
allReady.value
? "即将进入对局..."
@@ -818,4 +874,23 @@ onBeforeUnmount(() => {
border: 1rpx solid #a3793f66;
color: #fed847;
}
+
+.guide-tips__target {
+ font-weight: 400;
+ font-size: 26rpx;
+ color: rgba(255, 217, 71, 0.8);
+}
+
+.guide-tips__warn {
+ font-weight: 400;
+ font-size: 22rpx;
+ color: rgba(255, 255, 255, 0.8);
+ margin-top: 6rpx;
+}
+
+.guide-tips {
+ display: flex;
+ flex-direction: column;
+ padding-left: 20rpx;
+}
diff --git a/src/pages/first-try.vue b/src/pages/first-try.vue
index e33dc79..8201644 100644
--- a/src/pages/first-try.vue
+++ b/src/pages/first-try.vue
@@ -1,5 +1,5 @@
-
+
{
,这是新人必刷小任务,0基础小白也能快速掌握弓箭技巧和游戏规则哦~:)
这是我们人帅技高的高教练。首先,请按教练示范,尝试自己去做这些动作和手势吧。这位就是人帅技高的高教练!接下来请跟随教练指引,做好射箭前期准备。
+ 请按下方步骤完成智能弓校准,让瞄准器和靶子保持对齐。
你知道5米射程有多远吗?
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
-
+
一切准备就绪
试着完成一个真正的弓箭手任务吧!
-
+
新手试炼场通关啦,优秀!
反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:) {
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
class="try-tip"
mode="widthFix"
- v-if="step === 3"
+ v-if="step === 4"
/>
-
+
-
-
+
+
+
+ {{ index + 1 }}
+ {{ guide.title }}
+
+
+
+ 请完成以上步骤校准智能弓
+
+
+
{
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
}}
- {{ stepButtonTexts[step] }}
+ {{ currentStepButtonText }}
@@ -301,4 +375,43 @@ const onClose = async () => {
width: calc(100% - 20px);
margin: 0 10px;
}
+.calibration-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+.calibration-guide {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 26rpx;
+ color: #ffffff;
+ margin-bottom: 15rpx;
+}
+.calibration-guide > view {
+ width: 100%;
+ margin: 25rpx 0;
+ display: flex;
+ align-items: center;
+}
+.calibration-guide > view > text:first-child {
+ font-size: 24rpx;
+ background: #e89024;
+ border-radius: 50%;
+ width: 32rpx;
+ height: 32rpx;
+ line-height: 32rpx;
+ display: block;
+ text-align: center;
+ margin-right: 15rpx;
+}
+.calibration-guide > image {
+ width: 630rpx;
+ height: 250rpx;
+}
+.calibration-container > text {
+ font-size: 24rpx;
+ color: #fff9;
+ margin: 30rpx;
+}
diff --git a/src/pages/friend-battle-result.vue b/src/pages/friend-battle-result.vue
new file mode 100644
index 0000000..f231ce2
--- /dev/null
+++ b/src/pages/friend-battle-result.vue
@@ -0,0 +1,1101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
+
+ 蓝方得分
+ {{ blueScore }}
+
+
+
+ {{ redScore }}
+ 红方得分
+
+
+
+
+
+
+
+
+
+
+
+
+ 斩获{{ mvpPlayer.totalRing }}环
+
+
+
+
+
+ {{ mvpPlayer.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.rank }}
+
+
+
+
+
+
+
+
+ {{ item.name }}
+ {{ item.lvlName }}
+
+
+
+
+ {{ item.totalRing }}
+ 环
+
+
+
+
+
+
+
+ 查看完整成绩
+
+
+
+ +{{ expGained }}经验
+
+ LV.{{ userLvl }}
+
+
+
+ {{ expCurrent }} / {{ expTotal }}
+
+
+
+
+
+
+
+
+
+ {{ exitBtnText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/friend-battle.vue b/src/pages/friend-battle.vue
index 3741aba..2cbdda1 100644
--- a/src/pages/friend-battle.vue
+++ b/src/pages/friend-battle.vue
@@ -98,8 +98,10 @@ onLoad(async (options) => {
- 约上朋友开几局,欢乐多,不寂寞
- 一起练升级更快,早日加入全国排位赛!
+
+ 约上朋友开几局,欢乐多,不寂寞
+ 一起练升级更快,早日加入全国排位赛!
+
@@ -139,7 +141,7 @@ onLoad(async (options) => {
- 进入房间
+ enterRoom(roomNumber))">进入房间
@@ -153,7 +155,7 @@ onLoad(async (options) => {
-
+
创建约战房
@@ -162,7 +164,8 @@ onLoad(async (options) => {
{{ warnning }}
-
+
+
@@ -170,6 +173,24 @@ onLoad(async (options) => {
diff --git a/src/pages/my-device.vue b/src/pages/my-device.vue
index 8ca7210..b4edcd9 100644
--- a/src/pages/my-device.vue
+++ b/src/pages/my-device.vue
@@ -130,7 +130,7 @@ onShow(() => {
-
+
@@ -212,33 +212,35 @@ onShow(() => {
{{ user.nickName }}
-
- 进入新手试炼
-
- 返回首页
-
-
-
+
恭喜,你的弓箭和账号已成功绑定!
已赠送6个月射灵世界会员
-
+
+
+
+ 返回首页
+
+
+ 进入新手试炼
+
+
+
@@ -269,7 +271,7 @@ onShow(() => {
- 解绑
diff --git a/src/pages/my-growth.vue b/src/pages/my-growth.vue
index c0f2bf9..5a2e3b8 100644
--- a/src/pages/my-growth.vue
+++ b/src/pages/my-growth.vue
@@ -55,7 +55,14 @@ const onPractiseLoading = async (page) => {
};
const getName = (battle) => {
if (battle.mode <= 3) return `${battle.mode}V${battle.mode}`;
- return battle.mode + "人大乱斗";
+ // 排位赛大乱斗:mode 数字与实际人数不一致,使用固定映射
+ if (battle.way === 2) {
+ if (battle.mode === 4) return "5人大乱斗";
+ if (battle.mode === 5) return "10人大乱斗";
+ }
+ // 好友约战大乱斗:从 teams[0].players 取实际参与人数动态展示
+ const count = battle.teams?.[0]?.players?.length;
+ return count ? `${count}人大乱斗` : "大乱斗";
};
/**
@@ -108,7 +115,7 @@ onLoad((options) => {
:blueTeam="item.teams[1] ? item.teams[1].players : []"
:redTeam="item.teams[2] ? item.teams[2].players : []"
:winner="item.winTeam"
- :showRank="item.teams[0]"
+ :showRank="!!item.teams[0]"
:showHeader="false"
/>
@@ -131,7 +138,7 @@ onLoad((options) => {
:blueTeam="item.teams[1] ? item.teams[1].players : []"
:redTeam="item.teams[2] ? item.teams[2].players : []"
:winner="item.winTeam"
- :showRank="item.teams[0]"
+ :showRank="!!item.teams[0]"
:showHeader="false"
/>
diff --git a/src/pages/point-book-create.vue b/src/pages/point-book-create.vue
index c95489d..2682c66 100644
--- a/src/pages/point-book-create.vue
+++ b/src/pages/point-book-create.vue
@@ -114,7 +114,7 @@ onMounted(async () => {
/>
- 下一步
+ 下一步
diff --git a/src/pages/point-book-edit.vue b/src/pages/point-book-edit.vue
index 07a42ff..69f61aa 100644
--- a/src/pages/point-book-edit.vue
+++ b/src/pages/point-book-edit.vue
@@ -198,7 +198,7 @@ onLoad((options) => {
-
+
{{ currentGroup === groups ? "保存并查看分析" : "下一组" }}
diff --git a/src/pages/point-book.vue b/src/pages/point-book.vue
index 7494f48..6722cb7 100644
--- a/src/pages/point-book.vue
+++ b/src/pages/point-book.vue
@@ -329,10 +329,10 @@ onShareTimeline(() => {
-
+
-
+
diff --git a/src/pages/practise-one.vue b/src/pages/practise-one.vue
index 9473cf6..a6cbcd3 100644
--- a/src/pages/practise-one.vue
+++ b/src/pages/practise-one.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/Avatar.vue b/src/pages/team-battle/components/Avatar.vue
new file mode 100644
index 0000000..c5ba5bb
--- /dev/null
+++ b/src/pages/team-battle/components/Avatar.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+ {{ rank }}
+
+
+
+
+
diff --git a/src/pages/team-battle/components/BackToGame.vue b/src/pages/team-battle/components/BackToGame.vue
new file mode 100644
index 0000000..748db2e
--- /dev/null
+++ b/src/pages/team-battle/components/BackToGame.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+ 返回进行中的对局
+
+
+ 返回房间
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/BattleFooter.vue b/src/pages/team-battle/components/BattleFooter.vue
new file mode 100644
index 0000000..c7c2442
--- /dev/null
+++ b/src/pages/team-battle/components/BattleFooter.vue
@@ -0,0 +1,203 @@
+=
+
+
+
+
+
+
+
+
+
+
+
+
+ 蓝队({{ bluePoints }})
+ 红队({{ redPoints }})
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ result.shoots[1] && result.shoots[1].length
+ ? result.shoots[1]
+ .map((item) => item.ring)
+ .reduce((last, next) => last + next, 0)
+ : ""
+ }}
+ 环
+
+
+
+
+
+
+
+ 环
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ result.shoots[2] && result.shoots[2].length
+ ? result.shoots[2]
+ .map((item) => item.ring)
+ .reduce((last, next) => last + next, 0)
+ : ""
+ }}
+ 环
+
+
+
+
+
+
+
+ 环
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/BattleHeader.vue b/src/pages/team-battle/components/BattleHeader.vue
new file mode 100644
index 0000000..633d086
--- /dev/null
+++ b/src/pages/team-battle/components/BattleHeader.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+
+ players.length > 5 && e.stopPropagation()"
+ :style="{ paddingTop: showHeader ? '15px' : '0' }"
+ >
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/BowPower.vue b/src/pages/team-battle/components/BowPower.vue
new file mode 100644
index 0000000..9153a2b
--- /dev/null
+++ b/src/pages/team-battle/components/BowPower.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ 电量{{ power || 1 }}%
+
+
+
+
diff --git a/src/pages/team-battle/components/BowTarget.vue b/src/pages/team-battle/components/BowTarget.vue
new file mode 100644
index 0000000..c855fea
--- /dev/null
+++ b/src/pages/team-battle/components/BowTarget.vue
@@ -0,0 +1,518 @@
+
+
+
+
+
+
+
+
+
+
+
+ 中场休息
+
+ 经验 +1
+
+ {{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
+ }}环
+
+
+ 经验 +1
+
+ {{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
+ }}环
+
+ {{ index + 1 }}
+
+
+
+ {{ index + 1 }}
+
+
+
+
+
+
+ 模拟
+ 射箭
+
+
+
+
+
diff --git a/src/pages/team-battle/components/Container.vue b/src/pages/team-battle/components/Container.vue
new file mode 100644
index 0000000..5e3ce8e
--- /dev/null
+++ b/src/pages/team-battle/components/Container.vue
@@ -0,0 +1,353 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 完成进行中的对局才能开启新的。
+ 您有正在进行中的对局,是否进入?
+
+ (showHint = false)">
+ 不进入
+
+
+ {{ isLoading ? "加载中..." : "进入" }}
+
+
+
+
+ 离开比赛可能会导致比赛失败,
+ 确认离开吗?
+
+ 离开比赛
+ (showHint = false)">
+ 继续比赛
+
+
+
+
+ 今天不玩了吗?
+
+ (showHint = false)">
+ 取消
+
+ 确认
+
+
+
+ 完成智能弓校准,即可解锁全部功能
+
+ (showHint = false)">
+ 取消
+
+ 去校准
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? loadingText || "加载中..." : "若加载时间过长,请" }}
+ 点击这里重启
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/Guide.vue b/src/pages/team-battle/components/Guide.vue
new file mode 100644
index 0000000..0400dd6
--- /dev/null
+++ b/src/pages/team-battle/components/Guide.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/Header.vue b/src/pages/team-battle/components/Header.vue
new file mode 100644
index 0000000..35df1ba
--- /dev/null
+++ b/src/pages/team-battle/components/Header.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+ 箭前准备
+ -
+ 感知距离
+ -
+ 小试牛刀
+
+
+
+
+ {{ pointBook.bowType.name }}
+ {{ pointBook.distance }} 米
+ {{
+ pointBook.bowtargetType.name.substring(
+ 0,
+ pointBook.bowtargetType.name.length - 3
+ )
+ }}
+ {{
+ pointBook.bowtargetType.name.substring(
+ pointBook.bowtargetType.name.length - 3
+ )
+ }}
+
+
+
+
+
+
+ 房号: {{ game.roomNumber }}
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/HeaderProgress.vue b/src/pages/team-battle/components/HeaderProgress.vue
new file mode 100644
index 0000000..ce1db3e
--- /dev/null
+++ b/src/pages/team-battle/components/HeaderProgress.vue
@@ -0,0 +1,83 @@
+
+
+
+
+ {{ (tips || "").replace(/你/g, "").replace(/重回/g, "") }}
+ ({{ currentShot }}/{{ totalShot }})
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/IconButton.vue b/src/pages/team-battle/components/IconButton.vue
new file mode 100644
index 0000000..bc466dd
--- /dev/null
+++ b/src/pages/team-battle/components/IconButton.vue
@@ -0,0 +1,39 @@
+
+
+
+
+ {{ name }}
+
+
+
+
diff --git a/src/pages/team-battle/components/PointSwitcher.vue b/src/pages/team-battle/components/PointSwitcher.vue
new file mode 100644
index 0000000..1edf96e
--- /dev/null
+++ b/src/pages/team-battle/components/PointSwitcher.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ 放大
+
+ 真实
+
+
+
+
+
diff --git a/src/pages/team-battle/components/RoundEndTip.vue b/src/pages/team-battle/components/RoundEndTip.vue
new file mode 100644
index 0000000..cc9a6f2
--- /dev/null
+++ b/src/pages/team-battle/components/RoundEndTip.vue
@@ -0,0 +1,172 @@
+
+
+
+
+ 第{{ round }}轮射箭结束
+
+
+ 本轮蓝队
+ {{
+ (roundData.shoots[1] || []).reduce(
+ (last, next) => last + next.ring,
+ 0
+ )
+ }}
+ 环,红队
+ {{
+ (roundData.shoots[2] || []).reduce(
+ (last, next) => last + next.ring,
+ 0
+ )
+ }}
+ 环
+
+
+ 连续3个来回双方均无人射箭,比赛取消。
+
+
+ 红队、蓝队各得{{
+ redPoint
+ }}分
+
+
+ 蓝队获胜,得{{
+ bluePoint
+ }}分
+
+
+ 红队获胜,得{{
+ redPoint
+ }}分
+
+
+
+
+ 蓝队
+ {{ bluePoint }}
+ 分,红队
+ {{ redPoint }}
+ 分
+
+ 同分僵局!最后一箭定江山
+
+ {{ count }}
+ 秒后蓝红双方
+ 决金箭
+ 一箭决胜负
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/SButton.vue b/src/pages/team-battle/components/SButton.vue
new file mode 100644
index 0000000..a4d1bcf
--- /dev/null
+++ b/src/pages/team-battle/components/SButton.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/SModal.vue b/src/pages/team-battle/components/SModal.vue
new file mode 100644
index 0000000..3a0ba89
--- /dev/null
+++ b/src/pages/team-battle/components/SModal.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/ScreenHint.vue b/src/pages/team-battle/components/ScreenHint.vue
new file mode 100644
index 0000000..e3fb03d
--- /dev/null
+++ b/src/pages/team-battle/components/ScreenHint.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/team-battle/components/ShootProgress2.vue b/src/pages/team-battle/components/ShootProgress2.vue
new file mode 100644
index 0000000..89af288
--- /dev/null
+++ b/src/pages/team-battle/components/ShootProgress2.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+ 剩余{{ remain }}秒
+ ···
+
+
+
+
+
diff --git a/src/pages/team-battle/components/TeamAvatars.vue b/src/pages/team-battle/components/TeamAvatars.vue
new file mode 100644
index 0000000..3eb7a3b
--- /dev/null
+++ b/src/pages/team-battle/components/TeamAvatars.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+ {{ isRed ? "红队" : "蓝队" }}
+
+ {{ firstName }}
+
+
+
+
diff --git a/src/pages/team-battle/components/TestDistance.vue b/src/pages/team-battle/components/TestDistance.vue
new file mode 100644
index 0000000..16a27e0
--- /dev/null
+++ b/src/pages/team-battle/components/TestDistance.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+ 请确保站距达到5米
+ 低于5米的射箭无效
+
+
+
+
+
+ 模拟射箭
+
+
+
+ 当前距离{{ distance }}米
+ 已达到距离要求
+ 请调整站位
+
+
+ 请射箭,测试站距
+
+
+
+
+
+
+
+
+
+
+ 具体正式比赛还有
+ {{ count }}
+ 秒
+
+ 进入中...
+
+
+
+
+
diff --git a/src/pages/team-battle/index.vue b/src/pages/team-battle/index.vue
new file mode 100644
index 0000000..b269f64
--- /dev/null
+++ b/src/pages/team-battle/index.vue
@@ -0,0 +1,1277 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备已离线
+ 检测到设备已断开连接,请检查设备后继续比赛
+ 我知道了
+
+
+
+
+
+
+
diff --git a/src/pages/user.vue b/src/pages/user.vue
index 1a05fea..e2f2fcd 100644
--- a/src/pages/user.vue
+++ b/src/pages/user.vue
@@ -69,6 +69,10 @@ onMounted(() => {
const envVersion = accountInfo.miniProgram.envVersion;
if (envVersion !== "release") showLogout.value = true;
});
+
+/* global __BUILD_TIME__ */
+/** 构建时刻,由 vite.config.js define 在打包时自动注入;仅开发/体验版展示 */
+const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : '';
@@ -140,6 +144,10 @@ onMounted(() => {
:onClick="toAudioTestPage"
v-if="showLogout"
/>
+ {{ buildVersion }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/static/friend-battle-result/rank-three.svg b/src/static/friend-battle-result/rank-three.svg
new file mode 100644
index 0000000..d446ca0
--- /dev/null
+++ b/src/static/friend-battle-result/rank-three.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/static/friend-battle-result/rank-two.svg b/src/static/friend-battle-result/rank-two.svg
new file mode 100644
index 0000000..44694a1
--- /dev/null
+++ b/src/static/friend-battle-result/rank-two.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/static/long-bubble-border.png b/src/static/long-bubble-border.png
deleted file mode 100644
index 391bff1..0000000
Binary files a/src/static/long-bubble-border.png and /dev/null differ
diff --git a/src/static/mvp-blue.png b/src/static/mvp-blue.png
new file mode 100644
index 0000000..409705d
Binary files /dev/null and b/src/static/mvp-blue.png differ
diff --git a/src/static/mvp-red.png b/src/static/mvp-red.png
new file mode 100644
index 0000000..61cfa55
Binary files /dev/null and b/src/static/mvp-red.png differ
diff --git a/src/static/mvp-tip.png b/src/static/mvp-tip.png
new file mode 100644
index 0000000..1ae13ca
Binary files /dev/null and b/src/static/mvp-tip.png differ
diff --git a/src/static/shooter2.png b/src/static/shooter2.png
deleted file mode 100644
index 1fb3622..0000000
Binary files a/src/static/shooter2.png and /dev/null differ
diff --git a/src/static/tab-point-book.png b/src/static/tab-point-book.png
index cb2afe0..b49c39c 100644
Binary files a/src/static/tab-point-book.png and b/src/static/tab-point-book.png differ
diff --git a/src/static/you-lost.png b/src/static/you-lost.png
deleted file mode 100644
index 89c9c2f..0000000
Binary files a/src/static/you-lost.png and /dev/null differ
diff --git a/src/static/you-win.png b/src/static/you-win.png
deleted file mode 100644
index 0745f04..0000000
Binary files a/src/static/you-win.png and /dev/null differ
diff --git a/src/store.js b/src/store.js
index b53daef..bacc5e5 100644
--- a/src/store.js
+++ b/src/store.js
@@ -1,5 +1,7 @@
import { defineStore } from "pinia";
+const PERSISTED_STORE_KEY = "store";
+
const defaultUser = {
id: "",
nickName: "",
@@ -8,6 +10,22 @@ const defaultUser = {
lvlName: "",
};
+const getDefaultUser = () => ({ ...defaultUser });
+
+const getDefaultDevice = () => ({
+ deviceId: "",
+ deviceName: "",
+});
+
+const getDefaultGame = () => ({
+ roomID: "",
+ inBattle: false,
+ roomNumber: "",
+ currentShot: 0,
+ totalShot: 0,
+ tips: "",
+});
+
const getLvlName = (rankLvl, rankList = []) => {
if (!rankList) return;
let lvlName = "";
@@ -65,11 +83,8 @@ const getLvlImageByScore = (score, rankList = []) => {
export default defineStore("store", {
// 状态
state: () => ({
- user: defaultUser,
- device: {
- deviceId: "",
- deviceName: "",
- },
+ user: getDefaultUser(),
+ device: getDefaultDevice(),
config: {},
rankData: {
rank: [],
@@ -80,6 +95,9 @@ export default defineStore("store", {
roomID: "",
inBattle: false,
roomNumber: "", // 当前房间号,供 Header 展示房号胶囊
+ currentShot: 0, // 当前已射箭数(用于 HeaderProgress 恢复状态)
+ totalShot: 0, // 轮次总箭数(用于 HeaderProgress 恢复状态)
+ tips: "", // 当前提示文案(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题)
},
}),
@@ -108,7 +126,7 @@ export default defineStore("store", {
this.online = online;
},
async updateUser(user = {}) {
- this.user = { ...defaultUser, ...user };
+ this.user = { ...getDefaultUser(), ...user };
this.user.lvlName = getLvlNameByScore(this.user.scores, this.config.randInfos)
this.user.lvlImage = getLvlImageByScore(
this.user.scores,
@@ -136,20 +154,41 @@ export default defineStore("store", {
this.game.roomID = roomID;
this.game.inBattle = inBattle;
},
+ /** 更新当前射箭进度(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题) */
+ updateShotInfo(currentShot = 0, totalShot = 0) {
+ this.game.currentShot = currentShot;
+ this.game.totalShot = totalShot;
+ },
+ /** 更新当前提示文案(用于 HeaderProgress 恢复状态,替代 uni.$emit 避免时序问题) */
+ updateTips(tips = "") {
+ this.game.tips = tips;
+ },
/** 更新当前房间号,供 Header 组件展示房号胶囊 */
updateRoomNumber(number) {
this.game.roomNumber = number;
},
+ clearSessionState() {
+ this.$patch({
+ user: getDefaultUser(),
+ device: getDefaultDevice(),
+ online: false,
+ game: getDefaultGame(),
+ });
+ uni.removeStorageSync(PERSISTED_STORE_KEY);
+ setTimeout(() => {
+ uni.removeStorageSync(PERSISTED_STORE_KEY);
+ }, 0);
+ },
},
- // 开启数据持久化
+ // 数据持久化(via pinia-plugin-persistedstate)
+ // 仅持久化 user 和 device:身份凭证需在冷启动时恢复(如从分享链接进入)
+ // config、game 等运行时状态不持久化,每次联网后重新拉取
persist: {
- enabled: true,
- strategies: [
- {
- storage: uni.getStorageSync,
- paths: ["user", "device", "config"], // 只持久化用户信息
- },
- ],
+ storage: {
+ getItem: (key) => uni.getStorageSync(key),
+ setItem: (key, value) => uni.setStorageSync(key, value),
+ },
+ paths: ['user', 'device'],
},
});
diff --git a/src/websocket.js b/src/websocket.js
index ba202b3..c06c0b2 100644
--- a/src/websocket.js
+++ b/src/websocket.js
@@ -1,12 +1,22 @@
import { MESSAGETYPES, getMessageTypeName } from "@/constants";
+import { getUserGameState } from "@/apis";
+
let socket = null;
let heartbeatInterval = null;
let reconnectTimer = null;
+let manualClose = false;
+let checkingSession = false;
+let kickedOut = false;
+let isConnecting = false;
-/**
- * 建立 WebSocket 连接
- */
function createWebSocket(token, onMessage) {
+ if (!token) return;
+ if (kickedOut) kickedOut = false;
+ if (socket || isConnecting) return;
+
+ manualClose = false;
+ isConnecting = true;
+
let url = "wss://api.shelingxingqiu.com/socket";
try {
const accountInfo = uni.getAccountInfoSync();
@@ -14,48 +24,71 @@ function createWebSocket(token, onMessage) {
switch (envVersion) {
case "develop": // 开发版
- // url = "ws://localhost:8000/socket";
+ // url = "ws://192.168.1.30:8000/socket";
url = "wss://apitest.shelingxingqiu.com/socket";
break;
case "trial": // 体验版
url = "wss://apitest.shelingxingqiu.com/socket";
break;
- case "release": // 正式版
+ case "trial":
+ url = "wss://apitest.shelingxingqiu.com/socket";
+ break;
+ case "release":
url = "wss://api.shelingxingqiu.com/socket";
break;
default:
- // 保持默认值
break;
}
} catch (e) {
- console.error("获取环境信息失败,使用默认正式环境", e);
+ console.error("获取 WebSocket 环境信息失败,使用默认正式环境", e);
}
+
url += `?authorization=${token}`;
- socket = uni.connectSocket({
+ const socketTask = uni.connectSocket({
url,
success: () => {
- console.log("websocket 连接成功");
- // 启动心跳
- startHeartbeat(onMessage);
+ console.log("WebSocket 已发起连接");
},
- fail: () => {
+ fail: (err) => {
+ if (socket !== socketTask) return;
+ console.error("WebSocket 连接失败", err);
+ socket = null;
+ isConnecting = false;
reconnect(onMessage);
},
});
- // 接收消息
- uni.onSocketMessage((res) => {
+ socket = socketTask;
+
+ socketTask.onOpen(() => {
+ if (socket !== socketTask) return;
+ console.log("WebSocket 连接成功");
+ isConnecting = false;
+ startHeartbeat(onMessage);
+ });
+
+ socketTask.onMessage((res) => {
+ if (socket !== socketTask) return;
+
const { data, event } = JSON.parse(res.data);
if (event === "pong") return;
if (data.type) {
- console.log("收到消息:", getMessageTypeName(data.type), data.data);
+ console.log(
+ "收到 WebSocket 消息",
+ getMessageTypeName(data.type),
+ data.data
+ );
if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return;
}
if (onMessage && data.updates) onMessage(data.updates);
const msg = data.updates[0];
if (msg) {
- console.log("收到消息:", getMessageTypeName(msg.constructor), msg);
+ console.log(
+ "收到 WebSocket 更新",
+ getMessageTypeName(msg.constructor),
+ msg
+ );
if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
@@ -68,84 +101,109 @@ function createWebSocket(token, onMessage) {
}
});
- // 错误处理
- uni.onSocketError((err) => {
+ socketTask.onError((err) => {
+ if (socket !== socketTask) return;
console.error("WebSocket 错误", err);
- reconnect(onMessage);
});
- uni.onSocketClose((result) => {
+ socketTask.onClose(async (result) => {
+ if (socket !== socketTask) return;
console.log("WebSocket 已关闭", result);
stopHeartbeat();
- reconnect(onMessage);
+ socket = null;
+ isConnecting = false;
+
+ if (manualClose || kickedOut) return;
+ await handleUnexpectedClose(onMessage);
});
}
-/**
- * 重连机制
- */
-function reconnect(onMessage) {
- reconnectTimer && clearTimeout(reconnectTimer);
- closeWebSocket(); // 确保关闭旧连接
+async function handleUnexpectedClose(onMessage) {
+ if (checkingSession || manualClose || kickedOut) return;
const token = uni.getStorageSync(
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
);
if (!token) return;
+ checkingSession = true;
+ try {
+ await getUserGameState();
+ if (!manualClose && !kickedOut) reconnect(onMessage);
+ } catch (err) {
+ if (err?.type === "AUTH_INVALID") {
+ kickedOut = true;
+ manualClose = true;
+ reconnectTimer && clearTimeout(reconnectTimer);
+ uni.$emit("session-kicked-out");
+ return;
+ }
+ if (!manualClose && !kickedOut) reconnect(onMessage);
+ } finally {
+ checkingSession = false;
+ }
+}
+
+function reconnect(onMessage) {
+ reconnectTimer && clearTimeout(reconnectTimer);
+
+ const token = uni.getStorageSync(
+ `${uni.getAccountInfoSync().miniProgram.envVersion}_token`
+ );
+ if (!token || manualClose || kickedOut || socket || isConnecting) return;
+
reconnectTimer = setTimeout(() => {
- console.log("reconnecting...");
+ if (manualClose || kickedOut || socket || isConnecting) return;
+ console.log("WebSocket 正在重连...");
createWebSocket(token, onMessage);
}, 1000);
}
-function closeWebSocket() {
+function closeWebSocket(isManual = true) {
+ manualClose = isManual;
+ reconnectTimer && clearTimeout(reconnectTimer);
+ stopHeartbeat();
+ isConnecting = false;
+
if (socket) {
- reconnectTimer && clearTimeout(reconnectTimer);
- stopHeartbeat();
+ const currentSocket = socket;
+ socket = null;
try {
- socket.close();
+ currentSocket.close();
} catch (err) {
- console.error("关闭WebSocket连接失败", err);
+ console.error("关闭 WebSocket 失败", err);
}
-
- socket = null; // 清除socket引用
}
}
function sendHeartbeat(onMessage) {
- uni.sendSocketMessage({
+ if (!socket) return;
+
+ const currentSocket = socket;
+ currentSocket.send({
data: JSON.stringify({ event: "ping", data: {} }),
- success: () => {
- // console.log("发送心跳成功");
- },
+ success: () => {},
fail: (err) => {
- console.error("发送心跳失败", err);
+ if (socket !== currentSocket) return;
+ console.error("心跳发送失败", err);
stopHeartbeat();
- closeWebSocket(); // 关闭失效的连接
- reconnect(onMessage); // 触发重连
+ closeWebSocket(false);
+ reconnect(onMessage);
},
});
}
-/**
- * 启动心跳
- */
function startHeartbeat(onMessage) {
- stopHeartbeat(); // 防止重复启动
+ stopHeartbeat();
heartbeatInterval = setInterval(() => {
- if (socket && socket.readyState === 1) {
- // 检查连接状态
+ if (socket) {
sendHeartbeat(onMessage);
}
}, 10000);
}
-/**
- * 停止心跳
- */
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
diff --git a/vite.config.js b/vite.config.js
index f727f00..4ee5277 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -12,6 +12,14 @@ export default defineConfig({
plugins: [
uni(),
],
+ /** 构建时常量注入:__BUILD_TIME__ 在源码中展开为精确的打包时刻字符串 */
+ define: (() => {
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ // 格式:YYYY.MMDD.HHmm,如 2026.0514.1530
+ const version = `${d.getFullYear()}.${pad(d.getMonth() + 1)}${pad(d.getDate())}.${pad(d.getHours())}${pad(d.getMinutes())}`;
+ return { __BUILD_TIME__: JSON.stringify(version) };
+ })(),
})
diff --git a/yarn.lock b/yarn.lock
index 155f790..e1cb5e1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3468,6 +3468,11 @@ pify@^2.3.0:
resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
+pinia-plugin-persistedstate@3.2.1:
+ version "3.2.1"
+ resolved "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz#66780602aecd6c7b152dd7e3ddc249a1f7a13fe5"
+ integrity sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==
+
pinia@2.0.36:
version "2.0.36"
resolved "https://registry.npmjs.org/pinia/-/pinia-2.0.36.tgz"