Compare commits
14
Commits
318b952a9b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57374d023 | ||
|
|
4435553c60 | ||
|
|
419cefd00e | ||
|
|
14f9ccd1ef | ||
|
|
899a222e3f | ||
|
|
ca727ec1f7 | ||
|
|
cc8f997600 | ||
|
|
bac84536c7 | ||
|
|
a81494973c | ||
|
|
94c46bb7df | ||
|
|
f3fab17687 | ||
|
|
d22d958215 | ||
|
|
b4ed13d2b5 | ||
|
|
b8b0ab4005 |
+8
-4
@@ -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": []
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 = "请射箭";
|
||||||
@@ -62,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",
|
||||||
设备连接已断开:
|
设备连接已断开:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-2
@@ -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,
|
||||||
@@ -597,7 +598,10 @@ 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:
|
||||||
@@ -1001,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`,
|
||||||
@@ -1064,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>没找到二维码或遇到问题></text>
|
<text>没找到二维码或遇到问题></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%;
|
||||||
|
|||||||
@@ -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连接成功"
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
+25
-32
@@ -93,49 +93,42 @@ 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];
|
||||||
console.log(
|
if (msg) {
|
||||||
"收到 WebSocket 更新",
|
if (ENABLE_REALTIME_MESSAGE_LOG) {
|
||||||
getMessageTypeName(firstMessage.constructor),
|
console.log(
|
||||||
{ updateCount: data.updates.length }
|
"收到 WebSocket 更新",
|
||||||
);
|
getMessageTypeName(msg.constructor),
|
||||||
}
|
{ updateCount: data.updates.length }
|
||||||
data.updates.forEach((msg) => {
|
);
|
||||||
if (msg) {
|
|
||||||
if (msg.constructor === MESSAGETYPES.RankUpdate) {
|
|
||||||
uni.setStorageSync("latestRank", msg.lvl);
|
|
||||||
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
|
|
||||||
uni.setStorageSync("latestLvl", msg.lvl);
|
|
||||||
} else if (msg.constructor === MESSAGETYPES.DeviceOnline) {
|
|
||||||
uni.$emit("update-online", true);
|
|
||||||
} else if (msg.constructor === MESSAGETYPES.DeviceOffline) {
|
|
||||||
uni.$emit("update-online", false);
|
|
||||||
} else if (msg.constructor === MESSAGETYPES.DeviceCharging) {
|
|
||||||
uni.$emit("device-charging");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
if (msg.constructor === MESSAGETYPES.RankUpdate) {
|
||||||
|
uni.setStorageSync("latestRank", msg.lvl);
|
||||||
|
} else if (msg.constructor === MESSAGETYPES.LvlUpdate) {
|
||||||
|
uni.setStorageSync("latestLvl", msg.lvl);
|
||||||
|
} else if (msg.constructor === MESSAGETYPES.DeviceOnline) {
|
||||||
|
uni.$emit("update-online", true);
|
||||||
|
} else if (msg.constructor === MESSAGETYPES.DeviceOffline) {
|
||||||
|
uni.$emit("update-online", false);
|
||||||
|
} else if (msg.constructor === MESSAGETYPES.DeviceCharging) {
|
||||||
|
uni.$emit("device-charging");
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socketTask.onError((err) => {
|
socketTask.onError((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user