update:对接个人训练改版
@@ -8,7 +8,7 @@ try {
|
||||
|
||||
switch (envVersion) {
|
||||
case "develop": // 开发版
|
||||
// BASE_URL = "http://192.168.1.2:8000/api/shoot";
|
||||
// BASE_URL = "http://192.168.1.5:8000/api/shoot";
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "trial": // 体验版
|
||||
@@ -26,8 +26,6 @@ try {
|
||||
}
|
||||
|
||||
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||
const PRACTICE_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api");
|
||||
|
||||
// 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。
|
||||
function request(method, url, data = {}, baseUrl = BASE_URL) {
|
||||
const token = uni.getStorageSync(
|
||||
@@ -264,13 +262,11 @@ export const createPractiseAPI = (arrows, time, target) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createPractiseV2API = (arrows, time, target, deviceId) => {
|
||||
return request("POST", "/practice/create-v2", {
|
||||
shootNumber: arrows,
|
||||
shootTime: time,
|
||||
targetType: Number(target || 1) * 20,
|
||||
deviceId,
|
||||
}, PRACTICE_BASE_URL);
|
||||
export const createPractiseV2API = (trainingType, difficultyLevel) => {
|
||||
return request("POST", "/user/practice/create/v2", {
|
||||
trainingType,
|
||||
difficultyLevel,
|
||||
});
|
||||
};
|
||||
|
||||
export const startPractiseAPI = (id) => {
|
||||
|
||||
@@ -21,34 +21,29 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否显示象限文字。
|
||||
showQuadrantLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否显示环数文字。
|
||||
showRingLabels: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 象限文字配置,key 为 1/2/3/4。
|
||||
quadrantLabels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
}),
|
||||
// 从正上方开始顺时针等分的区域数量。
|
||||
sectorCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 高亮区域数组。
|
||||
// quadrant: 1/2/3/4,表示第几个象限。
|
||||
// rings: "all" 或环数数组,例如 [7, 8, 9, 10]。
|
||||
// scope: "box" 表示整象限矩形,"sector" 表示环形扇区。
|
||||
// style: 可覆盖高亮填充色、描边色、线宽比例。
|
||||
highlightAreas: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
// 当前高亮区域,范围为 1 到 sectorCount。
|
||||
activeSector: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 指定环数,1 到 10;无效值表示高亮整个区域。
|
||||
activeRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showSectorLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 只绘制透明高亮层,不绘制完整靶纸;用于叠加在靶纸图片上。
|
||||
highlightOnly: {
|
||||
@@ -74,8 +69,18 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 象限文字样式覆盖配置。
|
||||
quadrantLabelStyle: {
|
||||
// 区域分割线样式覆盖配置。
|
||||
sectorStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 区域数字样式覆盖配置。
|
||||
sectorLabelStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 高亮样式覆盖配置。
|
||||
highlightStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
@@ -120,16 +125,24 @@ const defaultCrosshairStyle = {
|
||||
lineWidthRatio: 0.0025,
|
||||
};
|
||||
|
||||
// 象限文字默认样式。
|
||||
const defaultQuadrantLabelStyle = {
|
||||
// 顺时针等分线默认样式。
|
||||
const defaultSectorStyle = {
|
||||
color: "rgba(255, 255, 255, 0.82)",
|
||||
lineWidthRatio: 0.004,
|
||||
};
|
||||
|
||||
// 区域数字默认样式。
|
||||
const defaultSectorLabelStyle = {
|
||||
color: "#ffffff",
|
||||
fontSizeRatio: 0.045,
|
||||
offsetRatio: 0.78,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.62)",
|
||||
fontSizeRatio: 0.075,
|
||||
radiusRatio: 0.76,
|
||||
badgeRadiusRatio: 0.07,
|
||||
};
|
||||
|
||||
// 高亮区域默认样式。
|
||||
const defaultHighlightStyle = {
|
||||
color: "rgba(254, 216, 71, 0.34)",
|
||||
color: "rgba(255, 228, 0, 0.6)",
|
||||
strokeColor: "rgba(254, 216, 71, 0.82)",
|
||||
lineWidthRatio: 0.003,
|
||||
};
|
||||
@@ -155,40 +168,24 @@ const getRingColor = (ring, config) => {
|
||||
return config.ringColors?.[ring] || config.ringColors?.[String(ring)] || "#ffffff";
|
||||
};
|
||||
|
||||
// 规范化高亮环数配置;"all" 表示全部环,数组或单值会过滤非法环数。
|
||||
const normalizeRings = (rings, ringCount) => {
|
||||
if (rings === "all") {
|
||||
return "all";
|
||||
}
|
||||
|
||||
const rawRings = Array.isArray(rings) ? rings : [rings];
|
||||
return rawRings
|
||||
.map((ring) => Number(ring))
|
||||
.filter((ring) => Number.isInteger(ring) && ring >= 1 && ring <= ringCount);
|
||||
const getPositiveInteger = (value) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
// 获取象限对应的扇形弧度范围。
|
||||
const getQuadrantAngles = (quadrant) => {
|
||||
const angleMap = {
|
||||
1: [Math.PI, Math.PI * 1.5],
|
||||
2: [Math.PI * 1.5, Math.PI * 2],
|
||||
3: [Math.PI * 0.5, Math.PI],
|
||||
4: [0, Math.PI * 0.5],
|
||||
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
|
||||
const getSectorAngles = (sector, sectorCount) => {
|
||||
const count = getPositiveInteger(sectorCount);
|
||||
const index = getPositiveInteger(sector);
|
||||
if (!count || !index || index > count) return null;
|
||||
|
||||
const step = (Math.PI * 2) / count;
|
||||
const startAngle = -Math.PI / 2 + (index - 1) * step;
|
||||
return {
|
||||
startAngle,
|
||||
endAngle: startAngle + step,
|
||||
middleAngle: startAngle + step / 2,
|
||||
};
|
||||
|
||||
return angleMap[Number(quadrant)] || null;
|
||||
};
|
||||
|
||||
// 获取象限对应的矩形区域,用于整象限高亮。
|
||||
const getQuadrantBox = (quadrant, centerX, centerY, radius) => {
|
||||
const boxMap = {
|
||||
1: [centerX - radius, centerY - radius, radius, radius],
|
||||
2: [centerX, centerY - radius, radius, radius],
|
||||
3: [centerX - radius, centerY, radius, radius],
|
||||
4: [centerX, centerY, radius, radius],
|
||||
};
|
||||
|
||||
return boxMap[Number(quadrant)] || null;
|
||||
};
|
||||
|
||||
// 绘制实心圆,靶纸环区和中心点都会用到。
|
||||
@@ -240,58 +237,61 @@ const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制所有高亮区域,支持整象限矩形高亮和指定环数扇区高亮。
|
||||
const drawHighlights = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
props.highlightAreas.forEach((area = {}) => {
|
||||
const angles = getQuadrantAngles(area.quadrant);
|
||||
// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
|
||||
const drawSectorHighlight = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
const angles = getSectorAngles(props.activeSector, props.sectorCount);
|
||||
if (!angles) return;
|
||||
|
||||
if (!angles) {
|
||||
return;
|
||||
}
|
||||
const ring = getPositiveInteger(props.activeRing);
|
||||
const hasActiveRing = ring >= 1 && ring <= config.ringCount;
|
||||
const innerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount - ring) / config.ringCount)
|
||||
: 0;
|
||||
const outerRadius = hasActiveRing
|
||||
? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
|
||||
: targetRadius;
|
||||
const style = {
|
||||
...defaultHighlightStyle,
|
||||
...props.highlightStyle,
|
||||
};
|
||||
|
||||
const highlightStyle = {
|
||||
...defaultHighlightStyle,
|
||||
...(area.style || {}),
|
||||
};
|
||||
const highlightLineWidth = Math.max(1, targetRadius * highlightStyle.lineWidthRatio);
|
||||
const rings = normalizeRings(area.rings || "all", config.ringCount);
|
||||
const scope = area.scope || (rings === "all" ? "box" : "sector");
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
angles.startAngle,
|
||||
angles.endAngle,
|
||||
style.color,
|
||||
style.strokeColor,
|
||||
Math.max(1, targetRadius * style.lineWidthRatio)
|
||||
);
|
||||
};
|
||||
|
||||
// 整象限默认画成矩形高亮,便于对应 1/2/3/4 号框训练提示。
|
||||
if (rings === "all" && scope === "box") {
|
||||
const box = getQuadrantBox(area.quadrant, centerX, centerY, targetRadius);
|
||||
if (!box) return;
|
||||
ctx.beginPath();
|
||||
ctx.rect(...box);
|
||||
ctx.setFillStyle(highlightStyle.color);
|
||||
ctx.fill();
|
||||
ctx.setStrokeStyle(highlightStyle.strokeColor);
|
||||
ctx.setLineWidth(highlightLineWidth);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
// 从正上方开始顺时针绘制所有区域边界。
|
||||
const drawSectorLines = (ctx, centerX, centerY, targetRadius) => {
|
||||
const count = getPositiveInteger(props.sectorCount);
|
||||
if (!count) return;
|
||||
|
||||
const targetRings = rings === "all"
|
||||
? Array.from({ length: config.ringCount }, (_, index) => index + 1)
|
||||
: rings;
|
||||
const style = {
|
||||
...defaultSectorStyle,
|
||||
...props.sectorStyle,
|
||||
};
|
||||
const step = (Math.PI * 2) / count;
|
||||
|
||||
targetRings.forEach((ring) => {
|
||||
const innerRadius = targetRadius * ((config.ringCount - ring) / config.ringCount);
|
||||
const outerRadius = targetRadius * ((config.ringCount + 1 - ring) / config.ringCount);
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
angles[0],
|
||||
angles[1],
|
||||
highlightStyle.color,
|
||||
highlightStyle.strokeColor,
|
||||
highlightLineWidth
|
||||
);
|
||||
});
|
||||
});
|
||||
ctx.beginPath();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const angle = -Math.PI / 2 + index * step;
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.lineTo(
|
||||
centerX + Math.cos(angle) * targetRadius,
|
||||
centerY + Math.sin(angle) * targetRadius
|
||||
);
|
||||
}
|
||||
ctx.setStrokeStyle(style.color);
|
||||
ctx.setLineWidth(Math.max(1, targetRadius * style.lineWidthRatio));
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// 绘制各环之间的分割线。
|
||||
@@ -351,44 +351,30 @@ const drawRingLabels = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制象限文字。
|
||||
const drawQuadrantLabels = (ctx, centerX, centerY, targetRadius) => {
|
||||
if (!props.showQuadrantLabels) {
|
||||
return;
|
||||
}
|
||||
// 在每个区域中线位置绘制编号,编号层始终位于高亮和分割线之上。
|
||||
const drawSectorLabels = (ctx, centerX, centerY, targetRadius) => {
|
||||
const count = getPositiveInteger(props.sectorCount);
|
||||
if (!props.showSectorLabels || !count) return;
|
||||
|
||||
const style = {
|
||||
...defaultQuadrantLabelStyle,
|
||||
...props.quadrantLabelStyle,
|
||||
};
|
||||
const offset = targetRadius * style.offsetRatio;
|
||||
const positions = {
|
||||
1: [centerX - offset, centerY - offset],
|
||||
2: [centerX + offset, centerY - offset],
|
||||
3: [centerX - offset, centerY + offset],
|
||||
4: [centerX + offset, centerY + offset],
|
||||
...defaultSectorLabelStyle,
|
||||
...props.sectorLabelStyle,
|
||||
};
|
||||
const labelRadius = targetRadius * style.radiusRatio;
|
||||
const badgeRadius = Math.max(10, targetRadius * style.badgeRadiusRatio);
|
||||
|
||||
ctx.setFontSize(Math.max(12, targetRadius * style.fontSizeRatio));
|
||||
ctx.setFontSize(Math.max(11, targetRadius * style.fontSizeRatio));
|
||||
ctx.setTextAlign("center");
|
||||
ctx.setTextBaseline("middle");
|
||||
ctx.setFillStyle(style.color);
|
||||
|
||||
Object.entries(positions).forEach(([key, position]) => {
|
||||
const label = props.quadrantLabels?.[key] || props.quadrantLabels?.[Number(key)];
|
||||
if (label === undefined || label === null || label === "") return;
|
||||
ctx.fillText(String(label), position[0], position[1]);
|
||||
});
|
||||
};
|
||||
|
||||
// 生成高亮区域的绘制 key,只保留真正影响画面的字段。
|
||||
const getHighlightDrawKeyAreas = () => {
|
||||
return props.highlightAreas.map((area = {}) => ({
|
||||
quadrant: area.quadrant,
|
||||
rings: area.rings,
|
||||
scope: area.scope,
|
||||
style: area.style,
|
||||
}));
|
||||
for (let sector = 1; sector <= count; sector += 1) {
|
||||
const angles = getSectorAngles(sector, count);
|
||||
const x = centerX + Math.cos(angles.middleAngle) * labelRadius;
|
||||
const y = centerY + Math.sin(angles.middleAngle) * labelRadius;
|
||||
drawCircle(ctx, x, y, badgeRadius, style.backgroundColor);
|
||||
ctx.setFillStyle(style.color);
|
||||
ctx.fillText(String(sector), x, y);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成本次绘制状态的唯一 key,用于避免相同内容重复 draw。
|
||||
@@ -398,12 +384,16 @@ const getDrawKey = (width, height) => {
|
||||
height,
|
||||
coordinateRadius: props.coordinateRadius,
|
||||
showCrosshair: props.showCrosshair,
|
||||
showQuadrantLabels: props.showQuadrantLabels,
|
||||
showRingLabels: props.showRingLabels,
|
||||
highlightAreas: getHighlightDrawKeyAreas(),
|
||||
sectorCount: props.sectorCount,
|
||||
activeSector: props.activeSector,
|
||||
activeRing: props.activeRing,
|
||||
showSectorLabels: props.showSectorLabels,
|
||||
targetStyleConfig: props.targetStyleConfig,
|
||||
crosshairStyle: props.crosshairStyle,
|
||||
quadrantLabelStyle: props.quadrantLabelStyle,
|
||||
sectorStyle: props.sectorStyle,
|
||||
sectorLabelStyle: props.sectorLabelStyle,
|
||||
highlightStyle: props.highlightStyle,
|
||||
highlightOnly: props.highlightOnly,
|
||||
});
|
||||
};
|
||||
@@ -431,7 +421,7 @@ const drawTarget = () => {
|
||||
drawTargetRings(ctx, centerX, centerY, targetRadius, config);
|
||||
}
|
||||
|
||||
drawHighlights(ctx, centerX, centerY, targetRadius, config);
|
||||
drawSectorHighlight(ctx, centerX, centerY, targetRadius, config);
|
||||
|
||||
if (!props.highlightOnly) {
|
||||
drawRingLines(ctx, centerX, centerY, targetRadius, config);
|
||||
@@ -444,9 +434,12 @@ const drawTarget = () => {
|
||||
);
|
||||
drawCrosshair(ctx, centerX, centerY, targetRadius);
|
||||
drawRingLabels(ctx, centerX, centerY, targetRadius, config);
|
||||
drawQuadrantLabels(ctx, centerX, centerY, targetRadius);
|
||||
}
|
||||
|
||||
// 高亮先画,等分线和编号后画,避免高亮覆盖区域边界。
|
||||
drawSectorLines(ctx, centerX, centerY, targetRadius);
|
||||
drawSectorLabels(ctx, centerX, centerY, targetRadius);
|
||||
|
||||
ctx.draw();
|
||||
lastDrawKey.value = drawKey;
|
||||
};
|
||||
@@ -494,16 +487,19 @@ watch(
|
||||
() => [
|
||||
props.coordinateRadius,
|
||||
props.showCrosshair,
|
||||
props.showQuadrantLabels,
|
||||
props.showRingLabels,
|
||||
props.highlightAreas,
|
||||
props.sectorCount,
|
||||
props.activeSector,
|
||||
props.activeRing,
|
||||
props.showSectorLabels,
|
||||
props.highlightOnly,
|
||||
props.canvasWidth,
|
||||
props.canvasHeight,
|
||||
props.quadrantLabels,
|
||||
props.targetStyleConfig,
|
||||
props.crosshairStyle,
|
||||
props.quadrantLabelStyle,
|
||||
props.sectorStyle,
|
||||
props.sectorLabelStyle,
|
||||
props.highlightStyle,
|
||||
],
|
||||
scheduleDraw,
|
||||
{
|
||||
|
||||
@@ -289,6 +289,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createAckMessage,
|
||||
createHeartbeatAckMessage,
|
||||
createLeaveMessage,
|
||||
createSyncPracticeInfoMessage,
|
||||
decodeServerMessage,
|
||||
getServerMessageTypeName,
|
||||
} from "@/utils/matchProtocol";
|
||||
@@ -57,6 +58,7 @@ const BUSINESS_TYPE_BY_SERVER_TYPE = {
|
||||
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
|
||||
export const MATCH_WS_STATE_EVENT = "match-ws-state";
|
||||
export const MATCH_WS_PRACTICE_SYNC_EVENT = "match-ws-practice-sync";
|
||||
|
||||
function normalizeShootData(shootData) {
|
||||
if (!shootData || typeof shootData !== "object") return shootData;
|
||||
@@ -184,6 +186,22 @@ function buildBusinessMessage(message) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPracticeSyncMessage(message) {
|
||||
const practiceInfo = normalizePracticeInfo(message.practice_info);
|
||||
const matchId = normalizeId(
|
||||
pickField(message, "matchId", "match_id") ||
|
||||
practiceInfo.id ||
|
||||
currentContext?.matchId
|
||||
);
|
||||
|
||||
return {
|
||||
matchId,
|
||||
timestamp: message.timestamp,
|
||||
sequence: message.sequence,
|
||||
practiceInfo,
|
||||
};
|
||||
}
|
||||
|
||||
function getReadySnapshotKey(matchId) {
|
||||
return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(matchId)}`;
|
||||
}
|
||||
@@ -611,6 +629,24 @@ function sendHeartbeatAck() {
|
||||
);
|
||||
}
|
||||
|
||||
function sendPracticeInfoSync() {
|
||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) return;
|
||||
|
||||
const clientMessage = {
|
||||
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
||||
match_id: currentContext.matchId,
|
||||
user_id: currentContext.userId,
|
||||
};
|
||||
sendBuffer(
|
||||
createSyncPracticeInfoMessage({
|
||||
matchId: clientMessage.match_id,
|
||||
userId: clientMessage.user_id,
|
||||
}),
|
||||
"CLIENT_MSG_SYNC_PRACTICE_INFO",
|
||||
clientMessage
|
||||
);
|
||||
}
|
||||
|
||||
function sendAck({ matchId, sequence }) {
|
||||
// sequence 由后端处理,前端只原样带回,不做重排和断线补发。
|
||||
if (sequence === undefined || sequence === null || sequence === "") return;
|
||||
@@ -682,26 +718,27 @@ function removeAudioAckListener() {
|
||||
}
|
||||
|
||||
function queueAckAfterAudio(message, businessMessage) {
|
||||
// 除心跳外,只要服务端带了 sequence,都需要走 ACK;没有语音的消息立即 ACK。
|
||||
if (
|
||||
message.sequence === undefined ||
|
||||
message.sequence === null ||
|
||||
message.sequence === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 终止消息即使没有 sequence,也要等结束语音完成后主动关闭连接。
|
||||
const leaveAfterAck = shouldCloseAfterAck(message, businessMessage);
|
||||
const hasSequence =
|
||||
message.sequence !== undefined &&
|
||||
message.sequence !== null &&
|
||||
message.sequence !== "";
|
||||
if (!hasSequence && !leaveAfterAck) return;
|
||||
|
||||
const task = {
|
||||
matchId: normalizeId(
|
||||
pickField(message, "matchId", "match_id") || currentContext?.matchId
|
||||
),
|
||||
sequence: message.sequence,
|
||||
leaveAfterAck: shouldCloseAfterAck(message, businessMessage),
|
||||
leaveAfterAck,
|
||||
};
|
||||
const actionLabel = hasSequence ? "ack" : "terminal close";
|
||||
|
||||
const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
|
||||
if (!audioKeys.length) {
|
||||
console.log(
|
||||
"[match-ws] ack immediately without audio",
|
||||
`[match-ws] ${actionLabel} immediately without audio`,
|
||||
getServerMessageTypeName(message.type),
|
||||
message.sequence
|
||||
);
|
||||
@@ -715,7 +752,7 @@ function queueAckAfterAudio(message, businessMessage) {
|
||||
if (index === -1) return;
|
||||
pendingAcks.splice(index, 1);
|
||||
console.log(
|
||||
"[match-ws] ack audio wait timeout",
|
||||
`[match-ws] ${actionLabel} audio wait timeout`,
|
||||
getServerMessageTypeName(message.type),
|
||||
message.sequence,
|
||||
task.expectedAudioKey
|
||||
@@ -724,7 +761,7 @@ function queueAckAfterAudio(message, businessMessage) {
|
||||
}, ACK_AUDIO_TIMEOUT_MS);
|
||||
pendingAcks.push(task);
|
||||
console.log(
|
||||
"[match-ws] ack queued until audioEnded",
|
||||
`[match-ws] ${actionLabel} queued until audioEnded`,
|
||||
getServerMessageTypeName(message.type),
|
||||
message.sequence,
|
||||
task.expectedAudioKey
|
||||
@@ -741,11 +778,6 @@ function handleMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
|
||||
if (decodedMatchId && currentContext) {
|
||||
currentContext.matchId = decodedMatchId;
|
||||
}
|
||||
|
||||
if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
|
||||
sendHeartbeatAck();
|
||||
return;
|
||||
@@ -754,6 +786,34 @@ function handleMessage(data) {
|
||||
const typeName = getServerMessageTypeName(message.type);
|
||||
console.log("收到比赛服 WebSocket 消息", typeName, message);
|
||||
|
||||
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
|
||||
if (message.type === ServerMessageType.SERVER_MSG_SYNC_PRACTICE_INFO) {
|
||||
// 同步响应是完整快照,不映射成开始/报靶等实时事件,避免重放页面副作用。
|
||||
queueAckAfterAudio(message, null);
|
||||
if (
|
||||
decodedMatchId &&
|
||||
currentContext?.matchId &&
|
||||
decodedMatchId !== currentContext.matchId
|
||||
) {
|
||||
console.log("[match-ws] ignore mismatched practice sync", {
|
||||
expectedMatchId: currentContext.matchId,
|
||||
receivedMatchId: decodedMatchId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const syncMessage = buildPracticeSyncMessage(message);
|
||||
if (syncMessage.matchId && currentContext) {
|
||||
currentContext.matchId = syncMessage.matchId;
|
||||
}
|
||||
uni.$emit(MATCH_WS_PRACTICE_SYNC_EVENT, syncMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedMatchId && currentContext) {
|
||||
currentContext.matchId = decodedMatchId;
|
||||
}
|
||||
|
||||
const businessMessage = buildBusinessMessage(message);
|
||||
if (businessMessage?.matchId && currentContext) {
|
||||
currentContext.matchId = businessMessage.matchId;
|
||||
@@ -788,6 +848,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
userId,
|
||||
token,
|
||||
mode,
|
||||
requestPracticeInfoOnOpen = false,
|
||||
force = false,
|
||||
reconnecting = false,
|
||||
reconnectReason = "",
|
||||
@@ -858,6 +919,7 @@ export function connectMatchWebSocket(options = {}) {
|
||||
mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined,
|
||||
isMelee:
|
||||
Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined,
|
||||
requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
|
||||
meleeHalfRest: isSameContext
|
||||
? currentContext?.meleeHalfRest === true
|
||||
: false,
|
||||
@@ -911,6 +973,9 @@ export function connectMatchWebSocket(options = {}) {
|
||||
reason: reconnectReason,
|
||||
reconnected: wasReconnected,
|
||||
});
|
||||
if (currentContext?.requestPracticeInfoOnOpen) {
|
||||
sendPracticeInfoSync();
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
|
||||
@@ -4,43 +4,43 @@ export const trainingHomeWeekSchedule = [
|
||||
key: "mon",
|
||||
label: "周一",
|
||||
status: "done",
|
||||
icon: "../../static/training-home/done.png",
|
||||
icon: "/pages/training/static/training-home/done.png",
|
||||
},
|
||||
{
|
||||
key: "tue",
|
||||
label: "周二",
|
||||
status: "done",
|
||||
icon: "../../static/training-home/done.png",
|
||||
icon: "/pages/training/static/training-home/done.png",
|
||||
},
|
||||
{
|
||||
key: "wed",
|
||||
label: "周三",
|
||||
status: "missed",
|
||||
icon: "../../static/training-home/missed.png",
|
||||
icon: "/pages/training/static/training-home/missed.png",
|
||||
},
|
||||
{
|
||||
key: "thu",
|
||||
label: "周四",
|
||||
status: "missed",
|
||||
icon: "../../static/training-home/missed.png",
|
||||
icon: "/pages/training/static/training-home/missed.png",
|
||||
},
|
||||
{
|
||||
key: "fri",
|
||||
label: "周五",
|
||||
status: "done",
|
||||
icon: "../../static/training-home/done.png",
|
||||
icon: "/pages/training/static/training-home/done.png",
|
||||
},
|
||||
{
|
||||
key: "sat",
|
||||
label: "周六",
|
||||
status: "done",
|
||||
icon: "../../static/training-home/done.png",
|
||||
icon: "/pages/training/static/training-home/done.png",
|
||||
},
|
||||
{
|
||||
key: "sun",
|
||||
label: "周日",
|
||||
status: "missed",
|
||||
icon: "../../static/training-home/missed.png",
|
||||
icon: "/pages/training/static/training-home/missed.png",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,7 +73,7 @@ export const trainingHomeModes = [
|
||||
key: "endurance",
|
||||
title: "耐力训练",
|
||||
progressText: "当前进度 LV5 >",
|
||||
icon: "../../static/training-home/img_3.png",
|
||||
icon: "/pages/training/static/training-home/img_3.png",
|
||||
recommended: true,
|
||||
disabled: false,
|
||||
},
|
||||
@@ -81,7 +81,7 @@ export const trainingHomeModes = [
|
||||
key: "precision",
|
||||
title: "精准训练",
|
||||
progressText: "当前进度 LV3 >",
|
||||
icon: "../../static/training-home/img_4.png",
|
||||
icon: "/pages/training/static/training-home/img_4.png",
|
||||
recommended: false,
|
||||
disabled: false,
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const trainingHomeModes = [
|
||||
key: "rhythm",
|
||||
title: "节奏训练",
|
||||
progressText: "当前进度 LV6 >",
|
||||
icon: "../../static/training-home/img_5.png",
|
||||
icon: "/pages/training/static/training-home/img_5.png",
|
||||
recommended: false,
|
||||
disabled: false,
|
||||
},
|
||||
@@ -97,7 +97,7 @@ export const trainingHomeModes = [
|
||||
key: "power",
|
||||
title: "力量训练",
|
||||
progressText: "Coming! LV10",
|
||||
icon: "../../static/training-home/img_6.png",
|
||||
icon: "/pages/training/static/training-home/img_6.png",
|
||||
recommended: false,
|
||||
disabled: true,
|
||||
},
|
||||
|
||||
@@ -105,15 +105,6 @@
|
||||
{
|
||||
"path": "pages/mine-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/training/difficulty"
|
||||
},
|
||||
{
|
||||
"path": "pages/training/index"
|
||||
},
|
||||
{
|
||||
"path": "pages/training/practise-one"
|
||||
},
|
||||
{
|
||||
"path": "pages/ota-wifi",
|
||||
"style": {
|
||||
@@ -169,6 +160,20 @@
|
||||
"path": "team-bow-data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index"
|
||||
},
|
||||
{
|
||||
"path": "difficulty"
|
||||
},
|
||||
{
|
||||
"path": "practise-one"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import BowShotEffect from "@/components/BowShotEffect.vue";
|
||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||
import TargetCanvas from "@/components/TargetCanvas.vue";
|
||||
|
||||
@@ -33,6 +36,14 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSvip: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shotEffectToken: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "solo", // solo 单排,team 双排
|
||||
@@ -57,23 +68,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showQuadrantLabels: {
|
||||
sectorCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
activeSector: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
activeRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showSectorLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
quadrantLabels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
}),
|
||||
},
|
||||
highlightAreas: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const pMode = ref(true);
|
||||
@@ -85,6 +95,12 @@ const timer = ref(null);
|
||||
const dirTimer = ref(null);
|
||||
const angle = ref(null);
|
||||
const circleColor = ref("");
|
||||
const shotEffect = ref(null);
|
||||
const hiddenLatestKey = ref("");
|
||||
const targetShaking = ref(false);
|
||||
const targetSize = ref({ width: 0, height: 0 });
|
||||
const shakeTimer = ref(null);
|
||||
const instance = getCurrentInstance();
|
||||
const ROUND_TIP_OFFSET_Y = -32;
|
||||
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||
|
||||
@@ -162,6 +178,13 @@ function getHitStyle(shot) {
|
||||
};
|
||||
}
|
||||
|
||||
function getSvipHitBgStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return getTargetPositionStyle(point, radius);
|
||||
}
|
||||
|
||||
function getRoundTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
@@ -180,15 +203,116 @@ function getExperienceTipStyle(shot) {
|
||||
);
|
||||
}
|
||||
|
||||
function clearTipTimer() {
|
||||
if (!timer.value) return;
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
|
||||
function showShotTip(shot) {
|
||||
clearTipTimer();
|
||||
latestOne.value = shot;
|
||||
timer.value = setTimeout(() => {
|
||||
latestOne.value = null;
|
||||
timer.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hasShotPoint(shot) {
|
||||
return !!getShotPoint(shot);
|
||||
}
|
||||
|
||||
function shouldPlayShotEffect(shot) {
|
||||
return (
|
||||
props.isSvip &&
|
||||
!!shot &&
|
||||
Number(shot.ring) > 0 &&
|
||||
hasShotPoint(shot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildShotEffectKey(shot, index) {
|
||||
return [
|
||||
props.shotEffectToken,
|
||||
index,
|
||||
shot?.playerId ?? "",
|
||||
shot?.x ?? "",
|
||||
shot?.y ?? "",
|
||||
shot?.ring ?? "",
|
||||
shot?.ringX ? 1 : 0,
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function triggerShotEffect(shot, index) {
|
||||
const key = buildShotEffectKey(shot, index);
|
||||
clearTipTimer();
|
||||
latestOne.value = null;
|
||||
hiddenLatestKey.value = key;
|
||||
shotEffect.value = { key, shot };
|
||||
}
|
||||
|
||||
function completeShotEffect(key) {
|
||||
if (!shotEffect.value || shotEffect.value.key !== key) return;
|
||||
|
||||
const shot = shotEffect.value.shot;
|
||||
hiddenLatestKey.value = "";
|
||||
shotEffect.value = null;
|
||||
showShotTip(shot);
|
||||
}
|
||||
|
||||
function shouldHideLatestHit(index) {
|
||||
return !!hiddenLatestKey.value && index === props.scores.length - 1;
|
||||
}
|
||||
|
||||
function shakeTarget() {
|
||||
targetShaking.value = false;
|
||||
if (shakeTimer.value) {
|
||||
clearTimeout(shakeTimer.value);
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
targetShaking.value = true;
|
||||
shakeTimer.value = setTimeout(() => {
|
||||
targetShaking.value = false;
|
||||
shakeTimer.value = null;
|
||||
}, 260);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTargetSize() {
|
||||
nextTick(() => {
|
||||
const query = instance?.proxy
|
||||
? uni.createSelectorQuery().in(instance.proxy)
|
||||
: uni.createSelectorQuery();
|
||||
|
||||
query
|
||||
.select(".target")
|
||||
.boundingClientRect((rect) => {
|
||||
const width = Number(rect?.width);
|
||||
const height = Number(rect?.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
targetSize.value = { width, height };
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
updateTargetSize();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevScores.value.length === 1) {
|
||||
latestOne.value = newVal[newVal.length - 1];
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
latestOne.value = null;
|
||||
}, 1000);
|
||||
showShotTip(newVal[newVal.length - 1]);
|
||||
} else if (newVal.length < prevScores.value.length) {
|
||||
clearTipTimer();
|
||||
latestOne.value = null;
|
||||
hiddenLatestKey.value = "";
|
||||
shotEffect.value = null;
|
||||
}
|
||||
prevScores.value = [...newVal];
|
||||
},
|
||||
@@ -197,6 +321,19 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.shotEffectToken,
|
||||
(token) => {
|
||||
// token 只由实时 ShootResult 推进,同步快照不会重播飞箭。
|
||||
if (!token || props.scores.length === 0) return;
|
||||
const latestIndex = props.scores.length - 1;
|
||||
const latestShot = props.scores[latestIndex];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
triggerShotEffect(latestShot, latestIndex);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.blueScores,
|
||||
(newVal) => {
|
||||
@@ -237,42 +374,9 @@ const arrowStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const currentArrowIndex = computed(() => {
|
||||
return props.scores.length + props.blueScores.length + 1;
|
||||
});
|
||||
|
||||
const getHighlightArrowIndex = (area = {}) => {
|
||||
const arrowIndex = Number(area.arrowIndex ?? area.arrowNo ?? area.arrow);
|
||||
return Number.isInteger(arrowIndex) && arrowIndex > 0 ? arrowIndex : null;
|
||||
};
|
||||
|
||||
const currentHighlightAreas = computed(() => {
|
||||
if (!Array.isArray(props.highlightAreas) || props.highlightAreas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hasExplicitArrowIndex = props.highlightAreas.some((area = {}) => {
|
||||
return getHighlightArrowIndex(area) !== null;
|
||||
});
|
||||
|
||||
const matchedAreas = props.highlightAreas.filter((area = {}) => {
|
||||
return getHighlightArrowIndex(area) === currentArrowIndex.value;
|
||||
});
|
||||
|
||||
if (hasExplicitArrowIndex) {
|
||||
return matchedAreas;
|
||||
}
|
||||
|
||||
if (props.highlightAreas.length === 1) {
|
||||
return props.highlightAreas.slice(0, 1);
|
||||
}
|
||||
|
||||
const currentArea = props.highlightAreas[currentArrowIndex.value - 1];
|
||||
return currentArea ? [currentArea] : [];
|
||||
});
|
||||
|
||||
const showHighlightCanvas = computed(() => {
|
||||
return props.totalRound > 0 && currentHighlightAreas.value.length > 0;
|
||||
const showSectorCanvas = computed(() => {
|
||||
const count = Number(props.sectorCount);
|
||||
return props.totalRound > 0 && Number.isInteger(count) && count > 0;
|
||||
});
|
||||
|
||||
async function onReceiveMessage(message) {
|
||||
@@ -299,23 +403,27 @@ async function onReceiveMessage(message) {
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
updateTargetSize();
|
||||
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
clearTipTimer();
|
||||
if (dirTimer.value) {
|
||||
clearTimeout(dirTimer.value);
|
||||
dirTimer.value = null;
|
||||
}
|
||||
if (shakeTimer.value) {
|
||||
clearTimeout(shakeTimer.value);
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view :class="['container', { 'container--effecting': shotEffect }]">
|
||||
<!-- <view class="header" v-if="totalRound > 0">
|
||||
<text v-if="totalRound > 0" class="round-count">{{
|
||||
(currentRound > totalRound ? totalRound : currentRound) +
|
||||
@@ -323,37 +431,44 @@ onBeforeUnmount(() => {
|
||||
totalRound
|
||||
}}</text>
|
||||
</view> -->
|
||||
<view class="target">
|
||||
<view :class="['target', { 'target--shake': targetShaking }]">
|
||||
<image
|
||||
class="target-image"
|
||||
src="../../../static/bow-target.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<TargetCanvas
|
||||
v-if="showHighlightCanvas"
|
||||
v-if="showSectorCanvas"
|
||||
class="target-highlight-layer"
|
||||
:coordinateRadius="coordinateRadius"
|
||||
:showCrosshair="false"
|
||||
:showQuadrantLabels="false"
|
||||
:showRingLabels="false"
|
||||
:highlightOnly="true"
|
||||
:highlightAreas="currentHighlightAreas"
|
||||
:sectorCount="sectorCount"
|
||||
:activeSector="activeSector"
|
||||
:activeRing="activeRing"
|
||||
:showSectorLabels="showSectorLabels"
|
||||
/>
|
||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||
<view :style="{ background: circleColor }">
|
||||
<image src="../../../static/dot-circle.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/dot-circle.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="stop" class="stop-sign">中场休息</view>
|
||||
<view
|
||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||
v-if="
|
||||
!shotEffect &&
|
||||
latestOne &&
|
||||
latestOne.ring &&
|
||||
user.id === latestOne.playerId
|
||||
"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(latestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="latestOne"
|
||||
v-if="!shotEffect && latestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="getRoundTipStyle(latestOne)"
|
||||
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
||||
@@ -378,8 +493,15 @@ onBeforeUnmount(() => {
|
||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||
>
|
||||
<block v-for="(bow, index) in scores" :key="index">
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideLatestHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../../../static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
v-if="bow.ring > 0"
|
||||
v-if="bow.ring > 0 && !shouldHideLatestHit(index)"
|
||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
@@ -404,6 +526,16 @@ onBeforeUnmount(() => {
|
||||
<text v-if="pMode">{{ index + 1 }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<BowShotEffect
|
||||
:shot="shotEffect && shotEffect.shot"
|
||||
:playKey="shotEffect ? shotEffect.key : ''"
|
||||
:targetRadius="safeTargetRadius"
|
||||
:targetWidth="targetSize.width"
|
||||
:targetHeight="targetSize.height"
|
||||
:hitOffsetPx="currentHitRadiusPx"
|
||||
@impact="shakeTarget"
|
||||
@complete="completeShotEffect"
|
||||
/>
|
||||
</view>
|
||||
<view class="footer">
|
||||
<PointSwitcher
|
||||
@@ -424,13 +556,22 @@ onBeforeUnmount(() => {
|
||||
height: calc(100vw - 30px);
|
||||
padding: 0px 15px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.container--effecting {
|
||||
z-index: 10000;
|
||||
}
|
||||
.target {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 20px);
|
||||
z-index: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.target--shake {
|
||||
animation: target-shake 0.26s ease-out;
|
||||
}
|
||||
.target-image {
|
||||
position: absolute;
|
||||
@@ -499,6 +640,15 @@ onBeforeUnmount(() => {
|
||||
.e-value.fade-in-out {
|
||||
animation: target-tip-fade-in-out 1.2s ease forwards;
|
||||
}
|
||||
.svip-hit-bg {
|
||||
position: absolute;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
animation: svip-hit-xuan 1.2s linear infinite;
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
@@ -527,6 +677,20 @@ onBeforeUnmount(() => {
|
||||
transform: translate(-50%, -50%);*/
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
@keyframes svip-hit-xuan {
|
||||
0% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(180deg) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(360deg) scale(0.92);
|
||||
}
|
||||
}
|
||||
@keyframes target-pump-in {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
@@ -536,6 +700,29 @@ onBeforeUnmount(() => {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes target-shake {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
14% {
|
||||
transform: translate(-20rpx, 8rpx);
|
||||
}
|
||||
28% {
|
||||
transform: translate(16rpx, -8rpx);
|
||||
}
|
||||
44% {
|
||||
transform: translate(-12rpx, 6rpx);
|
||||
}
|
||||
64% {
|
||||
transform: translate(8rpx, -4rpx);
|
||||
}
|
||||
82% {
|
||||
transform: translate(-4rpx, 2rpx);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.hit.pump-in {
|
||||
animation: target-pump-in 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
|
||||
@@ -85,8 +85,8 @@ onBeforeUnmount(() => {
|
||||
class="score-item-bg"
|
||||
:src="
|
||||
isLowScore(arrows[index])
|
||||
? '/static/training-difficulty-design/block-gray.png'
|
||||
: '/static/training-difficulty-design/block-gold.png'
|
||||
? '../static/training-difficulty-design/block-gray.png'
|
||||
: '../static/training-difficulty-design/block-gold.png'
|
||||
"
|
||||
/>
|
||||
<text
|
||||
|
||||
@@ -25,9 +25,8 @@ const isLowScore = (arrow = {}) => {
|
||||
|
||||
const displayArrows = computed(() => {
|
||||
const list = [...props.arrows];
|
||||
if (props.total > 0 && list.length < props.total) {
|
||||
list.push(null);
|
||||
}
|
||||
// total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。
|
||||
list.push(null);
|
||||
return list;
|
||||
});
|
||||
</script>
|
||||
@@ -40,7 +39,7 @@ const displayArrows = computed(() => {
|
||||
:key="index"
|
||||
class="score-card"
|
||||
>
|
||||
<image class="score-card-bg" :src="isLowScore(arrow)?'/static/training-difficulty-design/block-gray.png':'/static/training-difficulty-design/block-gold.png'"></image>
|
||||
<image class="score-card-bg" :src="isLowScore(arrow)?'../static/training-difficulty-design/block-gray.png':'../static/training-difficulty-design/block-gold.png'"></image>
|
||||
<text
|
||||
class="score-value"
|
||||
:class="{ 'score-value--low': isLowScore(arrow) }"
|
||||
|
||||
@@ -27,6 +27,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
trainingType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
difficultyLevel: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
result: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
@@ -60,12 +68,6 @@ function onClickShare() {
|
||||
uni.$emit("share-image");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.result.lvl > user.value.lvl) {
|
||||
showUpgrade.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const details = computed(() => props.result.details || []);
|
||||
|
||||
const arrows = computed(() => {
|
||||
@@ -81,25 +83,89 @@ const totalRing = computed(() =>
|
||||
details.value.reduce((last, next) => last + (Number(next.ring) || 0), 0)
|
||||
);
|
||||
|
||||
const gainedExp = computed(
|
||||
() => props.result.exp || props.result.experience || validArrows.value
|
||||
const hasResultValue = (...keys) =>
|
||||
keys.some((key) => {
|
||||
const value = props.result[key];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
|
||||
const readResultNumber = (keys, fallback = 0) => {
|
||||
for (const key of keys) {
|
||||
const value = props.result[key];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) return numberValue;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const beforeExp = computed(() =>
|
||||
readResultNumber(["beforeExp", "before_exp"])
|
||||
);
|
||||
|
||||
const currentLevel = computed(
|
||||
() => props.result.lvl || user.value.lvl || user.value.rankLvl || 1
|
||||
);
|
||||
const currentExp = computed(() => {
|
||||
const userScores = Number(user.value.scores);
|
||||
return readResultNumber(
|
||||
["currentExp", "current_exp", "score"],
|
||||
Number.isFinite(userScores) ? userScores : 0
|
||||
);
|
||||
});
|
||||
|
||||
const currentExp = computed(
|
||||
() => props.result.currentExp || props.result.score || user.value.scores || 0
|
||||
);
|
||||
// 新版练习结算返回练习前后累计经验,本局经验由两者相减得到。
|
||||
const gainedExp = computed(() => {
|
||||
if (
|
||||
hasResultValue("beforeExp", "before_exp") &&
|
||||
hasResultValue("currentExp", "current_exp")
|
||||
) {
|
||||
return Math.max(0, currentExp.value - beforeExp.value);
|
||||
}
|
||||
return Math.max(0, readResultNumber(["exp", "experience"]));
|
||||
});
|
||||
|
||||
const nextExp = computed(
|
||||
() => props.result.nextExp || props.result.upgradeScore || 100
|
||||
const beforeLevel = computed(() => {
|
||||
const currentUserLevel = Number(user.value.lvl);
|
||||
return readResultNumber(
|
||||
["beforeLevel", "before_level"],
|
||||
Number.isFinite(currentUserLevel) ? currentUserLevel : 0
|
||||
);
|
||||
});
|
||||
|
||||
const userLevel = computed(() => {
|
||||
const fallbackLevel = Number(user.value.lvl ?? user.value.rankLvl ?? 1);
|
||||
const level = readResultNumber(
|
||||
["level", "lvl"],
|
||||
Number.isFinite(fallbackLevel) ? fallbackLevel : 1
|
||||
);
|
||||
return Math.max(1, Math.trunc(level));
|
||||
});
|
||||
|
||||
const resultDifficultyLevel = computed(() => {
|
||||
const level = Number(props.difficultyLevel);
|
||||
return Number.isInteger(level) && level > 0 ? level : "--";
|
||||
});
|
||||
|
||||
const upgradeExp = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
readResultNumber(
|
||||
["upgradeExp", "upgrade_exp", "nextExp", "upgradeScore"],
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const expPercent = computed(() => {
|
||||
if (!nextExp.value) return 0;
|
||||
return Math.min(100, Math.max(0, (currentExp.value / nextExp.value) * 100));
|
||||
if (!upgradeExp.value) return 0;
|
||||
return Math.min(
|
||||
100,
|
||||
Math.max(0, (currentExp.value / upgradeExp.value) * 100)
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (userLevel.value > beforeLevel.value) {
|
||||
showUpgrade.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const findValue = (...keys) => {
|
||||
@@ -108,83 +174,184 @@ const findValue = (...keys) => {
|
||||
};
|
||||
|
||||
const formatDuration = (value) => {
|
||||
const seconds = Number(value || 0);
|
||||
if (!seconds) return "--";
|
||||
const valueNumber = Number(value);
|
||||
const seconds = Number.isFinite(valueNumber)
|
||||
? Math.max(0, Math.round(valueNumber))
|
||||
: 0;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return minutes ? `${minutes}分${rest}秒` : `${rest}秒`;
|
||||
};
|
||||
|
||||
const usedTime = computed(() =>
|
||||
findValue("duration", "usedTime", "shootTime", "time")
|
||||
const formatMetricNumber = (value) => {
|
||||
const valueNumber = Number(value);
|
||||
if (!Number.isFinite(valueNumber)) return "0";
|
||||
return String(Number(valueNumber.toFixed(2)));
|
||||
};
|
||||
|
||||
const readMetricNumber = (keys, fallback = 0) => {
|
||||
const value = findValue(...keys);
|
||||
const valueNumber = Number(value);
|
||||
return Number.isFinite(valueNumber) ? valueNumber : fallback;
|
||||
};
|
||||
|
||||
const resultTrainingType = computed(
|
||||
() => props.result.trainingType || props.trainingType || "precision"
|
||||
);
|
||||
|
||||
const hitCompare = computed(
|
||||
() => Number(findValue("hitCompare", "hitDiff", "hitDelta") || 0)
|
||||
const metricConfigs = {
|
||||
base: [
|
||||
{
|
||||
label: "平均环数",
|
||||
valueKeys: ["averageRing", "average_ring"],
|
||||
unit: "环",
|
||||
deltaKeys: ["deltaAverageRing", "delta_average_ring"],
|
||||
deltaUnit: "环",
|
||||
},
|
||||
{
|
||||
label: "稳定性",
|
||||
valueKeys: ["stability"],
|
||||
unit: "",
|
||||
deltaKeys: ["deltaStability", "delta_stability"],
|
||||
deltaUnit: "",
|
||||
},
|
||||
],
|
||||
rhythm: [
|
||||
{
|
||||
label: "最高连击次数",
|
||||
valueKeys: ["maxCombo", "max_combo"],
|
||||
unit: "连",
|
||||
deltaKeys: ["deltaMaxCombo", "delta_max_combo"],
|
||||
deltaUnit: "连",
|
||||
},
|
||||
{
|
||||
label: "共命中环数",
|
||||
valueKeys: ["currentRings", "current_rings"],
|
||||
unit: "环",
|
||||
deltaKeys: ["deltaTotalRings", "delta_total_rings"],
|
||||
deltaUnit: "环",
|
||||
},
|
||||
],
|
||||
endurance: [
|
||||
{
|
||||
label: "完成箭数",
|
||||
valueKeys: ["totalArrows", "total_arrows"],
|
||||
unit: "支",
|
||||
deltaKeys: ["deltaTotalArrows", "delta_total_arrows"],
|
||||
deltaUnit: "支",
|
||||
},
|
||||
{
|
||||
label: "命中环数",
|
||||
valueKeys: ["currentRings", "current_rings"],
|
||||
unit: "环",
|
||||
deltaKeys: ["deltaTotalRings", "delta_total_rings"],
|
||||
deltaUnit: "环",
|
||||
},
|
||||
],
|
||||
precision: [
|
||||
{
|
||||
label: "共命中目标",
|
||||
valueKeys: ["totalHits", "total_hits"],
|
||||
fallback: () => 0,
|
||||
unit: "次",
|
||||
deltaKeys: [
|
||||
"deltaTotalHits",
|
||||
"delta_total_hits",
|
||||
"hitCompare",
|
||||
"hitDiff",
|
||||
"hitDelta",
|
||||
],
|
||||
deltaUnit: "次",
|
||||
},
|
||||
{
|
||||
label: "用时",
|
||||
valueKeys: ["duration", "usedTime", "shootTime", "time"],
|
||||
unit: "",
|
||||
deltaKeys: [
|
||||
"deltaDuration",
|
||||
"delta_duration",
|
||||
"timeCompare",
|
||||
"timeDiff",
|
||||
"durationDiff",
|
||||
],
|
||||
deltaUnit: "",
|
||||
duration: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resultRows = computed(() => {
|
||||
const configs = metricConfigs[resultTrainingType.value] || metricConfigs.precision;
|
||||
return configs.map((config) => {
|
||||
const fallback = config.fallback ? config.fallback() : 0;
|
||||
const value = readMetricNumber(config.valueKeys, fallback);
|
||||
const delta = readMetricNumber(config.deltaKeys);
|
||||
const formatter = config.duration ? formatDuration : formatMetricNumber;
|
||||
|
||||
return {
|
||||
...config,
|
||||
valueText: formatter(value),
|
||||
delta,
|
||||
deltaText: formatter(Math.abs(delta)),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const advancesDifficulty = computed(() =>
|
||||
["base", "endurance"].includes(resultTrainingType.value)
|
||||
);
|
||||
const primaryText = computed(() =>
|
||||
advancesDifficulty.value ? "下一难度" : "再来一次"
|
||||
);
|
||||
|
||||
const timeCompare = computed(
|
||||
() => Number(findValue("timeCompare", "timeDiff", "durationDiff") || 0)
|
||||
);
|
||||
const handlePrimary = () => {
|
||||
if (advancesDifficulty.value) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
retryPractice();
|
||||
};
|
||||
|
||||
const calories = computed(
|
||||
() => Number(findValue("calories", "calorie", "kcal") || 0)
|
||||
() => formatMetricNumber(readMetricNumber(["calories", "calorie", "kcal"]))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="['result-mask', showPanel ? 'result-mask--show' : 'result-mask--hide']">
|
||||
<image class="hero-glow" src="/static/training-difficulty-design/result-bg.png" mode="widthFix" />
|
||||
<image class="hero-glow" src="../static/training-difficulty-design/result-bg.png" mode="widthFix" />
|
||||
<view class="result-title">
|
||||
<image class="result-title-bg" src="/static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
|
||||
<view class="result-title-text">Lv{{ currentLevel }}</view>
|
||||
<image class="result-title-bg" src="../static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
|
||||
<view class="result-title-text">Lv{{ resultDifficultyLevel }}</view>
|
||||
</view>
|
||||
|
||||
<view class="result-panel">
|
||||
<view class="line-top"></view>
|
||||
<view class="line-bottom"></view>
|
||||
<view class="stats">
|
||||
<view class="stat-row">
|
||||
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
|
||||
<view v-for="row in resultRows" :key="row.label" class="stat-row">
|
||||
<image class="stat-bg" src="../static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
|
||||
<view class="stat-cell">
|
||||
<text class="stat-label">共命中目标</text>
|
||||
<text class="stat-label">{{ row.label }}</text>
|
||||
<view class="stat-value">
|
||||
<text>{{ validArrows }}</text>
|
||||
<text class="stat-unit">次</text>
|
||||
<text>{{ row.valueText }}</text>
|
||||
<text v-if="row.unit" class="stat-unit">{{ row.unit }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-cell stat-cell--compare">
|
||||
<text class="stat-label">对比上次</text>
|
||||
<view class="stat-value">
|
||||
<text>{{ Math.abs(hitCompare) }}</text>
|
||||
<text class="stat-unit">次</text>
|
||||
<image class="trend-icon" :class="{ 'trend-icon--down': hitCompare < 0 }"
|
||||
src="/static/training-difficulty-design/result-up.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-row">
|
||||
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
|
||||
<view class="stat-cell">
|
||||
<text class="stat-label">用时</text>
|
||||
<view class="stat-value">
|
||||
<text>{{ formatDuration(usedTime) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-cell stat-cell--compare">
|
||||
<text class="stat-label">对比上次</text>
|
||||
<view class="stat-value">
|
||||
<text>{{ formatDuration(Math.abs(timeCompare)) }}</text>
|
||||
<image class="trend-icon" :class="{ 'trend-icon--down': timeCompare <= 0 }"
|
||||
src="/static/training-difficulty-design/result-up.png" mode="widthFix" />
|
||||
<view v-if="row.delta !== 0" class="stat-value">
|
||||
<text>{{ row.delta > 0 ? "+" : "-" }}{{ row.deltaText }}</text>
|
||||
<text v-if="row.deltaUnit" class="stat-unit">{{ row.deltaUnit }}</text>
|
||||
<image class="trend-icon" :class="{ 'trend-icon--down': row.delta < 0 }"
|
||||
src="../static/training-difficulty-design/result-up.png" mode="widthFix" />
|
||||
</view>
|
||||
<view v-else class="stat-value">--</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
|
||||
<image class="stat-bg" src="../static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
|
||||
<view class="stat-cell">
|
||||
<text class="stat-label">消耗卡路里</text>
|
||||
<view class="stat-value">
|
||||
@@ -196,7 +363,7 @@ const calories = computed(
|
||||
<view class="stat-cell stat-cell--compare">
|
||||
<view class="stat-value">
|
||||
<image v-for="index in 3" :key="index" class="rice-icon"
|
||||
src="/static/training-difficulty-design/result-rice.png" mode="widthFix" />
|
||||
src="../static/training-difficulty-design/result-rice.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -204,15 +371,15 @@ const calories = computed(
|
||||
|
||||
<view class="actions">
|
||||
<view class="action-item" @click="() => (showBowData = true)">
|
||||
<image class="action-icon" src="/static/training-difficulty-design/result-icon-1.png" mode="widthFix" />
|
||||
<image class="action-icon" src="../static/training-difficulty-design/result-icon-1.png" mode="widthFix" />
|
||||
<text>查看靶纸</text>
|
||||
</view>
|
||||
<view v-if="validArrows === total" class="action-item" @click="() => (showComment = true)">
|
||||
<image class="action-icon" src="/static/training-difficulty-design/result-icon-2.png" mode="widthFix" />
|
||||
<view class="action-item" @click="() => (showComment = true)">
|
||||
<image class="action-icon" src="../static/training-difficulty-design/result-icon-2.png" mode="widthFix" />
|
||||
<text>教练点评</text>
|
||||
</view>
|
||||
<view v-if="validArrows === total" class="action-item" @click="onClickShare">
|
||||
<image class="action-icon" src="/static/training-difficulty-design/result-icon-3.png" mode="widthFix" />
|
||||
<view class="action-item" @click="onClickShare">
|
||||
<image class="action-icon" src="../static/training-difficulty-design/result-icon-3.png" mode="widthFix" />
|
||||
<text>分享成绩</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -222,20 +389,20 @@ const calories = computed(
|
||||
<view class="exp-area">
|
||||
<text class="exp-gain">+{{ gainedExp }}经验</text>
|
||||
<view class="level-progress">
|
||||
<text class="level-text">LV.{{ currentLevel }}</text>
|
||||
<text class="level-text">LV.{{ userLevel }}</text>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: `${expPercent}%` }"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ currentExp }} / {{ nextExp }}</text>
|
||||
<text class="progress-text">{{ currentExp }} / {{ upgradeExp }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-actions">
|
||||
<view class="result-btn result-btn--muted" @click="closePanel">
|
||||
<text>{{ validArrows === total ? "完成" : "返回" }}</text>
|
||||
<text>完成</text>
|
||||
</view>
|
||||
<view class="result-btn result-btn--primary" @click="retryPractice">
|
||||
<text>再来一次</text>
|
||||
<view class="result-btn result-btn--primary" @click="handlePrimary">
|
||||
<text>{{ primaryText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -268,7 +435,7 @@ const calories = computed(
|
||||
</ScreenHint>
|
||||
<BowData :total="arrows.length" :arrows="result.details" :show="showBowData"
|
||||
:onClose="() => (showBowData = false)" />
|
||||
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="result.lvl" />
|
||||
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="userLevel" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -27,29 +27,29 @@ const getContentHeight = () => {
|
||||
<view class="scale-in" :style="{ height: getContentHeight() }">
|
||||
<image
|
||||
v-if="mode === 'normal'"
|
||||
src="/static/screen-hint-bg.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/screen-hint-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'tall'"
|
||||
src="/static/coach-comment.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coach-comment.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'square'"
|
||||
src="/static/prompt-bg-square.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/prompt-bg-square.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'small'"
|
||||
src="/static/finish-frame.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
</view>
|
||||
<IconButton
|
||||
v-if="!!onClose"
|
||||
src="/static/close-gold-outline.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/close-gold-outline.png"
|
||||
:width="30"
|
||||
:onClick="onClose"
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 120,
|
||||
},
|
||||
countdownEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
trainingType: {
|
||||
type: String,
|
||||
default: "precision",
|
||||
},
|
||||
currentRound: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
@@ -45,8 +53,19 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const trainingTitleIconMap = Object.freeze({
|
||||
base: "../static/training-difficulty-design/text-icon-jcxl.png",
|
||||
precision: "../static/training-difficulty-design/text-icon-jingzxl.png",
|
||||
rhythm: "../static/training-difficulty-design/text-icon-jzxl.png",
|
||||
endurance: "../static/training-difficulty-design/text-icon-nlxl.png",
|
||||
});
|
||||
const trainingTitleIcon = computed(
|
||||
() =>
|
||||
trainingTitleIconMap[props.trainingType] || trainingTitleIconMap.precision
|
||||
);
|
||||
|
||||
const barColor = ref("#fed847");
|
||||
const remain = ref(props.total);
|
||||
const remain = ref(props.countdownEnabled ? props.total : 0);
|
||||
const timer = ref(null);
|
||||
const sound = ref(true);
|
||||
const currentRound = ref(props.currentRound);
|
||||
@@ -56,7 +75,7 @@ const wait = ref(0);
|
||||
const transitionStyle = ref("all 1s linear");
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!props.total) return 0;
|
||||
if (!props.countdownEnabled || !props.total) return 0;
|
||||
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
||||
});
|
||||
|
||||
@@ -98,9 +117,23 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const clearTimer = () => {
|
||||
if (!timer.value) return;
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
};
|
||||
|
||||
const resetTimer = (count) => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
const newVal = Math.round(count);
|
||||
clearTimer();
|
||||
if (!props.countdownEnabled) {
|
||||
remain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const countValue = Number(count);
|
||||
const newVal = Number.isFinite(countValue)
|
||||
? Math.max(0, Math.round(countValue))
|
||||
: 0;
|
||||
|
||||
if (newVal >= remain.value) {
|
||||
transitionStyle.value = "none";
|
||||
@@ -115,7 +148,7 @@ const resetTimer = (count) => {
|
||||
if (remain.value > 0) {
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value === 0) {
|
||||
clearInterval(timer.value);
|
||||
clearTimer();
|
||||
props.onStop();
|
||||
}
|
||||
if (remain.value > 0) remain.value--;
|
||||
@@ -124,13 +157,13 @@ const resetTimer = (count) => {
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.start,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
() => [props.start, props.countdownEnabled],
|
||||
([started, countdownEnabled]) => {
|
||||
if (started && countdownEnabled) {
|
||||
resetTimer(props.total);
|
||||
} else {
|
||||
clearTimer();
|
||||
remain.value = 0;
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -158,18 +191,29 @@ async function onReceiveMessage(msg) {
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("比赛结束", false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
let arrow = {};
|
||||
if (msg.details && Array.isArray(msg.details)) {
|
||||
arrow = msg.details[msg.details.length - 1];
|
||||
} else {
|
||||
if (msg.shootData.playerId !== user.value.id) return;
|
||||
if (msg.shootData) arrow = msg.shootData;
|
||||
const latestDetail =
|
||||
Array.isArray(msg.details) && msg.details.length > 0
|
||||
? msg.details[msg.details.length - 1]
|
||||
: null;
|
||||
// 语音和 ACK 优先使用同一份当前箭数据,details 仅作为兼容兜底。
|
||||
const arrow = msg.shootData || latestDetail;
|
||||
if (!arrow) return;
|
||||
if (
|
||||
arrow.playerId !== undefined &&
|
||||
arrow.playerId !== null &&
|
||||
String(arrow.playerId) !== String(user.value?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let key = [];
|
||||
|
||||
const key = [];
|
||||
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
||||
if (arrow.angle !== null) {
|
||||
if (arrow.angle !== null && arrow.angle !== undefined) {
|
||||
key.push(`向${getDirectionText(arrow.angle)}调整`);
|
||||
}
|
||||
if (arrow.threeConsecutive10Rings === true) {
|
||||
key.push("tententen");
|
||||
}
|
||||
audioManager.play(key, false);
|
||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||
halfTime.value = true;
|
||||
@@ -197,7 +241,7 @@ onBeforeUnmount(() => {
|
||||
uni.$off("update-remain", resetTimer);
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
clearTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -228,10 +272,10 @@ onBeforeUnmount(() => {
|
||||
<view class="progress-card__track-wrap">
|
||||
<image
|
||||
class="progress-card__titile"
|
||||
src="../../../static/training-difficulty-design/text-icon-cgxl.png"
|
||||
:src="trainingTitleIcon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="progress-card__track">
|
||||
<view v-if="countdownEnabled" class="progress-card__track">
|
||||
<view
|
||||
class="progress-card__fill"
|
||||
:style="{
|
||||
|
||||
@@ -23,6 +23,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
targetType: {
|
||||
type: [Number, String],
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
@@ -78,7 +82,7 @@ onBeforeUnmount(() => {
|
||||
<view class="test-area">
|
||||
<image
|
||||
class="text-bg"
|
||||
src="../../../static/training-difficulty-design/par-bg.png"
|
||||
src="../static/training-difficulty-design/par-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<button
|
||||
@@ -90,7 +94,7 @@ onBeforeUnmount(() => {
|
||||
模拟射箭
|
||||
</button>
|
||||
<view class="warnning-text">
|
||||
<view class="target-tip">当前靶子为<text class="text-yellow">20cm</text>全环靶,请更换靶子</view>
|
||||
<view class="target-tip">当前靶子为<text class="text-yellow">{{ targetType }}cm</text>全环靶,请更换靶子</view>
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离<text class="text-yellow">{{ distance }}</text>米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const lockedBadgeBackground =
|
||||
"/static/training-difficulty-design/unlock.svg";
|
||||
"../static/training-difficulty-design/unlock.svg";
|
||||
const unlockedBadgeBackground =
|
||||
"/static/training-difficulty-design/lock.svg";
|
||||
"../static/training-difficulty-design/lock.svg";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
|
||||
@@ -21,7 +21,7 @@ const previewLines = computed(() => {
|
||||
<view class="difficulty-preview">
|
||||
<image
|
||||
class="difficulty-preview__bg"
|
||||
src="/static/training-difficulty-design/text.png"
|
||||
src="../static/training-difficulty-design/text.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="difficulty-preview__content">
|
||||
@@ -52,10 +52,15 @@ const previewLines = computed(() => {
|
||||
|
||||
.difficulty-preview__content {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
top: 0;
|
||||
left: 30rpx;
|
||||
box-sizing: border-box;
|
||||
width: 486rpx;
|
||||
height: 93%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.difficulty-preview__title {
|
||||
|
||||
@@ -21,7 +21,7 @@ const handleClick = () => {
|
||||
>
|
||||
<image
|
||||
class="difficulty-start__button"
|
||||
src="/static/training-difficulty-design/btn.png"
|
||||
src="../static/training-difficulty-design/btn.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,11 @@ import Container from "@/components/Container.vue";
|
||||
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
|
||||
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
|
||||
import TrainingDifficultyStartButton from "./components/TrainingDifficultyStartButton.vue";
|
||||
import { getTrainingDifficultyListAPI } from "@/apis";
|
||||
import {
|
||||
createPractiseV2API,
|
||||
endPractiseAPI,
|
||||
getTrainingDifficultyListAPI,
|
||||
} from "@/apis";
|
||||
|
||||
// 难度页接口数据源:
|
||||
// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
|
||||
@@ -236,6 +240,7 @@ const nodesScrollTop = ref(0);
|
||||
const nodesScrollWithAnimation = ref(false);
|
||||
const routeOptions = ref({});
|
||||
const needRefreshProgress = ref(false);
|
||||
const creatingPractice = ref(false);
|
||||
|
||||
const difficultyProgressMap = computed(() => {
|
||||
return pageConfig.value?.progressMap || {};
|
||||
@@ -574,7 +579,6 @@ const createPracticeQuery = (difficulty) => {
|
||||
difficulty: difficulty.level,
|
||||
recordId: difficulty.recordId,
|
||||
arrows: toNumber(difficulty.arrows, 12),
|
||||
time: toNumber(difficulty.time_limit, 120) || 120,
|
||||
target: defaultTargetType,
|
||||
};
|
||||
const typedQueryMap = {
|
||||
@@ -610,7 +614,7 @@ const createPracticeUrl = (difficulty) => {
|
||||
return `/pages/training/practise-one${query ? `?${query}` : ""}`;
|
||||
};
|
||||
|
||||
const saveTrainingContext = () => {
|
||||
const saveTrainingContext = (practice = {}) => {
|
||||
const difficulty = selectedDifficulty.value;
|
||||
|
||||
if (!difficulty.id) {
|
||||
@@ -624,9 +628,32 @@ const saveTrainingContext = () => {
|
||||
difficultyLabel: difficulty.label,
|
||||
targetType: defaultTargetType,
|
||||
targetPaperType: difficulty.targetPaperType,
|
||||
practiceId: practice.id || "",
|
||||
serverAddr: practice.serverAddr || "",
|
||||
createdAt: practice.id ? Date.now() : 0,
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToPractice = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.navigateTo({
|
||||
url,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const stopCreatedPractice = async (id) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await endPractiseAPI(id);
|
||||
} catch (error) {
|
||||
console.error("training practice cleanup failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDifficulty = (node) => {
|
||||
if (!node?.id) {
|
||||
return;
|
||||
@@ -650,15 +677,63 @@ const handleSelectDifficulty = (node) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!selectedDifficulty.value.id) {
|
||||
const handleStart = async () => {
|
||||
if (!selectedDifficulty.value.id || creatingPractice.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveTrainingContext();
|
||||
uni.navigateTo({
|
||||
url: createPracticeUrl(selectedDifficulty.value),
|
||||
const trainingType = pageConfig.value.key || defaultTrainingType;
|
||||
const difficultyLevel = selectedDifficulty.value.level;
|
||||
let createdPracticeId = "";
|
||||
|
||||
creatingPractice.value = true;
|
||||
uni.showLoading({
|
||||
title: "训练创建中",
|
||||
mask: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// 先由业务接口创建训练和比赛服,再把连接上下文交给目标页。
|
||||
const result = await createPractiseV2API(trainingType, difficultyLevel);
|
||||
createdPracticeId = result?.id || "";
|
||||
|
||||
if (!createdPracticeId) {
|
||||
saveTrainingContext();
|
||||
uni.showToast({
|
||||
title: "训练创建失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!String(result?.serverAddr || "").trim()) {
|
||||
await stopCreatedPractice(createdPracticeId);
|
||||
saveTrainingContext();
|
||||
uni.showToast({
|
||||
title: "练习连接信息异常,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
saveTrainingContext(result);
|
||||
await navigateToPractice(createPracticeUrl(selectedDifficulty.value));
|
||||
createdPracticeId = "";
|
||||
} catch (error) {
|
||||
await stopCreatedPractice(createdPracticeId);
|
||||
saveTrainingContext();
|
||||
console.error("training practice create failed", error);
|
||||
|
||||
if (String(error?.errMsg || "").includes("navigateTo")) {
|
||||
uni.showToast({
|
||||
title: "进入训练失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
creatingPractice.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const markProgressRefresh = () => {
|
||||
@@ -709,7 +784,7 @@ onUnload(() => {
|
||||
v-for="connector in difficultyConnectors"
|
||||
:key="connector.id"
|
||||
class="difficulty-page__connector"
|
||||
src="../../static/training-difficulty-design/jiantou.png"
|
||||
src="./static/training-difficulty-design/jiantou.png"
|
||||
mode="aspectFit"
|
||||
:style="connector"
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import TargetPicker from "@/components/TargetPicker.vue";
|
||||
import { getPersonalTrainingAPI } from "@/apis";
|
||||
|
||||
const checkedIcon = "../../static/training-home/done.png";
|
||||
const missedIcon = "../../static/training-home/missed.png";
|
||||
const checkedIcon = "./static/training-home/done.png";
|
||||
const missedIcon = "./static/training-home/missed.png";
|
||||
// 后端训练项目 id 与难度页 mode 参数的映射关系。
|
||||
const trainingModeRouteMap = {
|
||||
base: "basic",
|
||||
@@ -17,18 +17,18 @@ const trainingModeRouteMap = {
|
||||
};
|
||||
// 训练项目卡片右侧主图标。
|
||||
const trainingModeIconMap = {
|
||||
base_bow: "../../static/training-home/img_22.png",
|
||||
bow: "../../static/training-home/img_3.png",
|
||||
target: "../../static/training-home/img_4.png",
|
||||
wave: "../../static/training-home/img_5.png",
|
||||
muscle: "../../static/training-home/img_6.png",
|
||||
base_bow: "./static/training-home/img_22.png",
|
||||
bow: "./static/training-home/img_3.png",
|
||||
target: "./static/training-home/img_4.png",
|
||||
wave: "./static/training-home/img_5.png",
|
||||
muscle: "./static/training-home/img_6.png",
|
||||
};
|
||||
// 训练项目卡片标题图,按接口 id 映射本地资源。
|
||||
const trainingModeTitleImageMap = {
|
||||
endurance: "../../static/training-home/nailixunlian.png",
|
||||
precision: "../../static/training-home/jingzhunxunlian.png",
|
||||
rhythm: "../../static/training-home/jiezouxunlian.png",
|
||||
strength: "../../static/training-home/liliangxulian.png",
|
||||
endurance: "./static/training-home/nailixunlian.png",
|
||||
precision: "./static/training-home/jingzhunxunlian.png",
|
||||
rhythm: "./static/training-home/jiezouxunlian.png",
|
||||
strength: "./static/training-home/liliangxulian.png",
|
||||
};
|
||||
const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
|
||||
const defaultRadarDimensions = [
|
||||
@@ -38,6 +38,13 @@ const defaultRadarDimensions = [
|
||||
{ name: "节奏", score: 0 },
|
||||
{ name: "耐力", score: 0 },
|
||||
];
|
||||
const radarDimensionTrainingIdMap = Object.freeze({
|
||||
基础: "base",
|
||||
精准: "precision",
|
||||
力量: "strength",
|
||||
节奏: "rhythm",
|
||||
耐力: "endurance",
|
||||
});
|
||||
|
||||
// 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值。
|
||||
const createDefaultTrainingData = () => ({
|
||||
@@ -50,6 +57,7 @@ const createDefaultTrainingData = () => ({
|
||||
total_calories: 0,
|
||||
overtake_rate: 0,
|
||||
},
|
||||
radar_max: 0,
|
||||
radar: {
|
||||
dimensions: defaultRadarDimensions,
|
||||
},
|
||||
@@ -57,6 +65,12 @@ const createDefaultTrainingData = () => ({
|
||||
});
|
||||
|
||||
const trainingData = ref(createDefaultTrainingData());
|
||||
const recommendedTrainingId = ref("");
|
||||
const visibleTrainingItems = computed(() =>
|
||||
Array.isArray(trainingData.value.training_items)
|
||||
? trainingData.value.training_items.filter((item) => item.id !== "strength")
|
||||
: []
|
||||
);
|
||||
const pageMounted = ref(false);
|
||||
const showRoutineTargetPicker = ref(false);
|
||||
const trainingRadarCanvasId = "training-home-radar";
|
||||
@@ -77,7 +91,6 @@ const radarStrokeWidth = Math.max(1, 2 * radarScale);
|
||||
const radarPointRadius = Math.max(2.5, 3.5 * radarScale);
|
||||
const radarOuterRadiusX = 110.7089 * radarScaleX;
|
||||
const radarOuterRadiusY = 110.7089 * radarScaleY;
|
||||
const radarMaxValue = 100;
|
||||
const radarFigureStyle = {
|
||||
width: `${radarFigureWidthRpx}rpx`,
|
||||
height: `${radarFigureHeightRpx}rpx`,
|
||||
@@ -113,26 +126,78 @@ const getTrainingTitleImage = (item = {}) =>
|
||||
const getTrainingMode = (item = {}) =>
|
||||
trainingModeRouteMap[item.id] || item.id || "";
|
||||
|
||||
const updateRecommendedTraining = () => {
|
||||
recommendedTrainingId.value = "";
|
||||
|
||||
const radarMaxValue = Number(trainingData.value.radar_max);
|
||||
const dimensions = trainingData.value.radar?.dimensions;
|
||||
if (
|
||||
!Number.isFinite(radarMaxValue) ||
|
||||
radarMaxValue <= 0 ||
|
||||
!Array.isArray(dimensions) ||
|
||||
dimensions.length !== 5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleTrainingIds = visibleTrainingItems.value.map((item) => item.id);
|
||||
const candidates = dimensions.reduce((result, item) => {
|
||||
const trainingId = radarDimensionTrainingIdMap[item?.name];
|
||||
const rawScore = item?.score;
|
||||
if (
|
||||
!visibleTrainingIds.includes(trainingId) ||
|
||||
rawScore === undefined ||
|
||||
rawScore === null ||
|
||||
rawScore === ""
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const score = Number(rawScore);
|
||||
if (!Number.isFinite(score) || score < 0) return result;
|
||||
|
||||
result.push({ trainingId, score });
|
||||
return result;
|
||||
}, []);
|
||||
if (!candidates.length) return;
|
||||
|
||||
const lowestScore = Math.min(...candidates.map((item) => item.score));
|
||||
const lowestCandidates = candidates.filter(
|
||||
(item) => item.score === lowestScore
|
||||
);
|
||||
const selectedIndex = Math.floor(Math.random() * lowestCandidates.length);
|
||||
recommendedTrainingId.value =
|
||||
lowestCandidates[selectedIndex]?.trainingId || "";
|
||||
};
|
||||
|
||||
const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({
|
||||
x: centerX + radiusX * Math.cos(angle),
|
||||
y: centerY + radiusY * Math.sin(angle),
|
||||
});
|
||||
|
||||
// 雷达图直接使用接口的 5 维 score,按 0-100 等比映射到顶点位置。
|
||||
// 雷达图直接使用接口的 5 维 score,按后端 radar_max 等比映射到顶点位置。
|
||||
const drawRadar = () => {
|
||||
const dimensions = Array.isArray(trainingData.value.radar?.dimensions)
|
||||
? trainingData.value.radar.dimensions.slice(0, 5)
|
||||
: [];
|
||||
|
||||
if (dimensions.length !== 5) return;
|
||||
const radarMaxValue = Number(trainingData.value.radar_max);
|
||||
|
||||
const ctx = uni.createCanvasContext(trainingRadarCanvasId);
|
||||
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
|
||||
|
||||
if (
|
||||
dimensions.length !== 5 ||
|
||||
!Number.isFinite(radarMaxValue) ||
|
||||
radarMaxValue <= 0
|
||||
) {
|
||||
ctx.draw();
|
||||
return;
|
||||
}
|
||||
|
||||
const angles = dimensions.map(
|
||||
(_, index) => (-90 + index * 72) * (Math.PI / 180)
|
||||
);
|
||||
|
||||
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
|
||||
|
||||
const points = dimensions.map((item, index) => {
|
||||
const normalized = Math.max(
|
||||
0,
|
||||
@@ -199,6 +264,7 @@ const loadPersonalTrainingData = async () => {
|
||||
total_calories: result?.stats?.total_calories ?? 0,
|
||||
overtake_rate: result?.stats?.overtake_rate ?? 0,
|
||||
},
|
||||
radar_max: result?.radar_max ?? 0,
|
||||
radar: {
|
||||
dimensions:
|
||||
Array.isArray(result?.radar?.dimensions) &&
|
||||
@@ -214,6 +280,7 @@ const loadPersonalTrainingData = async () => {
|
||||
console.log("personal training load failed", error);
|
||||
trainingData.value = createDefaultTrainingData();
|
||||
} finally {
|
||||
updateRecommendedTraining();
|
||||
await refreshRadar();
|
||||
}
|
||||
};
|
||||
@@ -293,12 +360,12 @@ onShow(async () => {
|
||||
<view class="stats-card-bg"></view>
|
||||
<image
|
||||
class="stats-quote stats-quote-left"
|
||||
src="../../static/training-home/img_17.png"
|
||||
src="./static/training-home/img_17.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="stats-quote stats-quote-right"
|
||||
src="../../static/training-home/img_16.png"
|
||||
src="./static/training-home/img_16.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="stats-grid">
|
||||
@@ -373,7 +440,7 @@ onShow(async () => {
|
||||
<view class="record-bubble" @click="openTrainingRecord">
|
||||
<image
|
||||
class="record-bubble-bg"
|
||||
src="../../static/training-home/img_28.png"
|
||||
src="./static/training-home/img_28.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="record-bubble-copy">
|
||||
@@ -384,7 +451,7 @@ onShow(async () => {
|
||||
<text class="record-sub-text">我的训练记录</text>
|
||||
<image
|
||||
class="record-arrow"
|
||||
src="../../static/training-home/img_7.png"
|
||||
src="./static/training-home/img_7.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
@@ -412,7 +479,7 @@ onShow(async () => {
|
||||
<image
|
||||
class="radar-grid-image"
|
||||
:style="radarFigureStyle"
|
||||
src="../../static/training-home/img_19.png"
|
||||
src="./static/training-home/img_19.png"
|
||||
/>
|
||||
<canvas
|
||||
:canvas-id="trainingRadarCanvasId"
|
||||
@@ -424,7 +491,7 @@ onShow(async () => {
|
||||
/>
|
||||
<image
|
||||
class="radar-mascot"
|
||||
src="../../static/training-home/img_21.png"
|
||||
src="./static/training-home/img_21.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
@@ -434,7 +501,7 @@ onShow(async () => {
|
||||
<view class="featured-card" @click="openRoutineTraining">
|
||||
<image
|
||||
class="featured-card-bg"
|
||||
src="../../static/training-home/img_22.png"
|
||||
src="./static/training-home/img_22.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="featured-card-mask"></view>
|
||||
@@ -446,12 +513,14 @@ onShow(async () => {
|
||||
|
||||
<view class="mode-grid">
|
||||
<view
|
||||
v-for="item in trainingData.training_items.filter((item) => item.id !== 'strength')"
|
||||
v-for="item in visibleTrainingItems"
|
||||
:key="item.id"
|
||||
class="mode-card"
|
||||
@click="openTrainingItem(item)"
|
||||
>
|
||||
<view v-if="item.is_recommended" class="mode-tag">推荐</view>
|
||||
<view v-if="item.id === recommendedTrainingId" class="mode-tag">
|
||||
推荐
|
||||
</view>
|
||||
<view class="mode-card-copy">
|
||||
<image
|
||||
v-if="getTrainingTitleImage(item)"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ShootProgress from "./components/ShootProgress.vue";
|
||||
import BowTarget from "./components/BowTarget.vue";
|
||||
@@ -13,11 +13,17 @@ import BubbleTip from "./components/BubbleTip.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
|
||||
import {
|
||||
createPractiseAPI,
|
||||
createPractiseV2API,
|
||||
startPractiseAPI,
|
||||
endPractiseAPI,
|
||||
getPractiseAPI,
|
||||
} from "@/apis";
|
||||
import {
|
||||
connectMatchWebSocket,
|
||||
closeMatchWebSocket,
|
||||
MATCH_WS_PRACTICE_SYNC_EVENT,
|
||||
MATCH_WS_STATE_EVENT,
|
||||
} from "@/matchWebsocket";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { MESSAGETYPESV2, roundsName } from "@/constants";
|
||||
@@ -35,22 +41,41 @@ const pageStages = Object.freeze({
|
||||
RESULT: "result",
|
||||
LOADING: "loading",
|
||||
});
|
||||
const pageStage = ref(pageStages.DISTANCE);
|
||||
const pageStage = ref(pageStages.LOADING);
|
||||
const scores = ref([]);
|
||||
// 只在实时 ShootResult 新增一箭时递增,避免同步快照重播飞箭特效。
|
||||
const shotEffectToken = ref(0);
|
||||
const defaultTotal = 12;
|
||||
const defaultShootTime = 120;
|
||||
const defaultTargetType = 1;
|
||||
const total = ref(defaultTotal);
|
||||
const shootTime = ref(defaultShootTime);
|
||||
const practiseResult = ref({});
|
||||
const practiceEndSnapshot = ref({});
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const tips = ref("");
|
||||
const targetType = ref(defaultTargetType);
|
||||
const trainingParams = ref({});
|
||||
const practiceInfo = ref({});
|
||||
const trainingDifficultyStorageKey = "training-selection";
|
||||
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
||||
const useHighlightTest = ref(false);
|
||||
const highlightTestState = ref({
|
||||
blocks: 8,
|
||||
randomBlock: 1,
|
||||
randomRingArea: 0,
|
||||
});
|
||||
const highlightTestTimer = ref(null);
|
||||
const serverAddr = ref("");
|
||||
const practiceEnded = ref(false);
|
||||
const stopCompleted = ref(false);
|
||||
const stopInFlight = ref(false);
|
||||
const exiting = ref(false);
|
||||
const hiddenWhileActive = ref(false);
|
||||
const connectionClosed = ref(true);
|
||||
let stopPracticeTask = null;
|
||||
let practiceSyncTimer = null;
|
||||
let waitingPracticeSync = false;
|
||||
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
|
||||
|
||||
const env = computed(() => {
|
||||
try {
|
||||
@@ -66,27 +91,135 @@ const hasPractiseResult = computed(() => !!practiseResult.value?.details);
|
||||
const showResult = computed(
|
||||
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value
|
||||
);
|
||||
const isSvip = computed(() => practiceInfo.value.sVip === true);
|
||||
|
||||
const defaultHighlightAreas = [{ quadrant: 1, rings: [7] }];
|
||||
const trainingType = computed(
|
||||
() => practiceInfo.value.trainingType || trainingParams.value.type || ""
|
||||
);
|
||||
|
||||
// 临时高亮测试数据:第 N 项对应第 N 箭,每箭展示一个不同区域。
|
||||
const highlightTestAreas = [
|
||||
{ arrowIndex: 1, quadrant: 1, rings: [10] },
|
||||
{ arrowIndex: 2, quadrant: 2, rings: [9, 10] },
|
||||
{ arrowIndex: 3, quadrant: 3, rings: [8, 9] },
|
||||
{ arrowIndex: 4, quadrant: 4, rings: [7, 8] },
|
||||
{ arrowIndex: 5, quadrant: 1, rings: [6, 7] },
|
||||
{ arrowIndex: 6, quadrant: 2, rings: [5, 6] },
|
||||
{ arrowIndex: 7, quadrant: 3, rings: [4, 5] },
|
||||
{ arrowIndex: 8, quadrant: 4, rings: [3, 4] },
|
||||
{ arrowIndex: 9, quadrant: 1, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 10, quadrant: 2, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 11, quadrant: 3, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 12, quadrant: 4, rings: "all", scope: "sector" },
|
||||
];
|
||||
const getPracticeNumber = (value, fallback = 0) => {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const targetHighlightAreas = computed(() => {
|
||||
return useHighlightTest.value ? highlightTestAreas : defaultHighlightAreas;
|
||||
const getPositiveInteger = (value) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
const currentDifficultyLevel = computed(
|
||||
() =>
|
||||
getPositiveInteger(practiseResult.value.difficultyLevel) ||
|
||||
getPositiveInteger(practiseResult.value.difficulty_level) ||
|
||||
getPositiveInteger(practiceInfo.value.difficultyLevel) ||
|
||||
getPositiveInteger(trainingParams.value.difficulty)
|
||||
);
|
||||
|
||||
// time_limit 缺失或非正数都表示整局不限时。
|
||||
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
|
||||
const hasTimeLimit = computed(() => timeLimit.value > 0);
|
||||
|
||||
const precisionBlocks = computed(() => {
|
||||
if (useHighlightTest.value) {
|
||||
return getPositiveInteger(highlightTestState.value.blocks);
|
||||
}
|
||||
if (trainingType.value !== "precision") return 0;
|
||||
|
||||
return (
|
||||
getPositiveInteger(practiceInfo.value.blocks) ||
|
||||
getPositiveInteger(trainingParams.value.blocks)
|
||||
);
|
||||
});
|
||||
|
||||
const precisionRandomBlock = computed(() => {
|
||||
const block = getPositiveInteger(
|
||||
useHighlightTest.value
|
||||
? highlightTestState.value.randomBlock
|
||||
: practiceInfo.value.randomBlock
|
||||
);
|
||||
return block <= precisionBlocks.value ? block : 0;
|
||||
});
|
||||
|
||||
const precisionRandomRingArea = computed(() => {
|
||||
const ring = getPositiveInteger(
|
||||
useHighlightTest.value
|
||||
? highlightTestState.value.randomRingArea
|
||||
: practiceInfo.value.randomRingArea
|
||||
);
|
||||
return ring >= 1 && ring <= 10 ? ring : 0;
|
||||
});
|
||||
|
||||
// 只展示后端进度,不在前端重复判断训练是否完成。
|
||||
const trainingCopy = computed(() => {
|
||||
if (trainingType.value === "base") {
|
||||
const hitReq = getPracticeNumber(
|
||||
practiceInfo.value.hitReq,
|
||||
trainingParams.value.hitReq
|
||||
);
|
||||
const arrowsLeft = getPracticeNumber(
|
||||
practiceInfo.value.arrowsLeft,
|
||||
total.value
|
||||
);
|
||||
|
||||
return {
|
||||
title: `每箭命中${hitReq}环之上`,
|
||||
details: [
|
||||
{ text: "剩余" },
|
||||
{ text: arrowsLeft, highlight: true },
|
||||
{ text: "箭达到条件" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (trainingType.value === "endurance") {
|
||||
const targetArrows = getPracticeNumber(
|
||||
practiceInfo.value.targetArrows,
|
||||
total.value
|
||||
);
|
||||
const targetRings = getPracticeNumber(
|
||||
practiceInfo.value.targetRings,
|
||||
trainingParams.value.totalReq
|
||||
);
|
||||
const currentArrows = getPracticeNumber(practiceInfo.value.currentArrows);
|
||||
const currentRings = getPracticeNumber(practiceInfo.value.currentRings);
|
||||
|
||||
return {
|
||||
title: `完成${targetArrows}箭并累计${targetRings}环`,
|
||||
details: [
|
||||
{ text: "已完成" },
|
||||
{ text: currentArrows, highlight: true },
|
||||
{ text: "箭,累计" },
|
||||
{ text: currentRings, highlight: true },
|
||||
{ text: "环" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (trainingType.value === "precision") {
|
||||
const block = precisionRandomBlock.value;
|
||||
const ring = precisionRandomRingArea.value;
|
||||
const arrowsLeft = getPracticeNumber(
|
||||
practiceInfo.value.arrowsLeft,
|
||||
total.value
|
||||
);
|
||||
const title = block
|
||||
? ring
|
||||
? `请命中区域${block}的${ring}环`
|
||||
: `请命中区域${block}`
|
||||
: "等待目标区域";
|
||||
|
||||
return {
|
||||
title,
|
||||
details: [
|
||||
{ text: "剩余" },
|
||||
{ text: arrowsLeft, highlight: true },
|
||||
{ text: "箭" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const toRouteNumber = (value, fallback = 0) => {
|
||||
@@ -99,14 +232,352 @@ const toPositiveRouteNumber = (value, fallback) => {
|
||||
return numberValue > 0 ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const createPractice = async () => {
|
||||
const result = await createPractiseAPI(
|
||||
total.value,
|
||||
shootTime.value,
|
||||
targetType.value
|
||||
);
|
||||
const practiceInfoFields = [
|
||||
"id",
|
||||
"userId",
|
||||
"status",
|
||||
"statusText",
|
||||
"startTime",
|
||||
"targetType",
|
||||
"sVip",
|
||||
"trainingType",
|
||||
"difficultyLevel",
|
||||
"hitReq",
|
||||
"arrowsLeft",
|
||||
"targetArrows",
|
||||
"targetRings",
|
||||
"currentArrows",
|
||||
"currentRings",
|
||||
"blocks",
|
||||
"randomBlock",
|
||||
"randomRingArea",
|
||||
"timeLimit",
|
||||
"completed",
|
||||
"totalArrows",
|
||||
"duration",
|
||||
"averageRing",
|
||||
"stability",
|
||||
"maxCombo",
|
||||
"totalHits",
|
||||
"deltaTotalHits",
|
||||
"deltaDuration",
|
||||
"deltaMaxCombo",
|
||||
"deltaTotalRings",
|
||||
"deltaTotalArrows",
|
||||
"deltaAverageRing",
|
||||
"deltaStability",
|
||||
"beforeExp",
|
||||
"beforeLevel",
|
||||
"currentExp",
|
||||
"level",
|
||||
"upgradeExp",
|
||||
"calories",
|
||||
"shootData",
|
||||
"details",
|
||||
];
|
||||
|
||||
if (result) practiseId.value = result.id;
|
||||
const practiceResultFields = [
|
||||
"trainingType",
|
||||
"difficultyLevel",
|
||||
"completed",
|
||||
"totalArrows",
|
||||
"duration",
|
||||
"averageRing",
|
||||
"stability",
|
||||
"maxCombo",
|
||||
"totalHits",
|
||||
"currentRings",
|
||||
"deltaTotalHits",
|
||||
"deltaDuration",
|
||||
"deltaMaxCombo",
|
||||
"deltaTotalRings",
|
||||
"deltaTotalArrows",
|
||||
"deltaAverageRing",
|
||||
"deltaStability",
|
||||
"beforeExp",
|
||||
"beforeLevel",
|
||||
"currentExp",
|
||||
"level",
|
||||
"upgradeExp",
|
||||
"calories",
|
||||
"details",
|
||||
];
|
||||
|
||||
const syncPracticeInfo = (message = {}) => {
|
||||
const nextInfo = practiceInfoFields.reduce((result, field) => {
|
||||
if (Object.prototype.hasOwnProperty.call(message, field)) {
|
||||
result[field] = message[field];
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const isPrecisionSnapshot =
|
||||
(message.type === MESSAGETYPESV2.BattleStart ||
|
||||
message.type === MESSAGETYPESV2.ShootResult) &&
|
||||
(message.trainingType === "precision" || trainingType.value === "precision");
|
||||
if (isPrecisionSnapshot) {
|
||||
// proto3 会省略数值 0;新快照未携带时必须清除上一箭的随机目标。
|
||||
if (!Object.prototype.hasOwnProperty.call(message, "randomBlock")) {
|
||||
nextInfo.randomBlock = 0;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
|
||||
nextInfo.randomRingArea = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextInfo).length === 0) return;
|
||||
practiceInfo.value = {
|
||||
...practiceInfo.value,
|
||||
...nextInfo,
|
||||
};
|
||||
};
|
||||
|
||||
const createPracticeEndSnapshot = (message = {}) => {
|
||||
const source = {
|
||||
...practiceInfo.value,
|
||||
...message,
|
||||
};
|
||||
const snapshot = practiceResultFields.reduce((result, field) => {
|
||||
if (Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
result[field] = source[field];
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
// proto3 会省略 false,PRACTICE_END 未携带 completed 时按未达标处理。
|
||||
snapshot.completed = message.completed === true;
|
||||
snapshot.trainingType = source.trainingType || trainingType.value;
|
||||
if (Array.isArray(message.details)) {
|
||||
snapshot.details = message.details;
|
||||
} else if (scores.value.length > 0) {
|
||||
snapshot.details = [...scores.value];
|
||||
} else if (Array.isArray(practiceInfo.value.details)) {
|
||||
snapshot.details = practiceInfo.value.details;
|
||||
} else {
|
||||
delete snapshot.details;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const mergePracticeResult = (apiResult = {}) => {
|
||||
const snapshot = practiceEndSnapshot.value;
|
||||
const snapshotDetails = Array.isArray(snapshot.details)
|
||||
? snapshot.details
|
||||
: null;
|
||||
const apiDetails = Array.isArray(apiResult.details) ? apiResult.details : null;
|
||||
const details = snapshotDetails?.length
|
||||
? snapshotDetails
|
||||
: apiDetails || snapshotDetails || [...scores.value];
|
||||
|
||||
return {
|
||||
...apiResult,
|
||||
...snapshot,
|
||||
details,
|
||||
};
|
||||
};
|
||||
|
||||
const clearPracticeSyncTimer = () => {
|
||||
if (!practiceSyncTimer) return;
|
||||
clearTimeout(practiceSyncTimer);
|
||||
practiceSyncTimer = null;
|
||||
};
|
||||
|
||||
const cancelPracticeSyncWait = () => {
|
||||
waitingPracticeSync = false;
|
||||
clearPracticeSyncTimer();
|
||||
};
|
||||
|
||||
const preparePracticeSyncWait = () => {
|
||||
cancelPracticeSyncWait();
|
||||
waitingPracticeSync = true;
|
||||
};
|
||||
|
||||
const startPracticeSyncTimer = () => {
|
||||
clearPracticeSyncTimer();
|
||||
waitingPracticeSync = true;
|
||||
practiceSyncTimer = setTimeout(() => {
|
||||
practiceSyncTimer = null;
|
||||
if (!waitingPracticeSync) return;
|
||||
waitingPracticeSync = false;
|
||||
if (pageStage.value !== pageStages.LOADING) return;
|
||||
|
||||
uni.showToast({
|
||||
title: "练习信息获取失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
setTimeout(() => {
|
||||
void exitPractice();
|
||||
}, 500);
|
||||
}, PRACTICE_SYNC_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onMatchSocketState = (event = {}) => {
|
||||
if (event.state !== "open") return;
|
||||
if (
|
||||
event.matchId &&
|
||||
String(event.matchId) !== String(practiseId.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 管理器会在 open 事件后立即发送 5,从这里开始计算响应超时。
|
||||
startPracticeSyncTimer();
|
||||
};
|
||||
|
||||
const onPracticeInfoSync = (payload = {}) => {
|
||||
const responseMatchId = String(
|
||||
payload.matchId || payload.practiceInfo?.id || ""
|
||||
);
|
||||
if (!responseMatchId || responseMatchId !== String(practiseId.value)) return;
|
||||
|
||||
const snapshot = payload.practiceInfo;
|
||||
if (!snapshot || typeof snapshot !== "object") return;
|
||||
|
||||
const shouldShowDistance =
|
||||
waitingPracticeSync && pageStage.value === pageStages.LOADING;
|
||||
cancelPracticeSyncWait();
|
||||
|
||||
// 14 是完整快照,先清空旧值,避免 proto3 省略的 0 沿用上一份状态。
|
||||
practiceInfo.value = {};
|
||||
practiceEndSnapshot.value = {};
|
||||
syncPracticeInfo(snapshot);
|
||||
scores.value = Array.isArray(snapshot.details) ? snapshot.details : [];
|
||||
|
||||
if (shouldShowDistance) {
|
||||
start.value = false;
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
};
|
||||
|
||||
// 训练在难度页创建,目标页只消费连接上下文,避免进入页面后重复创建。
|
||||
const getTrainingContext = () => {
|
||||
const context = uni.getStorageSync(trainingDifficultyStorageKey);
|
||||
return context && typeof context === "object" ? context : {};
|
||||
};
|
||||
|
||||
const updateTrainingContext = (practice = {}) => {
|
||||
const context = getTrainingContext();
|
||||
uni.setStorageSync(trainingDifficultyStorageKey, {
|
||||
...context,
|
||||
trainingType: trainingParams.value.type,
|
||||
difficultyLevel: trainingParams.value.difficulty,
|
||||
practiceId: practice.id || "",
|
||||
serverAddr: practice.serverAddr || "",
|
||||
createdAt: practice.id ? Date.now() : 0,
|
||||
});
|
||||
};
|
||||
|
||||
const clearPracticeRuntimeContext = () => {
|
||||
const context = getTrainingContext();
|
||||
if (
|
||||
context.practiceId &&
|
||||
practiseId.value &&
|
||||
String(context.practiceId) !== String(practiseId.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
practiceId: _practiceId,
|
||||
serverAddr: _serverAddr,
|
||||
createdAt: _createdAt,
|
||||
...selectionContext
|
||||
} = context;
|
||||
uni.setStorageSync(trainingDifficultyStorageKey, selectionContext);
|
||||
};
|
||||
|
||||
const closePracticeConnection = (reason) => {
|
||||
cancelPracticeSyncWait();
|
||||
if (connectionClosed.value) return;
|
||||
connectionClosed.value = true;
|
||||
closeMatchWebSocket({ reason });
|
||||
};
|
||||
|
||||
const connectPracticeServer = () => {
|
||||
if (!practiseId.value || !String(serverAddr.value || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancelPracticeSyncWait();
|
||||
closeMatchWebSocket({ reason: "training-practice-switch" });
|
||||
preparePracticeSyncWait();
|
||||
connectMatchWebSocket({
|
||||
serverAddr: serverAddr.value,
|
||||
matchId: practiseId.value,
|
||||
userId: user.value.id,
|
||||
requestPracticeInfoOnOpen: true,
|
||||
});
|
||||
connectionClosed.value = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 返回、切后台和页面销毁可能连续触发,复用同一个 stop 任务避免重复请求。
|
||||
const stopCurrentPractice = () => {
|
||||
if (
|
||||
!practiseId.value ||
|
||||
practiceEnded.value ||
|
||||
stopCompleted.value
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (stopPracticeTask) return stopPracticeTask;
|
||||
|
||||
stopInFlight.value = true;
|
||||
stopPracticeTask = endPractiseAPI(practiseId.value)
|
||||
.then(() => {
|
||||
stopCompleted.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("training practice stop failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
stopInFlight.value = false;
|
||||
stopPracticeTask = null;
|
||||
});
|
||||
|
||||
return stopPracticeTask;
|
||||
};
|
||||
|
||||
const createPractice = async () => {
|
||||
const trainingType = trainingParams.value.type;
|
||||
const difficultyLevel = trainingParams.value.difficulty;
|
||||
|
||||
if (!trainingType || difficultyLevel <= 0) {
|
||||
uni.showToast({
|
||||
title: "训练参数异常,请重新选择难度",
|
||||
icon: "none",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
closePracticeConnection("training-practice-recreate");
|
||||
const result = await createPractiseV2API(trainingType, difficultyLevel);
|
||||
|
||||
if (!result?.id || !String(result?.serverAddr || "").trim()) {
|
||||
if (result?.id) {
|
||||
try {
|
||||
await endPractiseAPI(result.id);
|
||||
} catch (error) {
|
||||
console.error("training practice cleanup failed", error);
|
||||
}
|
||||
}
|
||||
uni.showToast({
|
||||
title: "练习连接信息异常,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
practiseId.value = result.id;
|
||||
serverAddr.value = result.serverAddr;
|
||||
practiceEnded.value = false;
|
||||
stopCompleted.value = false;
|
||||
stopInFlight.value = false;
|
||||
stopPracticeTask = null;
|
||||
updateTrainingContext(result);
|
||||
connectPracticeServer();
|
||||
return result;
|
||||
};
|
||||
|
||||
const clearHighlightTestTimer = () => {
|
||||
@@ -116,23 +587,7 @@ const clearHighlightTestTimer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildHighlightTestScore = (index) => ({
|
||||
playerId: user.value?.id,
|
||||
ring: 9,
|
||||
ringX: false,
|
||||
x: ((index % 4) - 1.5) * 2,
|
||||
y: (Math.floor(index / 4) - 1) * 2,
|
||||
angle: null,
|
||||
});
|
||||
|
||||
const setHighlightTestArrow = (arrowIndex) => {
|
||||
const completedCount = Math.max(arrowIndex - 1, 0);
|
||||
scores.value = Array.from({ length: completedCount }, (_, index) =>
|
||||
buildHighlightTestScore(index)
|
||||
);
|
||||
};
|
||||
|
||||
// 临时测试入口:自动切换第 1 到第 12 箭,让 BowTarget 按当前箭展示不同高亮。
|
||||
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
|
||||
const runHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = true;
|
||||
@@ -140,50 +595,83 @@ const runHighlightTest = () => {
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
start.value = true;
|
||||
|
||||
let arrowIndex = 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
let block = 1;
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: block,
|
||||
randomRingArea: 0,
|
||||
};
|
||||
|
||||
highlightTestTimer.value = setInterval(() => {
|
||||
if (arrowIndex >= highlightTestAreas.length) {
|
||||
if (block >= highlightTestState.value.blocks) {
|
||||
clearHighlightTestTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
arrowIndex += 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
block += 1;
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: block,
|
||||
randomRingArea: block % 2 === 0 ? Math.min(block, 10) : 0,
|
||||
};
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const resetHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: 1,
|
||||
randomRingArea: 0,
|
||||
};
|
||||
scores.value = [];
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
const trainingContext = getTrainingContext();
|
||||
targetType.value = toPositiveRouteNumber(options.target, defaultTargetType);
|
||||
total.value = toPositiveRouteNumber(options.arrows, defaultTotal);
|
||||
shootTime.value = toPositiveRouteNumber(options.time, defaultShootTime);
|
||||
trainingParams.value = {
|
||||
type: options.type || "",
|
||||
type: options.type || trainingContext.trainingType || "",
|
||||
difficultyId: options.difficultyId || "",
|
||||
difficulty: toRouteNumber(options.difficulty),
|
||||
difficulty: toRouteNumber(
|
||||
options.difficulty,
|
||||
toRouteNumber(trainingContext.difficultyLevel)
|
||||
),
|
||||
recordId: options.recordId || "",
|
||||
hitReq: toRouteNumber(options.hitReq),
|
||||
totalReq: toRouteNumber(options.totalReq),
|
||||
blocks: toRouteNumber(options.blocks),
|
||||
mode: toRouteNumber(options.mode),
|
||||
};
|
||||
practiseId.value = trainingContext.practiceId || "";
|
||||
serverAddr.value = trainingContext.serverAddr || "";
|
||||
});
|
||||
|
||||
const onReady = async () => {
|
||||
if (
|
||||
!practiseId.value ||
|
||||
practiceEnded.value ||
|
||||
stopCompleted.value ||
|
||||
stopInFlight.value
|
||||
) {
|
||||
uni.showToast({
|
||||
title: "训练已结束,请重新进入",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
practiceEndSnapshot.value = {};
|
||||
try {
|
||||
await startPractiseAPI();
|
||||
await startPractiseAPI(practiseId.value);
|
||||
practiseResult.value = {};
|
||||
scores.value = [];
|
||||
shotEffectToken.value = 0;
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
audioManager.play("练习开始");
|
||||
@@ -194,6 +682,24 @@ const onReady = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeLimitReached = () => {
|
||||
if (!hasTimeLimit.value || !isShootingStage.value) return;
|
||||
// 本地倒计时只负责停止射击展示,最终结算以 PRACTICE_END 为准。
|
||||
start.value = false;
|
||||
};
|
||||
|
||||
const enterPracticeResult = (result = {}) => {
|
||||
practiseResult.value = result;
|
||||
if (!hasPractiseResult.value) return false;
|
||||
|
||||
// 正常结算不调用 stop,只清理上下文并断开比赛服连接。
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
closePracticeConnection("training-practice-result");
|
||||
pageStage.value = pageStages.RESULT;
|
||||
return true;
|
||||
};
|
||||
|
||||
const onOver = async () => {
|
||||
if (!isShootingStage.value) return;
|
||||
|
||||
@@ -202,11 +708,15 @@ const onOver = async () => {
|
||||
start.value = false;
|
||||
|
||||
try {
|
||||
practiseResult.value = (await getPractiseAPI(practiseId.value)) || {};
|
||||
pageStage.value = hasPractiseResult.value
|
||||
? pageStages.RESULT
|
||||
: pageStages.DISTANCE;
|
||||
const apiResult = (await getPractiseAPI(practiseId.value)) || {};
|
||||
if (!enterPracticeResult(mergePracticeResult(apiResult))) {
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
} catch (error) {
|
||||
if (Object.keys(practiceEndSnapshot.value).length > 0) {
|
||||
enterPracticeResult(mergePracticeResult());
|
||||
return;
|
||||
}
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
throw error;
|
||||
@@ -214,9 +724,30 @@ const onOver = async () => {
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
syncPracticeInfo(msg);
|
||||
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult && isShootingStage.value) {
|
||||
scores.value = msg.details;
|
||||
if (Array.isArray(msg.details)) {
|
||||
const previousScoreLength = scores.value.length;
|
||||
scores.value = msg.details;
|
||||
if (msg.details.length === previousScoreLength + 1) {
|
||||
shotEffectToken.value += 1;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
practiceEndSnapshot.value = createPracticeEndSnapshot(msg);
|
||||
if (
|
||||
trainingType.value === "base" &&
|
||||
Number(msg.status) === 3 &&
|
||||
!Object.prototype.hasOwnProperty.call(msg, "arrowsLeft")
|
||||
) {
|
||||
practiceInfo.value = {
|
||||
...practiceInfo.value,
|
||||
arrowsLeft: 0,
|
||||
};
|
||||
}
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
// setTimeout(onOver, 1500);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +755,9 @@ async function onReceiveMessage(msg) {
|
||||
function onComplete() {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
start.value = false;
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
closePracticeConnection("training-practice-complete");
|
||||
uni.$emit(trainingDifficultyRefreshEvent);
|
||||
uni.navigateBack();
|
||||
}
|
||||
@@ -233,12 +767,18 @@ async function onRetry() {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
practiseId.value = "";
|
||||
serverAddr.value = "";
|
||||
practiseResult.value = {};
|
||||
practiceEndSnapshot.value = {};
|
||||
practiceInfo.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
shotEffectToken.value = 0;
|
||||
try {
|
||||
await createPractice();
|
||||
} finally {
|
||||
const practice = await createPractice();
|
||||
if (!practice) pageStage.value = pageStages.DISTANCE;
|
||||
} catch (error) {
|
||||
console.error("training practice retry failed", error);
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
}
|
||||
@@ -259,28 +799,89 @@ const updateSound = () => {
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
const exitPractice = async () => {
|
||||
if (exiting.value) return;
|
||||
exiting.value = true;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await stopCurrentPractice();
|
||||
} finally {
|
||||
closePracticeConnection("training-practice-exit");
|
||||
clearPracticeRuntimeContext();
|
||||
uni.navigateBack();
|
||||
}
|
||||
};
|
||||
|
||||
onHide(() => {
|
||||
// 小程序被切到后台时尽早通知后端,作为杀进程前的尽力兜底。
|
||||
if (
|
||||
!exiting.value &&
|
||||
practiseId.value &&
|
||||
!practiceEnded.value &&
|
||||
!stopCompleted.value
|
||||
) {
|
||||
hiddenWhileActive.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
void stopCurrentPractice();
|
||||
}
|
||||
closePracticeConnection("training-practice-hide");
|
||||
});
|
||||
|
||||
onShow(async () => {
|
||||
if (!hiddenWhileActive.value || exiting.value) return;
|
||||
|
||||
hiddenWhileActive.value = false;
|
||||
await stopCurrentPractice();
|
||||
clearPracticeRuntimeContext();
|
||||
exiting.value = true;
|
||||
uni.showToast({
|
||||
title: "训练已结束,请重新进入",
|
||||
icon: "none",
|
||||
});
|
||||
uni.navigateBack();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
clearPracticeRuntimeContext();
|
||||
void stopCurrentPractice();
|
||||
closePracticeConnection("training-practice-unload");
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// audioManager.play("第一轮");
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
|
||||
uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState);
|
||||
uni.$on("share-image", onClickShare);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
await createPractice();
|
||||
if (!connectPracticeServer()) {
|
||||
uni.showToast({
|
||||
title: "练习连接信息异常,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
setTimeout(() => {
|
||||
void exitPractice();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPracticeRuntimeContext();
|
||||
void stopCurrentPractice();
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
|
||||
uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState);
|
||||
uni.$off("share-image", onClickShare);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
audioManager.stopAll();
|
||||
clearHighlightTestTimer();
|
||||
endPractiseAPI();
|
||||
closePracticeConnection("training-practice-unmount");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -289,14 +890,21 @@ onBeforeUnmount(() => {
|
||||
:bgType="isDistanceStage ? 9 : 11"
|
||||
:showBottom="isDistanceStage"
|
||||
:scroll="!isShootingStage"
|
||||
:onBack="exitPractice"
|
||||
>
|
||||
<view class="practise-content">
|
||||
<TestDistance v-if="isDistanceStage" />
|
||||
<TestDistance
|
||||
v-if="isDistanceStage"
|
||||
:targetType="practiceInfo.targetType"
|
||||
/>
|
||||
<view v-else-if="isShootingStage" class="shooting-layout">
|
||||
<view class="shooting-fixed">
|
||||
<ShootProgress
|
||||
:start="start"
|
||||
:onStop="onOver"
|
||||
:total="timeLimit"
|
||||
:countdownEnabled="hasTimeLimit"
|
||||
:trainingType="trainingType"
|
||||
:onStop="onTimeLimitReached"
|
||||
/>
|
||||
<view class="user-row">
|
||||
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
||||
@@ -310,8 +918,13 @@ onBeforeUnmount(() => {
|
||||
:totalRound="start ? total / 4 : 0"
|
||||
:currentRound="scores.length % 3"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
:shotEffectToken="shotEffectToken"
|
||||
:showCrosshair="false"
|
||||
:highlightAreas="targetHighlightAreas"
|
||||
:sectorCount="precisionBlocks"
|
||||
:activeSector="precisionRandomBlock"
|
||||
:activeRing="precisionRandomRingArea"
|
||||
:showSectorLabels="precisionBlocks > 0"
|
||||
/>
|
||||
<view v-if="env !== 'release'" class="highlight-test-actions">
|
||||
<button
|
||||
@@ -319,7 +932,7 @@ onBeforeUnmount(() => {
|
||||
hover-class="none"
|
||||
@click="runHighlightTest"
|
||||
>
|
||||
高亮测试
|
||||
扇区测试
|
||||
</button>
|
||||
<button
|
||||
class="highlight-test-btn"
|
||||
@@ -340,14 +953,20 @@ onBeforeUnmount(() => {
|
||||
<view class="bat-text-big-box">
|
||||
<image
|
||||
class="dao-icon"
|
||||
src="../../static/training-difficulty-design/dao-icon.png"
|
||||
src="./static/training-difficulty-design/dao-icon.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bat-text-box">
|
||||
<view v-if="trainingCopy" class="bat-text-box">
|
||||
<view class="bat-text-small-box">
|
||||
<view class="text-round-box">
|
||||
<view class="text1">每箭命中9环之上</view>
|
||||
<view class="text2">剩余<text class="text2-yellow">3</text>箭</view>
|
||||
<view class="text1">{{ trainingCopy.title }}</view>
|
||||
<view class="text2">
|
||||
<text
|
||||
v-for="(part, index) in trainingCopy.details"
|
||||
:key="index"
|
||||
:class="{ 'text2-yellow': part.highlight }"
|
||||
>{{ part.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -369,6 +988,8 @@ onBeforeUnmount(() => {
|
||||
:total="total"
|
||||
:onClose="onComplete"
|
||||
:onRetry="onRetry"
|
||||
:trainingType="trainingType"
|
||||
:difficultyLevel="currentDifficultyLevel"
|
||||
:result="practiseResult"
|
||||
/>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
@@ -377,7 +998,7 @@ onBeforeUnmount(() => {
|
||||
<view class="btn-box">
|
||||
<image
|
||||
class="btn-box-bg"
|
||||
src="../../static/training-difficulty-design/par-star.png"
|
||||
src="./static/training-difficulty-design/par-star.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<button class="btn" @click="onReady">准备好了,开始练习</button>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 849 B After Width: | Height: | Size: 849 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 719 B After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1011 B After Width: | Height: | Size: 1011 B |
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 184 B |
|
Before Width: | Height: | Size: 192 B After Width: | Height: | Size: 192 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 468 B After Width: | Height: | Size: 468 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 173 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 351 B After Width: | Height: | Size: 351 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -20,6 +20,7 @@ export const ServerMessageType = {
|
||||
SERVER_MSG_PLAYER_LEFT: 11,
|
||||
SERVER_MSG_HEARTBEAT: 12,
|
||||
SERVER_MSG_PRACTICE_END: 13,
|
||||
SERVER_MSG_SYNC_PRACTICE_INFO: 14,
|
||||
};
|
||||
|
||||
export const ClientMessageType = {
|
||||
@@ -28,6 +29,7 @@ export const ClientMessageType = {
|
||||
CLIENT_MSG_SHOOT_DATA: 2,
|
||||
CLIENT_MSG_ACK: 3,
|
||||
CLIENT_MSG_LEAVE: 4,
|
||||
CLIENT_MSG_SYNC_PRACTICE_INFO: 5,
|
||||
};
|
||||
|
||||
// protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。
|
||||
@@ -149,6 +151,38 @@ const SCHEMAS = {
|
||||
9: { name: "device_id", kind: "string" },
|
||||
10: { name: "shoot_data", kind: "message", type: "MatchShoot" },
|
||||
11: { name: "details", kind: "message", type: "MatchShoot", repeated: true },
|
||||
12: { name: "training_type", kind: "string" },
|
||||
13: { name: "difficulty_level", kind: "int32" },
|
||||
14: { name: "hit_req", kind: "int32" },
|
||||
15: { name: "arrows_left", kind: "int32" },
|
||||
16: { name: "target_arrows", kind: "int32" },
|
||||
17: { name: "target_rings", kind: "int32" },
|
||||
18: { name: "current_arrows", kind: "int32" },
|
||||
19: { name: "current_rings", kind: "int32" },
|
||||
20: { name: "blocks", kind: "int32" },
|
||||
21: { name: "random_block", kind: "int32" },
|
||||
22: { name: "random_ring_area", kind: "int32" },
|
||||
23: { name: "time_limit", kind: "int32" },
|
||||
24: { name: "completed", kind: "bool" },
|
||||
25: { name: "total_arrows", kind: "int32" },
|
||||
26: { name: "duration", kind: "int32" },
|
||||
27: { name: "average_ring", kind: "float" },
|
||||
28: { name: "stability", kind: "float" },
|
||||
29: { name: "max_combo", kind: "int32" },
|
||||
30: { name: "total_hits", kind: "int32" },
|
||||
31: { name: "delta_total_hits", kind: "int32" },
|
||||
32: { name: "delta_duration", kind: "int32" },
|
||||
33: { name: "delta_max_combo", kind: "int32" },
|
||||
34: { name: "delta_total_rings", kind: "int32" },
|
||||
35: { name: "delta_total_arrows", kind: "int32" },
|
||||
36: { name: "delta_average_ring", kind: "float" },
|
||||
37: { name: "delta_stability", kind: "float" },
|
||||
38: { name: "before_exp", kind: "int32" },
|
||||
39: { name: "before_level", kind: "int32" },
|
||||
40: { name: "current_exp", kind: "int32" },
|
||||
41: { name: "level", kind: "int32" },
|
||||
42: { name: "upgrade_exp", kind: "int32" },
|
||||
43: { name: "calories", kind: "double" },
|
||||
},
|
||||
MatchInfo: {
|
||||
1: { name: "match_id", kind: "string" },
|
||||
@@ -233,6 +267,8 @@ function readScalar(reader, kind) {
|
||||
return reader.int64().toString();
|
||||
case "float":
|
||||
return reader.float();
|
||||
case "double":
|
||||
return reader.double();
|
||||
case "bool":
|
||||
return reader.bool();
|
||||
case "string":
|
||||
@@ -355,6 +391,15 @@ export function createHeartbeatAckMessage() {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSyncPracticeInfoMessage({ matchId, userId }) {
|
||||
// 新版个人训练页连接成功后主动请求完整练习信息。
|
||||
return encodeClientMessage({
|
||||
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAckMessage({ matchId, sequence }) {
|
||||
// 普通消息 ACK 仍携带 sequence,但前端不做 sequence 补发或排序。
|
||||
return encodeClientMessage({
|
||||
|
||||
@@ -24,7 +24,7 @@ function createWebSocket(token, onMessage) {
|
||||
|
||||
switch (envVersion) {
|
||||
case "develop": // 开发版
|
||||
// url = "ws://192.168.1.2:8000/socket";
|
||||
// url = "ws://192.168.1.5:8000/socket";
|
||||
url = "wss://apitest.shelingxingqiu.com/socket";
|
||||
break;
|
||||
case "trial": // 体验版
|
||||
|
||||