update:对接个人训练改版

This commit is contained in:
2026-07-23 09:44:58 +08:00
parent a78ab1daeb
commit 3c5754b3fd
64 changed files with 1769 additions and 488 deletions
+6 -10
View File
@@ -8,7 +8,7 @@ try {
switch (envVersion) { switch (envVersion) {
case "develop": // 开发版 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"; BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
break; break;
case "trial": // 体验版 case "trial": // 体验版
@@ -26,8 +26,6 @@ try {
} }
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot"); const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
const PRACTICE_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api");
// 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。 // 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。
function request(method, url, data = {}, baseUrl = BASE_URL) { function request(method, url, data = {}, baseUrl = BASE_URL) {
const token = uni.getStorageSync( const token = uni.getStorageSync(
@@ -264,13 +262,11 @@ export const createPractiseAPI = (arrows, time, target) => {
}); });
}; };
export const createPractiseV2API = (arrows, time, target, deviceId) => { export const createPractiseV2API = (trainingType, difficultyLevel) => {
return request("POST", "/practice/create-v2", { return request("POST", "/user/practice/create/v2", {
shootNumber: arrows, trainingType,
shootTime: time, difficultyLevel,
targetType: Number(target || 1) * 20, });
deviceId,
}, PRACTICE_BASE_URL);
}; };
export const startPractiseAPI = (id) => { export const startPractiseAPI = (id) => {
+143 -147
View File
@@ -21,34 +21,29 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
// 是否显示象限文字。
showQuadrantLabels: {
type: Boolean,
default: false,
},
// 是否显示环数文字。 // 是否显示环数文字。
showRingLabels: { showRingLabels: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
// 象限文字配置,key 为 1/2/3/4 // 从正上方开始顺时针等分的区域数量
quadrantLabels: { sectorCount: {
type: Object, type: Number,
default: () => ({ default: 0,
1: "1",
2: "2",
3: "3",
4: "4",
}),
}, },
// 高亮区域数组 // 当前高亮区域,范围为 1 到 sectorCount
// quadrant: 1/2/3/4,表示第几个象限。 activeSector: {
// rings: "all" 或环数数组,例如 [7, 8, 9, 10]。 type: Number,
// scope: "box" 表示整象限矩形,"sector" 表示环形扇区。 default: 0,
// style: 可覆盖高亮填充色、描边色、线宽比例。 },
highlightAreas: { // 指定环数,1 到 10;无效值表示高亮整个区域。
type: Array, activeRing: {
default: () => [], type: Number,
default: 0,
},
showSectorLabels: {
type: Boolean,
default: false,
}, },
// 只绘制透明高亮层,不绘制完整靶纸;用于叠加在靶纸图片上。 // 只绘制透明高亮层,不绘制完整靶纸;用于叠加在靶纸图片上。
highlightOnly: { highlightOnly: {
@@ -74,8 +69,18 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
// 象限文字样式覆盖配置。 // 区域分割线样式覆盖配置。
quadrantLabelStyle: { sectorStyle: {
type: Object,
default: () => ({}),
},
// 区域数字样式覆盖配置。
sectorLabelStyle: {
type: Object,
default: () => ({}),
},
// 高亮样式覆盖配置。
highlightStyle: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
@@ -120,16 +125,24 @@ const defaultCrosshairStyle = {
lineWidthRatio: 0.0025, lineWidthRatio: 0.0025,
}; };
// 象限文字默认样式。 // 顺时针等分线默认样式。
const defaultQuadrantLabelStyle = { const defaultSectorStyle = {
color: "rgba(255, 255, 255, 0.82)",
lineWidthRatio: 0.004,
};
// 区域数字默认样式。
const defaultSectorLabelStyle = {
color: "#ffffff", color: "#ffffff",
fontSizeRatio: 0.045, backgroundColor: "rgba(0, 0, 0, 0.62)",
offsetRatio: 0.78, fontSizeRatio: 0.075,
radiusRatio: 0.76,
badgeRadiusRatio: 0.07,
}; };
// 高亮区域默认样式。 // 高亮区域默认样式。
const defaultHighlightStyle = { const defaultHighlightStyle = {
color: "rgba(254, 216, 71, 0.34)", color: "rgba(255, 228, 0, 0.6)",
strokeColor: "rgba(254, 216, 71, 0.82)", strokeColor: "rgba(254, 216, 71, 0.82)",
lineWidthRatio: 0.003, lineWidthRatio: 0.003,
}; };
@@ -155,40 +168,24 @@ const getRingColor = (ring, config) => {
return config.ringColors?.[ring] || config.ringColors?.[String(ring)] || "#ffffff"; return config.ringColors?.[ring] || config.ringColors?.[String(ring)] || "#ffffff";
}; };
// 规范化高亮环数配置;"all" 表示全部环,数组或单值会过滤非法环数。 const getPositiveInteger = (value) => {
const normalizeRings = (rings, ringCount) => { const numberValue = Number(value);
if (rings === "all") { return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
return "all";
}
const rawRings = Array.isArray(rings) ? rings : [rings];
return rawRings
.map((ring) => Number(ring))
.filter((ring) => Number.isInteger(ring) && ring >= 1 && ring <= ringCount);
}; };
// 获取象限对应的扇形弧度范围 // 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针
const getQuadrantAngles = (quadrant) => { const getSectorAngles = (sector, sectorCount) => {
const angleMap = { const count = getPositiveInteger(sectorCount);
1: [Math.PI, Math.PI * 1.5], const index = getPositiveInteger(sector);
2: [Math.PI * 1.5, Math.PI * 2], if (!count || !index || index > count) return null;
3: [Math.PI * 0.5, Math.PI],
4: [0, Math.PI * 0.5], 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) => {
} }
}; };
// 绘制所有高亮区域,支持整象限矩形高亮和指定环数扇区高亮 // 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环
const drawHighlights = (ctx, centerX, centerY, targetRadius, config) => { const drawSectorHighlight = (ctx, centerX, centerY, targetRadius, config) => {
props.highlightAreas.forEach((area = {}) => { const angles = getSectorAngles(props.activeSector, props.sectorCount);
const angles = getQuadrantAngles(area.quadrant); if (!angles) return;
if (!angles) { const ring = getPositiveInteger(props.activeRing);
return; 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 = { drawAnnularSector(
...defaultHighlightStyle, ctx,
...(area.style || {}), centerX,
}; centerY,
const highlightLineWidth = Math.max(1, targetRadius * highlightStyle.lineWidthRatio); innerRadius,
const rings = normalizeRings(area.rings || "all", config.ringCount); outerRadius,
const scope = area.scope || (rings === "all" ? "box" : "sector"); angles.startAngle,
angles.endAngle,
style.color,
style.strokeColor,
Math.max(1, targetRadius * style.lineWidthRatio)
);
};
// 整象限默认画成矩形高亮,便于对应 1/2/3/4 号框训练提示 // 从正上方开始顺时针绘制所有区域边界
if (rings === "all" && scope === "box") { const drawSectorLines = (ctx, centerX, centerY, targetRadius) => {
const box = getQuadrantBox(area.quadrant, centerX, centerY, targetRadius); const count = getPositiveInteger(props.sectorCount);
if (!box) return; if (!count) return;
ctx.beginPath();
ctx.rect(...box);
ctx.setFillStyle(highlightStyle.color);
ctx.fill();
ctx.setStrokeStyle(highlightStyle.strokeColor);
ctx.setLineWidth(highlightLineWidth);
ctx.stroke();
return;
}
const targetRings = rings === "all" const style = {
? Array.from({ length: config.ringCount }, (_, index) => index + 1) ...defaultSectorStyle,
: rings; ...props.sectorStyle,
};
const step = (Math.PI * 2) / count;
targetRings.forEach((ring) => { ctx.beginPath();
const innerRadius = targetRadius * ((config.ringCount - ring) / config.ringCount); for (let index = 0; index < count; index += 1) {
const outerRadius = targetRadius * ((config.ringCount + 1 - ring) / config.ringCount); const angle = -Math.PI / 2 + index * step;
drawAnnularSector( ctx.moveTo(centerX, centerY);
ctx, ctx.lineTo(
centerX, centerX + Math.cos(angle) * targetRadius,
centerY, centerY + Math.sin(angle) * targetRadius
innerRadius, );
outerRadius, }
angles[0], ctx.setStrokeStyle(style.color);
angles[1], ctx.setLineWidth(Math.max(1, targetRadius * style.lineWidthRatio));
highlightStyle.color, ctx.stroke();
highlightStyle.strokeColor,
highlightLineWidth
);
});
});
}; };
// 绘制各环之间的分割线。 // 绘制各环之间的分割线。
@@ -351,44 +351,30 @@ const drawRingLabels = (ctx, centerX, centerY, targetRadius, config) => {
} }
}; };
// 绘制象限文字 // 在每个区域中线位置绘制编号,编号层始终位于高亮和分割线之上
const drawQuadrantLabels = (ctx, centerX, centerY, targetRadius) => { const drawSectorLabels = (ctx, centerX, centerY, targetRadius) => {
if (!props.showQuadrantLabels) { const count = getPositiveInteger(props.sectorCount);
return; if (!props.showSectorLabels || !count) return;
}
const style = { const style = {
...defaultQuadrantLabelStyle, ...defaultSectorLabelStyle,
...props.quadrantLabelStyle, ...props.sectorLabelStyle,
};
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],
}; };
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.setTextAlign("center");
ctx.setTextBaseline("middle"); ctx.setTextBaseline("middle");
ctx.setFillStyle(style.color);
Object.entries(positions).forEach(([key, position]) => { for (let sector = 1; sector <= count; sector += 1) {
const label = props.quadrantLabels?.[key] || props.quadrantLabels?.[Number(key)]; const angles = getSectorAngles(sector, count);
if (label === undefined || label === null || label === "") return; const x = centerX + Math.cos(angles.middleAngle) * labelRadius;
ctx.fillText(String(label), position[0], position[1]); 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,只保留真正影响画面的字段。 }
const getHighlightDrawKeyAreas = () => {
return props.highlightAreas.map((area = {}) => ({
quadrant: area.quadrant,
rings: area.rings,
scope: area.scope,
style: area.style,
}));
}; };
// 生成本次绘制状态的唯一 key,用于避免相同内容重复 draw。 // 生成本次绘制状态的唯一 key,用于避免相同内容重复 draw。
@@ -398,12 +384,16 @@ const getDrawKey = (width, height) => {
height, height,
coordinateRadius: props.coordinateRadius, coordinateRadius: props.coordinateRadius,
showCrosshair: props.showCrosshair, showCrosshair: props.showCrosshair,
showQuadrantLabels: props.showQuadrantLabels,
showRingLabels: props.showRingLabels, showRingLabels: props.showRingLabels,
highlightAreas: getHighlightDrawKeyAreas(), sectorCount: props.sectorCount,
activeSector: props.activeSector,
activeRing: props.activeRing,
showSectorLabels: props.showSectorLabels,
targetStyleConfig: props.targetStyleConfig, targetStyleConfig: props.targetStyleConfig,
crosshairStyle: props.crosshairStyle, crosshairStyle: props.crosshairStyle,
quadrantLabelStyle: props.quadrantLabelStyle, sectorStyle: props.sectorStyle,
sectorLabelStyle: props.sectorLabelStyle,
highlightStyle: props.highlightStyle,
highlightOnly: props.highlightOnly, highlightOnly: props.highlightOnly,
}); });
}; };
@@ -431,7 +421,7 @@ const drawTarget = () => {
drawTargetRings(ctx, centerX, centerY, targetRadius, config); drawTargetRings(ctx, centerX, centerY, targetRadius, config);
} }
drawHighlights(ctx, centerX, centerY, targetRadius, config); drawSectorHighlight(ctx, centerX, centerY, targetRadius, config);
if (!props.highlightOnly) { if (!props.highlightOnly) {
drawRingLines(ctx, centerX, centerY, targetRadius, config); drawRingLines(ctx, centerX, centerY, targetRadius, config);
@@ -444,9 +434,12 @@ const drawTarget = () => {
); );
drawCrosshair(ctx, centerX, centerY, targetRadius); drawCrosshair(ctx, centerX, centerY, targetRadius);
drawRingLabels(ctx, centerX, centerY, targetRadius, config); drawRingLabels(ctx, centerX, centerY, targetRadius, config);
drawQuadrantLabels(ctx, centerX, centerY, targetRadius);
} }
// 高亮先画,等分线和编号后画,避免高亮覆盖区域边界。
drawSectorLines(ctx, centerX, centerY, targetRadius);
drawSectorLabels(ctx, centerX, centerY, targetRadius);
ctx.draw(); ctx.draw();
lastDrawKey.value = drawKey; lastDrawKey.value = drawKey;
}; };
@@ -494,16 +487,19 @@ watch(
() => [ () => [
props.coordinateRadius, props.coordinateRadius,
props.showCrosshair, props.showCrosshair,
props.showQuadrantLabels,
props.showRingLabels, props.showRingLabels,
props.highlightAreas, props.sectorCount,
props.activeSector,
props.activeRing,
props.showSectorLabels,
props.highlightOnly, props.highlightOnly,
props.canvasWidth, props.canvasWidth,
props.canvasHeight, props.canvasHeight,
props.quadrantLabels,
props.targetStyleConfig, props.targetStyleConfig,
props.crosshairStyle, props.crosshairStyle,
props.quadrantLabelStyle, props.sectorStyle,
props.sectorLabelStyle,
props.highlightStyle,
], ],
scheduleDraw, scheduleDraw,
{ {
+3
View File
@@ -289,6 +289,9 @@
} }
] ]
}, },
"optimization" : {
"subPackages" : true
},
"setting" : { "setting" : {
"urlCheck" : false, "urlCheck" : false,
"minified" : true, "minified" : true,
+82 -17
View File
@@ -4,6 +4,7 @@ import {
createAckMessage, createAckMessage,
createHeartbeatAckMessage, createHeartbeatAckMessage,
createLeaveMessage, createLeaveMessage,
createSyncPracticeInfoMessage,
decodeServerMessage, decodeServerMessage,
getServerMessageTypeName, getServerMessageTypeName,
} from "@/utils/matchProtocol"; } from "@/utils/matchProtocol";
@@ -57,6 +58,7 @@ const BUSINESS_TYPE_BY_SERVER_TYPE = {
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack"; export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
export const MATCH_WS_STATE_EVENT = "match-ws-state"; export const MATCH_WS_STATE_EVENT = "match-ws-state";
export const MATCH_WS_PRACTICE_SYNC_EVENT = "match-ws-practice-sync";
function normalizeShootData(shootData) { function normalizeShootData(shootData) {
if (!shootData || typeof shootData !== "object") return 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) { function getReadySnapshotKey(matchId) {
return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(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 }) { function sendAck({ matchId, sequence }) {
// sequence 由后端处理,前端只原样带回,不做重排和断线补发。 // sequence 由后端处理,前端只原样带回,不做重排和断线补发。
if (sequence === undefined || sequence === null || sequence === "") return; if (sequence === undefined || sequence === null || sequence === "") return;
@@ -682,26 +718,27 @@ function removeAudioAckListener() {
} }
function queueAckAfterAudio(message, businessMessage) { function queueAckAfterAudio(message, businessMessage) {
// 除心跳外,只要服务端带了 sequence,都需要走 ACK;没有语音的消息立即 ACK // 终止消息即使没有 sequence,也要等结束语音完成后主动关闭连接
if ( const leaveAfterAck = shouldCloseAfterAck(message, businessMessage);
message.sequence === undefined || const hasSequence =
message.sequence === null || message.sequence !== undefined &&
message.sequence === "" message.sequence !== null &&
) { message.sequence !== "";
return; if (!hasSequence && !leaveAfterAck) return;
}
const task = { const task = {
matchId: normalizeId( matchId: normalizeId(
pickField(message, "matchId", "match_id") || currentContext?.matchId pickField(message, "matchId", "match_id") || currentContext?.matchId
), ),
sequence: message.sequence, sequence: message.sequence,
leaveAfterAck: shouldCloseAfterAck(message, businessMessage), leaveAfterAck,
}; };
const actionLabel = hasSequence ? "ack" : "terminal close";
const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean); const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
if (!audioKeys.length) { if (!audioKeys.length) {
console.log( console.log(
"[match-ws] ack immediately without audio", `[match-ws] ${actionLabel} immediately without audio`,
getServerMessageTypeName(message.type), getServerMessageTypeName(message.type),
message.sequence message.sequence
); );
@@ -715,7 +752,7 @@ function queueAckAfterAudio(message, businessMessage) {
if (index === -1) return; if (index === -1) return;
pendingAcks.splice(index, 1); pendingAcks.splice(index, 1);
console.log( console.log(
"[match-ws] ack audio wait timeout", `[match-ws] ${actionLabel} audio wait timeout`,
getServerMessageTypeName(message.type), getServerMessageTypeName(message.type),
message.sequence, message.sequence,
task.expectedAudioKey task.expectedAudioKey
@@ -724,7 +761,7 @@ function queueAckAfterAudio(message, businessMessage) {
}, ACK_AUDIO_TIMEOUT_MS); }, ACK_AUDIO_TIMEOUT_MS);
pendingAcks.push(task); pendingAcks.push(task);
console.log( console.log(
"[match-ws] ack queued until audioEnded", `[match-ws] ${actionLabel} queued until audioEnded`,
getServerMessageTypeName(message.type), getServerMessageTypeName(message.type),
message.sequence, message.sequence,
task.expectedAudioKey task.expectedAudioKey
@@ -741,11 +778,6 @@ function handleMessage(data) {
return; return;
} }
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
if (decodedMatchId && currentContext) {
currentContext.matchId = decodedMatchId;
}
if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) { if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
sendHeartbeatAck(); sendHeartbeatAck();
return; return;
@@ -754,6 +786,34 @@ function handleMessage(data) {
const typeName = getServerMessageTypeName(message.type); const typeName = getServerMessageTypeName(message.type);
console.log("收到比赛服 WebSocket 消息", typeName, message); 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); const businessMessage = buildBusinessMessage(message);
if (businessMessage?.matchId && currentContext) { if (businessMessage?.matchId && currentContext) {
currentContext.matchId = businessMessage.matchId; currentContext.matchId = businessMessage.matchId;
@@ -788,6 +848,7 @@ export function connectMatchWebSocket(options = {}) {
userId, userId,
token, token,
mode, mode,
requestPracticeInfoOnOpen = false,
force = false, force = false,
reconnecting = false, reconnecting = false,
reconnectReason = "", reconnectReason = "",
@@ -858,6 +919,7 @@ export function connectMatchWebSocket(options = {}) {
mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined, mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined,
isMelee: isMelee:
Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined, Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined,
requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
meleeHalfRest: isSameContext meleeHalfRest: isSameContext
? currentContext?.meleeHalfRest === true ? currentContext?.meleeHalfRest === true
: false, : false,
@@ -911,6 +973,9 @@ export function connectMatchWebSocket(options = {}) {
reason: reconnectReason, reason: reconnectReason,
reconnected: wasReconnected, reconnected: wasReconnected,
}); });
if (currentContext?.requestPracticeInfoOnOpen) {
sendPracticeInfoSync();
}
}); });
socketTask.onMessage((res) => { socketTask.onMessage((res) => {
+11 -11
View File
@@ -4,43 +4,43 @@ export const trainingHomeWeekSchedule = [
key: "mon", key: "mon",
label: "周一", label: "周一",
status: "done", status: "done",
icon: "../../static/training-home/done.png", icon: "/pages/training/static/training-home/done.png",
}, },
{ {
key: "tue", key: "tue",
label: "周二", label: "周二",
status: "done", status: "done",
icon: "../../static/training-home/done.png", icon: "/pages/training/static/training-home/done.png",
}, },
{ {
key: "wed", key: "wed",
label: "周三", label: "周三",
status: "missed", status: "missed",
icon: "../../static/training-home/missed.png", icon: "/pages/training/static/training-home/missed.png",
}, },
{ {
key: "thu", key: "thu",
label: "周四", label: "周四",
status: "missed", status: "missed",
icon: "../../static/training-home/missed.png", icon: "/pages/training/static/training-home/missed.png",
}, },
{ {
key: "fri", key: "fri",
label: "周五", label: "周五",
status: "done", status: "done",
icon: "../../static/training-home/done.png", icon: "/pages/training/static/training-home/done.png",
}, },
{ {
key: "sat", key: "sat",
label: "周六", label: "周六",
status: "done", status: "done",
icon: "../../static/training-home/done.png", icon: "/pages/training/static/training-home/done.png",
}, },
{ {
key: "sun", key: "sun",
label: "周日", label: "周日",
status: "missed", 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", key: "endurance",
title: "耐力训练", title: "耐力训练",
progressText: "当前进度 LV5 >", progressText: "当前进度 LV5 >",
icon: "../../static/training-home/img_3.png", icon: "/pages/training/static/training-home/img_3.png",
recommended: true, recommended: true,
disabled: false, disabled: false,
}, },
@@ -81,7 +81,7 @@ export const trainingHomeModes = [
key: "precision", key: "precision",
title: "精准训练", title: "精准训练",
progressText: "当前进度 LV3 >", progressText: "当前进度 LV3 >",
icon: "../../static/training-home/img_4.png", icon: "/pages/training/static/training-home/img_4.png",
recommended: false, recommended: false,
disabled: false, disabled: false,
}, },
@@ -89,7 +89,7 @@ export const trainingHomeModes = [
key: "rhythm", key: "rhythm",
title: "节奏训练", title: "节奏训练",
progressText: "当前进度 LV6 >", progressText: "当前进度 LV6 >",
icon: "../../static/training-home/img_5.png", icon: "/pages/training/static/training-home/img_5.png",
recommended: false, recommended: false,
disabled: false, disabled: false,
}, },
@@ -97,7 +97,7 @@ export const trainingHomeModes = [
key: "power", key: "power",
title: "力量训练", title: "力量训练",
progressText: "Coming! LV10", progressText: "Coming! LV10",
icon: "../../static/training-home/img_6.png", icon: "/pages/training/static/training-home/img_6.png",
recommended: false, recommended: false,
disabled: true, disabled: true,
}, },
+14 -9
View File
@@ -105,15 +105,6 @@
{ {
"path": "pages/mine-bow-data" "path": "pages/mine-bow-data"
}, },
{
"path": "pages/training/difficulty"
},
{
"path": "pages/training/index"
},
{
"path": "pages/training/practise-one"
},
{ {
"path": "pages/ota-wifi", "path": "pages/ota-wifi",
"style": { "style": {
@@ -169,6 +160,20 @@
"path": "team-bow-data" "path": "team-bow-data"
} }
] ]
},
{
"root": "pages/training",
"pages": [
{
"path": "index"
},
{
"path": "difficulty"
},
{
"path": "practise-one"
}
]
} }
] ]
} }
+257 -70
View File
@@ -1,11 +1,14 @@
<script setup> <script setup>
import { import {
computed, computed,
getCurrentInstance,
nextTick,
onBeforeUnmount, onBeforeUnmount,
onMounted, onMounted,
ref, ref,
watch, watch,
} from "vue"; } from "vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
import PointSwitcher from "@/components/PointSwitcher.vue"; import PointSwitcher from "@/components/PointSwitcher.vue";
import TargetCanvas from "@/components/TargetCanvas.vue"; import TargetCanvas from "@/components/TargetCanvas.vue";
@@ -33,6 +36,14 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isSvip: {
type: Boolean,
default: false,
},
shotEffectToken: {
type: Number,
default: 0,
},
mode: { mode: {
type: String, type: String,
default: "solo", // solo 单排,team 双排 default: "solo", // solo 单排,team 双排
@@ -57,23 +68,22 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showQuadrantLabels: { sectorCount: {
type: Number,
default: 0,
},
activeSector: {
type: Number,
default: 0,
},
activeRing: {
type: Number,
default: 0,
},
showSectorLabels: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
quadrantLabels: {
type: Object,
default: () => ({
1: "1",
2: "2",
3: "3",
4: "4",
}),
},
highlightAreas: {
type: Array,
default: () => [],
},
}); });
const pMode = ref(true); const pMode = ref(true);
@@ -85,6 +95,12 @@ const timer = ref(null);
const dirTimer = ref(null); const dirTimer = ref(null);
const angle = ref(null); const angle = ref(null);
const circleColor = ref(""); 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 ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68; 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) { function getRoundTipStyle(shot) {
const point = getShotPoint(shot, true); const point = getShotPoint(shot, true);
return getTargetPositionStyle( 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( watch(
() => props.scores, () => props.scores,
(newVal) => { (newVal) => {
if (newVal.length - prevScores.value.length === 1) { if (newVal.length - prevScores.value.length === 1) {
latestOne.value = newVal[newVal.length - 1]; showShotTip(newVal[newVal.length - 1]);
if (timer.value) clearTimeout(timer.value); } else if (newVal.length < prevScores.value.length) {
timer.value = setTimeout(() => { clearTipTimer();
latestOne.value = null; latestOne.value = null;
}, 1000); hiddenLatestKey.value = "";
shotEffect.value = null;
} }
prevScores.value = [...newVal]; 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( watch(
() => props.blueScores, () => props.blueScores,
(newVal) => { (newVal) => {
@@ -237,42 +374,9 @@ const arrowStyle = computed(() => {
}; };
}); });
const currentArrowIndex = computed(() => { const showSectorCanvas = computed(() => {
return props.scores.length + props.blueScores.length + 1; const count = Number(props.sectorCount);
}); return props.totalRound > 0 && Number.isInteger(count) && count > 0;
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;
}); });
async function onReceiveMessage(message) { async function onReceiveMessage(message) {
@@ -299,23 +403,27 @@ async function onReceiveMessage(message) {
onMounted(() => { onMounted(() => {
uni.$on("socket-inbox", onReceiveMessage); uni.$on("socket-inbox", onReceiveMessage);
updateTargetSize();
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timer.value) { clearTipTimer();
clearTimeout(timer.value);
timer.value = null;
}
if (dirTimer.value) { if (dirTimer.value) {
clearTimeout(dirTimer.value); clearTimeout(dirTimer.value);
dirTimer.value = null; dirTimer.value = null;
} }
if (shakeTimer.value) {
clearTimeout(shakeTimer.value);
shakeTimer.value = null;
}
uni.$off("socket-inbox", onReceiveMessage); uni.$off("socket-inbox", onReceiveMessage);
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
}); });
</script> </script>
<template> <template>
<view class="container"> <view :class="['container', { 'container--effecting': shotEffect }]">
<!-- <view class="header" v-if="totalRound > 0"> <!-- <view class="header" v-if="totalRound > 0">
<text v-if="totalRound > 0" class="round-count">{{ <text v-if="totalRound > 0" class="round-count">{{
(currentRound > totalRound ? totalRound : currentRound) + (currentRound > totalRound ? totalRound : currentRound) +
@@ -323,37 +431,44 @@ onBeforeUnmount(() => {
totalRound totalRound
}}</text> }}</text>
</view> --> </view> -->
<view class="target"> <view :class="['target', { 'target--shake': targetShaking }]">
<image <image
class="target-image" class="target-image"
src="../../../static/bow-target.png" src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png"
mode="aspectFit" mode="aspectFit"
/> />
<TargetCanvas <TargetCanvas
v-if="showHighlightCanvas" v-if="showSectorCanvas"
class="target-highlight-layer" class="target-highlight-layer"
:coordinateRadius="coordinateRadius" :coordinateRadius="coordinateRadius"
:showCrosshair="false" :showCrosshair="false"
:showQuadrantLabels="false"
:showRingLabels="false" :showRingLabels="false"
:highlightOnly="true" :highlightOnly="true"
:highlightAreas="currentHighlightAreas" :sectorCount="sectorCount"
:activeSector="activeSector"
:activeRing="activeRing"
:showSectorLabels="showSectorLabels"
/> />
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle"> <view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
<view :style="{ background: circleColor }"> <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> </view>
<view v-if="stop" class="stop-sign">中场休息</view> <view v-if="stop" class="stop-sign">中场休息</view>
<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" class="e-value fade-in-out"
:style="getExperienceTipStyle(latestOne)" :style="getExperienceTipStyle(latestOne)"
> >
经验 +1 经验 +1
</view> </view>
<view <view
v-if="latestOne" v-if="!shotEffect && latestOne"
class="round-tip fade-in-out" class="round-tip fade-in-out"
:style="getRoundTipStyle(latestOne)" :style="getRoundTipStyle(latestOne)"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶" >{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
@@ -378,8 +493,15 @@ onBeforeUnmount(() => {
}}<text v-if="bluelatestOne.ring">环</text></view }}<text v-if="bluelatestOne.ring">环</text></view
> >
<block v-for="(bow, index) in scores" :key="index"> <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 <view
v-if="bow.ring > 0" v-if="bow.ring > 0 && !shouldHideLatestHit(index)"
:class="`hit ${pMode ? 'b' : 's'}-point ${ :class="`hit ${pMode ? 'b' : 's'}-point ${
index === scores.length - 1 && latestOne ? 'pump-in' : '' index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`" }`"
@@ -404,6 +526,16 @@ onBeforeUnmount(() => {
<text v-if="pMode">{{ index + 1 }}</text> <text v-if="pMode">{{ index + 1 }}</text>
</view> </view>
</block> </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>
<view class="footer"> <view class="footer">
<PointSwitcher <PointSwitcher
@@ -424,13 +556,22 @@ onBeforeUnmount(() => {
height: calc(100vw - 30px); height: calc(100vw - 30px);
padding: 0px 15px; padding: 0px 15px;
position: relative; position: relative;
z-index: 3;
}
.container--effecting {
z-index: 10000;
} }
.target { .target {
position: relative; position: relative;
margin: 10px; margin: 10px;
width: calc(100% - 20px); width: calc(100% - 20px);
height: 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 { .target-image {
position: absolute; position: absolute;
@@ -499,6 +640,15 @@ onBeforeUnmount(() => {
.e-value.fade-in-out { .e-value.fade-in-out {
animation: target-tip-fade-in-out 1.2s ease forwards; 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 { .hit {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
@@ -527,6 +677,20 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/ transform: translate(-50%, -50%);*/
margin-top: 2rpx; 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 { @keyframes target-pump-in {
from { from {
transform: translate(-50%, -50%) scale(2); transform: translate(-50%, -50%) scale(2);
@@ -536,6 +700,29 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%) scale(1); 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 { .hit.pump-in {
animation: target-pump-in 0.3s ease-out forwards; animation: target-pump-in 0.3s ease-out forwards;
transform-origin: center center; transform-origin: center center;
+2 -2
View File
@@ -85,8 +85,8 @@ onBeforeUnmount(() => {
class="score-item-bg" class="score-item-bg"
:src=" :src="
isLowScore(arrows[index]) isLowScore(arrows[index])
? '/static/training-difficulty-design/block-gray.png' ? '../static/training-difficulty-design/block-gray.png'
: '/static/training-difficulty-design/block-gold.png' : '../static/training-difficulty-design/block-gold.png'
" "
/> />
<text <text
@@ -25,9 +25,8 @@ const isLowScore = (arrow = {}) => {
const displayArrows = computed(() => { const displayArrows = computed(() => {
const list = [...props.arrows]; const list = [...props.arrows];
if (props.total > 0 && list.length < props.total) { // total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。
list.push(null); list.push(null);
}
return list; return list;
}); });
</script> </script>
@@ -40,7 +39,7 @@ const displayArrows = computed(() => {
:key="index" :key="index"
class="score-card" 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 <text
class="score-value" class="score-value"
:class="{ 'score-value--low': isLowScore(arrow) }" :class="{ 'score-value--low': isLowScore(arrow) }"
+240 -73
View File
@@ -27,6 +27,14 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
trainingType: {
type: String,
default: "",
},
difficultyLevel: {
type: Number,
default: 0,
},
result: { result: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
@@ -60,12 +68,6 @@ function onClickShare() {
uni.$emit("share-image"); uni.$emit("share-image");
} }
onMounted(() => {
if (props.result.lvl > user.value.lvl) {
showUpgrade.value = true;
}
});
const details = computed(() => props.result.details || []); const details = computed(() => props.result.details || []);
const arrows = computed(() => { const arrows = computed(() => {
@@ -81,25 +83,89 @@ const totalRing = computed(() =>
details.value.reduce((last, next) => last + (Number(next.ring) || 0), 0) details.value.reduce((last, next) => last + (Number(next.ring) || 0), 0)
); );
const gainedExp = computed( const hasResultValue = (...keys) =>
() => props.result.exp || props.result.experience || validArrows.value 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( const currentExp = computed(() => {
() => props.result.lvl || user.value.lvl || user.value.rankLvl || 1 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( const beforeLevel = computed(() => {
() => props.result.nextExp || props.result.upgradeScore || 100 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(() => { const expPercent = computed(() => {
if (!nextExp.value) return 0; if (!upgradeExp.value) return 0;
return Math.min(100, Math.max(0, (currentExp.value / nextExp.value) * 100)); return Math.min(
100,
Math.max(0, (currentExp.value / upgradeExp.value) * 100)
);
});
onMounted(() => {
if (userLevel.value > beforeLevel.value) {
showUpgrade.value = true;
}
}); });
const findValue = (...keys) => { const findValue = (...keys) => {
@@ -108,83 +174,184 @@ const findValue = (...keys) => {
}; };
const formatDuration = (value) => { const formatDuration = (value) => {
const seconds = Number(value || 0); const valueNumber = Number(value);
if (!seconds) return "--"; const seconds = Number.isFinite(valueNumber)
? Math.max(0, Math.round(valueNumber))
: 0;
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const rest = seconds % 60; const rest = seconds % 60;
return minutes ? `${minutes}${rest}` : `${rest}`; return minutes ? `${minutes}${rest}` : `${rest}`;
}; };
const usedTime = computed(() => const formatMetricNumber = (value) => {
findValue("duration", "usedTime", "shootTime", "time") 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( const metricConfigs = {
() => Number(findValue("hitCompare", "hitDiff", "hitDelta") || 0) 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( const handlePrimary = () => {
() => Number(findValue("timeCompare", "timeDiff", "durationDiff") || 0) if (advancesDifficulty.value) {
); closePanel();
return;
}
retryPractice();
};
const calories = computed( const calories = computed(
() => Number(findValue("calories", "calorie", "kcal") || 0) () => formatMetricNumber(readMetricNumber(["calories", "calorie", "kcal"]))
); );
</script> </script>
<template> <template>
<view :class="['result-mask', showPanel ? 'result-mask--show' : 'result-mask--hide']"> <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"> <view class="result-title">
<image class="result-title-bg" src="/static/training-difficulty-design/result-t-bg.png" mode="widthFix" /> <image class="result-title-bg" src="../static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
<view class="result-title-text">Lv{{ currentLevel }}</view> <view class="result-title-text">Lv{{ resultDifficultyLevel }}</view>
</view> </view>
<view class="result-panel"> <view class="result-panel">
<view class="line-top"></view> <view class="line-top"></view>
<view class="line-bottom"></view> <view class="line-bottom"></view>
<view class="stats"> <view class="stats">
<view class="stat-row"> <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" /> <image class="stat-bg" src="../static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell"> <view class="stat-cell">
<text class="stat-label">共命中目标</text> <text class="stat-label">{{ row.label }}</text>
<view class="stat-value"> <view class="stat-value">
<text>{{ validArrows }}</text> <text>{{ row.valueText }}</text>
<text class="stat-unit"></text> <text v-if="row.unit" class="stat-unit">{{ row.unit }}</text>
</view> </view>
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-cell stat-cell--compare"> <view class="stat-cell stat-cell--compare">
<text class="stat-label">对比上次</text> <text class="stat-label">对比上次</text>
<view class="stat-value"> <view v-if="row.delta !== 0" class="stat-value">
<text>{{ Math.abs(hitCompare) }}</text> <text>{{ row.delta > 0 ? "+" : "-" }}{{ row.deltaText }}</text>
<text class="stat-unit"></text> <text v-if="row.deltaUnit" class="stat-unit">{{ row.deltaUnit }}</text>
<image class="trend-icon" :class="{ 'trend-icon--down': hitCompare < 0 }" <image class="trend-icon" :class="{ 'trend-icon--down': row.delta < 0 }"
src="/static/training-difficulty-design/result-up.png" mode="widthFix" /> 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> </view>
<view v-else class="stat-value">--</view>
</view> </view>
</view> </view>
<view class="stat-row"> <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"> <view class="stat-cell">
<text class="stat-label">消耗卡路里</text> <text class="stat-label">消耗卡路里</text>
<view class="stat-value"> <view class="stat-value">
@@ -196,7 +363,7 @@ const calories = computed(
<view class="stat-cell stat-cell--compare"> <view class="stat-cell stat-cell--compare">
<view class="stat-value"> <view class="stat-value">
<image v-for="index in 3" :key="index" class="rice-icon" <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> </view>
</view> </view>
@@ -204,15 +371,15 @@ const calories = computed(
<view class="actions"> <view class="actions">
<view class="action-item" @click="() => (showBowData = true)"> <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> <text>查看靶纸</text>
</view> </view>
<view v-if="validArrows === total" class="action-item" @click="() => (showComment = true)"> <view class="action-item" @click="() => (showComment = true)">
<image class="action-icon" src="/static/training-difficulty-design/result-icon-2.png" mode="widthFix" /> <image class="action-icon" src="../static/training-difficulty-design/result-icon-2.png" mode="widthFix" />
<text>教练点评</text> <text>教练点评</text>
</view> </view>
<view v-if="validArrows === total" class="action-item" @click="onClickShare"> <view class="action-item" @click="onClickShare">
<image class="action-icon" src="/static/training-difficulty-design/result-icon-3.png" mode="widthFix" /> <image class="action-icon" src="../static/training-difficulty-design/result-icon-3.png" mode="widthFix" />
<text>分享成绩</text> <text>分享成绩</text>
</view> </view>
</view> </view>
@@ -222,20 +389,20 @@ const calories = computed(
<view class="exp-area"> <view class="exp-area">
<text class="exp-gain">+{{ gainedExp }}经验</text> <text class="exp-gain">+{{ gainedExp }}经验</text>
<view class="level-progress"> <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-track">
<view class="progress-fill" :style="{ width: `${expPercent}%` }"></view> <view class="progress-fill" :style="{ width: `${expPercent}%` }"></view>
</view> </view>
<text class="progress-text">{{ currentExp }} / {{ nextExp }}</text> <text class="progress-text">{{ currentExp }} / {{ upgradeExp }}</text>
</view> </view>
</view> </view>
<view class="footer-actions"> <view class="footer-actions">
<view class="result-btn result-btn--muted" @click="closePanel"> <view class="result-btn result-btn--muted" @click="closePanel">
<text>{{ validArrows === total ? "完成" : "返回" }}</text> <text>完成</text>
</view> </view>
<view class="result-btn result-btn--primary" @click="retryPractice"> <view class="result-btn result-btn--primary" @click="handlePrimary">
<text>再来一次</text> <text>{{ primaryText }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -268,7 +435,7 @@ const calories = computed(
</ScreenHint> </ScreenHint>
<BowData :total="arrows.length" :arrows="result.details" :show="showBowData" <BowData :total="arrows.length" :arrows="result.details" :show="showBowData"
:onClose="() => (showBowData = false)" /> :onClose="() => (showBowData = false)" />
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="result.lvl" /> <UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="userLevel" />
</view> </view>
</template> </template>
+5 -5
View File
@@ -27,29 +27,29 @@ const getContentHeight = () => {
<view class="scale-in" :style="{ height: getContentHeight() }"> <view class="scale-in" :style="{ height: getContentHeight() }">
<image <image
v-if="mode === 'normal'" v-if="mode === 'normal'"
src="/static/screen-hint-bg.png" src="https://static.shelingxingqiu.com/shootmini/static/screen-hint-bg.png"
mode="widthFix" mode="widthFix"
/> />
<image <image
v-if="mode === 'tall'" v-if="mode === 'tall'"
src="/static/coach-comment.png" src="https://static.shelingxingqiu.com/shootmini/static/coach-comment.png"
mode="widthFix" mode="widthFix"
/> />
<image <image
v-if="mode === 'square'" v-if="mode === 'square'"
src="/static/prompt-bg-square.png" src="https://static.shelingxingqiu.com/shootmini/static/prompt-bg-square.png"
mode="widthFix" mode="widthFix"
/> />
<image <image
v-if="mode === 'small'" v-if="mode === 'small'"
src="/static/finish-frame.png" src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png"
mode="widthFix" mode="widthFix"
/> />
<slot /> <slot />
</view> </view>
<IconButton <IconButton
v-if="!!onClose" v-if="!!onClose"
src="/static/close-gold-outline.png" src="https://static.shelingxingqiu.com/shootmini/static/close-gold-outline.png"
:width="30" :width="30"
:onClick="onClose" :onClick="onClose"
/> />
+64 -20
View File
@@ -27,6 +27,14 @@ const props = defineProps({
type: Number, type: Number,
default: 120, default: 120,
}, },
countdownEnabled: {
type: Boolean,
default: true,
},
trainingType: {
type: String,
default: "precision",
},
currentRound: { currentRound: {
type: Number, type: Number,
default: 0, 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 barColor = ref("#fed847");
const remain = ref(props.total); const remain = ref(props.countdownEnabled ? props.total : 0);
const timer = ref(null); const timer = ref(null);
const sound = ref(true); const sound = ref(true);
const currentRound = ref(props.currentRound); const currentRound = ref(props.currentRound);
@@ -56,7 +75,7 @@ const wait = ref(0);
const transitionStyle = ref("all 1s linear"); const transitionStyle = ref("all 1s linear");
const progressPercent = computed(() => { 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)); 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) => { const resetTimer = (count) => {
if (timer.value) clearInterval(timer.value); clearTimer();
const newVal = Math.round(count); 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) { if (newVal >= remain.value) {
transitionStyle.value = "none"; transitionStyle.value = "none";
@@ -115,7 +148,7 @@ const resetTimer = (count) => {
if (remain.value > 0) { if (remain.value > 0) {
timer.value = setInterval(() => { timer.value = setInterval(() => {
if (remain.value === 0) { if (remain.value === 0) {
clearInterval(timer.value); clearTimer();
props.onStop(); props.onStop();
} }
if (remain.value > 0) remain.value--; if (remain.value > 0) remain.value--;
@@ -124,13 +157,13 @@ const resetTimer = (count) => {
}; };
watch( watch(
() => props.start, () => [props.start, props.countdownEnabled],
(newVal) => { ([started, countdownEnabled]) => {
if (newVal) { if (started && countdownEnabled) {
resetTimer(props.total); resetTimer(props.total);
} else { } else {
clearTimer();
remain.value = 0; remain.value = 0;
clearInterval(timer.value);
} }
}, },
{ {
@@ -158,18 +191,29 @@ async function onReceiveMessage(msg) {
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play("比赛结束", false); audioManager.play("比赛结束", false);
} else if (msg.type === MESSAGETYPESV2.ShootResult) { } else if (msg.type === MESSAGETYPESV2.ShootResult) {
let arrow = {}; const latestDetail =
if (msg.details && Array.isArray(msg.details)) { Array.isArray(msg.details) && msg.details.length > 0
arrow = msg.details[msg.details.length - 1]; ? msg.details[msg.details.length - 1]
} else { : null;
if (msg.shootData.playerId !== user.value.id) return; // 语音和 ACK 优先使用同一份当前箭数据,details 仅作为兼容兜底。
if (msg.shootData) arrow = msg.shootData; 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}` : "未上靶"); 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)}调整`); key.push(`${getDirectionText(arrow.angle)}调整`);
} }
if (arrow.threeConsecutive10Rings === true) {
key.push("tententen");
}
audioManager.play(key, false); audioManager.play(key, false);
} else if (msg.type === MESSAGETYPESV2.HalfRest) { } else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTime.value = true; halfTime.value = true;
@@ -197,7 +241,7 @@ onBeforeUnmount(() => {
uni.$off("update-remain", resetTimer); uni.$off("update-remain", resetTimer);
uni.$off("socket-inbox", onReceiveMessage); uni.$off("socket-inbox", onReceiveMessage);
uni.$off("play-sound", playSound); uni.$off("play-sound", playSound);
if (timer.value) clearInterval(timer.value); clearTimer();
}); });
</script> </script>
@@ -228,10 +272,10 @@ onBeforeUnmount(() => {
<view class="progress-card__track-wrap"> <view class="progress-card__track-wrap">
<image <image
class="progress-card__titile" class="progress-card__titile"
src="../../../static/training-difficulty-design/text-icon-cgxl.png" :src="trainingTitleIcon"
mode="aspectFit" mode="aspectFit"
/> />
<view class="progress-card__track"> <view v-if="countdownEnabled" class="progress-card__track">
<view <view
class="progress-card__fill" class="progress-card__fill"
:style="{ :style="{
@@ -23,6 +23,10 @@ const props = defineProps({
type: Number, type: Number,
default: 15, default: 15,
}, },
targetType: {
type: [Number, String],
default: "",
},
}); });
const arrow = ref({}); const arrow = ref({});
const distance = ref(0); const distance = ref(0);
@@ -78,7 +82,7 @@ onBeforeUnmount(() => {
<view class="test-area"> <view class="test-area">
<image <image
class="text-bg" class="text-bg"
src="../../../static/training-difficulty-design/par-bg.png" src="../static/training-difficulty-design/par-bg.png"
mode="widthFix" mode="widthFix"
/> />
<button <button
@@ -90,7 +94,7 @@ onBeforeUnmount(() => {
模拟射箭 模拟射箭
</button> </button>
<view class="warnning-text"> <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"> <block v-if="distance > 0">
<text>当前距离<text class="text-yellow">{{ distance }}</text></text> <text>当前距离<text class="text-yellow">{{ distance }}</text></text>
<text v-if="distance >= 5">已达到距离要求</text> <text v-if="distance >= 5">已达到距离要求</text>
@@ -2,9 +2,9 @@
import { computed } from "vue"; import { computed } from "vue";
const lockedBadgeBackground = const lockedBadgeBackground =
"/static/training-difficulty-design/unlock.svg"; "../static/training-difficulty-design/unlock.svg";
const unlockedBadgeBackground = const unlockedBadgeBackground =
"/static/training-difficulty-design/lock.svg"; "../static/training-difficulty-design/lock.svg";
const props = defineProps({ const props = defineProps({
node: { node: {
@@ -21,7 +21,7 @@ const previewLines = computed(() => {
<view class="difficulty-preview"> <view class="difficulty-preview">
<image <image
class="difficulty-preview__bg" class="difficulty-preview__bg"
src="/static/training-difficulty-design/text.png" src="../static/training-difficulty-design/text.png"
mode="widthFix" mode="widthFix"
/> />
<view class="difficulty-preview__content"> <view class="difficulty-preview__content">
@@ -52,10 +52,15 @@ const previewLines = computed(() => {
.difficulty-preview__content { .difficulty-preview__content {
position: absolute; position: absolute;
top: 28rpx; top: 0;
left: 30rpx; left: 30rpx;
box-sizing: border-box; box-sizing: border-box;
width: 486rpx; width: 486rpx;
height: 93%;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
} }
.difficulty-preview__title { .difficulty-preview__title {
@@ -21,7 +21,7 @@ const handleClick = () => {
> >
<image <image
class="difficulty-start__button" class="difficulty-start__button"
src="/static/training-difficulty-design/btn.png" src="../static/training-difficulty-design/btn.png"
mode="widthFix" mode="widthFix"
/> />
</button> </button>
+84 -9
View File
@@ -5,7 +5,11 @@ import Container from "@/components/Container.vue";
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue"; import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue"; import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
import TrainingDifficultyStartButton from "./components/TrainingDifficultyStartButton.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 // 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
@@ -236,6 +240,7 @@ const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false); const nodesScrollWithAnimation = ref(false);
const routeOptions = ref({}); const routeOptions = ref({});
const needRefreshProgress = ref(false); const needRefreshProgress = ref(false);
const creatingPractice = ref(false);
const difficultyProgressMap = computed(() => { const difficultyProgressMap = computed(() => {
return pageConfig.value?.progressMap || {}; return pageConfig.value?.progressMap || {};
@@ -574,7 +579,6 @@ const createPracticeQuery = (difficulty) => {
difficulty: difficulty.level, difficulty: difficulty.level,
recordId: difficulty.recordId, recordId: difficulty.recordId,
arrows: toNumber(difficulty.arrows, 12), arrows: toNumber(difficulty.arrows, 12),
time: toNumber(difficulty.time_limit, 120) || 120,
target: defaultTargetType, target: defaultTargetType,
}; };
const typedQueryMap = { const typedQueryMap = {
@@ -610,7 +614,7 @@ const createPracticeUrl = (difficulty) => {
return `/pages/training/practise-one${query ? `?${query}` : ""}`; return `/pages/training/practise-one${query ? `?${query}` : ""}`;
}; };
const saveTrainingContext = () => { const saveTrainingContext = (practice = {}) => {
const difficulty = selectedDifficulty.value; const difficulty = selectedDifficulty.value;
if (!difficulty.id) { if (!difficulty.id) {
@@ -624,9 +628,32 @@ const saveTrainingContext = () => {
difficultyLabel: difficulty.label, difficultyLabel: difficulty.label,
targetType: defaultTargetType, targetType: defaultTargetType,
targetPaperType: difficulty.targetPaperType, 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) => { const handleSelectDifficulty = (node) => {
if (!node?.id) { if (!node?.id) {
return; return;
@@ -650,15 +677,63 @@ const handleSelectDifficulty = (node) => {
}); });
}; };
const handleStart = () => { const handleStart = async () => {
if (!selectedDifficulty.value.id) { if (!selectedDifficulty.value.id || creatingPractice.value) {
return; return;
} }
saveTrainingContext(); const trainingType = pageConfig.value.key || defaultTrainingType;
uni.navigateTo({ const difficultyLevel = selectedDifficulty.value.level;
url: createPracticeUrl(selectedDifficulty.value), 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 = () => { const markProgressRefresh = () => {
@@ -709,7 +784,7 @@ onUnload(() => {
v-for="connector in difficultyConnectors" v-for="connector in difficultyConnectors"
:key="connector.id" :key="connector.id"
class="difficulty-page__connector" class="difficulty-page__connector"
src="../../static/training-difficulty-design/jiantou.png" src="./static/training-difficulty-design/jiantou.png"
mode="aspectFit" mode="aspectFit"
:style="connector" :style="connector"
/> />
+96 -27
View File
@@ -1,12 +1,12 @@
<script setup> <script setup>
import { nextTick, onMounted, ref } from "vue"; import { computed, nextTick, onMounted, ref } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue"; import Container from "@/components/Container.vue";
import TargetPicker from "@/components/TargetPicker.vue"; import TargetPicker from "@/components/TargetPicker.vue";
import { getPersonalTrainingAPI } from "@/apis"; import { getPersonalTrainingAPI } from "@/apis";
const checkedIcon = "../../static/training-home/done.png"; const checkedIcon = "./static/training-home/done.png";
const missedIcon = "../../static/training-home/missed.png"; const missedIcon = "./static/training-home/missed.png";
// 后端训练项目 id 与难度页 mode 参数的映射关系。 // 后端训练项目 id 与难度页 mode 参数的映射关系。
const trainingModeRouteMap = { const trainingModeRouteMap = {
base: "basic", base: "basic",
@@ -17,18 +17,18 @@ const trainingModeRouteMap = {
}; };
// 训练项目卡片右侧主图标。 // 训练项目卡片右侧主图标。
const trainingModeIconMap = { const trainingModeIconMap = {
base_bow: "../../static/training-home/img_22.png", base_bow: "./static/training-home/img_22.png",
bow: "../../static/training-home/img_3.png", bow: "./static/training-home/img_3.png",
target: "../../static/training-home/img_4.png", target: "./static/training-home/img_4.png",
wave: "../../static/training-home/img_5.png", wave: "./static/training-home/img_5.png",
muscle: "../../static/training-home/img_6.png", muscle: "./static/training-home/img_6.png",
}; };
// 训练项目卡片标题图,按接口 id 映射本地资源。 // 训练项目卡片标题图,按接口 id 映射本地资源。
const trainingModeTitleImageMap = { const trainingModeTitleImageMap = {
endurance: "../../static/training-home/nailixunlian.png", endurance: "./static/training-home/nailixunlian.png",
precision: "../../static/training-home/jingzhunxunlian.png", precision: "./static/training-home/jingzhunxunlian.png",
rhythm: "../../static/training-home/jiezouxunlian.png", rhythm: "./static/training-home/jiezouxunlian.png",
strength: "../../static/training-home/liliangxulian.png", strength: "./static/training-home/liliangxulian.png",
}; };
const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]; const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const defaultRadarDimensions = [ const defaultRadarDimensions = [
@@ -38,6 +38,13 @@ const defaultRadarDimensions = [
{ name: "节奏", score: 0 }, { name: "节奏", score: 0 },
{ name: "耐力", score: 0 }, { name: "耐力", score: 0 },
]; ];
const radarDimensionTrainingIdMap = Object.freeze({
基础: "base",
精准: "precision",
力量: "strength",
节奏: "rhythm",
耐力: "endurance",
});
// 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值。 // 页面始终直接消费接口字段,这里只保留一份兜底结构,避免模板访问空值。
const createDefaultTrainingData = () => ({ const createDefaultTrainingData = () => ({
@@ -50,6 +57,7 @@ const createDefaultTrainingData = () => ({
total_calories: 0, total_calories: 0,
overtake_rate: 0, overtake_rate: 0,
}, },
radar_max: 0,
radar: { radar: {
dimensions: defaultRadarDimensions, dimensions: defaultRadarDimensions,
}, },
@@ -57,6 +65,12 @@ const createDefaultTrainingData = () => ({
}); });
const trainingData = ref(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 pageMounted = ref(false);
const showRoutineTargetPicker = ref(false); const showRoutineTargetPicker = ref(false);
const trainingRadarCanvasId = "training-home-radar"; 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 radarPointRadius = Math.max(2.5, 3.5 * radarScale);
const radarOuterRadiusX = 110.7089 * radarScaleX; const radarOuterRadiusX = 110.7089 * radarScaleX;
const radarOuterRadiusY = 110.7089 * radarScaleY; const radarOuterRadiusY = 110.7089 * radarScaleY;
const radarMaxValue = 100;
const radarFigureStyle = { const radarFigureStyle = {
width: `${radarFigureWidthRpx}rpx`, width: `${radarFigureWidthRpx}rpx`,
height: `${radarFigureHeightRpx}rpx`, height: `${radarFigureHeightRpx}rpx`,
@@ -113,26 +126,78 @@ const getTrainingTitleImage = (item = {}) =>
const getTrainingMode = (item = {}) => const getTrainingMode = (item = {}) =>
trainingModeRouteMap[item.id] || item.id || ""; 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) => ({ const getRadarPoint = (centerX, centerY, radiusX, radiusY, angle) => ({
x: centerX + radiusX * Math.cos(angle), x: centerX + radiusX * Math.cos(angle),
y: centerY + radiusY * Math.sin(angle), y: centerY + radiusY * Math.sin(angle),
}); });
// 雷达图直接使用接口的 5 维 score,按 0-100 等比映射到顶点位置。 // 雷达图直接使用接口的 5 维 score,按后端 radar_max 等比映射到顶点位置。
const drawRadar = () => { const drawRadar = () => {
const dimensions = Array.isArray(trainingData.value.radar?.dimensions) const dimensions = Array.isArray(trainingData.value.radar?.dimensions)
? trainingData.value.radar.dimensions.slice(0, 5) ? trainingData.value.radar.dimensions.slice(0, 5)
: []; : [];
const radarMaxValue = Number(trainingData.value.radar_max);
if (dimensions.length !== 5) return;
const ctx = uni.createCanvasContext(trainingRadarCanvasId); 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( const angles = dimensions.map(
(_, index) => (-90 + index * 72) * (Math.PI / 180) (_, index) => (-90 + index * 72) * (Math.PI / 180)
); );
ctx.clearRect(0, 0, radarCanvasWidth, radarCanvasHeight);
const points = dimensions.map((item, index) => { const points = dimensions.map((item, index) => {
const normalized = Math.max( const normalized = Math.max(
0, 0,
@@ -199,6 +264,7 @@ const loadPersonalTrainingData = async () => {
total_calories: result?.stats?.total_calories ?? 0, total_calories: result?.stats?.total_calories ?? 0,
overtake_rate: result?.stats?.overtake_rate ?? 0, overtake_rate: result?.stats?.overtake_rate ?? 0,
}, },
radar_max: result?.radar_max ?? 0,
radar: { radar: {
dimensions: dimensions:
Array.isArray(result?.radar?.dimensions) && Array.isArray(result?.radar?.dimensions) &&
@@ -214,6 +280,7 @@ const loadPersonalTrainingData = async () => {
console.log("personal training load failed", error); console.log("personal training load failed", error);
trainingData.value = createDefaultTrainingData(); trainingData.value = createDefaultTrainingData();
} finally { } finally {
updateRecommendedTraining();
await refreshRadar(); await refreshRadar();
} }
}; };
@@ -293,12 +360,12 @@ onShow(async () => {
<view class="stats-card-bg"></view> <view class="stats-card-bg"></view>
<image <image
class="stats-quote stats-quote-left" class="stats-quote stats-quote-left"
src="../../static/training-home/img_17.png" src="./static/training-home/img_17.png"
mode="widthFix" mode="widthFix"
/> />
<image <image
class="stats-quote stats-quote-right" class="stats-quote stats-quote-right"
src="../../static/training-home/img_16.png" src="./static/training-home/img_16.png"
mode="widthFix" mode="widthFix"
/> />
<view class="stats-grid"> <view class="stats-grid">
@@ -373,7 +440,7 @@ onShow(async () => {
<view class="record-bubble" @click="openTrainingRecord"> <view class="record-bubble" @click="openTrainingRecord">
<image <image
class="record-bubble-bg" class="record-bubble-bg"
src="../../static/training-home/img_28.png" src="./static/training-home/img_28.png"
mode="widthFix" mode="widthFix"
/> />
<view class="record-bubble-copy"> <view class="record-bubble-copy">
@@ -384,7 +451,7 @@ onShow(async () => {
<text class="record-sub-text">我的训练记录</text> <text class="record-sub-text">我的训练记录</text>
<image <image
class="record-arrow" class="record-arrow"
src="../../static/training-home/img_7.png" src="./static/training-home/img_7.png"
mode="widthFix" mode="widthFix"
/> />
</view> </view>
@@ -412,7 +479,7 @@ onShow(async () => {
<image <image
class="radar-grid-image" class="radar-grid-image"
:style="radarFigureStyle" :style="radarFigureStyle"
src="../../static/training-home/img_19.png" src="./static/training-home/img_19.png"
/> />
<canvas <canvas
:canvas-id="trainingRadarCanvasId" :canvas-id="trainingRadarCanvasId"
@@ -424,7 +491,7 @@ onShow(async () => {
/> />
<image <image
class="radar-mascot" class="radar-mascot"
src="../../static/training-home/img_21.png" src="./static/training-home/img_21.png"
mode="widthFix" mode="widthFix"
/> />
</view> </view>
@@ -434,7 +501,7 @@ onShow(async () => {
<view class="featured-card" @click="openRoutineTraining"> <view class="featured-card" @click="openRoutineTraining">
<image <image
class="featured-card-bg" class="featured-card-bg"
src="../../static/training-home/img_22.png" src="./static/training-home/img_22.png"
mode="widthFix" mode="widthFix"
/> />
<view class="featured-card-mask"></view> <view class="featured-card-mask"></view>
@@ -446,12 +513,14 @@ onShow(async () => {
<view class="mode-grid"> <view class="mode-grid">
<view <view
v-for="item in trainingData.training_items.filter((item) => item.id !== 'strength')" v-for="item in visibleTrainingItems"
:key="item.id" :key="item.id"
class="mode-card" class="mode-card"
@click="openTrainingItem(item)" @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"> <view class="mode-card-copy">
<image <image
v-if="getTrainingTitleImage(item)" v-if="getTrainingTitleImage(item)"
+696 -75
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, ref, onMounted, onBeforeUnmount } from "vue"; 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 Container from "@/components/Container.vue";
import ShootProgress from "./components/ShootProgress.vue"; import ShootProgress from "./components/ShootProgress.vue";
import BowTarget from "./components/BowTarget.vue"; import BowTarget from "./components/BowTarget.vue";
@@ -13,11 +13,17 @@ import BubbleTip from "./components/BubbleTip.vue";
import audioManager from "@/audioManager"; import audioManager from "@/audioManager";
import { import {
createPractiseAPI, createPractiseV2API,
startPractiseAPI, startPractiseAPI,
endPractiseAPI, endPractiseAPI,
getPractiseAPI, getPractiseAPI,
} from "@/apis"; } from "@/apis";
import {
connectMatchWebSocket,
closeMatchWebSocket,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas"; import { sharePractiseData } from "@/canvas";
import { wxShare, debounce } from "@/util"; import { wxShare, debounce } from "@/util";
import { MESSAGETYPESV2, roundsName } from "@/constants"; import { MESSAGETYPESV2, roundsName } from "@/constants";
@@ -35,22 +41,41 @@ const pageStages = Object.freeze({
RESULT: "result", RESULT: "result",
LOADING: "loading", LOADING: "loading",
}); });
const pageStage = ref(pageStages.DISTANCE); const pageStage = ref(pageStages.LOADING);
const scores = ref([]); const scores = ref([]);
// 只在实时 ShootResult 新增一箭时递增,避免同步快照重播飞箭特效。
const shotEffectToken = ref(0);
const defaultTotal = 12; const defaultTotal = 12;
const defaultShootTime = 120;
const defaultTargetType = 1; const defaultTargetType = 1;
const total = ref(defaultTotal); const total = ref(defaultTotal);
const shootTime = ref(defaultShootTime);
const practiseResult = ref({}); const practiseResult = ref({});
const practiceEndSnapshot = ref({});
const practiseId = ref(""); const practiseId = ref("");
const showGuide = ref(false); const showGuide = ref(false);
const tips = ref(""); const tips = ref("");
const targetType = ref(defaultTargetType); const targetType = ref(defaultTargetType);
const trainingParams = ref({}); const trainingParams = ref({});
const practiceInfo = ref({});
const trainingDifficultyStorageKey = "training-selection";
const trainingDifficultyRefreshEvent = "training-difficulty-refresh"; const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
const useHighlightTest = ref(false); const useHighlightTest = ref(false);
const highlightTestState = ref({
blocks: 8,
randomBlock: 1,
randomRingArea: 0,
});
const highlightTestTimer = ref(null); 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(() => { const env = computed(() => {
try { try {
@@ -66,27 +91,135 @@ const hasPractiseResult = computed(() => !!practiseResult.value?.details);
const showResult = computed( const showResult = computed(
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value () => 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 getPracticeNumber = (value, fallback = 0) => {
const highlightTestAreas = [ if (value === undefined || value === null || value === "") return fallback;
{ arrowIndex: 1, quadrant: 1, rings: [10] }, const numberValue = Number(value);
{ arrowIndex: 2, quadrant: 2, rings: [9, 10] }, return Number.isFinite(numberValue) ? numberValue : fallback;
{ 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 targetHighlightAreas = computed(() => { const getPositiveInteger = (value) => {
return useHighlightTest.value ? highlightTestAreas : defaultHighlightAreas; 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) => { const toRouteNumber = (value, fallback = 0) => {
@@ -99,14 +232,352 @@ const toPositiveRouteNumber = (value, fallback) => {
return numberValue > 0 ? numberValue : fallback; return numberValue > 0 ? numberValue : fallback;
}; };
const createPractice = async () => { const practiceInfoFields = [
const result = await createPractiseAPI( "id",
total.value, "userId",
shootTime.value, "status",
targetType.value "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 会省略 falsePRACTICE_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 = () => { const clearHighlightTestTimer = () => {
@@ -116,23 +587,7 @@ const clearHighlightTestTimer = () => {
} }
}; };
const buildHighlightTestScore = (index) => ({ // 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
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 按当前箭展示不同高亮。
const runHighlightTest = () => { const runHighlightTest = () => {
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = true; useHighlightTest.value = true;
@@ -140,50 +595,83 @@ const runHighlightTest = () => {
pageStage.value = pageStages.SHOOTING; pageStage.value = pageStages.SHOOTING;
start.value = true; start.value = true;
let arrowIndex = 1; let block = 1;
setHighlightTestArrow(arrowIndex); highlightTestState.value = {
blocks: 8,
randomBlock: block,
randomRingArea: 0,
};
highlightTestTimer.value = setInterval(() => { highlightTestTimer.value = setInterval(() => {
if (arrowIndex >= highlightTestAreas.length) { if (block >= highlightTestState.value.blocks) {
clearHighlightTestTimer(); clearHighlightTestTimer();
return; return;
} }
arrowIndex += 1; block += 1;
setHighlightTestArrow(arrowIndex); highlightTestState.value = {
blocks: 8,
randomBlock: block,
randomRingArea: block % 2 === 0 ? Math.min(block, 10) : 0,
};
}, 1000); }, 1000);
}; };
const resetHighlightTest = () => { const resetHighlightTest = () => {
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
highlightTestState.value = {
blocks: 8,
randomBlock: 1,
randomRingArea: 0,
};
scores.value = []; scores.value = [];
}; };
onLoad((options = {}) => { onLoad((options = {}) => {
const trainingContext = getTrainingContext();
targetType.value = toPositiveRouteNumber(options.target, defaultTargetType); targetType.value = toPositiveRouteNumber(options.target, defaultTargetType);
total.value = toPositiveRouteNumber(options.arrows, defaultTotal); total.value = toPositiveRouteNumber(options.arrows, defaultTotal);
shootTime.value = toPositiveRouteNumber(options.time, defaultShootTime);
trainingParams.value = { trainingParams.value = {
type: options.type || "", type: options.type || trainingContext.trainingType || "",
difficultyId: options.difficultyId || "", difficultyId: options.difficultyId || "",
difficulty: toRouteNumber(options.difficulty), difficulty: toRouteNumber(
options.difficulty,
toRouteNumber(trainingContext.difficultyLevel)
),
recordId: options.recordId || "", recordId: options.recordId || "",
hitReq: toRouteNumber(options.hitReq), hitReq: toRouteNumber(options.hitReq),
totalReq: toRouteNumber(options.totalReq), totalReq: toRouteNumber(options.totalReq),
blocks: toRouteNumber(options.blocks), blocks: toRouteNumber(options.blocks),
mode: toRouteNumber(options.mode), mode: toRouteNumber(options.mode),
}; };
practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || "";
}); });
const onReady = async () => { const onReady = async () => {
if (
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value ||
stopInFlight.value
) {
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
return;
}
pageStage.value = pageStages.LOADING; pageStage.value = pageStages.LOADING;
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
practiceEndSnapshot.value = {};
try { try {
await startPractiseAPI(); await startPractiseAPI(practiseId.value);
practiseResult.value = {}; practiseResult.value = {};
scores.value = []; scores.value = [];
shotEffectToken.value = 0;
start.value = true; start.value = true;
pageStage.value = pageStages.SHOOTING; pageStage.value = pageStages.SHOOTING;
audioManager.play("练习开始"); 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 () => { const onOver = async () => {
if (!isShootingStage.value) return; if (!isShootingStage.value) return;
@@ -202,11 +708,15 @@ const onOver = async () => {
start.value = false; start.value = false;
try { try {
practiseResult.value = (await getPractiseAPI(practiseId.value)) || {}; const apiResult = (await getPractiseAPI(practiseId.value)) || {};
pageStage.value = hasPractiseResult.value if (!enterPracticeResult(mergePracticeResult(apiResult))) {
? pageStages.RESULT pageStage.value = pageStages.DISTANCE;
: pageStages.DISTANCE; }
} catch (error) { } catch (error) {
if (Object.keys(practiceEndSnapshot.value).length > 0) {
enterPracticeResult(mergePracticeResult());
return;
}
start.value = true; start.value = true;
pageStage.value = pageStages.SHOOTING; pageStage.value = pageStages.SHOOTING;
throw error; throw error;
@@ -214,9 +724,30 @@ const onOver = async () => {
}; };
async function onReceiveMessage(msg) { async function onReceiveMessage(msg) {
syncPracticeInfo(msg);
if (msg.type === MESSAGETYPESV2.ShootResult && isShootingStage.value) { 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) { } 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); // setTimeout(onOver, 1500);
} }
} }
@@ -224,6 +755,9 @@ async function onReceiveMessage(msg) {
function onComplete() { function onComplete() {
pageStage.value = pageStages.LOADING; pageStage.value = pageStages.LOADING;
start.value = false; start.value = false;
practiceEnded.value = true;
clearPracticeRuntimeContext();
closePracticeConnection("training-practice-complete");
uni.$emit(trainingDifficultyRefreshEvent); uni.$emit(trainingDifficultyRefreshEvent);
uni.navigateBack(); uni.navigateBack();
} }
@@ -233,12 +767,18 @@ async function onRetry() {
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
practiseId.value = ""; practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {}; practiseResult.value = {};
practiceEndSnapshot.value = {};
practiceInfo.value = {};
start.value = false; start.value = false;
scores.value = []; scores.value = [];
shotEffectToken.value = 0;
try { try {
await createPractice(); const practice = await createPractice();
} finally { if (!practice) pageStage.value = pageStages.DISTANCE;
} catch (error) {
console.error("training practice retry failed", error);
pageStage.value = pageStages.DISTANCE; pageStage.value = pageStages.DISTANCE;
} }
} }
@@ -259,28 +799,89 @@ const updateSound = () => {
audioManager.setMuted(!sound.value); 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("第一轮"); // audioManager.play("第一轮");
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: true, keepScreenOn: true,
}); });
uni.$on("socket-inbox", onReceiveMessage); 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("share-image", onClickShare);
uni.$on("audioEnded", onAudioEnded); uni.$on("audioEnded", onAudioEnded);
await createPractice(); if (!connectPracticeServer()) {
uni.showToast({
title: "练习连接信息异常,请重试",
icon: "none",
});
setTimeout(() => {
void exitPractice();
}, 500);
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearPracticeRuntimeContext();
void stopCurrentPractice();
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: false, keepScreenOn: false,
}); });
uni.$off("socket-inbox", onReceiveMessage); 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("share-image", onClickShare);
uni.$off("audioEnded", onAudioEnded); uni.$off("audioEnded", onAudioEnded);
audioManager.stopAll(); audioManager.stopAll();
clearHighlightTestTimer(); clearHighlightTestTimer();
endPractiseAPI(); closePracticeConnection("training-practice-unmount");
}); });
</script> </script>
@@ -289,14 +890,21 @@ onBeforeUnmount(() => {
:bgType="isDistanceStage ? 9 : 11" :bgType="isDistanceStage ? 9 : 11"
:showBottom="isDistanceStage" :showBottom="isDistanceStage"
:scroll="!isShootingStage" :scroll="!isShootingStage"
:onBack="exitPractice"
> >
<view class="practise-content"> <view class="practise-content">
<TestDistance v-if="isDistanceStage" /> <TestDistance
v-if="isDistanceStage"
:targetType="practiceInfo.targetType"
/>
<view v-else-if="isShootingStage" class="shooting-layout"> <view v-else-if="isShootingStage" class="shooting-layout">
<view class="shooting-fixed"> <view class="shooting-fixed">
<ShootProgress <ShootProgress
:start="start" :start="start"
:onStop="onOver" :total="timeLimit"
:countdownEnabled="hasTimeLimit"
:trainingType="trainingType"
:onStop="onTimeLimitReached"
/> />
<view class="user-row"> <view class="user-row">
<!-- <Avatar :src="user.avatar" :size="35" /> --> <!-- <Avatar :src="user.avatar" :size="35" /> -->
@@ -310,8 +918,13 @@ onBeforeUnmount(() => {
:totalRound="start ? total / 4 : 0" :totalRound="start ? total / 4 : 0"
:currentRound="scores.length % 3" :currentRound="scores.length % 3"
:scores="scores" :scores="scores"
:isSvip="isSvip"
:shotEffectToken="shotEffectToken"
:showCrosshair="false" :showCrosshair="false"
:highlightAreas="targetHighlightAreas" :sectorCount="precisionBlocks"
:activeSector="precisionRandomBlock"
:activeRing="precisionRandomRingArea"
:showSectorLabels="precisionBlocks > 0"
/> />
<view v-if="env !== 'release'" class="highlight-test-actions"> <view v-if="env !== 'release'" class="highlight-test-actions">
<button <button
@@ -319,7 +932,7 @@ onBeforeUnmount(() => {
hover-class="none" hover-class="none"
@click="runHighlightTest" @click="runHighlightTest"
> >
高亮测试 扇区测试
</button> </button>
<button <button
class="highlight-test-btn" class="highlight-test-btn"
@@ -340,14 +953,20 @@ onBeforeUnmount(() => {
<view class="bat-text-big-box"> <view class="bat-text-big-box">
<image <image
class="dao-icon" class="dao-icon"
src="../../static/training-difficulty-design/dao-icon.png" src="./static/training-difficulty-design/dao-icon.png"
mode="widthFix" mode="widthFix"
/> />
<view class="bat-text-box"> <view v-if="trainingCopy" class="bat-text-box">
<view class="bat-text-small-box"> <view class="bat-text-small-box">
<view class="text-round-box"> <view class="text-round-box">
<view class="text1">每箭命中9环之上</view> <view class="text1">{{ trainingCopy.title }}</view>
<view class="text2">剩余<text class="text2-yellow">3</text></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> </view>
</view> </view>
@@ -369,6 +988,8 @@ onBeforeUnmount(() => {
:total="total" :total="total"
:onClose="onComplete" :onClose="onComplete"
:onRetry="onRetry" :onRetry="onRetry"
:trainingType="trainingType"
:difficultyLevel="currentDifficultyLevel"
:result="practiseResult" :result="practiseResult"
/> />
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas> <canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
@@ -377,7 +998,7 @@ onBeforeUnmount(() => {
<view class="btn-box"> <view class="btn-box">
<image <image
class="btn-box-bg" class="btn-box-bg"
src="../../static/training-difficulty-design/par-star.png" src="./static/training-difficulty-design/par-star.png"
mode="widthFix" mode="widthFix"
/> />
<button class="btn" @click="onReady">准备好了开始练习</button> <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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

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

+1 -1
View File
File diff suppressed because one or more lines are too long
+45
View File
@@ -20,6 +20,7 @@ export const ServerMessageType = {
SERVER_MSG_PLAYER_LEFT: 11, SERVER_MSG_PLAYER_LEFT: 11,
SERVER_MSG_HEARTBEAT: 12, SERVER_MSG_HEARTBEAT: 12,
SERVER_MSG_PRACTICE_END: 13, SERVER_MSG_PRACTICE_END: 13,
SERVER_MSG_SYNC_PRACTICE_INFO: 14,
}; };
export const ClientMessageType = { export const ClientMessageType = {
@@ -28,6 +29,7 @@ export const ClientMessageType = {
CLIENT_MSG_SHOOT_DATA: 2, CLIENT_MSG_SHOOT_DATA: 2,
CLIENT_MSG_ACK: 3, CLIENT_MSG_ACK: 3,
CLIENT_MSG_LEAVE: 4, CLIENT_MSG_LEAVE: 4,
CLIENT_MSG_SYNC_PRACTICE_INFO: 5,
}; };
// protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。 // protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。
@@ -149,6 +151,38 @@ const SCHEMAS = {
9: { name: "device_id", kind: "string" }, 9: { name: "device_id", kind: "string" },
10: { name: "shoot_data", kind: "message", type: "MatchShoot" }, 10: { name: "shoot_data", kind: "message", type: "MatchShoot" },
11: { name: "details", kind: "message", type: "MatchShoot", repeated: true }, 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: { MatchInfo: {
1: { name: "match_id", kind: "string" }, 1: { name: "match_id", kind: "string" },
@@ -233,6 +267,8 @@ function readScalar(reader, kind) {
return reader.int64().toString(); return reader.int64().toString();
case "float": case "float":
return reader.float(); return reader.float();
case "double":
return reader.double();
case "bool": case "bool":
return reader.bool(); return reader.bool();
case "string": 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 }) { export function createAckMessage({ matchId, sequence }) {
// 普通消息 ACK 仍携带 sequence,但前端不做 sequence 补发或排序。 // 普通消息 ACK 仍携带 sequence,但前端不做 sequence 补发或排序。
return encodeClientMessage({ return encodeClientMessage({
+1 -1
View File
@@ -24,7 +24,7 @@ function createWebSocket(token, onMessage) {
switch (envVersion) { switch (envVersion) {
case "develop": // 开发版 case "develop": // 开发版
// url = "ws://192.168.1.2:8000/socket"; // url = "ws://192.168.1.5:8000/socket";
url = "wss://apitest.shelingxingqiu.com/socket"; url = "wss://apitest.shelingxingqiu.com/socket";
break; break;
case "trial": // 体验版 case "trial": // 体验版