Merge branch 'feat-jingzhun' into test

This commit is contained in:
2026-11-06 10:30:01 +08:00
7 changed files with 324 additions and 20 deletions
+9
View File
@@ -1,3 +1,8 @@
import {
MELEE_FIRST_HALF_COMPLETE_AUDIO,
MELEE_SECOND_HALF_COMPLETE_AUDIO,
} from "@/utils/meleeCompletionAudio";
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭";
@@ -62,6 +67,10 @@ export const getStabilityShotAudioKeys = (shootData) => {
};
export const audioFils = {
[MELEE_FIRST_HALF_COMPLETE_AUDIO]:
"https://static.shelingxingqiu.com/shootmini/static/audio20261105/%E4%B8%8A%E5%8D%8A%E5%9C%BA6%E7%AE%AD%E5%B7%B2%E5%B0%84%E5%AE%8C%EF%BC%8C%E8%AF%B7%E7%AD%89%E5%BE%85%E4%BB%96%E4%BA%BA%E5%AE%8C%E6%88%90%E5%B0%84%E7%AE%AD.mp3",
[MELEE_SECOND_HALF_COMPLETE_AUDIO]:
"https://static.shelingxingqiu.com/shootmini/static/audio20261105/%E4%B8%8B%E5%8D%8A%E5%9C%BA6%E7%AE%AD%E5%B7%B2%E5%B0%84%E5%AE%8C%EF%BC%8C%E8%AF%B7%E7%AD%89%E5%BE%85%E6%9C%80%E7%BB%88%E6%8E%92%E5%90%8D.mp3",
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",
设备连接已断开:
+7 -3
View File
@@ -152,7 +152,8 @@ async function onReceiveMessage(msg) {
halfTime.value = false;
audioManager.play(audioKey);
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play(props.endAudioKey, false);
// 大乱斗结束后取消尚未播完的等待提示,避免在结算阶段继续播报。
audioManager.play(props.endAudioKey, props.melee);
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
let arrow = {};
if (msg.details && Array.isArray(msg.details)) {
@@ -163,12 +164,15 @@ async function onReceiveMessage(msg) {
}
let key = [];
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
if (arrow.angle)
if (arrow.angle !== null && arrow.angle !== undefined)
key.push(`向${getDirectionText(arrow.angle)}调整`);
const shouldPlayTententen =
arrow.threeConsecutive10Rings === true ||
msg.shootData?.threeConsecutive10Rings === true;
if (!props.melee && shouldPlayTententen) key.push("tententen");
if (shouldPlayTententen) key.push("tententen");
if (props.melee && msg.meleeCompletionAudioKey) {
key.push(msg.meleeCompletionAudioKey);
}
audioManager.play(key, false);
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTime.value = true;
+21 -2
View File
@@ -9,6 +9,7 @@ import {
getServerMessageTypeName,
} from "@/utils/matchProtocol";
import { MESSAGETYPESV2 } from "@/constants";
import meleeCompletionTracker from "@/utils/meleeCompletionAudio";
import {
getDirectionText,
getDistanceCheckAudioKey,
@@ -597,7 +598,10 @@ function getAckAudioKeys(message, businessMessage) {
stabilityMilestoneAudioKey,
].filter(Boolean);
}
return getShootResultAudioKeys(businessMessage?.shootData);
return [
...getShootResultAudioKeys(businessMessage?.shootData),
businessMessage?.meleeCompletionAudioKey,
].filter(Boolean);
case ServerMessageType.SERVER_MSG_MATCH_END:
return ["比赛结束"];
case ServerMessageType.SERVER_MSG_TIMEOUT:
@@ -1001,7 +1005,7 @@ function queueAckAfterAudio(message, businessMessage) {
task.expectedAudioKey
);
completeAckTask(task);
}, ACK_AUDIO_TIMEOUT_MS);
}, businessMessage?.meleeCompletionAudioKey ? 20000 : ACK_AUDIO_TIMEOUT_MS);
pendingAcks.push(task);
console.log(
`[match-ws] ${actionLabel} queued until audioEnded`,
@@ -1064,6 +1068,21 @@ function handleMessage(data) {
}
// 阈值事件必须先于 ACK 判断生成,确保无 sequence 的状态推送也能播放。
attachStabilityAudioContext(message, businessMessage);
if (businessMessage && isMeleeMessage(message, businessMessage)) {
const info = {
...businessMessage,
mode: getCurrentMode(message, businessMessage),
};
if (businessMessage.type === MESSAGETYPESV2.ShootResult) {
businessMessage.meleeCompletionAudioKey =
meleeCompletionTracker.getShotAudio(info, currentContext?.userId);
} else {
// 中场/结束消息即使不带完整成绩,也关闭已经结束的半场。
if (businessMessage.type === MESSAGETYPESV2.HalfRest) info.status = 3;
if (businessMessage.type === MESSAGETYPESV2.BattleEnd) info.status = 2;
meleeCompletionTracker.observe(info, currentContext?.userId);
}
}
queueAckAfterAudio(message, businessMessage);
emitBusinessMessage(businessMessage);
}
+7 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from "vue";
import { ref, onMounted, onBeforeUnmount, watch, computed } from "vue";
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import BowTarget from "@/components/BowTarget.vue";
@@ -13,6 +13,7 @@ import ScreenHint from "@/components/ScreenHint.vue";
import TestDistance from "@/components/TestDistance.vue";
import SModal from "@/components/SModal.vue";
import audioManager from "@/audioManager";
import meleeCompletionTracker from "@/utils/meleeCompletionAudio";
import { getBattleAPI, laserCloseAPI } from "@/apis";
import {
closeMatchWebSocket,
@@ -255,6 +256,11 @@ watch(online, (newVal, oldVal) => {
function recoverData(battleInfo, { force = false } = {}) {
battleInfo = normalizeBattleInfo(battleInfo);
if (!battleInfo) return;
// 只同步完成状态,不补播恢复前已经打完的半场。
meleeCompletionTracker.observe(
{ ...battleInfo, matchId: battleInfo.matchId || battleId.value },
user.value.id
);
const nextTargetType = Number(battleInfo.targetType ?? battleInfo.target_type);
if ([20, 40].includes(nextTargetType)) targetType.value = nextTargetType;
if (battleInfo.status !== undefined) {
@@ -411,9 +417,6 @@ async function onReceiveMessage(msg) {
recoverData(msg);
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
recoverData(msg);
if (msg.shootData?.threeConsecutive10Rings === true) {
nextTick(() => audioManager.play("tententen", false));
}
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTimeTip.value = true;
halfRest.value = true;
+23 -11
View File
@@ -32,6 +32,7 @@ const defaultSeasonStats = {
rankIcon: "",
avgRing: 0,
winRate: 0,
scoreRate: 0,
modeStats: [],
};
@@ -425,17 +426,23 @@ onShow(async () => {
</text>
</view>
<view>
<text>赛季平均环数</text>
<text>平均环数</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.avgRing, "环") }}
</text>
</view>
<view>
<text>赛季胜率</text>
<text>对战胜率</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.winRate, "%") }}
</text>
</view>
<view>
<text>乱斗得分率</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.scoreRate, "%") }}
</text>
</view>
<view class="my-rank-score">
<image src="https://static.shelingxingqiu.com/shootmini/static/rank/bubble-tip.png" mode="widthFix" />
<text>积分:{{ seasonStats.score }}</text>
@@ -631,7 +638,7 @@ onShow(async () => {
/>
</template>
<style scoped>
<style scoped lang="scss">
.container {
width: 100%;
padding-bottom: 40rpx;
@@ -715,23 +722,28 @@ onShow(async () => {
flex-direction: column;
align-items: center;
justify-content: space-around;
width: 32%;
height: 60px;
flex: 1;
min-width: 0;
height: 120rpx;
position: relative;
}
.my-data > view:nth-child(2) {
border-left: 1px solid #48494e;
border-right: 1px solid #48494e;
padding: 0 15px;
.my-data > view:first-child {
flex: 1.2;
}
.my-data > view:nth-child(2),
.my-data > view:nth-child(3) {
border-right: 2rpx solid #48494e;
}
.my-data > view > text:first-child {
font-size: 14px;
font-size: 26rpx;
white-space: nowrap;
}
.my-data > view > text:last-child {
font-size: 18px;
font-size: 36rpx;
}
.battle-types-box {
+78
View File
@@ -0,0 +1,78 @@
export const MELEE_FIRST_HALF_COMPLETE_AUDIO =
"上半场6箭已射完,请等待他人完成射箭";
export const MELEE_SECOND_HALF_COMPLETE_AUDIO =
"下半场6箭已射完,请等待最终排名";
// 独立于页面和连接生命周期,避免切回页面、同场重连后重复提示。
export function createMeleeCompletionTracker() {
let contextKey = "";
let completedHalves = new Set();
let closedThrough = 0;
function prepare(info, userId) {
if (!info?.matchId || userId === undefined || userId === null) return false;
const key = JSON.stringify([String(info.matchId), String(userId)]);
if (contextKey !== key) {
contextKey = key;
completedHalves = new Set();
closedThrough = 0;
}
return true;
}
function observe(info, userId) {
if (!prepare(info, userId)) return;
const rounds = Array.isArray(info.rounds) ? info.rounds : [];
rounds.slice(0, 2).forEach((round, index) => {
if ((round.shoots?.[userId]?.length || 0) >= 6) {
completedHalves.add(index + 1);
}
});
if (rounds.length === 2 || Number(info.status) === 3) {
closedThrough = Math.max(closedThrough, 1);
}
if ([2, 4].includes(Number(info.status))) closedThrough = 2;
}
function getShotAudio(info, userId) {
if (!prepare(info, userId)) return "";
const mode = Number(info.mode);
const rounds = Array.isArray(info.rounds) ? info.rounds : [];
const half = rounds.length;
const roster = info.teams?.[0]?.players;
const expectedPlayers = mode === 4 ? 5 : mode === 5 ? 10 : 0;
const shoots = rounds[half - 1]?.shoots;
const ownShots = shoots?.[userId];
const eligible =
expectedPlayers > 0 &&
[1, 2].includes(half) &&
half > closedThrough &&
!completedHalves.has(half) &&
![2, 3, 4].includes(Number(info.status)) &&
String(info.shootData?.playerId) === String(userId) &&
Array.isArray(ownShots) && ownShots.length === 6 &&
Array.isArray(roster) && roster.length === expectedPlayers &&
new Set(roster.map((player) => String(player.id))).size === expectedPlayers &&
roster.some((player) => String(player.id) === String(userId)) &&
roster.every((player) => {
// protobuf map 可以省略零箭用户;缺失项按零箭处理,异常项不参与推断。
const list = shoots[player.id];
return list === undefined || Array.isArray(list);
}) &&
roster.some((player) =>
String(player.id) !== String(userId) &&
(shoots[player.id]?.length || 0) < 6
);
// 包括最后完成者在内,完成状态只消费一次;恢复快照也走同一入口。
observe(info, userId);
if (!eligible) return "";
return half === 1
? MELEE_FIRST_HALF_COMPLETE_AUDIO
: MELEE_SECOND_HALF_COMPLETE_AUDIO;
}
return { observe, getShotAudio };
}
export default createMeleeCompletionTracker();