16 Commits
Author SHA1 Message Date
zhangyi e57374d023 update:优化样式 2026-11-06 10:42:17 +08:00
zhangyi 4435553c60 update:新增乱斗语音,排位赛界面新增统计 2026-11-06 10:29:48 +08:00
zhangyi 419cefd00e update:优化精准训练描边 2026-11-04 16:06:34 +08:00
zhangyi 14f9ccd1ef Merge branch 'feat-shebei' 2026-10-29 16:49:46 +08:00
zhangyi 899a222e3f update:优化 2026-10-29 16:49:08 +08:00
zhangyi ca727ec1f7 update:优化wifi反馈 2026-10-29 16:28:03 +08:00
zhangyi cc8f997600 Merge branch 'feat-shebei' 2026-10-29 16:12:55 +08:00
zhangyi bac84536c7 update:优化样式 2026-10-29 16:11:58 +08:00
zhangyi a81494973c Merge branch 'feat-shebei' 2026-10-29 15:08:27 +08:00
zhangyi 94c46bb7df update:隐藏新人礼 2026-10-29 15:07:47 +08:00
zhangyi f3fab17687 update:过滤图片 2026-10-29 14:54:00 +08:00
zhangyi d22d958215 Merge branch 'feat-mendian' 2026-10-29 10:05:02 +08:00
zhangyi b4ed13d2b5 Merge branch 'feat-training' 2026-10-29 09:57:48 +08:00
zhangyi b8b0ab4005 Merge branch 'feat-shebei' 2026-10-29 09:57:07 +08:00
zhangyi 05f73acf47 update:优化节奏训练计数 2026-10-23 14:50:15 +08:00
zhangyi eca15178f4 update:优化语音和训练文案 2026-10-23 11:55:02 +08:00
17 changed files with 509 additions and 158 deletions
+8 -4
View File
@@ -4,10 +4,6 @@
"libVersion": "3.7.7", "libVersion": "3.7.7",
"packOptions": { "packOptions": {
"ignore": [ "ignore": [
{
"type": "folder",
"value": "static/coin"
},
{ {
"type": "file", "type": "file",
"value": "static/common/dialog-light.png" "value": "static/common/dialog-light.png"
@@ -247,6 +243,14 @@
{ {
"type": "folder", "type": "folder",
"value": "static/training-difficulty-design" "value": "static/training-difficulty-design"
},
{
"type": "folder",
"value": "static/coin"
},
{
"type": "folder",
"value": "static/home-device"
} }
], ],
"include": [] "include": []
+179
View File
@@ -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 });
});
+17
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_BEGIN_EVENT = "audio-interruption-begin";
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end"; export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭"; export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭";
@@ -18,6 +23,10 @@ const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
export const getTrainingStartAudioKey = (trainingType, fallback = "") => export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback; TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback;
// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。
export const getTrainingEndAudioKey = (completed) =>
completed === true ? "通关成功" : "通关失败";
export const getPrecisionShotAudioKeys = (shootData, directionText = "") => { export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
if (!shootData || typeof shootData !== "object") return []; if (!shootData || typeof shootData !== "object") return [];
const ring = Number(shootData.ring); const ring = Number(shootData.ring);
@@ -58,6 +67,10 @@ export const getStabilityShotAudioKeys = (shootData) => {
}; };
export const audioFils = { 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", 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", 点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
设备连接已断开: 设备连接已断开:
@@ -201,6 +214,10 @@ export const audioFils = {
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3", "https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3",
练习结束: 练习结束:
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3", "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
通关成功:
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3",
通关失败:
"https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3",
射箭声音: 射箭声音:
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3", "https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
命中: 命中:
+7 -3
View File
@@ -152,7 +152,8 @@ async function onReceiveMessage(msg) {
halfTime.value = false; halfTime.value = false;
audioManager.play(audioKey); audioManager.play(audioKey);
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play(props.endAudioKey, false); // 大乱斗结束后取消尚未播完的等待提示,避免在结算阶段继续播报。
audioManager.play(props.endAudioKey, props.melee);
} else if (msg.type === MESSAGETYPESV2.ShootResult) { } else if (msg.type === MESSAGETYPESV2.ShootResult) {
let arrow = {}; let arrow = {};
if (msg.details && Array.isArray(msg.details)) { if (msg.details && Array.isArray(msg.details)) {
@@ -163,12 +164,15 @@ async function onReceiveMessage(msg) {
} }
let key = []; let key = [];
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶"); key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
if (arrow.angle) if (arrow.angle !== null && arrow.angle !== undefined)
key.push(`向${getDirectionText(arrow.angle)}调整`); key.push(`向${getDirectionText(arrow.angle)}调整`);
const shouldPlayTententen = const shouldPlayTententen =
arrow.threeConsecutive10Rings === true || arrow.threeConsecutive10Rings === true ||
msg.shootData?.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); audioManager.play(key, false);
} else if (msg.type === MESSAGETYPESV2.HalfRest) { } else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTime.value = true; halfTime.value = true;
+3 -1
View File
@@ -164,6 +164,8 @@ const defaultHighlightStyle = {
color: "rgba(255, 228, 0, 0.6)", color: "rgba(255, 228, 0, 0.6)",
strokeColor: "rgba(254, 216, 71, 0.82)", strokeColor: "rgba(254, 216, 71, 0.82)",
lineWidthRatio: 0.003, lineWidthRatio: 0.003,
// 在最小线宽计算后应用倍数,确保不同靶面尺寸下按比例加粗。
lineWidthScale: 1,
}; };
// 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。 // 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。
@@ -319,7 +321,7 @@ const drawSectorHighlight = (
(angles.endAngle - angles.startAngle) * safeRevealProgress, (angles.endAngle - angles.startAngle) * safeRevealProgress,
style.color, style.color,
style.strokeColor, style.strokeColor,
Math.max(1, targetRadius * style.lineWidthRatio) Math.max(1, targetRadius * style.lineWidthRatio) * style.lineWidthScale
); );
}); });
}; };
+8 -4
View File
@@ -55,10 +55,6 @@
"appid" : "wxa8f5989dcd45cc23", "appid" : "wxa8f5989dcd45cc23",
"packOptions" : { "packOptions" : {
"ignore" : [ "ignore" : [
{
"type" : "folder",
"value" : "static/coin"
},
{ {
"type" : "file", "type" : "file",
"value" : "static/common/dialog-light.png" "value" : "static/common/dialog-light.png"
@@ -298,6 +294,14 @@
{ {
"type" : "folder", "type" : "folder",
"value" : "static/training-difficulty-design" "value" : "static/training-difficulty-design"
},
{
"type" : "folder",
"value" : "static/coin"
},
{
"type" : "folder",
"value" : "static/home-device"
} }
] ]
}, },
+26 -2
View File
@@ -9,6 +9,7 @@ import {
getServerMessageTypeName, getServerMessageTypeName,
} from "@/utils/matchProtocol"; } from "@/utils/matchProtocol";
import { MESSAGETYPESV2 } from "@/constants"; import { MESSAGETYPESV2 } from "@/constants";
import meleeCompletionTracker from "@/utils/meleeCompletionAudio";
import { import {
getDirectionText, getDirectionText,
getDistanceCheckAudioKey, getDistanceCheckAudioKey,
@@ -18,6 +19,7 @@ import {
getPrecisionShotAudioKeys, getPrecisionShotAudioKeys,
getRhythmShotAudioKeys, getRhythmShotAudioKeys,
getStabilityShotAudioKeys, getStabilityShotAudioKeys,
getTrainingEndAudioKey,
getTrainingStartAudioKey, getTrainingStartAudioKey,
STABILITY_ENERGY_50_AUDIO_KEY, STABILITY_ENERGY_50_AUDIO_KEY,
STABILITY_ENERGY_70_AUDIO_KEY, STABILITY_ENERGY_70_AUDIO_KEY,
@@ -596,13 +598,20 @@ function getAckAudioKeys(message, businessMessage) {
stabilityMilestoneAudioKey, stabilityMilestoneAudioKey,
].filter(Boolean); ].filter(Boolean);
} }
return getShootResultAudioKeys(businessMessage?.shootData); return [
...getShootResultAudioKeys(businessMessage?.shootData),
businessMessage?.meleeCompletionAudioKey,
].filter(Boolean);
case ServerMessageType.SERVER_MSG_MATCH_END: case ServerMessageType.SERVER_MSG_MATCH_END:
return ["比赛结束"]; return ["比赛结束"];
case ServerMessageType.SERVER_MSG_TIMEOUT: case ServerMessageType.SERVER_MSG_TIMEOUT:
if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"]; if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
return ["比赛结束"]; return ["比赛结束"];
case ServerMessageType.SERVER_MSG_PRACTICE_END: case ServerMessageType.SERVER_MSG_PRACTICE_END:
// 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。
if (currentContext?.practiceEndAudioKey === "练习结束") {
return [getTrainingEndAudioKey(businessMessage?.completed)];
}
return [currentContext?.practiceEndAudioKey || "比赛结束"]; return [currentContext?.practiceEndAudioKey || "比赛结束"];
case ServerMessageType.SERVER_MSG_CHECK: case ServerMessageType.SERVER_MSG_CHECK:
return getTestDistanceAudioKeys(businessMessage?.shootData); return getTestDistanceAudioKeys(businessMessage?.shootData);
@@ -996,7 +1005,7 @@ function queueAckAfterAudio(message, businessMessage) {
task.expectedAudioKey task.expectedAudioKey
); );
completeAckTask(task); completeAckTask(task);
}, ACK_AUDIO_TIMEOUT_MS); }, businessMessage?.meleeCompletionAudioKey ? 20000 : ACK_AUDIO_TIMEOUT_MS);
pendingAcks.push(task); pendingAcks.push(task);
console.log( console.log(
`[match-ws] ${actionLabel} queued until audioEnded`, `[match-ws] ${actionLabel} queued until audioEnded`,
@@ -1059,6 +1068,21 @@ function handleMessage(data) {
} }
// 阈值事件必须先于 ACK 判断生成,确保无 sequence 的状态推送也能播放。 // 阈值事件必须先于 ACK 判断生成,确保无 sequence 的状态推送也能播放。
attachStabilityAudioContext(message, businessMessage); 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); queueAckAfterAudio(message, businessMessage);
emitBusinessMessage(businessMessage); emitBusinessMessage(businessMessage);
} }
+5 -5
View File
@@ -447,12 +447,12 @@ onShow(async () => {
<text class="highlight-text">射灵智能弓箭</text> <text class="highlight-text">射灵智能弓箭</text>
<text>设备上的二维码</text> <text>设备上的二维码</text>
</view> </view>
<view class="benefit-copy"> <!-- <view class="benefit-copy">
<text>新设备首次绑定账号可获赠</text> <text>新设备首次绑定账号可获赠</text>
<text class="highlight-text">6个月射灵会员礼包</text> <text class="highlight-text">6个月射灵会员礼包</text>
<text>,</text> <text>,</text>
<text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text> <text>该礼包仅可使用一次,请确保当前登录账号为您本人账号。</text>
</view> </view> -->
<button class="help-link" hover-class="none" @click="showTip = true"> <button class="help-link" hover-class="none" @click="showTip = true">
<text>没找到二维码或遇到问题&gt;</text> <text>没找到二维码或遇到问题&gt;</text>
</button> </button>
@@ -866,7 +866,7 @@ onShow(async () => {
} }
.help-link { .help-link {
margin-top: 72rpx; margin-top: 36rpx;
color: #ffd947; color: #ffd947;
font-size: 26rpx; font-size: 26rpx;
line-height: 36rpx; line-height: 36rpx;
@@ -874,8 +874,8 @@ onShow(async () => {
.product-link { .product-link {
position: fixed; position: fixed;
bottom: 104rpx; bottom: 208rpx;
bottom: calc(env(safe-area-inset-bottom) + 104rpx); /* bottom: calc(env(safe-area-inset-bottom) + 104rpx); */
left: 0; left: 0;
z-index: 2; z-index: 2;
width: 100%; width: 100%;
+20 -1
View File
@@ -39,6 +39,7 @@ const connectMode = ref("secure"); // secure | open | manual
const connectError = ref(""); const connectError = ref("");
const connectStatusText = ref(""); const connectStatusText = ref("");
const isSubmittingWifi = ref(false); const isSubmittingWifi = ref(false);
const wifiSuccessVisible = ref(false);
const keyboardHeight = ref(0); const keyboardHeight = ref(0);
// 控制密码输入框是否显示明文 // 控制密码输入框是否显示明文
const showPassword = ref(false); const showPassword = ref(false);
@@ -364,10 +365,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
secure: connectMode.value === "secure" || !!password, secure: connectMode.value === "secure" || !!password,
}; };
connectError.value = ""; connectError.value = "";
currentState.value = STATES.CONNECTED;
void refreshConnectedWifiName(); void refreshConnectedWifiName();
if (fromFirmwareUpdate.value) { if (fromFirmwareUpdate.value) {
currentState.value = STATES.CONNECTED;
openFirmwareCountdown(); openFirmwareCountdown();
} else {
// 保留配网输入界面,避免成功提示出现前先露出 WiFi 列表。
wifiSuccessVisible.value = true;
} }
} catch (err) { } catch (err) {
if (requestId === wifiConnectRequestId) { if (requestId === wifiConnectRequestId) {
@@ -382,6 +386,13 @@ const submitDeviceWifiConfig = async ({ ssid, password }) => {
} }
}; };
// 普通配网成功后,确认并收起输入界面,停留在当前 WiFi 页面。
const confirmWifiSuccess = () => {
if (!wifiSuccessVisible.value) return;
wifiSuccessVisible.value = false;
currentState.value = STATES.CONNECTED;
};
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。 // 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
const joinNetwork = () => { const joinNetwork = () => {
if (joinDisabled.value) return; if (joinDisabled.value) return;
@@ -755,6 +766,14 @@ onUnmounted(() => {
</view> </view>
</view> </view>
<ModalDialog
:show="wifiSuccessVisible"
content="WIFI连接成功"
confirmText="确定"
:showCancel="false"
:onConfirm="confirmWifiSuccess"
></ModalDialog>
<ModalDialog <ModalDialog
:show="countdownVisible" :show="countdownVisible"
title="WiFi连接成功" title="WiFi连接成功"
+7 -4
View File
@@ -1,5 +1,5 @@
<script setup> <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 { onLoad, onShow, onHide } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue"; import Container from "@/components/Container.vue";
import BowTarget from "@/components/BowTarget.vue"; import BowTarget from "@/components/BowTarget.vue";
@@ -13,6 +13,7 @@ import ScreenHint from "@/components/ScreenHint.vue";
import TestDistance from "@/components/TestDistance.vue"; import TestDistance from "@/components/TestDistance.vue";
import SModal from "@/components/SModal.vue"; import SModal from "@/components/SModal.vue";
import audioManager from "@/audioManager"; import audioManager from "@/audioManager";
import meleeCompletionTracker from "@/utils/meleeCompletionAudio";
import { getBattleAPI, laserCloseAPI } from "@/apis"; import { getBattleAPI, laserCloseAPI } from "@/apis";
import { import {
closeMatchWebSocket, closeMatchWebSocket,
@@ -255,6 +256,11 @@ watch(online, (newVal, oldVal) => {
function recoverData(battleInfo, { force = false } = {}) { function recoverData(battleInfo, { force = false } = {}) {
battleInfo = normalizeBattleInfo(battleInfo); battleInfo = normalizeBattleInfo(battleInfo);
if (!battleInfo) return; if (!battleInfo) return;
// 只同步完成状态,不补播恢复前已经打完的半场。
meleeCompletionTracker.observe(
{ ...battleInfo, matchId: battleInfo.matchId || battleId.value },
user.value.id
);
const nextTargetType = Number(battleInfo.targetType ?? battleInfo.target_type); const nextTargetType = Number(battleInfo.targetType ?? battleInfo.target_type);
if ([20, 40].includes(nextTargetType)) targetType.value = nextTargetType; if ([20, 40].includes(nextTargetType)) targetType.value = nextTargetType;
if (battleInfo.status !== undefined) { if (battleInfo.status !== undefined) {
@@ -411,9 +417,6 @@ async function onReceiveMessage(msg) {
recoverData(msg); recoverData(msg);
} else if (msg.type === MESSAGETYPESV2.ShootResult) { } else if (msg.type === MESSAGETYPESV2.ShootResult) {
recoverData(msg); recoverData(msg);
if (msg.shootData?.threeConsecutive10Rings === true) {
nextTick(() => audioManager.play("tententen", false));
}
} else if (msg.type === MESSAGETYPESV2.HalfRest) { } else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTimeTip.value = true; halfTimeTip.value = true;
halfRest.value = true; halfRest.value = true;
+27 -11
View File
@@ -32,6 +32,7 @@ const defaultSeasonStats = {
rankIcon: "", rankIcon: "",
avgRing: 0, avgRing: 0,
winRate: 0, winRate: 0,
scoreRate: 0,
modeStats: [], modeStats: [],
}; };
@@ -425,17 +426,23 @@ onShow(async () => {
</text> </text>
</view> </view>
<view> <view>
<text>赛季平均环数</text> <text>平均环数</text>
<text :style="{ color: '#E7BA80' }"> <text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.avgRing, "环") }} {{ formatTopStatValue(seasonStats.avgRing, "环") }}
</text> </text>
</view> </view>
<view> <view>
<text>赛季胜率</text> <text>对战胜率</text>
<text :style="{ color: '#E7BA80' }"> <text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.winRate, "%") }} {{ formatTopStatValue(seasonStats.winRate, "%") }}
</text> </text>
</view> </view>
<view>
<text>乱斗得分率</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.scoreRate, "%") }}
</text>
</view>
<view class="my-rank-score"> <view class="my-rank-score">
<image src="https://static.shelingxingqiu.com/shootmini/static/rank/bubble-tip.png" mode="widthFix" /> <image src="https://static.shelingxingqiu.com/shootmini/static/rank/bubble-tip.png" mode="widthFix" />
<text>积分:{{ seasonStats.score }}</text> <text>积分:{{ seasonStats.score }}</text>
@@ -631,7 +638,7 @@ onShow(async () => {
/> />
</template> </template>
<style scoped> <style scoped lang="scss">
.container { .container {
width: 100%; width: 100%;
padding-bottom: 40rpx; padding-bottom: 40rpx;
@@ -715,23 +722,32 @@ onShow(async () => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
width: 32%; flex: 1;
height: 60px; min-width: 0;
height: 120rpx;
position: relative; position: relative;
} }
.my-data > view:first-child {
flex: 1.2;
}
.my-data > view:nth-child(2) { .my-data > view:nth-child(2) {
border-left: 1px solid #48494e; border-left: 2rpx solid #48494e;
border-right: 1px solid #48494e; }
padding: 0 15px;
.my-data > view:nth-child(2),
.my-data > view:nth-child(3) {
border-right: 2rpx solid #48494e;
} }
.my-data > view > text:first-child { .my-data > view > text:first-child {
font-size: 14px; font-size: 26rpx;
white-space: nowrap;
} }
.my-data > view > text:last-child { .my-data > view > text:last-child {
font-size: 18px; font-size: 36rpx;
} }
.battle-types-box { .battle-types-box {
@@ -962,7 +978,7 @@ onShow(async () => {
display: flex; display: flex;
justify-content: center; justify-content: center;
top: -34px; top: -34px;
left: 20px; left: 15px;
} }
.my-rank-score > image { .my-rank-score > image {
@@ -546,6 +546,7 @@ onBeforeUnmount(() => {
:showCrosshair="false" :showCrosshair="false"
:showRingLabels="false" :showRingLabels="false"
:highlightOnly="true" :highlightOnly="true"
:highlightStyle="{ strokeColor: '#00fffc', lineWidthScale: 3 }"
:sectorCount="sectorCount" :sectorCount="sectorCount"
:activeSector="activeSector" :activeSector="activeSector"
:activeRings="activeRings" :activeRings="activeRings"
@@ -8,6 +8,7 @@ import {
nextTick, nextTick,
} from "vue"; } from "vue";
import audioManager, { import audioManager, {
getTrainingEndAudioKey,
getTrainingStartAudioKey, getTrainingStartAudioKey,
RHYTHM_SHOOT_WINDOW_AUDIO_KEY, RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
} from "@/audioManager"; } from "@/audioManager";
@@ -111,6 +112,8 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["rhythm-round-complete"]);
const trainingTitleIconMap = Object.freeze({ const trainingTitleIconMap = Object.freeze({
base: base:
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png", "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
@@ -143,6 +146,7 @@ let rhythmCountdownTimer = null;
let rhythmTransitionTimer = null; let rhythmTransitionTimer = null;
let rhythmServerClockOffsetMs = 0; let rhythmServerClockOffsetMs = 0;
let rhythmSyncGeneration = 0; let rhythmSyncGeneration = 0;
let rhythmRoundCompletionTimer = null;
const isRhythmTraining = computed(() => props.trainingType === "rhythm"); const isRhythmTraining = computed(() => props.trainingType === "rhythm");
const isStabilityTraining = computed( const isStabilityTraining = computed(
@@ -306,6 +310,30 @@ const getRhythmRoundRemainingMs = () => {
); );
}; };
const clearRhythmRoundCompletionTimer = () => {
if (rhythmRoundCompletionTimer !== null) {
clearTimeout(rhythmRoundCompletionTimer);
rhythmRoundCompletionTimer = null;
}
};
// 与当前显示的倒计时一起计时;消息校准不重建此任务,避免同一轮重复计数。
const scheduleRhythmRoundCompletion = (remainingMs) => {
if (
rhythmRoundCompletionTimer !== null ||
!props.start ||
!isRhythmTraining.value ||
remainingMs <= 0
) return;
rhythmRoundCompletionTimer = setTimeout(() => {
rhythmRoundCompletionTimer = null;
if (!props.start || !isRhythmTraining.value) return;
emit("rhythm-round-complete");
scheduleRhythmRoundCompletion(rhythmRoundDurationMs.value);
}, remainingMs);
};
const updateRhythmShootWindow = (isInWindow) => { const updateRhythmShootWindow = (isInWindow) => {
const nextIsInWindow = Boolean(isInWindow); const nextIsInWindow = Boolean(isInWindow);
const enteredShootWindow = const enteredShootWindow =
@@ -364,7 +392,8 @@ const syncRhythmCountdown = async () => {
const generation = ++rhythmSyncGeneration; const generation = ++rhythmSyncGeneration;
clearRhythmTimers(); clearRhythmTimers();
if (!isRhythmTraining.value) { if (!isRhythmTraining.value || !props.start) {
clearRhythmRoundCompletionTimer();
rhythmTransitionStyle.value = "none"; rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = 0; rhythmRemainingMs.value = 0;
rhythmRemainingSeconds.value = 0; rhythmRemainingSeconds.value = 0;
@@ -382,6 +411,7 @@ const syncRhythmCountdown = async () => {
!validRhythmShootTime.value || !validRhythmShootTime.value ||
!normalizeTimestamp(props.shootWindowStart) !normalizeTimestamp(props.shootWindowStart)
) { ) {
clearRhythmRoundCompletionTimer();
rhythmTransitionStyle.value = "none"; rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = 0; rhythmRemainingMs.value = 0;
rhythmRemainingSeconds.value = 0; rhythmRemainingSeconds.value = 0;
@@ -391,6 +421,7 @@ const syncRhythmCountdown = async () => {
rhythmTransitionStyle.value = "none"; rhythmTransitionStyle.value = "none";
rhythmRemainingMs.value = getRhythmRoundRemainingMs(); rhythmRemainingMs.value = getRhythmRoundRemainingMs();
scheduleRhythmRoundCompletion(rhythmRemainingMs.value);
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000); rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
updateRhythmShootWindow( updateRhythmShootWindow(
rhythmRemainingMs.value > 0 && rhythmRemainingMs.value > 0 &&
@@ -462,6 +493,7 @@ watch(
watch( watch(
() => [ () => [
props.start,
props.trainingType, props.trainingType,
props.roundTime, props.roundTime,
props.shootTime, props.shootTime,
@@ -493,7 +525,7 @@ async function onReceiveMessage(msg) {
audioManager.play("比赛开始"); audioManager.play("比赛开始");
} }
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play("练习结束", false); audioManager.play(getTrainingEndAudioKey(msg.completed), false);
} else if (msg.type === MESSAGETYPESV2.ShootResult) { } else if (msg.type === MESSAGETYPESV2.ShootResult) {
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。 // 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
if (props.externalShootResultAudio) return; if (props.externalShootResultAudio) return;
@@ -550,6 +582,7 @@ onBeforeUnmount(() => {
clearTimer(); clearTimer();
rhythmSyncGeneration += 1; rhythmSyncGeneration += 1;
clearRhythmTimers(); clearRhythmTimers();
clearRhythmRoundCompletionTimer();
}); });
</script> </script>
+23 -30
View File
@@ -111,57 +111,50 @@ const createDifficultySummary = (item = {}) => {
const hitReq = toNumber(item.hit_req); const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req); const totalReq = toNumber(item.total_req);
const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows; const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows;
const timeLine = { const timeParts = timeLimit > 0
parts: timeLimit > 0 ? [{ text: `${timeLimit}`, highlight: true }, { text: "秒内," }]
? [{ text: "限时" }, { text: `${timeLimit}秒`, highlight: true }] : [{ text: "不限时," }];
: [{ text: "不限时" }],
};
const summaryMap = { const summaryMap = {
base: [ base: [
{ parts: [ { parts: [
{ text: "需有" }, { text: `${arrows}箭`, highlight: true }, ...timeParts,
{ text: "命中" }, { text: `≥${hitReq}环`, highlight: true }, { text: "命中" }, { text: `${arrows}`, highlight: true },
{ text: "支" }, { text: `≥${hitReq}`, highlight: true },
{ text: "环的箭" },
] }, ] },
timeLine,
], ],
endurance: [ endurance: [
{ parts: [ { parts: [
{ text: "需射箭数" }, { text: `≥${arrows}箭`, highlight: true }, ...timeParts,
{ text: ",累计环数" }, { text: `≥${totalReq}环`, highlight: true }, { text: "共射出" }, { text: `≥${arrows}`, highlight: true },
{ text: "支箭,且总环数" }, { text: `≥${totalReq}`, highlight: true },
{ text: "环" },
] }, ] },
timeLine,
], ],
precision: [ precision: [
{ parts: [ { parts: [
{ text: "需有" }, { text: `${arrows}箭`, highlight: true }, ...timeParts,
{ text: "命中高亮区域" }, { text: "命中" }, { text: `${arrows}`, highlight: true },
{ text: "箭高亮区域" },
] }, ] },
timeLine,
], ],
rhythm: [ rhythm: [
{ parts: [ { parts: [
{ text: "跟随节拍撒放,共" }, { text: `${arrows}箭`, highlight: true }, { text: `${arrows}`, highlight: true },
{ text: "需" }, { text: `${rhythmRequiredArrows}箭`, highlight: true }, { text: "个节奏内,随节奏射箭,命中" },
] }, { text: `${rhythmRequiredArrows}`, highlight: true },
{ parts: [ { text: "箭成绩" }, { text: `≥${hitReq}`, highlight: true },
{ text: "命中" }, { text: `≥${hitReq}环`, highlight: true }, { text: "环且踩中节拍区间" },
{ text: "且踏中节拍区间" },
] }, ] },
], ],
stability: [ stability: [
{ {
parts: [ parts: [
{ text: "命中" }, ...timeParts,
{ text: `≥${hitReq}环`, highlight: true }, { text: "每射一箭" },
{ text: "可为能量槽充能" }, { text: `≥${hitReq}`, highlight: true },
], { text: "环可为能量槽充能,能量充满即过关" },
},
{
parts: [
{ text: timeLimit > 0 ? "限时 " : "不限时" },
{ text: timeLimit > 0 ? `${timeLimit}` : "", highlight: true },
{ text: timeLimit > 0 ? " 秒,能量打满即过关" : ",能量打满即过关" },
], ],
}, },
], ],
+40 -59
View File
@@ -102,8 +102,8 @@ const rhythmServerTimestamp = ref(0);
const rhythmFallbackWindowStart = ref(0); const rhythmFallbackWindowStart = ref(0);
const rhythmFallbackTimestamp = ref(0); const rhythmFallbackTimestamp = ref(0);
const rhythmHasActiveServerAnchor = ref(false); const rhythmHasActiveServerAnchor = ref(false);
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。 // 仅由前端每轮倒计时完成事件累加,不根据服务端箭数推算。
const rhythmTargetArrows = ref(0); const rhythmCompletedRounds = ref(0);
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。 // 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({ const visiblePrecisionTarget = ref({
randomBlock: 0, randomBlock: 0,
@@ -440,10 +440,11 @@ const trainingCopy = computed(() => {
return { return {
inline: true, inline: true,
details: [ details: [
{ text: "需有 " }, { text: "命中 " },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true }, { text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: " 箭命中" }, { text: " 支" },
{ text: `≥${hitReq}环`, highlight: true }, { text: `≥${hitReq}`, highlight: true },
{ text: "环的箭" },
], ],
}; };
} }
@@ -463,10 +464,11 @@ const trainingCopy = computed(() => {
return { return {
inline: true, inline: true,
details: [ details: [
{ text: "需射箭数" }, { text: "射出≥ " },
{ text: `≥ (${currentArrows}/${targetArrows}) 支`, highlight: true }, { text: `(${currentArrows}/${targetArrows})`, highlight: true },
{ text: ",累计环数\n" }, { text: " 支箭,总环数达到≥ " },
{ text: `≥ (${currentRings}/${targetRings}) 环`, highlight: true }, { text: `(${currentRings}/${targetRings})`, highlight: true },
{ text: " 环" },
], ],
}; };
} }
@@ -485,38 +487,34 @@ const trainingCopy = computed(() => {
return { return {
inline: true, inline: true,
details: [ details: [
{ text: "需有 " }, { text: "命中高亮区域 " },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true }, { text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: " 箭命中高亮区域" }, { text: " 箭" },
], ],
}; };
} }
if (trainingType.value === "rhythm") { if (trainingType.value === "rhythm") {
const totalArrows = getPositiveInteger(rhythmTargetArrows.value) || total.value; // 总节奏数沿用关卡配置,与选择页保持一致,不使用消息中的箭数覆盖。
// 节奏训练进度由总箭数减剩余箭数计算,qualifiedArrows 仅用于稳定训练。 const totalArrows = getPositiveInteger(total.value);
// 节奏数由前端倒计时累计,达标箭数与下方金色箭记录使用同一判定。
const targetArrows = const targetArrows =
getPositiveInteger(practiceInfo.value.targetArrows) || getPositiveInteger(practiceInfo.value.targetArrows) ||
getPositiveInteger(trainingParams.value.totalReq) || totalArrows; getPositiveInteger(trainingParams.value.totalReq) || totalArrows;
const completedArrows = Math.min( // 展示当前节奏序号,从 1 开始;内部仍累计已完成次数。
totalArrows, const currentRound = Math.min(totalArrows, rhythmCompletedRounds.value + 1);
Math.max(
0,
totalArrows - getPracticeNumber(practiceInfo.value.arrowsLeft, totalArrows)
)
);
const hitReq = rhythmHitReq.value; const hitReq = rhythmHitReq.value;
const qualifiedArrows = scores.value.filter((arrow) => arrow?.ok === true).length;
return { return {
inline: true, inline: true,
details: [ details: [
{ text: "跟随节拍撒放,共" }, { text: `(${currentRound}/${totalArrows})`, highlight: true },
{ text: `${totalArrows}箭`, highlight: true }, { text: " 个节奏内,跟随节奏射箭,命中 " },
{ text: "需 " }, { text: `(${qualifiedArrows}/${targetArrows})`, highlight: true },
{ text: `(${completedArrows}/${targetArrows}) 箭`, highlight: true }, { text: " 箭" },
{ text: "\n命中" }, { text: `≥${hitReq}`, highlight: true },
{ text: `≥${hitReq}环`, highlight: true }, { text: "环且踩中节拍区间" },
{ text: "且踏中节拍区间" },
], ],
}; };
} }
@@ -529,9 +527,9 @@ const trainingCopy = computed(() => {
return { return {
inline: true, inline: true,
details: [ details: [
{ text: "命中" }, { text: "每射一箭" },
{ text: `≥${hitReq}环`, highlight: true }, { text: `≥${hitReq}`, highlight: true },
{ text: "可为能量槽充能,能量打满即过关" }, { text: "环可为能量槽充能,充满即过关" },
], ],
}; };
} }
@@ -696,31 +694,14 @@ const cacheRhythmTrainingConfig = (message = {}) => {
} }
}; };
const cacheRhythmTargetArrows = (message = {}) => { const onRhythmRoundComplete = () => {
const messageTrainingType = String( if (!isShootingStage.value || !start.value || !isRhythmTraining.value || practiceEnded.value) return;
message.trainingType ?? message.training_type ?? trainingType.value const totalRounds = getPositiveInteger(total.value);
).trim(); rhythmCompletedRounds.value = Math.min(totalRounds, rhythmCompletedRounds.value + 1);
if (messageTrainingType !== "rhythm") return;
const totalArrows = getPositiveInteger(
message.totalArrows ?? message.total_arrows
);
if (totalArrows > 0) {
rhythmTargetArrows.value = totalArrows;
return;
}
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
if (rhythmTargetArrows.value === 0) {
rhythmTargetArrows.value = getPositiveInteger(
message.arrowsLeft ?? message.arrows_left
);
}
}; };
const syncPracticeInfo = (message = {}) => { const syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message); cacheRhythmTrainingConfig(message);
cacheRhythmTargetArrows(message);
const messageTrainingType = String( const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value message.trainingType ?? message.training_type ?? trainingType.value
).trim(); ).trim();
@@ -741,11 +722,9 @@ const syncPracticeInfo = (message = {}) => {
].includes(message.type)); ].includes(message.type));
if (isRhythmSnapshot) { if (isRhythmSnapshot) {
// 完整快照中 proto3 会省略 0;最后一箭及重连时不能保留旧剩余数。 // 达标箭数的 0 按 protobuf 默认值处理;剩余数缺失不能用于推算节奏进度。
for (const field of ["arrowsLeft", "qualifiedArrows"]) { if (!Object.prototype.hasOwnProperty.call(nextInfo, "qualifiedArrows")) {
if (!Object.prototype.hasOwnProperty.call(nextInfo, field)) { nextInfo.qualifiedArrows = 0;
nextInfo[field] = 0;
}
} }
} }
@@ -1427,7 +1406,7 @@ onLoad((options = {}) => {
serverAddr.value = trainingContext.serverAddr || ""; serverAddr.value = trainingContext.serverAddr || "";
const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type); const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
const warmupKeys = [startAudioKey, "胜利", "失败"]; const warmupKeys = [startAudioKey, "通关成功", "通关失败", "胜利", "失败"];
if (trainingParams.value.type === "precision") { if (trainingParams.value.type === "precision") {
warmupKeys.push("Bingo命中目标", "未命中"); warmupKeys.push("Bingo命中目标", "未命中");
} else if (trainingParams.value.type === "rhythm") { } else if (trainingParams.value.type === "rhythm") {
@@ -1457,6 +1436,7 @@ const onReady = async () => {
} }
practiceStarting.value = true; practiceStarting.value = true;
rhythmCompletedRounds.value = 0;
pageStage.value = pageStages.LOADING; pageStage.value = pageStages.LOADING;
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
@@ -1698,7 +1678,7 @@ async function onRetry() {
rhythmFallbackWindowStart.value = 0; rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0; rhythmFallbackTimestamp.value = 0;
rhythmHasActiveServerAnchor.value = false; rhythmHasActiveServerAnchor.value = false;
rhythmTargetArrows.value = 0; rhythmCompletedRounds.value = 0;
invalidateShotPresentations({ resetVisible: true }); invalidateShotPresentations({ resetVisible: true });
start.value = false; start.value = false;
scores.value = []; scores.value = [];
@@ -1721,7 +1701,7 @@ function onAudioEnded(key) {
Array.from(audioWaiters).forEach((waiter) => { Array.from(audioWaiters).forEach((waiter) => {
if (waiter.expectedKey === key) waiter.done(); if (waiter.expectedKey === key) waiter.done();
}); });
if (["比赛结束", "练习结束"].includes(String(key || ""))) { if (["比赛结束", "练习结束", "通关成功", "通关失败"].includes(String(key || ""))) {
void onOver(); void onOver();
} }
} }
@@ -1869,6 +1849,7 @@ onBeforeUnmount(() => {
trainingType === 'stability' trainingType === 'stability'
" "
:onStop="onTimeLimitReached" :onStop="onTimeLimitReached"
@rhythm-round-complete="onRhythmRoundComplete"
/> />
<view class="user-row"> <view class="user-row">
<!-- <Avatar :src="user.avatar" :size="35" /> --> <!-- <Avatar :src="user.avatar" :size="35" /> -->
+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();
+9 -16
View File
@@ -93,36 +93,30 @@ function createWebSocket(token, onMessage) {
"/addons/shoot/otaResult", "/addons/shoot/otaResult",
]; ];
if (passthroughEvents.includes(event)) { if (passthroughEvents.includes(event)) {
if ( if ((code == null || Number(code) === 0) && onMessage && data && typeof data === "object") {
(code == null || Number(code) === 0) &&
onMessage &&
data &&
typeof data === "object"
) {
onMessage({ event, data, code, timestamp }); onMessage({ event, data, code, timestamp });
} }
return; return;
} }
if (!data || typeof data !== "object") return; if (data?.type) {
if (data.type) {
if (ENABLE_REALTIME_MESSAGE_LOG) { if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log("收到 WebSocket 消息", getMessageTypeName(data.type)); console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
} }
if (onMessage) onMessage({ ...(data.data || {}), type: data.type }); if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return; return;
} }
if (!Array.isArray(data.updates)) return; const updates = Array.isArray(data?.updates) ? data.updates : [];
if (onMessage) onMessage(data.updates); if (!updates.length) return;
const firstMessage = data.updates[0]; if (onMessage) onMessage(updates);
if (firstMessage && ENABLE_REALTIME_MESSAGE_LOG) { const msg = updates[0];
if (msg) {
if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log( console.log(
"收到 WebSocket 更新", "收到 WebSocket 更新",
getMessageTypeName(firstMessage.constructor), getMessageTypeName(msg.constructor),
{ updateCount: data.updates.length } { updateCount: data.updates.length }
); );
} }
data.updates.forEach((msg) => {
if (msg) {
if (msg.constructor === MESSAGETYPES.RankUpdate) { if (msg.constructor === MESSAGETYPES.RankUpdate) {
uni.setStorageSync("latestRank", msg.lvl); uni.setStorageSync("latestRank", msg.lvl);
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) { } else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
@@ -136,7 +130,6 @@ function createWebSocket(token, onMessage) {
} }
} }
}); });
});
socketTask.onError((err) => { socketTask.onError((err) => {
if (socket !== socketTask) return; if (socket !== socketTask) return;