From 4435553c609f1839c50e270fcdac488711795334 Mon Sep 17 00:00:00 2001 From: zhangyibo95 <690096405@qq.com> Date: Fri, 6 Nov 2026 10:29:48 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E6=96=B0=E5=A2=9E=E4=B9=B1?= =?UTF-8?q?=E6=96=97=E8=AF=AD=E9=9F=B3=EF=BC=8C=E6=8E=92=E4=BD=8D=E8=B5=9B?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=96=B0=E5=A2=9E=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/melee-completion-audio.test.mjs | 179 ++++++++++++++++++++++++ src/audioManager.js | 9 ++ src/components/ShootProgress.vue | 10 +- src/matchWebsocket.js | 23 ++- src/pages.json | 15 -- src/pages/melee-battle.vue | 11 +- src/pages/ranking.vue | 34 +++-- src/utils/meleeCompletionAudio.js | 78 +++++++++++ 8 files changed, 324 insertions(+), 35 deletions(-) create mode 100644 scripts/melee-completion-audio.test.mjs create mode 100644 src/utils/meleeCompletionAudio.js diff --git a/scripts/melee-completion-audio.test.mjs b/scripts/melee-completion-audio.test.mjs new file mode 100644 index 0000000..cf54ba3 --- /dev/null +++ b/scripts/melee-completion-audio.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +// 项目源码由 Vite 按 ESM 处理,测试通过 data URL 加载无环境依赖的判定模块。 +const source = await readFile( + new URL("../src/utils/meleeCompletionAudio.js", import.meta.url), "utf8" +); +const { + createMeleeCompletionTracker, + MELEE_FIRST_HALF_COMPLETE_AUDIO: firstAudio, + MELEE_SECOND_HALF_COMPLETE_AUDIO: secondAudio, +} = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`); + +function snapshot(mode = 4, half = 1, ownCount = 6, othersCount = 5) { + const count = mode === 5 ? 10 : 5; + const players = Array.from({ length: count }, (_, i) => ({ id: String(i + 1) })); + const shoots = Object.fromEntries(players.map(({ id }) => [ + id, Array.from({ length: id === "1" ? ownCount : othersCount }, () => ({ ring: 10 })), + ])); + return { + matchId: "match-a", mode, status: 1, teams: { 0: { players } }, + rounds: Array.from({ length: half }, (_, index) => ({ + round: index + 1, + shoots: index === half - 1 ? shoots : Object.fromEntries( + players.map(({ id }) => [id, Array(6).fill({ ring: 10 })]) + ), + })), + shootData: { playerId: 1, ring: 10 }, + }; +} + +for (const mode of [4, 5]) { + for (const half of [1, 2]) { + test(`${mode === 4 ? 5 : 10} 人大乱斗第 ${half} 半场:所有非最后完成者播放一次`, () => { + const expected = half === 1 ? firstAudio : secondAudio; + const count = mode === 4 ? 5 : 10; + for (let finishedOthers = 0; finishedOthers < count - 1; finishedOthers++) { + const tracker = createMeleeCompletionTracker(); + tracker.observe(snapshot(mode, half, 5), "1"); + const info = snapshot(mode, half); + for (let i = 2; i <= finishedOthers + 1; i++) { + info.rounds[half - 1].shoots[i] = Array(6).fill({}); + } + assert.equal(tracker.getShotAudio(info, "1"), expected); + assert.equal(tracker.getShotAudio(info, "1"), ""); + } + }); + test(`${mode}/${half}:最后完成、未满六箭、他人完成均不播`, () => { + assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode, half, 6, 6), "1"), ""); + assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode, half, 5), "1"), ""); + const other = snapshot(mode, half); + other.shootData.playerId = "2"; + assert.equal(createMeleeCompletionTracker().getShotAudio(other, "1"), ""); + }); + } +} + +test("上下半场独立,同场重连不重复,新比赛正常触发", () => { + const tracker = createMeleeCompletionTracker(); + assert.equal(tracker.getShotAudio(snapshot(), "1"), firstAudio); + tracker.observe(snapshot(), "1"); + assert.equal(tracker.getShotAudio(snapshot(), "1"), ""); + assert.equal(tracker.getShotAudio(snapshot(4, 2), "1"), secondAudio); + const next = snapshot(); + next.matchId = "match-b"; + assert.equal(tracker.getShotAudio(next, "1"), firstAudio); +}); + +test("恢复已完成快照后不补播,包括页面重新进入", () => { + for (const half of [1, 2]) { + const tracker = createMeleeCompletionTracker(); + const info = snapshot(4, half); + tracker.observe(info, "1"); + assert.equal(tracker.getShotAudio(info, "1"), ""); + } +}); + +test("中场、结束、进入下半场后忽略过期射箭消息", () => { + for (const status of [2, 3, 4]) { + const tracker = createMeleeCompletionTracker(); + tracker.observe({ matchId: "match-a", status }, "1"); + assert.equal(tracker.getShotAudio(snapshot(), "1"), ""); + assert.equal(tracker.getShotAudio({ ...snapshot(), status }, "1"), ""); + } + const tracker = createMeleeCompletionTracker(); + tracker.observe(snapshot(4, 2, 0), "1"); + assert.equal(tracker.getShotAudio(snapshot(), "1"), ""); + assert.equal(tracker.getShotAudio(snapshot(4, 2), "1"), secondAudio); +}); + +test("其他模式和不完整名单不触发", () => { + for (const mode of [1, 2, 3, 6]) { + assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode), "1"), ""); + } + for (const mutate of [ + (info) => { delete info.teams; }, + (info) => { info.teams[0].players.pop(); }, + (info) => { info.teams[0].players[1].id = "1"; }, + (info) => { delete info.rounds; }, + (info) => { info.rounds[0].shoots[2] = {}; }, + (info) => { delete info.matchId; }, + ]) { + const info = snapshot(); + mutate(info); + assert.equal(createMeleeCompletionTracker().getShotAudio(info, "1"), ""); + } +}); + +test("protobuf 缺失零箭用户的 map 项仍可判断有人未完成", () => { + const info = snapshot(4, 1, 6, 6); + delete info.rounds[0].shoots[2]; + assert.equal(createMeleeCompletionTracker().getShotAudio(info, "1"), firstAudio); +}); + +test("播报队列与 ACK 一致:三连十环后播完成提示,提示结束才确认", async () => { + const ws = await readFile(new URL("../src/matchWebsocket.js", import.meta.url), "utf8"); + const component = await readFile(new URL("../src/components/ShootProgress.vue", import.meta.url), "utf8"); + const played = []; + const timers = []; + const acked = []; + const types = { BattleStart: 1, BattleEnd: 2, ShootResult: 3, HalfRest: 4 }; + const serverTypes = { SERVER_MSG_SHOT: 4 }; + const context = vm.createContext({ + MESSAGETYPESV2: types, + ServerMessageType: serverTypes, + props: { melee: true, endAudioKey: "比赛结束" }, + user: { value: { id: "1" } }, + currentContext: { userId: "1", matchId: "match-a" }, + halfTime: { value: false }, + audioManager: { play: (keys, interrupt) => played.push({ keys, interrupt }) }, + getDirectionText: () => "上", + isMeleeMessage: () => true, + pendingAcks: [], + ACK_AUDIO_TIMEOUT_MS: 9000, + shouldCloseAfterAck: () => false, + normalizeId: String, + pickField: (value, key) => value[key], + getServerMessageTypeName: () => "SHOT", + completeAckTask: (task) => acked.push(task.sequence), + setTimeout: (callback, delay) => { timers.push({ callback, delay }); return timers.length; }, + console: { log() {} }, + }); + // 只加载实际业务函数,用轻量替身隔离小程序生命周期与网络。 + for (const [text, start, end] of [ + [component, "async function onReceiveMessage", "const playSound"], + [ws, "function getShootResultAudioKeys", "function normalizeScoreSlot"], + [ws, "function getAckAudioKeys", "function shouldCloseAfterAck"], + [ws, "function queueAckAfterAudio", "function handleMessage"], + [ws, "function flushPendingAcks", "function ensureAudioAckListener"], + ]) { + vm.runInContext(text.slice(text.indexOf(start), text.indexOf(end)), context); + } + const msg = { + ...snapshot(), type: types.ShootResult, sequence: 123, + shootData: { playerId: "1", ring: 10, angle: 0, threeConsecutive10Rings: true }, + meleeCompletionAudioKey: firstAudio, + }; + context.msg = msg; + await vm.runInContext("onReceiveMessage(msg)", context); + const ackKeys = vm.runInContext("getAckAudioKeys({ type: 4 }, msg)", context); + assert.deepEqual(Array.from(played[0].keys), ["10环", "向上调整", "tententen", firstAudio]); + assert.deepEqual(Array.from(ackKeys), Array.from(played[0].keys)); + assert.equal(played[0].interrupt, false); + vm.runInContext("queueAckAfterAudio({ type: 4, sequence: 123 }, msg)", context); + assert.equal(context.pendingAcks[0].expectedAudioKey, firstAudio); + assert.equal(timers[0].delay, 20000); + vm.runInContext('flushPendingAcks("tententen")', context); + assert.deepEqual(acked, []); + vm.runInContext(`flushPendingAcks(${JSON.stringify(firstAudio)})`, context); + assert.deepEqual(acked, [123]); + + // 阶段切换使用打断播放,清除尚未播出的等待提示。 + await vm.runInContext("onReceiveMessage({ type: MESSAGETYPESV2.HalfRest })", context); + assert.equal(played.at(-1).keys, "中场休息"); + await vm.runInContext("onReceiveMessage({ type: MESSAGETYPESV2.BattleEnd })", context); + assert.deepEqual(played.at(-1), { keys: "比赛结束", interrupt: true }); +}); diff --git a/src/audioManager.js b/src/audioManager.js index 44cf16e..797cdcf 100644 --- a/src/audioManager.js +++ b/src/audioManager.js @@ -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", 设备连接已断开: diff --git a/src/components/ShootProgress.vue b/src/components/ShootProgress.vue index 0df0139..b82b6a2 100644 --- a/src/components/ShootProgress.vue +++ b/src/components/ShootProgress.vue @@ -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; diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index c0f6020..35dc05c 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -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); } diff --git a/src/pages.json b/src/pages.json index a2184f4..a4b8ea4 100644 --- a/src/pages.json +++ b/src/pages.json @@ -21,9 +21,6 @@ { "path": "pages/audio-test" }, - { - "path": "pages/calibration" - }, { "path": "pages/about-us" }, @@ -60,12 +57,6 @@ { "path": "pages/match-page" }, - { - "path": "pages/my-device" - }, - { - "path": "pages/device-intro" - }, { "path": "pages/user" }, @@ -107,12 +98,6 @@ }, { "path": "pages/mine-bow-data" - }, - { - "path": "pages/ota-wifi", - "style": { - "navigationStyle": "custom" - } } ], "globalStyle": { diff --git a/src/pages/melee-battle.vue b/src/pages/melee-battle.vue index be0d9a9..2e2f999 100644 --- a/src/pages/melee-battle.vue +++ b/src/pages/melee-battle.vue @@ -1,5 +1,5 @@